Initial commit

This commit is contained in:
yknl
2022-12-05 19:25:31 +08:00
commit c2bb0fb5fb
17 changed files with 4504 additions and 0 deletions

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
SEED="your sponsor wallet seed phrase"
PASSWORD="sponsoredbyxverse"
NUM_ADDRESSES=5
MAX_FEE=50000

108
.gitignore vendored Normal file
View File

@@ -0,0 +1,108 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
env
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# ignore ds_store files created in mac os
.DS_Store

46
README.md Normal file
View File

@@ -0,0 +1,46 @@
## Stacks Transaction Sponsor Web Service
This is a web service that functions as a open sponsor for Stacks transactions. This allows users to send transactions for free, without owning any STX tokens in their wallet.
POST to the `/v1/sponsor` endpoint with the user signed transaction in the JSON body. The transaction must be created as a sponsored transaction. The service will sign the transaction as the sponsor and broadcast it.
The service will use multiple addresses from the same wallet at random to sponsor transactions. You can set the number of addresses to use in the `.env` file. Each addresses must be funded with STX. This allows the sponsor to handle more simultaneous pending transactions.
`/v1/info` will return the list of addresses used by the service for sponsoring transactions
**NOTE: Rules are currently set so that only NFT transfer transaction will be sponsored. You can modify the rules for your own use if you are forking.**
## Requirements
* [Node Js](https://nodejs.org/en/download)
## Setup Instructions
Clone the repo and install the dependencies.
```bash
npm install
```
Create an `.env` file in the project root using the included `.env.example` file as a guide.
Configure the .env file with your wallet seed phrase.
## Running
To start the server, run the following
```bash
npm run build
```
```bash
npm run start
```
To run in development mode
```bash
npm run dev
```
To run tests
```
npm run test
```

36
config/config.ts Normal file
View File

@@ -0,0 +1,36 @@
import * as dotenv from "dotenv";
let result;
if (process.env.NODE_ENV === "production") {
result = dotenv.config({ path: "/usr/src/app/env-config/env" });
} else {
result = dotenv.config();
}
if (result.error) {
throw result.error;
}
//will check and return envVar if required env variable is present in .env file
function isEnvVarValid(envVar: string) {
if (envVar === undefined || null) {
throw new Error(
"Incorrect env variable format! Compare with .env.example."
);
}
return envVar;
}
export default {
seed: isEnvVarValid(
process.env.SEED as string
),
password: isEnvVarValid(
process.env.PASSWORD as string
),
numAddresses: isEnvVarValid(
process.env.NUM_ADDRESSES as string
),
maxFee: isEnvVarValid(
process.env.MAX_FEE as string
),
};

3836
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "stacks-transaction-sponsor",
"version": "0.1.0",
"description": "Stacks Transaction Sponsor",
"main": "index.js",
"scripts": {
"build": "tsc --project ./",
"dev": "nodemon src/index.ts",
"start": "node dist/src/index.js",
"test": "jest",
"test:watch": "jest --watch --coverage=false"
},
"author": "",
"license": "MIT",
"dependencies": {
"@stacks/common": "^6.0.0",
"@stacks/network": "^6.0.0",
"@stacks/transactions": "^6.0.0",
"@stacks/wallet-sdk": "^6.0.0",
"@types/express": "^4.17.13",
"axios": "^0.21.4",
"body-parser": "^1.19.1",
"cors": "^2.8.5",
"dotenv": "^10.0.0",
"express": "^4.17.1",
"node-cache": "^5.1.2"
},
"devDependencies": {
"@types/node": "^12.12.6",
"nock": "^13.2.0",
"nodemon": "^2.0.15",
"ts-node": "^10.8.1",
"typescript": "^4.2.3"
}
}

101
src/api/v1/controller.ts Normal file
View File

