diff --git a/packages/assets-controller/src/AssetsController.stale-balance-scenarios.integration.test.ts b/packages/assets-controller/src/AssetsController.stale-balance-scenarios.integration.test.ts new file mode 100644 index 00000000000..6f02ec0d7bb --- /dev/null +++ b/packages/assets-controller/src/AssetsController.stale-balance-scenarios.integration.test.ts @@ -0,0 +1,937 @@ +import type { BalanceUpdate } from '@metamask/core-backend'; +import { TransactionStatus } from '@metamask/transaction-controller'; +import { cleanAll } from 'nock'; + +import { createMockMessengers } from './__fixtures__/MockAssetControllerMessenger.js'; +import type { + MockRootMessenger, + RegisterWalletLifecycleMocksOptions, +} from './__fixtures__/MockAssetControllerMessenger.js'; +import { createTestApiClient } from './__fixtures__/mockTokenApi.js'; +import { mockStaleBalanceApis } from './__fixtures__/stale-balance/api-responses/index.js'; +import { + buildStaleBalanceProviderStates, + registerStaleBalanceControllerActions, +} from './__fixtures__/stale-balance/messenger.js'; +import type { StaleBalanceProviderState } from './__fixtures__/stale-balance/provider.js'; +import { + buildCapturedBscBalances, + buildDoubledBscBalances, + buildDoubledBscNativeBalance, + buildDoubledBscTokenBalances, +} from './__fixtures__/stale-balance/rpcAmounts.js'; +import { buildSnapState } from './__fixtures__/stale-balance/snap.js'; +import type { StaleBalanceSnapState } from './__fixtures__/stale-balance/snap.js'; +import { + buildBscAccount, + buildEmptySolanaSnapState, + buildEmptyStaleBalanceState, + buildMainnetAccount, + buildSolanaSnapAccount, +} from './__fixtures__/stale-balance/staleBalanceWallet.js'; +import { + BNB_ASSET_ID, + BSC_ACCOUNT_ID, + BSC_CHAIN_ID, + ETH_ASSET_ID, + GTAI_ASSET_ID_CHECKSUM, + HOODI_CHAIN_ID, + HOODI_NATIVE_ASSET_ID, + HOODI_STAKED_ETH_ASSET_ID, + HOODI_STAKING_CONTRACT, + MAINNET_ACCOUNT_ID, + MAINNET_CHAIN_ID, + MAINNET_NETWORK_CLIENT_ID, + MAINNET_STAKED_ETH_ASSET_ID, + MAINNET_STAKING_CONTRACT, + SOL_ASSET_ID, + SOLANA_ACCOUNT_ID, + SOLANA_CHAIN_ID, + SOLANA_JUP_ASSET_ID, + SOLANA_USDC_ASSET_ID, + STALE_WALLET_ADDRESS, + USDT_ASSET_ID_CHECKSUM, + USDT_CONTRACT, +} from './__fixtures__/stale-balance/wallet.js'; +import { + getIgnoringCase, + waitFor, + waitUntilStable, +} from './__fixtures__/test-utils.js'; +import { AssetsController } from './AssetsController.js'; +import type { AssetsControllerState } from './AssetsController.js'; +import { normalizeAmountString } from './utils/normalizeAmountString.js'; + +/** + * Integration coverage for the twelve stale-balance scenarios of + * `docs/decisions/2026-09-23-accounts-api-v6-integration.md` — the + * "Stale balance scenarios (`full`)" and "Stale balance scenarios + * (`merge`)" tables. Boots the real controller against the same captured + * APIs as the other v6 integration suites and breaks one external boundary + * at a time. + */ + +/** The captured BNB Chain balances, keyed by lower-cased asset ID. */ +const BSC_CAPTURED = buildCapturedBscBalances(); + +/** The doubled BNB Chain balances the RPC provider serves, lower-cased. */ +const BSC_DOUBLED = buildDoubledBscBalances(); + +/** The lower-cased form of the USDT asset ID, as the captures key it. */ +const USDT_ASSET_ID_LOWER = USDT_ASSET_ID_CHECKSUM.toLowerCase(); + +type StateSurface = { + surface: string; + lookUp: (state: AssetsControllerState, assetId: string) => unknown; + getAssetIds: (state: AssetsControllerState) => Set; + lookupAmount: ( + state: AssetsControllerState, + assetId: string, + ) => string | undefined; +}; + +type StaleBalanceHarnessOptions = { + state?: Partial; + /** Mutations of the captured Accounts API v6 balances response. */ + apiMutations?: { + omitAssetIds?: string[]; + setBalances?: Record; + unprocessedNetworks?: string[]; + }; + /** EVM provider state overrides per chain. */ + providers?: { + bsc?: Partial; + mainnet?: Partial; + hoodi?: Partial; + }; + /** The Solana keyring snap's responses. */ + snap?: Partial; + /** Lifecycle and feature flags; defaults keep the controller dormant. */ + lifecycle?: RegisterWalletLifecycleMocksOptions; +}; + +type StaleBalanceHarnessCallback = (args: { + controller: AssetsController; + messenger: MockRootMessenger; + /** The per-chain provider states, mutable between phases. */ + providerStates: Record; + /** The Solana snap state, mutable between phases. */ + snapState: StaleBalanceSnapState; +}) => Promise; + +async function withStaleBalanceController( + { + state = buildEmptyStaleBalanceState(), + apiMutations = {}, + providers = {}, + snap = {}, + lifecycle = { + isKeyringUnlocked: false, + isAccountTreeInitialized: false, + clientControllerState: { isUiOpen: false }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, + }, + }: StaleBalanceHarnessOptions, + fn: StaleBalanceHarnessCallback, +): Promise { + const mocks = mockStaleBalanceApis(apiMutations); + const providerStates = buildStaleBalanceProviderStates(providers); + const snapState = buildSnapState(snap); + + const { rootMessenger, assetsControllerMessenger } = createMockMessengers({ + registerCustomRootActions: (messenger) => + registerStaleBalanceControllerActions(messenger, { + providerStates, + snapState, + lifecycle, + }), + }); + + const queryApiClient = createTestApiClient(); + const controller = new AssetsController({ + messenger: assetsControllerMessenger, + state, + queryApiClient, + isBasicFunctionality: (): boolean => true, + }); + + try { + // `AccountsApiDataSource` loads `/v2/supportedNetworks` asynchronously + // on boot; wait for it so the explicit fetch below cannot race it. + await waitFor(() => + expect(mocks.accountsSupportedNetworks.isDone()).toBe(true), + ); + return await fn({ + controller, + messenger: rootMessenger, + providerStates, + snapState, + }); + } finally { + controller.destroy(); + queryApiClient.clear(); + } +} + +/** The Solana wallet as the snap reports it: SOL, JUP, and the pinned USDC. */ +const SOLANA_SNAP_BALANCES = { + [SOL_ASSET_ID]: { amount: '3.25', unit: 'SOL' }, + [SOLANA_JUP_ASSET_ID]: { amount: '40.25', unit: 'JUP' }, + [SOLANA_USDC_ASSET_ID]: { amount: '125.5', unit: 'USDC' }, +} as const; + +/** + * Fetch the Solana snap account's balances over the snap. + * + * @param options - The snap's responses and the starting state. + * @param options.snap - Overrides of the healthy snap responses. + * @param options.state - The starting controller state. + * @returns The controller state after the fetch settles. + */ +async function fetchSolanaSnapWallet({ + snap = {}, + state = buildEmptySolanaSnapState(), +}: { + snap?: Partial; + state?: Partial; +} = {}): Promise { + return await withStaleBalanceController( + { + state, + snap: { + listedAssetIds: [SOL_ASSET_ID, SOLANA_JUP_ASSET_ID], + balances: SOLANA_SNAP_BALANCES, + ...snap, + }, + }, + async ({ controller }) => { + await controller.getAssets([buildSolanaSnapAccount()], { + chainIds: [SOLANA_CHAIN_ID], + forceUpdate: true, + }); + await waitUntilStable(() => controller.state); + return controller.state; + }, + ); +} + +describe('AssetsController stale-balance scenarios: Solana keyring snap', () => { + const BALANCES: StateSurface = { + surface: 'balances', + lookUp: (state, assetId) => + getIgnoringCase(state.assetsBalance[SOLANA_ACCOUNT_ID] ?? {}, assetId), + getAssetIds: (state) => + new Set( + Object.keys(state.assetsBalance[SOLANA_ACCOUNT_ID] ?? {}).map((key) => + key.toLowerCase(), + ), + ), + lookupAmount: (state, assetId) => + ( + getIgnoringCase( + state.assetsBalance[SOLANA_ACCOUNT_ID] ?? {}, + assetId, + ) as { amount?: string } | undefined + )?.amount, + }; + + afterEach(() => { + cleanAll(); + }); + + it('1: keeps the last amounts on screen when the snap is unreachable', async () => { + const seeded = await fetchSolanaSnapWallet(); + cleanAll(); + + const unreachable = await fetchSolanaSnapWallet({ + state: buildEmptySolanaSnapState({ + assetsBalance: seeded.assetsBalance, + assetsInfo: seeded.assetsInfo, + assetsPrice: seeded.assetsPrice, + }), + snap: { failAll: true }, + }); + + expect(BALANCES.lookupAmount(unreachable, SOL_ASSET_ID)).toBe('3.25'); + expect(BALANCES.lookupAmount(unreachable, SOLANA_JUP_ASSET_ID)).toBe( + '40.25', + ); + expect(BALANCES.lookupAmount(unreachable, SOLANA_USDC_ASSET_ID)).toBe( + '125.5', + ); + expect(BALANCES.getAssetIds(unreachable)).toStrictEqual( + BALANCES.getAssetIds(seeded), + ); + }); + + it('2: copies the last amount for a skipped asset the snap has seen before', async () => { + const seeded = await fetchSolanaSnapWallet(); + cleanAll(); + + const skipped = await fetchSolanaSnapWallet({ + state: buildEmptySolanaSnapState({ + assetsBalance: seeded.assetsBalance, + assetsInfo: seeded.assetsInfo, + assetsPrice: seeded.assetsPrice, + }), + snap: { + balances: { [SOLANA_JUP_ASSET_ID]: { amount: '45.5', unit: 'JUP' } }, + }, + }); + + expect(BALANCES.lookupAmount(skipped, SOLANA_JUP_ASSET_ID)).toBe('45.5'); + expect(BALANCES.lookupAmount(skipped, SOL_ASSET_ID)).toBe('3.25'); + expect(BALANCES.lookupAmount(skipped, SOLANA_USDC_ASSET_ID)).toBe('125.5'); + expect(BALANCES.getAssetIds(skipped)).toStrictEqual( + BALANCES.getAssetIds(seeded), + ); + }); + + it('3: zero-seeds a skipped visible asset no amount was ever stored for', async () => { + const zeroed = await fetchSolanaSnapWallet({ + snap: { + balances: { [SOLANA_JUP_ASSET_ID]: { amount: '40.25', unit: 'JUP' } }, + }, + }); + + expect(BALANCES.lookupAmount(zeroed, SOLANA_JUP_ASSET_ID)).toBe('40.25'); + expect(BALANCES.lookupAmount(zeroed, SOL_ASSET_ID)).toBe('0'); + expect(BALANCES.lookupAmount(zeroed, SOLANA_USDC_ASSET_ID)).toBe('0'); + }); + + it('11: overlays only the holdings the snap event names, and the next full fetch refreshes the skipped one', async () => { + await withStaleBalanceController( + { + state: buildEmptySolanaSnapState(), + snap: { + listedAssetIds: [SOL_ASSET_ID, SOLANA_JUP_ASSET_ID], + balances: SOLANA_SNAP_BALANCES, + }, + }, + async ({ controller, messenger, snapState }) => { + await controller.getAssets([buildSolanaSnapAccount()], { + chainIds: [SOLANA_CHAIN_ID], + forceUpdate: true, + }); + await waitUntilStable(() => controller.state); + + messenger.publish('AccountsController:accountBalancesUpdated', { + balances: { + [SOLANA_ACCOUNT_ID]: { + [SOLANA_JUP_ASSET_ID]: { amount: '50', unit: 'sol' }, + }, + }, + }); + await waitFor(() => { + expect( + BALANCES.lookupAmount(controller.state, SOLANA_JUP_ASSET_ID), + ).toBe('50'); + }); + + expect(BALANCES.lookupAmount(controller.state, SOL_ASSET_ID)).toBe( + '3.25', + ); + expect( + BALANCES.lookupAmount(controller.state, SOLANA_USDC_ASSET_ID), + ).toBe('125.5'); + + snapState.balances = { + [SOL_ASSET_ID]: { amount: '4', unit: 'SOL' }, + [SOLANA_JUP_ASSET_ID]: { amount: '40.25', unit: 'JUP' }, + [SOLANA_USDC_ASSET_ID]: { amount: '125.5', unit: 'USDC' }, + }; + await controller.getAssets([buildSolanaSnapAccount()], { + chainIds: [SOLANA_CHAIN_ID], + forceUpdate: true, + }); + await waitUntilStable(() => controller.state); + + expect(BALANCES.lookupAmount(controller.state, SOL_ASSET_ID)).toBe('4'); + expect( + BALANCES.lookupAmount(controller.state, SOLANA_JUP_ASSET_ID), + ).toBe('40.25'); + expect( + BALANCES.lookupAmount(controller.state, SOLANA_USDC_ASSET_ID), + ).toBe('125.5'); + }, + ); + }); +}); + +/** The BNB Chain provider as the recovered wallet: every holding doubled. */ +const BSC_DOUBLED_PROVIDER = { + nativeBalanceWei: buildDoubledBscNativeBalance(), + tokenBalancesWei: buildDoubledBscTokenBalances(), +} as const; + +/** + * Fetch the BNB Chain account's balances with the scenario's API mutations, + * RPC provider overrides and starting state. + * + * @param options - The scenario's API mutations, provider overrides and state. + * @param options.apiMutations - Mutations of the v6 balances response. + * @param options.bscProvider - Overrides of the doubled BNB Chain provider. + * @param options.state - The starting controller state. + * @returns The controller state after the fetch settles. + */ +async function fetchBscWallet({ + apiMutations = {}, + bscProvider = {}, + state = buildEmptyStaleBalanceState(), +}: { + apiMutations?: StaleBalanceHarnessOptions['apiMutations']; + bscProvider?: Partial; + state?: Partial; +} = {}): Promise { + return await withStaleBalanceController( + { + state, + apiMutations, + providers: { + bsc: { ...BSC_DOUBLED_PROVIDER, ...bscProvider }, + }, + }, + async ({ controller }) => { + await controller.getAssets([buildBscAccount()], { + chainIds: [BSC_CHAIN_ID], + forceUpdate: true, + }); + await waitUntilStable(() => controller.state); + return controller.state; + }, + ); +} + +describe('AssetsController stale-balance scenarios: BNB Chain RPC fallback', () => { + const BALANCES: StateSurface = { + surface: 'balances', + lookUp: (state, assetId) => + getIgnoringCase(state.assetsBalance[BSC_ACCOUNT_ID] ?? {}, assetId), + getAssetIds: (state) => + new Set( + Object.keys(state.assetsBalance[BSC_ACCOUNT_ID] ?? {}).map((key) => + key.toLowerCase(), + ), + ), + lookupAmount: (state, assetId) => + ( + getIgnoringCase(state.assetsBalance[BSC_ACCOUNT_ID] ?? {}, assetId) as + | { amount?: string } + | undefined + )?.amount, + }; + + afterEach(() => { + cleanAll(); + }); + + it('4: keeps the last amount for the one token whose RPC read fails while the rest of the chain updates', async () => { + const seeded = await fetchBscWallet(); + cleanAll(); + + const after = await fetchBscWallet({ + state: buildEmptyStaleBalanceState({ + assetsBalance: seeded.assetsBalance, + assetsInfo: seeded.assetsInfo, + assetsPrice: seeded.assetsPrice, + }), + apiMutations: { unprocessedNetworks: [BSC_CHAIN_ID] }, + bscProvider: { failingTokens: [USDT_CONTRACT] }, + }); + + for (const [assetId, captured] of Object.entries({ + ...BSC_DOUBLED, + [USDT_ASSET_ID_LOWER]: BSC_CAPTURED[USDT_ASSET_ID_LOWER], + })) { + expect(BALANCES.lookupAmount(after, assetId)).toBe( + normalizeAmountString(captured.balance, captured.decimals), + ); + } + expect(BALANCES.getAssetIds(after)).toStrictEqual( + BALANCES.getAssetIds(seeded), + ); + }); + + it('5: drops the failed token that was already at zero and is not tracked on purpose', async () => { + const seeded = await fetchBscWallet({ + apiMutations: { setBalances: { [USDT_ASSET_ID_LOWER]: '0' } }, + }); + expect(BALANCES.lookupAmount(seeded, USDT_ASSET_ID_CHECKSUM)).toBe('0'); + cleanAll(); + + const after = await fetchBscWallet({ + state: buildEmptyStaleBalanceState({ + assetsBalance: seeded.assetsBalance, + assetsInfo: seeded.assetsInfo, + assetsPrice: seeded.assetsPrice, + }), + apiMutations: { unprocessedNetworks: [BSC_CHAIN_ID] }, + bscProvider: { failingTokens: [USDT_CONTRACT] }, + }); + + expect( + BALANCES.lookupAmount(after, USDT_ASSET_ID_CHECKSUM), + ).toBeUndefined(); + expect(BALANCES.getAssetIds(after)).toStrictEqual( + new Set( + [...BALANCES.getAssetIds(seeded)].filter( + (assetId) => assetId !== USDT_ASSET_ID_LOWER, + ), + ), + ); + for (const [assetId, captured] of Object.entries(BSC_DOUBLED)) { + if (assetId === USDT_ASSET_ID_LOWER) { + continue; + } + expect(BALANCES.lookupAmount(after, assetId)).toBe( + normalizeAmountString(captured.balance, captured.decimals), + ); + } + }); + + it('6: keeps every last amount on the chain when all RPC calls fail', async () => { + const seeded = await fetchBscWallet(); + cleanAll(); + + const after = await fetchBscWallet({ + state: buildEmptyStaleBalanceState({ + assetsBalance: seeded.assetsBalance, + assetsInfo: seeded.assetsInfo, + assetsPrice: seeded.assetsPrice, + }), + apiMutations: { unprocessedNetworks: [BSC_CHAIN_ID] }, + bscProvider: { failAll: true }, + }); + + for (const [assetId, captured] of Object.entries(BSC_CAPTURED)) { + expect(BALANCES.lookupAmount(after, assetId)).toBe( + normalizeAmountString(captured.balance, captured.decimals), + ); + } + expect(BALANCES.getAssetIds(after)).toStrictEqual( + BALANCES.getAssetIds(seeded), + ); + }); + + it('7: recovers the chain with fresh amounts once the RPC fallback owns it', async () => { + const seeded = await fetchBscWallet(); + cleanAll(); + + const after = await fetchBscWallet({ + state: buildEmptyStaleBalanceState({ + assetsBalance: seeded.assetsBalance, + assetsInfo: seeded.assetsInfo, + assetsPrice: seeded.assetsPrice, + }), + apiMutations: { unprocessedNetworks: [BSC_CHAIN_ID] }, + }); + + for (const [assetId, captured] of Object.entries(BSC_DOUBLED)) { + expect(BALANCES.lookupAmount(after, assetId)).toBe( + normalizeAmountString(captured.balance, captured.decimals), + ); + } + expect(BALANCES.getAssetIds(after)).toStrictEqual( + BALANCES.getAssetIds(seeded), + ); + }); +}); + +/** `postBalance` amounts of GTAI in wei, hex-encoded as the service sends. */ +const GTAI_WEI = { + fresh: '0x33835e2c5c16f10000', + validAmongMalformed: '0x30cbbe666ef07c8000', + wrong: '0xde0b6b3a7640000', +} as const; + +/** The GTAI amounts the events above land, after unit conversion. */ +const GTAI_EVENT_AMOUNTS = { + fresh: '950.25', + validAmongMalformed: '900.125', + wrong: '1', +} as const; + +/** The captured amounts the seeded full poll lands, as events overlay them. */ +const CAPTURED_AMOUNTS = { + [GTAI_ASSET_ID_CHECKSUM]: '910.2040000000002', + [USDT_ASSET_ID_CHECKSUM]: '0.002916', + [BNB_ASSET_ID]: '0.0094950054678', +} as const; + +/** + * Build one `AccountActivityService:balanceUpdated` update row. + * + * @param assetId - The CAIP-19 asset ID the row names. + * @param amount - The `postBalance` amount (wei, hex or decimal). + * @returns The update row. + */ +const balanceUpdateRow = (assetId: string, amount: string): BalanceUpdate => ({ + asset: { fungible: true, type: assetId, unit: 'wei', decimals: 18 }, + postBalance: { amount }, + transfers: [], +}); + +/** + * Drop a field from a row, for the malformed-row scenario. The result + * deliberately violates `BalanceUpdate`, as a malformed service payload + * would; each directive below marks one intentional violation. + * + * @param row - The row to break. + * @param field - The field to remove. + * @returns The malformed row, as the service would send it. + */ +const malformedBalanceUpdateRow = ( + row: BalanceUpdate, + field: 'asset' | 'postBalance' | 'decimals', +): BalanceUpdate => { + if (field === 'asset') { + // @ts-expect-error The row deliberately carries no asset. + return { ...row, asset: undefined } as BalanceUpdate; + } + if (field === 'postBalance') { + // @ts-expect-error The row deliberately carries no postBalance. + return { ...row, postBalance: undefined } as BalanceUpdate; + } + // @ts-expect-error The row's asset deliberately carries no decimals. + return { + ...row, + asset: { ...row.asset, decimals: undefined }, + } as BalanceUpdate; +}; + +/** + * Publish an `AccountActivityService:balanceUpdated` event for the wallet's + * BNB Chain activity. + * + * @param messenger - The root messenger. + * @param updates - The event's update rows. + */ +const publishBalanceUpdated = ( + messenger: MockRootMessenger, + updates: BalanceUpdate[], +): void => { + messenger.publish('AccountActivityService:balanceUpdated', { + address: STALE_WALLET_ADDRESS, + chain: BSC_CHAIN_ID, + updates, + }); +}; + +describe('AssetsController stale-balance scenarios: Account Activity live events', () => { + const BALANCES: StateSurface = { + surface: 'balances', + lookUp: (state, assetId) => + getIgnoringCase(state.assetsBalance[BSC_ACCOUNT_ID] ?? {}, assetId), + getAssetIds: (state) => + new Set( + Object.keys(state.assetsBalance[BSC_ACCOUNT_ID] ?? {}).map((key) => + key.toLowerCase(), + ), + ), + lookupAmount: (state, assetId) => + ( + getIgnoringCase(state.assetsBalance[BSC_ACCOUNT_ID] ?? {}, assetId) as + | { amount?: string } + | undefined + )?.amount, + }; + + afterEach(() => { + cleanAll(); + }); + + /** + * Boot a controller over the captured BNB Chain wallet and drive it + * through a full fetch, ready for live event scenarios. + * + * @param fn - The scenario, run against the seeded controller. + * @returns A promise resolving when the scenario completes. + */ + const withBscLiveController = ( + fn: StaleBalanceHarnessCallback, + ): Promise => + withStaleBalanceController( + { state: buildEmptyStaleBalanceState() }, + async (args) => { + await args.controller.getAssets([buildBscAccount()], { + chainIds: [BSC_CHAIN_ID], + forceUpdate: true, + }); + await waitUntilStable(() => args.controller.state); + await fn(args); + }, + ); + + it('8: overlays only the token the event names; the omitted token keeps its last amount', async () => { + await withBscLiveController(async ({ controller, messenger }) => { + const before = controller.state; + expect( + BALANCES.lookupAmount(controller.state, GTAI_ASSET_ID_CHECKSUM), + ).toBe(CAPTURED_AMOUNTS[GTAI_ASSET_ID_CHECKSUM]); + expect( + BALANCES.lookupAmount(controller.state, USDT_ASSET_ID_CHECKSUM), + ).toBe(CAPTURED_AMOUNTS[USDT_ASSET_ID_CHECKSUM]); + + publishBalanceUpdated(messenger, [ + balanceUpdateRow(GTAI_ASSET_ID_CHECKSUM, GTAI_WEI.fresh), + ]); + await waitFor(() => { + expect( + BALANCES.lookupAmount(controller.state, GTAI_ASSET_ID_CHECKSUM), + ).toBe(GTAI_EVENT_AMOUNTS.fresh); + }); + + expect( + BALANCES.lookupAmount(controller.state, USDT_ASSET_ID_CHECKSUM), + ).toBe(CAPTURED_AMOUNTS[USDT_ASSET_ID_CHECKSUM]); + expect(BALANCES.lookupAmount(controller.state, BNB_ASSET_ID)).toBe( + CAPTURED_AMOUNTS[BNB_ASSET_ID], + ); + expect(BALANCES.getAssetIds(controller.state)).toStrictEqual( + BALANCES.getAssetIds(before), + ); + }); + }); + + it('9: writes nothing for malformed rows, and nothing at all for an empty event', async () => { + await withBscLiveController(async ({ controller, messenger }) => { + const before = controller.state; + + publishBalanceUpdated(messenger, [ + malformedBalanceUpdateRow( + balanceUpdateRow(USDT_ASSET_ID_CHECKSUM, GTAI_WEI.fresh), + 'postBalance', + ), + malformedBalanceUpdateRow( + balanceUpdateRow(GTAI_ASSET_ID_CHECKSUM, GTAI_WEI.fresh), + 'asset', + ), + malformedBalanceUpdateRow( + balanceUpdateRow(BNB_ASSET_ID, GTAI_WEI.fresh), + 'decimals', + ), + balanceUpdateRow(GTAI_ASSET_ID_CHECKSUM, GTAI_WEI.validAmongMalformed), + ]); + await waitFor(() => { + expect( + BALANCES.lookupAmount(controller.state, GTAI_ASSET_ID_CHECKSUM), + ).toBe(GTAI_EVENT_AMOUNTS.validAmongMalformed); + }); + + const after = controller.state.assetsBalance[BSC_ACCOUNT_ID] ?? {}; + expect( + BALANCES.lookupAmount(controller.state, USDT_ASSET_ID_CHECKSUM), + ).toBe(CAPTURED_AMOUNTS[USDT_ASSET_ID_CHECKSUM]); + expect(BALANCES.lookupAmount(controller.state, BNB_ASSET_ID)).toBe( + CAPTURED_AMOUNTS[BNB_ASSET_ID], + ); + expect(BALANCES.getAssetIds(controller.state)).toStrictEqual( + BALANCES.getAssetIds(before), + ); + + publishBalanceUpdated(messenger, []); + await waitUntilStable(() => controller.state); + expect( + controller.state.assetsBalance[BSC_ACCOUNT_ID] ?? {}, + ).toStrictEqual(after); + }); + }); + + it('10: overwrites a wrong event amount at the next full poll', async () => { + await withBscLiveController(async ({ controller, messenger }) => { + const before = controller.state; + + publishBalanceUpdated(messenger, [ + balanceUpdateRow(GTAI_ASSET_ID_CHECKSUM, GTAI_WEI.wrong), + ]); + await waitFor(() => { + expect( + BALANCES.lookupAmount(controller.state, GTAI_ASSET_ID_CHECKSUM), + ).toBe(GTAI_EVENT_AMOUNTS.wrong); + }); + + await controller.getAssets([buildBscAccount()], { + chainIds: [BSC_CHAIN_ID], + forceUpdate: true, + }); + await waitUntilStable(() => controller.state); + expect( + BALANCES.lookupAmount(controller.state, GTAI_ASSET_ID_CHECKSUM), + ).toBe(CAPTURED_AMOUNTS[GTAI_ASSET_ID_CHECKSUM]); + expect(BALANCES.getAssetIds(controller.state)).toStrictEqual( + BALANCES.getAssetIds(before), + ); + }); + }); +}); + +/** Staking-vault reads the providers serve. */ +const STAKING_READS = { + /** The mainnet vault as of the seed: 0.5 shares worth 0.6 ETH. */ + mainnetSeed: { + sharesWei: '500000000000000000', + assetsWei: '600000000000000000', + }, + /** The mainnet vault after the deposit: 0.7 shares worth 0.85 ETH. */ + mainnetAfterTx: { + sharesWei: '700000000000000000', + assetsWei: '850000000000000000', + }, + /** The Hoodi vault, untouched throughout: 0.3 shares worth 0.35 ETH. */ + hoodi: { + sharesWei: '300000000000000000', + assetsWei: '350000000000000000', + }, +} as const; + +/** The wallet's native ETH on Hoodi: 0.75 ETH. */ +const HOODI_NATIVE_BALANCE_WEI = '750000000000000000'; + +/** The staked ETH amounts the reads above land. */ +const STAKED_AMOUNTS = { + mainnetSeed: '0.6', + mainnetAfterTx: '0.85', + hoodi: '0.35', +} as const; + +/** The captured mainnet ETH the token poll lands. */ +const MAINNET_ETH_CAPTURED_AMOUNT = '0.053234767359623827'; + +describe('AssetsController stale-balance scenarios: staked ETH on mainnet and Hoodi', () => { + const BALANCES: StateSurface = { + surface: 'balances', + lookUp: (state, assetId) => + getIgnoringCase(state.assetsBalance[MAINNET_ACCOUNT_ID] ?? {}, assetId), + getAssetIds: (state) => + new Set( + Object.keys(state.assetsBalance[MAINNET_ACCOUNT_ID] ?? {}).map((key) => + key.toLowerCase(), + ), + ), + lookupAmount: (state, assetId) => + ( + getIgnoringCase( + state.assetsBalance[MAINNET_ACCOUNT_ID] ?? {}, + assetId, + ) as { amount?: string } | undefined + )?.amount, + }; + + afterEach(() => { + cleanAll(); + }); + + it('12: refreshes only the vault the transaction touched, and staked ETH survives token polls that never include it', async () => { + await withStaleBalanceController( + { + lifecycle: { + // The keyring is unlocked by the test itself, after the + // harness's readiness wait. + isKeyringUnlocked: false, + isAccountTreeInitialized: true, + clientControllerState: { isUiOpen: true }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, + }, + providers: { + mainnet: { + stakingByContract: { + [MAINNET_STAKING_CONTRACT]: STAKING_READS.mainnetSeed, + }, + }, + hoodi: { + nativeBalanceWei: HOODI_NATIVE_BALANCE_WEI, + stakingByContract: { + [HOODI_STAKING_CONTRACT]: STAKING_READS.hoodi, + }, + }, + }, + snap: { + listedAssetIds: [SOL_ASSET_ID, SOLANA_JUP_ASSET_ID], + balances: SOLANA_SNAP_BALANCES, + }, + }, + async ({ controller, messenger, providerStates }) => { + // Unlock starts the controller (UI open, account tree initialized). + messenger.publish('KeyringController:unlock'); + + await waitFor( + () => { + expect( + BALANCES.lookupAmount( + controller.state, + MAINNET_STAKED_ETH_ASSET_ID, + ), + ).toBe(STAKED_AMOUNTS.mainnetSeed); + expect( + BALANCES.lookupAmount( + controller.state, + HOODI_STAKED_ETH_ASSET_ID, + ), + ).toBe(STAKED_AMOUNTS.hoodi); + }, + { timeoutMs: 5000 }, + ); + await waitUntilStable(() => controller.state, { timeoutMs: 5000 }); + + providerStates[MAINNET_NETWORK_CLIENT_ID].stakingByContract[ + MAINNET_STAKING_CONTRACT + ] = { ...STAKING_READS.mainnetAfterTx }; + + messenger.publish('TransactionController:transactionConfirmed', { + id: 'stale-balance-staking-transaction', + networkClientId: MAINNET_NETWORK_CLIENT_ID, + status: TransactionStatus.confirmed, + time: 0, + chainId: '0x1', + txParams: { + from: STALE_WALLET_ADDRESS, + to: MAINNET_STAKING_CONTRACT, + }, + }); + + await waitFor( + () => { + expect( + BALANCES.lookupAmount( + controller.state, + MAINNET_STAKED_ETH_ASSET_ID, + ), + ).toBe(STAKED_AMOUNTS.mainnetAfterTx); + }, + { timeoutMs: 5000 }, + ); + await waitUntilStable(() => controller.state, { timeoutMs: 5000 }); + expect( + BALANCES.lookupAmount(controller.state, HOODI_STAKED_ETH_ASSET_ID), + ).toBe(STAKED_AMOUNTS.hoodi); + + await controller.getAssets([buildMainnetAccount()], { + chainIds: [MAINNET_CHAIN_ID, HOODI_CHAIN_ID], + forceUpdate: true, + }); + await waitUntilStable(() => controller.state, { timeoutMs: 5000 }); + + expect( + BALANCES.lookupAmount(controller.state, MAINNET_STAKED_ETH_ASSET_ID), + ).toBe(STAKED_AMOUNTS.mainnetAfterTx); + expect( + BALANCES.lookupAmount(controller.state, HOODI_STAKED_ETH_ASSET_ID), + ).toBe(STAKED_AMOUNTS.hoodi); + // The poll's own surfaces: the captured mainnet ETH and the Hoodi + // native ETH the RPC fallback read. + expect(BALANCES.lookupAmount(controller.state, ETH_ASSET_ID)).toBe( + MAINNET_ETH_CAPTURED_AMOUNT, + ); + expect( + BALANCES.lookupAmount(controller.state, HOODI_NATIVE_ASSET_ID), + ).toBe('0.75'); + expect( + BALANCES.lookUp(controller.state, MAINNET_STAKED_ETH_ASSET_ID), + ).toBeDefined(); + expect( + BALANCES.lookUp(controller.state, HOODI_STAKED_ETH_ASSET_ID), + ).toBeDefined(); + }, + ); + }); +}); diff --git a/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/accounts-api/v2-supportedNetworks.ts b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/accounts-api/v2-supportedNetworks.ts new file mode 100644 index 00000000000..d340d927c13 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/accounts-api/v2-supportedNetworks.ts @@ -0,0 +1,28 @@ +/** + * Real capture of GET https://accounts.api.cx.metamask.io/v2/supportedNetworks (2026-09-30). + */ +const accountsV2SupportedNetworks = { + fullSupport: [ + 'eip155:1', + 'eip155:137', + 'eip155:56', + 'eip155:1329', + 'eip155:43114', + 'eip155:59144', + 'eip155:8453', + 'eip155:10', + 'eip155:42161', + 'eip155:143', + 'eip155:999', + 'eip155:4663', + 'eip155:5042', + 'eip155:50', + ], + partialSupport: [ + 'tron:728126428', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + 'stellar:pubnet', + ], +} as const; + +export default accountsV2SupportedNetworks; diff --git a/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/accounts-api/v6-multiaccount-balances-bsc.ts b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/accounts-api/v6-multiaccount-balances-bsc.ts new file mode 100644 index 00000000000..dead9bb54a8 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/accounts-api/v6-multiaccount-balances-bsc.ts @@ -0,0 +1,308 @@ +/** + * Real capture of GET https://accounts.api.cx.metamask.io/v6/multiaccount/balances?accountIds=eip155%3A56%3A0x9decde522cc1285efe18afde31c79e89dee2e91e (2026-09-30). + */ +const v6MultiAccountBalancesBsc = { + balances: [ + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'native', + assetId: 'eip155:56/slip44:714', + name: 'BNB', + symbol: 'BNB', + decimals: 18, + balance: '0.009495005467800000', + securityResultType: 'Verified', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0x003d87d02a2a01e9e8a20f507c83e15dd83a33d1', + name: 'GT Protocol', + symbol: 'GTAI', + decimals: 18, + balance: '910.204000000000200000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955', + name: 'Tether USD', + symbol: 'USDT', + decimals: 18, + balance: '0.002916000000000000', + securityResultType: 'Verified', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0x8899ec96ed8c96b5c86c23c3f069c3def75b6d97', + name: 'Openfabric AI', + symbol: 'OFN', + decimals: 18, + balance: '2919.859199999999000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', + name: 'Binance-Peg USD Coin', + symbol: 'USDC', + decimals: 18, + balance: '0.000253000000000000', + securityResultType: 'Verified', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0x9452d45d33490234b8c96f42342f1be28c0fe097', + name: 'Swaperry', + symbol: 'PERRY', + decimals: 18, + balance: '16779.000000000000000000', + securityResultType: 'Warning', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0xb0eb3e295b44d7d405ba8026a9734a9ab354a8b2', + name: 'EarnGuild', + symbol: 'EARN', + decimals: 8, + balance: '68000.00000000', + securityResultType: 'Warning', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0xd9ea58350bf120e2169a35fa1afc31975b07de01', + name: 'Gains', + symbol: 'GAINS', + decimals: 18, + balance: '2145.457995009695314132', + securityResultType: 'Warning', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0xe9e7cea3dedca5984780bafc599bd69add087d56', + name: 'BUSD', + symbol: 'BUSD', + decimals: 18, + balance: '0.671315783745192035', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0xfaa0fc7b803919b091dbe5ff709b2dabb61b93d9', + name: 'Notable', + symbol: 'NBL', + decimals: 18, + balance: '74714.575000000000000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0x0ccd575bf9378c06f6dca82f8122f570769f00c2', + name: 'CryptoBlades Kingdoms', + symbol: 'KING', + decimals: 18, + balance: '46530.000000000000000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0x2aa504586d6cab3c59fa629f74c586d78b93a025', + name: 'ArenaPlay', + symbol: 'APC', + decimals: 18, + balance: '14.338600000000000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0x2b72867c32cf673f7b02d208b26889fed353b1f8', + name: 'Magic Square', + symbol: 'SQR', + decimals: 8, + balance: '5075.86500000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0x4b97c9bee3677797034033337f32115115867a62', + name: '$$EURCoin', + symbol: 'EURC', + decimals: 6, + balance: '1888888.800000', + securityResultType: 'Warning', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0x4f0ed527e8a95ecaa132af214dfd41f30b361600', + name: 'vBSWAP', + symbol: 'VBSWAP', + decimals: 18, + balance: '0.000000000001000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0x69b14e8d3cebfdd8196bfe530954a0c226e5008e', + name: 'SPACEPI', + symbol: 'SPACEPI', + decimals: 9, + balance: '1000000.000000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0x77087ab5df23cfb52449a188e80e9096201c2097', + name: 'hi Dollar', + symbol: 'HI', + decimals: 18, + balance: '27418.637920223037021652', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0x7d220240cf958c5c47f2daac821db965f9837e82', + name: 'WEB5 Inu', + symbol: 'WEB5', + decimals: 9, + balance: '724722.877364055', + securityResultType: 'Warning', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0x8632055b9caeebef7c7dccd95461608ca5378839', + name: 'TAU Token', + symbol: 'TAU', + decimals: 18, + balance: '2937.500000000000000000', + securityResultType: 'Warning', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0x94a8b4ee5cd64c79d0ee816f467ea73009f51aa0', + name: 'Realio Network Token', + symbol: 'RIO', + decimals: 18, + balance: '4624.900000000000000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0xb0a2416fd12711cbcfafb429031c0f7037fab970', + name: 'Bee Token', + symbol: 'BEETOKEN', + decimals: 18, + balance: '7.000000000000000000', + securityResultType: 'Warning', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0xbb7d61d2511fd2e63f02178ca9b663458af9fc63', + name: 'Exverse', + symbol: 'EXVG', + decimals: 18, + balance: '17581.843800000000000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0xbbcf57177d8752b21d080bf30a06ce20ad6333f8', + name: 'Zam.io', + symbol: 'ZAM', + decimals: 18, + balance: '18800.000000000000000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0xd22202d23fe7de9e3dbe11a2a88f42f4cb9507cf', + name: 'Minereum BSC', + symbol: 'MNEB', + decimals: 8, + balance: '150000.00000000', + securityResultType: 'Warning', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0xddc0dbd7dc799ae53a98a60b54999cb6ebb3abf0', + name: 'SafeBlast', + symbol: 'BLAST', + decimals: 9, + balance: '108056.780001461', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0xdf9e1a85db4f985d5bb5644ad07d9d7ee5673b5e', + name: 'MM72', + symbol: 'MM72', + decimals: 18, + balance: '72.000720067858500000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:56/erc20:0xe6906717f129427eebade5406de68cadd57aa0c0', + name: 'Meta Interstellar Token', + symbol: 'MIT', + decimals: 18, + balance: '705.000000000000000000', + securityResultType: 'Warning', + }, + ], + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], +} as const; + +export default v6MultiAccountBalancesBsc; diff --git a/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/accounts-api/v6-multiaccount-balances-mainnet.ts b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/accounts-api/v6-multiaccount-balances-mainnet.ts new file mode 100644 index 00000000000..a3541af1f4c --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/accounts-api/v6-multiaccount-balances-mainnet.ts @@ -0,0 +1,393 @@ +/** + * Real capture of GET https://accounts.api.cx.metamask.io/v6/multiaccount/balances?accountIds=eip155%3A1%3A0x9decde522cc1285efe18afde31c79e89dee2e91e (2026-09-30). + */ +const v6MultiAccountBalancesMainnet = { + balances: [ + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'native', + assetId: 'eip155:1/slip44:60', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + balance: '0.053234767359623827', + securityResultType: 'Verified', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x15e4132dcd932e8990e794d1300011a472819cbd', + name: 'GRPL', + symbol: 'GRPL', + decimals: 18, + balance: '0.292165088645896288', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x1bb9b64927e0c5e207c9db4093b3738eef5d8447', + name: 'Vector Reserve', + symbol: 'VEC', + decimals: 9, + balance: '17.369879411', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x69a95185ee2a045cdc4bcd1b1df10710395e4e23', + name: '$Poolz Finance', + symbol: 'POOLZ', + decimals: 18, + balance: '52.655978358427759193', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + name: 'Dai Stablecoin', + symbol: 'DAI', + decimals: 18, + balance: '0.049957490000000000', + securityResultType: 'Verified', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x799ebfabe77a6e34311eeee9825190b9ece32824', + name: 'Braintrust', + symbol: 'BTRST', + decimals: 18, + balance: '166.148729308234449667', + securityResultType: 'Verified', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x87c22db324b8b0637c8f09d2670ae7777651dbb8', + name: 'Root Protocol', + symbol: 'ISME', + decimals: 18, + balance: '5640.000000000000000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0xd9b312d77bc7bed9b9cecb56636300bed4fe5ce9', + name: 'Gains', + symbol: 'GAINS', + decimals: 18, + balance: '0.000000000000691385', + securityResultType: 'Warning', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0xf4d861575ecc9493420a3f5a14f85b13f0b50eb3', + name: 'Fractal Protocol', + symbol: 'FCL', + decimals: 18, + balance: '805.808026910269891918', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x033bbde722ea3cdcec73cffea6581df9f9c257de', + name: 'Velar', + symbol: 'VELAR', + decimals: 6, + balance: '2722.139405', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x09395a2a58db45db0da254c7eaa5ac469d8bdc85', + name: 'SubQuery Network', + symbol: 'SQT', + decimals: 18, + balance: '39329.409000000000000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x1117ac6ad6cdf1a3bc543bad3b133724620522d5', + name: 'moda', + symbol: 'MODA', + decimals: 18, + balance: '216.035800000000000000', + securityResultType: 'Warning', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x115ec79f1de567ec68b7ae7eda501b406626478e', + name: 'Carry', + symbol: 'CRE', + decimals: 18, + balance: '0.005000000000000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x12ef10a4fc6e1ea44b4ca9508760ff51c647bb71', + name: 'Restake Finance', + symbol: 'RSTK', + decimals: 18, + balance: '260.776773684784829908', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x1e354f9ab5bcc9fb981f31b794c5fe13f7a89218', + name: 'Neural Tensor Dynamics', + symbol: 'NTD', + decimals: 18, + balance: '2782.028215305662057165', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x295b42684f90c77da7ea46336001010f2791ec8c', + name: 'Xi', + symbol: 'XI', + decimals: 18, + balance: '1289.460367626619661984', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x2b591e99afe9f32eaa6214f7b7629768c40eeb39', + name: 'HEX', + symbol: 'HEX', + decimals: 8, + balance: '1.00000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x3073f7aaa4db83f95e9fff17424f71d4751a3073', + name: 'Movement', + symbol: 'MOVE', + decimals: 8, + balance: '650.07723486', + securityResultType: 'Verified', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x38b6088849e98115315bad2786f6490474158c33', + name: 'Ethereum Defi', + symbol: 'ETHF', + decimals: 18, + balance: '1012912.000000000000000000', + securityResultType: 'Warning', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x4ffe9cc172527df1e40d0b2efe1e9f05884a13da', + name: 'DEDPRZ', + symbol: 'USA', + decimals: 18, + balance: '237.655617764333923814', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x584a4dd38d28fd1ea0e147ba7b70aed29a37e335', + name: 'Bitcoin Inu', + symbol: 'BTCINU', + decimals: 18, + balance: '152.690545993876267592', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x5d07f52ae5f779422447c2a32f58c93a15e7d6f2', + name: 'Kylin Network Token', + symbol: 'KYL', + decimals: 18, + balance: '2229.314057071741503138', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x80c62fe4487e1351b47ba49809ebd60ed085bf52', + name: 'Clover Finance', + symbol: 'CLV', + decimals: 18, + balance: '4988.495791404261042582', + securityResultType: 'Verified', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0x96add417293a49e80f024734e96cfd8b355bcc14', + name: 'LiquidLayer', + symbol: 'LILA', + decimals: 18, + balance: '323.482426394330250376', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0xa130e3a33a4d84b04c3918c4e5762223ae252f80', + name: 'Swash Token', + symbol: 'SWASH', + decimals: 18, + balance: '9400.000000000000000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0xadd353fb2e2c563383ff3272a500f3e7134dafe4', + name: 'Tunachain', + symbol: 'TUNA', + decimals: 18, + balance: '2130.630400000000000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0xc6065b9fc8171ad3d29bad510709249681758972', + name: 'WFAIR Token', + symbol: 'WFAIR', + decimals: 18, + balance: '10771.930000000000000000', + securityResultType: 'Warning', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0xd4419c2d3daa986dc30444fa333a846be44fd1eb', + name: 'ZIK coin', + symbol: 'ZIK', + decimals: 18, + balance: '299.000000000000000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0xdaf88906ac1de12ba2b1d2f7bfc94e9638ac40c4', + name: 'EpiK Protocol', + symbol: 'ERC20-EPK', + decimals: 18, + balance: '0.100000000000000000', + securityResultType: 'Warning', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0xde8cd13b812bcd82218754a740b27e76ec1e86ad', + name: 'Trestle', + symbol: 'TRESTLE', + decimals: 18, + balance: '6167.772094376466442617', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0xe1ec350ea16d1ddaff57f31387b2d9708eb7ce28', + name: 'Pepechain', + symbol: 'PC', + decimals: 9, + balance: '64699.856422733', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0xe2353069f71a27bbbe66eeabff05de109c7d5e19', + name: 'Bonsai3', + symbol: 'SEED', + decimals: 18, + balance: '5807.982165934528528144', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0xe8fc52b1bb3a40fd8889c0f8f75879676310ddf0', + name: 'ExpandZK Token', + symbol: 'XZK', + decimals: 18, + balance: '4140.519660000000000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0xf5cfbc74057c610c8ef151a439252680ac68c6dc', + name: 'Octopus Network', + symbol: 'OCT', + decimals: 18, + balance: '1175.000000000000000000', + securityResultType: 'Benign', + }, + { + accountId: 'eip155:1:0x9decde522cc1285efe18afde31c79e89dee2e91e', + object: 'token', + type: 'erc20', + assetId: 'eip155:1/erc20:0xfe0c30065b384f05761f15d0cc899d4f9f9cc0eb', + name: 'Ether fi', + symbol: 'ETHFI', + decimals: 18, + balance: '0.152458862000000000', + securityResultType: 'Verified', + }, + ], + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], +} as const; + +export default v6MultiAccountBalancesMainnet; diff --git a/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/index.ts b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/index.ts new file mode 100644 index 00000000000..340e137219d --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/index.ts @@ -0,0 +1,382 @@ +import { API_URLS } from '@metamask/core-backend'; +import type { V3AssetResponse } from '@metamask/core-backend'; +import type { Json } from '@metamask/utils'; +import nock from 'nock'; + +import accountsV2SupportedNetworks from './accounts-api/v2-supportedNetworks.js'; +import v6MultiAccountBalancesBsc from './accounts-api/v6-multiaccount-balances-bsc.js'; +import v6MultiAccountBalancesMainnet from './accounts-api/v6-multiaccount-balances-mainnet.js'; +import pricesV2SupportedNetworks from './price-api/v2-supportedNetworks.js'; +import v3SpotPricesBsc from './price-api/v3-spot-prices-bsc.js'; +import v3SpotPricesMainnet from './price-api/v3-spot-prices-mainnet.js'; +import suggestedOccurrenceFloors from './token-api/suggestedOccurrenceFloors.js'; +import tokensV2SupportedNetworks from './tokens-api/v2-supportedNetworks.js'; +import v3AssetsBsc from './tokens-api/v3-assets-bsc.js'; +import v3AssetsMainnet from './tokens-api/v3-assets-mainnet.js'; + +/** A `/v6/multiaccount/balances` row, as the live API returns it. */ +export type V6BalanceEntry = { + readonly accountId: string; + readonly object: 'token'; + readonly type: 'native' | 'erc20'; + readonly assetId: string; + readonly name: string; + readonly symbol: string; + readonly decimals: number; + readonly balance: string; + readonly securityResultType?: string; +}; + +/** + * Scenario mutations layered on the captured `/v6/multiaccount/balances` + * response. + */ +export type StaleBalanceApiMutations = { + omitAssetIds?: string[]; + setBalances?: Record; + unprocessedNetworks?: string[]; +}; + +/** Captured `/v6/multiaccount/balances` rows for both wallets, by account ID. */ +const V6_BALANCES_BY_ACCOUNT_ID: Record = {}; +for (const capture of [ + v6MultiAccountBalancesBsc, + v6MultiAccountBalancesMainnet, +]) { + for (const entry of capture.balances) { + const key = entry.accountId.toLowerCase(); + (V6_BALANCES_BY_ACCOUNT_ID[key] ??= []).push(entry); + } +} + +/** + * Occurrence count stamped on every captured `/v3/assets` entry so spam + * filtering does not drop them (it has its own suite). + */ +const OCCURRENCES_ABOVE_FLOOR = 10_000; + +/** Captured `/v3/assets` entries for both wallets, keyed by lower-cased asset ID. */ +const V3_ASSETS_BY_LOWER_ID = [...v3AssetsBsc, ...v3AssetsMainnet].reduce< + Record +>((map, entry) => { + map[entry.assetId.toLowerCase()] = { + ...entry, + occurrences: OCCURRENCES_ABOVE_FLOOR, + }; + return map; +}, {}); + +/** Captured `/v3/spot-prices` entries for both wallets, keyed by lower-cased asset ID. */ +const V3_SPOT_PRICES_BY_LOWER_ID = { + ...v3SpotPricesBsc, + ...v3SpotPricesMainnet, +} as unknown as Record; + +/** + * Intercept `GET https://chainid.network/chains.json`, which + * `AssetsController` fetches on boot to fill native-asset gaps. + * + * @returns The nock scope. + */ +function mockChainIdNetwork(): nock.Scope { + return nock('https://chainid.network') + .persist() + .get('/chains.json') + .reply(200, []); +} + +/** + * Intercept `GET {ACCOUNTS}/v2/supportedNetworks`. The capture claims BNB + * Chain and mainnet, and lists solana under `partialSupport` as captured; + * the Solana account still balances through the keyring snap. Hoodi was + * never supported so the RPC fallback owns it. + * + * @returns The nock scope. + */ +function mockAccountsSupportedNetworks(): nock.Scope { + return nock(API_URLS.ACCOUNTS) + .persist() + .get('/v2/supportedNetworks') + .reply(200, accountsV2SupportedNetworks); +} + +/** + * Intercept `GET {TOKENS}/v2/supportedNetworks` (BNB Chain and mainnet are + * both in the captured `fullSupport` list). + * + * @returns The nock scope. + */ +function mockTokensSupportedNetworks(): nock.Scope { + return nock(API_URLS.TOKENS) + .persist() + .get('/v2/supportedNetworks') + .reply(200, tokensV2SupportedNetworks); +} + +/** + * Intercept `GET {TOKEN}/v1/suggestedOccurrenceFloors` (the capture has no + * `56` entry, so BNB Chain falls back to `TokenDataSource`'s floor). + * + * @returns The nock scope. + */ +function mockSuggestedOccurrenceFloors(): nock.Scope { + return nock(API_URLS.TOKEN) + .persist() + .get('/v1/suggestedOccurrenceFloors') + .reply(200, suggestedOccurrenceFloors); +} + +/** + * Intercept `GET {PRICES}/v2/supportedNetworks`, which `PriceDataSource` reads + * before fetching. + * + * @returns The nock scope. + */ +function mockPricesSupportedNetworks(): nock.Scope { + return nock(API_URLS.PRICES) + .persist() + .get('/v2/supportedNetworks') + .reply(200, pricesV2SupportedNetworks); +} + +/** + * Intercept `GET {TOKENS}/v3/assets`, answering each batch from the captured + * entries and stubbing assets the API does not carry. + * + * @returns The nock scope and the asset IDs each request asked about. + */ +export function mockV3Assets(): { + scope: nock.Scope; + requestedBatches: string[][]; +} { + const requestedBatches: string[][] = []; + + const scope = nock(API_URLS.TOKENS) + .persist() + .get('/v3/assets') + .query(true) + .reply(200, (uri: string) => { + const assetIds = readListParam(uri, 'assetIds', API_URLS.TOKENS); + requestedBatches.push(assetIds); + return assetIds.map((assetId) => lookupAsset(assetId)); + }); + + return { scope, requestedBatches }; +} + +/** + * Intercept `GET {PRICES}/v3/spot-prices`, answering from the captured prices. + * + * @returns The nock scope and the asset IDs each request asked about. + */ +export function mockV3SpotPrices(): { + scope: nock.Scope; + requestedBatches: string[][]; +} { + const requestedBatches: string[][] = []; + + const scope = nock(API_URLS.PRICES) + .persist() + .get('/v3/spot-prices') + .query(true) + .reply(200, (uri: string) => { + const assetIds = readListParam(uri, 'assetIds', API_URLS.PRICES); + requestedBatches.push(assetIds); + + const prices: Record = {}; + for (const assetId of assetIds) { + const lowerId = assetId.toLowerCase(); + const captured = V3_SPOT_PRICES_BY_LOWER_ID[lowerId]; + if (captured !== undefined) { + prices[lowerId] = captured; + } + } + return prices; + }); + + return { scope, requestedBatches }; +} + +/** + * Intercept `GET {ACCOUNTS}/v6/multiaccount/balances` with the captured + * balances of whichever wallets the request asked about, plus the scenario's + * mutations. Every requested `includeAssetIds` entry is answered, as the live + * backend does. + * + * @param mutations - Scenario mutations of the captured balances. + * @param mutations.omitAssetIds - Asset IDs (any casing) to drop. + * @param mutations.setBalances - Asset IDs (any casing) whose captured balance + * should be replaced, keyed lower-cased. + * @param mutations.unprocessedNetworks - CAIP-2 chain IDs the API should + * report as unprocessed, handing them to the RPC fallback. + * @returns The nock scope and the account IDs each request asked about. + */ +export function mockV6MultiAccountBalances({ + omitAssetIds = [], + setBalances = {}, + unprocessedNetworks = [], +}: StaleBalanceApiMutations = {}): { + scope: nock.Scope; + requestedAccountIds: string[][]; +} { + const requestedAccountIds: string[][] = []; + const omitted = new Set(omitAssetIds.map((assetId) => assetId.toLowerCase())); + const replacements = new Map( + Object.entries(setBalances).map(([assetId, balance]) => [ + assetId.toLowerCase(), + balance, + ]), + ); + + const scope = nock(API_URLS.ACCOUNTS) + .persist() + .get('/v6/multiaccount/balances') + .query(true) + .reply(200, (uri: string) => { + const accountIds = readListParam(uri, 'accountIds', API_URLS.ACCOUNTS); + const includeAssetIds = readListParam( + uri, + 'includeAssetIds', + API_URLS.ACCOUNTS, + ); + requestedAccountIds.push(accountIds); + + const balances: V6BalanceEntry[] = []; + const coveredLowerIds = new Set(); + for (const accountId of accountIds) { + // An account on a chain the API could not process is answered + // with nothing; the chain is reported in `unprocessedNetworks`. + const accountChainId = accountId.split(':').slice(0, 2).join(':'); + if (unprocessedNetworks.includes(accountChainId)) { + continue; + } + for (const entry of V6_BALANCES_BY_ACCOUNT_ID[ + accountId.toLowerCase() + ] ?? []) { + if (omitted.has(entry.assetId.toLowerCase())) { + continue; + } + const replacement = replacements.get(entry.assetId.toLowerCase()); + balances.push( + replacement === undefined + ? entry + : { ...entry, balance: replacement }, + ); + coveredLowerIds.add(entry.assetId.toLowerCase()); + } + } + + // Answer every requested includeAssetId, as the live backend does. + for (const assetId of includeAssetIds) { + const lowerId = assetId.toLowerCase(); + if (coveredLowerIds.has(lowerId)) { + continue; + } + const chainPrefix = lowerId.split('/')[0]; + if (unprocessedNetworks.includes(chainPrefix)) { + continue; + } + const accountId = accountIds.find((id) => + id.toLowerCase().startsWith(`${chainPrefix}:`), + ); + if (!accountId) { + continue; + } + balances.push({ + accountId, + object: 'token', + type: 'erc20', + assetId: lowerId, + name: '', + symbol: '', + decimals: 18, + balance: '0', + securityResultType: 'Verified', + }); + } + + return { + balances, + unprocessedNetworks, + unprocessedIncludeAssetIds: [], + }; + }); + + return { scope, requestedAccountIds }; +} + +/** + * Register every interceptor the suite's wallets need. All interceptors + * persist, so batch composition and cache misses cannot make a test fail + * for want of an interceptor. + * + * @param options - Options for shaping the intercepted v6 balances response. + * @param options.omitAssetIds - Asset IDs (any casing) to drop from the + * captured balances. + * @param options.setBalances - Asset IDs (any casing) whose captured balance + * should be replaced. + * @param options.unprocessedNetworks - CAIP-2 chain IDs the Accounts API + * should report as unprocessed. + * @returns The recording mocks. + */ +export function mockStaleBalanceApis({ + omitAssetIds = [], + setBalances = {}, + unprocessedNetworks = [], +}: StaleBalanceApiMutations = {}): { + accountsSupportedNetworks: nock.Scope; + balances: { requestedAccountIds: string[][] }; + assets: { scope: nock.Scope; requestedBatches: string[][] }; + prices: { scope: nock.Scope; requestedBatches: string[][] }; +} { + const accountsSupportedNetworks = mockAccountsSupportedNetworks(); + mockTokensSupportedNetworks(); + mockSuggestedOccurrenceFloors(); + mockPricesSupportedNetworks(); + mockChainIdNetwork(); + + const balances = mockV6MultiAccountBalances({ + omitAssetIds, + setBalances, + unprocessedNetworks, + }); + const assets = mockV3Assets(); + const prices = mockV3SpotPrices(); + + return { accountsSupportedNetworks, balances, assets, prices }; +} + +/** + * Read a comma-separated query parameter back off an intercepted request URI. + * + * @param uri - The intercepted request URI, path and query. + * @param param - The query parameter name. + * @param base - Base URL, so the relative URI can be parsed. + * @returns The parameter's values. + */ +function readListParam(uri: string, param: string, base: string): string[] { + const value = new URL(uri, base).searchParams.get(param); + return value ? value.split(',') : []; +} + +/** + * Look up the captured `/v3/assets` entry for an asset, answering an empty + * stub for tokens the API does not carry. + * + * @param assetId - The CAIP-19 asset ID, as requested (any casing). + * @returns The captured entry, or an empty stub. + */ +function lookupAsset(assetId: string): V3AssetResponse { + const captured = V3_ASSETS_BY_LOWER_ID[assetId.toLowerCase()]; + if (captured) { + return captured; + } + return { + symbol: '', + name: '', + decimals: null, + address: assetId.split(':').pop() ?? assetId, + type: 'erc20', + assetId: assetId.toLowerCase(), + } as unknown as V3AssetResponse; +} diff --git a/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/price-api/v2-supportedNetworks.ts b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/price-api/v2-supportedNetworks.ts new file mode 100644 index 00000000000..d253c72e900 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/price-api/v2-supportedNetworks.ts @@ -0,0 +1,176 @@ +/** + * Real capture of GET https://price.api.cx.metamask.io/v2/supportedNetworks (2026-09-30). + */ +const pricesV2SupportedNetworks = { + fullSupport: [ + 'eip155:1', + 'eip155:10', + 'eip155:56', + 'eip155:100', + 'eip155:137', + 'eip155:250', + 'eip155:324', + 'eip155:8453', + 'eip155:42161', + 'eip155:43114', + 'eip155:59144', + ], + partialSupport: { + spotPricesV2: [ + 'eip155:25', + 'eip155:30', + 'eip155:42', + 'eip155:50', + 'eip155:57', + 'eip155:66', + 'eip155:70', + 'eip155:82', + 'eip155:88', + 'eip155:106', + 'eip155:122', + 'eip155:128', + 'eip155:143', + 'eip155:146', + 'eip155:196', + 'eip155:232', + 'eip155:252', + 'eip155:288', + 'eip155:321', + 'eip155:336', + 'eip155:361', + 'eip155:714', + 'eip155:747', + 'eip155:988', + 'eip155:999', + 'eip155:1071', + 'eip155:1088', + 'eip155:1101', + 'eip155:1284', + 'eip155:1285', + 'eip155:1329', + 'eip155:1776', + 'eip155:1868', + 'eip155:2342', + 'eip155:2525', + 'eip155:2741', + 'eip155:4217', + 'eip155:4326', + 'eip155:4663', + 'eip155:5000', + 'eip155:5042', + 'eip155:7000', + 'eip155:9745', + 'eip155:10000', + 'eip155:16507', + 'eip155:33139', + 'eip155:41923', + 'eip155:42220', + 'eip155:42262', + 'eip155:42431', + 'eip155:42793', + 'eip155:43111', + 'eip155:57073', + 'eip155:60808', + 'eip155:68414', + 'eip155:73115', + 'eip155:80094', + 'eip155:81457', + 'eip155:88888', + 'eip155:97741', + 'eip155:98866', + 'eip155:167000', + 'eip155:333999', + 'eip155:534352', + 'eip155:747474', + 'eip155:984122', + 'eip155:1440000', + 'eip155:1313161554', + 'eip155:1666600000', + 'eip155:130', + 'eip155:16661', + 'eip155:204', + 'eip155:5031', + ], + spotPricesV3: [ + 'eip155:25', + 'eip155:30', + 'eip155:42', + 'eip155:50', + 'eip155:57', + 'eip155:66', + 'eip155:70', + 'eip155:82', + 'eip155:88', + 'eip155:106', + 'eip155:122', + 'eip155:128', + 'eip155:143', + 'eip155:146', + 'eip155:196', + 'eip155:232', + 'eip155:252', + 'eip155:288', + 'eip155:321', + 'eip155:336', + 'eip155:361', + 'eip155:714', + 'eip155:747', + 'eip155:988', + 'eip155:999', + 'eip155:1071', + 'eip155:1088', + 'eip155:1101', + 'eip155:1284', + 'eip155:1285', + 'eip155:1329', + 'eip155:1776', + 'eip155:1868', + 'eip155:2342', + 'eip155:2525', + 'eip155:2741', + 'eip155:4217', + 'eip155:4326', + 'eip155:4663', + 'eip155:5000', + 'eip155:5042', + 'eip155:7000', + 'eip155:9745', + 'eip155:10000', + 'eip155:16507', + 'eip155:33139', + 'eip155:41923', + 'eip155:42220', + 'eip155:42262', + 'eip155:42431', + 'eip155:42793', + 'eip155:43111', + 'eip155:57073', + 'eip155:60808', + 'eip155:68414', + 'eip155:73115', + 'eip155:80094', + 'eip155:81457', + 'eip155:88888', + 'eip155:97741', + 'eip155:98866', + 'eip155:167000', + 'eip155:333999', + 'eip155:534352', + 'eip155:747474', + 'eip155:984122', + 'eip155:1440000', + 'eip155:1313161554', + 'eip155:1666600000', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + 'bip122:000000000019d6689c085ae165831e93', + 'tron:728126428', + 'stellar:pubnet', + 'eip155:130', + 'eip155:16661', + 'eip155:204', + 'eip155:5031', + ], + }, +} as const; + +export default pricesV2SupportedNetworks; diff --git a/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/price-api/v3-spot-prices-bsc.ts b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/price-api/v3-spot-prices-bsc.ts new file mode 100644 index 00000000000..ac46d5cb5ee --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/price-api/v3-spot-prices-bsc.ts @@ -0,0 +1,93 @@ +/** + * Real capture of GET https://price.api.cx.metamask.io/v3/spot-prices?assetIds=eip155:56/slip44:714, + * eip155:56/erc20:0x003d87d02a2a01e9e8a20f507c83e15dd83a33d1, + * eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955, + * eip155:56/erc20:0x8899ec96ed8c96b5c86c23c3f069c3def75b6d97, + * eip155:56/erc20:0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d, + * eip155:56/erc20:0x9452d45d33490234b8c96f42342f1be28c0fe097, + * eip155:56/erc20:0xb0eb3e295b44d7d405ba8026a9734a9ab354a8b2, + * eip155:56/erc20:0xd9ea58350bf120e2169a35fa1afc31975b07de01, + * eip155:56/erc20:0xe9e7cea3dedca5984780bafc599bd69add087d56, + * eip155:56/erc20:0xfaa0fc7b803919b091dbe5ff709b2dabb61b93d9, + * eip155:56/erc20:0x0ccd575bf9378c06f6dca82f8122f570769f00c2, + * eip155:56/erc20:0x2aa504586d6cab3c59fa629f74c586d78b93a025, + * eip155:56/erc20:0x2b72867c32cf673f7b02d208b26889fed353b1f8, + * eip155:56/erc20:0x4b97c9bee3677797034033337f32115115867a62, + * eip155:56/erc20:0x4f0ed527e8a95ecaa132af214dfd41f30b361600, + * eip155:56/erc20:0x69b14e8d3cebfdd8196bfe530954a0c226e5008e, + * eip155:56/erc20:0x77087ab5df23cfb52449a188e80e9096201c2097, + * eip155:56/erc20:0x7d220240cf958c5c47f2daac821db965f9837e82, + * eip155:56/erc20:0x8632055b9caeebef7c7dccd95461608ca5378839, + * eip155:56/erc20:0x94a8b4ee5cd64c79d0ee816f467ea73009f51aa0, + * eip155:56/erc20:0xb0a2416fd12711cbcfafb429031c0f7037fab970, + * eip155:56/erc20:0xbb7d61d2511fd2e63f02178ca9b663458af9fc63, + * eip155:56/erc20:0xbbcf57177d8752b21d080bf30a06ce20ad6333f8, + * eip155:56/erc20:0xd22202d23fe7de9e3dbe11a2a88f42f4cb9507cf, + * eip155:56/erc20:0xddc0dbd7dc799ae53a98a60b54999cb6ebb3abf0, + * eip155:56/erc20:0xdf9e1a85db4f985d5bb5644ad07d9d7ee5673b5e, + * eip155:56/erc20:0xe6906717f129427eebade5406de68cadd57aa0c0 (2026-09-30), + * for the wallet's 27 BNB Chain asset IDs. + */ +const v3SpotPricesBsc = { + 'eip155:56/slip44:714': { + usd: 771.2017692826387, + }, + 'eip155:56/erc20:0x003d87d02a2a01e9e8a20f507c83e15dd83a33d1': { + usd: 0.00541265, + }, + 'eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955': { + usd: 1.0003297930182713, + }, + 'eip155:56/erc20:0x8899ec96ed8c96b5c86c23c3f069c3def75b6d97': null, + 'eip155:56/erc20:0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d': { + usd: 1.0007858806065655, + }, + 'eip155:56/erc20:0x9452d45d33490234b8c96f42342f1be28c0fe097': { + usd: 0.0006654960141, + }, + 'eip155:56/erc20:0xb0eb3e295b44d7d405ba8026a9734a9ab354a8b2': { + usd: 0.0001775997438, + }, + 'eip155:56/erc20:0xd9ea58350bf120e2169a35fa1afc31975b07de01': null, + 'eip155:56/erc20:0xe9e7cea3dedca5984780bafc599bd69add087d56': { + usd: 1.000252859826305, + }, + 'eip155:56/erc20:0xfaa0fc7b803919b091dbe5ff709b2dabb61b93d9': { + usd: 0.001044056534, + }, + 'eip155:56/erc20:0x0ccd575bf9378c06f6dca82f8122f570769f00c2': null, + 'eip155:56/erc20:0x2aa504586d6cab3c59fa629f74c586d78b93a025': { + usd: 0.001436356786, + }, + 'eip155:56/erc20:0x2b72867c32cf673f7b02d208b26889fed353b1f8': { + usd: 0.00007764, + }, + 'eip155:56/erc20:0x4b97c9bee3677797034033337f32115115867a62': null, + 'eip155:56/erc20:0x4f0ed527e8a95ecaa132af214dfd41f30b361600': { + usd: 15.35, + }, + 'eip155:56/erc20:0x69b14e8d3cebfdd8196bfe530954a0c226e5008e': { + usd: 6.706730268e-10, + }, + 'eip155:56/erc20:0x77087ab5df23cfb52449a188e80e9096201c2097': { + usd: 0.00002163755906, + }, + 'eip155:56/erc20:0x7d220240cf958c5c47f2daac821db965f9837e82': null, + 'eip155:56/erc20:0x8632055b9caeebef7c7dccd95461608ca5378839': null, + 'eip155:56/erc20:0x94a8b4ee5cd64c79d0ee816f467ea73009f51aa0': { + usd: 0.06972945950232526, + }, + 'eip155:56/erc20:0xb0a2416fd12711cbcfafb429031c0f7037fab970': null, + 'eip155:56/erc20:0xbb7d61d2511fd2e63f02178ca9b663458af9fc63': null, + 'eip155:56/erc20:0xbbcf57177d8752b21d080bf30a06ce20ad6333f8': null, + 'eip155:56/erc20:0xd22202d23fe7de9e3dbe11a2a88f42f4cb9507cf': null, + 'eip155:56/erc20:0xddc0dbd7dc799ae53a98a60b54999cb6ebb3abf0': { + usd: 3.027e-9, + }, + 'eip155:56/erc20:0xdf9e1a85db4f985d5bb5644ad07d9d7ee5673b5e': { + usd: 3.71322e-7, + }, + 'eip155:56/erc20:0xe6906717f129427eebade5406de68cadd57aa0c0': null, +} as const; + +export default v3SpotPricesBsc; diff --git a/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/price-api/v3-spot-prices-mainnet.ts b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/price-api/v3-spot-prices-mainnet.ts new file mode 100644 index 00000000000..48db2ef996d --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/price-api/v3-spot-prices-mainnet.ts @@ -0,0 +1,115 @@ +/** + * Real capture of GET https://price.api.cx.metamask.io/v3/spot-prices?assetIds=eip155:1/slip44:60, + * eip155:1/erc20:0x15e4132dcd932e8990e794d1300011a472819cbd, + * eip155:1/erc20:0x1bb9b64927e0c5e207c9db4093b3738eef5d8447, + * eip155:1/erc20:0x69a95185ee2a045cdc4bcd1b1df10710395e4e23, + * eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f, + * eip155:1/erc20:0x799ebfabe77a6e34311eeee9825190b9ece32824, + * eip155:1/erc20:0x87c22db324b8b0637c8f09d2670ae7777651dbb8, + * eip155:1/erc20:0xd9b312d77bc7bed9b9cecb56636300bed4fe5ce9, + * eip155:1/erc20:0xf4d861575ecc9493420a3f5a14f85b13f0b50eb3, + * eip155:1/erc20:0x033bbde722ea3cdcec73cffea6581df9f9c257de, + * eip155:1/erc20:0x09395a2a58db45db0da254c7eaa5ac469d8bdc85, + * eip155:1/erc20:0x1117ac6ad6cdf1a3bc543bad3b133724620522d5, + * eip155:1/erc20:0x115ec79f1de567ec68b7ae7eda501b406626478e, + * eip155:1/erc20:0x12ef10a4fc6e1ea44b4ca9508760ff51c647bb71, + * eip155:1/erc20:0x1e354f9ab5bcc9fb981f31b794c5fe13f7a89218, + * eip155:1/erc20:0x295b42684f90c77da7ea46336001010f2791ec8c, + * eip155:1/erc20:0x2b591e99afe9f32eaa6214f7b7629768c40eeb39, + * eip155:1/erc20:0x3073f7aaa4db83f95e9fff17424f71d4751a3073, + * eip155:1/erc20:0x38b6088849e98115315bad2786f6490474158c33, + * eip155:1/erc20:0x4ffe9cc172527df1e40d0b2efe1e9f05884a13da, + * eip155:1/erc20:0x584a4dd38d28fd1ea0e147ba7b70aed29a37e335, + * eip155:1/erc20:0x5d07f52ae5f779422447c2a32f58c93a15e7d6f2, + * eip155:1/erc20:0x80c62fe4487e1351b47ba49809ebd60ed085bf52, + * eip155:1/erc20:0x96add417293a49e80f024734e96cfd8b355bcc14, + * eip155:1/erc20:0xa130e3a33a4d84b04c3918c4e5762223ae252f80, + * eip155:1/erc20:0xadd353fb2e2c563383ff3272a500f3e7134dafe4, + * eip155:1/erc20:0xc6065b9fc8171ad3d29bad510709249681758972, + * eip155:1/erc20:0xd4419c2d3daa986dc30444fa333a846be44fd1eb, + * eip155:1/erc20:0xdaf88906ac1de12ba2b1d2f7bfc94e9638ac40c4, + * eip155:1/erc20:0xde8cd13b812bcd82218754a740b27e76ec1e86ad, + * eip155:1/erc20:0xe1ec350ea16d1ddaff57f31387b2d9708eb7ce28, + * eip155:1/erc20:0xe2353069f71a27bbbe66eeabff05de109c7d5e19, + * eip155:1/erc20:0xe8fc52b1bb3a40fd8889c0f8f75879676310ddf0, + * eip155:1/erc20:0xf5cfbc74057c610c8ef151a439252680ac68c6dc, + * eip155:1/erc20:0xfe0c30065b384f05761f15d0cc899d4f9f9cc0eb (2026-09-30), + * for the wallet's 35 mainnet asset IDs. + */ +const v3SpotPricesMainnet = { + 'eip155:1/slip44:60': { + usd: 2706.3904471831324, + }, + 'eip155:1/erc20:0x15e4132dcd932e8990e794d1300011a472819cbd': { + usd: 69.2732832984, + }, + 'eip155:1/erc20:0x1bb9b64927e0c5e207c9db4093b3738eef5d8447': null, + 'eip155:1/erc20:0x69a95185ee2a045cdc4bcd1b1df10710395e4e23': null, + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f': { + usd: 0.9996519295472747, + }, + 'eip155:1/erc20:0x799ebfabe77a6e34311eeee9825190b9ece32824': { + usd: 0.062517, + }, + 'eip155:1/erc20:0x87c22db324b8b0637c8f09d2670ae7777651dbb8': null, + 'eip155:1/erc20:0xd9b312d77bc7bed9b9cecb56636300bed4fe5ce9': null, + 'eip155:1/erc20:0xf4d861575ecc9493420a3f5a14f85b13f0b50eb3': null, + 'eip155:1/erc20:0x033bbde722ea3cdcec73cffea6581df9f9c257de': { + usd: 0.00020399, + }, + 'eip155:1/erc20:0x09395a2a58db45db0da254c7eaa5ac469d8bdc85': { + usd: 0.00000613, + }, + 'eip155:1/erc20:0x1117ac6ad6cdf1a3bc543bad3b133724620522d5': null, + 'eip155:1/erc20:0x115ec79f1de567ec68b7ae7eda501b406626478e': null, + 'eip155:1/erc20:0x12ef10a4fc6e1ea44b4ca9508760ff51c647bb71': { + usd: 0.00268601, + }, + 'eip155:1/erc20:0x1e354f9ab5bcc9fb981f31b794c5fe13f7a89218': null, + 'eip155:1/erc20:0x295b42684f90c77da7ea46336001010f2791ec8c': { + usd: 0.00093359, + }, + 'eip155:1/erc20:0x2b591e99afe9f32eaa6214f7b7629768c40eeb39': { + usd: 0.001052654259434585, + }, + 'eip155:1/erc20:0x3073f7aaa4db83f95e9fff17424f71d4751a3073': { + usd: 0.00925763, + }, + 'eip155:1/erc20:0x38b6088849e98115315bad2786f6490474158c33': null, + 'eip155:1/erc20:0x4ffe9cc172527df1e40d0b2efe1e9f05884a13da': null, + 'eip155:1/erc20:0x584a4dd38d28fd1ea0e147ba7b70aed29a37e335': { + usd: 0.00215796, + }, + 'eip155:1/erc20:0x5d07f52ae5f779422447c2a32f58c93a15e7d6f2': null, + 'eip155:1/erc20:0x80c62fe4487e1351b47ba49809ebd60ed085bf52': { + usd: 0.00238776, + }, + 'eip155:1/erc20:0x96add417293a49e80f024734e96cfd8b355bcc14': { + usd: 0.002255729326, + }, + 'eip155:1/erc20:0xa130e3a33a4d84b04c3918c4e5762223ae252f80': { + usd: 0.00067815, + }, + 'eip155:1/erc20:0xadd353fb2e2c563383ff3272a500f3e7134dafe4': null, + 'eip155:1/erc20:0xc6065b9fc8171ad3d29bad510709249681758972': { + usd: 0.0001006942507, + }, + 'eip155:1/erc20:0xd4419c2d3daa986dc30444fa333a846be44fd1eb': { + usd: 0.00013453, + }, + 'eip155:1/erc20:0xdaf88906ac1de12ba2b1d2f7bfc94e9638ac40c4': null, + 'eip155:1/erc20:0xde8cd13b812bcd82218754a740b27e76ec1e86ad': null, + 'eip155:1/erc20:0xe1ec350ea16d1ddaff57f31387b2d9708eb7ce28': null, + 'eip155:1/erc20:0xe2353069f71a27bbbe66eeabff05de109c7d5e19': null, + 'eip155:1/erc20:0xe8fc52b1bb3a40fd8889c0f8f75879676310ddf0': { + usd: 0.0001179245648, + }, + 'eip155:1/erc20:0xf5cfbc74057c610c8ef151a439252680ac68c6dc': { + usd: 0.00025637, + }, + 'eip155:1/erc20:0xfe0c30065b384f05761f15d0cc899d4f9f9cc0eb': { + usd: 0.778546, + }, +} as const; + +export default v3SpotPricesMainnet; diff --git a/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/token-api/suggestedOccurrenceFloors.ts b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/token-api/suggestedOccurrenceFloors.ts new file mode 100644 index 00000000000..b187457b2dc --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/token-api/suggestedOccurrenceFloors.ts @@ -0,0 +1,17 @@ +/** + * Real capture of GET https://token.api.cx.metamask.io/v1/suggestedOccurrenceFloors (2026-09-30). + */ +const suggestedOccurrenceFloors = { + '1': 3, + '143': 1, + '204': 1, + '232': 1, + '690': 1, + '1329': 1, + '4663': 1, + '10143': 1, + '59144': 1, + '98866': 1, +} as const; + +export default suggestedOccurrenceFloors; diff --git a/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/tokens-api/v2-supportedNetworks.ts b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/tokens-api/v2-supportedNetworks.ts new file mode 100644 index 00000000000..489ed3092fd --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/tokens-api/v2-supportedNetworks.ts @@ -0,0 +1,72 @@ +/** + * Real capture of GET https://tokens.api.cx.metamask.io/v2/supportedNetworks (2026-09-30). + */ +const tokensV2SupportedNetworks = { + fullSupport: [ + 'eip155:1', + 'eip155:10', + 'eip155:25', + 'eip155:56', + 'eip155:100', + 'eip155:137', + 'eip155:143', + 'eip155:250', + 'eip155:324', + 'eip155:1101', + 'eip155:1284', + 'eip155:1285', + 'eip155:1329', + 'eip155:8453', + 'eip155:42161', + 'eip155:42220', + 'eip155:43114', + 'eip155:59144', + 'eip155:1313161554', + 'eip155:1666600000', + 'eip155:11297108109', + 'eip155:13371', + 'eip155:534352', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + 'tron:728126428', + 'stellar:pubnet', + 'eip155:698', + 'eip155:16507', + 'eip155:41923', + 'eip155:747474', + 'eip155:80094', + 'eip155:33139', + 'eip155:2741', + 'eip155:1868', + 'eip155:166', + 'eip155:1440000', + 'eip155:252', + 'eip155:43111', + 'eip155:50', + 'eip155:42', + 'eip155:9745', + 'eip155:999', + 'eip155:1776', + 'eip155:4326', + 'eip155:196', + 'eip155:68414', + 'eip155:42793', + 'eip155:60808', + 'eip155:30', + 'bip122:000000000019d6689c085ae165831e93', + 'eip155:88888', + 'eip155:988', + 'eip155:42431', + 'eip155:4217', + 'eip155:5000', + 'eip155:5042', + 'eip155:4663', + 'eip155:5031', + 'eip155:16661', + 'eip155:130', + 'eip155:204', + 'eip155:81457', + ], + partialSupport: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'], +} as const; + +export default tokensV2SupportedNetworks; diff --git a/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/tokens-api/v3-assets-bsc.ts b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/tokens-api/v3-assets-bsc.ts new file mode 100644 index 00000000000..c8033b594ac --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/tokens-api/v3-assets-bsc.ts @@ -0,0 +1,196 @@ +/** + * Real capture of GET https://tokens.api.cx.metamask.io/v3/assets?assetIds=eip155:56/slip44:714, + * eip155:56/erc20:0x003d87d02a2a01e9e8a20f507c83e15dd83a33d1, + * eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955, + * eip155:56/erc20:0x8899ec96ed8c96b5c86c23c3f069c3def75b6d97, + * eip155:56/erc20:0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d, + * eip155:56/erc20:0x9452d45d33490234b8c96f42342f1be28c0fe097, + * eip155:56/erc20:0xb0eb3e295b44d7d405ba8026a9734a9ab354a8b2, + * eip155:56/erc20:0xd9ea58350bf120e2169a35fa1afc31975b07de01, + * eip155:56/erc20:0xe9e7cea3dedca5984780bafc599bd69add087d56, + * eip155:56/erc20:0xfaa0fc7b803919b091dbe5ff709b2dabb61b93d9, + * eip155:56/erc20:0x0ccd575bf9378c06f6dca82f8122f570769f00c2, + * eip155:56/erc20:0x2aa504586d6cab3c59fa629f74c586d78b93a025, + * eip155:56/erc20:0x2b72867c32cf673f7b02d208b26889fed353b1f8, + * eip155:56/erc20:0x4b97c9bee3677797034033337f32115115867a62, + * eip155:56/erc20:0x4f0ed527e8a95ecaa132af214dfd41f30b361600, + * eip155:56/erc20:0x69b14e8d3cebfdd8196bfe530954a0c226e5008e, + * eip155:56/erc20:0x77087ab5df23cfb52449a188e80e9096201c2097, + * eip155:56/erc20:0x7d220240cf958c5c47f2daac821db965f9837e82, + * eip155:56/erc20:0x8632055b9caeebef7c7dccd95461608ca5378839, + * eip155:56/erc20:0x94a8b4ee5cd64c79d0ee816f467ea73009f51aa0, + * eip155:56/erc20:0xb0a2416fd12711cbcfafb429031c0f7037fab970, + * eip155:56/erc20:0xbb7d61d2511fd2e63f02178ca9b663458af9fc63, + * eip155:56/erc20:0xbbcf57177d8752b21d080bf30a06ce20ad6333f8, + * eip155:56/erc20:0xd22202d23fe7de9e3dbe11a2a88f42f4cb9507cf, + * eip155:56/erc20:0xddc0dbd7dc799ae53a98a60b54999cb6ebb3abf0, + * eip155:56/erc20:0xdf9e1a85db4f985d5bb5644ad07d9d7ee5673b5e, + * eip155:56/erc20:0xe6906717f129427eebade5406de68cadd57aa0c0 (2026-09-30), + * for the wallet's 27 BNB Chain asset IDs. + */ +const v3AssetsBsc = [ + { + assetId: 'eip155:56/erc20:0x003d87d02a2a01e9e8a20f507c83e15dd83a33d1', + decimals: 18, + name: 'GT Protocol', + symbol: 'GTAI', + }, + { + assetId: 'eip155:56/erc20:0x0ccd575bf9378c06f6dca82f8122f570769f00c2', + decimals: 18, + name: 'CryptoBlades Kingdoms', + symbol: 'KING', + }, + { + assetId: 'eip155:56/erc20:0xfaa0fc7b803919b091dbe5ff709b2dabb61b93d9', + decimals: 18, + name: 'Notable', + symbol: 'NBL', + }, + { + assetId: 'eip155:56/slip44:714', + decimals: 18, + name: 'Binance Coin', + symbol: 'BNB', + }, + { + assetId: 'eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955', + decimals: 18, + name: 'Tether USD', + symbol: 'USDT', + }, + { + assetId: 'eip155:56/erc20:0x4b97c9bee3677797034033337f32115115867a62', + decimals: 6, + name: '$$EURCoin', + symbol: 'EURC', + }, + { + assetId: 'eip155:56/erc20:0x4f0ed527e8a95ecaa132af214dfd41f30b361600', + decimals: 18, + name: 'vBSWAP', + symbol: 'VBSWAP', + }, + { + assetId: 'eip155:56/erc20:0x94a8b4ee5cd64c79d0ee816f467ea73009f51aa0', + decimals: 18, + name: 'Realio Network Token', + symbol: 'RIO', + }, + { + assetId: 'eip155:56/erc20:0x9452d45d33490234b8c96f42342f1be28c0fe097', + decimals: 18, + name: 'Swaperry', + symbol: 'PERRY', + }, + { + assetId: 'eip155:56/erc20:0xdf9e1a85db4f985d5bb5644ad07d9d7ee5673b5e', + decimals: 18, + name: 'MM72', + symbol: 'MM72', + }, + { + assetId: 'eip155:56/erc20:0xd9ea58350bf120e2169a35fa1afc31975b07de01', + decimals: 18, + name: 'Gains', + symbol: 'GAINS', + }, + { + assetId: 'eip155:56/erc20:0xddc0dbd7dc799ae53a98a60b54999cb6ebb3abf0', + decimals: 9, + name: 'SafeBlast', + symbol: 'BLAST', + }, + { + assetId: 'eip155:56/erc20:0x7d220240cf958c5c47f2daac821db965f9837e82', + decimals: 9, + name: 'WEB5 Inu', + symbol: 'WEB5', + }, + { + assetId: 'eip155:56/erc20:0x77087ab5df23cfb52449a188e80e9096201c2097', + decimals: 18, + name: 'hi Dollar', + symbol: 'HI', + }, + { + assetId: 'eip155:56/erc20:0x8632055b9caeebef7c7dccd95461608ca5378839', + decimals: 18, + name: 'TAU Token', + symbol: 'TAU', + }, + { + assetId: 'eip155:56/erc20:0x8899ec96ed8c96b5c86c23c3f069c3def75b6d97', + decimals: 18, + name: 'Openfabric AI', + symbol: 'OFN', + }, + { + assetId: 'eip155:56/erc20:0xbbcf57177d8752b21d080bf30a06ce20ad6333f8', + decimals: 18, + name: 'Zam.io', + symbol: 'ZAM', + }, + { + assetId: 'eip155:56/erc20:0xbb7d61d2511fd2e63f02178ca9b663458af9fc63', + decimals: 18, + name: 'Exverse', + symbol: 'EXVG', + }, + { + assetId: 'eip155:56/erc20:0xe9e7cea3dedca5984780bafc599bd69add087d56', + decimals: 18, + name: 'BUSD', + symbol: 'BUSD', + }, + { + assetId: 'eip155:56/erc20:0xe6906717f129427eebade5406de68cadd57aa0c0', + decimals: 18, + name: 'Meta Interstellar Token', + symbol: 'MIT', + }, + { + assetId: 'eip155:56/erc20:0xb0eb3e295b44d7d405ba8026a9734a9ab354a8b2', + decimals: 8, + name: 'EarnGuild', + symbol: 'EARN', + }, + { + assetId: 'eip155:56/erc20:0xb0a2416fd12711cbcfafb429031c0f7037fab970', + decimals: 18, + name: 'Bee Token', + symbol: 'BEETOKEN', + }, + { + assetId: 'eip155:56/erc20:0xd22202d23fe7de9e3dbe11a2a88f42f4cb9507cf', + decimals: 8, + name: 'Minereum BSC', + symbol: 'MNEB', + }, + { + assetId: 'eip155:56/erc20:0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', + decimals: 18, + name: 'Binance-Peg USD Coin', + symbol: 'USDC', + }, + { + assetId: 'eip155:56/erc20:0x2b72867c32cf673f7b02d208b26889fed353b1f8', + decimals: 8, + name: 'Magic Square', + symbol: 'SQR', + }, + { + assetId: 'eip155:56/erc20:0x2aa504586d6cab3c59fa629f74c586d78b93a025', + decimals: 18, + name: 'ArenaPlay', + symbol: 'APC', + }, + { + assetId: 'eip155:56/erc20:0x69b14e8d3cebfdd8196bfe530954a0c226e5008e', + decimals: 9, + name: 'SPACEPI', + symbol: 'SPACEPI', + }, +] as const; + +export default v3AssetsBsc; diff --git a/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/tokens-api/v3-assets-mainnet.ts b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/tokens-api/v3-assets-mainnet.ts new file mode 100644 index 00000000000..fdb1976ee66 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/stale-balance/api-responses/tokens-api/v3-assets-mainnet.ts @@ -0,0 +1,252 @@ +/** + * Real capture of GET https://tokens.api.cx.metamask.io/v3/assets?assetIds=eip155:1/slip44:60, + * eip155:1/erc20:0x15e4132dcd932e8990e794d1300011a472819cbd, + * eip155:1/erc20:0x1bb9b64927e0c5e207c9db4093b3738eef5d8447, + * eip155:1/erc20:0x69a95185ee2a045cdc4bcd1b1df10710395e4e23, + * eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f, + * eip155:1/erc20:0x799ebfabe77a6e34311eeee9825190b9ece32824, + * eip155:1/erc20:0x87c22db324b8b0637c8f09d2670ae7777651dbb8, + * eip155:1/erc20:0xd9b312d77bc7bed9b9cecb56636300bed4fe5ce9, + * eip155:1/erc20:0xf4d861575ecc9493420a3f5a14f85b13f0b50eb3, + * eip155:1/erc20:0x033bbde722ea3cdcec73cffea6581df9f9c257de, + * eip155:1/erc20:0x09395a2a58db45db0da254c7eaa5ac469d8bdc85, + * eip155:1/erc20:0x1117ac6ad6cdf1a3bc543bad3b133724620522d5, + * eip155:1/erc20:0x115ec79f1de567ec68b7ae7eda501b406626478e, + * eip155:1/erc20:0x12ef10a4fc6e1ea44b4ca9508760ff51c647bb71, + * eip155:1/erc20:0x1e354f9ab5bcc9fb981f31b794c5fe13f7a89218, + * eip155:1/erc20:0x295b42684f90c77da7ea46336001010f2791ec8c, + * eip155:1/erc20:0x2b591e99afe9f32eaa6214f7b7629768c40eeb39, + * eip155:1/erc20:0x3073f7aaa4db83f95e9fff17424f71d4751a3073, + * eip155:1/erc20:0x38b6088849e98115315bad2786f6490474158c33, + * eip155:1/erc20:0x4ffe9cc172527df1e40d0b2efe1e9f05884a13da, + * eip155:1/erc20:0x584a4dd38d28fd1ea0e147ba7b70aed29a37e335, + * eip155:1/erc20:0x5d07f52ae5f779422447c2a32f58c93a15e7d6f2, + * eip155:1/erc20:0x80c62fe4487e1351b47ba49809ebd60ed085bf52, + * eip155:1/erc20:0x96add417293a49e80f024734e96cfd8b355bcc14, + * eip155:1/erc20:0xa130e3a33a4d84b04c3918c4e5762223ae252f80, + * eip155:1/erc20:0xadd353fb2e2c563383ff3272a500f3e7134dafe4, + * eip155:1/erc20:0xc6065b9fc8171ad3d29bad510709249681758972, + * eip155:1/erc20:0xd4419c2d3daa986dc30444fa333a846be44fd1eb, + * eip155:1/erc20:0xdaf88906ac1de12ba2b1d2f7bfc94e9638ac40c4, + * eip155:1/erc20:0xde8cd13b812bcd82218754a740b27e76ec1e86ad, + * eip155:1/erc20:0xe1ec350ea16d1ddaff57f31387b2d9708eb7ce28, + * eip155:1/erc20:0xe2353069f71a27bbbe66eeabff05de109c7d5e19, + * eip155:1/erc20:0xe8fc52b1bb3a40fd8889c0f8f75879676310ddf0, + * eip155:1/erc20:0xf5cfbc74057c610c8ef151a439252680ac68c6dc, + * eip155:1/erc20:0xfe0c30065b384f05761f15d0cc899d4f9f9cc0eb (2026-09-30), + * for the wallet's 35 mainnet asset IDs. + */ +const v3AssetsMainnet = [ + { + assetId: 'eip155:1/erc20:0x1bb9b64927e0c5e207c9db4093b3738eef5d8447', + decimals: 9, + name: 'Vector', + symbol: 'VEC', + }, + { + assetId: 'eip155:1/erc20:0x15e4132dcd932e8990e794d1300011a472819cbd', + decimals: 18, + name: 'GRPL', + symbol: 'GRPL', + }, + { + assetId: 'eip155:1/erc20:0x38b6088849e98115315bad2786f6490474158c33', + decimals: 18, + name: 'Ethereum Defi', + symbol: 'ETHF', + }, + { + assetId: 'eip155:1/erc20:0x12ef10a4fc6e1ea44b4ca9508760ff51c647bb71', + decimals: 18, + name: 'Restake Finance', + symbol: 'RSTK', + }, + { + assetId: 'eip155:1/erc20:0x1117ac6ad6cdf1a3bc543bad3b133724620522d5', + decimals: 18, + name: 'moda', + symbol: 'MODA', + }, + { + assetId: 'eip155:1/erc20:0x2b591e99afe9f32eaa6214f7b7629768c40eeb39', + decimals: 8, + name: 'HEX', + symbol: 'HEX', + }, + { + assetId: 'eip155:1/erc20:0x115ec79f1de567ec68b7ae7eda501b406626478e', + decimals: 18, + name: 'Carry', + symbol: 'CRE', + }, + { + assetId: 'eip155:1/erc20:0x1e354f9ab5bcc9fb981f31b794c5fe13f7a89218', + decimals: 18, + name: 'Neural Tensor Dynamics', + symbol: 'NTD', + }, + { + assetId: 'eip155:1/erc20:0xe8fc52b1bb3a40fd8889c0f8f75879676310ddf0', + decimals: 18, + name: 'ExpandZK Token', + symbol: 'XZK', + }, + { + assetId: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + { + assetId: 'eip155:1/erc20:0xfe0c30065b384f05761f15d0cc899d4f9f9cc0eb', + decimals: 18, + name: 'Ether fi', + symbol: 'ETHFI', + }, + { + assetId: 'eip155:1/erc20:0xe2353069f71a27bbbe66eeabff05de109c7d5e19', + decimals: 18, + name: 'Bonsai3', + symbol: 'SEED', + }, + { + assetId: 'eip155:1/erc20:0xf5cfbc74057c610c8ef151a439252680ac68c6dc', + decimals: 18, + name: 'Octopus Network', + symbol: 'OCT', + }, + { + assetId: 'eip155:1/erc20:0xf4d861575ecc9493420a3f5a14f85b13f0b50eb3', + decimals: 18, + name: 'Fractal Protocol', + symbol: 'FCL', + }, + { + assetId: 'eip155:1/erc20:0x033bbde722ea3cdcec73cffea6581df9f9c257de', + decimals: 6, + name: 'Velar', + symbol: 'VELAR', + }, + { + assetId: 'eip155:1/erc20:0x09395a2a58db45db0da254c7eaa5ac469d8bdc85', + decimals: 18, + name: 'SubQuery Network', + symbol: 'SQT', + }, + { + assetId: 'eip155:1/erc20:0x3073f7aaa4db83f95e9fff17424f71d4751a3073', + decimals: 8, + name: 'Movement', + symbol: 'MOVE', + }, + { + assetId: 'eip155:1/erc20:0x295b42684f90c77da7ea46336001010f2791ec8c', + decimals: 18, + name: 'Xi', + symbol: 'XI', + }, + { + assetId: 'eip155:1/erc20:0x584a4dd38d28fd1ea0e147ba7b70aed29a37e335', + decimals: 18, + name: 'Bitcoin Inu', + symbol: 'BTCINU', + }, + { + assetId: 'eip155:1/erc20:0x4ffe9cc172527df1e40d0b2efe1e9f05884a13da', + decimals: 18, + name: 'DEDPRZ', + symbol: 'USA', + }, + { + assetId: 'eip155:1/erc20:0xadd353fb2e2c563383ff3272a500f3e7134dafe4', + decimals: 18, + name: 'Tuna Chain', + symbol: 'TUNA', + }, + { + assetId: 'eip155:1/erc20:0xa130e3a33a4d84b04c3918c4e5762223ae252f80', + decimals: 18, + name: 'Swash Token', + symbol: 'SWASH', + }, + { + assetId: 'eip155:1/erc20:0x69a95185ee2a045cdc4bcd1b1df10710395e4e23', + decimals: 18, + name: '$Poolz Finance', + symbol: 'POOLZ', + }, + { + assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + name: 'Dai Stablecoin', + symbol: 'DAI', + }, + { + assetId: 'eip155:1/erc20:0x5d07f52ae5f779422447c2a32f58c93a15e7d6f2', + decimals: 18, + name: 'Kylin Network Token', + symbol: 'KYL', + }, + { + assetId: 'eip155:1/erc20:0x87c22db324b8b0637c8f09d2670ae7777651dbb8', + decimals: 18, + name: 'Root Protocol', + symbol: 'ISME', + }, + { + assetId: 'eip155:1/erc20:0xd9b312d77bc7bed9b9cecb56636300bed4fe5ce9', + decimals: 18, + name: 'Gains', + symbol: 'GAINS', + }, + { + assetId: 'eip155:1/erc20:0xde8cd13b812bcd82218754a740b27e76ec1e86ad', + decimals: 18, + name: 'Trestle', + symbol: 'TRESTLE', + }, + { + assetId: 'eip155:1/erc20:0xd4419c2d3daa986dc30444fa333a846be44fd1eb', + decimals: 18, + name: 'ZIK coin', + symbol: 'ZIK', + }, + { + assetId: 'eip155:1/erc20:0xe1ec350ea16d1ddaff57f31387b2d9708eb7ce28', + decimals: 9, + name: 'Pepechain', + symbol: 'PC', + }, + { + assetId: 'eip155:1/erc20:0xdaf88906ac1de12ba2b1d2f7bfc94e9638ac40c4', + decimals: 18, + name: 'EpiK Protocol', + symbol: 'ERC20-EPK', + }, + { + assetId: 'eip155:1/erc20:0xc6065b9fc8171ad3d29bad510709249681758972', + decimals: 18, + name: 'WFAIR Token', + symbol: 'WFAIR', + }, + { + assetId: 'eip155:1/erc20:0x96add417293a49e80f024734e96cfd8b355bcc14', + decimals: 18, + name: 'LiquidLayer', + symbol: 'LILA', + }, + { + assetId: 'eip155:1/erc20:0x80c62fe4487e1351b47ba49809ebd60ed085bf52', + decimals: 18, + name: 'Clover Finance', + symbol: 'CLV', + }, + { + assetId: 'eip155:1/erc20:0x799ebfabe77a6e34311eeee9825190b9ece32824', + decimals: 18, + name: 'Braintrust', + symbol: 'BTRST', + }, +] as const; + +export default v3AssetsMainnet; diff --git a/packages/assets-controller/src/__fixtures__/stale-balance/messenger.ts b/packages/assets-controller/src/__fixtures__/stale-balance/messenger.ts new file mode 100644 index 00000000000..7102e2c8685 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/stale-balance/messenger.ts @@ -0,0 +1,293 @@ +import type { NetworkState } from '@metamask/network-controller'; +import { + getDefaultNetworkControllerState, + NetworkClientType, + NetworkStatus, + RpcEndpointType, +} from '@metamask/network-controller'; +import type { Snap } from '@metamask/snaps-sdk'; + +import { + registerAccountMocks, + registerWalletLifecycleMocks, +} from '../MockAssetControllerMessenger.js'; +import type { MockRootMessenger } from '../MockAssetControllerMessenger.js'; +import { buildProviderState, createStaleBalanceProvider } from './provider.js'; +import type { StaleBalanceProviderState } from './provider.js'; +import { createSnapHandler } from './snap.js'; +import type { StaleBalanceSnapState } from './snap.js'; +import { buildStaleBalanceAccounts } from './staleBalanceWallet.js'; +import { + BNB_ASSET_ID, + BSC_CHAIN_ID, + BSC_CHAIN_ID_HEX, + BSC_NETWORK_CLIENT_ID, + BSC_RPC_URL, + ETH_ASSET_ID, + HOODI_CHAIN_ID, + HOODI_CHAIN_ID_HEX, + HOODI_NATIVE_ASSET_ID, + HOODI_NETWORK_CLIENT_ID, + HOODI_RPC_URL, + MAINNET_CHAIN_ID, + MAINNET_CHAIN_ID_HEX, + MAINNET_NETWORK_CLIENT_ID, + MAINNET_RPC_URL, + SOLANA_CHAIN_ID, + SOLANA_SNAP_ID, +} from './wallet.js'; + +/** Keyring and assets permissions that make the snap a keyring snap claiming the Solana chain. */ +const SOLANA_SNAP_PERMISSIONS = { + 'endowment:keyring': { + id: 'mock-solana-keyring-permission-id', + parentCapability: 'endowment:keyring', + invoker: SOLANA_SNAP_ID, + date: Date.now(), + caveats: null, + }, + 'endowment:assets': { + id: 'mock-solana-assets-permission-id', + parentCapability: 'endowment:assets', + invoker: SOLANA_SNAP_ID, + date: Date.now(), + caveats: [{ type: 'chainIds', value: [SOLANA_CHAIN_ID] }], + }, +} as const; + +/** + * NetworkController state with BNB Chain, mainnet and Hoodi configured. + * + * @returns The network state. + */ +function buildStaleBalanceNetworkState(): NetworkState { + return { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: BSC_NETWORK_CLIENT_ID, + networkConfigurationsByChainId: { + [BSC_CHAIN_ID_HEX]: { + chainId: BSC_CHAIN_ID_HEX, + name: 'BNB Chain', + nativeCurrency: 'BNB', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: BSC_NETWORK_CLIENT_ID, + url: BSC_RPC_URL, + type: RpcEndpointType.Custom, + failoverUrls: [], + }, + ], + }, + [MAINNET_CHAIN_ID_HEX]: { + chainId: MAINNET_CHAIN_ID_HEX, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: MAINNET_NETWORK_CLIENT_ID, + url: MAINNET_RPC_URL, + type: RpcEndpointType.Custom, + failoverUrls: [], + }, + ], + }, + [HOODI_CHAIN_ID_HEX]: { + chainId: HOODI_CHAIN_ID_HEX, + name: 'Hoodi', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: HOODI_NETWORK_CLIENT_ID, + url: HOODI_RPC_URL, + type: RpcEndpointType.Custom, + failoverUrls: [], + }, + ], + }, + }, + networksMetadata: { + [BSC_NETWORK_CLIENT_ID]: { status: NetworkStatus.Available, EIPS: {} }, + [MAINNET_NETWORK_CLIENT_ID]: { + status: NetworkStatus.Available, + EIPS: {}, + }, + [HOODI_NETWORK_CLIENT_ID]: { status: NetworkStatus.Available, EIPS: {} }, + }, + }; +} + +/** + * Provider states for the suite's three EVM chains, mutable between passes. + * + * @param overrides - Token balances / staking responses per chain. + * @returns The per-chain provider states, keyed by network client ID. + */ +export function buildStaleBalanceProviderStates(overrides: { + bsc?: Partial; + mainnet?: Partial; + hoodi?: Partial; +}): { + [networkClientId: string]: StaleBalanceProviderState; +} { + return { + [BSC_NETWORK_CLIENT_ID]: buildProviderState({ + chainIdHex: BSC_CHAIN_ID_HEX, + nativeBalanceWei: '0', + ...overrides.bsc, + }), + [MAINNET_NETWORK_CLIENT_ID]: buildProviderState({ + chainIdHex: MAINNET_CHAIN_ID_HEX, + nativeBalanceWei: '0', + ...overrides.mainnet, + }), + [HOODI_NETWORK_CLIENT_ID]: buildProviderState({ + chainIdHex: HOODI_CHAIN_ID_HEX, + nativeBalanceWei: '0', + ...overrides.hoodi, + }), + }; +} + +/** + * Register the NetworkController, NetworkEnablement and ConfigRegistry + * action handlers for the suite's three EVM chains. + * + * @param rootMessenger - The root messenger to register handlers on. + * @param providerStates - Provider states keyed by network client ID, as + * built by {@link buildStaleBalanceProviderStates}. + */ +export function registerStaleBalanceNetwork( + rootMessenger: MockRootMessenger, + providerStates: Record, +): void { + const networkState = buildStaleBalanceNetworkState(); + + rootMessenger.registerActionHandler( + 'NetworkController:getState', + () => networkState, + ); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + (networkClientId: string) => + ({ + configuration: { + type: NetworkClientType.Custom, + chainId: providerStates[networkClientId]?.chainIdHex, + rpcUrl: + networkState.networkConfigurationsByChainId[ + providerStates[networkClientId]?.chainIdHex ?? '0x0' + ]?.rpcEndpoints[0]?.url, + ticker: 'TEST', + failoverRpcUrls: [], + }, + provider: createStaleBalanceProvider( + providerStates[networkClientId] ?? + buildProviderState({ chainIdHex: '0x0', nativeBalanceWei: '0' }), + ), + }) as never, + ); + + rootMessenger.registerActionHandler( + 'NetworkEnablementController:getState', + () => ({ + enabledNetworkMap: { + eip155: { + [BSC_CHAIN_ID_HEX]: true, + [MAINNET_CHAIN_ID_HEX]: true, + [HOODI_CHAIN_ID_HEX]: true, + }, + solana: { [SOLANA_CHAIN_ID]: true }, + }, + nativeAssetIdentifiers: { + [BSC_CHAIN_ID]: BNB_ASSET_ID, + [MAINNET_CHAIN_ID]: ETH_ASSET_ID, + // Hoodi is absent from the controller's native-asset map, so it + // balances through the zero-address ERC-20 fallback the extension + // itself uses for such chains (see `NATIVE_ASSETS` in + // `utils/native-assets.ts`). `NativeAssetIdentifier` models only + // the `slip44` form, which does not admit that fallback asset ID. + // @ts-expect-error The captured fallback asset ID is not a slip44 form. + [HOODI_CHAIN_ID]: HOODI_NATIVE_ASSET_ID, + }, + }), + ); + + rootMessenger.registerActionHandler( + 'ConfigRegistryController:getNetworkConfigByCaip2ChainId', + () => undefined, + ); +} + +/** + * Register the SnapController / PermissionController action handlers that + * make the Solana keyring snap discoverable, plus its `handleRequest` + * handler. + * + * @param rootMessenger - The root messenger to register handlers on. + * @param snapState - The snap response state. + */ +export function registerSolanaSnap( + rootMessenger: MockRootMessenger, + snapState: StaleBalanceSnapState, +): void { + rootMessenger.registerActionHandler( + 'SnapController:getRunnableSnaps', + () => [{ id: SOLANA_SNAP_ID }] as never as Snap[], + ); + + rootMessenger.registerActionHandler( + 'PermissionController:getPermissions', + (subjectId: string) => { + if (subjectId === SOLANA_SNAP_ID) { + return SOLANA_SNAP_PERMISSIONS; + } + return {}; + }, + ); + + rootMessenger.registerActionHandler( + 'SnapController:handleRequest', + createSnapHandler(snapState), + ); +} + +/** + * Register every external action `AssetsController` needs to boot the + * suite's wallet. + * + * @param rootMessenger - The root messenger to register handlers on. + * @param opts - Providers, snap state, and lifecycle / flag options. + * @param opts.providerStates - Provider states keyed by network client ID. + * @param opts.snapState - The Solana snap response state. + * @param opts.lifecycle - Lifecycle / feature flag overrides. + */ +export function registerStaleBalanceControllerActions( + rootMessenger: MockRootMessenger, + opts: { + providerStates: Record; + snapState: StaleBalanceSnapState; + lifecycle?: Parameters[1]; + }, +): void { + registerWalletLifecycleMocks( + rootMessenger, + opts.lifecycle ?? { + isKeyringUnlocked: true, + isAccountTreeInitialized: true, + clientControllerState: { isUiOpen: true }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, + }, + ); + registerAccountMocks(rootMessenger, { + accounts: buildStaleBalanceAccounts(), + }); + registerStaleBalanceNetwork(rootMessenger, opts.providerStates); + registerSolanaSnap(rootMessenger, opts.snapState); +} diff --git a/packages/assets-controller/src/__fixtures__/stale-balance/provider.ts b/packages/assets-controller/src/__fixtures__/stale-balance/provider.ts new file mode 100644 index 00000000000..4a5e51fdd86 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/stale-balance/provider.ts @@ -0,0 +1,293 @@ +import { defaultAbiCoder, Interface } from '@ethersproject/abi'; +import type { Hex, Json } from '@metamask/utils'; + +import { MULTICALL3_ADDRESS } from './wallet.js'; + +/** + * Multicall3 ABI subset: `aggregate3` (batched calls) and `getEthBalance` + * (native balance through the Multicall3 contract). + */ +const MULTICALL3_ABI = [ + { + inputs: [ + { + components: [ + { internalType: 'address', name: 'target', type: 'address' }, + { internalType: 'bool', name: 'allowFailure', type: 'bool' }, + { internalType: 'bytes', name: 'callData', type: 'bytes' }, + ], + internalType: 'struct Multicall3.Call3[]', + name: 'calls', + type: 'tuple[]', + }, + ], + name: 'aggregate3', + outputs: [ + { + components: [ + { internalType: 'bool', name: 'success', type: 'bool' }, + { internalType: 'bytes', name: 'returnData', type: 'bytes' }, + ], + internalType: 'struct Multicall3.Result[]', + name: 'returnData', + type: 'tuple[]', + }, + ], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'addr', type: 'address' }], + name: 'getEthBalance', + outputs: [{ internalType: 'uint256', name: 'balance', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, +]; + +/** ERC-20 ABI subset: `balanceOf`. */ +const ERC20_ABI = [ + { + inputs: [{ internalType: 'address', name: 'account', type: 'address' }], + name: 'balanceOf', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, +]; + +/** Staking contract ABI subset: `getShares` and `convertToAssets`. */ +const STAKING_ABI = [ + { + inputs: [{ internalType: 'address', name: 'account', type: 'address' }], + name: 'getShares', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'shares', type: 'uint256' }], + name: 'convertToAssets', + outputs: [{ internalType: 'uint256', name: 'assets', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, +]; + +const multicall3Interface = new Interface(MULTICALL3_ABI); +const erc20Interface = new Interface(ERC20_ABI); +const stakingInterface = new Interface(STAKING_ABI); + +/** Staking-vault responses the provider should give. Mutable between passes. */ +export type StakingResponses = { + /** `getShares(account)` response, in wei. */ + sharesWei: string; + /** `convertToAssets(shares)` response, in wei. */ + assetsWei: string; +}; + +/** Everything the dynamic provider needs to answer an EVM chain. */ +export type StaleBalanceProviderState = { + /** Hex chain ID, answered to `eth_chainId`. */ + chainIdHex: Hex; + /** Native balance of the account, in wei, answered to `getEthBalance`. */ + nativeBalanceWei: string; + /** ERC-20 balances in wei, keyed by lower-cased contract address. */ + tokenBalancesWei: Record; + /** Lower-cased contract addresses whose `balanceOf` read should fail (batched entries are answered as failed; single calls reject). */ + failingTokens: string[]; + /** Staking responses keyed by lower-cased staking contract address. */ + stakingByContract: Record; + /** When set, every `eth_call` and `eth_getBalance` rejects. */ + failAll: boolean; +}; + +/** + * Build a mutable, scenario-shaped EVM RPC provider state. + * + * @param partial - Initial state (requires `chainIdHex` and + * `nativeBalanceWei`). + * @returns The provider state with defaults filled in. + */ +export function buildProviderState( + partial: Partial & { + chainIdHex: Hex; + nativeBalanceWei: string; + }, +): StaleBalanceProviderState { + return { + tokenBalancesWei: {}, + failingTokens: [], + stakingByContract: {}, + failAll: false, + ...partial, + }; +} + +function isFailingToken( + state: StaleBalanceProviderState, + address: string, +): boolean { + return state.failingTokens.includes(address.toLowerCase()); +} + +/** + * Answer one `aggregate3` batch: native balances from `getEthBalance`, + * token balances from `balanceOf`, and failed entries for tokens listed in + * `failingTokens` — exactly the shapes the on-chain Multicall3 contract + * returns when a sub-call reverts. + * + * @param state - The provider state. + * @param callData - The `aggregate3` call data. + * @returns The ABI-encoded `Result[]` response. + */ +function answerAggregate3( + state: StaleBalanceProviderState, + callData: Hex, +): Hex { + const [calls] = multicall3Interface.decodeFunctionData( + 'aggregate3', + callData, + ) as [{ target: string; callData: string }[]]; + + const results = calls.map(({ target, callData: innerCallData }) => { + try { + multicall3Interface.decodeFunctionData('getEthBalance', innerCallData); + if (state.failAll) { + return { success: false, returnData: '0x' }; + } + return { + success: true, + returnData: defaultAbiCoder.encode( + ['uint256'], + [state.nativeBalanceWei], + ), + }; + } catch { + // Not a getEthBalance call; fall through to balanceOf. + } + + try { + erc20Interface.decodeFunctionData('balanceOf', innerCallData); + if (state.failAll || isFailingToken(state, target)) { + return { success: false, returnData: '0x' }; + } + const balance = state.tokenBalancesWei[target.toLowerCase()] ?? '0'; + return { + success: true, + returnData: defaultAbiCoder.encode(['uint256'], [balance]), + }; + } catch { + // Not a balanceOf call either — a probe we do not model. Answer empty. + return { success: true, returnData: '0x' }; + } + }); + + // `defaultAbiCoder.encode` always returns a '0x'-prefixed hex string but + // is typed as plain `string`. + return defaultAbiCoder.encode(['tuple(bool,bytes)[]'], [results]) as Hex; +} + +/** + * Build the EIP-1193 provider `RpcDataSource` wraps in a `Web3Provider`. + * Answers chain probes, native and token balance reads (single and + * Multicall3-batched), and staking-contract reads, all from mutable state. + * + * @param state - The provider state the provider answers from. + * @returns The provider, ready for `NetworkController:getNetworkClientById`. + */ +export function createStaleBalanceProvider(state: StaleBalanceProviderState): { + request: (args: { method: string; params?: unknown[] }) => Promise; +} { + return { + request: async ({ method, params }) => { + switch (method) { + case 'eth_chainId': { + return state.chainIdHex; + } + + case 'eth_blockNumber': { + return '0x1'; + } + + case 'eth_getBalance': { + if (state.failAll) { + throw new Error('RPC unreachable: eth_getBalance'); + } + return `0x${BigInt(state.nativeBalanceWei).toString(16)}`; + } + + case 'eth_call': { + // ethers v5 performs `eth_call` with a single transaction object + // as the first parameter. + const transaction = + (params as { to?: string; data?: string }[] | undefined)?.[0] ?? {}; + const target = transaction.to; + const callData = transaction.data as Hex | undefined; + if (!target || !callData) { + return '0x'; + } + + if (state.failAll) { + throw new Error('RPC unreachable: eth_call'); + } + + const lowerTarget = target.toLowerCase(); + + // Staking contract reads. + const staking = state.stakingByContract[lowerTarget]; + if (staking) { + try { + stakingInterface.decodeFunctionData('getShares', callData); + return defaultAbiCoder.encode(['uint256'], [staking.sharesWei]); + } catch { + // Not getShares; try convertToAssets. + } + try { + stakingInterface.decodeFunctionData('convertToAssets', callData); + return defaultAbiCoder.encode(['uint256'], [staking.assetsWei]); + } catch { + // An unmodeled staking read — treat it as a reverted call. + throw new Error( + `StaleBalanceProvider: unmodeled staking call to ${target}`, + ); + } + } + + // Multicall3 aggregate3 batch. + if (lowerTarget === MULTICALL3_ADDRESS.toLowerCase()) { + try { + multicall3Interface.decodeFunctionData('aggregate3', callData); + return answerAggregate3(state, callData); + } catch { + // Not an aggregate3 call; answer empty as for any probe. + return '0x'; + } + } + + // Single balanceOf fallback call. + try { + erc20Interface.decodeFunctionData('balanceOf', callData); + } catch { + // Not a balanceOf call — an unmodeled probe. Answer empty. + return '0x'; + } + if (isFailingToken(state, target)) { + // A read that the contract reverts rejects, exactly like a real + // RPC `eth_call` to a broken contract. The client treats this as a + // failed read and preserves the previous amount in state. + throw new Error( + `StaleBalanceProvider: balanceOf reverted for ${target}`, + ); + } + const balance = state.tokenBalancesWei[lowerTarget] ?? '0'; + return defaultAbiCoder.encode(['uint256'], [balance]); + } + + default: { + return '0x'; + } + } + }, + }; +} diff --git a/packages/assets-controller/src/__fixtures__/stale-balance/rpcAmounts.ts b/packages/assets-controller/src/__fixtures__/stale-balance/rpcAmounts.ts new file mode 100644 index 00000000000..e7bd6f366ef --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/stale-balance/rpcAmounts.ts @@ -0,0 +1,130 @@ +import { BigNumber } from 'bignumber.js'; + +import v6MultiAccountBalancesBsc from './api-responses/accounts-api/v6-multiaccount-balances-bsc.js'; +import v6MultiAccountBalancesMainnet from './api-responses/accounts-api/v6-multiaccount-balances-mainnet.js'; +import type { V6BalanceEntry } from './api-responses/index.js'; + +/** A captured balance plus the decimals it is denominated at. */ +export type CapturedBalance = { balance: string; decimals: number }; + +/** + * Double a decimal balance string, exactly, modeling the wallet's holdings + * moving between a scenario's passes. + * + * @param balance - The decimal balance string, as captured. + * @returns The doubled balance string. + */ +export function doubleBalance(balance: string): string { + return new BigNumber(balance).multipliedBy(2).toFixed(); +} + +/** + * Convert a decimal balance string to its smallest-unit integer amount, + * as an `eth_call` / `aggregate3` response would carry it. + * + * @param balance - The decimal balance string. + * @param decimals - The asset's smallest-unit precision. + * @returns The integer amount as a decimal string. + */ +export function toSmallestUnits(balance: string, decimals: number): string { + return new BigNumber(balance).multipliedBy(10 ** decimals).toFixed(0); +} + +/** + * The native row of the BNB Chain capture. + * + * @returns The capture's `native` entry. + */ +function getBscNativeEntry(): V6BalanceEntry { + const native = v6MultiAccountBalancesBsc.balances.find( + (entry) => entry.type === 'native', + ); + if (!native) { + throw new Error('BSC capture is missing its native balance row'); + } + return native; +} + +/** + * The captured BNB Chain balances, keyed by lower-cased asset ID. + * + * @returns The captured raw amounts. + */ +export function buildCapturedBscBalances(): Record { + const balances: Record = {}; + for (const entry of v6MultiAccountBalancesBsc.balances) { + balances[entry.assetId.toLowerCase()] = { + balance: entry.balance, + decimals: entry.decimals, + }; + } + return balances; +} + +/** + * The captured mainnet balances, keyed by lower-cased asset ID. + * + * @returns The captured raw amounts. + */ +export function buildCapturedMainnetBalances(): Record< + string, + CapturedBalance +> { + const balances: Record = {}; + for (const entry of v6MultiAccountBalancesMainnet.balances) { + balances[entry.assetId.toLowerCase()] = { + balance: entry.balance, + decimals: entry.decimals, + }; + } + return balances; +} + +/** + * The doubled BNB Chain balances the RPC pass should land, keyed by + * lower-cased asset ID. + * + * @returns The expected raw amounts. + */ +export function buildDoubledBscBalances(): Record { + const balances: Record = {}; + for (const entry of v6MultiAccountBalancesBsc.balances) { + balances[entry.assetId.toLowerCase()] = { + balance: doubleBalance(entry.balance), + decimals: entry.decimals, + }; + } + return balances; +} + +/** + * Token balances for the BNB Chain provider state, as the captured ERC-20 + * holdings doubled, keyed by lower-cased contract address. + * + * @returns Balances in smallest units. + */ +export function buildDoubledBscTokenBalances(): Record { + const tokenBalancesWei: Record = {}; + for (const entry of v6MultiAccountBalancesBsc.balances) { + if (entry.type !== 'erc20') { + continue; + } + const contract = entry.assetId.split(':').pop() as string; + tokenBalancesWei[contract.toLowerCase()] = toSmallestUnits( + doubleBalance(entry.balance), + entry.decimals, + ); + } + return tokenBalancesWei; +} + +/** + * The doubled BNB Chain native balance, in wei, as the provider state's + * `nativeBalanceWei`. + * + * @returns The doubled native balance. + */ +export function buildDoubledBscNativeBalance(): string { + const native = getBscNativeEntry(); + return toSmallestUnits(doubleBalance(native.balance), native.decimals); +} diff --git a/packages/assets-controller/src/__fixtures__/stale-balance/snap.ts b/packages/assets-controller/src/__fixtures__/stale-balance/snap.ts new file mode 100644 index 00000000000..52b25e44fa6 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/stale-balance/snap.ts @@ -0,0 +1,80 @@ +import type { Json } from '@metamask/utils'; + +/** A balance row as reported by a keyring snap. */ +export type SnapBalanceRow = { + amount: string; + /** Required by the keyring API's `Balance` struct; unused downstream. */ + unit: string; + metadata?: Json; +}; + +/** Mutable, scenario-shaped keyring-snap responses. */ +export type StaleBalanceSnapState = { + /** Asset IDs the snap lists for the account (`keyring_listAccountAssets`). */ + listedAssetIds: string[]; + /** Balances the snap reports (`keyring_getAccountBalances`); asked-for assets absent here are skipped. */ + balances: Record; + /** When set, every snap call rejects (the snap is unreachable). */ + failAll: boolean; +}; + +/** + * Build a mutable, scenario-shaped keyring-snap response state. + * + * @param partial - Initial state. + * @returns The snap state with defaults filled in. + */ +export function buildSnapState( + partial: Partial, +): StaleBalanceSnapState { + return { + listedAssetIds: [], + balances: {}, + failAll: false, + ...partial, + }; +} + +/** + * Build the `SnapController:handleRequest` action handler for the Solana + * keyring snap, from mutable state. + * + * @param state - The snap response state. + * @returns The handler, answering only `OnKeyringRequest` methods. + */ +export function createSnapHandler(state: StaleBalanceSnapState) { + return ({ + request, + }: { + request?: { + method?: string; + params?: { id?: string; assets?: string[] }; + }; + }): Promise => { + if (state.failAll) { + return Promise.reject(new Error('Keyring snap unreachable')); + } + const { method } = request ?? {}; + if (method === 'keyring_listAccountAssets') { + return Promise.resolve([...state.listedAssetIds] as Json); + } + if (method === 'keyring_getAccountBalances') { + const requested = request?.params?.assets; + if (!requested) { + return Promise.resolve( + state.balances as unknown as Record as Json, + ); + } + // Asked-for assets the snap has no data for are skipped, like a real snap. + const answered = Object.fromEntries( + requested + .filter((assetId) => assetId in state.balances) + .map((assetId) => [assetId, state.balances[assetId]]), + ); + return Promise.resolve( + answered as unknown as Record as Json, + ); + } + return Promise.resolve(null); + }; +} diff --git a/packages/assets-controller/src/__fixtures__/stale-balance/staleBalanceWallet.ts b/packages/assets-controller/src/__fixtures__/stale-balance/staleBalanceWallet.ts new file mode 100644 index 00000000000..1fecb2ec6b8 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/stale-balance/staleBalanceWallet.ts @@ -0,0 +1,137 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +import type { AssetsControllerState } from '../../types.js'; +import { createMockInternalAccount } from '../MockAssetControllerMessenger.js'; +import { + BSC_ACCOUNT_ID, + BSC_CHAIN_ID, + HOODI_CHAIN_ID, + MAINNET_ACCOUNT_ID, + MAINNET_CHAIN_ID, + SOLANA_ACCOUNT_ID, + SOLANA_CHAIN_ID, + SOLANA_SNAP_ID, + SOLANA_USDC_ASSET_ID, + SOLANA_WALLET_ADDRESS, + STALE_WALLET_ADDRESS, +} from './wallet.js'; + +/** + * Build the BNB Chain `InternalAccount`, scoped to BNB Chain only. + * + * @param overrides - Fields to override on the account. + * @returns The internal account. + */ +export function buildBscAccount( + overrides?: Partial, +): InternalAccount { + const { metadata, ...rest } = overrides ?? {}; + return createMockInternalAccount({ + id: BSC_ACCOUNT_ID, + address: STALE_WALLET_ADDRESS, + scopes: [BSC_CHAIN_ID], + metadata: { + name: 'Stale Balance Wallet', + keyring: { type: 'HD Key Tree' }, + importTime: 1_756_100_000_000, + ...metadata, + }, + ...rest, + }); +} + +/** + * Build the mainnet `InternalAccount`, scoped to mainnet and Hoodi. + * + * @param overrides - Fields to override on the account. + * @returns The internal account. + */ +export function buildMainnetAccount( + overrides?: Partial, +): InternalAccount { + const { metadata, ...rest } = overrides ?? {}; + return createMockInternalAccount({ + id: MAINNET_ACCOUNT_ID, + address: STALE_WALLET_ADDRESS, + scopes: [MAINNET_CHAIN_ID, HOODI_CHAIN_ID], + metadata: { + name: 'Stale Balance Wallet', + keyring: { type: 'HD Key Tree' }, + importTime: 1_756_100_000_000, + ...metadata, + }, + ...rest, + }); +} + +/** + * Build the Solana `InternalAccount` owned by the keyring snap; + * `metadata.snap.id` routes it to `SnapDataSource`. + * + * @param overrides - Fields to override on the account. + * @returns The internal account. + */ +export function buildSolanaSnapAccount( + overrides?: Partial, +): InternalAccount { + const { metadata, ...rest } = overrides ?? {}; + return createMockInternalAccount({ + id: SOLANA_ACCOUNT_ID, + address: SOLANA_WALLET_ADDRESS, + type: 'solana:data-account', + scopes: [SOLANA_CHAIN_ID], + metadata: { + name: 'Solana Snap Account', + keyring: { type: 'Snap Keyring' }, + importTime: 1_756_100_000_000, + snap: { id: SOLANA_SNAP_ID }, + ...metadata, + }, + ...rest, + }); +} + +/** + * All three accounts, as the selected account group reports them. + * + * @returns The accounts of the selected account group. + */ +export function buildStaleBalanceAccounts(): InternalAccount[] { + return [buildBscAccount(), buildMainnetAccount(), buildSolanaSnapAccount()]; +} + +/** + * A fresh wallet: no balances, metadata, prices or custom assets yet. + * + * @param overrides - State slices to override. + * @returns The starting state. + */ +export function buildEmptyStaleBalanceState( + overrides?: Partial, +): AssetsControllerState { + return { + assetsInfo: {}, + assetsBalance: {}, + assetsPrice: {}, + customAssets: {}, + assetPreferences: {}, + selectedCurrency: 'usd', + ...overrides, + }; +} + +/** + * A fresh Solana wallet with USDC imported as a custom asset (the user's + * pin). + * + * @param overrides - State slices to override. + * @returns The starting state for the Solana scenarios. + */ +export function buildEmptySolanaSnapState( + overrides?: Partial, +): AssetsControllerState { + return buildEmptyStaleBalanceState({ + customAssets: { [SOLANA_ACCOUNT_ID]: [SOLANA_USDC_ASSET_ID] }, + ...overrides, + }); +} diff --git a/packages/assets-controller/src/__fixtures__/stale-balance/wallet.ts b/packages/assets-controller/src/__fixtures__/stale-balance/wallet.ts new file mode 100644 index 00000000000..85eaba1be23 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/stale-balance/wallet.ts @@ -0,0 +1,107 @@ +/** + * Constants for the stale-balance scenario suite: the same real BNB Chain + * wallet as the `bsc-spam-token` fixtures, on BNB Chain, mainnet and Hoodi, + * plus a Solana keyring-snap account. + */ + +/** BNB Smart Chain. */ +export const BSC_CHAIN_ID = 'eip155:56' as const; + +/** Example wallet, as it appears in the Accounts API request. */ +export const STALE_WALLET_ADDRESS = + '0x9decDe522Cc1285efe18AfdE31C79e89dee2e91E'; + +/** `InternalAccount.id` (a UUID), not the address — see `AccountId`. */ +export const BSC_ACCOUNT_ID = '5f6a7b8c-9d0e-4f1a-b2c3-d4e5f6a7b8c9'; + +/** BNB, the native asset of BNB Chain. */ +export const BNB_ASSET_ID = `${BSC_CHAIN_ID}/slip44:714` as const; + +/** Binance-Peg USDT, the token whose single `balanceOf` fails in the RPC-fallback scenarios. */ +export const USDT_ASSET_ID_CHECKSUM = + `${BSC_CHAIN_ID}/erc20:0x55d398326f99059fF775485246999027B3197955` as const; + +/** GT Protocol, a second BNB Chain token used to prove fresh reads land. */ +export const GTAI_ASSET_ID_CHECKSUM = + `${BSC_CHAIN_ID}/erc20:0x003d87d02A2A01E9E8a20f507C83E15DD83A33d1` as const; + +/** The USDT contract on BNB Chain, lower-cased for provider-state keys. */ +export const USDT_CONTRACT = + '0x55d398326f99059ff775485246999027b3197955' as const; + +/** Ethereum mainnet. */ +export const MAINNET_CHAIN_ID = 'eip155:1' as const; + +/** ETH, the native asset of Ethereum mainnet. */ +export const ETH_ASSET_ID = `${MAINNET_CHAIN_ID}/slip44:60` as const; + +/** `InternalAccount.id` (a UUID) of the mainnet account — the same EOA as the BNB Chain wallet. */ +export const MAINNET_ACCOUNT_ID = '0a1b2c3d-4e5f-4a6b-8c9d-e0f1a2b3c4d5'; + +/** Hoodi, the testnet that (like mainnet) has a known staking contract. */ +export const HOODI_CHAIN_ID = 'eip155:560048' as const; + +/** Staking contract on Ethereum mainnet (see `staking-contracts.ts`). */ +export const MAINNET_STAKING_CONTRACT = + '0x4fef9d741011476750a243ac70b9789a63dd47df' as const; + +/** Staking contract on Hoodi (see `staking-contracts.ts`). */ +export const HOODI_STAKING_CONTRACT = + '0xe96ac18cfe5a7af8fe1fe7bc37ff110d88bc67ff' as const; + +/** Staked ETH "vault share" asset ID on mainnet, as keyed in state. */ +export const MAINNET_STAKED_ETH_ASSET_ID = + `${MAINNET_CHAIN_ID}/erc20:0x4FEF9D741011476750A243aC70b9789a63dd47Df` as const; + +/** Staked ETH "vault share" asset ID on Hoodi, as keyed in state. */ +export const HOODI_STAKED_ETH_ASSET_ID = + `${HOODI_CHAIN_ID}/erc20:0xE96aC18cFE5A7AF8FE1FE7Bc37FF110d88bc67fF` as const; + +/** + * Native asset ID for Hoodi ETH — the controller's zero-address ERC-20 + * fallback for EVM chains absent from its native-asset map. + */ +export const HOODI_NATIVE_ASSET_ID = + `${HOODI_CHAIN_ID}/erc20:0x0000000000000000000000000000000000000000` as const; + +/** Solana mainnet. */ +export const SOLANA_CHAIN_ID = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const; + +/** The keyring snap that owns the Solana account. */ +export const SOLANA_SNAP_ID = 'npm:@metamask/solana-wallet-snap' as const; + +/** Solana account address (base58), as reported by the snap. */ +export const SOLANA_WALLET_ADDRESS = + 'FhRuTg4d2vbVbY1AhPWFGaJgMxNWUxUJUcNhjT5rFQZg' as const; + +/** `InternalAccount.id` (a UUID) of the Solana snap account. */ +export const SOLANA_ACCOUNT_ID = + 'a7b8c9d0-e1f2-4a3b-8c5d-6e7f8a9b0c1d' as const; + +/** SOL, the native asset of Solana. */ +export const SOL_ASSET_ID = `${SOLANA_CHAIN_ID}/slip44:501` as const; + +/** USDC on Solana, imported as a custom asset so it stays visible even when the snap omits it. */ +export const SOLANA_USDC_ASSET_ID = + `${SOLANA_CHAIN_ID}/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v` as const; + +/** JUP (the real Jupiter mint), a token the snap lists and reports itself. */ +export const SOLANA_JUP_ASSET_ID = + `${SOLANA_CHAIN_ID}/token:JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN` as const; + +export const BSC_CHAIN_ID_HEX = '0x38' as const; +export const BSC_NETWORK_CLIENT_ID = 'bsc' as const; +export const BSC_RPC_URL = 'https://bsc-rpc.test' as const; + +export const MAINNET_CHAIN_ID_HEX = '0x1' as const; +export const MAINNET_NETWORK_CLIENT_ID = 'mainnet' as const; +export const MAINNET_RPC_URL = 'https://mainnet-rpc.test' as const; + +export const HOODI_CHAIN_ID_HEX = '0x88bb0' as const; +export const HOODI_NETWORK_CLIENT_ID = 'hoodi' as const; +export const HOODI_RPC_URL = 'https://hoodi-rpc.test' as const; + +/** Multicall3 contract (same deployment on every chain; see MulticallClient). */ +export const MULTICALL3_ADDRESS = + '0xcA11bde05977b3631167028862bE2a173976CA11' as const; diff --git a/packages/assets-controller/src/__fixtures__/test-utils.ts b/packages/assets-controller/src/__fixtures__/test-utils.ts index d8a6d6ef5d6..4c2919ebb9c 100644 --- a/packages/assets-controller/src/__fixtures__/test-utils.ts +++ b/packages/assets-controller/src/__fixtures__/test-utils.ts @@ -48,6 +48,26 @@ export const waitFor = async ( ); }; +/** + * Testing Utility - getIgnoringCase. Look up a record entry ignoring + * CAIP-19 address casing, for asserting on state that may be keyed by either + * lower-cased or checksummed asset IDs. + * + * @param record - The record to search. + * @param assetId - The asset ID to look up, any casing. + * @returns The matching entry, or `undefined` when absent. + */ +export const getIgnoringCase = ( + record: Record, + assetId: string, +): unknown => { + const lowerId = assetId.toLowerCase(); + const match = Object.keys(record).find( + (key) => key.toLowerCase() === lowerId, + ); + return match === undefined ? undefined : record[match]; +}; + /** * Returns a plain deep clone of the given object with all `lastUpdated` * timestamps zeroed, so that snapshots are deterministic.