diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index fe3376bd7f..bd0e3d08d9 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add Stellar classic trustline enrichment to `AssetsController` via explicit `refreshAccountAssetInfo` and `invalidateAccountAssetExtras` messenger actions ([#TODO](https://github.com/MetaMask/core/pull/TODO)) + - Store trustline fields on `FungibleAssetBalance.extra` (`limit`, `authorized`, `sponsored`) + - Apply Stellar classic trustline enrichment incrementally per snap batch so successful batches are not blocked by later hung requests ([#TODO](https://github.com/MetaMask/core/pull/TODO)) + - Batch Stellar classic `getAccountAssetInfo` snap requests and serialize per snap id to avoid mobile snap termination on bulk enrichment ([#TODO](https://github.com/MetaMask/core/pull/TODO)) + - Refresh Stellar classic trustline enrichment on keyring unlock and after balance sync for tracked classic assets in `assetsBalance` and `customAssets` + - `addCustomAsset` triggers async enrichment for Stellar classic assets; `removeCustomAsset` sets `extra.limit` to `'0'` + - Export `isStellarClassicTrustlineInactiveForDisplay` for client trustline UX + ### Changed - Bump `@metamask/assets-controllers` from `^109.0.0` to `^109.1.0` ([#9110](https://github.com/MetaMask/core/pull/9110)) diff --git a/packages/assets-controller/src/AssetsController-method-action-types.ts b/packages/assets-controller/src/AssetsController-method-action-types.ts index 8cdd37b170..7379f6f3fb 100644 --- a/packages/assets-controller/src/AssetsController-method-action-types.ts +++ b/packages/assets-controller/src/AssetsController-method-action-types.ts @@ -93,6 +93,32 @@ export type AssetsControllerGetCustomAssetsAction = { handler: AssetsController['getCustomAssets']; }; +/** + * Fetches and merges snap account-asset enrichment for eligible assets. + * Only Stellar classic `asset:` tokens on enrichment-enabled chains are processed. + * No-ops when the keyring is locked or the snap request fails. + * + * @param accountId - Internal account UUID. + * @param assetIds - CAIP-19 asset ids to enrich. + */ +export type AssetsControllerRefreshAccountAssetInfoAction = { + type: `AssetsController:refreshAccountAssetInfo`; + handler: AssetsController['refreshAccountAssetInfo']; +}; + +/** + * Marks Stellar classic trustline enrichment as inactive for the given assets. + * Sets `extra.limit` to `'0'` rather than deleting `extra`, so UI can distinguish + * inactive trustlines from not-yet-enriched state. + * + * @param accountId - Internal account UUID. + * @param assetIds - CAIP-19 asset ids to invalidate. + */ +export type AssetsControllerInvalidateAccountAssetExtrasAction = { + type: `AssetsController:invalidateAccountAssetExtras`; + handler: AssetsController['invalidateAccountAssetExtras']; +}; + /** * Hide an asset globally. * Hidden assets are excluded from the asset list returned by getAssets. @@ -138,6 +164,8 @@ export type AssetsControllerMethodActions = | AssetsControllerAddCustomAssetAction | AssetsControllerRemoveCustomAssetAction | AssetsControllerGetCustomAssetsAction + | AssetsControllerRefreshAccountAssetInfoAction + | AssetsControllerInvalidateAccountAssetExtrasAction | AssetsControllerHideAssetAction | AssetsControllerUnhideAssetAction | AssetsControllerSetSelectedCurrencyAction; diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 98a34fe1c9..08fe9b7cd4 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -19,6 +19,10 @@ import type { } from './AssetsController'; import type { PriceDataSourceConfig } from './data-sources/PriceDataSource'; import { PriceDataSource } from './data-sources/PriceDataSource'; +import { + ASSETS_PERMISSION, + KEYRING_PERMISSION, +} from './data-sources/SnapDataSource'; import { TokenDataSource } from './data-sources/TokenDataSource'; import { buildDefaultAssetsInfo } from './defaults'; import type { @@ -29,6 +33,7 @@ import type { FungibleAssetMetadata, } from './types'; import { formatExchangeRatesForBridge, normalizeAssetId } from './utils'; +import { GET_ACCOUNT_ASSET_INFO_CLIENT_METHOD } from './utils/account-asset-enrichment'; jest.mock('./utils', () => { const actual = jest.requireActual('./utils'); @@ -89,6 +94,57 @@ const MOCK_ASSET_ID = const MOCK_ASSET_ID_LOWERCASE = 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Caip19AssetId; const MOCK_NATIVE_ASSET_ID = 'eip155:1/slip44:60' as Caip19AssetId; +const STELLAR_CLASSIC_USDC = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as Caip19AssetId; +const STELLAR_SNAP_ID = 'npm:@metamask/stellar-wallet-snap'; + +function registerStellarSnapMocks( + messenger: RootMessenger, + handleSnapRequest: jest.Mock, +): void { + ( + messenger as { + registerActionHandler: (a: string, h: (...args: unknown[]) => unknown) => void; + } + ).registerActionHandler('SnapController:getRunnableSnaps', () => [ + { + id: STELLAR_SNAP_ID, + version: '1.0.0', + enabled: true, + blocked: false, + }, + ]); + ( + messenger as { + registerActionHandler: (a: string, h: (...args: unknown[]) => unknown) => void; + } + ).registerActionHandler('PermissionController:getPermissions', () => ({ + [KEYRING_PERMISSION]: { + id: 'mock-keyring-permission-id', + parentCapability: KEYRING_PERMISSION, + invoker: 'test', + date: Date.now(), + caveats: null, + }, + [ASSETS_PERMISSION]: { + id: 'mock-assets-permission-id', + parentCapability: ASSETS_PERMISSION, + invoker: 'test', + date: Date.now(), + caveats: [ + { + type: 'chainIds', + value: ['stellar:pubnet'], + }, + ], + }, + })); + ( + messenger as { + registerActionHandler: (a: string, h: (...args: unknown[]) => unknown) => void; + } + ).registerActionHandler('SnapController:handleRequest', handleSnapRequest); +} function createMockInternalAccount( overrides?: Partial, @@ -124,6 +180,8 @@ type WithControllerOptions = { priceDataSourceConfig: PriceDataSourceConfig; isEnabled: () => boolean; }>; + /** When set, registers Stellar snap mocks before controller construction. */ + snapHandleRequest?: jest.Mock; }; type WithControllerCallback = ({ @@ -152,6 +210,7 @@ async function withController( isBasicFunctionality = (): boolean => true, clientControllerState, controllerOptions = {}, + snapHandleRequest, }, fn, ]: [WithControllerOptions, WithControllerCallback] = @@ -167,6 +226,10 @@ async function withController( namespace: MOCK_ANY_NAMESPACE, }); + if (snapHandleRequest) { + registerStellarSnapMocks(messenger, snapHandleRequest); + } + // Mock AccountsController ( messenger as { @@ -2278,4 +2341,277 @@ describe('AssetsController', () => { ); }); }); + + describe('Stellar classic trustline enrichment', () => { + it('preserves extra in merge mode when balance sync is amount-only', async () => { + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [STELLAR_CLASSIC_USDC]: { + amount: '1', + extra: { limit: '500' }, + }, + }, + }, + }; + + await withController({ state: initialState }, async ({ controller }) => { + await controller.handleAssetsUpdate( + { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [STELLAR_CLASSIC_USDC]: { amount: '2' }, + }, + }, + updateMode: 'merge', + }, + 'SnapDataSource', + ); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[STELLAR_CLASSIC_USDC], + ).toStrictEqual({ + amount: '2', + extra: { limit: '500' }, + }); + }); + }); + + it('preserves extra in full mode when balance sync is amount-only', async () => { + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [STELLAR_CLASSIC_USDC]: { + amount: '1', + extra: { limit: '500' }, + }, + }, + }, + }; + + await withController({ state: initialState }, async ({ controller }) => { + await controller.handleAssetsUpdate( + { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [STELLAR_CLASSIC_USDC]: { amount: '2' }, + }, + }, + updateMode: 'full', + }, + 'SnapDataSource', + ); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[STELLAR_CLASSIC_USDC], + ).toStrictEqual({ + amount: '2', + extra: { limit: '500' }, + }); + }); + }); + + it('refreshAccountAssetInfo merges snap trustline extra', async () => { + const handleSnapRequest = jest.fn().mockResolvedValue({ + [STELLAR_CLASSIC_USDC]: { limit: '1000', authorized: true }, + }); + + await withController( + { + snapHandleRequest: handleSnapRequest, + state: { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [STELLAR_CLASSIC_USDC]: { amount: '0' }, + }, + }, + }, + }, + async ({ controller, messenger }) => { + messenger.publish('KeyringController:unlock'); + + await controller.refreshAccountAssetInfo(MOCK_ACCOUNT_ID, [ + STELLAR_CLASSIC_USDC, + ]); + + expect(handleSnapRequest).toHaveBeenCalledWith( + expect.objectContaining({ + snapId: STELLAR_SNAP_ID, + request: expect.objectContaining({ + method: GET_ACCOUNT_ASSET_INFO_CLIENT_METHOD, + }), + }), + ); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ + STELLAR_CLASSIC_USDC + ], + ).toStrictEqual({ + amount: '0', + extra: { limit: '1000', authorized: true }, + }); + }, + ); + }); + + it('invalidateAccountAssetExtras sets limit to zero for Stellar classic assets', async () => { + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [STELLAR_CLASSIC_USDC]: { + amount: '5', + extra: { limit: '1000', authorized: true, sponsored: false }, + }, + }, + }, + }; + + await withController({ state: initialState }, async ({ controller }) => { + controller.invalidateAccountAssetExtras(MOCK_ACCOUNT_ID, [ + STELLAR_CLASSIC_USDC, + ]); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[STELLAR_CLASSIC_USDC], + ).toStrictEqual({ + amount: '5', + extra: { limit: '0', authorized: true, sponsored: false }, + }); + }); + }); + + it('addCustomAsset schedules enrichment for Stellar classic assets only', async () => { + await withController(async ({ controller }) => { + const refreshSpy = jest + .spyOn(controller, 'refreshAccountAssetInfo') + .mockResolvedValue(undefined); + + await controller.addCustomAsset(MOCK_ACCOUNT_ID, STELLAR_CLASSIC_USDC); + await flushPromises(); + + expect(refreshSpy).toHaveBeenCalledWith(MOCK_ACCOUNT_ID, [ + STELLAR_CLASSIC_USDC, + ]); + + refreshSpy.mockClear(); + await controller.addCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID); + await flushPromises(); + + expect(refreshSpy).not.toHaveBeenCalled(); + }); + }); + + it('does not call snap when keyring is locked', async () => { + const handleSnapRequest = jest.fn().mockResolvedValue({ + [STELLAR_CLASSIC_USDC]: { limit: '1000' }, + }); + + await withController( + { snapHandleRequest: handleSnapRequest }, + async ({ controller }) => { + await controller.refreshAccountAssetInfo(MOCK_ACCOUNT_ID, [ + STELLAR_CLASSIC_USDC, + ]); + + expect(handleSnapRequest).not.toHaveBeenCalled(); + }, + ); + }); + + it('tolerates snap failures during refreshAccountAssetInfo', async () => { + const handleSnapRequest = jest + .fn() + .mockRejectedValue(new Error('snap failed')); + + await withController( + { + snapHandleRequest: handleSnapRequest, + state: { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [STELLAR_CLASSIC_USDC]: { amount: '0' }, + }, + }, + }, + }, + async ({ controller, messenger }) => { + messenger.publish('KeyringController:unlock'); + + await expect( + controller.refreshAccountAssetInfo(MOCK_ACCOUNT_ID, [ + STELLAR_CLASSIC_USDC, + ]), + ).resolves.toBeUndefined(); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ + STELLAR_CLASSIC_USDC + ], + ).toStrictEqual({ amount: '0' }); + }, + ); + }); + + it('schedules refreshAccountAssetInfo for Stellar classic assets in customAssets and assetsBalance on keyring unlock', async () => { + await withController( + { + state: { + customAssets: { + [MOCK_ACCOUNT_ID]: [STELLAR_CLASSIC_USDC], + }, + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [STELLAR_CLASSIC_USDC]: { amount: '1' }, + 'stellar:pubnet/asset:EURC-GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2': + { amount: '0.5' }, + }, + }, + }, + }, + async ({ controller, messenger }) => { + const refreshSpy = jest + .spyOn(controller, 'refreshAccountAssetInfo') + .mockResolvedValue(undefined); + + messenger.publish('KeyringController:unlock'); + await flushPromises(); + + expect(refreshSpy).toHaveBeenCalledWith( + MOCK_ACCOUNT_ID, + expect.arrayContaining([ + STELLAR_CLASSIC_USDC, + 'stellar:pubnet/asset:EURC-GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2', + ]), + ); + }, + ); + }); + + it('removeCustomAsset invalidates trustline extra', async () => { + const initialState: Partial = { + customAssets: { + [MOCK_ACCOUNT_ID]: [STELLAR_CLASSIC_USDC], + }, + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [STELLAR_CLASSIC_USDC]: { + amount: '0', + extra: { limit: '1000' }, + }, + }, + }, + }; + + await withController({ state: initialState }, async ({ controller }) => { + controller.removeCustomAsset(MOCK_ACCOUNT_ID, STELLAR_CLASSIC_USDC); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[STELLAR_CLASSIC_USDC], + ).toStrictEqual({ + amount: '0', + extra: { limit: '0' }, + }); + }); + }); + }); }); diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 35e716ec4b..8441c0b2b9 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -48,6 +48,7 @@ import type { SnapControllerHandleRequestAction, SnapControllerSnapInstalledEvent, } from '@metamask/snaps-controllers'; +import type { SnapId } from '@metamask/snaps-sdk'; import type { TransactionControllerTransactionConfirmedEvent, TransactionControllerUnapprovedTransactionAddedEvent, @@ -102,6 +103,7 @@ import { createParallelMiddleware, } from './middlewares/ParallelMiddleware'; import { RpcFallbackMiddleware } from './middlewares/RpcFallbackMiddleware'; +import { AccountAssetEnrichmentService } from './services/AccountAssetEnrichmentService'; import type { AccountId, AssetPreferences, @@ -125,7 +127,14 @@ import type { Middleware, SubscriptionResponse, Asset, + GetAccountAssetInfoResponse, } from './types'; +import { + buildEffectiveAccountBalances, + createInvalidatedStellarClassicExtra, + filterStellarClassicAssetsForEnrichment, + isStellarClassicAssetId, +} from './utils/account-asset-enrichment'; import { normalizeAmountString, normalizeAssetId, @@ -183,6 +192,8 @@ const MESSENGER_EXPOSED_METHODS = [ 'hideAsset', 'unhideAsset', 'setSelectedCurrency', + 'refreshAccountAssetInfo', + 'invalidateAccountAssetExtras', ] as const; /** Default polling interval hint for data sources (30 seconds) */ @@ -749,6 +760,8 @@ export class AssetsController extends BaseController< readonly #tokenDataSource: TokenDataSource; + readonly #accountAssetEnrichmentService: AccountAssetEnrichmentService; + #unsubscribeBasicFunctionality: (() => void) | null = null; readonly #queryApiClient: ApiPlatformClient; @@ -826,6 +839,14 @@ export class AssetsController extends BaseController< messenger: this.messenger, onActiveChainsUpdated: this.#onActiveChainsUpdated, }); + this.#accountAssetEnrichmentService = new AccountAssetEnrichmentService({ + handleSnapRequest: async ( + params, + ): Promise => + this.messenger.call('SnapController:handleRequest', params), + getSnapIdForChain: (chainId): SnapId | undefined => + this.#snapDataSource.getSnapIdForChain(chainId), + }); this.#rpcDataSource = new RpcDataSource({ messenger: this.messenger, onActiveChainsUpdated: this.#onActiveChainsUpdated, @@ -1055,6 +1076,7 @@ export class AssetsController extends BaseController< this.messenger.subscribe('KeyringController:unlock', () => { this.#keyringUnlocked = true; this.#updateActive(); + this.#scheduleStellarClassicAssetEnrichmentRefresh(); }); this.messenger.subscribe('KeyringController:lock', () => { this.#keyringUnlocked = false; @@ -1069,6 +1091,22 @@ export class AssetsController extends BaseController< this.#onUnapprovedTransactionAdded(transactionMeta); }, ); + + // Subscribe to account balances updated - Stellar classic asset balance updates + // Ensures that Stellar classic asset balance updates are reflected in the assets controller + this.messenger.subscribe( + 'AccountsController:accountBalancesUpdated', + ({ balances }) => { + for (const [accountId, assets] of Object.entries(balances)) { + const stellarClassicAssets = Object.keys(assets).filter( + (assetId) => isStellarClassicAssetId(assetId as Caip19AssetId), + ) as Caip19AssetId[]; + if (stellarClassicAssets.length > 0) { + this.#scheduleAccountAssetInfoRefresh(accountId, stellarClassicAssets); + } + } + }, + ); } #onUnapprovedTransactionAdded(transactionMeta: TransactionMeta): void { @@ -1714,6 +1752,10 @@ export class AssetsController extends BaseController< // Re-evaluate subscriptions so the supplemental RPC poll picks up the // new customAsset on chains another data source already owns. this.#subscribeAssets(); + + if (isStellarClassicAssetId(normalizedAssetId)) { + this.#scheduleAccountAssetInfoRefresh(accountId, [normalizedAssetId]); + } } /** @@ -1740,6 +1782,8 @@ export class AssetsController extends BaseController< } }); + this.invalidateAccountAssetExtras(accountId, [normalizedAssetId]); + // Re-evaluate subscriptions so the supplemental RPC poll for that chain // is torn down when no more customAssets remain there. this.#subscribeAssets(); @@ -1755,6 +1799,180 @@ export class AssetsController extends BaseController< return this.state.customAssets[accountId] ?? []; } + // ============================================================================ + // ACCOUNT-ASSET ENRICHMENT (Stellar classic trustline) + // ============================================================================ + + /** + * Fetches and merges snap account-asset enrichment for eligible assets. + * Only Stellar classic `asset:` tokens on enrichment-enabled chains are processed. + * No-ops when the keyring is locked or the snap request fails. + * + * @param accountId - Internal account UUID. + * @param assetIds - CAIP-19 asset ids to enrich. + */ + async refreshAccountAssetInfo( + accountId: AccountId, + assetIds: Caip19AssetId[], + ): Promise { + if (!this.#keyringUnlocked || assetIds.length === 0) { + return; + } + + const assetsByChain = new Map(); + + for (const assetId of assetIds) { + const normalizedAssetId = normalizeAssetId(assetId); + let chainId: ChainId; + try { + chainId = extractChainId(normalizedAssetId); + } catch { + continue; + } + + const eligible = filterStellarClassicAssetsForEnrichment( + [normalizedAssetId], + chainId, + ); + if (eligible.length === 0) { + continue; + } + + const chainAssets = assetsByChain.get(chainId) ?? []; + chainAssets.push(normalizedAssetId); + assetsByChain.set(chainId, chainAssets); + } + + for (const [chainId, chainAssetIds] of assetsByChain) { + await this.#accountAssetEnrichmentService.fetchExtras({ + accountId, + chainId, + assetIds: chainAssetIds, + onBatchExtras: (batchExtras) => { + this.#applyAccountAssetExtras(accountId, batchExtras); + }, + }); + } + } + + /** + * Marks Stellar classic trustline enrichment as inactive for the given assets. + * Sets `extra.limit` to `'0'` rather than deleting `extra`, so UI can distinguish + * inactive trustlines from not-yet-enriched state. + * + * @param accountId - Internal account UUID. + * @param assetIds - CAIP-19 asset ids to invalidate. + */ + invalidateAccountAssetExtras( + accountId: AccountId, + assetIds: Caip19AssetId[], + ): void { + if (assetIds.length === 0) { + return; + } + + this.update((state) => { + const balances = state.assetsBalance as Record< + string, + Record + >; + balances[accountId] ??= {}; + + for (const assetId of assetIds) { + const normalizedAssetId = normalizeAssetId(assetId); + if (!isStellarClassicAssetId(normalizedAssetId)) { + continue; + } + + const existing = balances[accountId][normalizedAssetId] as + | { amount: string; extra?: GetAccountAssetInfoResponse[Caip19AssetId] } + | undefined; + + balances[accountId][normalizedAssetId] = { + amount: existing?.amount ?? '0', + extra: createInvalidatedStellarClassicExtra(existing?.extra), + }; + } + }); + } + + /** + * Merges snap enrichment fields into balance rows. + * + * @param accountId - Internal account UUID. + * @param extras - Per-asset enrichment from getAccountAssetInfo. + */ + #applyAccountAssetExtras( + accountId: AccountId, + extras: GetAccountAssetInfoResponse, + ): void { + this.update((state) => { + const balances = state.assetsBalance as Record< + string, + Record + >; + balances[accountId] ??= {}; + + for (const [assetId, extra] of Object.entries(extras)) { + const normalizedAssetId = normalizeAssetId(assetId as Caip19AssetId); + const existing = balances[accountId][normalizedAssetId] ?? { + amount: '0', + }; + balances[accountId][normalizedAssetId] = { + ...existing, + extra, + }; + } + }); + } + + /** + * Fire-and-forget wrapper for {@link refreshAccountAssetInfo}. + * + * @param accountId - Internal account UUID. + * @param assetIds - CAIP-19 asset ids to enrich. + */ + #scheduleAccountAssetInfoRefresh( + accountId: AccountId, + assetIds: Caip19AssetId[], + ): void { + this.refreshAccountAssetInfo(accountId, assetIds).catch((error) => { + log('Failed to refresh account asset info', { + accountId, + assetIds, + error, + }); + }); + } + + /** + * Refreshes Stellar classic trustline enrichment for tracked assets when the + * wallet becomes active (unlock / post-balance-sync start). + */ + #scheduleStellarClassicAssetEnrichmentRefresh(): void { + const accountIds = new Set([ + ...Object.keys(this.state.customAssets), + ...Object.keys(this.state.assetsBalance), + ]); + + for (const accountId of accountIds) { + const fromCustom = this.state.customAssets[accountId] ?? []; + const fromBalances = Object.keys( + this.state.assetsBalance[accountId] ?? {}, + ) as Caip19AssetId[]; + + const stellarClassicAssetIds = [ + ...new Set([...fromCustom, ...fromBalances]), + ].filter((assetId) => isStellarClassicAssetId(assetId)); + + if (stellarClassicAssetIds.length === 0) { + continue; + } + + this.#scheduleAccountAssetInfoRefresh(accountId, stellarClassicAssetIds); + } + } + // ============================================================================ // HIDDEN ASSETS MANAGEMENT // ============================================================================ @@ -2226,41 +2444,14 @@ export class AssetsController extends BaseController< // previous state so unsupported chains (e.g. Ink on AccountsAPI) // are never inadvertently reset to zero. // Merge: response overlays previous balances. - const effective: Record = - mode === 'merge' - ? { ...previousBalances, ...accountBalances } - : ((): Record => { - // Determine which chain namespaces this response covers. - const coveredChains = new Set( - Object.keys(accountBalances).map( - (assetId) => assetId.split('/')[0], - ), - ); - - // Start from previous balances, dropping only entries for - // chains this response is authoritative over. - const next: Record = {}; - for (const [assetId, balance] of Object.entries( - previousBalances, - )) { - if (!coveredChains.has(assetId.split('/')[0])) { - next[assetId] = balance; - } - } - - // Apply the response (authoritative for covered chains). - Object.assign(next, accountBalances); - - // Preserve custom assets that the response omitted. - for (const customId of customAssetIds) { - if (!(customId in next)) { - const prev = previousBalances[customId]; - next[customId] = - prev ?? ({ amount: '0' } as AssetBalance); - } - } - return next; - })(); + // Per-row merge preserves account-asset enrichment (e.g. Stellar + // trustline extra) when balance sources send amount-only rows. + const effective = buildEffectiveAccountBalances( + previousBalances, + accountBalances, + mode, + customAssetIds, + ); // Ensure native tokens have an entry (0 if missing) for chains this account supports const account = this.#getSelectedAccounts().find( @@ -2554,9 +2745,14 @@ export class AssetsController extends BaseController< this.getAssets(accounts, { chainIds, forceUpdate: true, - }).catch((error) => { - log('Failed to fetch assets', error); - }); + }) + .then(() => { + this.#scheduleStellarClassicAssetEnrichmentRefresh(); + return undefined; + }) + .catch((error) => { + log('Failed to fetch assets', error); + }); } /** diff --git a/packages/assets-controller/src/data-sources/SnapDataSource.ts b/packages/assets-controller/src/data-sources/SnapDataSource.ts index c1925e5355..bd6c29edce 100644 --- a/packages/assets-controller/src/data-sources/SnapDataSource.ts +++ b/packages/assets-controller/src/data-sources/SnapDataSource.ts @@ -678,6 +678,17 @@ export class SnapDataSource extends AbstractDataSource< } } + /** + * Returns the snap id responsible for a given chain, if any. + * + * @param chainId - CAIP-2 chain id. + * @returns Snap id when a keyring snap supports the chain. + */ + getSnapIdForChain(chainId: ChainId): SnapId | undefined { + const snapId = this.state.chainToSnap[chainId]; + return snapId ? (snapId as SnapId) : undefined; + } + // ============================================================================ // KEYRING CLIENT // ============================================================================ diff --git a/packages/assets-controller/src/index.ts b/packages/assets-controller/src/index.ts index 8b4aa80433..8646a1b55e 100644 --- a/packages/assets-controller/src/index.ts +++ b/packages/assets-controller/src/index.ts @@ -72,6 +72,8 @@ export type { ERC721AssetBalance, ERC1155AssetBalance, AssetBalance, + AccountAssetInfoExtra, + GetAccountAssetInfoResponse, // Data source types AccountWithSupportedChains, DataType, @@ -174,6 +176,19 @@ export { formatExchangeRatesForBridge, formatStateForTransactionPay, } from './utils'; +export { + isStellarClassicTrustlineInactiveForDisplay, +} from './utils/stellar'; +export { + isStellarClassicAssetId, + isAccountAssetInfoEnrichmentAvailable, + filterStellarClassicAssetsForEnrichment, + createInvalidatedStellarClassicExtra, + mergeAssetBalanceRow, + buildEffectiveAccountBalances, + GET_ACCOUNT_ASSET_INFO_CLIENT_METHOD, +} from './utils/account-asset-enrichment'; +export { AccountAssetEnrichmentService } from './services/AccountAssetEnrichmentService'; export type { AccountForLegacyFormat, BridgeExchangeRatesFormat, diff --git a/packages/assets-controller/src/services/AccountAssetEnrichmentService.test.ts b/packages/assets-controller/src/services/AccountAssetEnrichmentService.test.ts new file mode 100644 index 0000000000..374fd5aa54 --- /dev/null +++ b/packages/assets-controller/src/services/AccountAssetEnrichmentService.test.ts @@ -0,0 +1,171 @@ +import type { SnapId } from '@metamask/snaps-sdk'; +import type { CaipChainId } from '@metamask/utils'; + +import type { Caip19AssetId } from '../types'; +import { AccountAssetEnrichmentService } from './AccountAssetEnrichmentService'; + +const stellarClassic = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as Caip19AssetId; + +describe('AccountAssetEnrichmentService', () => { + const snapId = 'local:stellar-snap' as SnapId; + const chainId = 'stellar:pubnet' as CaipChainId; + const accountId = 'account-1'; + + it('fetches extras from the snap', async () => { + const handleSnapRequest = jest.fn().mockResolvedValue({ + [stellarClassic]: { limit: '1000' }, + }); + + const service = new AccountAssetEnrichmentService({ + handleSnapRequest, + getSnapIdForChain: (): SnapId => snapId, + }); + + const result = await service.fetchExtras({ + accountId, + chainId, + assetIds: [stellarClassic], + }); + + expect(result).toStrictEqual({ [stellarClassic]: { limit: '1000' } }); + expect(handleSnapRequest).toHaveBeenCalledTimes(1); + }); + + it('returns undefined when no snap supports the chain', async () => { + const handleSnapRequest = jest.fn(); + + const service = new AccountAssetEnrichmentService({ + handleSnapRequest, + getSnapIdForChain: (): undefined => undefined, + }); + + const result = await service.fetchExtras({ + accountId, + chainId, + assetIds: [stellarClassic], + }); + + expect(result).toBeUndefined(); + expect(handleSnapRequest).not.toHaveBeenCalled(); + }); + + it('batches large asset lists into serial snap requests', async () => { + const assetIds = Array.from({ length: 7 }, (_, index) => { + return `stellar:pubnet/asset:TOK${index}-GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA` as Caip19AssetId; + }); + + const handleSnapRequest = jest.fn().mockImplementation(({ request }) => { + const batchAssets = request.params.assets as Caip19AssetId[]; + return Object.fromEntries( + batchAssets.map((assetId) => [assetId, { limit: '100' }]), + ); + }); + + const service = new AccountAssetEnrichmentService({ + handleSnapRequest, + getSnapIdForChain: (): SnapId => snapId, + }); + + const result = await service.fetchExtras({ + accountId, + chainId, + assetIds, + }); + + expect(handleSnapRequest).toHaveBeenCalledTimes(3); + expect(Object.keys(result ?? {})).toHaveLength(7); + }); + + it('serializes concurrent fetches for the same snap across accounts', async () => { + const callOrder: string[] = []; + const handleSnapRequest = jest.fn().mockImplementation(async () => { + callOrder.push('start'); + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + callOrder.push('end'); + return { [stellarClassic]: { limit: '1000' } }; + }); + + const service = new AccountAssetEnrichmentService({ + handleSnapRequest, + getSnapIdForChain: (): SnapId => snapId, + }); + + await Promise.all([ + service.fetchExtras({ + accountId: 'account-1', + chainId, + assetIds: [stellarClassic], + }), + service.fetchExtras({ + accountId: 'account-2', + chainId, + assetIds: [stellarClassic], + }), + ]); + + expect(handleSnapRequest).toHaveBeenCalledTimes(2); + expect(callOrder).toStrictEqual(['start', 'end', 'start', 'end']); + }); + + it('calls onBatchExtras after each successful batch', async () => { + const assetIds = Array.from({ length: 4 }, (_, index) => { + return `stellar:pubnet/asset:TOK${index}-GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA` as Caip19AssetId; + }); + const onBatchExtras = jest.fn(); + + const handleSnapRequest = jest.fn().mockImplementation(({ request }) => { + const batchAssets = request.params.assets as Caip19AssetId[]; + return Object.fromEntries( + batchAssets.map((assetId) => [assetId, { limit: '100' }]), + ); + }); + + const service = new AccountAssetEnrichmentService({ + handleSnapRequest, + getSnapIdForChain: (): SnapId => snapId, + }); + + await service.fetchExtras({ + accountId, + chainId, + assetIds, + onBatchExtras, + }); + + expect(onBatchExtras).toHaveBeenCalledTimes(2); + expect(onBatchExtras.mock.calls[0]?.[0]).toEqual({ + [assetIds[0]]: { limit: '100' }, + [assetIds[1]]: { limit: '100' }, + [assetIds[2]]: { limit: '100' }, + }); + }); + + it('deduplicates concurrent fetches for the same account, chain, and assets', async () => { + const handleSnapRequest = jest + .fn() + .mockResolvedValue({ [stellarClassic]: { limit: '500' } }); + + const service = new AccountAssetEnrichmentService({ + handleSnapRequest, + getSnapIdForChain: (): SnapId => snapId, + }); + + const params = { + accountId, + chainId, + assetIds: [stellarClassic], + }; + + const [first, second] = await Promise.all([ + service.fetchExtras(params), + service.fetchExtras(params), + ]); + + expect(handleSnapRequest).toHaveBeenCalledTimes(1); + expect(first).toStrictEqual({ [stellarClassic]: { limit: '500' } }); + expect(second).toStrictEqual({ [stellarClassic]: { limit: '500' } }); + }); +}); diff --git a/packages/assets-controller/src/services/AccountAssetEnrichmentService.ts b/packages/assets-controller/src/services/AccountAssetEnrichmentService.ts new file mode 100644 index 0000000000..270c63fee4 --- /dev/null +++ b/packages/assets-controller/src/services/AccountAssetEnrichmentService.ts @@ -0,0 +1,183 @@ +import type { SnapId } from '@metamask/snaps-sdk'; +import type { CaipChainId } from '@metamask/utils'; + +import { reduceInBatchesSerially } from '../data-sources/evm-rpc-services/utils/batch'; +import type { + AccountId, + Caip19AssetId, + GetAccountAssetInfoResponse, +} from '../types'; +import { + ACCOUNT_ASSET_INFO_SNAP_BATCH_SIZE, + fetchAccountAssetInfoFromSnap, +} from '../utils/account-asset-enrichment'; +import type { SnapHandleRequestCaller } from '../utils/account-asset-enrichment'; + +export type AccountAssetEnrichmentServiceOptions = { + handleSnapRequest: SnapHandleRequestCaller; + getSnapIdForChain: (chainId: CaipChainId) => SnapId | undefined; +}; + +/** + * Fetches per-account asset enrichment from wallet snaps (e.g. Stellar trustline data). + * Deduplicates concurrent requests for the same account, chain, and asset set. + * Batches large asset lists and serializes snap calls per snap id. + */ +export class AccountAssetEnrichmentService { + readonly #handleSnapRequest: SnapHandleRequestCaller; + + readonly #getSnapIdForChain: (chainId: CaipChainId) => SnapId | undefined; + + readonly #inFlight = new Map< + string, + Promise + >(); + + readonly #snapTaskTail = new Map>(); + + constructor({ + handleSnapRequest, + getSnapIdForChain, + }: AccountAssetEnrichmentServiceOptions) { + this.#handleSnapRequest = handleSnapRequest; + this.#getSnapIdForChain = getSnapIdForChain; + } + + /** + * Fetches account-asset enrichment from the wallet snap for the given chain. + * + * @param params - Account, chain, and asset ids to enrich. + * @param params.accountId - Account to fetch enrichment for. + * @param params.chainId - CAIP-2 chain id for the snap request scope. + * @param params.assetIds - CAIP-19 asset ids to enrich on that chain. + * @param params.onBatchExtras - Optional callback invoked after each successful batch. + * @returns Per-asset enrichment map, or undefined when snap is unavailable or fails. + */ + async fetchExtras({ + accountId, + chainId, + assetIds, + onBatchExtras, + }: { + accountId: AccountId; + chainId: CaipChainId; + assetIds: Caip19AssetId[]; + onBatchExtras?: ( + extras: GetAccountAssetInfoResponse, + ) => void | Promise; + }): Promise { + if (assetIds.length === 0) { + return undefined; + } + + const snapId = this.#getSnapIdForChain(chainId); + if (!snapId) { + return undefined; + } + + const dedupeKey = `${accountId}:${chainId}:${[...assetIds].sort().join(',')}`; + const existing = this.#inFlight.get(dedupeKey); + if (existing) { + return existing; + } + + const request = this.#enqueueSnapTask(snapId, () => + this.#fetchExtrasInBatches({ + accountId, + snapId, + chainId, + assetIds, + onBatchExtras, + }), + ).finally(() => { + this.#inFlight.delete(dedupeKey); + }); + + this.#inFlight.set(dedupeKey, request); + return request; + } + + /** + * Runs snap enrichment batches serially and merges partial results. + * + * @param params - Snap request parameters. + * @param params.accountId - Account to fetch enrichment for. + * @param params.snapId - Wallet snap id to invoke. + * @param params.chainId - CAIP-2 chain id for the snap request scope. + * @param params.assetIds - CAIP-19 asset ids to enrich. + * @param params.onBatchExtras - Optional callback invoked after each successful batch. + * @returns Merged per-asset enrichment map, or undefined when all batches fail. + */ + async #fetchExtrasInBatches({ + accountId, + snapId, + chainId, + assetIds, + onBatchExtras, + }: { + accountId: AccountId; + snapId: SnapId; + chainId: CaipChainId; + assetIds: Caip19AssetId[]; + onBatchExtras?: ( + extras: GetAccountAssetInfoResponse, + ) => void | Promise; + }): Promise { + const merged = await reduceInBatchesSerially< + Caip19AssetId, + GetAccountAssetInfoResponse + >({ + values: assetIds, + batchSize: ACCOUNT_ASSET_INFO_SNAP_BATCH_SIZE, + initialResult: {} as GetAccountAssetInfoResponse, + eachBatch: async (workingResult, batch) => { + const batchResult = await fetchAccountAssetInfoFromSnap( + this.#handleSnapRequest, + { + accountId, + snapId, + chainId, + assets: batch, + }, + ); + + if (!batchResult) { + return workingResult; + } + + await onBatchExtras?.(batchResult); + + return { + ...workingResult, + ...batchResult, + }; + }, + }); + + return Object.keys(merged).length > 0 ? merged : undefined; + } + + /** + * Serializes snap client requests per snap id to avoid concurrent executions + * terminating the snap runtime on mobile. + * + * @param snapId - Wallet snap id. + * @param task - Snap work to run after prior tasks for this snap complete. + * @returns The task result. + */ + #enqueueSnapTask( + snapId: SnapId, + task: () => Promise, + ): Promise { + const tail = this.#snapTaskTail.get(snapId) ?? Promise.resolve(); + const run = tail.catch(() => undefined).then(task); + this.#snapTaskTail.set( + snapId, + run.then( + () => undefined, + () => undefined, + ), + ); + return run; + } +} diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index 038fe74df9..055df8ebe3 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -269,12 +269,31 @@ export type AssetPrice = FungibleAssetPrice | NFTAssetPrice; // BALANCE TYPES (vary by asset type) // ============================================================================ +/** + * Optional per-asset fields from snap account-asset enrichment. + * Stellar classic assets use trustline fields; native XLM may include baseReserve. + */ +export type AccountAssetInfoExtra = { + limit?: string; + authorized?: boolean; + sponsored?: boolean; + baseReserve?: string; +}; + +/** Per-asset enrichment keyed by CAIP-19 asset id from getAccountAssetInfo. */ +export type GetAccountAssetInfoResponse = Record< + Caip19AssetId, + AccountAssetInfoExtra +>; + /** * Balance data for fungible tokens (native, ERC20, SPL). */ export type FungibleAssetBalance = { /** Raw balance amount as string (e.g., "1000000000" for 1000 USDC) */ amount: string; + /** Chain-specific snap enrichment (e.g. Stellar classic trustline fields). */ + extra?: AccountAssetInfoExtra; }; /** diff --git a/packages/assets-controller/src/utils/account-asset-enrichment.test.ts b/packages/assets-controller/src/utils/account-asset-enrichment.test.ts new file mode 100644 index 0000000000..fa7a414b7f --- /dev/null +++ b/packages/assets-controller/src/utils/account-asset-enrichment.test.ts @@ -0,0 +1,289 @@ +import { HandlerType } from '@metamask/snaps-utils'; +import type { CaipChainId } from '@metamask/utils'; + +import type { Caip19AssetId } from '../types'; +import { + buildEffectiveAccountBalances, + createGetAccountAssetInfoClientRequest, + createInvalidatedStellarClassicExtra, + fetchAccountAssetInfoFromSnap, + filterStellarClassicAssetsForEnrichment, + GET_ACCOUNT_ASSET_INFO_CLIENT_METHOD, + isAccountAssetInfoEnrichmentAvailable, + isStellarClassicAssetId, + mergeAssetBalanceRow, +} from './account-asset-enrichment'; + +const stellarClassic = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as Caip19AssetId; + +describe('account-asset-enrichment utils', () => { + describe('isAccountAssetInfoEnrichmentAvailable', () => { + it('returns true for Stellar pubnet', () => { + expect( + isAccountAssetInfoEnrichmentAvailable('stellar:pubnet' as CaipChainId), + ).toBe(true); + }); + + it('returns true for Stellar testnet', () => { + expect( + isAccountAssetInfoEnrichmentAvailable('stellar:testnet' as CaipChainId), + ).toBe(true); + }); + + it('returns false for unsupported chains', () => { + expect( + isAccountAssetInfoEnrichmentAvailable( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId, + ), + ).toBe(false); + }); + }); + + describe('isStellarClassicAssetId', () => { + it('returns true for Stellar classic asset ids', () => { + expect(isStellarClassicAssetId(stellarClassic)).toBe(true); + }); + + it('returns false for EVM erc20 assets', () => { + expect( + isStellarClassicAssetId( + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId, + ), + ).toBe(false); + }); + + it('returns false for Stellar native slip44', () => { + expect( + isStellarClassicAssetId('stellar:pubnet/slip44:148' as Caip19AssetId), + ).toBe(false); + }); + }); + + describe('filterStellarClassicAssetsForEnrichment', () => { + it('returns Stellar classic assets on the enrichment-enabled chain', () => { + expect( + filterStellarClassicAssetsForEnrichment( + [stellarClassic], + 'stellar:pubnet' as CaipChainId, + ), + ).toStrictEqual([stellarClassic]); + }); + + it('returns empty when chain does not support enrichment', () => { + expect( + filterStellarClassicAssetsForEnrichment( + [stellarClassic], + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId, + ), + ).toStrictEqual([]); + }); + + it('excludes assets on a different chain than the caller scope', () => { + expect( + filterStellarClassicAssetsForEnrichment( + [stellarClassic], + 'stellar:testnet' as CaipChainId, + ), + ).toStrictEqual([]); + }); + + it('excludes native slip44 assets', () => { + const native = 'stellar:pubnet/slip44:148' as Caip19AssetId; + expect( + filterStellarClassicAssetsForEnrichment( + [native], + 'stellar:pubnet' as CaipChainId, + ), + ).toStrictEqual([]); + }); + }); + + describe('createInvalidatedStellarClassicExtra', () => { + it('sets limit to zero', () => { + expect(createInvalidatedStellarClassicExtra()).toStrictEqual({ + limit: '0', + }); + }); + + it('preserves prior authorized and sponsored fields', () => { + expect( + createInvalidatedStellarClassicExtra({ + limit: '1000', + authorized: true, + sponsored: false, + }), + ).toStrictEqual({ + limit: '0', + authorized: true, + sponsored: false, + }); + }); + }); + + describe('mergeAssetBalanceRow', () => { + it('preserves existing extra when incoming row omits it', () => { + expect( + mergeAssetBalanceRow( + { amount: '1', extra: { limit: '500' } }, + { amount: '2' }, + ), + ).toStrictEqual({ + amount: '2', + extra: { limit: '500' }, + }); + }); + + it('uses incoming extra when provided', () => { + expect( + mergeAssetBalanceRow( + { amount: '1', extra: { limit: '500' } }, + { amount: '2', extra: { limit: '1000' } }, + ), + ).toStrictEqual({ + amount: '2', + extra: { limit: '1000' }, + }); + }); + + it('seeds zero amount when no prior row exists', () => { + expect(mergeAssetBalanceRow(undefined, { amount: '5' })).toStrictEqual({ + amount: '5', + }); + }); + }); + + describe('buildEffectiveAccountBalances', () => { + it('preserves extra in merge mode when incoming is amount-only', () => { + expect( + buildEffectiveAccountBalances( + { + [stellarClassic]: { amount: '1', extra: { limit: '500' } }, + }, + { [stellarClassic]: { amount: '2' } }, + 'merge', + [], + ), + ).toStrictEqual({ + [stellarClassic]: { amount: '2', extra: { limit: '500' } }, + }); + }); + + it('preserves extra in full mode when incoming is amount-only', () => { + expect( + buildEffectiveAccountBalances( + { + [stellarClassic]: { amount: '1', extra: { limit: '500' } }, + }, + { [stellarClassic]: { amount: '2' } }, + 'full', + [], + ), + ).toStrictEqual({ + [stellarClassic]: { amount: '2', extra: { limit: '500' } }, + }); + }); + + it('preserves balances for chains not covered by a full response', () => { + const evmAsset = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + expect( + buildEffectiveAccountBalances( + { + [evmAsset]: { amount: '10' }, + [stellarClassic]: { amount: '1', extra: { limit: '500' } }, + }, + { [stellarClassic]: { amount: '2' } }, + 'full', + [], + ), + ).toStrictEqual({ + [evmAsset]: { amount: '10' }, + [stellarClassic]: { amount: '2', extra: { limit: '500' } }, + }); + }); + }); + + describe('createGetAccountAssetInfoClientRequest', () => { + it('builds a snap client request with getAccountAssetInfo params', () => { + const request = createGetAccountAssetInfoClientRequest( + 'local:stellar-snap' as never, + { + accountId: 'account-1', + scope: 'stellar:pubnet' as CaipChainId, + assets: [stellarClassic], + }, + ); + + expect(request).toStrictEqual({ + snapId: 'local:stellar-snap', + origin: 'metamask', + handler: HandlerType.OnClientRequest, + request: { + jsonrpc: '2.0', + method: GET_ACCOUNT_ASSET_INFO_CLIENT_METHOD, + params: { + accountId: 'account-1', + scope: 'stellar:pubnet', + assets: [stellarClassic], + }, + }, + }); + }); + }); + + describe('fetchAccountAssetInfoFromSnap', () => { + it('delegates to the request builder and caller', async () => { + const handleSnapRequest = jest.fn().mockResolvedValue({ + [stellarClassic]: { limit: '1000' }, + }); + + const result = await fetchAccountAssetInfoFromSnap(handleSnapRequest, { + accountId: 'account-1', + snapId: 'local:stellar-snap' as never, + chainId: 'stellar:pubnet' as CaipChainId, + assets: [stellarClassic], + }); + + expect(handleSnapRequest).toHaveBeenCalledWith( + createGetAccountAssetInfoClientRequest('local:stellar-snap' as never, { + accountId: 'account-1', + scope: 'stellar:pubnet' as CaipChainId, + assets: [stellarClassic], + }), + ); + expect(result).toStrictEqual({ + [stellarClassic]: { limit: '1000' }, + }); + }); + + it('returns undefined when assets list is empty', async () => { + const handleSnapRequest = jest.fn(); + + const result = await fetchAccountAssetInfoFromSnap(handleSnapRequest, { + accountId: 'account-1', + snapId: 'local:stellar-snap' as never, + chainId: 'stellar:pubnet' as CaipChainId, + assets: [], + }); + + expect(handleSnapRequest).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('returns undefined when the snap request fails', async () => { + const handleSnapRequest = jest + .fn() + .mockRejectedValue(new Error('snap failed')); + + const result = await fetchAccountAssetInfoFromSnap(handleSnapRequest, { + accountId: 'account-1', + snapId: 'local:stellar-snap' as never, + chainId: 'stellar:pubnet' as CaipChainId, + assets: [stellarClassic], + }); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/assets-controller/src/utils/account-asset-enrichment.ts b/packages/assets-controller/src/utils/account-asset-enrichment.ts new file mode 100644 index 0000000000..1a969bb502 --- /dev/null +++ b/packages/assets-controller/src/utils/account-asset-enrichment.ts @@ -0,0 +1,276 @@ +import type { SnapController } from '@metamask/snaps-controllers'; +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import type { CaipChainId } from '@metamask/utils'; +import { KnownCaipNamespace, parseCaipAssetType } from '@metamask/utils'; + +import type { + AccountAssetInfoExtra, + AssetBalance, + AssetsUpdateMode, + Caip19AssetId, + FungibleAssetBalance, + GetAccountAssetInfoResponse, +} from '../types'; +import { fetchWithTimeout } from './fetchWithTimeout'; + +/** Snap clientRequest method for per-(account, asset) enrichment data. */ +export const GET_ACCOUNT_ASSET_INFO_CLIENT_METHOD = + 'getAccountAssetInfo' as const; + +/** + * Max assets per snap `getAccountAssetInfo` request. Large batches can terminate + * the Stellar wallet snap on mobile when many trustlines are fetched at once. + */ +export const ACCOUNT_ASSET_INFO_SNAP_BATCH_SIZE = 3; + +/** Per-batch snap client request timeout (ms). Hung requests must not block apply. */ +export const ACCOUNT_ASSET_INFO_SNAP_TIMEOUT_MS = 15_000; + +/** + * Chains whose wallet snap implements {@link GET_ACCOUNT_ASSET_INFO_CLIENT_METHOD}. + */ +export const ACCOUNT_ASSET_INFO_ENRICHMENT_BY_CHAIN: Partial< + Record +> = { + 'stellar:pubnet': true, + 'stellar:testnet': true, +}; + +/** Caller shape for `SnapController:handleRequest`. */ +export type SnapHandleRequestCaller = ( + params: Parameters[0], +) => Promise; + +/** + * Returns whether the given chain supports snap account-asset enrichment. + * + * @param chainId - CAIP-2 chain identifier. + * @returns True when enrichment is configured for the chain. + */ +export function isAccountAssetInfoEnrichmentAvailable( + chainId: CaipChainId, +): boolean { + return ACCOUNT_ASSET_INFO_ENRICHMENT_BY_CHAIN[chainId] === true; +} + +/** + * Returns whether the asset id is a Stellar classic `asset:` token. + * + * @param assetId - CAIP-19 asset id. + * @returns True for Stellar classic assets on pubnet/testnet. + */ +export function isStellarClassicAssetId(assetId: Caip19AssetId): boolean { + try { + const parsed = parseCaipAssetType(assetId); + return ( + parsed.chain.namespace === KnownCaipNamespace.Stellar && + parsed.assetNamespace === 'asset' + ); + } catch { + return false; + } +} + +/** + * Filters asset ids to Stellar classic tokens on an enrichment-enabled chain. + * + * @param assetIds - CAIP-19 asset types to filter. + * @param chainId - Expected chain for enrichment (caller-provided scope). + * @returns Stellar classic asset ids on the given chain when enrichment is available. + */ +export function filterStellarClassicAssetsForEnrichment( + assetIds: Caip19AssetId[], + chainId: CaipChainId, +): Caip19AssetId[] { + if (!isAccountAssetInfoEnrichmentAvailable(chainId)) { + return []; + } + return assetIds.filter((assetId) => { + try { + return ( + parseCaipAssetType(assetId).chainId === chainId && + isStellarClassicAssetId(assetId) + ); + } catch { + return false; + } + }); +} + +/** + * Builds inactive trustline extra for Stellar classic invalidation. + * Uses `limit: '0'` so UI treats the trustline as inactive rather than unknown. + * + * @param previous - Prior enrichment fields to preserve (authorized, sponsored). + * @returns Extra with zero limit. + */ +export function createInvalidatedStellarClassicExtra( + previous?: AccountAssetInfoExtra, +): AccountAssetInfoExtra { + return { + ...previous, + limit: '0', + }; +} + +/** + * Merges incoming balance rows into prior rows, preserving `extra` when the + * incoming row is amount-only (as snap balance sync responses are). + * + * @param previous - Prior balance row, if any. + * @param incoming - Incoming balance row from a data source. + * @returns Merged balance row. + */ +export function mergeAssetBalanceRow( + previous: AssetBalance | undefined, + incoming: AssetBalance, +): AssetBalance { + const prev = previous ?? ({ amount: '0' } as FungibleAssetBalance); + const prevExtra = (prev as FungibleAssetBalance).extra; + const incomingExtra = (incoming as FungibleAssetBalance).extra; + + const merged: FungibleAssetBalance = { + ...(prev as FungibleAssetBalance), + ...(incoming as FungibleAssetBalance), + amount: (incoming as FungibleAssetBalance).amount, + }; + + if (incomingExtra !== undefined) { + merged.extra = incomingExtra; + } else if (prevExtra !== undefined) { + merged.extra = prevExtra; + } + + return merged; +} + +/** + * Builds the effective per-account balance map for a data-source response. + * Snap/RPC balance sync responses are amount-only and must not erase separately + * stored account-asset enrichment (e.g. Stellar trustline `extra`). + * + * @param previousBalances - Prior balances for the account. + * @param incomingBalances - Incoming balances from the data source. + * @param mode - Merge overlays incoming rows; full replaces covered chains. + * @param customAssetIds - Custom assets to preserve when omitted from full responses. + * @returns Effective balance map before amount normalization. + */ +export function buildEffectiveAccountBalances( + previousBalances: Record, + incomingBalances: Record, + mode: AssetsUpdateMode, + customAssetIds: Caip19AssetId[], +): Record { + if (mode === 'merge') { + const effective: Record = { ...previousBalances }; + for (const [assetId, incoming] of Object.entries(incomingBalances)) { + effective[assetId] = mergeAssetBalanceRow( + previousBalances[assetId], + incoming, + ); + } + return effective; + } + + const coveredChains = new Set( + Object.keys(incomingBalances).map((assetId) => assetId.split('/')[0]), + ); + + const next: Record = {}; + for (const [assetId, balance] of Object.entries(previousBalances)) { + if (!coveredChains.has(assetId.split('/')[0])) { + next[assetId] = balance; + } + } + + // Snap/RPC balance sync responses are amount-only; per-row merge preserves + // account-asset enrichment stored separately (e.g. Stellar trustline extra). + for (const [assetId, incoming] of Object.entries(incomingBalances)) { + next[assetId] = mergeAssetBalanceRow(previousBalances[assetId], incoming); + } + + for (const customId of customAssetIds) { + next[customId] ??= + previousBalances[customId] ?? ({ amount: '0' } as AssetBalance); + } + + return next; +} + +/** + * Builds a snap client request for `getAccountAssetInfo`. + * + * @param snapId - Wallet snap id. + * @param params - Account, chain scope, and assets to resolve. + * @param params.accountId - Account to fetch enrichment for. + * @param params.scope - CAIP-2 chain id passed to the snap handler. + * @param params.assets - CAIP-19 asset ids to resolve. + * @returns Payload for `SnapController:handleRequest`. + */ +export function createGetAccountAssetInfoClientRequest( + snapId: SnapId, + params: { + accountId: string; + scope: CaipChainId; + assets: Caip19AssetId[]; + }, +): Parameters[0] { + return { + snapId, + origin: 'metamask', + handler: HandlerType.OnClientRequest, + request: { + jsonrpc: '2.0', + method: GET_ACCOUNT_ASSET_INFO_CLIENT_METHOD, + params, + }, + }; +} + +/** + * Calls the snap `getAccountAssetInfo` client request handler. + * + * @param handleSnapRequest - SnapController handleRequest messenger action. + * @param options - Request parameters. + * @param options.accountId - Account to fetch enrichment for. + * @param options.snapId - Wallet snap id to invoke. + * @param options.chainId - CAIP-2 chain id for the snap request scope. + * @param options.assets - CAIP-19 asset ids to enrich. + * @returns Per-asset enrichment fields, or undefined on failure. + */ +export async function fetchAccountAssetInfoFromSnap( + handleSnapRequest: SnapHandleRequestCaller, + { + accountId, + snapId, + chainId, + assets, + }: { + accountId: string; + snapId: SnapId; + chainId: CaipChainId; + assets: Caip19AssetId[]; + }, +): Promise { + if (assets.length === 0) { + return undefined; + } + + const request = createGetAccountAssetInfoClientRequest(snapId, { + accountId, + scope: chainId, + assets, + }); + + try { + const response = (await fetchWithTimeout( + () => handleSnapRequest(request), + ACCOUNT_ASSET_INFO_SNAP_TIMEOUT_MS, + )) as GetAccountAssetInfoResponse | undefined; + + return response; + } catch { + return undefined; + } +} diff --git a/packages/assets-controller/src/utils/stellar.test.ts b/packages/assets-controller/src/utils/stellar.test.ts new file mode 100644 index 0000000000..fa37e6513d --- /dev/null +++ b/packages/assets-controller/src/utils/stellar.test.ts @@ -0,0 +1,165 @@ +import { isStellarClassicTrustlineInactiveForDisplay } from './stellar'; + +describe('isStellarClassicTrustlineInactiveForDisplay', () => { + const classicUsdc = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + const sep41SolvBtc = + 'stellar:pubnet/sep41:CBIJBDNZNF4X35BJ4FFZWCDBSCKOP5NB4PLG4SNENRMLAPYG4P5FM6VN'; + + it('returns false for native XLM without extra', () => { + expect( + isStellarClassicTrustlineInactiveForDisplay({ + chainId: 'stellar:pubnet', + assetId: 'stellar:pubnet/slip44:148', + isNative: true, + }), + ).toBe(false); + }); + + it('returns false for sep41 tokens without extra', () => { + expect( + isStellarClassicTrustlineInactiveForDisplay({ + chainId: 'stellar:pubnet', + assetId: sep41SolvBtc, + }), + ).toBe(false); + }); + + it('returns true for classic asset with zero limit extra', () => { + expect( + isStellarClassicTrustlineInactiveForDisplay({ + chainId: 'stellar:pubnet', + assetId: classicUsdc, + extra: { limit: '0' }, + }), + ).toBe(true); + }); + + it('returns false for classic asset with positive limit extra', () => { + expect( + isStellarClassicTrustlineInactiveForDisplay({ + chainId: 'stellar:pubnet', + assetId: classicUsdc, + extra: { limit: '1000' }, + }), + ).toBe(false); + }); + + it('returns true for classic asset without extra on first import', () => { + expect( + isStellarClassicTrustlineInactiveForDisplay({ + chainId: 'stellar:pubnet', + assetId: classicUsdc, + }), + ).toBe(true); + }); + + it('returns false for non-stellar chains', () => { + expect( + isStellarClassicTrustlineInactiveForDisplay({ + chainId: '0x1', + assetId: '0x123', + }), + ).toBe(false); + }); + + it('returns true when extra omits limit', () => { + expect( + isStellarClassicTrustlineInactiveForDisplay({ + chainId: 'stellar:pubnet', + assetId: classicUsdc, + extra: {}, + }), + ).toBe(true); + }); + + it('returns true when extra limit is not a string', () => { + expect( + isStellarClassicTrustlineInactiveForDisplay({ + chainId: 'stellar:pubnet', + assetId: classicUsdc, + extra: { limit: 1000 } as unknown as { limit?: string }, + }), + ).toBe(true); + }); + + it('returns true when extra limit is not numeric', () => { + expect( + isStellarClassicTrustlineInactiveForDisplay({ + chainId: 'stellar:pubnet', + assetId: classicUsdc, + extra: { limit: 'not-a-number' }, + }), + ).toBe(true); + }); + + it('returns true when extra limit is negative', () => { + expect( + isStellarClassicTrustlineInactiveForDisplay({ + chainId: 'stellar:pubnet', + assetId: classicUsdc, + extra: { limit: '-1' }, + }), + ).toBe(true); + }); + + it('returns false for invalid classic asset id', () => { + expect( + isStellarClassicTrustlineInactiveForDisplay({ + chainId: 'stellar:pubnet', + assetId: 'not-a-valid-caip-asset', + }), + ).toBe(false); + }); + + it('returns false when assetId is missing', () => { + expect( + isStellarClassicTrustlineInactiveForDisplay({ + chainId: 'stellar:pubnet', + }), + ).toBe(false); + }); + + it('returns false for classic asset with positive balance and no extra', () => { + expect( + isStellarClassicTrustlineInactiveForDisplay({ + chainId: 'stellar:pubnet', + assetId: classicUsdc, + balance: '10.5', + }), + ).toBe(false); + }); + + it('returns true for classic asset with zero balance and no extra', () => { + expect( + isStellarClassicTrustlineInactiveForDisplay({ + chainId: 'stellar:pubnet', + assetId: classicUsdc, + balance: '0', + }), + ).toBe(true); + }); + + it('returns true for classic asset with invalid balance and no extra', () => { + expect( + isStellarClassicTrustlineInactiveForDisplay({ + chainId: 'stellar:pubnet', + assetId: classicUsdc, + balance: 'not-a-number', + }), + ).toBe(true); + }); + + it('evaluates classic assets on stellar testnet', () => { + const testnetClassic = + 'stellar:testnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + + expect( + isStellarClassicTrustlineInactiveForDisplay({ + chainId: 'stellar:testnet', + assetId: testnetClassic, + extra: { limit: '0' }, + }), + ).toBe(true); + }); +}); diff --git a/packages/assets-controller/src/utils/stellar.ts b/packages/assets-controller/src/utils/stellar.ts new file mode 100644 index 0000000000..92b9f81c41 --- /dev/null +++ b/packages/assets-controller/src/utils/stellar.ts @@ -0,0 +1,85 @@ +import { XlmScope } from '@metamask/keyring-api'; +import { parseCaipAssetType } from '@metamask/utils'; +import type { CaipAssetType, CaipChainId } from '@metamask/utils'; + +import type { AccountAssetInfoExtra } from '../types'; + +function isStellarTrustlineInactiveFromExtra( + extra: AccountAssetInfoExtra | undefined, +): boolean { + if (extra?.limit === undefined) { + return true; + } + + const { limit } = extra; + if (typeof limit !== 'string') { + return true; + } + + const parsed = Number.parseFloat(limit); + if (Number.isNaN(parsed)) { + return true; + } + + return parsed <= 0; +} + +function isStellarChainId(chainId: CaipChainId | string): boolean { + return chainId === XlmScope.Pubnet || chainId === XlmScope.Testnet; +} + +function isStellarClassicAssetCaip19(assetId: CaipAssetType): boolean { + try { + const parsed = parseCaipAssetType(assetId); + return ( + isStellarChainId(parsed.chainId) && parsed.assetNamespace === 'asset' + ); + } catch { + return false; + } +} + +/** + * Whether a token row should show Stellar classic trustline-inactive UX. + * Only Stellar classic `asset:` tokens are evaluated; native, sep41, and other + * chains always return false. Classic assets without `extra` are treated as + * inactive (e.g. on first import before enrichment completes). + * + * @param options - Token context from selectors or asset page. + * @param options.chainId - CAIP-2 chain id for the token row. + * @param options.assetId - CAIP-19 asset id. + * @param options.isNative - Whether the row is a native asset. + * @param options.extra - Balance enrichment from AssetsController. + * @param options.balance - Display balance string. + * @returns True when inactive trustline UX should be shown. + */ +export function isStellarClassicTrustlineInactiveForDisplay(options: { + chainId: CaipChainId | string; + assetId?: CaipAssetType | string; + isNative?: boolean; + extra?: AccountAssetInfoExtra; + balance?: string; +}): boolean { + const { chainId, assetId, isNative, extra, balance } = options; + + if (isNative || !assetId || !isStellarChainId(chainId)) { + return false; + } + + if (!isStellarClassicAssetCaip19(assetId as CaipAssetType)) { + return false; + } + + if (extra !== undefined) { + return isStellarTrustlineInactiveFromExtra(extra); + } + + if (balance !== undefined) { + const parsedBalance = Number.parseFloat(balance); + if (!Number.isNaN(parsedBalance) && parsedBalance > 0) { + return false; + } + } + + return true; +}