Skip to content

Commit beea8e5

Browse files
feat(NEB-461): support TRC20 token balances for inactive accounts
Add fallback TRC20 balance fetching for inactive Tron accounts using the `/v1/accounts/{address}/trc20/balance` endpoint when the main account info endpoint returns no data (inactive accounts that haven't paid the 1 TRX activation fee). - Add `getTrc20BalancesByAddress()` method to TrongridApiClient - Implement fallback logic in AssetsService for inactive accounts - Add helper methods for zero-balance native assets and TRC20 extraction - Add comprehensive unit tests for new functionality
1 parent b80f440 commit beea8e5

File tree

9 files changed

+825
-25
lines changed

9 files changed

+825
-25
lines changed

packages/snap/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Support fetching TRC20 token balances for inactive accounts using fallback endpoint ([#190](https://github.com/MetaMask/snap-tron-wallet/pull/190))
13+
1014
## [1.20.0]
1115

1216
### Added

packages/snap/snap.manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"url": "https://github.com/MetaMask/snap-tron-wallet.git"
88
},
99
"source": {
10-
"shasum": "4ePTerkid6qLfLohShjnEntXl74FyR6QOKVMglucJdg=",
10+
"shasum": "JhdOj9LvAjBQqH/ZCwM6V5aIKXXA+6mnWnfNTA7El5g=",
1111
"location": {
1212
"npm": {
1313
"filePath": "dist/bundle.js",

packages/snap/src/clients/tron-http/TronHttpClient.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,14 @@ export class TronHttpClient {
117117
}
118118

119119
/**
120-
* Get account resources (Energy and Bandwidth)
120+
* Get account resources (Energy and Bandwidth).
121+
* For inactive accounts (not yet activated on-chain), returns an empty object `{}` with all resource values effectively being 0.
121122
*
122123
* @see https://developers.tron.network/reference/getaccountresource
123-
* @param network - Network to query
124-
* @param accountAddress - Account address in base58 format
125-
* @returns Promise<AccountResources> - Account resources
124+
* @param network - Network to query.
125+
* @param accountAddress - Account address in base58 format.
126+
* @returns Promise<AccountResources> - Account resources (energy, bandwidth, etc.).
127+
* @throws Error - HTTP errors or configuration errors.
126128
*/
127129
async getAccountResources(
128130
network: Network,
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
/* eslint-disable no-restricted-globals */
2+
/* eslint-disable @typescript-eslint/naming-convention */
3+
import { TrongridApiClient } from './TrongridApiClient';
4+
import type { Trc20Balance } from './types';
5+
import type { ICache } from '../../caching/ICache';
6+
import { Network } from '../../constants';
7+
import type { ConfigProvider } from '../../services/config';
8+
import type { Serializable } from '../../utils/serialization/types';
9+
import type { TronHttpClient } from '../tron-http/TronHttpClient';
10+
11+
describe('TrongridApiClient', () => {
12+
let client: TrongridApiClient;
13+
let mockConfigProvider: ConfigProvider;
14+
let mockTronHttpClient: jest.Mocked<TronHttpClient>;
15+
let mockCache: jest.Mocked<ICache<Serializable>>;
16+
17+
const originalFetch = global.fetch;
18+
19+
beforeEach(() => {
20+
jest.clearAllMocks();
21+
22+
mockConfigProvider = {
23+
get: jest.fn().mockReturnValue({
24+
trongridApi: {
25+
baseUrls: {
26+
[Network.Mainnet]: 'https://api.trongrid.io',
27+
[Network.Nile]: 'https://nile.trongrid.io',
28+
[Network.Shasta]: 'https://api.shasta.trongrid.io',
29+
},
30+
},
31+
}),
32+
} as unknown as ConfigProvider;
33+
34+
mockTronHttpClient = {
35+
getAccountResources: jest.fn(),
36+
getChainParameters: jest.fn(),
37+
getNextMaintenanceTime: jest.fn(),
38+
} as unknown as jest.Mocked<TronHttpClient>;
39+
40+
mockCache = {
41+
get: jest.fn(),
42+
set: jest.fn(),
43+
} as unknown as jest.Mocked<ICache<Serializable>>;
44+
45+
client = new TrongridApiClient({
46+
configProvider: mockConfigProvider,
47+
tronHttpClient: mockTronHttpClient,
48+
cache: mockCache,
49+
});
50+
});
51+
52+
afterEach(() => {
53+
global.fetch = originalFetch;
54+
});
55+
56+
describe('getTrc20BalancesByAddress', () => {
57+
const mockAddress = 'TGJn1wnUYHJbvN88cynZbsAz2EMeZq73yx';
58+
59+
it('fetches and returns TRC20 balances for an address', async () => {
60+
const mockTrc20Balances: Trc20Balance[] = [
61+
{ TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: '24249143' },
62+
{ TGPuQ7g7H8GsUEXhwvvJop4zCncurEh2ht: '88123456' },
63+
];
64+
65+
jest
66+
.spyOn(global, 'fetch')
67+
.mockImplementation()
68+
.mockResolvedValueOnce({
69+
ok: true,
70+
json: jest.fn().mockResolvedValueOnce({
71+
data: mockTrc20Balances,
72+
success: true,
73+
meta: { at: 1770121997373, page_size: 4 },
74+
}),
75+
} as unknown as Response);
76+
77+
const result = await client.getTrc20BalancesByAddress(
78+
Network.Mainnet,
79+
mockAddress,
80+
);
81+
82+
expect(result).toStrictEqual(mockTrc20Balances);
83+
expect(global.fetch).toHaveBeenCalledWith(
84+
`https://api.trongrid.io/v1/accounts/${mockAddress}/trc20/balance`,
85+
expect.objectContaining({
86+
headers: expect.objectContaining({
87+
'Content-Type': 'application/json',
88+
}),
89+
}),
90+
);
91+
});
92+
93+
it('returns empty array when no TRC20 tokens are found', async () => {
94+
jest
95+
.spyOn(global, 'fetch')
96+
.mockImplementation()
97+
.mockResolvedValueOnce({
98+
ok: true,
99+
json: jest.fn().mockResolvedValueOnce({
100+
data: [],
101+
success: true,
102+
meta: { at: 1770121997373, page_size: 0 },
103+
}),
104+
} as unknown as Response);
105+
106+
const result = await client.getTrc20BalancesByAddress(
107+
Network.Mainnet,
108+
mockAddress,
109+
);
110+
111+
expect(result).toStrictEqual([]);
112+
});
113+
114+
it('returns empty array when data is undefined', async () => {
115+
jest
116+
.spyOn(global, 'fetch')
117+
.mockImplementation()
118+
.mockResolvedValueOnce({
119+
ok: true,
120+
json: jest.fn().mockResolvedValueOnce({
121+
success: true,
122+
meta: { at: 1770121997373, page_size: 0 },
123+
}),
124+
} as unknown as Response);
125+
126+
const result = await client.getTrc20BalancesByAddress(
127+
Network.Mainnet,
128+
mockAddress,
129+
);
130+
131+
expect(result).toStrictEqual([]);
132+
});
133+
134+
it('throws error when network is not configured', async () => {
135+
// Create a client with only mainnet configured
136+
const limitedConfigProvider = {
137+
get: jest.fn().mockReturnValue({
138+
trongridApi: {
139+
baseUrls: {
140+
[Network.Mainnet]: 'https://api.trongrid.io',
141+
},
142+
},
143+
}),
144+
} as unknown as ConfigProvider;
145+
146+
const limitedClient = new TrongridApiClient({
147+
configProvider: limitedConfigProvider,
148+
tronHttpClient: mockTronHttpClient,
149+
cache: mockCache,
150+
});
151+
152+
await expect(
153+
limitedClient.getTrc20BalancesByAddress(Network.Nile, mockAddress),
154+
).rejects.toThrow('No client configured for network: tron:3448148188');
155+
});
156+
157+
it('throws error when HTTP request fails', async () => {
158+
jest
159+
.spyOn(global, 'fetch')
160+
.mockImplementation()
161+
.mockResolvedValueOnce({
162+
ok: false,
163+
status: 500,
164+
} as unknown as Response);
165+
166+
await expect(
167+
client.getTrc20BalancesByAddress(Network.Mainnet, mockAddress),
168+
).rejects.toThrow('HTTP error! status: 500');
169+
});
170+
171+
it('throws error when API returns success: false', async () => {
172+
jest
173+
.spyOn(global, 'fetch')
174+
.mockImplementation()
175+
.mockResolvedValueOnce({
176+
ok: true,
177+
json: jest.fn().mockResolvedValueOnce({
178+
data: [],
179+
success: false,
180+
meta: { at: 1770121997373, page_size: 0 },
181+
}),
182+
} as unknown as Response);
183+
184+
await expect(
185+
client.getTrc20BalancesByAddress(Network.Mainnet, mockAddress),
186+
).rejects.toThrow('API request failed');
187+
});
188+
189+
it('works with different networks', async () => {
190+
const mockTrc20Balances: Trc20Balance[] = [{ TTestToken123: '1000000' }];
191+
192+
jest
193+
.spyOn(global, 'fetch')
194+
.mockImplementation()
195+
.mockResolvedValueOnce({
196+
ok: true,
197+
json: jest.fn().mockResolvedValueOnce({
198+
data: mockTrc20Balances,
199+
success: true,
200+
meta: { at: 1770121997373, page_size: 1 },
201+
}),
202+
} as unknown as Response);
203+
204+
const result = await client.getTrc20BalancesByAddress(
205+
Network.Nile,
206+
mockAddress,
207+
);
208+
209+
expect(result).toStrictEqual(mockTrc20Balances);
210+
expect(global.fetch).toHaveBeenCalledWith(
211+
expect.stringContaining('nile.trongrid.io'),
212+
expect.any(Object),
213+
);
214+
});
215+
216+
it('validates TRC20 balance data structure', async () => {
217+
// Valid structure: array of Record<string, string>
218+
const validBalances: Trc20Balance[] = [
219+
{ TokenAddress1: '100' },
220+
{ TokenAddress2: '200' },
221+
];
222+
223+
jest
224+
.spyOn(global, 'fetch')
225+
.mockImplementation()
226+
.mockResolvedValueOnce({
227+
ok: true,
228+
json: jest.fn().mockResolvedValueOnce({
229+
data: validBalances,
230+
success: true,
231+
meta: { at: 1770121997373, page_size: 2 },
232+
}),
233+
} as unknown as Response);
234+
235+
const result = await client.getTrc20BalancesByAddress(
236+
Network.Mainnet,
237+
mockAddress,
238+
);
239+
240+
expect(result).toHaveLength(2);
241+
expect(result[0]).toStrictEqual({ TokenAddress1: '100' });
242+
expect(result[1]).toStrictEqual({ TokenAddress2: '200' });
243+
});
244+
});
245+
});

packages/snap/src/clients/trongrid/TrongridApiClient.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { assert } from '@metamask/superstruct';
22

33
import {
44
ContractTransactionInfoStruct,
5+
Trc20BalanceStruct,
56
TransactionInfoStruct,
67
TronAccountStruct,
78
TrongridApiMetaStruct,
89
} from './structs';
910
import type {
1011
ContractTransactionInfo,
12+
Trc20Balance,
1113
TransactionInfo,
1214
TronAccount,
1315
TrongridApiResponse,
@@ -79,12 +81,15 @@ export class TrongridApiClient {
7981
}
8082

8183
/**
82-
* Get account information by address for a specific network. The returned data will also have assets information.
84+
* Get account information by address for a specific network.
85+
* The returned data includes TRX balance, TRC10 assets, and TRC20 token balances.
8386
*
8487
* @see https://developers.tron.network/reference/get-account-info-by-address
8588
* @param scope - The network to query (e.g., 'mainnet', 'shasta')
8689
* @param address - The TRON address to query
87-
* @returns Promise<TronAccount> - Account data in camelCase
90+
* @returns Promise<TronAccount> - Account data including balances.
91+
* @throws Error - "Account not found or no data returned" for inactive accounts (accounts that haven't paid the 1 TRX activation fee don't exist on-chain).
92+
* @throws Error - HTTP errors or API failures.
8893
*/
8994
async getAccountInfoByAddress(
9095
scope: Network,
@@ -234,6 +239,57 @@ export class TrongridApiClient {
234239
return rawData.data;
235240
}
236241

242+
/**
243+
* Get TRC20 token balances for an account address.
244+
* This endpoint works for inactive accounts that haven't been activated yet.
245+
*
246+
* @see https://developers.tron.network/reference/get-trc20-token-holder-balances
247+
* @param scope - The network to query (e.g., 'mainnet', 'shasta')
248+
* @param address - The TRON address to query
249+
* @returns Promise<Trc20Balance[]> - Array of TRC20 balances (contract address -> balance)
250+
*/
251+
async getTrc20BalancesByAddress(
252+
scope: Network,
253+
address: string,
254+
): Promise<Trc20Balance[]> {
255+
const client = this.#clients.get(scope);
256+
if (!client) {
257+
throw new Error(`No client configured for network: ${scope}`);
258+
}
259+
260+
const { baseUrl, headers } = client;
261+
const url = buildUrl({
262+
baseUrl,
263+
path: '/v1/accounts/{address}/trc20/balance',
264+
pathParams: { address },
265+
});
266+
267+
const response = await fetch(url, { headers });
268+
269+
if (!response.ok) {
270+
throw new Error(`HTTP error! status: ${response.status}`);
271+
}
272+
273+
const rawData: TrongridApiResponse<Trc20Balance[]> = await response.json();
274+
275+
// Validate API response structure
276+
if (typeof rawData.success !== 'boolean' || !rawData.success) {
277+
throw new Error('API request failed');
278+
}
279+
assert(rawData.meta, TrongridApiMetaStruct);
280+
281+
if (!rawData.data) {
282+
return [];
283+
}
284+
285+
// Validate each TRC20 balance entry
286+
for (const balance of rawData.data) {
287+
assert(balance, Trc20BalanceStruct);
288+
}
289+
290+
return rawData.data;
291+
}
292+
237293
/**
238294
* Get chain parameters for a specific network.
239295
* Results are cached until the next maintenance period (every ~6 hours).

packages/snap/src/clients/trongrid/structs.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,22 @@ export const TrongridContractTransactionInfoResponseStruct = type({
221221
success: boolean(),
222222
meta: TrongridApiMetaStruct,
223223
});
224+
225+
// --------------------------------------------------------------------------
226+
// TRC20 Balance Response Structs (for inactive account fallback)
227+
// --------------------------------------------------------------------------
228+
229+
/**
230+
* Struct for validating individual TRC20 balance entries.
231+
* Each entry is a record mapping contract address to balance string.
232+
*/
233+
export const Trc20BalanceStruct = record(string(), string());
234+
235+
/**
236+
* Struct for validating the TRC20 balance API response.
237+
*/
238+
export const TrongridTrc20BalanceResponseStruct = type({
239+
data: array(Trc20BalanceStruct),
240+
success: boolean(),
241+
meta: TrongridApiMetaStruct,
242+
});

0 commit comments

Comments
 (0)