diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 6c1c53becd..c363e8f178 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -854,10 +854,10 @@ async function handleV2Sweep(req: ExpressApiRouteRequest<'express.v2.wallet.swee * handle CPFP accelerate transaction creation * @param req */ -async function handleV2AccelerateTransaction(req: express.Request) { +async function handleV2AccelerateTransaction(req: ExpressApiRouteRequest<'express.v2.wallet.accelerateTx', 'post'>) { const bitgo = req.bitgo; - const coin = bitgo.coin(req.params.coin); - const wallet = await coin.wallets().get({ id: req.params.id }); + const coin = bitgo.coin(req.decoded.coin); + const wallet = await coin.wallets().get({ id: req.decoded.id }); return wallet.accelerateTransaction(createSendParams(req)); } @@ -1682,12 +1682,10 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { router.post('express.v2.wallet.sweep', [prepareBitGo(config), typedPromiseWrapper(handleV2Sweep)]); // CPFP - app.post( - '/api/v2/:coin/wallet/:id/acceleratetx', - parseBody, + router.post('express.v2.wallet.accelerateTx', [ prepareBitGo(config), - promiseWrapper(handleV2AccelerateTransaction) - ); + typedPromiseWrapper(handleV2AccelerateTransaction), + ]); // account-based router.post('express.v2.wallet.consolidateaccount', [ diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index 86df2f5196..bcef0cd387 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -48,6 +48,7 @@ import { PostConsolidateAccount } from './v2/consolidateAccount'; import { PostCanonicalAddress } from './v2/canonicalAddress'; import { PostWalletEnableTokens } from './v2/walletEnableTokens'; import { PostWalletSweep } from './v2/walletSweep'; +import { PostWalletAccelerateTx } from './v2/walletAccelerateTx'; import { PostIsWalletAddress } from './v2/isWalletAddress'; // Too large types can cause the following error @@ -311,6 +312,12 @@ export const ExpressV2WalletSweepApiSpec = apiSpec({ }, }); +export const ExpressV2WalletAccelerateTxApiSpec = apiSpec({ + 'express.v2.wallet.accelerateTx': { + post: PostWalletAccelerateTx, + }, +}); + export type ExpressApi = typeof ExpressPingApiSpec & typeof ExpressPingExpressApiSpec & typeof ExpressLoginApiSpec & @@ -348,6 +355,7 @@ export type ExpressApi = typeof ExpressPingApiSpec & typeof ExpressWalletSigningApiSpec & typeof ExpressV2CanonicalAddressApiSpec & typeof ExpressV2WalletSweepApiSpec & + typeof ExpressV2WalletAccelerateTxApiSpec & typeof ExpressWalletManagementApiSpec; export const ExpressApi: ExpressApi = { @@ -388,6 +396,7 @@ export const ExpressApi: ExpressApi = { ...ExpressWalletSigningApiSpec, ...ExpressV2CanonicalAddressApiSpec, ...ExpressV2WalletSweepApiSpec, + ...ExpressV2WalletAccelerateTxApiSpec, ...ExpressWalletManagementApiSpec, }; diff --git a/modules/express/src/typedRoutes/api/v2/walletAccelerateTx.ts b/modules/express/src/typedRoutes/api/v2/walletAccelerateTx.ts new file mode 100644 index 0000000000..194e99f87d --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/walletAccelerateTx.ts @@ -0,0 +1,433 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BitgoExpressError } from '../../schemas/error'; + +/** + * Path parameters for accelerate transaction endpoint + */ +export const AccelerateTxParams = { + /** Coin identifier (e.g., 'btc', 'tbtc', 'ltc') */ + coin: t.string, + /** Wallet ID */ + id: t.string, +} as const; + +/** + * EIP-1559 fee parameters for Ethereum transactions + * When eip1559 object is present, both fields are REQUIRED + */ +export const EIP1559Params = t.type({ + /** Maximum priority fee per gas (in wei) - REQUIRED */ + maxPriorityFeePerGas: t.union([t.number, t.string]), + /** Maximum fee per gas (in wei) - REQUIRED */ + maxFeePerGas: t.union([t.number, t.string]), +}); + +/** + * Memo object for chains that support memos (e.g., Stellar, XRP) + * When memo object is present, both fields are REQUIRED + */ +export const MemoParams = t.type({ + /** Memo value - REQUIRED */ + value: t.string, + /** Memo type - REQUIRED */ + type: t.string, +}); + +/** + * Request body for accelerating a transaction using CPFP or RBF + * + * IMPORTANT: Must provide EITHER cpfpTxIds OR rbfTxIds (mutually exclusive) + * + * CPFP (Child-Pays-For-Parent) Requirements: + * - cpfpTxIds: Array of length 1 + * - cpfpFeeRate: Required unless noCpfpFeeRate=true + * - maxFee: Required unless noMaxFee=true + * + * RBF (Replace-By-Fee) Requirements: + * - rbfTxIds: Array of length 1 + * - feeMultiplier: Required and must be > 1 + * + * The request body is organized into two sections: + * 1. Fields from AccelerateTransactionOptions (acceleration-specific fields) + * 2. Additional fields from PrebuildAndSignTransactionOptions (transaction building and signing fields) + */ +export const AccelerateTxRequestBody = { + /** Transaction IDs to accelerate using CPFP (Child-Pays-For-Parent). Must be array of length 1. */ + cpfpTxIds: optional(t.array(t.string)), + + /** Transaction IDs to accelerate using RBF (Replace-By-Fee). Must be array of length 1. */ + rbfTxIds: optional(t.array(t.string)), + + /** Fee rate for the CPFP transaction (in satoshis/kB). Required for CPFP unless noCpfpFeeRate=true. */ + cpfpFeeRate: optional(t.number), + + /** If true, allows skipping cpfpFeeRate requirement for CPFP */ + noCpfpFeeRate: optional(t.boolean), + + /** Maximum fee willing to pay (in satoshis). Required for CPFP unless noMaxFee=true. */ + maxFee: optional(t.number), + + /** If true, allows skipping maxFee requirement for CPFP */ + noMaxFee: optional(t.boolean), + + /** Fee multiplier for RBF (must be > 1). Required when using rbfTxIds. */ + feeMultiplier: optional(t.number), + + /** Recipients array (will be set to empty array by SDK for acceleration) */ + recipients: optional( + t.array( + t.type({ + address: t.string, + amount: t.union([t.string, t.number]), + }) + ) + ), + + /** The wallet passphrase to decrypt the user key */ + walletPassphrase: optional(t.string), + + /** The private key (prv) in string form */ + prv: optional(t.string), + + /** Extended private key (alternative to walletPassphrase for certain operations) */ + xprv: optional(t.string), + + /** Array of public keys for signing */ + pubs: optional(t.array(t.string)), + + /** Co-signer public key */ + cosignerPub: optional(t.string), + + /** Flag indicating if this is the last signature */ + isLastSignature: optional(t.boolean), + + /** Pre-built transaction object */ + txPrebuild: optional(t.any), + + /** Multisig type version (e.g., 'MPCv2') */ + multisigTypeVersion: optional(t.literal('MPCv2')), + + /** Transaction request ID (for TSS wallets) */ + txRequestId: optional(t.string), + + /** Transaction verification parameters (used for verifying transaction before signing) */ + verifyTxParams: optional(t.any), + + /** API version to use for TSS transaction requests */ + apiVersion: optional(t.union([t.literal('lite'), t.literal('full')])), + + /** Estimate fees to aim for first confirmation within this number of blocks */ + numBlocks: optional(t.number), + + /** The desired fee rate for the transaction in base units per kilobyte (e.g., satoshis/kB) */ + feeRate: optional(t.number), + + /** The maximum limit for a fee rate in base units per kilobyte */ + maxFeeRate: optional(t.number), + + /** Custom gas price to be used for sending the transaction (for account-based coins) */ + gasPrice: optional(t.number), + + /** Gas limit for the transaction (for account-based coins) */ + gasLimit: optional(t.number), + + /** EIP-1559 fee parameters for Ethereum transactions */ + eip1559: optional(EIP1559Params), + + /** Minimum value of unspents to use (in base units) */ + minValue: optional(t.union([t.number, t.string])), + + /** Maximum value of unspents to use (in base units) */ + maxValue: optional(t.union([t.number, t.string])), + + /** Array of specific unspent IDs to use in the transaction */ + unspents: optional(t.array(t.any)), + + /** Minimum number of confirmations needed for an unspent to be included (defaults to 1) */ + minConfirms: optional(t.number), + + /** If true, minConfirms also applies to change outputs */ + enforceMinConfirmsForChange: optional(t.boolean), + + /** Target number of unspents to maintain in the wallet */ + targetWalletUnspents: optional(t.number), + + /** Set to true to disable automatic change splitting for purposes of unspent management */ + noSplitChange: optional(t.boolean), + + /** Specifies the destination of the change output */ + changeAddress: optional(t.string), + + /** If true, allows using an external change address */ + allowExternalChangeAddress: optional(t.boolean), + + /** Address type for the transaction (e.g., 'p2sh', 'p2wsh') */ + addressType: optional(t.string), + + /** Change address type (e.g., 'p2sh', 'p2wsh') */ + changeAddressType: optional(t.string), + + /** The receive address from which funds will be withdrawn (supported for specific coins like ADA) */ + senderAddress: optional(t.string), + + /** The wallet ID of the sender wallet when different from current wallet (for BTC unstaking) */ + senderWalletId: optional(t.string), + + /** Receive address (for specific coins like ADA) */ + receiveAddress: optional(t.string), + + /** Custom sequence ID for the transaction */ + sequenceId: optional(t.string), + + /** Transaction nonce (for account-based coins) */ + nonce: optional(t.string), + + /** Type of transaction (e.g., 'trustline' for Stellar) */ + type: optional(t.string), + + /** Send this transaction using coin-specific instant sending method (if available) */ + instant: optional(t.boolean), + + /** If true, enables hop transactions for exchanges */ + hop: optional(t.boolean), + + /** If true, only preview the transaction without sending */ + preview: optional(t.boolean), + + /** Transaction format (legacy or psbt) */ + txFormat: optional(t.union([t.literal('legacy'), t.literal('psbt'), t.literal('psbt-lite')])), + + /** If set to false, sweep all funds including required minimums (e.g., DOT requires 1 DOT minimum) */ + keepAlive: optional(t.boolean), + + /** If true, indicates this is a test transaction */ + isTestTransaction: optional(t.boolean), + + /** Memo to use in transaction (supported by Stellar, XRP, etc.) */ + memo: optional(MemoParams), + + /** Messages to be signed with specific addresses */ + messages: optional( + t.array( + t.type({ + address: t.string, + message: t.string, + }) + ) + ), + + /** Absolute max ledger the transaction should be accepted in (for XRP) */ + lastLedgerSequence: optional(t.number), + + /** Relative ledger height (in relation to the current ledger) that the transaction should be accepted in */ + ledgerSequenceDelta: optional(t.number), + + /** Close remainder to address (for specific blockchain protocols like Algorand) */ + closeRemainderTo: optional(t.string), + + /** Non-participation flag (for governance/staking protocols like Algorand) */ + nonParticipation: optional(t.boolean), + + /** Valid from block height */ + validFromBlock: optional(t.number), + + /** Valid to block height */ + validToBlock: optional(t.number), + + /** Token name for token transfers */ + tokenName: optional(t.string), + + /** NFT collection ID (for NFT transfers) */ + nftCollectionId: optional(t.string), + + /** NFT ID (for NFT transfers) */ + nftId: optional(t.string), + + /** Token enablements array (for enabling multiple tokens in a single transaction) */ + enableTokens: optional(t.array(t.any)), + + /** Low fee transaction ID (for CPFP - Child Pays For Parent) */ + lowFeeTxid: optional(t.string), + + /** Custodian transaction ID (for institutional custody integrations) */ + custodianTransactionId: optional(t.string), + + /** Reservation parameters for unspent management */ + reservation: optional( + t.partial({ + expireTime: t.string, + pendingApprovalId: t.string, + }) + ), + + /** Enable offline transaction verification */ + offlineVerification: optional(t.boolean), + + /** Wallet contract address (for smart contract wallets) */ + walletContractAddress: optional(t.string), + + /** IDF (Identity Framework) signed timestamp */ + idfSignedTimestamp: optional(t.string), + + /** IDF user ID */ + idfUserId: optional(t.string), + + /** IDF version number */ + idfVersion: optional(t.number), + + /** Pre-built transaction (hex string or serialized object) */ + prebuildTx: optional(t.union([t.string, t.any])), + + /** Verification options for the transaction */ + verification: optional(t.any), + + /** Request ID for tracing and logging */ + reqId: optional(t.any), + + /** Flag indicating if this is a TSS transaction */ + isTss: optional(t.boolean), + + /** Custom Solana instructions to include in the transaction */ + solInstructions: optional( + t.array( + t.type({ + programId: t.string, + keys: t.array( + t.type({ + pubkey: t.string, + isSigner: t.boolean, + isWritable: t.boolean, + }) + ), + data: t.string, + }) + ) + ), + + /** Solana versioned transaction data for building transactions with Address Lookup Tables */ + solVersionedTransactionData: optional( + t.partial({ + versionedInstructions: t.array( + t.type({ + programIdIndex: t.number, + accountKeyIndexes: t.array(t.number), + data: t.string, + }) + ), + addressLookupTables: t.array( + t.type({ + accountKey: t.string, + writableIndexes: t.array(t.number), + readonlyIndexes: t.array(t.number), + }) + ), + staticAccountKeys: t.array(t.string), + messageHeader: t.type({ + numRequiredSignatures: t.number, + numReadonlySignedAccounts: t.number, + numReadonlyUnsignedAccounts: t.number, + }), + recentBlockhash: t.string, + }) + ), + + /** Custom transaction parameters for Aptos entry function calls */ + aptosCustomTransactionParams: optional( + t.intersection([ + t.type({ + /** Module name - REQUIRED */ + moduleName: t.string, + /** Function name - REQUIRED */ + functionName: t.string, + }), + t.partial({ + /** Type arguments - OPTIONAL */ + typeArguments: t.array(t.string), + /** Function arguments - OPTIONAL */ + functionArguments: t.array(t.any), + /** ABI - OPTIONAL */ + abi: t.any, + }), + ]) + ), + + /** Comment to attach to the transaction */ + comment: optional(t.string), + + /** Message to attach to the transaction */ + message: optional(t.string), + + /** One-time password for 2FA */ + otp: optional(t.string), +} as const; + +/** + * Response for accelerate transaction operation + * + * The response structure varies based on wallet type and coin: + * - Hot/Cold wallets: Transaction details (tx, txid, status, transfer) + * - Custodial wallets: Pending approval or initiation details + * - TSS wallets: May include txRequest, transfer, pendingApproval + * + * Common fields may include: + * - tx: The signed transaction hex + * - txid: The transaction ID + * - status: Transaction status (e.g., 'signed', 'pending') + * - transfer: Transfer object with coin, wallet, and transaction details + * - pendingApproval: Pending approval details if transaction requires approval + */ +export const AccelerateTxResponse = t.unknown; + +/** + * Accelerate Transaction Confirmation (CPFP/RBF) + * + * This endpoint accelerates a stuck or slow transaction using either: + * - **CPFP (Child-Pays-For-Parent)**: Creates a new transaction that spends the output + * of the stuck transaction with a higher fee, incentivizing miners to confirm both. + * - **RBF (Replace-By-Fee)**: Replaces the original transaction with a new one that + * has a higher fee (only works if original transaction was marked as RBF-enabled). + * + * Supported coins: Primarily UTXO-based coins like Bitcoin (btc, tbtc), Litecoin (ltc), etc. + * + * **CPFP Requirements:** + * - cpfpTxIds: Array with exactly one transaction ID + * - cpfpFeeRate: Fee rate in satoshis/kB (required unless noCpfpFeeRate=true) + * - maxFee: Maximum fee in satoshis (required unless noMaxFee=true) + * - walletPassphrase, xprv, or prv: For signing the CPFP transaction + * + * **RBF Requirements:** + * - rbfTxIds: Array with exactly one transaction ID + * - feeMultiplier: Multiplier for the fee (must be > 1, e.g., 1.5 for 50% increase) + * - walletPassphrase, xprv, or prv: For signing the replacement transaction + * + * **Important:** + * - Must specify EITHER cpfpTxIds OR rbfTxIds (not both) + * - Original transaction must be unconfirmed + * - For RBF, original transaction must have been created with RBF enabled + * + * **Behavior:** + * 1. Validates acceleration parameters (CPFP or RBF) + * 2. Builds and signs the acceleration transaction + * 3. Submits the transaction to the blockchain + * 4. Returns transaction details + * + * @operationId express.v2.wallet.accelerateTx + * @tag Express + */ +export const PostWalletAccelerateTx = httpRoute({ + path: '/api/v2/{coin}/wallet/{id}/acceleratetx', + method: 'POST', + request: httpRequest({ + params: AccelerateTxParams, + body: AccelerateTxRequestBody, + }), + response: { + /** Successfully accelerated transaction */ + 200: AccelerateTxResponse, + /** Invalid request parameters or validation failure (e.g., missing required fields, invalid array length) */ + 400: BitgoExpressError, + /** Internal server error, wallet not found, SDK errors, or coin operation failures */ + 500: BitgoExpressError, + }, +}); diff --git a/modules/express/test/unit/typedRoutes/walletAccelerateTx.ts b/modules/express/test/unit/typedRoutes/walletAccelerateTx.ts new file mode 100644 index 0000000000..cb5f2f0c26 --- /dev/null +++ b/modules/express/test/unit/typedRoutes/walletAccelerateTx.ts @@ -0,0 +1,1142 @@ +import * as assert from 'assert'; +import * as t from 'io-ts'; +import { + AccelerateTxParams, + AccelerateTxRequestBody, + AccelerateTxResponse, + PostWalletAccelerateTx, +} from '../../../src/typedRoutes/api/v2/walletAccelerateTx'; +import { assertDecode } from './common'; +import 'should'; +import 'should-http'; +import 'should-sinon'; +import * as sinon from 'sinon'; +import { BitGo } from 'bitgo'; +import { setupAgent } from '../../lib/testutil'; + +describe('WalletAccelerateTx codec tests', function () { + describe('walletAccelerateTx', function () { + const agent = setupAgent(); + const coin = 'tbtc'; + const walletId = '68c02f96aa757d9212bd1a536f123456'; + + const mockAccelerateTransactionResponse = { + tx: '02000000000101abcdef1234567890abcdef1234567890abcdef1234567890abcdef12345678900000000000ffffffff0100e1f505000000001600148888888888888888888888888888888888888888024730440220abcdef1234567890abcdef1234567890abcdef1234567890abcdef12345678900220abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890012102abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789000000000', + txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + status: 'signed', + }; + + afterEach(function () { + sinon.restore(); + }); + + it('should successfully accelerate transaction using CPFP', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: 100000, + walletPassphrase: 'test_passphrase_12345', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().resolves(mockAccelerateTransactionResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + const coinStub = sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + result.body.should.have.property('txid'); + assert.strictEqual(result.body.txid, mockAccelerateTransactionResponse.txid); + + const decodedResponse = assertDecode(AccelerateTxResponse, result.body); + assert.ok(decodedResponse); + + assert.strictEqual(coinStub.calledOnceWith(coin), true); + assert.strictEqual(mockCoin.wallets.calledOnce, true); + assert.strictEqual(walletsGetStub.calledOnceWith({ id: walletId }), true); + assert.strictEqual(mockWallet.accelerateTransaction.calledOnce, true); + }); + + it('should successfully accelerate transaction using CPFP with noCpfpFeeRate', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + noCpfpFeeRate: true, + maxFee: 100000, + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().resolves(mockAccelerateTransactionResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + }); + + it('should successfully accelerate transaction using CPFP with noMaxFee', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + noMaxFee: true, + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().resolves(mockAccelerateTransactionResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + }); + + it('should successfully accelerate transaction using RBF', async function () { + const requestBody = { + rbfTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + feeMultiplier: 1.5, + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().resolves(mockAccelerateTransactionResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + result.body.should.have.property('txid'); + }); + + it('should successfully accelerate transaction with prv instead of walletPassphrase', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: 100000, + prv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().resolves(mockAccelerateTransactionResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + }); + + it('should successfully accelerate transaction with xprv', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: 100000, + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().resolves(mockAccelerateTransactionResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + }); + + it('should successfully accelerate transaction with additional parameters', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: 100000, + walletPassphrase: 'test_passphrase', + feeRate: 45000, + gasPrice: 20000000000, + comment: 'Accelerating slow transaction', + otp: '123456', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().resolves(mockAccelerateTransactionResponse), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + + // Verify all parameters were passed to SDK + assert.strictEqual(mockWallet.accelerateTransaction.calledOnce, true); + const callArgs = mockWallet.accelerateTransaction.firstCall.args[0]; + assert.strictEqual(callArgs.cpfpTxIds[0], requestBody.cpfpTxIds[0]); + assert.strictEqual(callArgs.cpfpFeeRate, requestBody.cpfpFeeRate); + assert.strictEqual(callArgs.maxFee, requestBody.maxFee); + assert.strictEqual(callArgs.comment, requestBody.comment); + }); + + describe('Validation Errors', function () { + it('should reject request without cpfpTxIds or rbfTxIds', async function () { + const requestBody = { + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().rejects(new Error('must pass cpfpTxIds or rbfTxIds')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject request with both cpfpTxIds and rbfTxIds', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + rbfTxIds: ['1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'], + cpfpFeeRate: 50000, + maxFee: 100000, + feeMultiplier: 1.5, + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().rejects(new Error('cannot specify both cpfpTxIds and rbfTxIds')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject CPFP request with empty cpfpTxIds array', async function () { + const requestBody = { + cpfpTxIds: [], + cpfpFeeRate: 50000, + maxFee: 100000, + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().rejects(new Error('expecting cpfpTxIds to be an array of length 1')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject CPFP request with multiple cpfpTxIds', async function () { + const requestBody = { + cpfpTxIds: [ + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + ], + cpfpFeeRate: 50000, + maxFee: 100000, + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().rejects(new Error('expecting cpfpTxIds to be an array of length 1')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject CPFP request without cpfpFeeRate or noCpfpFeeRate', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + maxFee: 100000, + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().rejects(new Error('cpfpFeeRate must be set unless noCpfpFeeRate is set')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject CPFP request without maxFee or noMaxFee', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().rejects(new Error('maxFee must be set unless noMaxFee is set')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject RBF request with empty rbfTxIds array', async function () { + const requestBody = { + rbfTxIds: [], + feeMultiplier: 1.5, + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().rejects(new Error('expecting rbfTxIds to be an array of length 1')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject RBF request without feeMultiplier', async function () { + const requestBody = { + rbfTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().rejects(new Error('feeMultiplier must be set')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject RBF request with feeMultiplier <= 1', async function () { + const requestBody = { + rbfTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + feeMultiplier: 0.5, + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().rejects(new Error('feeMultiplier must be a greater than 1')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + }); + + describe('SDK and System Errors', function () { + it('should handle wallet not found error', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: 100000, + walletPassphrase: 'test_passphrase', + }; + + const walletsGetStub = sinon.stub().rejects(new Error('Wallet not found')); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle invalid passphrase error', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: 100000, + walletPassphrase: 'wrong_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().rejects(new Error('Invalid passphrase')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle transaction not found error', async function () { + const requestBody = { + cpfpTxIds: ['0000000000000000000000000000000000000000000000000000000000000000'], + cpfpFeeRate: 50000, + maxFee: 100000, + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().rejects(new Error('Transaction not found')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle transaction already confirmed error', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: 100000, + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().rejects(new Error('Transaction already confirmed')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle insufficient funds error', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: 100000, + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().rejects(new Error('Insufficient funds')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle RBF not enabled error', async function () { + const requestBody = { + rbfTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + feeMultiplier: 1.5, + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().rejects(new Error('Transaction does not support RBF')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle unsupported coin error', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: 100000, + walletPassphrase: 'test_passphrase', + }; + + sinon.stub(BitGo.prototype, 'coin').throws(new Error('Unsupported coin: eth')); + + const result = await agent + .post(`/api/v2/eth/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + }); + + describe('Invalid Request Body Types', function () { + it('should reject request with invalid cpfpTxIds type', async function () { + const requestBody = { + cpfpTxIds: 'not_an_array', // string instead of array + cpfpFeeRate: 50000, + maxFee: 100000, + walletPassphrase: 'test_passphrase', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject request with invalid rbfTxIds type', async function () { + const requestBody = { + rbfTxIds: 'not_an_array', // string instead of array + feeMultiplier: 1.5, + walletPassphrase: 'test_passphrase', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject request with invalid cpfpFeeRate type', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 'not_a_number', // string instead of number + maxFee: 100000, + walletPassphrase: 'test_passphrase', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject request with invalid maxFee type', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: 'not_a_number', // string instead of number + walletPassphrase: 'test_passphrase', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject request with invalid feeMultiplier type', async function () { + const requestBody = { + rbfTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + feeMultiplier: 'not_a_number', // string instead of number + walletPassphrase: 'test_passphrase', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject request with invalid walletPassphrase type', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: 100000, + walletPassphrase: 123, // number instead of string + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject request with invalid noCpfpFeeRate type', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + noCpfpFeeRate: 'true', // string instead of boolean + maxFee: 100000, + walletPassphrase: 'test_passphrase', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject request with invalid noMaxFee type', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + noMaxFee: 'true', // string instead of boolean + walletPassphrase: 'test_passphrase', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + }); + + describe('Edge Cases', function () { + it('should handle empty body', async function () { + const requestBody = {}; + + const mockWallet = { + accelerateTransaction: sinon.stub().rejects(new Error('must pass cpfpTxIds or rbfTxIds')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should handle very long wallet ID', async function () { + const veryLongWalletId = 'a'.repeat(1000); + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: 100000, + walletPassphrase: 'test_passphrase', + }; + + const walletsGetStub = sinon.stub().rejects(new Error('Invalid wallet ID')); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${veryLongWalletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should handle wallet ID with special characters', async function () { + const specialCharWalletId = '../../../etc/passwd'; + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: 100000, + walletPassphrase: 'test_passphrase', + }; + + const walletsGetStub = sinon.stub().rejects(new Error('Invalid wallet ID')); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${encodeURIComponent(specialCharWalletId)}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should handle negative cpfpFeeRate', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: -50000, + maxFee: 100000, + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().rejects(new Error('cpfpFeeRate must be a non-negative integer')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should handle negative maxFee', async function () { + const requestBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: -100000, + walletPassphrase: 'test_passphrase', + }; + + const mockWallet = { + accelerateTransaction: sinon.stub().rejects(new Error('maxFee must be a non-negative integer')), + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: sinon.stub().returns(mockWallets) }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/acceleratetx`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + }); + }); + + describe('AccelerateTxParams', function () { + it('should validate params with required coin and id', function () { + const validParams = { + coin: 'tbtc', + id: '123456789abcdef', + }; + + const decoded = assertDecode(t.type(AccelerateTxParams), validParams); + assert.strictEqual(decoded.coin, validParams.coin); + assert.strictEqual(decoded.id, validParams.id); + }); + + it('should reject params with missing coin', function () { + const invalidParams = { + id: '123456789abcdef', + }; + + assert.throws(() => { + assertDecode(t.type(AccelerateTxParams), invalidParams); + }); + }); + + it('should reject params with missing id', function () { + const invalidParams = { + coin: 'tbtc', + }; + + assert.throws(() => { + assertDecode(t.type(AccelerateTxParams), invalidParams); + }); + }); + + it('should reject params with non-string coin', function () { + const invalidParams = { + coin: 123, // number instead of string + id: '123456789abcdef', + }; + + assert.throws(() => { + assertDecode(t.type(AccelerateTxParams), invalidParams); + }); + }); + + it('should reject params with non-string id', function () { + const invalidParams = { + coin: 'tbtc', + id: 123, // number instead of string + }; + + assert.throws(() => { + assertDecode(t.type(AccelerateTxParams), invalidParams); + }); + }); + }); + + describe('AccelerateTxRequestBody', function () { + it('should validate body with CPFP fields', function () { + const validBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: 100000, + walletPassphrase: 'test_passphrase', + }; + + const decoded = assertDecode(t.type(AccelerateTxRequestBody), validBody); + assert.deepStrictEqual(decoded.cpfpTxIds, validBody.cpfpTxIds); + assert.strictEqual(decoded.cpfpFeeRate, validBody.cpfpFeeRate); + assert.strictEqual(decoded.maxFee, validBody.maxFee); + assert.strictEqual(decoded.walletPassphrase, validBody.walletPassphrase); + }); + + it('should validate body with RBF fields', function () { + const validBody = { + rbfTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + feeMultiplier: 1.5, + walletPassphrase: 'test_passphrase', + }; + + const decoded = assertDecode(t.type(AccelerateTxRequestBody), validBody); + assert.deepStrictEqual(decoded.rbfTxIds, validBody.rbfTxIds); + assert.strictEqual(decoded.feeMultiplier, validBody.feeMultiplier); + assert.strictEqual(decoded.walletPassphrase, validBody.walletPassphrase); + }); + + it('should validate body with optional prv field', function () { + const validBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: 100000, + prv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + }; + + const decoded = assertDecode(t.type(AccelerateTxRequestBody), validBody); + assert.strictEqual(decoded.prv, validBody.prv); + }); + + it('should validate body with optional xprv field', function () { + const validBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: 100000, + xprv: 'xprv9s21ZrQH143K3D8TXfvAJgHVfTEeQNW5Ys9wZtnUZkqPzFzSjbEJrWC1vZ4GnXCvR7rQL2UFX3RSuYeU9MrERm1XBvACow7c36vnz5iYyj2', + }; + + const decoded = assertDecode(t.type(AccelerateTxRequestBody), validBody); + assert.strictEqual(decoded.xprv, validBody.xprv); + }); + + it('should validate body with additional transaction parameters', function () { + const validBody = { + cpfpTxIds: ['abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'], + cpfpFeeRate: 50000, + maxFee: 100000, + walletPassphrase: 'test_passphrase', + feeRate: 45000, + gasPrice: 20000000000, + gasLimit: 21000, + comment: 'Test acceleration', + otp: '123456', + }; + + const decoded = assertDecode(t.type(AccelerateTxRequestBody), validBody); + assert.strictEqual(decoded.feeRate, validBody.feeRate); + assert.strictEqual(decoded.gasPrice, validBody.gasPrice); + assert.strictEqual(decoded.gasLimit, validBody.gasLimit); + assert.strictEqual(decoded.comment, validBody.comment); + assert.strictEqual(decoded.otp, validBody.otp); + }); + + it('should validate empty body (all fields are optional)', function () { + const validBody = {}; + + // All fields are optional, so empty body should be valid + const decoded = assertDecode(t.type(AccelerateTxRequestBody), validBody); + assert.ok(decoded); + }); + + it('should reject body with non-array cpfpTxIds', function () { + const invalidBody = { + cpfpTxIds: 'not_an_array', + }; + + assert.throws(() => { + assertDecode(t.type(AccelerateTxRequestBody), invalidBody); + }); + }); + + it('should reject body with non-number cpfpFeeRate', function () { + const invalidBody = { + cpfpFeeRate: 'not_a_number', + }; + + assert.throws(() => { + assertDecode(t.type(AccelerateTxRequestBody), invalidBody); + }); + }); + + it('should reject body with non-boolean noCpfpFeeRate', function () { + const invalidBody = { + noCpfpFeeRate: 'not_a_boolean', + }; + + assert.throws(() => { + assertDecode(t.type(AccelerateTxRequestBody), invalidBody); + }); + }); + }); + + describe('AccelerateTxResponse', function () { + it('should accept any valid response structure', function () { + const validResponse = { + tx: '02000000...', + txid: '1234567890abcdef', + status: 'signed', + }; + + const decoded = assertDecode(AccelerateTxResponse, validResponse); + assert.ok(decoded); + }); + + it('should accept response with pendingApproval', function () { + const validResponse = { + pendingApproval: { + id: 'approval123', + state: 'pending', + }, + }; + + const decoded = assertDecode(AccelerateTxResponse, validResponse); + assert.ok(decoded); + }); + + it('should accept response with transfer object', function () { + const validResponse = { + transfer: { + id: 'transfer123', + coin: 'tbtc', + wallet: '68c02f96aa757d9212bd1a536f123456', + }, + txid: '1234567890abcdef', + }; + + const decoded = assertDecode(AccelerateTxResponse, validResponse); + assert.ok(decoded); + }); + + it('should accept empty response', function () { + const validResponse = {}; + + // t.unknown accepts any value + const decoded = assertDecode(AccelerateTxResponse, validResponse); + assert.ok(decoded !== null); + }); + }); + + describe('PostWalletAccelerateTx route definition', function () { + it('should have the correct path', function () { + assert.strictEqual(PostWalletAccelerateTx.path, '/api/v2/{coin}/wallet/{id}/acceleratetx'); + }); + + it('should have the correct HTTP method', function () { + assert.strictEqual(PostWalletAccelerateTx.method, 'POST'); + }); + + it('should have the correct request configuration', function () { + // Verify the route is configured with a request property + assert.ok(PostWalletAccelerateTx.request); + }); + + it('should have the correct response types', function () { + // Check that the response object has the expected status codes + assert.ok(PostWalletAccelerateTx.response[200]); + assert.ok(PostWalletAccelerateTx.response[400]); + assert.ok(PostWalletAccelerateTx.response[500]); + }); + }); +});