@@ -0,0 +1,101 @@
import { NextFunction, Request, Response } from "express";
import {
deserializeTransaction,
sponsorTransaction,
broadcastTransaction,
SponsoredAuthorization
} from "@stacks/transactions";
import { bytesToHex } from "@stacks/common";
import { StacksMainnet } from '@stacks/network';
import envVariables from '../../../config/config';
import {
SponsorAccountsKey,
} from '../../constants'
import {
lockRandomSponsorAccount,
getAccountNonce,
incrementAccountNonce,
unlockSponsorAccount,
check
} from '../../nonce';
import {
getAccountAddress
} from '../../utils';
import {
validateTransaction
} from '../../validation';
let cache = require('../../cache');
export class Controller {
info = async (req: Request, res: Response, next: NextFunction) => {
try {
const accounts = cache.instance().get(SponsorAccountsKey);
const addresses = [];
accounts.forEach(account => {
const address = getAccountAddress(account);
addresses.push(address);
});
return res.json({
active: true,
sponsor_addresses: addresses
});
} catch (error) {
next(error);
}
};
sponsor = async (req: Request, res: Response, next: NextFunction) => {
try {
const rawTx = req.body.tx;
const tx = deserializeTransaction(rawTx);
// check against rules to see if transaction is allowed for sponsorship
const validTx = await validateTransaction(tx);
if (!validTx) {
throw new Error('Transaction not valid for sponsorship');
}
// attempt to lock a random sponsor account from wallet
const account = await lockRandomSponsorAccount();
// get the sponsor address nonce
const network = new StacksMainnet();
const nonce = getAccountNonce(account);
// sign transaction as sponsor
const signedTx = await sponsorTransaction({
transaction: tx,
sponsorPrivateKey: account.stxPrivateKey,
network,
sponsorNonce: nonce
});
// make sure fee doesn't exceed maximum
const auth = signedTx.auth as SponsoredAuthorization;
const fee = auth.sponsorSpendingCondition.fee;
const maxFee = BigInt(envVariables.maxFee);
if (fee > maxFee) {
throw new Error(`Transaction fee (${fee}) exceeds max sponsor fee (${maxFee})`);
}
// broadcast transaction
const result = await broadcastTransaction(signedTx, network);
// increment nonce, release sponsor account and handle errors
if ('error' in result) {
unlockSponsorAccount(account);
throw new Error(`Broadcast failed: ${result.error}`);
} else {
incrementAccountNonce(account);
unlockSponsorAccount(account);
}
return res.json({txid: result.txid, rawTx: bytesToHex(signedTx.serialize())});
} catch (error) {
next(error);
}
};
}

10
src/api/v1/router.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Controller } from "./controller";
const express = require("express");
const router = express.Router();
const controller = new Controller();
router.get("/info", controller.info);
router.post("/sponsor", controller.sponsor);
export = router;

12
src/cache.ts Normal file
View File

@@ -0,0 +1,12 @@
let nodeCache = require('node-cache')
let cache = null
exports.start = function (done) {
if (cache) return done()
cache = new nodeCache()
}
exports.instance = function () {
return cache
}

4
src/constants.ts Normal file
View File

@@ -0,0 +1,4 @@
export const SponsorAccountsKey = "SponsorAccounts";
export const AddressNoncePrefix = "Nonce-";
export const AddressLockPrefix = "Lock-";
export const MaxLockAttempts = 10;

39
src/index.ts Normal file
View File

@@ -0,0 +1,39 @@
import { json } from 'body-parser';
import { errorHandler, wrongRouteHandler } from "./response";
import { initializeSponsorWallet } from './initialization';
let cache = require('./cache')
const express = require("express");
var cors = require('cors')
const app = express();
const v1 = require("./api/v1/router");
let port = 8080;
if (app.get("env") === "development") {
port = 3100;
}
cache.start(function (err) {
if (err) console.error(err)
})
// initialize the sponsor wallets to the correct nonce
initializeSponsorWallet();
app.use(json());
app.use(cors());
app.use("/v1", v1);
app.use(errorHandler);
app.use(wrongRouteHandler);
const server = app.listen(port, () => {
console.log(
`Stacks Transaction Sponsor Web Service started and listening at http://localhost:${port} in ${app.get(
"env"
)} mode`
);
});
export default server;

91
src/initialization.ts Normal file
View File

@@ -0,0 +1,91 @@
import { StacksMainnet } from "@stacks/network";
import {
getAddressFromPrivateKey,
TransactionVersion
} from "@stacks/transactions";
import envVariables from "../config/config";
import axios from "axios";
import {
generateWallet,
generateNewAccount
} from "@stacks/wallet-sdk";
import {
SponsorAccountsKey,
AddressNoncePrefix
} from './constants'
let cache = require('./cache')
export interface StxAddressDataResponse {
balance: string;
locked: string;
unlock_height: string;
nonce: number;
}
export async function initializeSponsorWallet() {
const seed = envVariables.seed;
const password = envVariables.password;
const numAddresses = Number(envVariables.numAddresses);
// generate wallet from seed
const wallet = await generateWallet({
secretKey: seed,
password: password,
});
// derive specified number of accounts/addresses
for (let i = 0; i < numAddresses - 1; i++) {
const newAccounts = await generateNewAccount(wallet);
wallet.accounts = newAccounts.accounts;
}
// cache sponsor accounts
const result = cache.instance().set(SponsorAccountsKey, wallet.accounts);
// get the correct next nonce for each addresses
wallet.accounts.forEach(account => {
const address = getAddressFromPrivateKey(account.stxPrivateKey, TransactionVersion.Mainnet);
setupAccountNonce(address);
});
}
export async function setupAccountNonce(address: string) {
const network = new StacksMainnet();
const apiUrl = `${network.coreApiUrl}/v2/accounts/${address}?proof=0`;
// get current nonce
const balanceInfo = await axios.get<StxAddressDataResponse>(apiUrl, {
timeout: 30000,
});
const nonce = balanceInfo.data.nonce;
// get pending transactions, increment nonce for each
const pendingTransactionCount = await getMempoolTransactions(address);
// calculate correct next nonce
const nextNonce = nonce + pendingTransactionCount;
// cache correct next nonce
const result = cache.instance().set(AddressNoncePrefix+address, nextNonce);
}
export async function getMempoolTransactions(
stxAddress: string
): Promise<number> {
const network = new StacksMainnet();
let apiUrl = `${network.coreApiUrl}/extended/v1/tx/mempool?address=${stxAddress}`;
return axios
.get(apiUrl, {
timeout: 30000,
params: {
limit: 0,
offset: 30,
},
})
.then((response) => {
return response.data.total;
});
}

