From aea2d738e0eb88148d1c8e859d3cf38ed5593fdd Mon Sep 17 00:00:00 2001 From: Prajwal U Date: Wed, 15 Jul 2026 07:19:07 +0000 Subject: [PATCH] fix(sdk-coin-stx): verify recipient and amount in native STX verifyTransaction Previously, Stx.verifyTransaction was a near no-op: it only checked that the recipient count was <= 1. A compromised prebuild could redirect native STX sends to any address or alter the amount, and local verification would still pass. Port the decode-and-compare pattern from Sip10Token.verifyTransaction into Stx.verifyTransaction, adapted for single-recipient STX. The new implementation decodes the prebuild txHex via explainTransaction and directly compares the single decoded output's address and amount against txParams.recipients[0], then validates the memo. No iteration needed since STX enforces a single-recipient constraint at the top of the method. This closes the gap where the SIP10 token path already had full recipient validation but native STX did not. Ticket: CSHLD-839 Session-Id: 7de30368-fc1b-4b09-9d2e-55be78e462dc Task-Id: 2203e736-35f2-4e2f-8cde-6cdbd0d3e2b6 --- modules/sdk-coin-stx/src/stx.ts | 46 +++++++++++++- modules/sdk-coin-stx/test/unit/stx.ts | 87 +++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/modules/sdk-coin-stx/src/stx.ts b/modules/sdk-coin-stx/src/stx.ts index eee65ca6b4..2b0abe0bd2 100644 --- a/modules/sdk-coin-stx/src/stx.ts +++ b/modules/sdk-coin-stx/src/stx.ts @@ -40,7 +40,12 @@ import { ExplainTransactionOptions, StxSignTransactionOptions, StxTransactionExp import { StxLib } from '.'; import { TransactionBuilderFactory } from './lib'; import { TransactionBuilder } from './lib/transactionBuilder'; -import { findContractTokenNameUsingContract, findTokenNameByContract, getAddressDetails } from './lib/utils'; +import { + findContractTokenNameUsingContract, + findTokenNameByContract, + getAddressDetails, + getMemoIdAndBaseAddressFromAddress, +} from './lib/utils'; import { AddressDetails, NativeStxBalance, @@ -98,12 +103,49 @@ export class Stx extends BaseCoin { } async verifyTransaction(params: VerifyTransactionOptions): Promise { - const { txParams } = params; + const { txPrebuild, txParams } = params; + const { memo } = txParams; if (Array.isArray(txParams.recipients) && txParams.recipients.length > 1) { throw new Error( `${this.getChain()} doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.` ); } + const rawTx = txPrebuild?.txHex; + if (!rawTx) { + throw new Error('missing required tx prebuild property txHex'); + } + const explainedTx = await this.explainTransaction({ txHex: rawTx, feeInfo: { fee: '' } }); + const recipient = txParams.recipients?.[0]; + if (recipient !== undefined && explainedTx) { + const txOutput = explainedTx.outputs[0]; + const recipientAddress = getMemoIdAndBaseAddressFromAddress(recipient.address).address; + if (txOutput?.address !== recipientAddress) { + throw new Error( + `Tx destination does not match with expected txParams recipient: expected ${recipientAddress} but got ${txOutput?.address}` + ); + } + if (BigInt(txOutput?.amount) !== BigInt(recipient.amount)) { + throw new Error( + `Tx amount does not match with expected txParams recipient amount: expected ${recipient.amount} but got ${txOutput?.amount}` + ); + } + // compare memo + let memoInput = ''; + if (memo && memo.value) { + memoInput = memo.value; + } else { + const addressDetails = getMemoIdAndBaseAddressFromAddress(recipient.address); + memoInput = addressDetails.memoId ?? ''; + } + const memoOutput = explainedTx.memo ?? ''; + if (memoInput !== memoOutput) { + throw new Error('Tx memo does not match with expected txParams recipient memo'); + } + // compare amount + if (!new BigNumber(recipient.amount).isEqualTo(explainedTx.outputAmount)) { + throw new Error('Tx total amount does not match with expected total amount field'); + } + } return true; } diff --git a/modules/sdk-coin-stx/test/unit/stx.ts b/modules/sdk-coin-stx/test/unit/stx.ts index 7e6d95efc2..df7d6903d1 100644 --- a/modules/sdk-coin-stx/test/unit/stx.ts +++ b/modules/sdk-coin-stx/test/unit/stx.ts @@ -324,6 +324,8 @@ describe('STX:', function () { describe('Verify Transaction', function () { const address1 = '0x174cfd823af8ce27ed0afee3fcf3c3ba259116be'; const address2 = '0x7e85bdc27c050e3905ebf4b8e634d9ad6edd0de6'; + const txPrebuild = { txHex: testData.txForExplainTransfer, txInfo: {} }; + it('should reject a txPrebuild with more than one recipient', async function () { const wallet = new Wallet(bitgo, basecoin, {}); @@ -342,6 +344,91 @@ describe('STX:', function () { `tstx doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.` ); }); + + it('should reject when txPrebuild is missing txHex', async function () { + const wallet = new Wallet(bitgo, basecoin, {}); + const txParams = { + recipients: [{ amount: '1000', address: testData.txExplainedTransfer.recipient }], + wallet, + walletPassphrase: 'fakeWalletPassphrase', + }; + await basecoin + .verifyTransaction({ txPrebuild: {}, txParams }) + .should.be.rejectedWith('missing required tx prebuild property txHex'); + }); + + it('should succeed to verify a native STX transfer with matching recipient and amount', async function () { + const wallet = new Wallet(bitgo, basecoin, {}); + const txParams = { + recipients: [ + { + address: testData.txExplainedTransfer.recipient, + amount: testData.txExplainedTransfer.outputAmount, + }, + ], + memo: { type: '', value: testData.txExplainedTransfer.memo }, + wallet, + }; + const result = await basecoin.verifyTransaction({ txPrebuild, txParams }); + result.should.equal(true); + }); + + it('should fail to verify transaction with wrong recipient address', async function () { + const wallet = new Wallet(bitgo, basecoin, {}); + const requestedAddress = 'ST11NJTTKGVT6D1HY4NJRVQWMQM7TVAR091EJ8P2Y'; + const txParams = { + recipients: [ + { + address: requestedAddress, + amount: testData.txExplainedTransfer.outputAmount, + }, + ], + memo: { type: '', value: testData.txExplainedTransfer.memo }, + wallet, + }; + await basecoin + .verifyTransaction({ txPrebuild, txParams }) + .should.be.rejectedWith( + `Tx destination does not match with expected txParams recipient: expected ${requestedAddress} but got ${testData.txExplainedTransfer.recipient}` + ); + }); + + it('should fail to verify transaction with wrong amount', async function () { + const wallet = new Wallet(bitgo, basecoin, {}); + const requestedAmount = '9999'; + const txParams = { + recipients: [ + { + address: testData.txExplainedTransfer.recipient, + amount: requestedAmount, + }, + ], + memo: { type: '', value: testData.txExplainedTransfer.memo }, + wallet, + }; + await basecoin + .verifyTransaction({ txPrebuild, txParams }) + .should.be.rejectedWith( + `Tx amount does not match with expected txParams recipient amount: expected ${requestedAmount} but got ${testData.txExplainedTransfer.outputAmount}` + ); + }); + + it('should fail to verify transaction with wrong memo', async function () { + const wallet = new Wallet(bitgo, basecoin, {}); + const txParams = { + recipients: [ + { + address: testData.txExplainedTransfer.recipient, + amount: testData.txExplainedTransfer.outputAmount, + }, + ], + memo: { type: '', value: 'wrong memo' }, + wallet, + }; + await basecoin + .verifyTransaction({ txPrebuild, txParams }) + .should.be.rejectedWith('Tx memo does not match with expected txParams recipient memo'); + }); }); describe('Recover Transaction STX', function () {