diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 356907132f..b5d171a354 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add optional `snapDataSourceConfig.assetEnrichmentEnabled` getter to `AssetsControllerOptions` so clients can enable or disable snap `getAccountAssetInfo` enrichment at runtime (defaults to disabled) ([#9392](https://github.com/MetaMask/core/pull/9217)) +- Add optional snap account-asset enrichment to balances in `SnapDataSource.fetch`, so `getAssets` includes Stellar trustline metadata. Balance updates skip enrichment; use `getAssets` after trustline events. ([#9392](https://github.com/MetaMask/core/pull/9217)) + ## [10.2.0] ### Added @@ -34,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Refresh Stellar trustline enrichment for custom assets that drop off `listAccountAssets` after deactivate (e.g. limit 0 tombstones) by including `customAssets` in `SnapDataSource.fetch` balance and enrichment requests - Fetch balances when switching account groups, enabling RPC-only networks, or after a new account is added to the account tree ([#9388](https://github.com/MetaMask/core/pull/9388)) ## [10.0.1] diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 8fbd85f741..2b1463e621 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -1807,6 +1807,40 @@ describe('AssetsController', () => { }); }); + it('preserves metadata enrichment when merge update changes amount', async () => { + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { + amount: '1', + metadata: { limit: '1000', authorized: true }, + }, + }, + }, + }; + + await withController({ state: initialState }, async ({ controller }) => { + await controller.handleAssetsUpdate( + { + updateMode: 'merge', + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { amount: '2' }, + }, + }, + }, + 'TestSource', + ); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], + ).toStrictEqual({ + amount: '2', + metadata: { limit: '1000', authorized: true }, + }); + }); + }); + it('preserves existing balances when merge update adds new chain data', async () => { const polygonNative = 'eip155:137/slip44:966' as Caip19AssetId; const initialState: Partial = { diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 3a6612af09..e13bb80ff4 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -84,7 +84,10 @@ import type { PriceDataSourceConfig } from './data-sources/PriceDataSource'; import { PriceDataSource } from './data-sources/PriceDataSource'; import type { RpcDataSourceConfig } from './data-sources/RpcDataSource'; import { RpcDataSource } from './data-sources/RpcDataSource'; -import type { AccountsControllerAccountBalancesUpdatedEvent } from './data-sources/SnapDataSource'; +import type { + AccountsControllerAccountBalancesUpdatedEvent, + SnapDataSourceConfig, +} from './data-sources/SnapDataSource'; import { SnapDataSource } from './data-sources/SnapDataSource'; import type { StakedBalanceDataSourceConfig } from './data-sources/StakedBalanceDataSource'; import { StakedBalanceDataSource } from './data-sources/StakedBalanceDataSource'; @@ -430,6 +433,8 @@ export type AssetsControllerOptions = { priceDataSourceConfig?: PriceDataSourceConfig; /** Optional configuration for StakedBalanceDataSource. */ stakedBalanceDataSourceConfig?: StakedBalanceDataSourceConfig; + /** Optional configuration for SnapDataSource. */ + snapDataSourceConfig?: SnapDataSourceConfig; /** * Function returning whether onboarding is complete. When false, * RPC and staked balance data sources skip fetch and subscribe @@ -582,7 +587,14 @@ function mergeAccountBalances( replaceCoveredChains: boolean, ): Record { if (!replaceCoveredChains) { - return { ...previousBalances, ...accountBalances }; + const next: Record = { ...previousBalances }; + for (const [assetId, balance] of Object.entries(accountBalances)) { + next[assetId] = { + ...(previousBalances[assetId] ?? { amount: '0' }), + ...balance, + }; + } + return next; } const coveredChains = new Set( @@ -856,6 +868,7 @@ export class AssetsController extends BaseController< accountsApiDataSourceConfig, priceDataSourceConfig, stakedBalanceDataSourceConfig, + snapDataSourceConfig, isOnboarded, tempMigrateAssetsInfoMetadataAssets3346, }: AssetsControllerOptions) { @@ -921,6 +934,7 @@ export class AssetsController extends BaseController< this.#snapDataSource = new SnapDataSource({ messenger: this.messenger, onActiveChainsUpdated: this.#onActiveChainsUpdated, + ...snapDataSourceConfig, }); this.#rpcDataSource = new RpcDataSource({ messenger: this.messenger, diff --git a/packages/assets-controller/src/data-sources/SnapDataSource.test.ts b/packages/assets-controller/src/data-sources/SnapDataSource.test.ts index 8518b3d694..03628e9d3c 100644 --- a/packages/assets-controller/src/data-sources/SnapDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/SnapDataSource.test.ts @@ -28,10 +28,12 @@ import { const SOLANA_MAINNET = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as ChainId; const BITCOIN_MAINNET = 'bip122:000000000019d6689c085ae165831e93' as ChainId; const TRON_MAINNET = 'tron:728126428' as ChainId; +const STELLAR_PUBNET = 'stellar:pubnet' as ChainId; // Test snap IDs const SOLANA_SNAP_ID = 'npm:@metamask/solana-wallet-snap'; const BITCOIN_SNAP_ID = 'npm:@metamask/bitcoin-wallet-snap'; +const STELLAR_SNAP_ID = 'npm:@metamask/stellar-wallet-snap'; type AllActions = SnapDataSourceAllowedActions; type AllEvents = SnapDataSourceAllowedEvents; @@ -43,6 +45,10 @@ const MOCK_SOL_ASSET = const MOCK_BTC_ASSET = 'bip122:000000000019d6689c085ae165831e93/slip44:0' as Caip19AssetId; const MOCK_TRON_ASSET = 'tron:728126428/slip44:195' as Caip19AssetId; +const MOCK_STELLAR_USDC_ASSET = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as Caip19AssetId; +const MOCK_STELLAR_AUDD_ASSET = + 'stellar:pubnet/asset:AUDD-GDC7X2MXTYSAKUUGAIQ7J7RPEIM7GXSAIWFYWWH4GLNFECQVJJLB2EEU' as Caip19AssetId; const CHAIN_MAINNET = 'eip155:1' as ChainId; type SetupResult = { @@ -156,6 +162,7 @@ function createMockPermissions( function createMockHandleRequest( accountAssets: string[] = [], balances: Record = {}, + accountAssetInfo: Record = {}, ): jest.Mock { return jest.fn().mockImplementation((params) => { const { request } = params; @@ -165,6 +172,9 @@ function createMockHandleRequest( if (request?.method === 'keyring_getAccountBalances') { return Promise.resolve(balances); } + if (request?.method === 'getAccountAssetInfo') { + return Promise.resolve(accountAssetInfo); + } return Promise.resolve(null); }); } @@ -174,10 +184,18 @@ function setupController( installedSnaps?: Record; accountAssets?: string[]; balances?: Record; + accountAssetInfo?: Record; configuredNetworks?: ChainId[]; + assetEnrichmentEnabled?: () => boolean; } = {}, ): SetupResult { - const { installedSnaps = {}, accountAssets = [], balances = {} } = options; + const { + installedSnaps = {}, + accountAssets = [], + balances = {}, + accountAssetInfo = {}, + assetEnrichmentEnabled, + } = options; const rootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, @@ -226,7 +244,11 @@ function setupController( mockGetRunnableSnaps, ); - const mockHandleRequest = createMockHandleRequest(accountAssets, balances); + const mockHandleRequest = createMockHandleRequest( + accountAssets, + balances, + accountAssetInfo, + ); rootMessenger.registerActionHandler( 'SnapController:handleRequest', mockHandleRequest, @@ -249,6 +271,9 @@ function setupController( const controllerOptions: SnapDataSourceOptions = { messenger: controllerMessenger as unknown as AssetsControllerMessenger, onActiveChainsUpdated: activeChainsUpdateHandler, + ...(assetEnrichmentEnabled === undefined + ? {} + : { assetEnrichmentEnabled }), }; const controller = new SnapDataSource(controllerOptions); @@ -521,6 +546,177 @@ describe('SnapDataSource', () => { cleanup(); }); + it('fetch enriches Stellar assets with account asset info', async () => { + const { controller, mockHandleRequest, cleanup } = setupController({ + assetEnrichmentEnabled: () => true, + installedSnaps: { + [STELLAR_SNAP_ID]: { version: '1.0.0', chainIds: [STELLAR_PUBNET] }, + }, + accountAssets: [MOCK_STELLAR_USDC_ASSET], + balances: { + [MOCK_STELLAR_USDC_ASSET]: { amount: '25', unit: 'USDC' }, + }, + accountAssetInfo: { + [MOCK_STELLAR_USDC_ASSET]: { + limit: '1000', + authorized: true, + sponsored: false, + }, + }, + }); + await new Promise(process.nextTick); + + const response = await controller.fetch( + createDataRequest({ + chainIds: [STELLAR_PUBNET], + accounts: [ + createMockAccount({ + scopes: [STELLAR_PUBNET], + metadata: { + name: 'Stellar Account', + importTime: Date.now(), + keyring: { type: 'Snap Keyring' }, + snap: { id: STELLAR_SNAP_ID, name: 'Stellar Snap' }, + }, + }), + ], + }), + ); + + expect(mockHandleRequest).toHaveBeenCalledWith( + expect.objectContaining({ + snapId: STELLAR_SNAP_ID, + origin: 'metamask', + handler: 'onClientRequest', + request: expect.objectContaining({ + method: 'getAccountAssetInfo', + params: { + accountId: 'mock-account-id', + scope: STELLAR_PUBNET, + assets: [MOCK_STELLAR_USDC_ASSET], + }, + }), + }), + ); + expect( + response.assetsBalance?.['mock-account-id']?.[MOCK_STELLAR_USDC_ASSET], + ).toStrictEqual({ + amount: '25', + metadata: { + limit: '1000', + authorized: true, + sponsored: false, + }, + }); + + cleanup(); + }); + + it('fetch does not include customAssets in the snap balance request', async () => { + const { controller, mockHandleRequest, cleanup } = setupController({ + assetEnrichmentEnabled: () => true, + installedSnaps: { + [STELLAR_SNAP_ID]: { version: '1.0.0', chainIds: [STELLAR_PUBNET] }, + }, + accountAssets: [MOCK_STELLAR_USDC_ASSET], + balances: { + [MOCK_STELLAR_USDC_ASSET]: { amount: '25', unit: 'USDC' }, + }, + accountAssetInfo: { + [MOCK_STELLAR_USDC_ASSET]: { + limit: '1000', + authorized: true, + }, + }, + }); + await new Promise(process.nextTick); + + const response = await controller.fetch( + createDataRequest({ + chainIds: [STELLAR_PUBNET], + customAssets: [MOCK_STELLAR_AUDD_ASSET], + accounts: [ + createMockAccount({ + scopes: [STELLAR_PUBNET], + metadata: { + name: 'Stellar Account', + importTime: Date.now(), + keyring: { type: 'Snap Keyring' }, + snap: { id: STELLAR_SNAP_ID, name: 'Stellar Snap' }, + }, + }), + ], + }), + ); + + expect(mockHandleRequest).toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ + method: 'keyring_getAccountBalances', + params: { + id: 'mock-account-id', + assets: [MOCK_STELLAR_USDC_ASSET], + }, + }), + }), + ); + expect( + response.assetsBalance?.['mock-account-id']?.[MOCK_STELLAR_AUDD_ASSET], + ).toBeUndefined(); + + cleanup(); + }); + + it('fetch does not call getAccountAssetInfo when assetEnrichmentEnabled is false', async () => { + const { controller, mockHandleRequest, cleanup } = setupController({ + assetEnrichmentEnabled: () => false, + installedSnaps: { + [STELLAR_SNAP_ID]: { version: '1.0.0', chainIds: [STELLAR_PUBNET] }, + }, + accountAssets: [MOCK_STELLAR_USDC_ASSET], + balances: { + [MOCK_STELLAR_USDC_ASSET]: { amount: '25', unit: 'USDC' }, + }, + accountAssetInfo: { + [MOCK_STELLAR_USDC_ASSET]: { + limit: '1000', + authorized: true, + }, + }, + }); + await new Promise(process.nextTick); + + const response = await controller.fetch( + createDataRequest({ + chainIds: [STELLAR_PUBNET], + accounts: [ + createMockAccount({ + scopes: [STELLAR_PUBNET], + metadata: { + name: 'Stellar Account', + importTime: Date.now(), + keyring: { type: 'Snap Keyring' }, + snap: { id: STELLAR_SNAP_ID, name: 'Stellar Snap' }, + }, + }), + ], + }), + ); + + expect(mockHandleRequest).not.toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ + method: 'getAccountAssetInfo', + }), + }), + ); + expect( + response.assetsBalance?.['mock-account-id']?.[MOCK_STELLAR_USDC_ASSET], + ).toStrictEqual({ amount: '25' }); + + cleanup(); + }); + it('fetch handles empty account assets gracefully', async () => { const { controller, mockHandleRequest, cleanup } = setupController({ installedSnaps: { @@ -600,6 +796,50 @@ describe('SnapDataSource', () => { cleanup(); }); + it('does not enrich Stellar assets from snap balances updated event', async () => { + const { triggerBalancesUpdated, assetsUpdateHandler, mockHandleRequest, cleanup } = + setupController({ + installedSnaps: { + [STELLAR_SNAP_ID]: { version: '1.0.0', chainIds: [STELLAR_PUBNET] }, + }, + accountAssetInfo: { + [MOCK_STELLAR_USDC_ASSET]: { limit: '500' }, + }, + }); + await new Promise(process.nextTick); + + triggerBalancesUpdated({ + balances: { + 'account-1': { + [MOCK_STELLAR_USDC_ASSET]: { amount: '5', unit: 'USDC' }, + }, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(assetsUpdateHandler).toHaveBeenCalledWith( + expect.objectContaining({ + assetsBalance: { + 'account-1': { + [MOCK_STELLAR_USDC_ASSET]: { + amount: '5', + }, + }, + }, + }), + ); + expect(mockHandleRequest).not.toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ + method: 'getAccountAssetInfo', + }), + }), + ); + + cleanup(); + }); + it('filters assets for chains without discovered snaps from balance update event', async () => { const { triggerBalancesUpdated, assetsUpdateHandler, cleanup } = setupController({ diff --git a/packages/assets-controller/src/data-sources/SnapDataSource.ts b/packages/assets-controller/src/data-sources/SnapDataSource.ts index 17da7b0b5e..475a5e15fa 100644 --- a/packages/assets-controller/src/data-sources/SnapDataSource.ts +++ b/packages/assets-controller/src/data-sources/SnapDataSource.ts @@ -28,6 +28,10 @@ import type { Middleware, } from '../types'; import { AbstractDataSource } from './AbstractDataSource'; +import { + hasAccountAssetInfoEnrichmentCandidate, + SnapAccountAssetInfoEnricher, +} from './snap-account-asset-info-enrichment'; import type { DataSourceState, SubscriptionRequest, @@ -157,7 +161,15 @@ export type SnapDataSourceAllowedActions = // OPTIONS // ============================================================================ -export type SnapDataSourceOptions = { +export type SnapDataSourceConfig = { + /** + * When false, SnapDataSource skips `getAccountAssetInfo` enrichment. + * Evaluated at call time. Defaults to () => false. + */ + assetEnrichmentEnabled?: () => boolean; +}; + +export type SnapDataSourceOptions = SnapDataSourceConfig & { /** The AssetsController messenger (shared by all data sources). */ messenger: AssetsControllerMessenger; /** Called when this data source's active chains change. Pass dataSourceName so the controller knows the source. */ @@ -218,6 +230,10 @@ export class SnapDataSource extends AbstractDataSource< /** Cache of KeyringClient instances per snap ID to avoid re-instantiation */ readonly #keyringClientCache: Map = new Map(); + readonly #assetEnrichmentEnabled: () => boolean; + + readonly #accountAssetInfoEnricher: SnapAccountAssetInfoEnricher; + constructor(options: SnapDataSourceOptions) { super(SNAP_DATA_SOURCE_NAME, { ...defaultSnapState, @@ -226,6 +242,17 @@ export class SnapDataSource extends AbstractDataSource< this.#messenger = options.messenger; this.#onActiveChainsUpdated = options.onActiveChainsUpdated; + this.#assetEnrichmentEnabled = + options.assetEnrichmentEnabled ?? ((): boolean => false); + // TODO(STELLAR): Remove once the Accounts API returns account-asset metadata directly. + this.#accountAssetInfoEnricher = new SnapAccountAssetInfoEnricher({ + getSnapIdForChain: (chainId): SnapId | undefined => + this.state.chainToSnap[chainId] as SnapId | undefined, + callSnapRequest: (request): Promise => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.#messenger as any).call('SnapController:handleRequest', request), + log, + }); // Bind handlers for cleanup in destroy() this.#handleSnapBalancesUpdatedBound = this.#handleSnapBalancesUpdated.bind( @@ -266,6 +293,11 @@ export class SnapDataSource extends AbstractDataSource< * Handle snap balance updates from the keyring. * Transforms the payload and publishes to AssetsController. * + * Push updates carry amounts only. Per-asset snap enrichment (e.g. Stellar + * trustline fields) is applied in {@link SnapDataSource.fetch} when clients + * call `getAssets` after trustline-related events such as + * `AccountsController:accountAssetListUpdated`. + * * @param payload - The balance update payload from AccountsController. */ #handleSnapBalancesUpdated( @@ -506,6 +538,26 @@ export class SnapDataSource extends AbstractDataSource< } } + if ( + this.#assetEnrichmentEnabled() && + results.assetsBalance && + hasAccountAssetInfoEnrichmentCandidate({ + assetsBalance: results.assetsBalance, + getSnapIdForChain: (chainId): SnapId | undefined => + this.state.chainToSnap[chainId] as SnapId | undefined, + }) + ) { + // TODO(STELLAR): Remove once the Accounts API returns account-asset metadata directly. + for (const [accountId, accountAssets] of Object.entries( + results.assetsBalance, + )) { + await this.#accountAssetInfoEnricher.enrichAccount({ + accountId, + assetsBalance: accountAssets, + }); + } + } + return results; } diff --git a/packages/assets-controller/src/data-sources/index.ts b/packages/assets-controller/src/data-sources/index.ts index 47cb7c5734..809b947ccf 100644 --- a/packages/assets-controller/src/data-sources/index.ts +++ b/packages/assets-controller/src/data-sources/index.ts @@ -57,6 +57,7 @@ export { // Types type SnapDataSourceState, type SnapDataSourceOptions, + type SnapDataSourceConfig, type SnapDataSourceAllowedActions, type SnapDataSourceAllowedEvents, } from './SnapDataSource'; diff --git a/packages/assets-controller/src/data-sources/snap-account-asset-info-enrichment.test.ts b/packages/assets-controller/src/data-sources/snap-account-asset-info-enrichment.test.ts new file mode 100644 index 0000000000..c4c31a76b3 --- /dev/null +++ b/packages/assets-controller/src/data-sources/snap-account-asset-info-enrichment.test.ts @@ -0,0 +1,366 @@ +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; + +import type { ChainId, Caip19AssetId, DataResponse } from '../types'; +import { + GET_ACCOUNT_ASSET_INFO_CLIENT_METHOD, + getAssetsToFetchWithEligibleCustomAssets, + hasAccountAssetInfoEnrichmentCandidate, + isAccountAssetInfoEnrichmentAvailable, + SnapAccountAssetInfoEnricher, +} from './snap-account-asset-info-enrichment'; + +const STELLAR_PUBNET = 'stellar:pubnet' as ChainId; +const STELLAR_TESTNET = 'stellar:testnet' as ChainId; +const SOLANA_MAINNET = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as ChainId; +const STELLAR_SNAP_ID = 'npm:@metamask/stellar-wallet-snap' as SnapId; + +const MOCK_STELLAR_USDC_ASSET = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as Caip19AssetId; +const MOCK_STELLAR_ASSET_2 = + 'stellar:pubnet/asset:USDT-GCQTGZQQ5G4PTM2GL7CDIFKUBIPEC52BROAQIAPW53XBRJVN6ZJVTG6' as Caip19AssetId; +const MOCK_SOL_ASSET = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as Caip19AssetId; + +describe('snap-account-asset-info-enrichment', () => { + describe('isAccountAssetInfoEnrichmentAvailable', () => { + it('returns true for Stellar pubnet', () => { + expect(isAccountAssetInfoEnrichmentAvailable(STELLAR_PUBNET)).toBe(true); + }); + + it('returns false for non-enrichment chains', () => { + expect(isAccountAssetInfoEnrichmentAvailable(STELLAR_TESTNET)).toBe(false); + expect(isAccountAssetInfoEnrichmentAvailable(SOLANA_MAINNET)).toBe(false); + }); + }); + + describe('getAssetsToFetchWithEligibleCustomAssets', () => { + it('adds enrichable custom assets on requested chains', () => { + const result = getAssetsToFetchWithEligibleCustomAssets({ + listedAssets: [MOCK_STELLAR_USDC_ASSET], + customAssets: [MOCK_STELLAR_ASSET_2], + requestedChainIds: [STELLAR_PUBNET], + }); + + expect(result).toStrictEqual( + expect.arrayContaining([MOCK_STELLAR_USDC_ASSET, MOCK_STELLAR_ASSET_2]), + ); + }); + + it('ignores custom assets on non-enrichment chains', () => { + const result = getAssetsToFetchWithEligibleCustomAssets({ + listedAssets: [MOCK_STELLAR_USDC_ASSET], + customAssets: [MOCK_SOL_ASSET], + requestedChainIds: [STELLAR_PUBNET, SOLANA_MAINNET], + }); + + expect(result).toStrictEqual([MOCK_STELLAR_USDC_ASSET]); + }); + }); + + describe('hasAccountAssetInfoEnrichmentCandidate', () => { + const accountId = 'mock-account-id'; + + it('returns true for Stellar asset when Snap ID exists', () => { + const result = hasAccountAssetInfoEnrichmentCandidate({ + assetsBalance: { + [accountId]: { + [MOCK_STELLAR_USDC_ASSET]: { amount: '25' }, + }, + }, + getSnapIdForChain: () => STELLAR_SNAP_ID, + }); + + expect(result).toBe(true); + }); + + it('returns false for Stellar asset when no Snap ID exists', () => { + const result = hasAccountAssetInfoEnrichmentCandidate({ + assetsBalance: { + [accountId]: { + [MOCK_STELLAR_USDC_ASSET]: { amount: '25' }, + }, + }, + getSnapIdForChain: () => undefined, + }); + + expect(result).toBe(false); + }); + + it('returns false for non-Stellar asset', () => { + const result = hasAccountAssetInfoEnrichmentCandidate({ + assetsBalance: { + [accountId]: { + [MOCK_SOL_ASSET]: { amount: '100' }, + }, + }, + getSnapIdForChain: () => STELLAR_SNAP_ID, + }); + + expect(result).toBe(false); + }); + + it('ignores malformed asset IDs', () => { + const result = hasAccountAssetInfoEnrichmentCandidate({ + assetsBalance: { + [accountId]: { + ['not-a-caip-asset' as Caip19AssetId]: { amount: '1' }, + }, + }, + getSnapIdForChain: () => STELLAR_SNAP_ID, + }); + + expect(result).toBe(false); + }); + }); + + describe('SnapAccountAssetInfoEnricher', () => { + const accountId = 'mock-account-id'; + + function createAccountAssets(): NonNullable< + DataResponse['assetsBalance'] + >[string] { + return { + [MOCK_STELLAR_USDC_ASSET]: { amount: '25' }, + }; + } + + function createEnricher( + callSnapRequest: jest.Mock, + ): SnapAccountAssetInfoEnricher { + return new SnapAccountAssetInfoEnricher({ + getSnapIdForChain: () => STELLAR_SNAP_ID, + callSnapRequest, + }); + } + + it('enriches Stellar assets with metadata', async () => { + const assetsBalance = createAccountAssets(); + const callSnapRequest = jest.fn().mockResolvedValue({ + [MOCK_STELLAR_USDC_ASSET]: { + limit: '1000', + authorized: true, + sponsored: false, + }, + }); + const enricher = createEnricher(callSnapRequest); + + await enricher.enrichAccount({ accountId, assetsBalance }); + + expect(callSnapRequest).toHaveBeenCalledWith({ + snapId: STELLAR_SNAP_ID, + origin: 'metamask', + handler: HandlerType.OnClientRequest, + request: { + jsonrpc: '2.0', + method: GET_ACCOUNT_ASSET_INFO_CLIENT_METHOD, + params: { + accountId, + scope: STELLAR_PUBNET, + assets: [MOCK_STELLAR_USDC_ASSET], + }, + }, + }); + expect(assetsBalance[MOCK_STELLAR_USDC_ASSET]).toStrictEqual({ + amount: '25', + metadata: { + limit: '1000', + authorized: true, + sponsored: false, + }, + }); + }); + + it('requests all assets on a chain in a single getAccountAssetInfo call', async () => { + const assetsBalance: NonNullable< + DataResponse['assetsBalance'] + >[string] = { + [MOCK_STELLAR_USDC_ASSET]: { amount: '10' }, + [MOCK_STELLAR_ASSET_2]: { amount: '20' }, + }; + const callSnapRequest = jest.fn().mockResolvedValue({ + [MOCK_STELLAR_USDC_ASSET]: { limit: '1000', authorized: true }, + [MOCK_STELLAR_ASSET_2]: { limit: '500', authorized: false }, + }); + const enricher = createEnricher(callSnapRequest); + + await enricher.enrichAccount({ accountId, assetsBalance }); + + expect(callSnapRequest).toHaveBeenCalledTimes(1); + expect(callSnapRequest).toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ + params: expect.objectContaining({ + assets: expect.arrayContaining([ + MOCK_STELLAR_USDC_ASSET, + MOCK_STELLAR_ASSET_2, + ]), + }), + }), + }), + ); + expect(assetsBalance[MOCK_STELLAR_USDC_ASSET]).toStrictEqual({ + amount: '10', + metadata: { limit: '1000', authorized: true }, + }); + expect(assetsBalance[MOCK_STELLAR_ASSET_2]).toStrictEqual({ + amount: '20', + metadata: { limit: '500', authorized: false }, + }); + }); + + it('deduplicates concurrent enrichment for the same account and chain', async () => { + const assetsBalance = createAccountAssets(); + let resolveSnapRequest: + | ((value: Record) => void) + | undefined; + const callSnapRequest = jest.fn( + () => + new Promise((resolve) => { + resolveSnapRequest = resolve; + }), + ); + const enricher = createEnricher(callSnapRequest); + + const first = enricher.enrichAccount({ accountId, assetsBalance }); + const second = enricher.enrichAccount({ accountId, assetsBalance }); + + expect(callSnapRequest).toHaveBeenCalledTimes(1); + + resolveSnapRequest?.({ + [MOCK_STELLAR_USDC_ASSET]: { + limit: '1000', + authorized: true, + }, + }); + await Promise.all([first, second]); + + expect(callSnapRequest).toHaveBeenCalledTimes(1); + expect(assetsBalance[MOCK_STELLAR_USDC_ASSET]).toStrictEqual({ + amount: '25', + metadata: { + limit: '1000', + authorized: true, + }, + }); + }); + + it('applies shared fetch result to each distinct assetsBalance object', async () => { + const assetsBalanceA = createAccountAssets(); + const assetsBalanceB = createAccountAssets(); + let resolveSnapRequest: + | ((value: Record) => void) + | undefined; + const callSnapRequest = jest.fn( + () => + new Promise((resolve) => { + resolveSnapRequest = resolve; + }), + ); + const enricher = createEnricher(callSnapRequest); + + const first = enricher.enrichAccount({ + accountId, + assetsBalance: assetsBalanceA, + }); + const second = enricher.enrichAccount({ + accountId, + assetsBalance: assetsBalanceB, + }); + + expect(callSnapRequest).toHaveBeenCalledTimes(1); + + resolveSnapRequest?.({ + [MOCK_STELLAR_USDC_ASSET]: { + limit: '1000', + authorized: true, + }, + }); + await Promise.all([first, second]); + + expect(callSnapRequest).toHaveBeenCalledTimes(1); + expect(assetsBalanceA[MOCK_STELLAR_USDC_ASSET]).toStrictEqual({ + amount: '25', + metadata: { + limit: '1000', + authorized: true, + }, + }); + expect(assetsBalanceB[MOCK_STELLAR_USDC_ASSET]).toStrictEqual({ + amount: '25', + metadata: { + limit: '1000', + authorized: true, + }, + }); + }); + + it('allows a new enrichment after the previous in-flight operation completes', async () => { + const assetsBalance = createAccountAssets(); + const callSnapRequest = jest + .fn() + .mockResolvedValueOnce({ + [MOCK_STELLAR_USDC_ASSET]: { limit: '1000', authorized: true }, + }) + .mockResolvedValueOnce({ + [MOCK_STELLAR_USDC_ASSET]: { limit: '2000', authorized: false }, + }); + const enricher = createEnricher(callSnapRequest); + + await enricher.enrichAccount({ accountId, assetsBalance }); + await enricher.enrichAccount({ accountId, assetsBalance }); + + expect(callSnapRequest).toHaveBeenCalledTimes(2); + expect(assetsBalance[MOCK_STELLAR_USDC_ASSET]).toStrictEqual({ + amount: '25', + metadata: { + limit: '2000', + authorized: false, + }, + }); + }); + + it('returns balances without metadata when enrichment hangs past timeout', async () => { + jest.useFakeTimers(); + + const assetsBalance = createAccountAssets(); + const callSnapRequest = jest.fn(() => new Promise(() => undefined)); + const enricher = createEnricher(callSnapRequest); + + const enrichPromise = enricher.enrichAccount({ + accountId, + assetsBalance, + }); + + await jest.advanceTimersByTimeAsync(20_000); + await enrichPromise; + + expect(assetsBalance[MOCK_STELLAR_USDC_ASSET]).toStrictEqual({ + amount: '25', + }); + expect( + ( + assetsBalance[MOCK_STELLAR_USDC_ASSET] as Record + )?.metadata, + ).toBeUndefined(); + + jest.useRealTimers(); + }); + + it('skips enrichment when no Snap ID is available for the chain', async () => { + const assetsBalance = createAccountAssets(); + const callSnapRequest = jest.fn(); + const enricher = new SnapAccountAssetInfoEnricher({ + getSnapIdForChain: (): SnapId | undefined => undefined, + callSnapRequest, + }); + + await enricher.enrichAccount({ accountId, assetsBalance }); + + expect(callSnapRequest).not.toHaveBeenCalled(); + expect(assetsBalance[MOCK_STELLAR_USDC_ASSET]).toStrictEqual({ + amount: '25', + }); + }); + }); +}); diff --git a/packages/assets-controller/src/data-sources/snap-account-asset-info-enrichment.ts b/packages/assets-controller/src/data-sources/snap-account-asset-info-enrichment.ts new file mode 100644 index 0000000000..ee72191302 --- /dev/null +++ b/packages/assets-controller/src/data-sources/snap-account-asset-info-enrichment.ts @@ -0,0 +1,372 @@ +// TODO(STELLAR): This helper is a temporary bridge for Snap-provided balance metadata. +// Remove it once the Accounts API supports account-asset enrichment directly. + +import type { CaipAssetType } from '@metamask/keyring-api'; +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import { parseCaipAssetType } from '@metamask/utils'; + +import { projectLogger, createModuleLogger } from '../logger'; +import type { + AssetBalance, + ChainId, + Caip19AssetId, + DataResponse, + GetAccountAssetInfoResponse, +} from '../types'; + +const log = createModuleLogger( + projectLogger, + 'snap-account-asset-info-enrichment', +); + +export const GET_ACCOUNT_ASSET_INFO_CLIENT_METHOD = 'getAccountAssetInfo'; + +/** Enrichment operation timeout (ms). Hung Snap requests must not block callers. */ +const ACCOUNT_ASSET_INFO_SNAP_TIMEOUT_MS = 15_000; + +// TODO(STELLAR): Replace this chain allowlist with Accounts API-backed enrichment support. +const ACCOUNT_ASSET_INFO_ENRICHMENT_BY_CHAIN: Partial< + Record +> = { + 'stellar:pubnet': true, +}; + +const ENRICHMENT_TIMEOUT = Symbol('enrichmentTimeout'); + +type SnapAccountAssetInfoRequest = { + snapId: SnapId; + origin: string; + handler: HandlerType; + request: { + jsonrpc: '2.0'; + method: string; + params: { + accountId: string; + scope: ChainId; + assets: Caip19AssetId[]; + }; + }; +}; + +export type SnapAccountAssetInfoEnricherOptions = { + getSnapIdForChain: (chainId: ChainId) => SnapId | undefined; + callSnapRequest: (request: SnapAccountAssetInfoRequest) => Promise; + log?: typeof log; +}; + +export type EnrichAccountParams = { + accountId: string; + assetsBalance: Record; +}; + +function extractChainFromAssetId(assetId: string): ChainId { + const parsed = parseCaipAssetType(assetId as CaipAssetType); + return parsed.chainId; +} + +export function isAccountAssetInfoEnrichmentAvailable(chainId: ChainId): boolean { + return ACCOUNT_ASSET_INFO_ENRICHMENT_BY_CHAIN[chainId] === true; +} + +/** + * Merge listed snap assets with enrichable custom assets on the requested chains. + * + * Custom assets can drop off `listAccountAssets` after trustline deactivation + * (e.g. limit-0 tombstones) while still needing enrichment refresh. + * + * @param params - Listed assets, optional custom assets, and requested chains. + * @returns Deduplicated asset list to fetch balances/enrichment for. + */ +export function getAssetsToFetchWithEligibleCustomAssets(params: { + listedAssets: CaipAssetType[]; + customAssets?: Caip19AssetId[]; + requestedChainIds: ChainId[]; +}): CaipAssetType[] { + const { listedAssets, customAssets, requestedChainIds } = params; + const assetsToFetch = new Set(listedAssets); + + if (customAssets) { + for (const assetId of customAssets) { + try { + const assetChainId = extractChainFromAssetId(assetId); + if ( + requestedChainIds.includes(assetChainId) && + isAccountAssetInfoEnrichmentAvailable(assetChainId) + ) { + assetsToFetch.add(assetId as CaipAssetType); + } + } catch { + // Ignore malformed custom asset ids. + } + } + } + + return [...assetsToFetch]; +} + +export function hasAccountAssetInfoEnrichmentCandidate(params: { + assetsBalance: NonNullable; + getSnapIdForChain: (chainId: ChainId) => SnapId | undefined; +}): boolean { + const { assetsBalance, getSnapIdForChain } = params; + + for (const accountAssets of Object.values(assetsBalance)) { + for (const assetId of Object.keys(accountAssets) as Caip19AssetId[]) { + let chainId: ChainId; + try { + chainId = extractChainFromAssetId(assetId); + } catch { + continue; + } + + if ( + isAccountAssetInfoEnrichmentAvailable(chainId) && + getSnapIdForChain(chainId) + ) { + return true; + } + } + } + + return false; +} + +/** + * Stateful enricher for Snap `getAccountAssetInfo` responses. + * + * Deduplicates the Snap fetch per `${accountId}:${chainId}` so concurrent + * callers share one request, then each caller applies the result to its own + * `assetsBalance` object. + */ +export class SnapAccountAssetInfoEnricher { + readonly #getSnapIdForChain: ( + chainId: ChainId, + ) => SnapId | undefined; + + readonly #callSnapRequest: ( + request: SnapAccountAssetInfoRequest, + ) => Promise; + + readonly #log: typeof log; + + readonly #inFlight = new Map< + string, + Promise + >(); + + constructor(options: SnapAccountAssetInfoEnricherOptions) { + this.#getSnapIdForChain = options.getSnapIdForChain; + this.#callSnapRequest = options.callSnapRequest; + this.#log = options.log ?? log; + } + + /** + * Enrich all eligible assets for a single account. + * + * Groups assets by chain, then runs one deduplicated fetch per + * (accountId, chainId) and applies the result to this caller's balances. + * + * @param params - Account id and that account's balance rows to mutate. + */ + async enrichAccount(params: EnrichAccountParams): Promise { + const { accountId, assetsBalance } = params; + const assetsByChain = this.#groupAssetsByChain(assetsBalance); + + for (const [chainId, assets] of assetsByChain) { + await this.#enrich(accountId, chainId, assets, assetsBalance); + } + } + + /** + * Deduplicate the Snap fetch for one (accountId, chainId), then apply to + * this caller's `assetsBalance`. + * + * Concurrent callers share the in-flight fetch promise, but each applies + * the response to its own balance object. + * + * @param accountId - Account id. + * @param chainId - CAIP-2 chain id. + * @param assets - All enrichable assets on that chain for the account. + * @param assetsBalance - Account balance rows to update in place. + * @returns Resolves when fetch (shared) and apply (per-caller) complete. + */ + async #enrich( + accountId: string, + chainId: ChainId, + assets: Caip19AssetId[], + assetsBalance: Record, + ): Promise { + const key = `${accountId}:${chainId}`; + let promise = this.#inFlight.get(key); + if (!promise) { + promise = this.#fetchWithTimeout(accountId, chainId, assets).finally( + () => { + this.#inFlight.delete(key); + }, + ); + this.#inFlight.set(key, promise); + } + + const accountAssetInfo = await promise; + if (!accountAssetInfo) { + return; + } + this.#apply(accountAssetInfo, assetsBalance); + } + + /** + * Fetch enrichment from the Snap, racing against a timeout so callers are + * not blocked if the Snap hangs. + * + * @param accountId - Account id. + * @param chainId - CAIP-2 chain id. + * @param assets - All enrichable assets on that chain for the account. + * @returns Snap response, or undefined on skip/timeout/error. + */ + async #fetchWithTimeout( + accountId: string, + chainId: ChainId, + assets: Caip19AssetId[], + ): Promise { + if (assets.length === 0) { + return undefined; + } + + const snapId = this.#getSnapIdForChain(chainId); + if (!snapId) { + return undefined; + } + + try { + const snapRequest = this.#fetch(accountId, snapId, chainId, assets); + const result = await Promise.race([ + snapRequest, + new Promise((resolve) => + setTimeout( + () => resolve(ENRICHMENT_TIMEOUT), + ACCOUNT_ASSET_INFO_SNAP_TIMEOUT_MS, + ), + ), + ]); + + if (result === ENRICHMENT_TIMEOUT) { + snapRequest + .then(() => + this.#log('Snap account asset info resolved after timeout', { + accountId, + snapId, + chainId, + assetCount: assets.length, + }), + ) + .catch((error) => + this.#log('Snap account asset info failed after timeout', { + accountId, + snapId, + chainId, + assetCount: assets.length, + error, + }), + ); + return undefined; + } + + return result; + } catch (error) { + this.#log('Failed to enrich snap account asset info', { + accountId, + snapId, + chainId, + assetCount: assets.length, + error, + }); + return undefined; + } + } + + /** + * Build and execute a single Snap `getAccountAssetInfo` request. + * + * @param accountId - Account id. + * @param snapId - Snap id for the chain. + * @param chainId - CAIP-2 chain id. + * @param assets - All enrichable assets on that chain for the account. + * @returns Snap response, or undefined. + */ + async #fetch( + accountId: string, + snapId: SnapId, + chainId: ChainId, + assets: Caip19AssetId[], + ): Promise { + const request: SnapAccountAssetInfoRequest = { + snapId, + origin: 'metamask', + handler: HandlerType.OnClientRequest, + request: { + jsonrpc: '2.0', + method: GET_ACCOUNT_ASSET_INFO_CLIENT_METHOD, + params: { + accountId, + scope: chainId, + assets, + }, + }, + }; + + return (await this.#callSnapRequest( + request, + )) as GetAccountAssetInfoResponse | undefined; + } + + /** + * Merge snap `getAccountAssetInfo` into the account's balance rows as + * `metadata`. + * + * Pure aside from mutating `assetsBalance`. + * + * @param enrichment - Enrichment keyed by CAIP-19 asset id. + * @param assetsBalance - Account balance rows to update in place. + */ + #apply( + enrichment: GetAccountAssetInfoResponse, + assetsBalance: Record, + ): void { + for (const [assetId, assetInfo] of Object.entries(enrichment)) { + const row = assetsBalance[assetId as Caip19AssetId]; + if (!row) { + continue; + } + + assetsBalance[assetId as Caip19AssetId] = { + ...row, + metadata: assetInfo, + } satisfies AssetBalance; + } + } + + #groupAssetsByChain( + assetsBalance: Record, + ): Map { + const assetsByChain = new Map(); + + for (const assetId of Object.keys(assetsBalance) as Caip19AssetId[]) { + let chainId: ChainId; + try { + chainId = extractChainFromAssetId(assetId); + } catch { + continue; + } + if (!isAccountAssetInfoEnrichmentAvailable(chainId)) { + continue; + } + + const assetIds = assetsByChain.get(chainId) ?? []; + assetIds.push(assetId); + assetsByChain.set(chainId, assetIds); + } + + return assetsByChain; + } +} diff --git a/packages/assets-controller/src/index.ts b/packages/assets-controller/src/index.ts index 1ebe4be876..06bc09c0f3 100644 --- a/packages/assets-controller/src/index.ts +++ b/packages/assets-controller/src/index.ts @@ -146,6 +146,7 @@ export { export type { SnapDataSourceState, SnapDataSourceOptions, + SnapDataSourceConfig, } from './data-sources'; // Enrichment data sources diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index 2b2b940a71..70b980c9a0 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -270,12 +270,34 @@ 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 AccountAssetInfo = { + limit?: string; + authorized?: boolean; + sponsored?: boolean; + baseReserve?: string; +}; + +/** Per-asset enrichment keyed by CAIP-19 asset id from snap getAccountAssetInfo. */ +export type GetAccountAssetInfoResponse = Record< + Caip19AssetId, + AccountAssetInfo +>; + /** * Balance data for fungible tokens (native, ERC20, SPL). */ export type FungibleAssetBalance = { /** Raw balance amount as string (e.g., "1000000000" for 1000 USDC) */ amount: string; + /** + * Optional per-asset account metadata from snap enrichment + * (e.g. Stellar trustline fields). Distinct from `assetsInfo` token metadata. + */ + metadata?: AccountAssetInfo; }; /**