84
src/nonce.ts Normal file
View File

@@ -0,0 +1,84 @@
import {
SponsorAccountsKey,
AddressNoncePrefix,
AddressLockPrefix,
MaxLockAttempts
} from './constants'
import { Account } from '@stacks/wallet-sdk';
import envVariables from "../config/config";
import {
sleep,
getRandomInt,
getAccountAddress
} from './utils';
let cache = require('./cache');
export function check(address: string): boolean {
return cache.instance().get(AddressLockPrefix+address);
}
// lock address nonce
export function lock(address: string): boolean {
const lock = check(address);
if (lock === true) {
return false;
} else {
cache.instance().set(AddressLockPrefix+address, true);
return true;
}
}
// unlock address nonce
export function unlock(address: string) {
cache.instance().set(AddressLockPrefix+address, false);
}
export function getRandomSponsorAccount(): Account {
const numAddresses = Number(envVariables.numAddresses);
const randomIndex = getRandomInt(0, numAddresses - 1);
const accounts = cache.instance().get(SponsorAccountsKey);
return accounts[randomIndex];
}
// attempt to lock one of the available addresses
// retry after 1000ms if locked by another request
export async function lockRandomSponsorAccount(): Promise<Account> {
var locked = false;
var attempts = 0;
while(!locked) {
const account = getRandomSponsorAccount();
const address = getAccountAddress(account);
locked = lock(address);
if (locked) {
return account;
} else {
attempts++;
await sleep(1000);
if (attempts >= MaxLockAttempts) {
throw new Error('Reached max attempts while locking sponsor address');
}
}
}
}
export function unlockSponsorAccount(account: Account) {
const address = getAccountAddress(account);
unlock(address);
}
export function getAccountNonce(account: Account) {
const address = getAccountAddress(account);
const nonce = cache.instance().get(AddressNoncePrefix+address);
return nonce;
}
export function incrementAccountNonce(account: Account) {
const address = getAccountAddress(account);
const currentNonce = getAccountNonce(account);
return cache.instance().set(AddressNoncePrefix+address, currentNonce + 1);
}

15
src/response.ts Normal file
View File

@@ -0,0 +1,15 @@
export function errorHandler(err, req, res, next) {
err.statusCode = err.statusCode || 400;
res.status(err.statusCode).json({
status: err.statusCode,
message: err.message
})
}
export function wrongRouteHandler(req, res, next) {
const statusCode = 404;
res.status(statusCode).json({
status: statusCode,
message: "Route not found. Request failed with status code 404 "
})
}

24
src/utils.ts Normal file
View File

@@ -0,0 +1,24 @@
import { Account } from '@stacks/wallet-sdk';
import {
getAddressFromPrivateKey,
TransactionVersion
} from "@stacks/transactions";
export function getRandomInt(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
export function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export function getAccountAddress(
account: Account,
version: TransactionVersion = TransactionVersion.Mainnet
): string {
return getAddressFromPrivateKey(account.stxPrivateKey, version);
}

43
src/validation.ts Normal file
View File

@@ -0,0 +1,43 @@
import {
PayloadType,
ContractCallPayload,
getAbi,
addressToString,
StacksTransaction
} from "@stacks/transactions";
// rules for transaction sponsorship eligibility
// customize this if you are forking this repo
export async function validateTransaction(transaction: StacksTransaction) {
if (transaction.payload.payloadType !== PayloadType.ContractCall) {
throw new Error('Transaction is not a contract call');
}
const payload = transaction.payload as ContractCallPayload;
// check and limit to SIP-09 transfers calls
if (payload.functionName.content.toString() !== 'transfer') {
throw new Error('Transaction is not a NFT transfer contract call');
}
const contractAddress = addressToString(payload.contractAddress);
const contractName = payload.contractName.content.toString();
const functionName = payload.functionName.content.toString();
const abi = await getAbi(contractAddress, contractName, "mainnet");
const func = abi.functions.find((func) => {
return func.name === functionName;
})
if (func.args.length !== 3) {
throw new Error('Transaction is not a NFT transfer contract call');
}
// check against sip-09 interface for transfer
if (func.args[0].name !== 'id' || func.args[0].type !== 'uint128'
|| func.args[1].name !== 'sender' || func.args[1].type !== 'principal'
|| func.args[2].name !== 'recipient' || func.args[2].type !== 'principal') {
throw new Error('Transaction is not a NFT transfer contract call');
}
return true;
}

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"rootDir": "./",
"outDir": "./dist",
"lib": [
"dom", "ES2020.Promise",
],
"esModuleInterop": true,
"resolveJsonModule": true,
"noImplicitAny": false,
"strictNullChecks": false,
"strict": true
}
}