diff --git a/modules/sdk-coin-xtz/src/xtz.ts b/modules/sdk-coin-xtz/src/xtz.ts index 559b6b539e..eec643a89a 100644 --- a/modules/sdk-coin-xtz/src/xtz.ts +++ b/modules/sdk-coin-xtz/src/xtz.ts @@ -122,12 +122,61 @@ export class Xtz extends BaseCoin { } async verifyTransaction(params: VerifyTransactionOptions): Promise { - const { txParams } = params; + const { txParams, txPrebuild, wallet, verification } = params; 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'); + } + + // Decode the prebuild and verify destination/amount. Fail closed on decode failure or mismatch. + let txOutputs: { address: string; value: string }[]; + try { + const txBuilder = new TransactionBuilder(coins.get(this.getChain())); + txBuilder.from(rawTx); + const tx = await txBuilder.build(); + txOutputs = tx.outputs; + } catch (e) { + throw new Error(`Failed to decode Tezos prebuild transaction: ${e.message}`); + } + + if (txOutputs.length !== 1) { + throw new Error(`Tezos prebuild contains ${txOutputs.length} output(s) but expected exactly 1`); + } + + const { address: prebuildAddress, value: prebuildAmount } = txOutputs[0]; + + // Validate recipients if provided (normal send). Consolidation builds omit recipients. + const recipient = txParams.recipients?.[0]; + if (recipient) { + const requestedAddress = recipient.address; + const requestedAmount = recipient.amount.toString(); + + if (prebuildAddress !== requestedAddress) { + throw new Error(`Tezos prebuild destination mismatch: expected ${requestedAddress} but got ${prebuildAddress}`); + } + + if (new BigNumber(prebuildAmount).toFixed(0) !== new BigNumber(requestedAmount).toFixed(0)) { + throw new Error(`Tezos prebuild amount mismatch: expected ${requestedAmount} but got ${prebuildAmount}`); + } + } + + // Consolidation txs are built by the server with no client recipients — verify funds go to the base address. + if (verification?.consolidationToBaseAddress) { + const baseAddress = wallet?.coinSpecific()?.baseAddress || wallet?.coinSpecific()?.rootAddress; + if (!baseAddress) { + throw new Error('Unable to determine base address for consolidation'); + } + if (prebuildAddress !== baseAddress) { + throw new Error('Consolidation transaction recipient does not match wallet base address'); + } + } + return true; } diff --git a/modules/sdk-coin-xtz/test/unit/xtz.ts b/modules/sdk-coin-xtz/test/unit/xtz.ts index 61100dd70a..fcc8a78eda 100644 --- a/modules/sdk-coin-xtz/test/unit/xtz.ts +++ b/modules/sdk-coin-xtz/test/unit/xtz.ts @@ -204,6 +204,11 @@ describe('Tezos:', function () { describe('Verify Transaction', function () { const address1 = '5Ge59qRnZa8bxyhVFE6BDoY3kuhSrNVETRxXYLty1Hh6XTaf'; const address2 = '5DiMLZugmcKEH3igPZP367FqummZkWeW5Z6zDCHLfxRjnPXe'; + + // unsignedHex encodes: destination=tz2PtJ9zgEgFVTRqy6GXsst54tH3ksEnYvvS, amount=11800000 + const prebuildDestination = 'tz2PtJ9zgEgFVTRqy6GXsst54tH3ksEnYvvS'; + const prebuildAmount = '11800000'; + it('should reject a txPrebuild with more than one recipient', async function () { const wallet = new Wallet(bitgo, basecoin, {}); @@ -217,10 +222,124 @@ describe('Tezos:', function () { }; await basecoin - .verifyTransaction({ txParams }) + .verifyTransaction({ txParams, txPrebuild: { txHex: unsignedHex } }) .should.be.rejectedWith( `txtz doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.` ); }); + + it('should pass when no recipients are specified (consolidation builds omit them)', async function () { + const txParams = { recipients: [] }; + const txPrebuild = { txHex: unsignedHex }; + + const result = await basecoin.verifyTransaction({ txParams, txPrebuild } as any); + result.should.equal(true); + }); + + it('should reject when txPrebuild is missing txHex', async function () { + const txParams = { + recipients: [{ address: prebuildDestination, amount: prebuildAmount }], + }; + + await basecoin + .verifyTransaction({ txParams, txPrebuild: {} } as any) + .should.be.rejectedWith('missing required tx prebuild property txHex'); + }); + + it('should verify a valid transaction where destination and amount match', async function () { + const txParams = { + recipients: [{ address: prebuildDestination, amount: prebuildAmount }], + }; + const txPrebuild = { txHex: unsignedHex }; + + const result = await basecoin.verifyTransaction({ txParams, txPrebuild } as any); + result.should.equal(true); + }); + + it('should reject when prebuild destination does not match requested recipient', async function () { + const txParams = { + recipients: [{ address: 'tz1VRjRpVKnv16AVprFH1tkDn4TDfVqA893A', amount: prebuildAmount }], + }; + const txPrebuild = { txHex: unsignedHex }; + + await basecoin + .verifyTransaction({ txParams, txPrebuild } as any) + .should.be.rejectedWith( + `Tezos prebuild destination mismatch: expected tz1VRjRpVKnv16AVprFH1tkDn4TDfVqA893A but got ${prebuildDestination}` + ); + }); + + it('should reject when prebuild amount does not match requested recipient', async function () { + const txParams = { + recipients: [{ address: prebuildDestination, amount: '99999999' }], + }; + const txPrebuild = { txHex: unsignedHex }; + + await basecoin + .verifyTransaction({ txParams, txPrebuild } as any) + .should.be.rejectedWith(`Tezos prebuild amount mismatch: expected 99999999 but got ${prebuildAmount}`); + }); + + it('should reject when the prebuild contains an unexpected number of outputs', async function () { + // unsignedTransactionWithTwoTransfersHex has 2 outputs, so it should fail the single-output check + const txParams = { + recipients: [{ address: prebuildDestination, amount: prebuildAmount }], + }; + const txPrebuild = { txHex: unsignedTransactionWithTwoTransfersHex }; + + await basecoin + .verifyTransaction({ txParams, txPrebuild } as any) + .should.be.rejectedWith('Tezos prebuild contains 2 output(s) but expected exactly 1'); + }); + + it('should reject when the prebuild cannot be decoded', async function () { + const txParams = { + recipients: [{ address: prebuildDestination, amount: prebuildAmount }], + }; + // A hex string that is not a valid Tezos transaction + const txPrebuild = { txHex: 'ff'.repeat(200) }; + + let threw = false; + try { + await basecoin.verifyTransaction({ txParams, txPrebuild } as any); + } catch (e) { + threw = true; + e.message.should.startWith('Failed to decode Tezos prebuild transaction'); + } + threw.should.equal(true); + }); + + it('should verify consolidation to wallet base address', async function () { + const mockWallet = { + coinSpecific: () => ({ + baseAddress: prebuildDestination, + }), + }; + + const result = await basecoin.verifyTransaction({ + txParams: {}, + txPrebuild: { txHex: unsignedHex }, + verification: { consolidationToBaseAddress: true }, + wallet: mockWallet as any, + } as any); + result.should.equal(true); + }); + + it('should reject consolidation when destination does not match base address', async function () { + const mockWallet = { + coinSpecific: () => ({ + baseAddress: 'tz1VRjRpVKnv16AVprFH1tkDn4TDfVqA893A', + }), + }; + + await basecoin + .verifyTransaction({ + txParams: {}, + txPrebuild: { txHex: unsignedHex }, + verification: { consolidationToBaseAddress: true }, + wallet: mockWallet as any, + } as any) + .should.be.rejectedWith('Consolidation transaction recipient does not match wallet base address'); + }); }); });