diff --git a/package.json b/package.json index 2f91f25..52ceb71 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "safe-json-stringify": "^1.2.0", "slonik": "^37.2.0", "ts-clarity": "^0.0.16", + "yargs-parser": "^21.1.1", "zod": "^3.22.4" }, "devDependencies": { @@ -49,6 +50,7 @@ "@types/node": "^20.11.18", "@types/ramda": "^0.29.10", "@types/safe-json-stringify": "^1.1.5", + "@types/yargs-parser": "^21.0.3", "@typescript-eslint/eslint-plugin": "^7.0.1", "@typescript-eslint/parser": "^7.0.1", "eslint": "^8.56.0", diff --git a/src/cli/replace-sponsor-nonce.ts b/src/cli/replace-sponsor-nonce.ts new file mode 100644 index 0000000..a1c84ce --- /dev/null +++ b/src/cli/replace-sponsor-nonce.ts @@ -0,0 +1,173 @@ +import { StacksMainnet, StacksMocknet } from '@stacks/network'; +import { + broadcastTransaction, + deserializeTransaction, + sponsorTransaction, +} from '@stacks/transactions'; +import { getAccountNonces, getNodeInfo } from 'ts-clarity'; +import yargs from 'yargs-parser'; +import { getSponsorAccounts } from '../accounts'; +import { kStacksEndpoint, kStacksNetworkType } from '../config'; +import { UserOperation, getPgPool, sql } from '../db'; +import { stringify } from '../util'; + +// DO NOT USE THIS unless you know what you're doing! +async function main() { + const argv = yargs(process.argv.slice(2), { + configuration: { 'parse-numbers': false }, + }); + let tx = String(argv.tx); + if (tx.startsWith('\\x') || tx.startsWith('0x')) { + tx = tx.substring(2); + } + if (tx.startsWith('x')) { + tx = tx.substring(1); + } + if (!tx.match(/[0-9a-fA-F]{64}/)) { + console.log(`Invalid tx: ${tx}`); + return; + } + const tx_id = Buffer.from(tx, 'hex'); + const sponsorAccount = argv.account; + const nonce = Number(BigInt(argv.nonce)); + const gas = BigInt(argv.gas); + + const account = getSponsorAccounts().find(a => a.address === sponsorAccount); + if (account == null) { + console.log(`Invalid sponsor account: ${sponsorAccount}`); + return; + } + const { last_executed_tx_nonce } = await getAccountNonces(sponsorAccount, { + stacksEndpoint: kStacksEndpoint, + }); + if (!(last_executed_tx_nonce < nonce)) { + console.log( + `Invalid sponsor nonce: ${nonce}, last executed: ${last_executed_tx_nonce}`, + ); + return; + } + + const pgPool = await getPgPool(); + const replacer = await pgPool.maybeOne(sql.type(UserOperation)` + SELECT * FROM "user_operations" WHERE "tx_id" = ${sql.binary(tx_id)}`); + if (replacer == null) { + console.log(`User operation 0x${tx} not found`); + return; + } + const { last_executed_tx_nonce: user_last_executed_nonce } = + await getAccountNonces(replacer.sender, { + stacksEndpoint: kStacksEndpoint, + }); + if (replacer.nonce <= BigInt(user_last_executed_nonce)) { + console.log( + `Invalid user operation 0x${tx} nonce ${replacer.nonce}, last executed: ${user_last_executed_nonce}`, + ); + return; + } + + await pgPool.transaction(async client => { + const currentOperation = await client.maybeOne(sql.type(UserOperation)` + SELECT * FROM "user_operations" + WHERE "sponsor" = ${sponsorAccount} + AND "sponsor_nonce" = ${nonce} + AND "status" = 'submitted'`); + if (currentOperation != null && !currentOperation.tx_id.equals(tx_id)) { + await client.query(sql.typeAlias('void')` + UPDATE "sponsor_records" + SET "status" = 'failed', + "error" = 'dropped by another operation' + WHERE "tx_id" = ${sql.binary(currentOperation.tx_id)}`); + await client.query(sql.typeAlias('void')` + UPDATE "user_operations" + SET "status" = 'failed', + "error" = 'dropped by another operation' + WHERE "id" = ${currentOperation.id}`); + } + await client.query(sql.typeAlias('void')` + UPDATE "sponsor_records" + SET "status" = 'failed', + "error" = 'dropped and processed with another nonce' + WHERE "tx_id" = ${sql.binary(replacer.tx_id)}`); + await client.query(sql.typeAlias('void')` + UPDATE "user_operations" + SET "sponsor" = ${sponsorAccount}, + "sponsor_nonce" = ${nonce} + WHERE "id" = ${replacer.id}`); + }); + + const user_tx = deserializeTransaction(replacer.raw_tx); + const user_tx_id = user_tx.txid(); + const network = + kStacksNetworkType === 'mocknet' + ? new StacksMocknet({ url: kStacksEndpoint }) + : new StacksMainnet({ url: kStacksEndpoint }); + const nodeInfo = await getNodeInfo({ stacksEndpoint: kStacksEndpoint }); + const sponsored_tx = await sponsorTransaction({ + transaction: user_tx, + sponsorPrivateKey: account.secretKey, + network, + fee: gas, + sponsorNonce: nonce, + }); + // record first and then submit + await pgPool.query(sql.typeAlias( + 'void', + )`INSERT INTO "public"."sponsor_records" + (tx_id, raw_tx, sender, nonce, contract_address, function_name, args, fee, sponsor, sponsor_tx_id, sponsor_nonce, submit_block_height, status, created_at, updated_at) VALUES + ( + ${sql.binary(replacer.tx_id)}, + ${sql.binary(replacer.raw_tx)}, + ${replacer.sender}, ${String(replacer.nonce)}, + ${replacer.contract_address}, + ${replacer.function_name}, + ${JSON.stringify(replacer.args)}, + ${String(gas)}, + ${account.address}, + ${sql.binary(Buffer.from(sponsored_tx.txid(), 'hex'))}, + ${String(replacer.sponsor_nonce)}, + ${nodeInfo.stacks_tip_height}, + 'pending', + NOW(), + NOW() + )`); + const rs = await broadcastTransaction(sponsored_tx, network); + if (rs.reason == null) { + console.log( + `Submitted user tx 0x${user_tx_id} by 0x${sponsored_tx.txid()}`, + ); + await pgPool.query(sql.typeAlias('void')` + UPDATE user_operations + SET sponsor_tx_id = ${sql.binary(Buffer.from(sponsored_tx.txid(), 'hex'))}, + submit_block_height = ${nodeInfo.stacks_tip_height}, + fee = ${gas}, + updated_at = NOW() + WHERE id = ${replacer.id}`); + await pgPool.query(sql.typeAlias('void')` + UPDATE sponsor_records + SET status = 'submitted', + fee = ${gas}, + updated_at = NOW() + WHERE tx_id = ${sql.binary(replacer.tx_id)} + AND sponsor_tx_id = ${sql.binary(Buffer.from(sponsored_tx.txid(), 'hex'))}`); + } else { + console.error( + `Fail to broadcast tx ${rs.txid}, error: ${rs.error}, reason: ${ + rs.reason + }, reason_data: ${stringify(rs, null, 2)}`, + ); + await pgPool.query(sql.typeAlias('void')` + UPDATE user_operations + SET sponsor_tx_id = ${sql.binary(Buffer.from(sponsored_tx.txid(), 'hex'))}, + fee = ${gas}, + status = 'failed', + error = ${rs.reason ?? 'N/A'}, + updated_at = NOW() + WHERE id = ${replacer.id}`); + await pgPool.query(sql.typeAlias('void')` + DELETE FROM "sponsor_records" + WHERE tx_id = ${sql.binary(replacer.tx_id)} + AND sponsor_tx_id = ${sql.binary(Buffer.from(sponsored_tx.txid(), 'hex'))}`); + } +} + +main().catch(console.error); diff --git a/src/cli/reset-db.ts b/src/cli/reset-db.ts index c4a222d..8324329 100644 --- a/src/cli/reset-db.ts +++ b/src/cli/reset-db.ts @@ -1,5 +1,5 @@ import { sql } from 'slonik'; -import { kStacksNetworkType } from 'src/config'; +import { kStacksNetworkType } from '../config'; import { getPgPool } from '../db'; async function main() { diff --git a/tsconfig.json b/tsconfig.json index a7cf99b..f6666c0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "baseUrl": ".", "lib": ["es2020"], "target": "es2020", "strict": true, diff --git a/yarn.lock b/yarn.lock index b14fed3..1c605c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -521,6 +521,11 @@ "@types/mime" "*" "@types/node" "*" +"@types/yargs-parser@^21.0.3": + version "21.0.3" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" + integrity sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ== + "@typescript-eslint/eslint-plugin@^7.0.1": version "7.0.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.1.tgz#407daffe09d964d57aceaf3ac51846359fbe61b0" @@ -3517,6 +3522,11 @@ yaml@2.3.4: resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.4.tgz#53fc1d514be80aabf386dc6001eb29bf3b7523b2" integrity sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA== +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + yn@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"