Skip to content

Commit 48929cc

Browse files
committed
Merge branch 'main' into feat/add-estimate-gas-fee-batch-tx
2 parents dd6fefd + 68a0d00 commit 48929cc

36 files changed

+896
-158
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@metamask/core-monorepo",
3-
"version": "430.0.0",
3+
"version": "432.0.0",
44
"private": true,
55
"description": "Monorepo for packages shared between MetaMask clients",
66
"repository": {

packages/account-tree-controller/src/AccountTreeController.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export type AccountWallet = {
6868
groups: {
6969
[groupId: AccountGroupId]: AccountGroup;
7070
};
71-
metadata: AccountGroupMetadata; // Assuming Metadata is a defined type
71+
metadata: AccountWalletMetadata;
7272
};
7373

7474
export type AccountTreeControllerState = {

packages/assets-controllers/CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ 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+
16+
### Fixed
17+
18+
- Prevented `AccountTrackerController` from updating state with empty or unchanged account balance data during refresh ([#5942](https://github.com/MetaMask/core/pull/5942))
19+
- Added guards to skip state updates when fetched balances are empty or identical to existing state
20+
- Reduces unnecessary `stateChange` emissions and preserves previously-cached balances under network failure scenarios
21+
1022
## [68.1.0]
1123

1224
### Added
@@ -18,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1830
### Changed
1931

2032
- 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
2135

2236
## [68.0.0]
2337

packages/assets-controllers/src/AccountTrackerController.ts

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { StaticIntervalPollingController } from '@metamask/polling-controller';
2424
import type { PreferencesControllerGetStateAction } from '@metamask/preferences-controller';
2525
import { assert } from '@metamask/utils';
2626
import { Mutex } from 'async-mutex';
27-
import { cloneDeep } from 'lodash';
27+
import { cloneDeep, isEqual } from 'lodash';
2828

2929
import type {
3030
AssetsContractController,
@@ -193,13 +193,18 @@ export class AccountTrackerController extends StaticIntervalPollingController<Ac
193193

194194
this.messagingSystem.subscribe(
195195
'AccountsController:selectedEvmAccountChange',
196-
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
197-
// eslint-disable-next-line @typescript-eslint/no-misused-promises
198-
() => this.refresh(this.#getNetworkClientIds()),
196+
(newAddress, prevAddress) => {
197+
if (newAddress !== prevAddress) {
198+
// Making an async call for this new event
199+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
200+
this.refresh(this.#getNetworkClientIds());
201+
}
202+
},
203+
(event): string => event.address,
199204
);
200205
}
201206

202-
private syncAccounts(newChainId: string) {
207+
private syncAccounts(newChainIds: string[]) {
203208
const accountsByChainId = cloneDeep(this.state.accountsByChainId);
204209
const { selectedNetworkClientId } = this.messagingSystem.call(
205210
'NetworkController:getState',
@@ -213,12 +218,15 @@ export class AccountTrackerController extends StaticIntervalPollingController<Ac
213218

214219
const existing = Object.keys(accountsByChainId?.[currentChainId] ?? {});
215220

216-
if (!accountsByChainId[newChainId]) {
217-
accountsByChainId[newChainId] = {};
218-
existing.forEach((address) => {
219-
accountsByChainId[newChainId][address] = { balance: '0x0' };
220-
});
221-
}
221+
// Initialize new chain IDs if they don't exist
222+
newChainIds.forEach((newChainId) => {
223+
if (!accountsByChainId[newChainId]) {
224+
accountsByChainId[newChainId] = {};
225+
existing.forEach((address) => {
226+
accountsByChainId[newChainId][address] = { balance: '0x0' };
227+
});
228+
}
229+
});
222230

223231
// Note: The address from the preferences controller are checksummed
224232
// The addresses from the accounts controller are lowercased
@@ -249,9 +257,11 @@ export class AccountTrackerController extends StaticIntervalPollingController<Ac
249257
});
250258
});
251259

252-
this.update((state) => {
253-
state.accountsByChainId = accountsByChainId;
254-
});
260+
if (!isEqual(this.state.accountsByChainId, accountsByChainId)) {
261+
this.update((state) => {
262+
state.accountsByChainId = accountsByChainId;
263+
});
264+
}
255265
}
256266

257267
/**
@@ -327,11 +337,17 @@ export class AccountTrackerController extends StaticIntervalPollingController<Ac
327337
);
328338
const releaseLock = await this.#refreshMutex.acquire();
329339
try {
340+
const chainIds = networkClientIds.map((networkClientId) => {
341+
const { chainId } = this.#getCorrectNetworkClient(networkClientId);
342+
return chainId;
343+
});
344+
345+
this.syncAccounts(chainIds);
346+
330347
// Create an array of promises for each networkClientId
331348
const updatePromises = networkClientIds.map(async (networkClientId) => {
332349
const { chainId, ethQuery } =
333350
this.#getCorrectNetworkClient(networkClientId);
334-
this.syncAccounts(chainId);
335351
const { accountsByChainId } = this.state;
336352
const { isMultiAccountBalancesEnabled } = this.messagingSystem.call(
337353
'PreferencesController:getState',
@@ -394,15 +410,28 @@ export class AccountTrackerController extends StaticIntervalPollingController<Ac
394410
// Wait for all networkClientId updates to settle in parallel
395411
const allResults = await Promise.allSettled(updatePromises);
396412

397-
// Update the state once all networkClientId updates are completed
413+
// Build a _copy_ of the current state and track whether anything changed
414+
const nextAccountsByChainId: AccountTrackerControllerState['accountsByChainId'] =
415+
cloneDeep(this.state.accountsByChainId);
416+
let hasChanges = false;
417+
398418
allResults.forEach((result) => {
399419
if (result.status === 'fulfilled') {
400420
const { chainId, accountsForChain } = result.value;
401-
this.update((state) => {
402-
state.accountsByChainId[chainId] = accountsForChain;
403-
});
421+
// Only mark as changed if the incoming data differs
422+
if (!isEqual(nextAccountsByChainId[chainId], accountsForChain)) {
423+
nextAccountsByChainId[chainId] = accountsForChain;
424+
hasChanges = true;
425+
}
404426
}
405427
});
428+
429+
// 👇🏻 call `update` only when something is new / different
430+
if (hasChanges) {
431+
this.update((state) => {
432+
state.accountsByChainId = nextAccountsByChainId;
433+
});
434+
}
406435
} finally {
407436
releaseLock();
408437
}

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 & {

packages/notification-services-controller/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@
123123
"@lavamoat/preinstall-always-fail": "^2.1.0",
124124
"@metamask/auto-changelog": "^3.4.4",
125125
"@metamask/keyring-controller": "^22.0.2",
126-
"@metamask/profile-sync-controller": "^17.0.0",
126+
"@metamask/profile-sync-controller": "^17.1.0",
127127
"@types/jest": "^27.4.1",
128128
"@types/readable-stream": "^2.3.0",
129129
"contentful": "^10.15.0",

packages/profile-sync-controller/CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [17.1.0]
11+
12+
### Added
13+
14+
- Add `EventQueue` class util to guarantee the order of some user-storage updates ([#5937](https://github.com/MetaMask/core/pull/5937))
15+
- Add an instance of `EventQueue` to `UserStorageController`
16+
- Event subscriptions for `AccountsController:accountAdded` and `AccountsController:accountRenamed` are now pushing their callbacks to the `UserStorageController` instance of `EventQueue`, so that we stay in control of the order these callbacks are fulfilled.
17+
1018
## [17.0.0]
1119

1220
### Added
@@ -614,7 +622,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
614622

615623
- Initial release
616624

617-
[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/[email protected]
625+
[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/[email protected]
626+
[17.1.0]: https://github.com/MetaMask/core/compare/@metamask/[email protected]...@metamask/[email protected]
618627
[17.0.0]: https://github.com/MetaMask/core/compare/@metamask/[email protected]...@metamask/[email protected]
619628
[16.0.0]: https://github.com/MetaMask/core/compare/@metamask/[email protected]...@metamask/[email protected]
620629
[15.0.0]: https://github.com/MetaMask/core/compare/@metamask/[email protected]...@metamask/[email protected]

packages/profile-sync-controller/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@metamask/profile-sync-controller",
3-
"version": "17.0.0",
3+
"version": "17.1.0",
44
"description": "The profile sync helps developers synchronize data across multiple clients and devices in a privacy-preserving way. All data saved in the user storage database is encrypted client-side to preserve privacy. The user storage provides a modular design, giving developers the flexibility to construct and manage their storage spaces in a way that best suits their needs",
55
"keywords": [
66
"MetaMask",

0 commit comments

Comments
 (0)