Skip to content

Commit 58d2f07

Browse files
committed
fix(sdk-coin-trx): validate TRC20 recipient and amount for TSS transactions
TICKET: CHALO-448
1 parent e1eed7b commit 58d2f07

4 files changed

Lines changed: 251 additions & 18 deletions

File tree

modules/sdk-coin-trx/src/trx.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -389,15 +389,7 @@ export class Trx extends BaseCoin {
389389
// containing { txID, raw_data, raw_data_hex }.
390390
// 2. ECDSA signing flow (ecdsa.ts) — txHex is signableHex, the raw protobuf bytes (raw_data_hex).
391391
// We need to extract the raw_data_hex in case 1 before decoding.
392-
let rawDataHex: string;
393-
try {
394-
// serializedTxHex: full JSON string — extract the raw_data_hex field
395-
rawDataHex = JSON.parse(txPrebuild.txHex).raw_data_hex;
396-
} catch {
397-
// signableHex: already raw protobuf hex (raw_data_hex)
398-
console.debug(`Could not parse txHex as JSON for coin ${this.getChain()}, using txHex directly`);
399-
rawDataHex = txPrebuild.txHex;
400-
}
392+
const rawDataHex = this.extractRawDataHex(txPrebuild.txHex);
401393
const decodedTx = Utils.decodeTransaction(rawDataHex);
402394

403395
// decodedTx uses a numeric enum for contract type (from protobuf decoding),
@@ -411,6 +403,15 @@ export class Trx extends BaseCoin {
411403
return this.validateTransferContract(decodedTx.contract[0], txParams, true);
412404
}
413405

406+
if (decodedTx.contractType === Enum.ContractType.TriggerSmartContract) {
407+
// TRC20 token transfers (TriggerSmartContract) must be verified via TrxToken.verifyTransaction,
408+
// not here. Fail closed to prevent unvalidated token transfers from being silently signed.
409+
throw new Error(
410+
'TriggerSmartContract verification is not supported by native TRX. ' +
411+
'TRC20 token transfers must be verified via TrxToken.verifyTransaction.'
412+
);
413+
}
414+
414415
return true;
415416
}
416417

@@ -434,6 +435,24 @@ export class Trx extends BaseCoin {
434435
}
435436
}
436437

