From eb57a0f5970a7abe55c15f8a321c306c9407f2c5 Mon Sep 17 00:00:00 2001 From: Support Bot Date: Thu, 16 Jul 2026 14:40:58 +0000 Subject: [PATCH] fix(sdk-coin-trx): guard against missing trc20 field in account response The TRON node API omits the `trc20` field entirely from account info responses for accounts that have never interacted with TRC20 tokens. Three recovery paths iterated over `account.data[0].trc20` without first checking whether the field exists, causing a TypeError crash ("Cannot read properties of undefined (reading Symbol.iterator)") whenever consolidation or recovery was attempted and any scanned receive address had TRX balance but no prior TRC20 history. Fix: use the `?? []` nullish coalescing fallback when iterating in `recover()`, `recoverTss()`, and `recoverConsolidations()`. Also mark the `trc20` field optional in `AccountInfo` to reflect the real API contract. Add unit tests for all three code paths that confirm graceful handling when the `trc20` field is absent (the base-address recovery and TSS recovery throw a clear "token not found" error, and consolidation skips the address instead of crashing). Ticket: SPT-101 Session-Id: e2414be7-1b86-4412-b43c-7267b56dac1d Task-Id: 1123cac4-3d06-4115-8039-d9aea9e761aa --- modules/sdk-coin-trx/src/lib/iface.ts | 2 +- modules/sdk-coin-trx/src/trx.ts | 9 +-- modules/sdk-coin-trx/test/resources.ts | 90 ++++++++++++++++++++++++++ modules/sdk-coin-trx/test/unit/trx.ts | 72 +++++++++++++++++++++ 4 files changed, 168 insertions(+), 5 deletions(-) diff --git a/modules/sdk-coin-trx/src/lib/iface.ts b/modules/sdk-coin-trx/src/lib/iface.ts index e7ab0bda71..0056aa3b01 100644 --- a/modules/sdk-coin-trx/src/lib/iface.ts +++ b/modules/sdk-coin-trx/src/lib/iface.ts @@ -125,7 +125,7 @@ export interface AccountInfo { keys: [PermissionKey]; }; active_permission: [{ keys: [PermissionKey] }]; - trc20: [Record]; + trc20?: Record[]; } /** diff --git a/modules/sdk-coin-trx/src/trx.ts b/modules/sdk-coin-trx/src/trx.ts index 0a00781e88..1b9e2e782f 100644 --- a/modules/sdk-coin-trx/src/trx.ts +++ b/modules/sdk-coin-trx/src/trx.ts @@ -871,7 +871,7 @@ export class Trx extends BaseCoin { // check for possible token recovery, recover the token provide by user if (tokenContractAddr) { let rawTokenTxn: any | undefined; - for (const token of account.data[0].trc20) { + for (const token of account.data[0].trc20 ?? []) { if (token[tokenContractAddr]) { const amount = token[tokenContractAddr]; const tokenContractAddrHex = Utils.getHexAddressFromBase58Address(tokenContractAddr); @@ -1053,7 +1053,7 @@ export class Trx extends BaseCoin { let rawTokenTxn: any | undefined; let recoveryAmount = 0; - for (const token of account.data[0].trc20) { + for (const token of account.data[0].trc20 ?? []) { if (token[tokenContractAddr]) { const amount = token[tokenContractAddr]; const tokenContractAddrHex = Utils.getHexAddressFromBase58Address(tokenContractAddr); @@ -1165,8 +1165,9 @@ export class Trx extends BaseCoin { // check for possible token recovery, recover the token provide by user if (params.tokenContractAddress) { - if (accountInfo.data[0].balance > SAFE_TRON_TOKEN_TRANSACTION_FEE && accountInfo.data[0].trc20[0]) { - const tokenDataArray = accountInfo.data[0].trc20; + const trc20Data = accountInfo.data[0].trc20 ?? []; + if (accountInfo.data[0].balance > SAFE_TRON_TOKEN_TRANSACTION_FEE && trc20Data.length > 0) { + const tokenDataArray = trc20Data; for (const tokenData of tokenDataArray) { const contractAddress = Object.keys(tokenData) as Array; if (params.tokenContractAddress === contractAddress[0]) { diff --git a/modules/sdk-coin-trx/test/resources.ts b/modules/sdk-coin-trx/test/resources.ts index 11dd695711..e770293623 100644 --- a/modules/sdk-coin-trx/test/resources.ts +++ b/modules/sdk-coin-trx/test/resources.ts @@ -353,6 +353,96 @@ export function receiveAddressBalance(balance: number, address: string, trc20Bal }; } +export function receiveAddressBalanceWithoutTrc20Field(balance: number, address: string) { + return { + data: [ + { + owner_permission: { + keys: [ + { + address: address, + weight: 1, + }, + ], + threshold: 1, + permission_name: 'owner', + }, + active_permission: [ + { + operations: '7fff1fc0033ec30f000000000000000000000000000000000000000000000000', + keys: [ + { + address: address, + weight: 1, + }, + ], + threshold: 1, + id: 2, + type: 'Active', + permission_name: 'active', + }, + ], + balance: balance, + // trc20 field intentionally absent — real TRON node omits it for + // accounts that have never interacted with TRC20 tokens + }, + ], + }; +} + +export function baseAddressBalanceWithoutTrc20Field(trxBalance: number) { + return { + data: [ + { + owner_permission: { + keys: [ + { + address: 'TTgisRP7EJWMgpLXvbNHoHh5UotkjkBPoo', + weight: 1, + }, + { + address: 'TBDy8HAy8vvhoqKc5V1hHQatfjZHM1MhPb', + weight: 1, + }, + { + address: 'TPHPDfQ8Vs3Yp5UKDLHr5MjoVUrr5Y69m9', + weight: 1, + }, + ], + threshold: 2, + permission_name: 'owner', + }, + balance: trxBalance, + // trc20 field intentionally absent — real TRON node omits it for + // accounts that have never interacted with TRC20 tokens + active_permission: [ + { + operations: '7fff1fc0037e0000000000000000000000000000000000000000000000000000', + keys: [ + { + address: 'TTgisRP7EJWMgpLXvbNHoHh5UotkjkBPoo', + weight: 1, + }, + { + address: 'TBDy8HAy8vvhoqKc5V1hHQatfjZHM1MhPb', + weight: 1, + }, + { + address: 'TPHPDfQ8Vs3Yp5UKDLHr5MjoVUrr5Y69m9', + weight: 1, + }, + ], + threshold: 2, + id: 2, + type: 'Active', + permission_name: 'active0', + }, + ], + }, + ], + }; +} + export function creationTransaction(fromAddress: string, toAddress: string, amount: number) { return { visible: false, diff --git a/modules/sdk-coin-trx/test/unit/trx.ts b/modules/sdk-coin-trx/test/unit/trx.ts index a8cccb56f6..8c0a304f29 100644 --- a/modules/sdk-coin-trx/test/unit/trx.ts +++ b/modules/sdk-coin-trx/test/unit/trx.ts @@ -7,8 +7,10 @@ import { Trx, Ttrx, Utils } from '../../src'; import { signTxOptions, mockTx } from '../fixtures'; import { baseAddressBalance, + baseAddressBalanceWithoutTrc20Field, SampleRawTokenSendTxn, receiveAddressBalance, + receiveAddressBalanceWithoutTrc20Field, TestRecoverData, TssTestRecoverData, creationTransaction, @@ -442,6 +444,28 @@ describe('TRON:', function () { } ); }); + + it('should throw if trc20 field absent and token recovery requested (base address)', async function () { + mock.method(Trx.prototype as any, 'getAccountBalancesFromNode', (...args) => { + if (args.length > 0 && args[0] === TestRecoverData.baseAddress) { + return Promise.resolve(baseAddressBalanceWithoutTrc20Field(100000000)); + } + return undefined; + }); + + await assert.rejects( + basecoin.recover({ + userKey: TestRecoverData.userKey, + backupKey: TestRecoverData.backupKey, + bitgoKey: TestRecoverData.bitgoKey, + tokenContractAddress: 'TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs', + recoveryDestination: TestRecoverData.recoveryDestination, + }), + { + message: 'Not found token to recover, please check token balance', + } + ); + }); }); describe('Build Unsigned Consolidation Recoveries', () => { @@ -633,6 +657,34 @@ describe('TRON:', function () { assert.equal(Utils.getBase58AddressFromHex(value1.owner_address), TestRecoverData.firstReceiveAddress); assert.equal(Utils.getBase58AddressFromHex(value1.to_address), TestRecoverData.baseAddress); }); + + it('should skip token consolidation when trc20 field absent from account response', async () => { + mock.method(Trx.prototype as any, 'getAccountBalancesFromNode', (...args) => { + if (args.length > 0 && args[0] === TestRecoverData.firstReceiveAddress) { + return Promise.resolve( + receiveAddressBalanceWithoutTrc20Field(202100000, TestRecoverData.firstReceiveAddress) + ); + } + + if (args.length > 0 && args[0] === TestRecoverData.secondReceiveAddress) { + return Promise.resolve(receiveAddressBalanceWithoutTrc20Field(500, TestRecoverData.secondReceiveAddress)); + } + + return undefined; + }); + + const res = await basecoin.recoverConsolidations({ + userKey: TestRecoverData.userKey, + backupKey: TestRecoverData.backupKey, + bitgoKey: TestRecoverData.bitgoKey, + tokenContractAddress: 'TSdZwNqpHofzP6BsBKGQUWdBeJphLmF6id', + startingScanIndex: 1, + endingScanIndex: 3, + }); + + assert.ok(Object.prototype.hasOwnProperty.call(res, 'transactions')); + assert.equal(res.transactions.length, 0); + }); }); describe('TSS Recovery', () => { @@ -824,6 +876,26 @@ describe('TRON:', function () { } ); }); + + it('should throw if trc20 field absent for TSS wallet token recovery', async function () { + mock.method(Trx.prototype as any, 'getAccountBalancesFromNode', () => { + return Promise.resolve(baseAddressBalanceWithoutTrc20Field(100000000)); + }); + + await assert.rejects( + basecoin.recover({ + userKey: TssTestRecoverData.userKey, + backupKey: TssTestRecoverData.backupKey, + bitgoKey: TssTestRecoverData.bitgoKey, + tokenContractAddress: 'TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs', + recoveryDestination: TssTestRecoverData.recoveryDestination, + isTss: true, + }), + { + message: 'Not found token to recover, please check token balance', + } + ); + }); }); describe('isWalletAddress', () => {