diff --git a/modules/sdk-coin-trx/src/trxToken.ts b/modules/sdk-coin-trx/src/trxToken.ts index d824b9c16e..75bfa659cc 100644 --- a/modules/sdk-coin-trx/src/trxToken.ts +++ b/modules/sdk-coin-trx/src/trxToken.ts @@ -100,8 +100,10 @@ export class TrxToken extends Trx { if (walletType === 'tss') { // For TSS wallets, TRC20 token transfers are TriggerSmartContract transactions. - // Decode the transaction and validate destination address and amount against - // txParams.recipients before signing to ensure intent matches the prebuild. + // Always verify structure and ABI decodability. Intent validation (address + amount + // comparison) is performed only when recipients are present — absent recipients + // indicates a server-determined transfer (e.g. consolidation) where the server + // owns the intent. const rawDataHex = this.extractRawDataHex(txPrebuild.txHex); const decodedTx = Utils.decodeTransaction(rawDataHex); @@ -110,7 +112,6 @@ export class TrxToken extends Trx { `Expected TriggerSmartContract for TRC20 token transfer, got contract type: ${decodedTx.contractType}` ); } - if (!Array.isArray(decodedTx.contract) || decodedTx.contract.length !== 1) { throw new Error('Invalid TriggerSmartContract structure'); } @@ -119,11 +120,6 @@ export class TrxToken extends Trx { // data is base64-encoded from protobuf decoding; convert to hex for decodeDataParams const contractData = Buffer.from(triggerContract.parameter.value.data, 'base64').toString('hex'); - const recipients = txParams.recipients || (txPrebuild.txInfo as TronTxInfo).recipients; - if (!recipients || recipients.length !== 1) { - throw new Error('missing or invalid required property recipients'); - } - let recipientHex: string; let transferAmount: { toString(): string }; try { @@ -135,6 +131,15 @@ export class TrxToken extends Trx { throw new Error(`Failed to decode TRC20 transfer ABI data: ${e instanceof Error ? e.message : String(e)}`); } + const recipients = txParams.recipients || (txPrebuild.txInfo as TronTxInfo | undefined)?.recipients; + if (!recipients || recipients.length === 0) { + // No recipients — server-determined transfer (e.g. consolidation); structural check above is sufficient. + return true; + } + if (recipients.length !== 1) { + throw new Error('invalid required property recipients'); + } + // recipientHex has '41' hex prefix; convert to base58 for comparison const actualDestination = Utils.getBase58AddressFromHex(recipientHex); const actualAmount = transferAmount.toString(); diff --git a/modules/sdk-coin-trx/test/unit/trxToken.ts b/modules/sdk-coin-trx/test/unit/trxToken.ts index 37d1fc78eb..0e77bef9c5 100644 --- a/modules/sdk-coin-trx/test/unit/trxToken.ts +++ b/modules/sdk-coin-trx/test/unit/trxToken.ts @@ -83,15 +83,29 @@ describe('TrxToken verifyTransaction:', function () { ); }); - it('should throw when recipients is empty', async function () { - await assert.rejects( - tokenCoin.verifyTransaction({ - txPrebuild: { txHex: TRC20_RAW_DATA_HEX }, - txParams: { recipients: [] }, - walletType: 'tss', - } as any), - { message: 'missing or invalid required property recipients' } - ); + it('should return true when recipients is absent (consolidation path — undefined)', async function () { + // Consolidation: server determines intent; no recipients on txParams or txInfo. + // Verify passes because tx structure is valid TriggerSmartContract AND ABI decodes + // to a valid (address, uint256) pair — malformed ABI would still throw. + const result = await tokenCoin.verifyTransaction({ + txPrebuild: { txHex: TRC20_RAW_DATA_HEX }, + txParams: {}, + walletType: 'tss', + } as any); + + assert.strictEqual(result, true); + }); + + it('should return true when recipients is empty array (consolidation path — [])', async function () { + // Some callers pass recipients: [] rather than omitting the field entirely. + // Empty array must be treated the same as absent — consolidation, no intent match. + const result = await tokenCoin.verifyTransaction({ + txPrebuild: { txHex: TRC20_RAW_DATA_HEX }, + txParams: { recipients: [] }, + walletType: 'tss', + } as any); + + assert.strictEqual(result, true); }); it('should throw when contract type is not TriggerSmartContract', async function () {