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/README.md b/README.md index f9029c8..95d6e70 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,60 @@ const allowCallerResponse = await sdk.allowContractCaller( const revokeResponse = await sdk.revokeDelegation(); ``` +### **Nonce Management** + +```typescript +// 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.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 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. Both native STX transfers and contract calls (PoX operations, etc.) are supported. + +```typescript +// 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 ≥ RBF_MIN_FEE_MULTIPLIER × original fee) +); + +// 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, + "ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG", // newRecipient (required) + 10.5, // newAmount in STX (required) + 7, // nonceOverride +); + +if (replacement.success) { + console.log("Replacement tx hash:", replacement.txHash); +} +``` + +> 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** ```typescript @@ -376,6 +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 confirmed nonce, pending tx count, and next available nonce (gap-aware) | ### **Balance Endpoints** @@ -386,22 +453,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 +593,57 @@ 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 +# → { +# "success": true, +# "confirmedNonce": 5, +# "pendingTxCount": 2, +# "nextAvailable": 7 +# } +``` + +### **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 dd019af..8d953a6 100644 --- a/src/StacksSDK.ts +++ b/src/StacksSDK.ts @@ -25,6 +25,7 @@ import { CheckStatusResponse, CreateTransactionResponse, FireblocksConfig, + GetAccountNonceResponse, GetFtBalancesResponse, GetNativeBalanceResponse, GetPoxInfoResponse, @@ -35,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 { @@ -54,6 +55,7 @@ import { } from "./utils/helpers"; import { createMessageSignature, + hexToCV, StacksTransactionWire, uintCV, principalCV, @@ -184,6 +186,29 @@ export class StacksSDK { } }; + /** + * Returns nonce information for this vault's Stacks address, accounting for + * pending mempool 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}. + */ + public getAccountNonce = async (): Promise => { + if (!this.address) { + throw new Error("Stacks address is not set."); + } + try { + const result = await this.chainService.getAccountNonce(this.address); + return { success: true, ...result }; + } catch (error) { + return { success: false, error: formatErrorMessage(error) }; + } + }; + /** * Retrieves the status of a transaction by its ID. * @param txId - The transaction ID. @@ -580,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 nextAvailable; + }; + /** * Builds, signs, and sends an STX or fungible token transfer transaction. * @param recipientAddress - The address of the recipient. @@ -598,8 +635,11 @@ export class StacksSDK { customTokenContractName?: string, customTokenAssetName?: string, note?: string, + nonce?: bigint, + feeUstx?: bigint, ): Promise => { try { + const resolvedNonce = await this.resolveNonce(nonce); const transactionToSign = await this.chainService.serializeTransaction( this.address, this.publicKey, @@ -610,6 +650,8 @@ export class StacksSDK { customTokenContractAddress, customTokenContractName, customTokenAssetName, + resolvedNonce, + feeUstx, ); const rawSignature = await this.fireblocksService.signTransaction( @@ -636,81 +678,62 @@ 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, - ): 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); + let transactionToSign: { unsignedContractCall: StacksTransactionWire; preSignSigHash: string; @@ -719,56 +742,34 @@ export class StacksSDK { switch (functionName) { case "allow-contract-caller": transactionToSign = await this.chainService.allowPoxContractCaller( - this.publicKey, - poolAddress, - poolContractName!, + this.publicKey, poolAddress, poolContractName!, resolvedNonce, ); break; case "delegate-stx": transactionToSign = await this.chainService.delegateStx( - this.publicKey, - poolAddress, - amount!, - lockPeriod!, + this.publicKey, poolAddress, amount!, lockPeriod!, resolvedNonce, ); break; case "revoke-delegate-stx": transactionToSign = await this.chainService.revokeStxDelegation( - this.publicKey, + this.publicKey, resolvedNonce, ); break; case "solo-stack": transactionToSign = await this.chainService.soloStack( - this.publicKey, - signerKey, - amount, - this.btcRewardsAddress, - lockPeriod, - maxAmount, // maxAmount ( >= amount ) - signerSig65Hex, - startBurnHeight, - authId, + 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!, + 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!, + this.publicKey, signerKey!, this.btcRewardsAddress!, extendCycles!, + maxAmount!, signerSig65Hex!, authId!, resolvedNonce, ); break; default: @@ -776,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)}`, ); } }; @@ -803,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. */ @@ -815,6 +809,8 @@ export class StacksSDK { amount: number, grossTransaction: boolean = false, note?: string, + nonce?: bigint, + fee?: number, ): Promise => { if (!this.address || !this.publicKey || !this.vaultAccountId) { throw new Error("Address, Public Key or Vault ID are not set"); @@ -846,6 +842,8 @@ export class StacksSDK { undefined, // customTokenContractName undefined, // customTokenAssetName note, + nonce, + fee !== undefined ? stxToMicro(fee) : undefined, ); if (!result || result.error || !result.txid || result.reason) { @@ -878,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. */ @@ -893,6 +892,7 @@ export class StacksSDK { customTokenContractName?: string, customTokenAssetName?: string, note?: string, + nonce?: bigint, ): Promise => { if (!this.address || !this.publicKey || !this.vaultAccountId) { throw new Error("Address, Public Key or Vault ID are not set"); @@ -940,6 +940,7 @@ export class StacksSDK { customTokenContractName, customTokenAssetName, note, + nonce, ); if (!result || result.error || !result.txid || result.reason) { @@ -971,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. */ @@ -981,7 +983,8 @@ export class StacksSDK { poolsAddress: string, poolContractName: string, amount: number, - lockPeriod: number, // Number of cycles + lockPeriod: number, + nonce?: bigint, ): Promise => { if (this.testnet) { console.log(`[WARNING] delegateToPool is not supported on testnet.`); @@ -1016,14 +1019,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, - ); + nonce, + }); const assertDelegateResult = assertResultSuccess(delegateResult); if (assertDelegateResult.success === false) { @@ -1053,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. */ @@ -1060,6 +1064,7 @@ export class StacksSDK { public allowContractCaller = async ( poolsAddress: string, poolContractName: string, + nonce?: bigint, ): Promise => { if (this.testnet) { console.log(`[WARNING] allowContractCaller is not supported on testnet.`); @@ -1079,11 +1084,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, - ); + nonce, + }); const assertAllowCallerResult = assertResultSuccess(allowCallerResult); if (assertAllowCallerResult.success === false) { @@ -1114,11 +1120,12 @@ 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. */ - public revokeDelegation = async (): Promise => { + public revokeDelegation = async (nonce?: bigint): Promise => { if (this.testnet) { console.log(`[WARNING] revokeDelegation is not supported on testnet.`); return { @@ -1135,9 +1142,10 @@ export class StacksSDK { try { // Revoke any existing delegations. - const revokeResult = await this.buildSignSendContractCall( - "revoke-delegate-stx", - ); + const revokeResult = await this.buildSignSendContractCall({ + functionName: "revoke-delegate-stx", + nonce, + }); const assertDelegateResult = assertResultSuccess(revokeResult); if (assertDelegateResult.success === false) { @@ -1318,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 ( @@ -1329,8 +1338,9 @@ export class StacksSDK { signerSig65Hex: string, amount: number, maxAmount: number, - lockPeriod: number, // Number of cycles + lockPeriod: number, authId: bigint, + nonce?: bigint, ): Promise => { try { if (!this.address || !this.publicKey || !this.vaultAccountId) { @@ -1352,19 +1362,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, - ); + nonce, + }); const assertResult = assertResultSuccess(result); if (assertResult.success === false) { @@ -1401,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 ( @@ -1412,6 +1421,7 @@ export class StacksSDK { increaseBy: number, maxAmount: number, authId: bigint, + nonce?: bigint, ): Promise => { try { if (!this.address || !this.publicKey || !this.vaultAccountId) { @@ -1420,19 +1430,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, - ); + nonce, + }); const assertResult = assertResultSuccess(result); if (assertResult.success === false) { @@ -1469,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 ( @@ -1480,6 +1487,7 @@ export class StacksSDK { extendCycles: number, maxAmount: number, authId: bigint, + nonce?: bigint, ): Promise => { try { if (!this.address || !this.publicKey || !this.vaultAccountId) { @@ -1488,19 +1496,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, - ); + nonce, + }); const assertResult = assertResultSuccess(result); if (assertResult.success === false) { @@ -1534,6 +1538,210 @@ export class StacksSDK { }; + /** + * Replaces a pending STX transaction with a new one using the same nonce but a higher fee. + * 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 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 + * 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}. + */ + public replaceTransaction = async ( + originalTxId: string, + newFee: number, + newRecipient?: string, + newAmount?: number, + nonceOverride?: bigint, + ): Promise => { + if (!this.address || !this.publicKey || !this.vaultAccountId) { + throw new Error("Address, Public Key or Vault ID are not set"); + } + + try { + 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, + error: "newRecipient and newAmount are required when nonceOverride is provided", + }; + } + if (!validateAddress(newRecipient, this.testnet)) { + return { success: false, error: "Invalid recipient address" }; + } + + 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, + 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 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 }; + } + + // ── 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" }; + } + + 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 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, + 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), + ); + + 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, + ); + unsignedTxWire = serialized.unsignedContractCall; + preSignSigHash = serialized.preSignSigHash; + } + + const rawSignature = await this.fireblocksService.signTransaction( + preSignSigHash, this.vaultAccountId.toString(), + ); + const signature = concatSignature(rawSignature.fullSig, rawSignature.v); + unsignedTxWire.auth.spendingCondition.signature = createMessageSignature(signature); + + const result = await this.chainService.broadcastTransaction(unsignedTxWire); + + 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/__tests__/e2e/stx-transfer.e2e.test.ts b/src/__tests__/e2e/stx-transfer.e2e.test.ts index 6e28a8b..7690a36 100644 --- a/src/__tests__/e2e/stx-transfer.e2e.test.ts +++ b/src/__tests__/e2e/stx-transfer.e2e.test.ts @@ -57,6 +57,10 @@ 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(); // Initial delay to allow tx to be indexed @@ -67,6 +71,12 @@ describeE2E("E2E: STX Transfer", () => { 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) { if (status.error?.includes("404")) { console.log(`Transaction ${txId} not yet indexed, waiting...`); diff --git a/src/api/api.service.ts b/src/api/api.service.ts index 09825ee..9b99c2a 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,8 @@ export class ApiService { params.amount, params.grossTransaction, params.note, + params.nonce, + params.fee, ); break; case ActionType.CREATE_FT_TRANSACTION: @@ -116,6 +121,7 @@ export class ApiService { params.tokenContractName, params.tokenAssetName, params.note, + params.nonce, ); break; case ActionType.GET_BALANCE: @@ -147,6 +153,7 @@ export class ApiService { params.increaseBy, params.maxAmount, params.authId, + params.nonce, ); break; case ActionType.EXTEND_STACKING_PERIOD: @@ -156,8 +163,21 @@ export class ApiService { params.extendCycles, params.maxAmount, params.authId, + 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 f794f2e..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; @@ -80,6 +81,25 @@ 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 result = await apiService.executeAction( + vaultId, + ActionType.GET_ACCOUNT_NONCE, + {}, + ) as any; + res.json({ + ...result, + ...(result.confirmedNonce !== undefined && { confirmedNonce: result.confirmedNonce.toString() }), + ...(result.nextAvailable !== undefined && { nextAvailable: result.nextAvailable.toString() }), + }); + } catch (err) { + next(err); + } +}; + // GET /:vaultId/balance export const getBalance: Handler = async (req, res, next) => { try { @@ -170,6 +190,9 @@ export const createTransaction: Handler = async (req, res, next) => { ? String(req.body.tokenAssetName).trim() : undefined; + const nonce = parseOptionalNonce(req.body.nonce); + const fee = parseOptionalFee(req.body.fee); + if (!recipientAddress || !amountStr || !assetUi) { res.status(400).json({ error: @@ -215,7 +238,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, fee }, ); res.json(tx); return; @@ -233,6 +256,7 @@ export const createTransaction: Handler = async (req, res, next) => { tokenContractName, tokenAssetName, note, + nonce, }, ); res.json(tx); @@ -271,6 +295,8 @@ export const delegateToPool: Handler = async (req, res, next) => { return; } + const nonce = parseOptionalNonce(req.body.nonce); + // Map UI label -> Pool Type (enum value) const poolSelectionMap: Record = { FAST_POOL: StackingPools.FAST_POOL, @@ -285,11 +311,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 +336,8 @@ export const allowContractCaller: Handler = async (req, res, next) => { return; } + const nonce = parseOptionalNonce(req.body.nonce); + // 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,12 @@ export const revokeDelegation: Handler = async (req, res, next) => { try { const vaultId = getVaultId(req); + const nonce = parseOptionalNonce(req.body?.nonce); + const tx = await apiService.executeAction( vaultId, ActionType.REVOKE_DELEGATION, - {}, + { nonce }, ); res.json(tx); } catch (err) { @@ -422,6 +450,8 @@ export const stackSolo: Handler = async (req, res, next) => { } const maxAmount = Number(maxAmountStr); + const nonce = parseOptionalNonce(req.body.nonce); + const tx = await apiService.executeAction(vaultId, ActionType.STACK_SOLO, { signerKey, signerSig65Hex, @@ -429,6 +459,7 @@ export const stackSolo: Handler = async (req, res, next) => { maxAmount, lockPeriod, authId, + nonce, }); res.json(tx); @@ -477,12 +508,15 @@ export const increaseStackedAmount: Handler = async (req, res, next) => { } const maxAmount = BigInt(maxAmountStr); + const nonce = parseOptionalNonce(req.body.nonce); + const tx = await apiService.executeAction(vaultId, ActionType.INCREASE_STACKED_AMOUNT, { signerKey, signerSig65Hex, increaseBy, maxAmount, authId, + nonce, }); res.json(tx); @@ -533,12 +567,15 @@ export const extendStackingPeriod: Handler = async (req, res, next) => { } const maxAmount = Number(maxAmountStr); + const nonce = parseOptionalNonce(req.body.nonce); + const tx = await apiService.executeAction(vaultId, ActionType.EXTEND_STACKING_PERIOD, { signerKey, signerSig65Hex, extendCycles, maxAmount, authId, + nonce, }); res.json(tx); @@ -558,6 +595,48 @@ 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 newRecipient = req.body.newRecipient + ? String(req.body.newRecipient).trim() + : undefined; + + if (!originalTxId || req.body.newFee === undefined || req.body.newFee === "") { + res.status(400).json({ + error: "Bad Request: originalTxId and newFee are required", + }); + return; + } + + const newFee = parseOptionalFee(req.body.newFee)!; + + const newAmount = parseOptionalFee(req.body.newAmount); + const nonceOverride = parseOptionalNonce(req.body.nonceOverride); + + if (nonceOverride !== undefined) { + 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 feb79bf..f16d8c3 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -167,6 +167,51 @@ router.get( router.get("/poxInfo", controller.getPoxInfo); +// Nonce +/** + * @openapi + * /{vaultId}/nonce: + * get: + * summary: Get account nonce + * description: > + * Returns nonce information for this vault's Stacks address, accounting + * for pending mempool 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: + * 200: + * description: Nonce fetched successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * confirmedNonce: + * type: integer + * 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: + * description: Internal server error + */ +router.get("/:vaultId/nonce", validateVaultId, controller.getAccountNonce); + // Balance endpoints /** * @openapi @@ -306,6 +351,20 @@ 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. + * 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 @@ -354,6 +413,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 +456,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 +485,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 +555,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 +608,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 +663,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 @@ -577,6 +679,74 @@ 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 transaction (bump fee) + * description: > + * 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**: + * - 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: + * 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/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/services/stacks.service.ts b/src/services/stacks.service.ts index 8285a7b..5a3685e 100644 --- a/src/services/stacks.service.ts +++ b/src/services/stacks.service.ts @@ -112,6 +112,54 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra } }; + /** + * 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. + */ + public getAccountNonce = async (address: string): Promise<{ + confirmedNonce: bigint; + pendingTxCount: number; + nextAvailable: bigint; + }> => { + try { + 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}`); + } + + const confirmedNonce = BigInt(nonceResponse.data.nonce); + const pending: any[] = mempoolResponse.data?.results ?? []; + const pendingNonces = new Set(pending.map((tx: any) => BigInt(tx.nonce))); + + let nextAvailable = confirmedNonce; + while (pendingNonces.has(nextAvailable)) { + nextAvailable++; + } + + return { confirmedNonce, pendingTxCount: pending.length, nextAvailable }; + } 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. @@ -352,6 +400,8 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra customTokenContractAddress?: string, customTokenContractName?: string, customTokenAssetName?: string, + nonce?: bigint, + fee?: bigint, ): Promise => { try { if (!validateAddress(recipient, this.network === STACKS_TESTNET)) { @@ -419,6 +469,8 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra network: this.network, postConditionMode: PostConditionMode.Deny, postConditions: [postCondition], + ...(nonce !== undefined ? { nonce } : {}), + ...(fee !== undefined ? { fee } : {}), }); } else { unsignedTx = await makeUnsignedSTXTokenTransfer({ @@ -426,6 +478,8 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra amount, publicKey: senderPublicKey, network: this.network, + ...(nonce !== undefined ? { nonce } : {}), + ...(fee !== undefined ? { fee } : {}), }); } @@ -456,6 +510,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 +533,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra publicKey: senderPublicKey, network: this.network, postConditionMode: PostConditionMode.Deny, + ...(nonce !== undefined ? { nonce } : {}), }); return unsignedContractCall; @@ -512,6 +568,8 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra customTokenContractAddress?: string, customTokenContractName?: string, customTokenAssetName?: string, + nonce?: bigint, + fee?: bigint, ): Promise<{ unsignedTx: StacksTransactionWire; preSignSigHash: string; @@ -541,6 +599,8 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra customTokenContractAddress, customTokenContractName, customTokenAssetName, + nonce, + fee, ); const sigHash = unsignedTx.signBegin(); @@ -578,6 +638,8 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra contractName: string, functionName: string, functionArgs: ClarityValue[], + nonce?: bigint, + fee?: bigint, ): Promise<{ unsignedContractCall: StacksTransactionWire; preSignSigHash: string; @@ -589,7 +651,13 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra contractName, functionName, functionArgs, + nonce, ); + + if (fee !== undefined) { + (unsignedContractCall as any).auth.spendingCondition.fee = fee; + } + const sigHash = unsignedContractCall.signBegin(); const preSignSigHash = sigHashPreSign( @@ -844,7 +912,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 +951,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra someCV(uintCV(until_burn_ht)), noneCV(), ], + nonce, ); return serializedContractCall; @@ -903,6 +973,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra */ public revokeStxDelegation = async ( senderPublicKey: string, + nonce?: bigint, ): Promise<{ unsignedContractCall: StacksTransactionWire; preSignSigHash: string; @@ -920,6 +991,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra poxName, "revoke-delegate-stx", [], + nonce, ); return serializedContractCall; @@ -946,6 +1018,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra senderPublicKey: string, poolAddress: string, poolContractName: string, + nonce?: bigint, ): Promise<{ unsignedContractCall: StacksTransactionWire; preSignSigHash: string; @@ -971,6 +1044,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra poxName, "allow-contract-caller", [contractPrincipalCV(poolAddress, poolContractName), noneCV()], + nonce, ); return serializedContractCall; @@ -1006,6 +1080,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra signerSig65Hex: string, startBurnHeight: number, authId: bigint, + nonce?: bigint, ): Promise<{ unsignedContractCall: StacksTransactionWire; preSignSigHash: string; @@ -1038,6 +1113,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra uintCV(maxAmountUstx), uintCV(authId), ], + nonce, ); return serializedContractCall; @@ -1067,6 +1143,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra maxAmountUstx: bigint, signerSig65Hex: string, authId: bigint, + nonce?: bigint, ): Promise<{ unsignedContractCall: StacksTransactionWire; preSignSigHash: string; @@ -1091,6 +1168,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra uintCV(maxAmountUstx), // max-amount uintCV(authId), // auth-id ], + nonce, ); return serializedContractCall; @@ -1123,6 +1201,7 @@ private getPoxContractInfo = async (): Promise<{ contractAddress: string; contra maxAmountUstx: bigint, signerSig65Hex: string, authId: bigint, + nonce?: bigint, ): Promise<{ unsignedContractCall: StacksTransactionWire; preSignSigHash: string; @@ -1147,12 +1226,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; diff --git a/src/services/types.ts b/src/services/types.ts index 80b1d58..79763a1 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -124,8 +124,17 @@ export type PoolInfo = { poolContractName: string; }; +export type GetAccountNonceResponse = { + success: boolean; + confirmedNonce?: bigint; + pendingTxCount?: number; + nextAvailable?: bigint; + error?: string; +}; + export type SDKResponse = | GetNativeBalanceResponse | string | CreateTransactionResponse - | GetTransactionHistoryResponse; + | GetTransactionHistoryResponse + | GetAccountNonceResponse; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f0a671e..3724e0f 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -20,6 +20,14 @@ 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; + +// 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 new file mode 100644 index 0000000..7461618 --- /dev/null +++ b/src/utils/validation.ts @@ -0,0 +1,29 @@ +import { MAX_FEE_STX } from "./constants"; + +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)"); + } + if (fee > MAX_FEE_STX) { + throw new ValidationError(`fee ${fee} STX exceeds the safety limit of ${MAX_FEE_STX} STX`); + } + return fee; +}