438+
/**
439+
* Extract the raw protobuf hex (raw_data_hex) from a TSS txHex.
440+
*
441+
* TSS verifyTransaction is called from two places:
442+
* 1. prebuildAndSignTransaction — txHex is a full JSON string containing raw_data_hex.
443+
* 2. ECDSA signing flow — txHex is already the raw protobuf hex (raw_data_hex).
444+
*
445+
* This helper handles both formats so callers don't duplicate the try/catch.
446+
*/
447+
protected extractRawDataHex(txHex: string): string {
448+
try {
449+
return JSON.parse(txHex).raw_data_hex;
450+
} catch {
451+
console.debug(`Could not parse txHex as JSON for coin ${this.getChain()}, using txHex directly`);
452+
return txHex;
453+
}
454+
}
455+
437456
/**
438457
* Validate Transfer contract (native TRX transfer).
439458
* Shared by both on-chain multisig and TSS wallet verification paths.

modules/sdk-coin-trx/src/trxToken.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { TrxTokenConfig, coins, tokens } from '@bitgo/statics';
44
import { getBuilder } from './lib/builder';
55
import { Recipient } from '../../sdk-core/src/bitgo/baseCoin/iBaseCoin';
66
import assert from 'assert';
7+
import { Enum, Utils, Interface } from './lib';
78

89
export { TrxTokenConfig };
910

@@ -94,15 +95,56 @@ export class TrxToken extends Trx {
9495
}
9596

9697
async verifyTransaction(params: VerifyTransactionOptions): Promise<boolean> {
97-
const { txPrebuild: txPrebuild, txParams: txParams, walletType } = params;
98+
const { txPrebuild, txParams, walletType } = params;
9899
assert(txPrebuild.txHex, new Error('missing required tx prebuild property txHex'));
99100

100-
// For TSS wallets, TRC20 token transfers are TriggerSmartContract transactions.
101-
// Trx.verifyTransaction already returns true for TriggerSmartContract in TSS mode
102-
// (only TransferContract/native TRX gets validated against recipients there).
103-
// We apply the same convention here: the TSS signing protocol itself provides
104-
// cryptographic guarantees; recipients-based verification is not applicable.
105101
if (walletType === 'tss') {
102+
// For TSS wallets, TRC20 token transfers are TriggerSmartContract transactions.
103+
// Decode the transaction and validate destination address and amount against
104+
// txParams.recipients before signing to ensure intent matches the prebuild.
105+
const rawDataHex = this.extractRawDataHex(txPrebuild.txHex);
106+
const decodedTx = Utils.decodeTransaction(rawDataHex);
107+
108+
if (decodedTx.contractType !== Enum.ContractType.TriggerSmartContract) {
109+
throw new Error(
110+
`Expected TriggerSmartContract for TRC20 token transfer, got contract type: ${decodedTx.contractType}`
111+
);
112+
}
113+
114+
if (!Array.isArray(decodedTx.contract) || decodedTx.contract.length !== 1) {
115+
throw new Error('Invalid TriggerSmartContract structure');
116+
}
117+
118+
const triggerContract = decodedTx.contract[0] as Interface.TriggerSmartContract;
119+
// data is base64-encoded from protobuf decoding; convert to hex for decodeDataParams
120+
const contractData = Buffer.from(triggerContract.parameter.value.data, 'base64').toString('hex');
121+
122+
const recipients = txParams.recipients || (txPrebuild.txInfo as TronTxInfo).recipients;
123+
if (!recipients || recipients.length !== 1) {
124+
throw new Error('missing or invalid required property recipients');
125+
}
126+
127+
let decodedParams: any[];
128+
try {
129+
decodedParams = Utils.decodeDataParams(['address', 'uint256'], contractData);
130+
} catch (e) {
131+
throw new Error(`Failed to decode TRC20 transfer ABI data: ${e instanceof Error ? e.message : String(e)}`);
132+
}
133+
134+
// decodedParams[0] is the recipient address with '41' hex prefix; convert to base58 for comparison
135+
const actualDestination = Utils.getBase58AddressFromHex(decodedParams[0]);
136+
const actualAmount = decodedParams[1].toString();
137+
const expectedDestination = recipients[0].address;
138+
const expectedAmount = recipients[0].amount.toString();
139+
140+
if (actualAmount !== expectedAmount) {
141+
throw new Error('transaction amount in txPrebuild does not match the value given by client');
142+
}
143+
144+
if (expectedDestination.toLowerCase() !== actualDestination.toLowerCase()) {
145+
throw new Error('destination address does not match with the recipient address');
146+
}
147+
106148
return true;
107149
}
108150

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import assert from 'node:assert';
2+
import { describe, it, before } from 'node:test';
3+
import { BitGoAPI } from '@bitgo/sdk-api';
4+
import { TestBitGoAPI, TestBitGo } from '@bitgo/sdk-test';
5+
import { tokens } from '@bitgo/statics';
6+
import { Trx } from '../../src/trx';
7+
import { TrxToken } from '../../src/trxToken';
8+
import { Ttrx } from '../../src/ttrx';
9+
import { Utils } from '../../src/lib';
10+
11+
// Real TriggerSmartContract protobuf raw_data_hex encoding a TRC20 transfer:
12+
// owner: 41c51fbeea78910b15b1d3e8a9b62914ca94d1a4ac
13+
// contract: 4142a1e39aefa49290f2b3f9ed688d7cecf86cd6e0
14+
// data: a9059cbb + abi(address=8483618ca85c35a9b923d98bebca718f5a1db279, uint256=100000000)
15+
const TRC20_RAW_DATA_HEX =
16+
'0a02578b22086113bb9ac351432b4088eae7a6de305aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a1541c51fbeea78910b15b1d3e8a9b62914ca94d1a4ac12154142a1e39aefa49290f2b3f9ed688d7cecf86cd6e02244a9059cbb0000000000000000000000008483618ca85c35a9b923d98bebca718f5a1db2790000000000000000000000000000000000000000000000000000000005f5e10070888d8ca5de309001c0c39307';
17+
18+
// Recipient address decoded from the ABI data above (41 prefix → base58)
19+
const TRC20_RECIPIENT_HEX = '418483618ca85c35a9b923d98bebca718f5a1db279';
20+
const TRC20_AMOUNT = '100000000';
21+
22+
describe('TrxToken verifyTransaction:', function () {
23+
const bitgo: TestBitGoAPI = TestBitGo.decorate(BitGoAPI, { env: 'test' });
24+
bitgo.initializeTestVars();
25+
bitgo.safeRegister('trx', Trx.createInstance);
26+
bitgo.safeRegister('ttrx', Ttrx.createInstance);
27+
28+
let tokenCoin: TrxToken;
29+
30+
before(function () {
31+
const usdtConfig = tokens.testnet.trx.tokens.find((t) => t.type === 'ttrx:usdt');
32+
assert.ok(usdtConfig, 'ttrx:usdt token config not found');
33+
tokenCoin = new TrxToken(bitgo, usdtConfig);
34+
});
35+
36+
describe('TSS wallet — TriggerSmartContract validation', () => {
37+
it('should validate a correct TRC20 transfer', async function () {
38+
const recipientBase58 = Utils.getBase58AddressFromHex(TRC20_RECIPIENT_HEX);
39+
40+
const result = await tokenCoin.verifyTransaction({
41+
txPrebuild: { txHex: TRC20_RAW_DATA_HEX },
42+
txParams: { recipients: [{ address: recipientBase58, amount: TRC20_AMOUNT }] },
43+
walletType: 'tss',
44+
} as any);
45+
46+
assert.strictEqual(result, true);
47+
});
48+
49+
it('should validate when txHex is a serialized JSON (prebuildAndSignTransaction path)', async function () {
50+
const recipientBase58 = Utils.getBase58AddressFromHex(TRC20_RECIPIENT_HEX);
51+
const serializedTxHex = JSON.stringify({ txID: 'abc', raw_data_hex: TRC20_RAW_DATA_HEX, raw_data: {} });
52+
53+
const result = await tokenCoin.verifyTransaction({
54+
txPrebuild: { txHex: serializedTxHex },
55+
txParams: { recipients: [{ address: recipientBase58, amount: TRC20_AMOUNT }] },
56+
walletType: 'tss',
57+
} as any);
58+
59+
assert.strictEqual(result, true);
60+
});
61+
62+
it('should throw when amount does not match', async function () {
63+
const recipientBase58 = Utils.getBase58AddressFromHex(TRC20_RECIPIENT_HEX);
64+
65+
await assert.rejects(
66+
tokenCoin.verifyTransaction({
67+
txPrebuild: { txHex: TRC20_RAW_DATA_HEX },
68+
txParams: { recipients: [{ address: recipientBase58, amount: '999' }] },
69+
walletType: 'tss',
70+
} as any),
71+
{ message: 'transaction amount in txPrebuild does not match the value given by client' }
72+
);
73+
});
74+
75+
it('should throw when recipient address does not match', async function () {
76+
await assert.rejects(
77+
tokenCoin.verifyTransaction({
78+
txPrebuild: { txHex: TRC20_RAW_DATA_HEX },
79+
txParams: { recipients: [{ address: 'TLWh67P93KgtnZNCtGnEHM1H33Nhq2uvvN', amount: TRC20_AMOUNT }] },
80+
walletType: 'tss',
81+
} as any),
82+
{ message: 'destination address does not match with the recipient address' }
83+
);
84+
});
85+
86+
it('should throw when recipients is empty', async function () {
87+
await assert.rejects(
88+
tokenCoin.verifyTransaction({
89+
txPrebuild: { txHex: TRC20_RAW_DATA_HEX },
90+
txParams: { recipients: [] },
91+
walletType: 'tss',
92+
} as any),
93+
{ message: 'missing or invalid required property recipients' }
94+
);
95+
});
96+
97+
it('should throw when contract type is not TriggerSmartContract', async function () {
98+
// Use a native TRX Transfer protobuf as txHex — TrxToken only handles TriggerSmartContract
99+
const recipientBase58 = Utils.getBase58AddressFromHex(TRC20_RECIPIENT_HEX);
100+
const nativeTrxRawDataHex = Utils.generateRawDataHex({
101+
contract: [
102+
{
103+
parameter: {
104+
value: {
105+
amount: 100000000,
106+
owner_address: '4173a5993cd182ae152adad8203163f780c65a8aa5',
107+
to_address: TRC20_RECIPIENT_HEX,
108+
} as any,
109+
type_url: 'type.googleapis.com/protocol.TransferContract',
110+
},
111+
type: 'TransferContract',
112+
} as any,
113+
],
114+
refBlockBytes: 'c8cf',
115+
refBlockHash: '89177fd84c5d9196',
116+
expiration: Date.now() + 3600000,
117+
timestamp: Date.now(),
118+
});
119+
120+
await assert.rejects(
121+
tokenCoin.verifyTransaction({
122+
txPrebuild: { txHex: nativeTrxRawDataHex },
123+
txParams: { recipients: [{ address: recipientBase58, amount: '100000000' }] },
124+
walletType: 'tss',
125+
} as any),
126+
{ message: /Expected TriggerSmartContract for TRC20 token transfer/ }
127+
);
128+
});
129+
});
130+
131+
describe('non-TSS wallet — builder-based validation (existing path)', () => {
132+
it('should validate a correct non-TSS TRC20 transfer using txBuilder', async function () {
133+
// The non-TSS path uses getBuilder().from(rawTx).build() and checks tx.outputs[0]
134+
// This test uses the full JSON tx format that the builder understands.
135+
const txHex =
136+
'{"raw_data":{"contractType":2,"contract":[{"parameter":{"value":{"data":"a9059cbb0000000000000000000000008483618ca85c35a9b923d98bebca718f5a1db2790000000000000000000000000000000000000000000000000000000005f5e100","owner_address":"41c51fbeea78910b15b1d3e8a9b62914ca94d1a4ac","contract_address":"4142a1e39aefa49290f2b3f9ed688d7cecf86cd6e0"},"type_url":"type.googleapis.com/protocol.TriggerSmartContract"},"type":"TriggerSmartContract"}],"expiration":1674581767432,"timestamp":1674578167432,"ref_block_bytes":"578b","ref_block_hash":"6113bb9ac351432b","fee_limit":15000000},"raw_data_hex":"0a02578b22086113bb9ac351432b4088eae7a6de305aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a1541c51fbeea78910b15b1d3e8a9b62914ca94d1a4ac12154142a1e39aefa49290f2b3f9ed688d7cecf86cd6e02244a9059cbb0000000000000000000000008483618ca85c35a9b923d98bebca718f5a1db2790000000000000000000000000000000000000000000000000000000005f5e10070888d8ca5de309001c0c39307","txID":"fe21c49f4febd9089125e3a006943c145721d8fcb7ab84136f8c6663ff92f8ed","signature":["0775cde302689eb8293883c66a89b31e80d608bfc3ad3c283b64a490ea4cc712c55a2fd2e62c75843dd7e77d8c4cb52e0f371fbb29b332c259f8cb63c2e6195301"]}';
137+
const recipientBase58 = Utils.getBase58AddressFromHex(TRC20_RECIPIENT_HEX);
138+
139+
const result = await tokenCoin.verifyTransaction({
140+
txPrebuild: { txHex },
141+
txParams: { recipients: [{ address: recipientBase58, amount: TRC20_AMOUNT }] },
142+
} as any);
143+
144+
assert.strictEqual(result, true);
145+
});
146+
});
147+
});

modules/sdk-coin-trx/test/unit/verifyTransaction.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -540,9 +540,9 @@ describe('TRON Verify Transaction:', function () {
540540
});
541541
});
542542

543-
it('should return true for non-Transfer contract types in TSS', async function () {
544-
// For non-Transfer contracts (e.g., AccountPermissionUpdate), TSS path returns true
545-
// without detailed validation.
543+
it('should return true for non-Transfer, non-TriggerSmartContract types in TSS', async function () {
544+
// AccountPermissionUpdate and other native TRX contracts (freeze, vote, etc.) pass through
545+
// without recipient validation in the TSS path.
546546
const rawDataHex = UnsignedAccountPermissionUpdateContractTx.raw_data_hex;
547547

548548
const params = {
@@ -560,6 +560,31 @@ describe('TRON Verify Transaction:', function () {
560560
assert.strictEqual(result, true);
561561
});
562562

563+
it('should throw when TSS native TRX verifyTransaction encounters a TriggerSmartContract', async function () {
564+
// Defense-in-depth: native TRX (Trx) should never verify TriggerSmartContract.
565+
// TRC20 token transfers must go through TrxToken.verifyTransaction.
566+
// The raw_data_hex below is a real TRC20 TriggerSmartContract protobuf.
567+
const trc20RawDataHex =
568+
'0a02578b22086113bb9ac351432b4088eae7a6de305aae01081f12a9010a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412740a1541c51fbeea78910b15b1d3e8a9b62914ca94d1a4ac12154142a1e39aefa49290f2b3f9ed688d7cecf86cd6e02244a9059cbb0000000000000000000000008483618ca85c35a9b923d98bebca718f5a1db2790000000000000000000000000000000000000000000000000000000005f5e10070888d8ca5de309001c0c39307';
569+
570+
const params = {
571+
txParams: {
572+
recipients: [{ address: 'TLWh67P93KgtnZNCtGnEHM1H33Nhq2uvvN', amount: '100000000' }],
573+
},
574+
txPrebuild: {
575+
txHex: trc20RawDataHex,
576+
},
577+
wallet: {},
578+
walletType: 'tss',
579+
};
580+
581+
await assert.rejects(basecoin.verifyTransaction(params), {
582+
message:
583+
'TriggerSmartContract verification is not supported by native TRX. ' +
584+
'TRC20 token transfers must be verified via TrxToken.verifyTransaction.',
585+
});
586+
});
587+
563588
it('should throw error when txHex is missing for TSS wallet', async function () {
564589
const params = {
565590
txParams: {

0 commit comments

Comments
 (0)