From 2200289bce5aa6e845e28548bdc2333f7aea7521 Mon Sep 17 00:00:00 2001 From: Saleem Araidy Date: Thu, 9 Apr 2026 20:02:20 +0300 Subject: [PATCH 01/12] Fixed adding fee to ft transfer amount when checking for funds suffiucency --- src/StacksSDK.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StacksSDK.ts b/src/StacksSDK.ts index f3cbee1..dd019af 100644 --- a/src/StacksSDK.ts +++ b/src/StacksSDK.ts @@ -544,7 +544,7 @@ export class StacksSDK { balance = (balanceResponse as GetNativeBalanceResponse).balance; } - if (amount + fee > balance) { + if ((type === TransactionType.FungibleToken ? amount : amount + fee) > balance) { return { validParams: false, reason: `Insufficient funds. Available balance: ${balance}, required: ${amount}`, From 33e31d97cf9fd88e9c767e4e846cbb2e80a1fdeb Mon Sep 17 00:00:00 2001 From: Saleem Araidy Date: Sun, 26 Apr 2026 13:21:09 +0300 Subject: [PATCH 02/12] Implemented manual nonce managment (explicit nonce as optional param) --- .gitignore | 2 +- src/StacksSDK.ts | 65 +++++++++++++++++++++++++--- src/api/api.service.ts | 9 +++- src/api/controller.ts | 77 +++++++++++++++++++++++++++++++--- src/api/router.ts | 51 ++++++++++++++++++++++ src/services/stacks.service.ts | 25 ++++++++++- 6 files changed, 213 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index d5da6b0..02a25cc 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,9 @@ src/tests secrets .vscode .vscode/launch.json -<<<<<<< HEAD .DS_Store CLAUDE.md *.md !README.md +.env* diff --git a/src/StacksSDK.ts b/src/StacksSDK.ts index dd019af..3d0a53c 100644 --- a/src/StacksSDK.ts +++ b/src/StacksSDK.ts @@ -598,6 +598,7 @@ export class StacksSDK { customTokenContractName?: string, customTokenAssetName?: string, note?: string, + nonce?: bigint, ): Promise => { try { const transactionToSign = await this.chainService.serializeTransaction( @@ -610,6 +611,7 @@ export class StacksSDK { customTokenContractAddress, customTokenContractName, customTokenAssetName, + nonce, ); const rawSignature = await this.fireblocksService.signTransaction( @@ -664,6 +666,7 @@ export class StacksSDK { startBurnHeight?: number, authId?: bigint, note?: string, + nonce?: bigint, ): Promise => { try { if ( @@ -722,6 +725,7 @@ export class StacksSDK { this.publicKey, poolAddress, poolContractName!, + nonce, ); break; case "delegate-stx": @@ -730,11 +734,13 @@ export class StacksSDK { poolAddress, amount!, lockPeriod!, + nonce, ); break; case "revoke-delegate-stx": transactionToSign = await this.chainService.revokeStxDelegation( this.publicKey, + nonce, ); break; case "solo-stack": @@ -744,10 +750,11 @@ export class StacksSDK { amount, this.btcRewardsAddress, lockPeriod, - maxAmount, // maxAmount ( >= amount ) + maxAmount, signerSig65Hex, startBurnHeight, authId, + nonce, ); break; case "increase-stack-amount": @@ -755,9 +762,10 @@ export class StacksSDK { this.publicKey, signerKey!, amount!, - maxAmount!, + maxAmount!, signerSig65Hex!, authId!, + nonce, ); break; case "extend-stack-period": @@ -766,9 +774,10 @@ export class StacksSDK { signerKey!, this.btcRewardsAddress!, extendCycles!, - maxAmount!, + maxAmount!, signerSig65Hex!, authId!, + nonce, ); break; default: @@ -815,6 +824,7 @@ export class StacksSDK { amount: number, grossTransaction: boolean = false, note?: string, + nonce?: number, ): Promise => { if (!this.address || !this.publicKey || !this.vaultAccountId) { throw new Error("Address, Public Key or Vault ID are not set"); @@ -846,6 +856,7 @@ export class StacksSDK { undefined, // customTokenContractName undefined, // customTokenAssetName note, + nonce !== undefined ? BigInt(nonce) : undefined, ); if (!result || result.error || !result.txid || result.reason) { @@ -893,6 +904,7 @@ export class StacksSDK { customTokenContractName?: string, customTokenAssetName?: string, note?: string, + nonce?: number, ): Promise => { if (!this.address || !this.publicKey || !this.vaultAccountId) { throw new Error("Address, Public Key or Vault ID are not set"); @@ -940,6 +952,7 @@ export class StacksSDK { customTokenContractName, customTokenAssetName, note, + nonce !== undefined ? BigInt(nonce) : undefined, ); if (!result || result.error || !result.txid || result.reason) { @@ -981,7 +994,8 @@ export class StacksSDK { poolsAddress: string, poolContractName: string, amount: number, - lockPeriod: number, // Number of cycles + lockPeriod: number, + nonce?: number, ): Promise => { if (this.testnet) { console.log(`[WARNING] delegateToPool is not supported on testnet.`); @@ -1023,6 +1037,13 @@ export class StacksSDK { stxToMicro(amount), undefined, lockPeriod, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + nonce !== undefined ? BigInt(nonce) : undefined, ); const assertDelegateResult = assertResultSuccess(delegateResult); @@ -1060,6 +1081,7 @@ export class StacksSDK { public allowContractCaller = async ( poolsAddress: string, poolContractName: string, + nonce?: number, ): Promise => { if (this.testnet) { console.log(`[WARNING] allowContractCaller is not supported on testnet.`); @@ -1083,6 +1105,16 @@ export class StacksSDK { "allow-contract-caller", poolsAddress, poolContractName, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + nonce !== undefined ? BigInt(nonce) : undefined, ); const assertAllowCallerResult = assertResultSuccess(allowCallerResult); @@ -1118,7 +1150,7 @@ export class StacksSDK { * @throws {Error} If the address, public key, or vault ID are not set, or if the process fails. */ - public revokeDelegation = async (): Promise => { + public revokeDelegation = async (nonce?: number): Promise => { if (this.testnet) { console.log(`[WARNING] revokeDelegation is not supported on testnet.`); return { @@ -1137,6 +1169,18 @@ export class StacksSDK { // Revoke any existing delegations. const revokeResult = await this.buildSignSendContractCall( "revoke-delegate-stx", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + nonce !== undefined ? BigInt(nonce) : undefined, ); const assertDelegateResult = assertResultSuccess(revokeResult); @@ -1329,8 +1373,9 @@ export class StacksSDK { signerSig65Hex: string, amount: number, maxAmount: number, - lockPeriod: number, // Number of cycles + lockPeriod: number, authId: bigint, + nonce?: number, ): Promise => { try { if (!this.address || !this.publicKey || !this.vaultAccountId) { @@ -1364,6 +1409,8 @@ export class StacksSDK { signerSig65Hex, startBurnHeight, authId, + undefined, + nonce !== undefined ? BigInt(nonce) : undefined, ); const assertResult = assertResultSuccess(result); @@ -1412,6 +1459,7 @@ export class StacksSDK { increaseBy: number, maxAmount: number, authId: bigint, + nonce?: number, ): Promise => { try { if (!this.address || !this.publicKey || !this.vaultAccountId) { @@ -1432,6 +1480,8 @@ export class StacksSDK { signerSig65Hex, undefined, authId, + undefined, + nonce !== undefined ? BigInt(nonce) : undefined, ); const assertResult = assertResultSuccess(result); @@ -1480,6 +1530,7 @@ export class StacksSDK { extendCycles: number, maxAmount: number, authId: bigint, + nonce?: number, ): Promise => { try { if (!this.address || !this.publicKey || !this.vaultAccountId) { @@ -1500,6 +1551,8 @@ export class StacksSDK { signerSig65Hex, undefined, authId, + undefined, + nonce !== undefined ? BigInt(nonce) : undefined, ); const assertResult = assertResultSuccess(result); diff --git a/src/api/api.service.ts b/src/api/api.service.ts index 09825ee..133766f 100644 --- a/src/api/api.service.ts +++ b/src/api/api.service.ts @@ -66,7 +66,7 @@ export class ApiService { result = await sdk.getBtcRewardsAddress(); break; case ActionType.REVOKE_DELEGATION: - result = await sdk.revokeDelegation(); + result = await sdk.revokeDelegation(params.nonce); break; case ActionType.CHECK_STATUS: result = await sdk.checkStatus(); @@ -79,6 +79,7 @@ export class ApiService { params.maxAmount, params.lockPeriod, params.authId, + params.nonce, ); break; case ActionType.GET_TX_STATUS_BY_ID: @@ -91,12 +92,14 @@ export class ApiService { params.poolContractName, params.amount, params.lockPeriod, + params.nonce, ); break; case ActionType.ALLOW_CONTRACT_CALLER: result = await sdk.allowContractCaller( params.poolAddress, params.poolContractName, + params.nonce, ); break; case ActionType.CREATE_NATIVE_TRANSACTION: @@ -105,6 +108,7 @@ export class ApiService { params.amount, params.grossTransaction, params.note, + params.nonce, ); break; case ActionType.CREATE_FT_TRANSACTION: @@ -116,6 +120,7 @@ export class ApiService { params.tokenContractName, params.tokenAssetName, params.note, + params.nonce, ); break; case ActionType.GET_BALANCE: @@ -147,6 +152,7 @@ export class ApiService { params.increaseBy, params.maxAmount, params.authId, + params.nonce, ); break; case ActionType.EXTEND_STACKING_PERIOD: @@ -156,6 +162,7 @@ export class ApiService { params.extendCycles, params.maxAmount, params.authId, + params.nonce, ); break; default: diff --git a/src/api/controller.ts b/src/api/controller.ts index f794f2e..fab56f9 100644 --- a/src/api/controller.ts +++ b/src/api/controller.ts @@ -170,6 +170,15 @@ export const createTransaction: Handler = async (req, res, next) => { ? String(req.body.tokenAssetName).trim() : undefined; + let nonce: number | undefined; + if (req.body.nonce !== undefined && req.body.nonce !== "") { + nonce = Number(req.body.nonce); + if (!Number.isInteger(nonce) || nonce < 0) { + res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); + return; + } + } + if (!recipientAddress || !amountStr || !assetUi) { res.status(400).json({ error: @@ -215,7 +224,7 @@ export const createTransaction: Handler = async (req, res, next) => { const tx = await apiService.executeAction( vaultId, ActionType.CREATE_NATIVE_TRANSACTION, - { recipientAddress, amount, grossTransaction, note }, + { recipientAddress, amount, grossTransaction, note, nonce }, ); res.json(tx); return; @@ -233,6 +242,7 @@ export const createTransaction: Handler = async (req, res, next) => { tokenContractName, tokenAssetName, note, + nonce, }, ); res.json(tx); @@ -271,6 +281,15 @@ export const delegateToPool: Handler = async (req, res, next) => { return; } + let nonce: number | undefined; + if (req.body.nonce !== undefined && req.body.nonce !== "") { + nonce = Number(req.body.nonce); + if (!Number.isInteger(nonce) || nonce < 0) { + res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); + return; + } + } + // Map UI label -> Pool Type (enum value) const poolSelectionMap: Record = { FAST_POOL: StackingPools.FAST_POOL, @@ -285,11 +304,10 @@ export const delegateToPool: Handler = async (req, res, next) => { const poolAddress = poolInfo[poolType].poolAddress; const poolContractName = poolInfo[poolType].poolContractName; - // FT transfer const tx = await apiService.executeAction( vaultId, ActionType.DELEGATE_TO_POOL, - { poolAddress, poolContractName, amount, lockPeriod }, + { poolAddress, poolContractName, amount, lockPeriod, nonce }, ); res.json(tx); } catch (err) { @@ -311,6 +329,15 @@ export const allowContractCaller: Handler = async (req, res, next) => { return; } + let nonce: number | undefined; + if (req.body.nonce !== undefined && req.body.nonce !== "") { + nonce = Number(req.body.nonce); + if (!Number.isInteger(nonce) || nonce < 0) { + res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); + return; + } + } + // Map UI label -> Pool Type (enum value) const poolSelectionMap: Record = { FAST_POOL: StackingPools.FAST_POOL, @@ -325,11 +352,10 @@ export const allowContractCaller: Handler = async (req, res, next) => { const poolAddress = poolInfo[poolType].poolAddress; const poolContractName = poolInfo[poolType].poolContractName; - // FT transfer const tx = await apiService.executeAction( vaultId, ActionType.ALLOW_CONTRACT_CALLER, - { poolAddress, poolContractName }, + { poolAddress, poolContractName, nonce }, ); res.json(tx); } catch (err) { @@ -342,10 +368,19 @@ export const revokeDelegation: Handler = async (req, res, next) => { try { const vaultId = getVaultId(req); + let nonce: number | undefined; + if (req.body?.nonce !== undefined && req.body.nonce !== "") { + nonce = Number(req.body.nonce); + if (!Number.isInteger(nonce) || nonce < 0) { + res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); + return; + } + } + const tx = await apiService.executeAction( vaultId, ActionType.REVOKE_DELEGATION, - {}, + { nonce }, ); res.json(tx); } catch (err) { @@ -422,6 +457,15 @@ export const stackSolo: Handler = async (req, res, next) => { } const maxAmount = Number(maxAmountStr); + let nonce: number | undefined; + if (req.body.nonce !== undefined && req.body.nonce !== "") { + nonce = Number(req.body.nonce); + if (!Number.isInteger(nonce) || nonce < 0) { + res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); + return; + } + } + const tx = await apiService.executeAction(vaultId, ActionType.STACK_SOLO, { signerKey, signerSig65Hex, @@ -429,6 +473,7 @@ export const stackSolo: Handler = async (req, res, next) => { maxAmount, lockPeriod, authId, + nonce, }); res.json(tx); @@ -477,12 +522,22 @@ export const increaseStackedAmount: Handler = async (req, res, next) => { } const maxAmount = BigInt(maxAmountStr); + let nonce: number | undefined; + if (req.body.nonce !== undefined && req.body.nonce !== "") { + nonce = Number(req.body.nonce); + if (!Number.isInteger(nonce) || nonce < 0) { + res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); + return; + } + } + const tx = await apiService.executeAction(vaultId, ActionType.INCREASE_STACKED_AMOUNT, { signerKey, signerSig65Hex, increaseBy, maxAmount, authId, + nonce, }); res.json(tx); @@ -533,12 +588,22 @@ export const extendStackingPeriod: Handler = async (req, res, next) => { } const maxAmount = Number(maxAmountStr); + let nonce: number | undefined; + if (req.body.nonce !== undefined && req.body.nonce !== "") { + nonce = Number(req.body.nonce); + if (!Number.isInteger(nonce) || nonce < 0) { + res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); + return; + } + } + const tx = await apiService.executeAction(vaultId, ActionType.EXTEND_STACKING_PERIOD, { signerKey, signerSig65Hex, extendCycles, maxAmount, authId, + nonce, }); res.json(tx); diff --git a/src/api/router.ts b/src/api/router.ts index feb79bf..65e8d95 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -306,6 +306,14 @@ router.get( * note: * type: string * description: Optional note attached to Fireblocks signing request + * nonce: + * type: integer + * minimum: 0 + * description: > + * Optional transaction nonce override. If omitted, the SDK auto-fetches + * the current account nonce from the network (default behavior). + * Only set this for advanced use cases such as nonce management or + * transaction replacement. * responses: * 200: * description: Transaction created successfully @@ -354,6 +362,12 @@ router.post( * minimum: 1 * maximum: 12 * description: Number of cycles to stack (1-12). Default is 1. + * nonce: + * type: integer + * minimum: 0 + * description: > + * Optional transaction nonce override. If omitted, the SDK auto-fetches + * the current account nonce from the network (default behavior). * responses: * 200: * description: Delegated to pool successfully. @@ -391,6 +405,12 @@ router.post( * type: string * enum: [FAST_POOL] * description: Pool to allow as contract caller. + * nonce: + * type: integer + * minimum: 0 + * description: > + * Optional transaction nonce override. If omitted, the SDK auto-fetches + * the current account nonce from the network (default behavior). * responses: * 200: * description: Pool allowed as contract caller successfully. @@ -414,6 +434,19 @@ router.post( * Revokes any existing STX delegations for the account associated with the given vault ID. * parameters: * - $ref: '#/components/parameters/vaultId' + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * nonce: + * type: integer + * minimum: 0 + * description: > + * Optional transaction nonce override. If omitted, the SDK auto-fetches + * the current account nonce from the network (default behavior). * responses: * 200: * description: Delegation revoked successfully. @@ -471,6 +504,12 @@ router.post( * authId: * type: string * description: Integer string (bigint) used for signer-sig replay protection (must be the same authId used to generate the signature). + * nonce: + * type: integer + * minimum: 0 + * description: > + * Optional transaction nonce override. If omitted, the SDK auto-fetches + * the current account nonce from the network (default behavior). * responses: * 200: * description: Solo stacking transaction submitted @@ -518,6 +557,12 @@ router.post("/:vaultId/stacking/solo", validateVaultId, controller.stackSolo); * authId: * type: string * description: Integer string (bigint) used for signer-sig replay protection (must be the same authId used to generate the signature). + * nonce: + * type: integer + * minimum: 0 + * description: > + * Optional transaction nonce override. If omitted, the SDK auto-fetches + * the current account nonce from the network (default behavior). * responses: * 200: * description: Increase stacked amount transaction submitted @@ -567,6 +612,12 @@ router.post("/:vaultId/stacking/solo/increase", validateVaultId, controller.incr * authId: * type: string * description: Integer string (bigint) used for signer-sig replay protection (must be the same authId used to generate the signature). + * nonce: + * type: integer + * minimum: 0 + * description: > + * Optional transaction nonce override. If omitted, the SDK auto-fetches + * the current account nonce from the network (default behavior). * responses: * 200: * description: Extend stacking period transaction submitted diff --git a/src/services/stacks.service.ts b/src/services/stacks.service.ts index 8285a7b..b0b951b 100644 --- a/src/services/stacks.service.ts +++ b/src/services/stacks.service.ts @@ -352,6 +352,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra customTokenContractAddress?: string, customTokenContractName?: string, customTokenAssetName?: string, + nonce?: bigint, ): Promise => { try { if (!validateAddress(recipient, this.network === STACKS_TESTNET)) { @@ -419,6 +420,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra network: this.network, postConditionMode: PostConditionMode.Deny, postConditions: [postCondition], + ...(nonce !== undefined ? { nonce } : {}), }); } else { unsignedTx = await makeUnsignedSTXTokenTransfer({ @@ -426,6 +428,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra amount, publicKey: senderPublicKey, network: this.network, + ...(nonce !== undefined ? { nonce } : {}), }); } @@ -456,6 +459,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra contractName: string, functionName: string, functionArgs: ClarityValue[], + nonce?: bigint, ): Promise => { try { if (!validateAddress(contractAddress, this.network === STACKS_TESTNET)) { @@ -478,6 +482,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra publicKey: senderPublicKey, network: this.network, postConditionMode: PostConditionMode.Deny, + ...(nonce !== undefined ? { nonce } : {}), }); return unsignedContractCall; @@ -512,6 +517,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra customTokenContractAddress?: string, customTokenContractName?: string, customTokenAssetName?: string, + nonce?: bigint, ): Promise<{ unsignedTx: StacksTransactionWire; preSignSigHash: string; @@ -541,6 +547,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra customTokenContractAddress, customTokenContractName, customTokenAssetName, + nonce, ); const sigHash = unsignedTx.signBegin(); @@ -578,6 +585,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra contractName: string, functionName: string, functionArgs: ClarityValue[], + nonce?: bigint, ): Promise<{ unsignedContractCall: StacksTransactionWire; preSignSigHash: string; @@ -589,6 +597,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra contractName, functionName, functionArgs, + nonce, ); const sigHash = unsignedContractCall.signBegin(); @@ -844,7 +853,8 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra senderPublicKey: string, delegateTo: string, amount: bigint, - lockPeriod: number, // Number of cycles + lockPeriod: number, + nonce?: bigint, ): Promise<{ unsignedContractCall: StacksTransactionWire; preSignSigHash: string; @@ -882,6 +892,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra someCV(uintCV(until_burn_ht)), noneCV(), ], + nonce, ); return serializedContractCall; @@ -903,6 +914,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra */ public revokeStxDelegation = async ( senderPublicKey: string, + nonce?: bigint, ): Promise<{ unsignedContractCall: StacksTransactionWire; preSignSigHash: string; @@ -920,6 +932,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra poxName, "revoke-delegate-stx", [], + nonce, ); return serializedContractCall; @@ -946,6 +959,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra senderPublicKey: string, poolAddress: string, poolContractName: string, + nonce?: bigint, ): Promise<{ unsignedContractCall: StacksTransactionWire; preSignSigHash: string; @@ -971,6 +985,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra poxName, "allow-contract-caller", [contractPrincipalCV(poolAddress, poolContractName), noneCV()], + nonce, ); return serializedContractCall; @@ -1006,6 +1021,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra signerSig65Hex: string, startBurnHeight: number, authId: bigint, + nonce?: bigint, ): Promise<{ unsignedContractCall: StacksTransactionWire; preSignSigHash: string; @@ -1038,6 +1054,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra uintCV(maxAmountUstx), uintCV(authId), ], + nonce, ); return serializedContractCall; @@ -1067,6 +1084,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra maxAmountUstx: bigint, signerSig65Hex: string, authId: bigint, + nonce?: bigint, ): Promise<{ unsignedContractCall: StacksTransactionWire; preSignSigHash: string; @@ -1091,6 +1109,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra uintCV(maxAmountUstx), // max-amount uintCV(authId), // auth-id ], + nonce, ); return serializedContractCall; @@ -1123,6 +1142,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra maxAmountUstx: bigint, signerSig65Hex: string, authId: bigint, + nonce?: bigint, ): Promise<{ unsignedContractCall: StacksTransactionWire; preSignSigHash: string; @@ -1147,12 +1167,13 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra tupleCV({ // 2. pox-addr version: bufferCV(Uint8Array.from([version])), hashbytes: bufferCV(hashbytes), - }), + }), someCV(bufferCV(Buffer.from(signerSig65Hex, "hex"))), // signer-sig bufferCV(Buffer.from(signerKey, "hex")), // signer-key uintCV(maxAmountUstx), // max-amount uintCV(authId), // auth-id ], + nonce, ); return serializedContractCall; From 5658383155e0d3a3cb56f3ee49eaf360be53596a Mon Sep 17 00:00:00 2001 From: Saleem Araidy Date: Tue, 28 Apr 2026 12:29:11 +0300 Subject: [PATCH 03/12] implemented RBF flow [FIREHOG-BYPASS] not secrets --- A | 0 B | 0 README.md | 146 +++++++++++++++++++++++++++--- src/StacksSDK.ts | 160 +++++++++++++++++++++++++++++++++ src/api/api.service.ts | 13 +++ src/api/controller.ts | 88 +++++++++++++++++- src/api/router.ts | 106 ++++++++++++++++++++++ src/pool/types.ts | 2 + src/services/stacks.service.ts | 31 +++++++ src/services/types.ts | 9 +- 10 files changed, 540 insertions(+), 15 deletions(-) delete mode 100644 A delete mode 100644 B diff --git a/A b/A deleted file mode 100644 index e69de29..0000000 diff --git a/B b/B deleted file mode 100644 index e69de29..0000000 diff --git a/README.md b/README.md index f9029c8..4ff2504 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ It's designed to simplify integration with Fireblocks for secure Stacks transact - **Fireblocks raw signing support** - **Native STX transfers**: Send STX with optional gross transactions (fee deduction from recipient) - **Fungible token transfers**: Support for SIP-010 token transfers (sBTC, USDC, etc.) +- **Nonce management**: Optional nonce override on every transaction method; query confirmed on-chain nonce via `getAccountNonce()` +- **Replace-by-fee**: Replace a stuck pending STX transaction with a higher-fee one using the same nonce - **Stacking functionality**: - Solo stacking - Pool delegation and stacking @@ -227,6 +229,16 @@ const grossTransfer = await sdk.createNativeTransaction( 10.5, true, // fee will be deducted from the 10.5 STX ); + +// With explicit nonce and fee override +const transfer = await sdk.createNativeTransaction( + "ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG", + 10.5, + false, + undefined, // note + 7, // nonce override (integer) + 0.01, // fee in STX (overrides auto-estimation) +); ``` ### **Transfer Fungible Tokens** @@ -331,6 +343,53 @@ const allowCallerResponse = await sdk.allowContractCaller( const revokeResponse = await sdk.revokeDelegation(); ``` +### **Nonce Management** + +```typescript +// Query the confirmed on-chain nonce for this vault's address. +// Pending mempool transactions are NOT reflected — if you have unconfirmed +// transactions in flight, the next safe nonce is this value plus the number +// of pending transactions. +const nonceResponse = await sdk.getAccountNonce(); +if (nonceResponse.success) { + console.log("Confirmed nonce:", nonceResponse.nonce); +} +``` + +All transaction methods (`createNativeTransaction`, `createFTTransaction`, `delegateToPool`, `allowContractCaller`, `revokeDelegation`, `stackSolo`, `increaseStackedAmount`, `extendStackingPeriod`) accept an optional `nonce?: number` parameter as their last argument. When omitted, the nonce is estimated automatically. + +### **Replace a Stuck Transaction** + +If a transaction is stuck in the mempool due to a low fee, you can replace it by submitting a new transaction with the same nonce and a higher fee. + +```typescript +// Replace a pending transaction visible to the Hiro indexer +const replacement = await sdk.replaceTransaction( + "0xabc123...", // original tx ID + 0.01, // new fee in STX (must be higher than original) + // optionally override recipient/amount: + // "ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG", + // 10.5, +); + +// Replace a future-nonce transaction not visible to the Hiro indexer. +// nonceOverride bypasses the indexer lookup — newRecipient and newAmount +// are required in this case. +const replacement = await sdk.replaceTransaction( + "0xabc123...", + 0.01, + "ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG", // newRecipient (required) + 10.5, // newAmount in STX (required) + 7, // nonceOverride +); + +if (replacement.success) { + console.log("Replacement tx hash:", replacement.txHash); +} +``` + +> **Note:** Only native STX `token_transfer` transactions can be replaced. The new fee must be higher than the original or the node will reject the replacement. + ### **Transaction Status Monitoring** ```typescript @@ -376,6 +435,7 @@ history.forEach((tx) => { | GET | `/api/:vaultId/address` | Fetch the Stacks address associated with the given vault | | GET | `/api/:vaultId/publicKey` | Retrieve the public key for the vault account | | GET | `/api/:vaultId/btc-rewards-address` | Get the BTC rewards address associated with the given vault (for stacking) | +| GET | `/api/:vaultId/nonce` | Get the confirmed on-chain nonce for this vault's Stacks address | ### **Balance Endpoints** @@ -386,22 +446,37 @@ history.forEach((tx) => { ### **Transaction Endpoints** -| Method | Route | Description | -| ------ | ---------------------------------- | ------------------------------------------------------- | -| GET | `/api/:vaultId/transactions` | List recent transactions for this vault | -| GET | `/api/transactions/:txId` | Get detailed transaction status with error code mapping | -| POST | `/api/:vaultId/transfer` | Transfer STX or Fungible Tokens to another address | +| Method | Route | Description | +| ------ | --------------------------------------- | ---------------------------------------------------------------------------- | +| GET | `/api/:vaultId/transactions` | List recent transactions for this vault | +| GET | `/api/transactions/:txId` | Get detailed transaction status with error code mapping | +| POST | `/api/:vaultId/transfer` | Transfer STX or Fungible Tokens to another address | +| POST | `/api/:vaultId/replace-transaction` | Replace a stuck pending STX transaction with a higher-fee one (same nonce) | + +`/transfer` accepts optional `nonce` (integer) and `fee` (STX, for STX transfers only) body fields to override the auto-estimated values. + +`/replace-transaction` body fields: + +| Field | Type | Required | Description | +| --------------- | ------- | -------- | ---------------------------------------------------------------------------- | +| `originalTxId` | string | Yes | Transaction ID of the pending transaction to replace | +| `newFee` | number | Yes | New fee in STX — must be higher than the original | +| `newRecipient` | string | No | New recipient address. Defaults to the original recipient | +| `newAmount` | number | No | New transfer amount in STX. Defaults to the original amount | +| `nonceOverride` | integer | No | Nonce to use directly, bypassing the Hiro indexer lookup. Required when the original tx is a future-nonce tx not visible in the explorer. When set, `newRecipient` and `newAmount` are also required. | ### **Stacking Endpoints** -| Method | Route | Description | -| ------ | --------------------------------------------------- | ------------------------------------------------- | -| GET | `/api/:vaultId/check-status` | Check account stacking status and delegation info | -| GET | `/api/poxInfo` | Fetch current PoX info from blockchain | -| POST | `/api/:vaultId/stacking/solo` | Solo stack STX with automatic signer signature | -| POST | `/api/:vaultId/stacking/pool/delegate` | Delegate amonunt of STX to a stacking pool | -| POST | `/api/:vaultId/stacking/pool/allow-contract-caller` | Allow a pool contract to lock your STX | -| POST | `/api/:vaultId/revoke-delegation` | Revoke any active STX delegation | +| Method | Route | Description | +| ------ | --------------------------------------------------- | ---------------------------------------------------- | +| GET | `/api/:vaultId/check-status` | Check account stacking status and delegation info | +| GET | `/api/poxInfo` | Fetch current PoX info from blockchain | +| POST | `/api/:vaultId/stacking/solo` | Solo stack STX | +| POST | `/api/:vaultId/stacking/solo/increase` | Increase the STX amount of an existing solo position | +| POST | `/api/:vaultId/stacking/solo/extend` | Extend the lock period of an existing solo position | +| POST | `/api/:vaultId/stacking/pool/delegate` | Delegate amount of STX to a stacking pool | +| POST | `/api/:vaultId/stacking/pool/allow-contract-caller` | Allow a pool contract to lock your STX | +| POST | `/api/:vaultId/revoke-delegation` | Revoke any active STX delegation | ### **Utility Endpoints** @@ -511,6 +586,51 @@ curl -X 'POST' \ > **Note:** `tokenAssetName` is the name from the contract's `define-fungible-token` declaration, which may differ from `tokenContractName`. +### **Get Account Nonce** + +```bash +curl http://localhost:3000/api/123/nonce +``` + +### **Transfer STX with Nonce Override** + +```bash +curl -X POST http://localhost:3000/api/123/transfer \ + -H 'Content-Type: application/json' \ + -d '{ + "recipientAddress": "ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG", + "amount": 10.5, + "assetType": "STX", + "nonce": 7, + "fee": 0.01 + }' +``` + +### **Replace a Stuck Transaction** + +```bash +curl -X POST http://localhost:3000/api/123/replace-transaction \ + -H 'Content-Type: application/json' \ + -d '{ + "originalTxId": "0xabc123...", + "newFee": 0.01 + }' +``` + +For a future-nonce transaction not visible in the explorer, provide `nonceOverride` with the exact nonce, plus `newRecipient` and `newAmount`: + +```bash +curl -X POST http://localhost:3000/api/123/replace-transaction \ + -H 'Content-Type: application/json' \ + -d '{ + "originalTxId": "0xabc123...", + "newFee": 0.01, + "newRecipient": "ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG", + "newAmount": 10.5, + "nonceOverride": 7 + }' +``` + ### **Solo Stack STX** ```bash curl -X 'POST' \ diff --git a/src/StacksSDK.ts b/src/StacksSDK.ts index 3d0a53c..1a83bfb 100644 --- a/src/StacksSDK.ts +++ b/src/StacksSDK.ts @@ -25,6 +25,7 @@ import { CheckStatusResponse, CreateTransactionResponse, FireblocksConfig, + GetAccountNonceResponse, GetFtBalancesResponse, GetNativeBalanceResponse, GetPoxInfoResponse, @@ -184,6 +185,27 @@ export class StacksSDK { } }; + /** + * Retrieves the next expected nonce for this vault's Stacks address. + * + * Returns the confirmed on-chain nonce — pending mempool transactions are not + * reflected. If you have unconfirmed transactions in flight, the actual next + * safe nonce is this value plus the number of pending transactions. + * + * @returns A promise that resolves to a {GetAccountNonceResponse}. + */ + public getAccountNonce = async (): Promise => { + if (!this.address) { + throw new Error("Stacks address is not set."); + } + try { + const nonce = await this.chainService.getAccountNonce(this.address); + return { success: true, nonce }; + } catch (error) { + return { success: false, error: formatErrorMessage(error) }; + } + }; + /** * Retrieves the status of a transaction by its ID. * @param txId - The transaction ID. @@ -599,6 +621,7 @@ export class StacksSDK { customTokenAssetName?: string, note?: string, nonce?: bigint, + feeUstx?: bigint, ): Promise => { try { const transactionToSign = await this.chainService.serializeTransaction( @@ -612,6 +635,7 @@ export class StacksSDK { customTokenContractName, customTokenAssetName, nonce, + feeUstx, ); const rawSignature = await this.fireblocksService.signTransaction( @@ -825,6 +849,7 @@ export class StacksSDK { grossTransaction: boolean = false, note?: string, nonce?: number, + fee?: number, ): Promise => { if (!this.address || !this.publicKey || !this.vaultAccountId) { throw new Error("Address, Public Key or Vault ID are not set"); @@ -857,6 +882,7 @@ export class StacksSDK { undefined, // customTokenAssetName note, nonce !== undefined ? BigInt(nonce) : undefined, + fee !== undefined ? stxToMicro(fee) : undefined, ); if (!result || result.error || !result.txid || result.reason) { @@ -1587,6 +1613,140 @@ export class StacksSDK { }; + /** + * Replaces a pending STX transaction with a new one using the same nonce but a higher fee. + * This is useful when a transaction is stuck in the mempool due to a low fee. + * Only native STX token_transfer transactions are supported. + * @param originalTxId - The transaction ID of the transaction to replace. + * @param newFee - The new fee in STX. Must be higher than the original fee. + * @param newRecipient - Optional new recipient address. Defaults to the original recipient. + * @param newAmount - Optional new amount in STX. Defaults to the original amount. + * @param nonceOverride - Provide the nonce directly to skip the transaction lookup. + * Required when the original tx is not visible to the Hiro indexer (e.g. future-nonce + * transactions). When set, newRecipient and newAmount must also be provided. + * @returns A promise that resolves to a {CreateTransactionResponse}. + */ + public replaceTransaction = async ( + originalTxId: string, + newFee: number, + newRecipient?: string, + newAmount?: number, + nonceOverride?: number, + ): Promise => { + if (!this.address || !this.publicKey || !this.vaultAccountId) { + throw new Error("Address, Public Key or Vault ID are not set"); + } + + try { + let nonce: bigint; + let recipient: string; + let amountUstx: bigint; + + if (nonceOverride !== undefined) { + // ── Override path: nonce is known, tx may not be visible to the indexer ── + if (!newRecipient || newAmount === undefined) { + return { + success: false, + error: "newRecipient and newAmount are required when nonceOverride is provided", + }; + } + if (!validateAddress(newRecipient, this.testnet)) { + return { success: false, error: "Invalid recipient address" }; + } + nonce = BigInt(nonceOverride); + recipient = newRecipient; + amountUstx = stxToMicro(newAmount); + } else { + // ── Lookup path: resolve nonce, recipient and amount from the original tx ── + const originalTxResponse = await this.getTxStatusById(originalTxId); + + if (!originalTxResponse.success || !originalTxResponse.data) { + return { success: false, error: "Could not fetch original transaction details" }; + } + + if (originalTxResponse.data.tx_status !== "pending") { + return { + success: false, + error: `Can only replace pending transactions. Current status: ${originalTxResponse.data.tx_status}`, + }; + } + + const fullTx = originalTxResponse.data.full_tx_details; + + if (fullTx?.tx_type !== "token_transfer") { + return { + success: false, + error: `Only native STX token_transfer transactions can be replaced. Got: ${fullTx?.tx_type}`, + }; + } + + if (fullTx.sender_address !== this.address) { + return { + success: false, + error: "Transaction sender does not match this vault account address", + }; + } + + nonce = BigInt(fullTx.nonce); + recipient = newRecipient ?? fullTx.token_transfer.recipient_address; + amountUstx = newAmount !== undefined + ? stxToMicro(newAmount) + : BigInt(fullTx.token_transfer.amount); + + if (!validateAddress(recipient, this.testnet)) { + return { success: false, error: "Invalid recipient address" }; + } + } + + const feeBigInt = stxToMicro(newFee); + + // Build and serialize with the nonce + fee baked in before hash computation. + // Passing fee here is critical — serializeTransaction computes preSignSigHash from the + // fee on the spending condition, so the fee must be set before signing, not after. + const transactionToSign = await this.chainService.serializeTransaction( + this.address, + this.publicKey, + recipient, + amountUstx, + TransactionType.STX, + undefined, + undefined, + undefined, + undefined, + nonce, + feeBigInt, + ); + + const rawSignature = await this.fireblocksService.signTransaction( + transactionToSign.preSignSigHash, + this.vaultAccountId.toString(), + ); + + const signature = concatSignature(rawSignature.fullSig, rawSignature.v); + (transactionToSign.unsignedTx as any).auth.spendingCondition.signature = + createMessageSignature(signature); + + const result = await this.chainService.broadcastTransaction(transactionToSign.unsignedTx); + + if (!result || result.error || !result.txid || result.reason) { + const errorAndReason = + result?.error && result?.reason + ? `${result.error} - ${result.reason}` + : result?.error || result?.reason || "unknown error"; + return { success: false, error: formatErrorMessage(errorAndReason) }; + } + + console.log(`Replaced transaction ${originalTxId} with ${result.txid}`); + return { success: true, txHash: result.txid }; + } catch (error) { + console.error(`Error replacing transaction: ${formatErrorMessage(error)}`); + return { + success: false, + error: `Failed to replace transaction: ${formatErrorMessage(error)}`, + }; + } + }; + /** * fetches current pox info from blockchain. * @returns the pox info response. diff --git a/src/api/api.service.ts b/src/api/api.service.ts index 133766f..9b99c2a 100644 --- a/src/api/api.service.ts +++ b/src/api/api.service.ts @@ -109,6 +109,7 @@ export class ApiService { params.grossTransaction, params.note, params.nonce, + params.fee, ); break; case ActionType.CREATE_FT_TRANSACTION: @@ -165,6 +166,18 @@ export class ApiService { params.nonce, ); break; + case ActionType.REPLACE_TRANSACTION: + result = await sdk.replaceTransaction( + params.originalTxId, + params.newFee, + params.newRecipient, + params.newAmount, + params.nonceOverride, + ); + break; + case ActionType.GET_ACCOUNT_NONCE: + result = await sdk.getAccountNonce(); + break; default: throw new Error( `InvalidType : diff --git a/src/api/controller.ts b/src/api/controller.ts index fab56f9..f987185 100644 --- a/src/api/controller.ts +++ b/src/api/controller.ts @@ -80,6 +80,21 @@ export const getPublicKey: Handler = async (req, res, next) => { } }; +// GET /:vaultId/nonce +export const getAccountNonce: Handler = async (req, res, next) => { + try { + const vaultId = getVaultId(req); + const nonce = await apiService.executeAction( + vaultId, + ActionType.GET_ACCOUNT_NONCE, + {}, + ); + res.json(nonce); + } catch (err) { + next(err); + } +}; + // GET /:vaultId/balance export const getBalance: Handler = async (req, res, next) => { try { @@ -179,6 +194,15 @@ export const createTransaction: Handler = async (req, res, next) => { } } + let fee: number | undefined; + if (req.body.fee !== undefined && req.body.fee !== "") { + fee = Number(req.body.fee); + if (!Number.isFinite(fee) || fee <= 0) { + res.status(400).json({ error: "Bad Request: fee must be a positive number (STX)" }); + return; + } + } + if (!recipientAddress || !amountStr || !assetUi) { res.status(400).json({ error: @@ -224,7 +248,7 @@ export const createTransaction: Handler = async (req, res, next) => { const tx = await apiService.executeAction( vaultId, ActionType.CREATE_NATIVE_TRANSACTION, - { recipientAddress, amount, grossTransaction, note, nonce }, + { recipientAddress, amount, grossTransaction, note, nonce, fee }, ); res.json(tx); return; @@ -623,6 +647,68 @@ export const getPoxInfo: Handler = async (req, res, next) => { }; +// POST /:vaultId/replace-transaction +export const replaceTransaction: Handler = async (req, res, next) => { + try { + const vaultId = getVaultId(req); + + const originalTxId = String(req.body.originalTxId || "").trim(); + const newFeeStr = String(req.body.newFee || ""); + const newRecipient = req.body.newRecipient + ? String(req.body.newRecipient).trim() + : undefined; + const newAmountStr = req.body.newAmount !== undefined + ? String(req.body.newAmount) + : undefined; + + if (!originalTxId || !newFeeStr) { + res.status(400).json({ + error: "Bad Request: originalTxId and newFee are required", + }); + return; + } + + const newFee = Number(newFeeStr); + if (!Number.isFinite(newFee) || newFee <= 0) { + res.status(400).json({ error: "Bad Request: newFee must be a positive number (STX)" }); + return; + } + + let newAmount: number | undefined; + if (newAmountStr !== undefined) { + newAmount = Number(newAmountStr); + if (!Number.isFinite(newAmount) || newAmount <= 0) { + res.status(400).json({ error: "Bad Request: newAmount must be a positive number (STX)" }); + return; + } + } + + let nonceOverride: number | undefined; + if (req.body.nonceOverride !== undefined && req.body.nonceOverride !== "") { + nonceOverride = Number(req.body.nonceOverride); + if (!Number.isInteger(nonceOverride) || nonceOverride < 0) { + res.status(400).json({ error: "Bad Request: nonceOverride must be a non-negative integer" }); + return; + } + if (!newRecipient || newAmount === undefined) { + res.status(400).json({ + error: "Bad Request: newRecipient and newAmount are required when nonceOverride is provided", + }); + return; + } + } + + const tx = await apiService.executeAction( + vaultId, + ActionType.REPLACE_TRANSACTION, + { originalTxId, newFee, newRecipient, newAmount, nonceOverride }, + ); + res.json(tx); + } catch (err) { + next(err); + } +}; + // GET /metrics export const getPoolMetrics: Handler = async (req, res, next) => { try { diff --git a/src/api/router.ts b/src/api/router.ts index 65e8d95..c7cee0e 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -167,6 +167,42 @@ router.get( router.get("/poxInfo", controller.getPoxInfo); +// Nonce +/** + * @openapi + * /{vaultId}/nonce: + * get: + * summary: Get current account nonce + * description: > + * Returns the next expected nonce for this vault's Stacks address, + * derived from the confirmed on-chain state. + * + * **Note**: pending mempool transactions are not reflected. If you have + * unconfirmed transactions in flight, the next safe nonce is this value + * plus the number of pending transactions. + * parameters: + * - $ref: '#/components/parameters/vaultId' + * responses: + * 200: + * description: Nonce fetched successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * nonce: + * type: integer + * description: Next nonce to use for a new transaction. + * example: 5 + * 400: + * description: vaultId missing + * 500: + * description: Internal server error + */ +router.get("/:vaultId/nonce", validateVaultId, controller.getAccountNonce); + // Balance endpoints /** * @openapi @@ -314,6 +350,12 @@ router.get( * the current account nonce from the network (default behavior). * Only set this for advanced use cases such as nonce management or * transaction replacement. + * fee: + * type: number + * description: > + * STX only — optional fee override in STX (e.g. 0.0001). If omitted, + * the SDK estimates the fee automatically. Set a deliberately low value + * to test replace-by-fee flows. * responses: * 200: * description: Transaction created successfully @@ -628,6 +670,70 @@ router.post("/:vaultId/stacking/solo/increase", validateVaultId, controller.incr */ router.post("/:vaultId/stacking/solo/extend", validateVaultId, controller.extendStackingPeriod); +/** + * @openapi + * /{vaultId}/replace-transaction: + * post: + * summary: Replace a stuck pending STX transaction (bump fee) + * description: > + * Replaces a pending native STX token_transfer transaction that is stuck in the mempool + * by submitting a new transaction with the **same nonce** but a higher fee. + * The Stacks node will evict the original transaction in favour of this one. + * + * **Limitations**: + * - Only native STX token_transfer transactions are supported (not FT or contract calls). + * - The new fee must be higher than the original fee or the node may reject the replacement. + * - The original transaction must still be in "pending" status. + * parameters: + * - $ref: '#/components/parameters/vaultId' + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - originalTxId + * - newFee + * properties: + * originalTxId: + * type: string + * description: Transaction ID of the pending transaction to replace. + * newFee: + * type: number + * description: New fee in STX. Must be higher than the original fee. + * newRecipient: + * type: string + * description: > + * Optional new recipient Stacks address. Defaults to the original recipient. + * newAmount: + * type: number + * description: > + * Optional new transfer amount in STX. + * Defaults to the original amount. Required when nonceOverride is set. + * nonceOverride: + * type: integer + * minimum: 0 + * description: > + * Provide the nonce directly to skip the transaction lookup. + * Use this when the original transaction is not visible to the Hiro + * indexer — for example, a future-nonce transaction that was accepted + * by the node but does not appear in the explorer or getTxStatusById. + * When set, newRecipient and newAmount are required. + * responses: + * 200: + * description: Replacement transaction submitted successfully. + * 400: + * description: Invalid input or transaction cannot be replaced. + * 500: + * description: Internal server error + */ +router.post( + "/:vaultId/replace-transaction", + validateVaultId, + controller.replaceTransaction, +); + // Pool metrics /** * @openapi diff --git a/src/pool/types.ts b/src/pool/types.ts index 439efb4..3d3c167 100644 --- a/src/pool/types.ts +++ b/src/pool/types.ts @@ -40,6 +40,8 @@ export enum ActionType { GET_POX_INFO = "getPoxInfo", INCREASE_STACKED_AMOUNT = "increaseStackedAmount", EXTEND_STACKING_PERIOD = "extendStackingPeriod", + REPLACE_TRANSACTION = "replaceTransaction", + GET_ACCOUNT_NONCE = "getAccountNonce", } export interface SdkManagerMetrics { diff --git a/src/services/stacks.service.ts b/src/services/stacks.service.ts index b0b951b..d6b0090 100644 --- a/src/services/stacks.service.ts +++ b/src/services/stacks.service.ts @@ -112,6 +112,32 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra } }; + /** + * Fetches the next expected nonce for a given Stacks address. + * Returns the confirmed on-chain nonce only — pending mempool transactions + * are not reflected. Use this as the starting point for nonce sequencing. + * @param address - The Stacks address to query. + * @returns - The next nonce to use for a new transaction. + */ + public getAccountNonce = async (address: string): Promise => { + try { + const response = await this.axiosClient.get( + `${this.stackBaseUrl}/v2/accounts/${address}?proof=0`, + ); + + if (!response || !response.data || response.status !== 200) { + throw new Error(`HTTP ${response.status}`); + } + + return response.data.nonce as number; + } catch (error) { + console.error(`Error fetching account nonce: ${formatErrorMessage(error)}`); + throw new Error( + `Failed to fetch account nonce for address ${address}: ${formatErrorMessage(error)}`, + ); + } + }; + /** * Makes a call to the Stacks balances endpoint for a given address. * @param address - The Stacks address to query balances for. @@ -353,6 +379,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra customTokenContractName?: string, customTokenAssetName?: string, nonce?: bigint, + fee?: bigint, ): Promise => { try { if (!validateAddress(recipient, this.network === STACKS_TESTNET)) { @@ -421,6 +448,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra postConditionMode: PostConditionMode.Deny, postConditions: [postCondition], ...(nonce !== undefined ? { nonce } : {}), + ...(fee !== undefined ? { fee } : {}), }); } else { unsignedTx = await makeUnsignedSTXTokenTransfer({ @@ -429,6 +457,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra publicKey: senderPublicKey, network: this.network, ...(nonce !== undefined ? { nonce } : {}), + ...(fee !== undefined ? { fee } : {}), }); } @@ -518,6 +547,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra customTokenContractName?: string, customTokenAssetName?: string, nonce?: bigint, + fee?: bigint, ): Promise<{ unsignedTx: StacksTransactionWire; preSignSigHash: string; @@ -548,6 +578,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra customTokenContractName, customTokenAssetName, nonce, + fee, ); const sigHash = unsignedTx.signBegin(); diff --git a/src/services/types.ts b/src/services/types.ts index 80b1d58..6f37435 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -124,8 +124,15 @@ export type PoolInfo = { poolContractName: string; }; +export type GetAccountNonceResponse = { + success: boolean; + nonce?: number; + error?: string; +}; + export type SDKResponse = | GetNativeBalanceResponse | string | CreateTransactionResponse - | GetTransactionHistoryResponse; + | GetTransactionHistoryResponse + | GetAccountNonceResponse; From 46884069cbf1b62123ccff687a6e0a1d5006f5b7 Mon Sep 17 00:00:00 2001 From: Saleem Araidy Date: Wed, 29 Apr 2026 13:43:19 +0300 Subject: [PATCH 04/12] Code review fixed for nonce-management-rbf [FIREHOG-BYPASS] not a secret --- README.md | 47 +++++--- src/StacksSDK.ts | 212 +++++++++++++++++++++------------ src/api/router.ts | 43 ++++--- src/services/stacks.service.ts | 51 ++++++-- src/services/types.ts | 4 +- src/utils/constants.ts | 5 + 6 files changed, 239 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index 4ff2504..95d6e70 100644 --- a/README.md +++ b/README.md @@ -346,35 +346,42 @@ const revokeResponse = await sdk.revokeDelegation(); ### **Nonce Management** ```typescript -// Query the confirmed on-chain nonce for this vault's address. -// Pending mempool transactions are NOT reflected — if you have unconfirmed -// transactions in flight, the next safe nonce is this value plus the number -// of pending transactions. +// Returns confirmed nonce, pending tx count, and the next safe nonce to use. +// nextAvailable is gap-aware: if pending nonces are [5, 6, 9], it returns 7 +// (the first gap) rather than 10, so your tx confirms as soon as possible. const nonceResponse = await sdk.getAccountNonce(); if (nonceResponse.success) { - console.log("Confirmed nonce:", nonceResponse.nonce); + console.log("Confirmed nonce:", nonceResponse.confirmedNonce); + console.log("Pending txs: ", nonceResponse.pendingTxCount); + console.log("Use this nonce: ", nonceResponse.nextAvailable); } ``` -All transaction methods (`createNativeTransaction`, `createFTTransaction`, `delegateToPool`, `allowContractCaller`, `revokeDelegation`, `stackSolo`, `increaseStackedAmount`, `extendStackingPeriod`) accept an optional `nonce?: number` parameter as their last argument. When omitted, the nonce is estimated automatically. +All transaction methods (`createNativeTransaction`, `createFTTransaction`, `delegateToPool`, `allowContractCaller`, `revokeDelegation`, `stackSolo`, `increaseStackedAmount`, `extendStackingPeriod`) accept an optional `nonce?: number` parameter as their last argument. When omitted, the SDK automatically uses `nextAvailable` from `getAccountNonce()` — the same gap-aware value the nonce endpoint returns — so auto-nonce and manual nonce are always consistent. ### **Replace a Stuck Transaction** -If a transaction is stuck in the mempool due to a low fee, you can replace it by submitting a new transaction with the same nonce and a higher fee. +If a transaction is stuck in the mempool due to a low fee, you can replace it by submitting a new transaction with the same nonce and a higher fee. Both native STX transfers and contract calls (PoX operations, etc.) are supported. ```typescript -// Replace a pending transaction visible to the Hiro indexer +// Replace any pending transaction visible to the Hiro indexer. +// The original tx is looked up automatically — same nonce, same args, higher fee. const replacement = await sdk.replaceTransaction( "0xabc123...", // original tx ID - 0.01, // new fee in STX (must be higher than original) - // optionally override recipient/amount: - // "ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG", - // 10.5, + 0.01, // new fee in STX (must be ≥ RBF_MIN_FEE_MULTIPLIER × original fee) ); -// Replace a future-nonce transaction not visible to the Hiro indexer. -// nonceOverride bypasses the indexer lookup — newRecipient and newAmount -// are required in this case. +// For token_transfer only: optionally change recipient or amount +const replacement = await sdk.replaceTransaction( + "0xabc123...", + 0.01, + "ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG", // newRecipient + 10.5, // newAmount in STX +); + +// Replace a future-nonce STX transfer not visible to the Hiro indexer. +// nonceOverride bypasses the indexer lookup. Only STX transfers are supported +// on this path since contract call args cannot be inferred. const replacement = await sdk.replaceTransaction( "0xabc123...", 0.01, @@ -388,7 +395,7 @@ if (replacement.success) { } ``` -> **Note:** Only native STX `token_transfer` transactions can be replaced. The new fee must be higher than the original or the node will reject the replacement. +> The minimum fee bump is controlled by `RBF_MIN_FEE_MULTIPLIER` in `constants.ts` (default `1.25`). The fee check only applies on the lookup path where the original fee is known. ### **Transaction Status Monitoring** @@ -435,7 +442,7 @@ history.forEach((tx) => { | GET | `/api/:vaultId/address` | Fetch the Stacks address associated with the given vault | | GET | `/api/:vaultId/publicKey` | Retrieve the public key for the vault account | | GET | `/api/:vaultId/btc-rewards-address` | Get the BTC rewards address associated with the given vault (for stacking) | -| GET | `/api/:vaultId/nonce` | Get the confirmed on-chain nonce for this vault's Stacks address | +| GET | `/api/:vaultId/nonce` | Get confirmed nonce, pending tx count, and next available nonce (gap-aware) | ### **Balance Endpoints** @@ -590,6 +597,12 @@ curl -X 'POST' \ ```bash curl http://localhost:3000/api/123/nonce +# → { +# "success": true, +# "confirmedNonce": 5, +# "pendingTxCount": 2, +# "nextAvailable": 7 +# } ``` ### **Transfer STX with Nonce Override** diff --git a/src/StacksSDK.ts b/src/StacksSDK.ts index 1a83bfb..6dac6f4 100644 --- a/src/StacksSDK.ts +++ b/src/StacksSDK.ts @@ -36,7 +36,7 @@ import { TransactionDetails, TransactionType, } from "./services/types"; -import { pagination_defaults, POX4_ERRORS} from "./utils/constants"; +import { pagination_defaults, POX4_ERRORS, RBF_MIN_FEE_MULTIPLIER } from "./utils/constants"; import { formatErrorMessage } from "./utils/errorHandling"; import { validateApiCredentials } from "./utils/fireblocks.utils"; import { @@ -55,6 +55,7 @@ import { } from "./utils/helpers"; import { createMessageSignature, + hexToCV, StacksTransactionWire, uintCV, principalCV, @@ -186,11 +187,13 @@ export class StacksSDK { }; /** - * Retrieves the next expected nonce for this vault's Stacks address. + * Returns nonce information for this vault's Stacks address, accounting for + * pending mempool transactions. * - * Returns the confirmed on-chain nonce — pending mempool transactions are not - * reflected. If you have unconfirmed transactions in flight, the actual next - * safe nonce is this value plus the number of pending transactions. + * - confirmedNonce: next nonce per confirmed on-chain state. + * - pendingTxCount: number of this address's transactions in the mempool. + * - nextAvailable: first nonce not already taken by a pending tx (gap-aware). + * Use this value when submitting a new transaction. * * @returns A promise that resolves to a {GetAccountNonceResponse}. */ @@ -199,8 +202,8 @@ export class StacksSDK { throw new Error("Stacks address is not set."); } try { - const nonce = await this.chainService.getAccountNonce(this.address); - return { success: true, nonce }; + const result = await this.chainService.getAccountNonce(this.address); + return { success: true, ...result }; } catch (error) { return { success: false, error: formatErrorMessage(error) }; } @@ -602,6 +605,18 @@ export class StacksSDK { } }; + /** + * Resolves the nonce to use for a transaction. If an explicit nonce is + * provided it is returned as-is. Otherwise the gap-aware nextAvailable + * value from getAccountNonce() is used, keeping our auto-nonce consistent + * with what GET /:vaultId/nonce reports. + */ + private resolveNonce = async (nonce?: bigint): Promise => { + if (nonce !== undefined) return nonce; + const { nextAvailable } = await this.chainService.getAccountNonce(this.address!); + return BigInt(nextAvailable); + }; + /** * Builds, signs, and sends an STX or fungible token transfer transaction. * @param recipientAddress - The address of the recipient. @@ -624,6 +639,7 @@ export class StacksSDK { feeUstx?: bigint, ): Promise => { try { + const resolvedNonce = await this.resolveNonce(nonce); const transactionToSign = await this.chainService.serializeTransaction( this.address, this.publicKey, @@ -634,7 +650,7 @@ export class StacksSDK { customTokenContractAddress, customTokenContractName, customTokenAssetName, - nonce, + resolvedNonce, feeUstx, ); @@ -738,6 +754,8 @@ export class StacksSDK { ); } + const resolvedNonce = await this.resolveNonce(nonce); + let transactionToSign: { unsignedContractCall: StacksTransactionWire; preSignSigHash: string; @@ -749,7 +767,7 @@ export class StacksSDK { this.publicKey, poolAddress, poolContractName!, - nonce, + resolvedNonce, ); break; case "delegate-stx": @@ -758,13 +776,13 @@ export class StacksSDK { poolAddress, amount!, lockPeriod!, - nonce, + resolvedNonce, ); break; case "revoke-delegate-stx": transactionToSign = await this.chainService.revokeStxDelegation( this.publicKey, - nonce, + resolvedNonce, ); break; case "solo-stack": @@ -778,7 +796,7 @@ export class StacksSDK { signerSig65Hex, startBurnHeight, authId, - nonce, + resolvedNonce, ); break; case "increase-stack-amount": @@ -789,7 +807,7 @@ export class StacksSDK { maxAmount!, signerSig65Hex!, authId!, - nonce, + resolvedNonce, ); break; case "extend-stack-period": @@ -801,7 +819,7 @@ export class StacksSDK { maxAmount!, signerSig65Hex!, authId!, - nonce, + resolvedNonce, ); break; default: @@ -1615,15 +1633,14 @@ export class StacksSDK { /** * Replaces a pending STX transaction with a new one using the same nonce but a higher fee. - * This is useful when a transaction is stuck in the mempool due to a low fee. - * Only native STX token_transfer transactions are supported. + * Supports both native STX token_transfer and contract_call transactions. * @param originalTxId - The transaction ID of the transaction to replace. - * @param newFee - The new fee in STX. Must be higher than the original fee. - * @param newRecipient - Optional new recipient address. Defaults to the original recipient. - * @param newAmount - Optional new amount in STX. Defaults to the original amount. - * @param nonceOverride - Provide the nonce directly to skip the transaction lookup. - * Required when the original tx is not visible to the Hiro indexer (e.g. future-nonce - * transactions). When set, newRecipient and newAmount must also be provided. + * @param newFee - The new fee in STX. Must be at least RBF_MIN_FEE_MULTIPLIER × the original. + * @param newRecipient - For token_transfer only: optional new recipient. Defaults to original. + * @param newAmount - For token_transfer only: optional new amount in STX. Defaults to original. + * @param nonceOverride - Bypasses the Hiro indexer lookup. Use when the original tx is a + * future-nonce tx not visible in the explorer. When set, newRecipient and newAmount are + * required (only STX transfers supported on this path). * @returns A promise that resolves to a {CreateTransactionResponse}. */ public replaceTransaction = async ( @@ -1638,12 +1655,11 @@ export class StacksSDK { } try { - let nonce: bigint; - let recipient: string; - let amountUstx: bigint; + const feeBigInt = stxToMicro(newFee); if (nonceOverride !== undefined) { // ── Override path: nonce is known, tx may not be visible to the indexer ── + // Only STX transfers are supported here — no original tx to reconstruct args from. if (!newRecipient || newAmount === undefined) { return { success: false, @@ -1653,80 +1669,118 @@ export class StacksSDK { if (!validateAddress(newRecipient, this.testnet)) { return { success: false, error: "Invalid recipient address" }; } - nonce = BigInt(nonceOverride); - recipient = newRecipient; - amountUstx = stxToMicro(newAmount); - } else { - // ── Lookup path: resolve nonce, recipient and amount from the original tx ── - const originalTxResponse = await this.getTxStatusById(originalTxId); - if (!originalTxResponse.success || !originalTxResponse.data) { - return { success: false, error: "Could not fetch original transaction details" }; - } + const nonce = BigInt(nonceOverride); + const amountUstx = stxToMicro(newAmount); - if (originalTxResponse.data.tx_status !== "pending") { - return { - success: false, - error: `Can only replace pending transactions. Current status: ${originalTxResponse.data.tx_status}`, - }; - } + const transactionToSign = await this.chainService.serializeTransaction( + this.address, this.publicKey, newRecipient, amountUstx, + TransactionType.STX, undefined, undefined, undefined, undefined, + nonce, feeBigInt, + ); - const fullTx = originalTxResponse.data.full_tx_details; + const rawSignature = await this.fireblocksService.signTransaction( + transactionToSign.preSignSigHash, this.vaultAccountId.toString(), + ); + const signature = concatSignature(rawSignature.fullSig, rawSignature.v); + (transactionToSign.unsignedTx as any).auth.spendingCondition.signature = + createMessageSignature(signature); - if (fullTx?.tx_type !== "token_transfer") { - return { - success: false, - error: `Only native STX token_transfer transactions can be replaced. Got: ${fullTx?.tx_type}`, - }; + const result = await this.chainService.broadcastTransaction(transactionToSign.unsignedTx); + if (!result || result.error || !result.txid || result.reason) { + const msg = result?.error && result?.reason + ? `${result.error} - ${result.reason}` + : result?.error || result?.reason || "unknown error"; + return { success: false, error: formatErrorMessage(msg) }; } + console.log(`Replaced transaction ${originalTxId} with ${result.txid}`); + return { success: true, txHash: result.txid }; + } - if (fullTx.sender_address !== this.address) { - return { - success: false, - error: "Transaction sender does not match this vault account address", - }; - } + // ── Lookup path: reconstruct any pending tx type with higher fee ────────── + const originalTxResponse = await this.getTxStatusById(originalTxId); + + if (!originalTxResponse.success || !originalTxResponse.data) { + return { success: false, error: "Could not fetch original transaction details" }; + } - nonce = BigInt(fullTx.nonce); - recipient = newRecipient ?? fullTx.token_transfer.recipient_address; - amountUstx = newAmount !== undefined + if (originalTxResponse.data.tx_status !== "pending") { + return { + success: false, + error: `Can only replace pending transactions. Current status: ${originalTxResponse.data.tx_status}`, + }; + } + + const fullTx = originalTxResponse.data.full_tx_details; + + if (fullTx?.tx_type !== "token_transfer" && fullTx?.tx_type !== "contract_call") { + return { + success: false, + error: `Cannot replace tx of type "${fullTx?.tx_type}". Only token_transfer and contract_call are supported.`, + }; + } + + if (fullTx.sender_address !== this.address) { + return { + success: false, + error: "Transaction sender does not match this vault account address", + }; + } + + // Fee check: new fee must be at least RBF_MIN_FEE_MULTIPLIER × original + const originalFeeUstx = BigInt(fullTx.fee_rate); + const minFeeUstx = (originalFeeUstx * BigInt(Math.round(RBF_MIN_FEE_MULTIPLIER * 100))) / BigInt(100); + if (feeBigInt < minFeeUstx) { + return { + success: false, + error: `New fee (${newFee} STX) must be at least ${RBF_MIN_FEE_MULTIPLIER}x the original fee (${microToStx(originalFeeUstx)} STX). Minimum required: ${microToStx(minFeeUstx)} STX`, + }; + } + + const nonce = BigInt(fullTx.nonce); + let unsignedTxWire: any; + let preSignSigHash: string; + + if (fullTx.tx_type === "token_transfer") { + const recipient = newRecipient ?? fullTx.token_transfer.recipient_address; + const amountUstx = newAmount !== undefined ? stxToMicro(newAmount) : BigInt(fullTx.token_transfer.amount); if (!validateAddress(recipient, this.testnet)) { return { success: false, error: "Invalid recipient address" }; } - } - const feeBigInt = stxToMicro(newFee); + const serialized = await this.chainService.serializeTransaction( + this.address, this.publicKey, recipient, amountUstx, + TransactionType.STX, undefined, undefined, undefined, undefined, + nonce, feeBigInt, + ); + unsignedTxWire = serialized.unsignedTx; + preSignSigHash = serialized.preSignSigHash; + } else { + // contract_call — reconstruct with identical args, same nonce, higher fee + const [contractAddress, contractName] = fullTx.contract_call.contract_id.split("."); + const functionName = fullTx.contract_call.function_name; + const functionArgs = (fullTx.contract_call.function_args as any[]).map( + (arg: { hex: string }) => hexToCV(arg.hex), + ); - // Build and serialize with the nonce + fee baked in before hash computation. - // Passing fee here is critical — serializeTransaction computes preSignSigHash from the - // fee on the spending condition, so the fee must be set before signing, not after. - const transactionToSign = await this.chainService.serializeTransaction( - this.address, - this.publicKey, - recipient, - amountUstx, - TransactionType.STX, - undefined, - undefined, - undefined, - undefined, - nonce, - feeBigInt, - ); + const serialized = await this.chainService.serializeContractCall( + this.publicKey, contractAddress, contractName, functionName, functionArgs, + nonce, feeBigInt, + ); + unsignedTxWire = serialized.unsignedContractCall; + preSignSigHash = serialized.preSignSigHash; + } const rawSignature = await this.fireblocksService.signTransaction( - transactionToSign.preSignSigHash, - this.vaultAccountId.toString(), + preSignSigHash, this.vaultAccountId.toString(), ); - const signature = concatSignature(rawSignature.fullSig, rawSignature.v); - (transactionToSign.unsignedTx as any).auth.spendingCondition.signature = - createMessageSignature(signature); + unsignedTxWire.auth.spendingCondition.signature = createMessageSignature(signature); - const result = await this.chainService.broadcastTransaction(transactionToSign.unsignedTx); + const result = await this.chainService.broadcastTransaction(unsignedTxWire); if (!result || result.error || !result.txid || result.reason) { const errorAndReason = diff --git a/src/api/router.ts b/src/api/router.ts index c7cee0e..f16d8c3 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -172,14 +172,15 @@ router.get( * @openapi * /{vaultId}/nonce: * get: - * summary: Get current account nonce + * summary: Get account nonce * description: > - * Returns the next expected nonce for this vault's Stacks address, - * derived from the confirmed on-chain state. + * Returns nonce information for this vault's Stacks address, accounting + * for pending mempool transactions. * - * **Note**: pending mempool transactions are not reflected. If you have - * unconfirmed transactions in flight, the next safe nonce is this value - * plus the number of pending transactions. + * - **confirmedNonce**: next nonce per confirmed on-chain state. + * - **pendingTxCount**: number of this address's transactions currently in the mempool. + * - **nextAvailable**: first nonce not already taken by a pending tx (gap-aware). + * Use this when submitting a new transaction. * parameters: * - $ref: '#/components/parameters/vaultId' * responses: @@ -192,10 +193,18 @@ router.get( * properties: * success: * type: boolean - * nonce: + * confirmedNonce: * type: integer - * description: Next nonce to use for a new transaction. + * description: Next nonce per confirmed on-chain state. * example: 5 + * pendingTxCount: + * type: integer + * description: Number of pending mempool transactions from this address. + * example: 2 + * nextAvailable: + * type: integer + * description: First gap-free nonce to use for a new transaction. + * example: 7 * 400: * description: vaultId missing * 500: @@ -674,16 +683,20 @@ router.post("/:vaultId/stacking/solo/extend", validateVaultId, controller.extend * @openapi * /{vaultId}/replace-transaction: * post: - * summary: Replace a stuck pending STX transaction (bump fee) + * summary: Replace a stuck pending transaction (bump fee) * description: > - * Replaces a pending native STX token_transfer transaction that is stuck in the mempool - * by submitting a new transaction with the **same nonce** but a higher fee. - * The Stacks node will evict the original transaction in favour of this one. + * Replaces a pending transaction that is stuck in the mempool by submitting a new one + * with the **same nonce** but a higher fee. The Stacks node will evict the original. + * + * Supported transaction types: `token_transfer` and `contract_call`. + * The original transaction is looked up automatically — args are reconstructed from + * the Hiro indexer response, so only the fee (and optionally recipient/amount for + * token_transfer) need to be provided. * * **Limitations**: - * - Only native STX token_transfer transactions are supported (not FT or contract calls). - * - The new fee must be higher than the original fee or the node may reject the replacement. - * - The original transaction must still be in "pending" status. + * - The new fee must be at least `RBF_MIN_FEE_MULTIPLIER` × the original fee (default 1.25×). + * - The original transaction must be in "pending" status (visible to the Hiro indexer). + * - `nonceOverride` path only supports STX token_transfer (contract args cannot be inferred). * parameters: * - $ref: '#/components/parameters/vaultId' * requestBody: diff --git a/src/services/stacks.service.ts b/src/services/stacks.service.ts index d6b0090..c875b05 100644 --- a/src/services/stacks.service.ts +++ b/src/services/stacks.service.ts @@ -20,6 +20,7 @@ import { cvToValue, fetchCallReadOnlyFunction, fetchFeeEstimateTransaction, + hexToCV, makeUnsignedContractCall, makeUnsignedSTXTokenTransfer, noneCV, @@ -113,23 +114,45 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra }; /** - * Fetches the next expected nonce for a given Stacks address. - * Returns the confirmed on-chain nonce only — pending mempool transactions - * are not reflected. Use this as the starting point for nonce sequencing. + * Returns nonce information for the given address, accounting for pending mempool transactions. + * + * - confirmedNonce: the next nonce per on-chain confirmed state. + * - pendingTxCount: number of this address's transactions currently in the mempool. + * - nextAvailable: the first nonce not already taken by a pending tx (gap-aware). + * Use this when submitting a new transaction that should confirm as soon as possible. + * + * Note: if a pending tx is evicted from the mempool (e.g. fee too low), its nonce is freed + * but nextAvailable will remain elevated until the confirmed nonce catches up. + * * @param address - The Stacks address to query. - * @returns - The next nonce to use for a new transaction. */ - public getAccountNonce = async (address: string): Promise => { + public getAccountNonce = async (address: string): Promise<{ + confirmedNonce: number; + pendingTxCount: number; + nextAvailable: number; + }> => { try { - const response = await this.axiosClient.get( - `${this.stackBaseUrl}/v2/accounts/${address}?proof=0`, - ); + const [nonceResponse, mempoolResponse] = await Promise.all([ + this.axiosClient.get(`${this.stackBaseUrl}/v2/accounts/${address}?proof=0`), + this.axiosClient.get( + `${this.stackBaseUrl}/extended/v1/tx/mempool?sender_address=${address}&limit=50`, + ), + ]); + + if (!nonceResponse?.data || nonceResponse.status !== 200) { + throw new Error(`HTTP ${nonceResponse.status}`); + } - if (!response || !response.data || response.status !== 200) { - throw new Error(`HTTP ${response.status}`); + const confirmedNonce = nonceResponse.data.nonce as number; + const pending: any[] = mempoolResponse.data?.results ?? []; + const pendingNonces = new Set(pending.map((tx: any) => tx.nonce as number)); + + let nextAvailable = confirmedNonce; + while (pendingNonces.has(nextAvailable)) { + nextAvailable++; } - return response.data.nonce as number; + return { confirmedNonce, pendingTxCount: pending.length, nextAvailable }; } catch (error) { console.error(`Error fetching account nonce: ${formatErrorMessage(error)}`); throw new Error( @@ -617,6 +640,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra functionName: string, functionArgs: ClarityValue[], nonce?: bigint, + fee?: bigint, ): Promise<{ unsignedContractCall: StacksTransactionWire; preSignSigHash: string; @@ -630,6 +654,11 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra functionArgs, nonce, ); + + if (fee !== undefined) { + (unsignedContractCall as any).auth.spendingCondition.fee = fee; + } + const sigHash = unsignedContractCall.signBegin(); const preSignSigHash = sigHashPreSign( diff --git a/src/services/types.ts b/src/services/types.ts index 6f37435..570663e 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -126,7 +126,9 @@ export type PoolInfo = { export type GetAccountNonceResponse = { success: boolean; - nonce?: number; + confirmedNonce?: number; + pendingTxCount?: number; + nextAvailable?: number; error?: string; }; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f0a671e..b5d0677 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -20,6 +20,11 @@ export const helperConstants = { stacks_api_max_limit: 200, // Maximum limit accepted from callers; service paginates internally when limit > stacks_api_page_size } +// Minimum fee multiplier for replace-by-fee (RBF) transactions. +// The new fee must be at least this multiple of the original fee. +// Applied only on the lookup path (when the original tx is visible to the indexer). +export const RBF_MIN_FEE_MULTIPLIER = 1.25; + export const api_constants = { stacks_mainnet_rpc: "https://api.hiro.so", stacks_testnet_rpc: "https://api.testnet.hiro.so", From 16d04f6f1a316d25a5d12f737512486457c2c240 Mon Sep 17 00:00:00 2001 From: Saleem Araidy Date: Wed, 29 Apr 2026 13:47:58 +0300 Subject: [PATCH 05/12] Remove unused import --- src/services/stacks.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/services/stacks.service.ts b/src/services/stacks.service.ts index c875b05..6394556 100644 --- a/src/services/stacks.service.ts +++ b/src/services/stacks.service.ts @@ -20,7 +20,6 @@ import { cvToValue, fetchCallReadOnlyFunction, fetchFeeEstimateTransaction, - hexToCV, makeUnsignedContractCall, makeUnsignedSTXTokenTransfer, noneCV, From 3dc6f10755c323bd81df59d65a6b30926b893eed Mon Sep 17 00:00:00 2001 From: Saleem Araidy Date: Wed, 29 Apr 2026 14:59:04 +0300 Subject: [PATCH 06/12] Fixed nonce type, used bigInt instead of number --- src/StacksSDK.ts | 38 ++++++++++---------- src/api/controller.ts | 66 ++++++++++++++++++++-------------- src/services/stacks.service.ts | 8 ++--- src/services/types.ts | 4 +-- 4 files changed, 64 insertions(+), 52 deletions(-) diff --git a/src/StacksSDK.ts b/src/StacksSDK.ts index 6dac6f4..ac9a8fa 100644 --- a/src/StacksSDK.ts +++ b/src/StacksSDK.ts @@ -614,7 +614,7 @@ export class StacksSDK { private resolveNonce = async (nonce?: bigint): Promise => { if (nonce !== undefined) return nonce; const { nextAvailable } = await this.chainService.getAccountNonce(this.address!); - return BigInt(nextAvailable); + return nextAvailable; }; /** @@ -866,7 +866,7 @@ export class StacksSDK { amount: number, grossTransaction: boolean = false, note?: string, - nonce?: number, + nonce?: bigint, fee?: number, ): Promise => { if (!this.address || !this.publicKey || !this.vaultAccountId) { @@ -899,7 +899,7 @@ export class StacksSDK { undefined, // customTokenContractName undefined, // customTokenAssetName note, - nonce !== undefined ? BigInt(nonce) : undefined, + nonce, fee !== undefined ? stxToMicro(fee) : undefined, ); @@ -948,7 +948,7 @@ export class StacksSDK { customTokenContractName?: string, customTokenAssetName?: string, note?: string, - nonce?: number, + nonce?: bigint, ): Promise => { if (!this.address || !this.publicKey || !this.vaultAccountId) { throw new Error("Address, Public Key or Vault ID are not set"); @@ -996,7 +996,7 @@ export class StacksSDK { customTokenContractName, customTokenAssetName, note, - nonce !== undefined ? BigInt(nonce) : undefined, + nonce, ); if (!result || result.error || !result.txid || result.reason) { @@ -1039,7 +1039,7 @@ export class StacksSDK { poolContractName: string, amount: number, lockPeriod: number, - nonce?: number, + nonce?: bigint, ): Promise => { if (this.testnet) { console.log(`[WARNING] delegateToPool is not supported on testnet.`); @@ -1087,7 +1087,7 @@ export class StacksSDK { undefined, undefined, undefined, - nonce !== undefined ? BigInt(nonce) : undefined, + nonce, ); const assertDelegateResult = assertResultSuccess(delegateResult); @@ -1125,7 +1125,7 @@ export class StacksSDK { public allowContractCaller = async ( poolsAddress: string, poolContractName: string, - nonce?: number, + nonce?: bigint, ): Promise => { if (this.testnet) { console.log(`[WARNING] allowContractCaller is not supported on testnet.`); @@ -1158,7 +1158,7 @@ export class StacksSDK { undefined, undefined, undefined, - nonce !== undefined ? BigInt(nonce) : undefined, + nonce, ); const assertAllowCallerResult = assertResultSuccess(allowCallerResult); @@ -1194,7 +1194,7 @@ export class StacksSDK { * @throws {Error} If the address, public key, or vault ID are not set, or if the process fails. */ - public revokeDelegation = async (nonce?: number): Promise => { + public revokeDelegation = async (nonce?: bigint): Promise => { if (this.testnet) { console.log(`[WARNING] revokeDelegation is not supported on testnet.`); return { @@ -1224,7 +1224,7 @@ export class StacksSDK { undefined, undefined, undefined, - nonce !== undefined ? BigInt(nonce) : undefined, + nonce, ); const assertDelegateResult = assertResultSuccess(revokeResult); @@ -1419,7 +1419,7 @@ export class StacksSDK { maxAmount: number, lockPeriod: number, authId: bigint, - nonce?: number, + nonce?: bigint, ): Promise => { try { if (!this.address || !this.publicKey || !this.vaultAccountId) { @@ -1454,7 +1454,7 @@ export class StacksSDK { startBurnHeight, authId, undefined, - nonce !== undefined ? BigInt(nonce) : undefined, + nonce, ); const assertResult = assertResultSuccess(result); @@ -1503,7 +1503,7 @@ export class StacksSDK { increaseBy: number, maxAmount: number, authId: bigint, - nonce?: number, + nonce?: bigint, ): Promise => { try { if (!this.address || !this.publicKey || !this.vaultAccountId) { @@ -1525,7 +1525,7 @@ export class StacksSDK { undefined, authId, undefined, - nonce !== undefined ? BigInt(nonce) : undefined, + nonce, ); const assertResult = assertResultSuccess(result); @@ -1574,7 +1574,7 @@ export class StacksSDK { extendCycles: number, maxAmount: number, authId: bigint, - nonce?: number, + nonce?: bigint, ): Promise => { try { if (!this.address || !this.publicKey || !this.vaultAccountId) { @@ -1596,7 +1596,7 @@ export class StacksSDK { undefined, authId, undefined, - nonce !== undefined ? BigInt(nonce) : undefined, + nonce, ); const assertResult = assertResultSuccess(result); @@ -1648,7 +1648,7 @@ export class StacksSDK { newFee: number, newRecipient?: string, newAmount?: number, - nonceOverride?: number, + nonceOverride?: bigint, ): Promise => { if (!this.address || !this.publicKey || !this.vaultAccountId) { throw new Error("Address, Public Key or Vault ID are not set"); @@ -1670,7 +1670,7 @@ export class StacksSDK { return { success: false, error: "Invalid recipient address" }; } - const nonce = BigInt(nonceOverride); + const nonce = nonceOverride; const amountUstx = stxToMicro(newAmount); const transactionToSign = await this.chainService.serializeTransaction( diff --git a/src/api/controller.ts b/src/api/controller.ts index f987185..d335689 100644 --- a/src/api/controller.ts +++ b/src/api/controller.ts @@ -84,12 +84,16 @@ export const getPublicKey: Handler = async (req, res, next) => { export const getAccountNonce: Handler = async (req, res, next) => { try { const vaultId = getVaultId(req); - const nonce = await apiService.executeAction( + const result = await apiService.executeAction( vaultId, ActionType.GET_ACCOUNT_NONCE, {}, - ); - res.json(nonce); + ) as any; + res.json({ + ...result, + ...(result.confirmedNonce !== undefined && { confirmedNonce: result.confirmedNonce.toString() }), + ...(result.nextAvailable !== undefined && { nextAvailable: result.nextAvailable.toString() }), + }); } catch (err) { next(err); } @@ -185,13 +189,14 @@ export const createTransaction: Handler = async (req, res, next) => { ? String(req.body.tokenAssetName).trim() : undefined; - let nonce: number | undefined; + let nonce: bigint | undefined; if (req.body.nonce !== undefined && req.body.nonce !== "") { - nonce = Number(req.body.nonce); - if (!Number.isInteger(nonce) || nonce < 0) { + const nonceNum = Number(req.body.nonce); + if (!Number.isInteger(nonceNum) || nonceNum < 0) { res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); return; } + nonce = BigInt(nonceNum); } let fee: number | undefined; @@ -305,13 +310,14 @@ export const delegateToPool: Handler = async (req, res, next) => { return; } - let nonce: number | undefined; + let nonce: bigint | undefined; if (req.body.nonce !== undefined && req.body.nonce !== "") { - nonce = Number(req.body.nonce); - if (!Number.isInteger(nonce) || nonce < 0) { + const nonceNum = Number(req.body.nonce); + if (!Number.isInteger(nonceNum) || nonceNum < 0) { res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); return; } + nonce = BigInt(nonceNum); } // Map UI label -> Pool Type (enum value) @@ -353,13 +359,14 @@ export const allowContractCaller: Handler = async (req, res, next) => { return; } - let nonce: number | undefined; + let nonce: bigint | undefined; if (req.body.nonce !== undefined && req.body.nonce !== "") { - nonce = Number(req.body.nonce); - if (!Number.isInteger(nonce) || nonce < 0) { + const nonceNum = Number(req.body.nonce); + if (!Number.isInteger(nonceNum) || nonceNum < 0) { res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); return; } + nonce = BigInt(nonceNum); } // Map UI label -> Pool Type (enum value) @@ -392,13 +399,14 @@ export const revokeDelegation: Handler = async (req, res, next) => { try { const vaultId = getVaultId(req); - let nonce: number | undefined; + let nonce: bigint | undefined; if (req.body?.nonce !== undefined && req.body.nonce !== "") { - nonce = Number(req.body.nonce); - if (!Number.isInteger(nonce) || nonce < 0) { + const nonceNum = Number(req.body.nonce); + if (!Number.isInteger(nonceNum) || nonceNum < 0) { res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); return; } + nonce = BigInt(nonceNum); } const tx = await apiService.executeAction( @@ -481,13 +489,14 @@ export const stackSolo: Handler = async (req, res, next) => { } const maxAmount = Number(maxAmountStr); - let nonce: number | undefined; + let nonce: bigint | undefined; if (req.body.nonce !== undefined && req.body.nonce !== "") { - nonce = Number(req.body.nonce); - if (!Number.isInteger(nonce) || nonce < 0) { + const nonceNum = Number(req.body.nonce); + if (!Number.isInteger(nonceNum) || nonceNum < 0) { res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); return; } + nonce = BigInt(nonceNum); } const tx = await apiService.executeAction(vaultId, ActionType.STACK_SOLO, { @@ -546,13 +555,14 @@ export const increaseStackedAmount: Handler = async (req, res, next) => { } const maxAmount = BigInt(maxAmountStr); - let nonce: number | undefined; + let nonce: bigint | undefined; if (req.body.nonce !== undefined && req.body.nonce !== "") { - nonce = Number(req.body.nonce); - if (!Number.isInteger(nonce) || nonce < 0) { + const nonceNum = Number(req.body.nonce); + if (!Number.isInteger(nonceNum) || nonceNum < 0) { res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); return; } + nonce = BigInt(nonceNum); } const tx = await apiService.executeAction(vaultId, ActionType.INCREASE_STACKED_AMOUNT, { @@ -612,13 +622,14 @@ export const extendStackingPeriod: Handler = async (req, res, next) => { } const maxAmount = Number(maxAmountStr); - let nonce: number | undefined; + let nonce: bigint | undefined; if (req.body.nonce !== undefined && req.body.nonce !== "") { - nonce = Number(req.body.nonce); - if (!Number.isInteger(nonce) || nonce < 0) { + const nonceNum = Number(req.body.nonce); + if (!Number.isInteger(nonceNum) || nonceNum < 0) { res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); return; } + nonce = BigInt(nonceNum); } const tx = await apiService.executeAction(vaultId, ActionType.EXTEND_STACKING_PERIOD, { @@ -683,13 +694,14 @@ export const replaceTransaction: Handler = async (req, res, next) => { } } - let nonceOverride: number | undefined; + let nonceOverride: bigint | undefined; if (req.body.nonceOverride !== undefined && req.body.nonceOverride !== "") { - nonceOverride = Number(req.body.nonceOverride); - if (!Number.isInteger(nonceOverride) || nonceOverride < 0) { + const nonceOverrideNum = Number(req.body.nonceOverride); + if (!Number.isInteger(nonceOverrideNum) || nonceOverrideNum < 0) { res.status(400).json({ error: "Bad Request: nonceOverride must be a non-negative integer" }); return; } + nonceOverride = BigInt(nonceOverrideNum); if (!newRecipient || newAmount === undefined) { res.status(400).json({ error: "Bad Request: newRecipient and newAmount are required when nonceOverride is provided", diff --git a/src/services/stacks.service.ts b/src/services/stacks.service.ts index 6394556..5a3685e 100644 --- a/src/services/stacks.service.ts +++ b/src/services/stacks.service.ts @@ -126,9 +126,9 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra * @param address - The Stacks address to query. */ public getAccountNonce = async (address: string): Promise<{ - confirmedNonce: number; + confirmedNonce: bigint; pendingTxCount: number; - nextAvailable: number; + nextAvailable: bigint; }> => { try { const [nonceResponse, mempoolResponse] = await Promise.all([ @@ -142,9 +142,9 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra throw new Error(`HTTP ${nonceResponse.status}`); } - const confirmedNonce = nonceResponse.data.nonce as number; + const confirmedNonce = BigInt(nonceResponse.data.nonce); const pending: any[] = mempoolResponse.data?.results ?? []; - const pendingNonces = new Set(pending.map((tx: any) => tx.nonce as number)); + const pendingNonces = new Set(pending.map((tx: any) => BigInt(tx.nonce))); let nextAvailable = confirmedNonce; while (pendingNonces.has(nextAvailable)) { diff --git a/src/services/types.ts b/src/services/types.ts index 570663e..79763a1 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -126,9 +126,9 @@ export type PoolInfo = { export type GetAccountNonceResponse = { success: boolean; - confirmedNonce?: number; + confirmedNonce?: bigint; pendingTxCount?: number; - nextAvailable?: number; + nextAvailable?: bigint; error?: string; }; From c8ce3395927201cd87363645ff1cc81cafacfd04 Mon Sep 17 00:00:00 2001 From: Saleem Araidy Date: Wed, 29 Apr 2026 15:38:27 +0300 Subject: [PATCH 07/12] refactor: replace positional params with options object in buildSignSendContractCall --- src/StacksSDK.ts | 242 ++++++++++++++--------------------------------- 1 file changed, 70 insertions(+), 172 deletions(-) diff --git a/src/StacksSDK.ts b/src/StacksSDK.ts index ac9a8fa..feded32 100644 --- a/src/StacksSDK.ts +++ b/src/StacksSDK.ts @@ -678,80 +678,58 @@ export class StacksSDK { } }; - /** - * Builds, signs, and sends the delegate-stx or allow-contract-caller contract calls. - * @param poolAddress - The address of the stacking pool. - * @param functionName - The contract function name to call ("delegate-stx" or "allow-contract-caller"). - * @param poolContractName - The contract name of the stacking pool. - * @param amount - The amount of STX to delegate in micro units. - * @param lockPeriod - The lock period in cycles. - * @returns - A promise that resolves to the transaction broadcast result. - */ - private buildSignSendContractCall = async ( + private buildSignSendContractCall = async (options: { functionName: | "delegate-stx" | "allow-contract-caller" | "revoke-delegate-stx" | "solo-stack" | "increase-stack-amount" - | "extend-stack-period", - poolAddress?: string, - poolContractName?: string, - amount?: bigint, - maxAmount?: bigint, - lockPeriod?: number, - extendCycles?: number, - signerKey?: string, - signerSig65Hex?: string, - startBurnHeight?: number, - authId?: bigint, - note?: string, - nonce?: bigint, - ): Promise => { + | "extend-stack-period"; + poolAddress?: string; + poolContractName?: string; + amount?: bigint; + maxAmount?: bigint; + lockPeriod?: number; + extendCycles?: number; + signerKey?: string; + signerSig65Hex?: string; + startBurnHeight?: number; + authId?: bigint; + note?: string; + nonce?: bigint; + }): Promise => { + const { + functionName, poolAddress, poolContractName, amount, maxAmount, + lockPeriod, extendCycles, signerKey, signerSig65Hex, startBurnHeight, + authId, note, nonce, + } = options; + try { - if ( - functionName === "allow-contract-caller" && - (!poolContractName || !poolAddress) - ) { - throw new Error( - "Pool contract name and address must be provided for allow-contract-caller", - ); + if (functionName === "allow-contract-caller" && (!poolContractName || !poolAddress)) { + throw new Error("Pool contract name and address must be provided for allow-contract-caller"); } - if ( - functionName === "delegate-stx" && - (!amount || !lockPeriod || !poolAddress) - ) { - throw new Error( - "Amount, lock period, and pool address must be provided for delegate-stx", - ); + if (functionName === "delegate-stx" && (!amount || !lockPeriod || !poolAddress)) { + throw new Error("Amount, lock period, and pool address must be provided for delegate-stx"); } - if ( - functionName === "solo-stack" && + if (functionName === "solo-stack" && (!amount || !lockPeriod || !signerSig65Hex || !startBurnHeight || !signerKey || maxAmount == null || authId == null) ) { - throw new Error( - "Amount, lock period, signer signature, start burn height, signer key, max amount, and auth ID must be provided for solo-stack", - ); + throw new Error("Amount, lock period, signer signature, start burn height, signer key, max amount, and auth ID must be provided for solo-stack"); } - if ( - functionName === "increase-stack-amount" && + if (functionName === "increase-stack-amount" && (!amount || !signerSig65Hex || !signerKey || authId == null || maxAmount == null) ) { - throw new Error( - "Amount, signer signature, signer key, auth ID and max amount must be provided for increase-stack-amount", - ); + throw new Error("Amount, signer signature, signer key, auth ID and max amount must be provided for increase-stack-amount"); } - if ( - functionName === "extend-stack-period" && + if (functionName === "extend-stack-period" && (!extendCycles || !signerSig65Hex || !signerKey || authId == null || maxAmount == null) ) { - throw new Error( - "Extend cycles, signer signature, signer key, auth ID and max amount must be provided for extend-stack-period", - ); + throw new Error("Extend cycles, signer signature, signer key, auth ID and max amount must be provided for extend-stack-period"); } const resolvedNonce = await this.resolveNonce(nonce); @@ -764,62 +742,34 @@ export class StacksSDK { switch (functionName) { case "allow-contract-caller": transactionToSign = await this.chainService.allowPoxContractCaller( - this.publicKey, - poolAddress, - poolContractName!, - resolvedNonce, + this.publicKey, poolAddress, poolContractName!, resolvedNonce, ); break; case "delegate-stx": transactionToSign = await this.chainService.delegateStx( - this.publicKey, - poolAddress, - amount!, - lockPeriod!, - resolvedNonce, + this.publicKey, poolAddress, amount!, lockPeriod!, resolvedNonce, ); break; case "revoke-delegate-stx": transactionToSign = await this.chainService.revokeStxDelegation( - this.publicKey, - resolvedNonce, + this.publicKey, resolvedNonce, ); break; case "solo-stack": transactionToSign = await this.chainService.soloStack( - this.publicKey, - signerKey, - amount, - this.btcRewardsAddress, - lockPeriod, - maxAmount, - signerSig65Hex, - startBurnHeight, - authId, - resolvedNonce, + this.publicKey, signerKey, amount, this.btcRewardsAddress, + lockPeriod, maxAmount, signerSig65Hex, startBurnHeight, authId, resolvedNonce, ); break; case "increase-stack-amount": transactionToSign = await this.chainService.increaseStackedStx( - this.publicKey, - signerKey!, - amount!, - maxAmount!, - signerSig65Hex!, - authId!, - resolvedNonce, + this.publicKey, signerKey!, amount!, maxAmount!, signerSig65Hex!, authId!, resolvedNonce, ); break; case "extend-stack-period": transactionToSign = await this.chainService.extendStackingPeriod( - this.publicKey, - signerKey!, - this.btcRewardsAddress!, - extendCycles!, - maxAmount!, - signerSig65Hex!, - authId!, - resolvedNonce, + this.publicKey, signerKey!, this.btcRewardsAddress!, extendCycles!, + maxAmount!, signerSig65Hex!, authId!, resolvedNonce, ); break; default: @@ -827,26 +777,17 @@ export class StacksSDK { } const rawSignature = await this.fireblocksService.signTransaction( - transactionToSign.preSignSigHash, - this.vaultAccountId.toString(), - note || "", + transactionToSign.preSignSigHash, this.vaultAccountId.toString(), note || "", ); const signature = concatSignature(rawSignature.fullSig, rawSignature.v); + (transactionToSign.unsignedContractCall as any).auth.spendingCondition.signature = + createMessageSignature(signature); - ( - transactionToSign.unsignedContractCall as any - ).auth.spendingCondition.signature = createMessageSignature(signature); - - const result = await this.chainService.broadcastTransaction( - transactionToSign.unsignedContractCall, - ); - return result; + return await this.chainService.broadcastTransaction(transactionToSign.unsignedContractCall); } catch (error) { throw new Error( - `Failed to build, sign or send contract call transaction: ${formatErrorMessage( - error, - )}`, + `Failed to build, sign or send contract call transaction: ${formatErrorMessage(error)}`, ); } }; @@ -1074,21 +1015,14 @@ export class StacksSDK { ); // Delegate STX to pool address - const delegateResult = await this.buildSignSendContractCall( - "delegate-stx", - poolsAddress, + const delegateResult = await this.buildSignSendContractCall({ + functionName: "delegate-stx", + poolAddress: poolsAddress, poolContractName, - stxToMicro(amount), - undefined, + amount: stxToMicro(amount), lockPeriod, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, nonce, - ); + }); const assertDelegateResult = assertResultSuccess(delegateResult); if (assertDelegateResult.success === false) { @@ -1145,21 +1079,12 @@ export class StacksSDK { try { // Allow contract caller - const allowCallerResult = await this.buildSignSendContractCall( - "allow-contract-caller", - poolsAddress, + const allowCallerResult = await this.buildSignSendContractCall({ + functionName: "allow-contract-caller", + poolAddress: poolsAddress, poolContractName, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, nonce, - ); + }); const assertAllowCallerResult = assertResultSuccess(allowCallerResult); if (assertAllowCallerResult.success === false) { @@ -1211,21 +1136,10 @@ export class StacksSDK { try { // Revoke any existing delegations. - const revokeResult = await this.buildSignSendContractCall( - "revoke-delegate-stx", - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, + const revokeResult = await this.buildSignSendContractCall({ + functionName: "revoke-delegate-stx", nonce, - ); + }); const assertDelegateResult = assertResultSuccess(revokeResult); if (assertDelegateResult.success === false) { @@ -1441,21 +1355,17 @@ export class StacksSDK { const startBurnHeight = pox.current_burnchain_block_height; - const result = await this.buildSignSendContractCall( - "solo-stack", - undefined, - undefined, - stxToMicro(amount), - stxToMicro(maxAmount), + const result = await this.buildSignSendContractCall({ + functionName: "solo-stack", + amount: stxToMicro(amount), + maxAmount: stxToMicro(maxAmount), lockPeriod, - undefined, signerKey, signerSig65Hex, startBurnHeight, authId, - undefined, nonce, - ); + }); const assertResult = assertResultSuccess(result); if (assertResult.success === false) { @@ -1512,21 +1422,15 @@ export class StacksSDK { console.log(`Increasing stacked amount by ${increaseBy} STX`); - const result = await this.buildSignSendContractCall( - "increase-stack-amount", - undefined, - undefined, - stxToMicro(increaseBy), - stxToMicro(maxAmount), - undefined, - undefined, + const result = await this.buildSignSendContractCall({ + functionName: "increase-stack-amount", + amount: stxToMicro(increaseBy), + maxAmount: stxToMicro(maxAmount), signerKey, signerSig65Hex, - undefined, authId, - undefined, nonce, - ); + }); const assertResult = assertResultSuccess(result); if (assertResult.success === false) { @@ -1583,21 +1487,15 @@ export class StacksSDK { console.log(`Extending stacking period by ${extendCycles} cycles`); - const result = await this.buildSignSendContractCall( - "extend-stack-period", - undefined, - undefined, - undefined, - stxToMicro(maxAmount), - undefined, + const result = await this.buildSignSendContractCall({ + functionName: "extend-stack-period", + maxAmount: stxToMicro(maxAmount), extendCycles, signerKey, signerSig65Hex, - undefined, authId, - undefined, nonce, - ); + }); const assertResult = assertResultSuccess(result); if (assertResult.success === false) { From e1d8bdb0d0013129d1e7c41b2a7a2487cf3ba752 Mon Sep 17 00:00:00 2001 From: Saleem Araidy Date: Wed, 29 Apr 2026 16:16:08 +0300 Subject: [PATCH 08/12] Added nonce and fee validation as a util function to avoid code duplication --- src/api/controller.ts | 112 +++++----------------------------------- src/server.ts | 12 ++++- src/utils/validation.ts | 24 +++++++++ 3 files changed, 49 insertions(+), 99 deletions(-) create mode 100644 src/utils/validation.ts diff --git a/src/api/controller.ts b/src/api/controller.ts index d335689..75cdd16 100644 --- a/src/api/controller.ts +++ b/src/api/controller.ts @@ -4,6 +4,7 @@ import { ActionType } from "../pool/types"; import { StackingPools, TokenType } from "../services/types"; import { validateAmount } from "../utils/helpers"; import { helperConstants, poolInfo } from "../utils/constants"; +import { parseOptionalFee, parseOptionalNonce } from "../utils/validation"; const apiService = apiServiceSingleton; @@ -189,24 +190,8 @@ export const createTransaction: Handler = async (req, res, next) => { ? String(req.body.tokenAssetName).trim() : undefined; - let nonce: bigint | undefined; - if (req.body.nonce !== undefined && req.body.nonce !== "") { - const nonceNum = Number(req.body.nonce); - if (!Number.isInteger(nonceNum) || nonceNum < 0) { - res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); - return; - } - nonce = BigInt(nonceNum); - } - - let fee: number | undefined; - if (req.body.fee !== undefined && req.body.fee !== "") { - fee = Number(req.body.fee); - if (!Number.isFinite(fee) || fee <= 0) { - res.status(400).json({ error: "Bad Request: fee must be a positive number (STX)" }); - return; - } - } + const nonce = parseOptionalNonce(req.body.nonce); + const fee = parseOptionalFee(req.body.fee); if (!recipientAddress || !amountStr || !assetUi) { res.status(400).json({ @@ -310,15 +295,7 @@ export const delegateToPool: Handler = async (req, res, next) => { return; } - let nonce: bigint | undefined; - if (req.body.nonce !== undefined && req.body.nonce !== "") { - const nonceNum = Number(req.body.nonce); - if (!Number.isInteger(nonceNum) || nonceNum < 0) { - res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); - return; - } - nonce = BigInt(nonceNum); - } + const nonce = parseOptionalNonce(req.body.nonce); // Map UI label -> Pool Type (enum value) const poolSelectionMap: Record = { @@ -359,15 +336,7 @@ export const allowContractCaller: Handler = async (req, res, next) => { return; } - let nonce: bigint | undefined; - if (req.body.nonce !== undefined && req.body.nonce !== "") { - const nonceNum = Number(req.body.nonce); - if (!Number.isInteger(nonceNum) || nonceNum < 0) { - res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); - return; - } - nonce = BigInt(nonceNum); - } + const nonce = parseOptionalNonce(req.body.nonce); // Map UI label -> Pool Type (enum value) const poolSelectionMap: Record = { @@ -399,15 +368,7 @@ export const revokeDelegation: Handler = async (req, res, next) => { try { const vaultId = getVaultId(req); - let nonce: bigint | undefined; - if (req.body?.nonce !== undefined && req.body.nonce !== "") { - const nonceNum = Number(req.body.nonce); - if (!Number.isInteger(nonceNum) || nonceNum < 0) { - res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); - return; - } - nonce = BigInt(nonceNum); - } + const nonce = parseOptionalNonce(req.body?.nonce); const tx = await apiService.executeAction( vaultId, @@ -489,15 +450,7 @@ export const stackSolo: Handler = async (req, res, next) => { } const maxAmount = Number(maxAmountStr); - let nonce: bigint | undefined; - if (req.body.nonce !== undefined && req.body.nonce !== "") { - const nonceNum = Number(req.body.nonce); - if (!Number.isInteger(nonceNum) || nonceNum < 0) { - res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); - return; - } - nonce = BigInt(nonceNum); - } + const nonce = parseOptionalNonce(req.body.nonce); const tx = await apiService.executeAction(vaultId, ActionType.STACK_SOLO, { signerKey, @@ -555,15 +508,7 @@ export const increaseStackedAmount: Handler = async (req, res, next) => { } const maxAmount = BigInt(maxAmountStr); - let nonce: bigint | undefined; - if (req.body.nonce !== undefined && req.body.nonce !== "") { - const nonceNum = Number(req.body.nonce); - if (!Number.isInteger(nonceNum) || nonceNum < 0) { - res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); - return; - } - nonce = BigInt(nonceNum); - } + const nonce = parseOptionalNonce(req.body.nonce); const tx = await apiService.executeAction(vaultId, ActionType.INCREASE_STACKED_AMOUNT, { signerKey, @@ -622,15 +567,7 @@ export const extendStackingPeriod: Handler = async (req, res, next) => { } const maxAmount = Number(maxAmountStr); - let nonce: bigint | undefined; - if (req.body.nonce !== undefined && req.body.nonce !== "") { - const nonceNum = Number(req.body.nonce); - if (!Number.isInteger(nonceNum) || nonceNum < 0) { - res.status(400).json({ error: "Bad Request: nonce must be a non-negative integer" }); - return; - } - nonce = BigInt(nonceNum); - } + const nonce = parseOptionalNonce(req.body.nonce); const tx = await apiService.executeAction(vaultId, ActionType.EXTEND_STACKING_PERIOD, { signerKey, @@ -664,44 +601,23 @@ export const replaceTransaction: Handler = async (req, res, next) => { const vaultId = getVaultId(req); const originalTxId = String(req.body.originalTxId || "").trim(); - const newFeeStr = String(req.body.newFee || ""); const newRecipient = req.body.newRecipient ? String(req.body.newRecipient).trim() : undefined; - const newAmountStr = req.body.newAmount !== undefined - ? String(req.body.newAmount) - : undefined; - if (!originalTxId || !newFeeStr) { + if (!originalTxId || req.body.newFee === undefined || req.body.newFee === "") { res.status(400).json({ error: "Bad Request: originalTxId and newFee are required", }); return; } - const newFee = Number(newFeeStr); - if (!Number.isFinite(newFee) || newFee <= 0) { - res.status(400).json({ error: "Bad Request: newFee must be a positive number (STX)" }); - return; - } + const newFee = parseOptionalFee(req.body.newFee)!; - let newAmount: number | undefined; - if (newAmountStr !== undefined) { - newAmount = Number(newAmountStr); - if (!Number.isFinite(newAmount) || newAmount <= 0) { - res.status(400).json({ error: "Bad Request: newAmount must be a positive number (STX)" }); - return; - } - } + const newAmount = parseOptionalFee(req.body.newAmount); + const nonceOverride = parseOptionalNonce(req.body.nonceOverride); - let nonceOverride: bigint | undefined; - if (req.body.nonceOverride !== undefined && req.body.nonceOverride !== "") { - const nonceOverrideNum = Number(req.body.nonceOverride); - if (!Number.isInteger(nonceOverrideNum) || nonceOverrideNum < 0) { - res.status(400).json({ error: "Bad Request: nonceOverride must be a non-negative integer" }); - return; - } - nonceOverride = BigInt(nonceOverrideNum); + if (nonceOverride !== undefined) { if (!newRecipient || newAmount === undefined) { res.status(400).json({ error: "Bad Request: newRecipient and newAmount are required when nonceOverride is provided", diff --git a/src/server.ts b/src/server.ts index de3d2ec..3bf4e62 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,8 +1,9 @@ -import express from "express"; +import express, { Request, Response, NextFunction } from "express"; import cors from "cors"; import dotenv from "dotenv"; import router from "./api/router"; import { swaggerUi, specs } from "./utils/swagger"; +import { ValidationError } from "./utils/validation"; // Load environment variables dotenv.config(); @@ -22,6 +23,15 @@ app.get("/api-docs-json", (req, res) => { // Apply routes app.use("/api", router); +// Validation error middleware — must be registered after routes +app.use((err: Error, _req: Request, res: Response, next: NextFunction) => { + if (err instanceof ValidationError) { + res.status(400).json({ error: `Bad Request: ${err.message}` }); + return; + } + next(err); +}); + // Start the server only if this file is run directly (not imported) const PORT = process.env.PORT || 3000; diff --git a/src/utils/validation.ts b/src/utils/validation.ts new file mode 100644 index 0000000..51847a4 --- /dev/null +++ b/src/utils/validation.ts @@ -0,0 +1,24 @@ +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } +} + +export function parseOptionalNonce(value: unknown): bigint | undefined { + if (value === undefined || value === "") return undefined; + const num = Number(value); + if (!Number.isInteger(num) || num < 0) { + throw new ValidationError("nonce must be a non-negative integer"); + } + return BigInt(num); +} + +export function parseOptionalFee(value: unknown): number | undefined { + if (value === undefined || value === "") return undefined; + const fee = Number(value); + if (!Number.isFinite(fee) || fee <= 0) { + throw new ValidationError("fee must be a positive number (STX)"); + } + return fee; +} From 550620139817049726bcaf4dd363bb3a5737eac1 Mon Sep 17 00:00:00 2001 From: Saleem Araidy Date: Thu, 30 Apr 2026 12:56:18 +0300 Subject: [PATCH 09/12] Added more explicit method JSDocs for parameters types + added safety check for maximum allowed fee --- src/StacksSDK.ts | 39 ++++++++++++++++++++++++--------------- src/utils/constants.ts | 3 +++ src/utils/validation.ts | 5 +++++ 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/StacksSDK.ts b/src/StacksSDK.ts index feded32..de29a1b 100644 --- a/src/StacksSDK.ts +++ b/src/StacksSDK.ts @@ -795,9 +795,11 @@ export class StacksSDK { /** * Creates a native coin transaction to transfer funds to a recipient address. * @param recipientAddress - The address of the recipient. - * @param amount - The amount to transfer in native coin. + * @param amount - Amount to transfer in STX (number, e.g. 1.5 for 1.5 STX). Converted to microSTX internally. * @param grossTransaction - Optional flag indicating if the transaction is gross, if so fee will be deducted from recipient (default is false). * @param note - Optional note to be attached to the transaction in raw signing. + * @param nonce - Optional nonce override (bigint). Defaults to next available gap-aware nonce. + * @param fee - Optional fee in STX (number). Defaults to network estimate. * @returns A promise that resolves to a {CreateTransactionResponse}. * @throws {Error} If the address, public key, or vault ID are not set, or if the transaction creation fails. */ @@ -874,9 +876,10 @@ export class StacksSDK { /** * Creates a fungible token transaction to transfer funds to a recipient address. * @param recipientAddress - The address of the recipient. - * @param amount - The amount to transfer in native coin. + * @param amount - Amount to transfer in STX (number). Converted to microSTX internally. * @param token - The type of fungible token to transfer. * @param note - Optional note to be attached to the transaction in raw signing. + * @param nonce - Optional nonce override (bigint). Defaults to next available gap-aware nonce. * @returns A promise that resolves to a {CreateTransactionResponse}. * @throws {Error} If the address, public key, or vault ID are not set, or if the transaction creation fails. */ @@ -969,8 +972,9 @@ export class StacksSDK { * Delegate STX to a stacking pool. * @param poolsAddress - The address of the stacking pool. * @param poolContractName - The contract name of the stacking pool. - * @param amount - The amount of STX to stack. + * @param amount - Amount of STX to delegate (number). Converted to microSTX internally. * @param lockPeriod - The lock period in cycles. + * @param nonce - Optional nonce override (bigint). Defaults to next available gap-aware nonce. * @returns A promise that resolves to a {CreateTransactionResponse}. * @throws {Error} If the address, public key, or vault ID are not set, or if the delegate process fails. */ @@ -1052,6 +1056,7 @@ export class StacksSDK { * Allows a stacking pool to lock delegated STX on behalf of the delegator. * @param poolsAddress - The address of the stacking pool. * @param poolContractName - The contract name of the stacking pool. + * @param nonce - Optional nonce override (bigint). Defaults to next available gap-aware nonce. * @returns A promise that resolves to a {CreateTransactionResponse}. * @throws {Error} If the address, public key, or vault ID are not set, or if the process fails. */ @@ -1115,6 +1120,7 @@ export class StacksSDK { /** * Revoke any STX delegation to any address for this account. + * @param nonce - Optional nonce override (bigint). Defaults to next available gap-aware nonce. * @returns A promise that resolves to a {CreateTransactionResponse}. * @throws {Error} If the address, public key, or vault ID are not set, or if the process fails. */ @@ -1320,10 +1326,11 @@ export class StacksSDK { * Solo stacks a specified amount of STX for a given lock period. * @param signerKey - The signer's compressed public key (hex). * @param signerSig65Hex - 65-byte signer signature (hex). - * @param amount - The amount of STX to stack. - * @param maxAmount - The maximum authorized amount of STX to stack (must be >= amount). + * @param amount - Amount of STX to stack (number). Converted to microSTX internally. + * @param maxAmount - Maximum authorized STX amount, must be >= amount (number). Converted to microSTX internally. * @param lockPeriod - The number of cycles to lock the STX. - * @param authId - Authorization ID for the transaction. + * @param authId - Authorization ID for the transaction (bigint). + * @param nonce - Optional nonce override (bigint). Defaults to next available gap-aware nonce. * @returns A response indicating success or failure of the transaction. */ public stackSolo = async ( @@ -1402,9 +1409,10 @@ export class StacksSDK { * Increases the stacked amount of an existing solo stacking position. * @param signerKey - The signer's compressed public key (hex). * @param signerSig65Hex - 65-byte signer signature (hex). - * @param increaseBy - The amount of STX to add to the existing stack. - * @param maxAmount - The new maximum amount of the stack after increase. - * @param authId - Authorization ID for the transaction. + * @param increaseBy - Amount of STX to add to the existing stack (number). Converted to microSTX internally. + * @param maxAmount - New maximum authorized STX amount after increase (number). Converted to microSTX internally. + * @param authId - Authorization ID for the transaction (bigint). + * @param nonce - Optional nonce override (bigint). Defaults to next available gap-aware nonce. * @returns A response indicating success or failure of the transaction. */ public increaseStackedAmount = async ( @@ -1467,9 +1475,10 @@ export class StacksSDK { * Extends the stacking period of an existing solo stacking position. * @param signerKey - The signer's compressed public key (hex). * @param signerSig65Hex - 65-byte signer signature (hex). - * @param increaseBy - The amount of STX to add to the existing stack. - * @param maxAmount - Maximum amount authorized for the stack - * @param authId - Authorization ID for the transaction. + * @param increaseBy - Number of additional cycles to extend the stacking period. + * @param maxAmount - Maximum authorized STX amount for the extension (number). Converted to microSTX internally. + * @param authId - Authorization ID for the transaction (bigint). + * @param nonce - Optional nonce override (bigint). Defaults to next available gap-aware nonce. * @returns A response indicating success or failure of the transaction. */ public extendStackingPeriod = async ( @@ -1536,9 +1545,9 @@ export class StacksSDK { * @param newFee - The new fee in STX. Must be at least RBF_MIN_FEE_MULTIPLIER × the original. * @param newRecipient - For token_transfer only: optional new recipient. Defaults to original. * @param newAmount - For token_transfer only: optional new amount in STX. Defaults to original. - * @param nonceOverride - Bypasses the Hiro indexer lookup. Use when the original tx is a - * future-nonce tx not visible in the explorer. When set, newRecipient and newAmount are - * required (only STX transfers supported on this path). + * @param nonceOverride - Optional nonce override (bigint). Bypasses the Hiro indexer lookup. + * Use when the original tx is a future-nonce tx not visible in the explorer. When set, + * newRecipient and newAmount are required (only STX transfers supported on this path). * @returns A promise that resolves to a {CreateTransactionResponse}. */ public replaceTransaction = async ( diff --git a/src/utils/constants.ts b/src/utils/constants.ts index b5d0677..3724e0f 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -25,6 +25,9 @@ export const helperConstants = { // Applied only on the lookup path (when the original tx is visible to the indexer). export const RBF_MIN_FEE_MULTIPLIER = 1.25; +// Maximum fee accepted by the SDK in STX. Guards against typos (e.g. 100 instead of 0.001). +export const MAX_FEE_STX = 10; + export const api_constants = { stacks_mainnet_rpc: "https://api.hiro.so", stacks_testnet_rpc: "https://api.testnet.hiro.so", diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 51847a4..7461618 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -1,3 +1,5 @@ +import { MAX_FEE_STX } from "./constants"; + export class ValidationError extends Error { constructor(message: string) { super(message); @@ -20,5 +22,8 @@ export function parseOptionalFee(value: unknown): number | undefined { if (!Number.isFinite(fee) || fee <= 0) { throw new ValidationError("fee must be a positive number (STX)"); } + if (fee > MAX_FEE_STX) { + throw new ValidationError(`fee ${fee} STX exceeds the safety limit of ${MAX_FEE_STX} STX`); + } return fee; } From 767ef494f257a7403709968a8d0ede1e35ff68ee Mon Sep 17 00:00:00 2001 From: Saleem Araidy Date: Thu, 30 Apr 2026 13:04:54 +0300 Subject: [PATCH 10/12] Added documentation about skipping tx ownership when nonceOverride is provided in replaceTransaction method --- src/StacksSDK.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/StacksSDK.ts b/src/StacksSDK.ts index de29a1b..a36f98c 100644 --- a/src/StacksSDK.ts +++ b/src/StacksSDK.ts @@ -1545,8 +1545,9 @@ export class StacksSDK { * @param newFee - The new fee in STX. Must be at least RBF_MIN_FEE_MULTIPLIER × the original. * @param newRecipient - For token_transfer only: optional new recipient. Defaults to original. * @param newAmount - For token_transfer only: optional new amount in STX. Defaults to original. - * @param nonceOverride - Optional nonce override (bigint). Bypasses the Hiro indexer lookup. - * Use when the original tx is a future-nonce tx not visible in the explorer. When set, + * @param nonceOverride - Optional nonce override (bigint). Bypasses the Hiro indexer lookup + * and skips ownership validation of the original transaction. Use only when you are certain + * of the nonce value and the original tx is not visible in the explorer. When set, * newRecipient and newAmount are required (only STX transfers supported on this path). * @returns A promise that resolves to a {CreateTransactionResponse}. */ From 607e6db309973b50407271c57e01b1b3c4d54fd4 Mon Sep 17 00:00:00 2001 From: Saleem Araidy Date: Thu, 30 Apr 2026 13:18:17 +0300 Subject: [PATCH 11/12] Added balance check to replaceTransaction --- src/StacksSDK.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/StacksSDK.ts b/src/StacksSDK.ts index a36f98c..8d953a6 100644 --- a/src/StacksSDK.ts +++ b/src/StacksSDK.ts @@ -1581,6 +1581,17 @@ export class StacksSDK { const nonce = nonceOverride; const amountUstx = stxToMicro(newAmount); + const balance = await this.getBalance(); + if (balance.success) { + const totalRequired = microToStx(amountUstx + feeBigInt); + if (balance.balance !== undefined && totalRequired > balance.balance) { + return { + success: false, + error: `Insufficient balance. Required: ${totalRequired} STX, Available: ${balance.balance} STX`, + }; + } + } + const transactionToSign = await this.chainService.serializeTransaction( this.address, this.publicKey, newRecipient, amountUstx, TransactionType.STX, undefined, undefined, undefined, undefined, @@ -1659,6 +1670,17 @@ export class StacksSDK { return { success: false, error: "Invalid recipient address" }; } + const balanceCheck = await this.getBalance(); + if (balanceCheck.success) { + const totalRequired = microToStx(amountUstx + feeBigInt); + if (balanceCheck.balance !== undefined && totalRequired > balanceCheck.balance) { + return { + success: false, + error: `Insufficient balance. Required: ${totalRequired} STX, Available: ${balanceCheck.balance} STX`, + }; + } + } + const serialized = await this.chainService.serializeTransaction( this.address, this.publicKey, recipient, amountUstx, TransactionType.STX, undefined, undefined, undefined, undefined, @@ -1674,6 +1696,17 @@ export class StacksSDK { (arg: { hex: string }) => hexToCV(arg.hex), ); + const balanceCheck = await this.getBalance(); + if (balanceCheck.success) { + const feeStx = microToStx(feeBigInt); + if (balanceCheck.balance !== undefined && feeStx > balanceCheck.balance) { + return { + success: false, + error: `Insufficient balance for fee. Required: ${feeStx} STX, Available: ${balanceCheck.balance} STX`, + }; + } + } + const serialized = await this.chainService.serializeContractCall( this.publicKey, contractAddress, contractName, functionName, functionArgs, nonce, feeBigInt, From 2e4b7149fca339bb37611c265e9f0e145f071f0d Mon Sep 17 00:00:00 2001 From: Saleem Araidy Date: Thu, 30 Apr 2026 13:29:24 +0300 Subject: [PATCH 12/12] fix: restore 404 handling in E2E tx confirmation polling --- src/__tests__/e2e/stx-transfer.e2e.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/__tests__/e2e/stx-transfer.e2e.test.ts b/src/__tests__/e2e/stx-transfer.e2e.test.ts index 1405e55..533781b 100644 --- a/src/__tests__/e2e/stx-transfer.e2e.test.ts +++ b/src/__tests__/e2e/stx-transfer.e2e.test.ts @@ -57,11 +57,22 @@ describeE2E("E2E: STX Transfer", () => { txId: string, timeoutMs: number = TX_CONFIRMATION_TIMEOUT ): Promise => { + // Initial delay to allow tx to be indexed + console.log(`Waiting for transaction ${txId} to be indexed...`); + await new Promise((resolve) => setTimeout(resolve, 10000)); + const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { const status = await sdk.getTxStatusById(txId); + // Handle 404 (tx not yet indexed) as pending + if (status.error?.includes("404")) { + console.log(`Transaction ${txId} not yet indexed, waiting...`); + await new Promise((resolve) => setTimeout(resolve, TX_POLL_INTERVAL)); + continue; + } + if (!status.success) { throw new Error(`Failed to get tx status: ${status.error}`); }