diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index cf2afd1720..0b6ceba901 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,12 +7,18 @@ 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) +- Add optional snap account-asset enrichment to fungible balances on `SnapDataSource.fetch` so Stellar trustline fields (`accountAssetInfo`) are included when clients call `getAssets`; balance push events (`AccountsController:accountBalancesUpdated`) pass amounts through without enrichment — clients should call `getAssets` after trustline-related snap events (e.g. `AccountsController:accountAssetListUpdated`) + ### Changed - Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) ### 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/package.json b/packages/assets-controller/package.json index 9fa1ae7755..834aa386ca 100644 --- a/packages/assets-controller/package.json +++ b/packages/assets-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controller", - "version": "10.0.1", + "version": "10.0.1-dev.8", "description": "Tracks assets balances/prices and handles token detection across all digital assets", "keywords": [ "Ethereum", diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 3222a8d285..9442987fd9 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -1687,6 +1687,40 @@ describe('AssetsController', () => { }); }); + it('preserves accountAssetInfo enrichment when merge update changes amount', async () => { + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { + amount: '1', + accountAssetInfo: { 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', + accountAssetInfo: { 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 9e99e94d7c..eece474d7b 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'; @@ -428,6 +431,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 @@ -574,7 +579,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( @@ -848,6 +860,7 @@ export class AssetsController extends BaseController< accountsApiDataSourceConfig, priceDataSourceConfig, stakedBalanceDataSourceConfig, + snapDataSourceConfig, isOnboarded, }: AssetsControllerOptions) { super({ @@ -900,6 +913,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..ef9710afe9 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', + accountAssetInfo: { + 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..7ec71e3f0a 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 { + enrichAccountAssetInfo, + hasAccountAssetInfoEnrichmentCandidate, +} 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,8 @@ export class SnapDataSource extends AbstractDataSource< /** Cache of KeyringClient instances per snap ID to avoid re-instantiation */ readonly #keyringClientCache: Map = new Map(); + readonly #assetEnrichmentEnabled: () => boolean; + constructor(options: SnapDataSourceOptions) { super(SNAP_DATA_SOURCE_NAME, { ...defaultSnapState, @@ -226,6 +240,8 @@ export class SnapDataSource extends AbstractDataSource< this.#messenger = options.messenger; this.#onActiveChainsUpdated = options.onActiveChainsUpdated; + this.#assetEnrichmentEnabled = + options.assetEnrichmentEnabled ?? ((): boolean => false); // Bind handlers for cleanup in destroy() this.#handleSnapBalancesUpdatedBound = this.#handleSnapBalancesUpdated.bind( @@ -266,6 +282,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 +527,28 @@ export class SnapDataSource extends AbstractDataSource< } } + // Post-fetch enrichment stage: assetsBalance above already matches the + // standard (unenriched) balance shape. When the feature flag is enabled + // and there are eligible assets, apply accountAssetInfo enrichment once. + if ( + this.#assetEnrichmentEnabled() && + results.assetsBalance && + hasAccountAssetInfoEnrichmentCandidate({ + assetsBalance: results.assetsBalance, + chainToSnap: this.state.chainToSnap, + }) + ) { + // TODO(STELLAR): Remove this Snap-side accountAssetInfo enrichment path once the Accounts API returns account-asset enrichment directly. + await enrichAccountAssetInfo({ + assetsBalance: results.assetsBalance, + chainToSnap: this.state.chainToSnap, + callSnapRequest: (request) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.#messenger as any).call('SnapController:handleRequest', request), + log, + }); + } + 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..fc5da0164c --- /dev/null +++ b/packages/assets-controller/src/data-sources/snap-account-asset-info-enrichment.test.ts @@ -0,0 +1,213 @@ +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, + enrichAccountAssetInfo, + hasAccountAssetInfoEnrichmentCandidate, + isAccountAssetInfoEnrichmentAvailable, +} 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 chains', () => { + expect(isAccountAssetInfoEnrichmentAvailable(STELLAR_PUBNET)).toBe(true); + expect(isAccountAssetInfoEnrichmentAvailable(STELLAR_TESTNET)).toBe(true); + }); + + it('returns false for non-enrichment chains', () => { + expect(isAccountAssetInfoEnrichmentAvailable(SOLANA_MAINNET)).toBe(false); + }); + }); + + 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('enrichAccountAssetInfo', () => { + const accountId = 'mock-account-id'; + + function createAssetsBalance(): NonNullable { + return { + [accountId]: { + [MOCK_STELLAR_USDC_ASSET]: { amount: '25' }, + }, + }; + } + + it('enriches Stellar assets with accountAssetInfo', async () => { + const assetsBalance = createAssetsBalance(); + const callSnapRequest = jest.fn().mockResolvedValue({ + [MOCK_STELLAR_USDC_ASSET]: { + limit: '1000', + authorized: true, + sponsored: false, + }, + }); + + await enrichAccountAssetInfo({ + assetsBalance, + getSnapIdForChain: () => STELLAR_SNAP_ID, + callSnapRequest, + }); + + 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[accountId]?.[MOCK_STELLAR_USDC_ASSET]).toStrictEqual({ + amount: '25', + accountAssetInfo: { + limit: '1000', + authorized: true, + sponsored: false, + }, + }); + }); + + it('returns balances without accountAssetInfo when enrichment hangs past timeout', async () => { + jest.useFakeTimers(); + + const assetsBalance = createAssetsBalance(); + const callSnapRequest = jest.fn( + () => new Promise(() => undefined), + ); + + const enrichPromise = enrichAccountAssetInfo({ + assetsBalance, + getSnapIdForChain: () => STELLAR_SNAP_ID, + callSnapRequest, + }); + + await jest.advanceTimersByTimeAsync(20_000); + await enrichPromise; + + expect(assetsBalance[accountId]?.[MOCK_STELLAR_USDC_ASSET]).toStrictEqual({ + amount: '25', + }); + expect( + ( + assetsBalance[accountId]?.[MOCK_STELLAR_USDC_ASSET] as Record< + string, + unknown + > + )?.accountAssetInfo, + ).toBeUndefined(); + + jest.useRealTimers(); + }); + + it('stops after the first enrichment batch timeout', async () => { + jest.useFakeTimers(); + + const MOCK_STELLAR_ASSET_3 = + 'stellar:pubnet/asset:EURC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as Caip19AssetId; + const MOCK_STELLAR_ASSET_4 = + 'stellar:pubnet/asset:BTC-GCQTGZQQ5G4PTM2GL7CDIFKUBIPEC52BROAQIAPW53XBRJVN6ZJVTG6' as Caip19AssetId; + + const assetsBalance: NonNullable = { + [accountId]: { + [MOCK_STELLAR_USDC_ASSET]: { amount: '10' }, + [MOCK_STELLAR_ASSET_2]: { amount: '10' }, + [MOCK_STELLAR_ASSET_3]: { amount: '10' }, + [MOCK_STELLAR_ASSET_4]: { amount: '10' }, + }, + }; + + let callCount = 0; + const callSnapRequest = jest.fn(() => { + callCount += 1; + return new Promise(() => undefined); + }); + + const enrichPromise = enrichAccountAssetInfo({ + assetsBalance, + getSnapIdForChain: () => STELLAR_SNAP_ID, + callSnapRequest, + }); + + await jest.advanceTimersByTimeAsync(20_000); + await enrichPromise; + + // Batch size is 3; a second batch would be queued without the timeout break. + expect(callCount).toBe(1); + + jest.useRealTimers(); + }); + }); +}); 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..93e8f3fb4e --- /dev/null +++ b/packages/assets-controller/src/data-sources/snap-account-asset-info-enrichment.ts @@ -0,0 +1,246 @@ +// TODO(STELLAR): This helper is a temporary bridge for Snap-provided accountAssetInfo. +// 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'; + +/** + * Max assets per snap getAccountAssetInfo request. Large batches can terminate + * the Stellar wallet snap on mobile when many trustlines are fetched at once. + */ +const ACCOUNT_ASSET_INFO_SNAP_BATCH_SIZE = 1; + +/** Per-batch snap client request timeout (ms). Hung requests must not block apply. */ +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, + 'stellar:testnet': true, +}; + +const ENRICHMENT_TIMEOUT = Symbol('enrichmentTimeout'); + +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; +} + +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; +} + +type SnapAccountAssetInfoRequest = { + snapId: SnapId; + origin: string; + handler: HandlerType; + request: { + jsonrpc: '2.0'; + method: string; + params: { + accountId: string; + scope: ChainId; + assets: Caip19AssetId[]; + }; + }; +}; + +async function fetchAccountAssetInfoFromSnap({ + accountId, + snapId, + chainId, + assets, + callSnapRequest, + moduleLog = log, +}: { + accountId: string; + snapId: SnapId; + chainId: ChainId; + assets: Caip19AssetId[]; + callSnapRequest: (request: SnapAccountAssetInfoRequest) => Promise; + moduleLog?: typeof log; +}): Promise { + if (assets.length === 0) { + return undefined; + } + + try { + 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, + }, + }, + }; + + const snapRequest = callSnapRequest(request); + 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(() => + moduleLog('Snap account asset info resolved after timeout', { + accountId, + snapId, + chainId, + assetCount: assets.length, + }), + ) + .catch((error) => + moduleLog('Snap account asset info failed after timeout', { + accountId, + snapId, + chainId, + assetCount: assets.length, + error, + }), + ); + return ENRICHMENT_TIMEOUT; + } + + return result as GetAccountAssetInfoResponse | undefined; + } catch (error) { + moduleLog('Failed to enrich snap account asset info', { + accountId, + snapId, + chainId, + assetCount: assets.length, + error, + }); + return ENRICHMENT_TIMEOUT; + } +} + +export async function enrichAccountAssetInfo(params: { + assetsBalance: NonNullable; + getSnapIdForChain: (chainId: ChainId) => SnapId | undefined; + callSnapRequest: (request: SnapAccountAssetInfoRequest) => Promise; + log?: typeof log; +}): Promise { + const { assetsBalance, getSnapIdForChain, callSnapRequest, log: moduleLog } = + params; + + for (const [accountId, accountAssets] of Object.entries(assetsBalance)) { + const assetsByChain = new Map(); + + for (const assetId of Object.keys(accountAssets) 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); + } + + for (const [chainId, assetIds] of assetsByChain) { + const snapId = getSnapIdForChain(chainId); + if (!snapId) { + continue; + } + + for ( + let i = 0; + i < assetIds.length; + i += ACCOUNT_ASSET_INFO_SNAP_BATCH_SIZE + ) { + const batch = assetIds.slice( + i, + i + ACCOUNT_ASSET_INFO_SNAP_BATCH_SIZE, + ); + const accountAssetInfo = await fetchAccountAssetInfoFromSnap({ + accountId, + snapId, + chainId, + assets: batch, + callSnapRequest, + moduleLog, + }); + + if ( + accountAssetInfo === ENRICHMENT_TIMEOUT || + accountAssetInfo === undefined + ) { + break; + } + + for (const [assetId, assetInfo] of Object.entries(accountAssetInfo)) { + const row = accountAssets[assetId as Caip19AssetId]; + if (!row) { + continue; + } + + accountAssets[assetId as Caip19AssetId] = { + ...row, + accountAssetInfo: assetInfo, + } satisfies AssetBalance; + } + } + } + } +} 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 0fb743e853..0fc27c1a8d 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 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 info from snap account-asset enrichment (e.g. Stellar trustlines). */ + accountAssetInfo?: AccountAssetInfo; }; /**