From c2725f263b722fe938e81a3cbf946cb194f80385 Mon Sep 17 00:00:00 2001 From: Alla Gopi Karath Date: Wed, 14 Oct 2026 14:26:36 +0530 Subject: [PATCH] fix(sdk-coin-tia): normalize memo-suffixed recipients in verifyTransaction CosmosCoin.verifyTransaction compared txParams.recipients, which encode the memo as `address?memoId=X`, against explainTransaction() outputs (plain address plus a separate memo field) as raw strings. Any memo-tagged TIA recipient therefore failed local validation with "Tx outputs does not match with expected txParams recipients", even when the transaction was built correctly. Override verifyTransaction in Tia to split the recipient address via getAddressDetails() so both sides are compared as {address, amount, memo?}. This also rejects a recipient missing its memoId when the tx carries a memo, which the base implementation let through silently. Mirrors the ZETA fix (51d997c5). Scoped to sdk-coin-tia only. Ticket: COINS-1760 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0197EizfQXpRL29XyEsyVJ4k --- modules/sdk-coin-tia/package.json | 3 +- modules/sdk-coin-tia/src/tia.ts | 45 ++++++++++++++++++++++++++- modules/sdk-coin-tia/test/unit/tia.ts | 34 ++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/modules/sdk-coin-tia/package.json b/modules/sdk-coin-tia/package.json index 3d373cc5022..f61a1471901 100644 --- a/modules/sdk-coin-tia/package.json +++ b/modules/sdk-coin-tia/package.json @@ -47,7 +47,8 @@ "@cosmjs/amino": "^0.29.5", "@cosmjs/encoding": "^0.29.5", "@cosmjs/stargate": "^0.29.5", - "bignumber.js": "^9.1.1" + "bignumber.js": "^9.1.1", + "lodash": "^4.18.0" }, "devDependencies": { "@bitgo/sdk-api": "^3.0.7", diff --git a/modules/sdk-coin-tia/src/tia.ts b/modules/sdk-coin-tia/src/tia.ts index dcae42c328d..1a4e8b022e6 100644 --- a/modules/sdk-coin-tia/src/tia.ts +++ b/modules/sdk-coin-tia/src/tia.ts @@ -1,6 +1,8 @@ import { CosmosCoin, CosmosKeyPair, GasAmountDetails } from '@bitgo/abstract-cosmos'; -import { BaseCoin, BitGoBase, Environments } from '@bitgo/sdk-core'; +import { BaseCoin, BitGoBase, Environments, TransactionType, VerifyTransactionOptions } from '@bitgo/sdk-core'; import { BaseUnit, BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; +import BigNumber from 'bignumber.js'; +import * as _ from 'lodash'; import { KeyPair, TransactionBuilderFactory } from './lib'; import utils from './lib/utils'; @@ -63,4 +65,45 @@ export class Tia extends CosmosCoin { getAddressFromPublicKey(publicKey: string): string { return new KeyPair({ pub: publicKey }).getAddress(); } + + /** + * @inheritDoc + * + * Overrides CosmosCoin.verifyTransaction because recipients can carry their memo folded into + * the address as `address?memoId=X` (see getAddressDetails), while explainTransaction() reports + * the plain on-chain address plus a separate `memo` field. The base class compares the two + * as raw strings, so any memo-tagged recipient fails verification even when the transaction was + * built correctly. Normalize both sides to the same {address, amount, memo?} shape first. + */ + async verifyTransaction(params: VerifyTransactionOptions): Promise { + let totalAmount = new BigNumber(0); + const { txPrebuild, txParams } = params; + const rawTx = txPrebuild.txHex; + if (!rawTx) { + throw new Error('missing required tx prebuild property txHex'); + } + const transaction = await this.getBuilder().from(rawTx).build(); + const explainedTx = transaction.explainTransaction(); + if (txParams.recipients && txParams.recipients.length > 0) { + const filteredRecipients = txParams.recipients.map((recipient) => { + const { address, memoId } = this.getAddressDetails(recipient.address); + return { address, amount: recipient.amount, ...(memoId !== undefined ? { memo: memoId } : {}) }; + }); + const filteredOutputs = explainedTx.outputs.map((output) => _.pick(output, ['address', 'amount', 'memo'])); + + if (!_.isEqual(filteredOutputs, filteredRecipients)) { + throw new Error('Tx outputs does not match with expected txParams recipients'); + } + // WithdrawDelegatorRewards and ContractCall transaction don't have amount + if (transaction.type !== TransactionType.StakingWithdraw && transaction.type !== TransactionType.ContractCall) { + for (const recipient of txParams.recipients) { + totalAmount = totalAmount.plus(recipient.amount); + } + if (!totalAmount.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-tia/test/unit/tia.ts b/modules/sdk-coin-tia/test/unit/tia.ts index 7c41b3d201c..5c00e502aa7 100644 --- a/modules/sdk-coin-tia/test/unit/tia.ts +++ b/modules/sdk-coin-tia/test/unit/tia.ts @@ -199,6 +199,40 @@ describe('TIA', function () { isTransactionVerified.should.equal(true); }); + it('should succeed to verify transaction with a memo-suffixed recipient address', async function () { + const txPrebuild = { txHex: TEST_TX_WITH_MEMO.signedTxBase64, txInfo: {} }; + const txParams = { + recipients: [ + { + address: `${TEST_TX_WITH_MEMO.to}?memoId=${TEST_TX_WITH_MEMO.memo}`, + amount: TEST_TX_WITH_MEMO.sendAmount, + }, + ], + }; + const isTransactionVerified = await basecoin.verifyTransaction({ txParams, txPrebuild, verification: {} }); + isTransactionVerified.should.equal(true); + }); + + it('should fail to verify transaction when the recipient memoId does not match the tx memo', async function () { + const txPrebuild = { txHex: TEST_TX_WITH_MEMO.signedTxBase64, txInfo: {} }; + const txParams = { + recipients: [{ address: `${TEST_TX_WITH_MEMO.to}?memoId=999999`, amount: TEST_TX_WITH_MEMO.sendAmount }], + }; + await basecoin + .verifyTransaction({ txParams, txPrebuild, verification: {} }) + .should.be.rejectedWith('Tx outputs does not match with expected txParams recipients'); + }); + + it('should fail to verify transaction when the recipient has no memoId but the tx has one', async function () { + const txPrebuild = { txHex: TEST_TX_WITH_MEMO.signedTxBase64, txInfo: {} }; + const txParams = { + recipients: [{ address: TEST_TX_WITH_MEMO.to, amount: TEST_TX_WITH_MEMO.sendAmount }], + }; + await basecoin + .verifyTransaction({ txParams, txPrebuild, verification: {} }) + .should.be.rejectedWith('Tx outputs does not match with expected txParams recipients'); + }); + it('should fail to verify transaction with invalid param', async function () { const txPrebuild = {}; const txParams = { recipients: undefined };