Skip to content

Commit aeb7f8b

Browse files
authored
feat: add getErc20Balances helper TokenBalancesControllers (#5925)
## Explanation This change introduces a new `getErc20Balances` service function within the `TokenBalancesController`. The goal is to provide a reusable and isolated utility to fetch ERC-20 token balances for a given address and token list. The service is intended to be used internally by the controller or other consumers needing direct access to raw ERC-20 balances, while abstracting away Web3 calls and error handling. <!-- Thanks for your contribution! Take a moment to answer these questions so that reviewers have the information they need to properly understand your changes: * What is the current state of things and why does it need to change? * What is the solution your changes offer and how does it work? * Are there any changes whose purpose might not obvious to those unfamiliar with the domain? * If your primary goal was to update one package but you found you had to update another one along the way, why did you do so? * If you had to upgrade a dependency, why did you do so? --> ## References <!-- Are there any issues that this pull request is tied to? Are there other links that reviewers should consult to understand these changes better? Are there client or consumer pull requests to adopt any breaking changes? For example: * Fixes #12345 * Related to #67890 --> ## Changelog ### `@metamask/assets-controllers` UPDATE: Created getErc20Balances function within TokenBalancesController to support fetching ERC-20 token balances for a given address and token list. This modular service simplifies balance retrieval logic and can be reused across different parts of the controller. <!-- THIS SECTION IS NO LONGER NEEDED. The process for updating changelogs has changed. Please consult the "Updating changelogs" section of the Contributing doc for more. --> ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes
1 parent e88bcf2 commit aeb7f8b

File tree

3 files changed

+158
-13
lines changed

3 files changed

+158
-13
lines changed

packages/assets-controllers/CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Changed
11+
12+
- Bump `@metamask/controller-utils` to `^11.10.0` ([#5935](https://github.com/MetaMask/core/pull/5935))
13+
- Add `getErc20Balances` function within `TokenBalancesController` to support fetching ERC-20 token balances for a given address and token list ([#5925](https://github.com/MetaMask/core/pull/5925))
14+
- This modular service simplifies balance retrieval logic and can be reused across different parts of the controller
15+
1016
### Fixed
1117

1218
- Prevented `AccountTrackerController` from updating state with empty or unchanged account balance data during refresh ([#5942](https://github.com/MetaMask/core/pull/5942))
@@ -24,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2430
### Changed
2531

2632
- Bump `@metamask/controller-utils` to `^11.10.0` ([#5935](https://github.com/MetaMask/core/pull/5935))
33+
- Add `getErc20Balances` function within `TokenBalancesController` to support fetching ERC-20 token balances for a given address and token list ([#5925](https://github.com/MetaMask/core/pull/5925))
34+
- This modular service simplifies balance retrieval logic and can be reused across different parts of the controller
2735

2836
## [68.0.0]
2937

packages/assets-controllers/src/TokenBalancesController.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,4 +832,70 @@ describe('TokenBalancesController', () => {
832832
});
833833
});
834834
});
835+
836+
describe('getErc20Balances', () => {
837+
const chainId = '0x1';
838+
const account = '0x0000000000000000000000000000000000000000';
839+
const tokenA = '0x00000000000000000000000000000000000000a1';
840+
const tokenB = '0x00000000000000000000000000000000000000b2';
841+
842+
afterEach(() => {
843+
// make sure spies do not leak between tests
844+
jest.restoreAllMocks();
845+
});
846+
847+
it('returns an **empty object** if no token addresses are provided', async () => {
848+
const { controller } = setupController();
849+
const balances = await controller.getErc20Balances({
850+
chainId,
851+
accountAddress: account,
852+
tokenAddresses: [],
853+
});
854+
855+
expect(balances).toStrictEqual({});
856+
});
857+
858+
it('maps **each address to a hex balance** on success', async () => {
859+
const bal1 = 42;
860+
const bal2 = 0;
861+
862+
jest.spyOn(multicall, 'multicallOrFallback').mockResolvedValueOnce([
863+
{ success: true, value: new BN(bal1) },
864+
{ success: true, value: new BN(bal2) },
865+
]);
866+
867+
const { controller } = setupController();
868+
869+
const balances = await controller.getErc20Balances({
870+
chainId,
871+
accountAddress: account,
872+
tokenAddresses: [tokenA, tokenB],
873+
});
874+
875+
expect(balances).toStrictEqual({
876+
[tokenA]: toHex(bal1),
877+
[tokenB]: toHex(bal2), // zero balance is still a success
878+
});
879+
});
880+
881+
it('returns **null** for tokens whose `balanceOf` call failed', async () => {
882+
jest.spyOn(multicall, 'multicallOrFallback').mockResolvedValueOnce([
883+
{ success: false, value: null },
884+
{ success: true, value: new BN(7) },
885+
]);
886+
887+
const { controller } = setupController();
888+
889+
const balances = await controller.getErc20Balances({
890+
chainId,
891+
accountAddress: account,
892+
tokenAddresses: [tokenA, tokenB],
893+
});
894+
895+
expect(balances).toStrictEqual({
896+
[tokenA]: null, // failed call
897+
[tokenB]: toHex(7), // succeeded call
898+
});
899+
});
900+
});
835901
});

packages/assets-controllers/src/TokenBalancesController.ts

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,86 @@ export class TokenBalancesController extends StaticIntervalPollingController<Tok
411411
}
412412
}
413413

414+
/**
415+
* Get an Ethers.js Web3Provider for the requested chain.
416+
*
417+
* @param chainId - The chain id to get the provider for.
418+
* @returns The provider for the given chain id.
419+
*/
420+
#getProvider(chainId: Hex): Web3Provider {
421+
return new Web3Provider(this.#getNetworkClient(chainId).provider);
422+
}
423+
424+
/**
425+
* Internal util: run `balanceOf` for an arbitrary set of account/token pairs.
426+
*
427+
* @param params - The parameters for the balance fetch.
428+
* @param params.chainId - The chain id to fetch balances on.
429+
* @param params.pairs - The account/token pairs to fetch balances for.
430+
* @returns The balances for the given token addresses.
431+
*/
432+
async #batchBalanceOf({
433+
chainId,
434+
pairs,
435+
}: {
436+
chainId: Hex;
437+
pairs: { accountAddress: Hex; tokenAddress: Hex }[];
438+
}): Promise<MulticallResult[]> {
439+
if (!pairs.length) {
440+
return [];
441+
}
442+
443+
const provider = this.#getProvider(chainId);
444+
445+
const calls = pairs.map(({ accountAddress, tokenAddress }) => ({
446+
contract: new Contract(tokenAddress, abiERC20, provider),
447+
functionSignature: 'balanceOf(address)',
448+
arguments: [accountAddress],
449+
}));
450+
451+
return multicallOrFallback(calls, chainId, provider);
452+
}
453+
454+
/**
455+
* Returns ERC-20 balances for a single account on a single chain.
456+
*
457+
* @param params - The parameters for the balance fetch.
458+
* @param params.chainId - The chain id to fetch balances on.
459+
* @param params.accountAddress - The account address to fetch balances for.
460+
* @param params.tokenAddresses - The token addresses to fetch balances for.
461+
* @returns A mapping from token address to balance (hex) | null.
462+
*/
463+
async getErc20Balances({
464+
chainId,
465+
accountAddress,
466+
tokenAddresses,
467+
}: {
468+
chainId: Hex;
469+
accountAddress: Hex;
470+
tokenAddresses: Hex[];
471+
}): Promise<Record<Hex, Hex | null>> {
472+
// Return early if no token addresses provided
473+
if (tokenAddresses.length === 0) {
474+
return {};
475+
}
476+
477+
const pairs = tokenAddresses.map((tokenAddress) => ({
478+
accountAddress,
479+
tokenAddress,
480+
}));
481+
482+
const results = await this.#batchBalanceOf({ chainId, pairs });
483+
484+
const balances: Record<Hex, Hex | null> = {};
485+
tokenAddresses.forEach((tokenAddress, i) => {
486+
balances[tokenAddress] = results[i]?.success
487+
? toHex(results[i].value as BN)
488+
: null;
489+
});
490+
491+
return balances;
492+
}
493+
414494
/**
415495
* Updates token balances for the given chain id.
416496
* @param input - The input for the update.
@@ -448,19 +528,10 @@ export class TokenBalancesController extends StaticIntervalPollingController<Tok
448528
);
449529

450530
if (accountTokenPairs.length > 0) {
451-
const provider = new Web3Provider(
452-
this.#getNetworkClient(chainId).provider,
453-
);
454-
455-
const calls = accountTokenPairs.map(
456-
({ accountAddress, tokenAddress }) => ({
457-
contract: new Contract(tokenAddress, abiERC20, provider),
458-
functionSignature: 'balanceOf(address)',
459-
arguments: [accountAddress],
460-
}),
461-
);
462-
463-
results = await multicallOrFallback(calls, chainId, provider);
531+
results = await this.#batchBalanceOf({
532+
chainId,
533+
pairs: accountTokenPairs,
534+
});
464535
}
465536

466537
const updatedResults: (MulticallResult & {

0 commit comments

Comments
 (0)