diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 28cb1ed14f..3c37dc9b4d 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -175,7 +175,7 @@ }, "packages/assets-controller/src/data-sources/SnapDataSource.ts": { "no-restricted-syntax": { - "count": 2 + "count": 3 } }, "packages/assets-controller/src/data-sources/StakedBalanceDataSource.ts": { diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 356907132f..26df009777 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add Stellar-only snap account-asset enrichment ([9455] (https://github.com/MetaMask/core/pull/9455)) + - Add optional `snapDataSourceConfig.isStellarEnabled` getter to `AssetsControllerOptions` so clients can enable or disable Stellar snap `getAccountAssetInfo` enrichment at runtime (defaults to disabled) + - Add Stellar-only snap account-asset enrichment on `SnapDataSource.fetch` when `isStellarEnabled` is true: trustline fields from `getAccountAssetInfo` are attached to matching `stellar:pubnet` native and trustline balance rows under `metadata` on `FungibleAssetBalance`; balance push events (`AccountsController:accountBalancesUpdated`) pass amounts through without enrichment — clients should call `getAssets` after trustline-related snap events (e.g. `AccountsController:accountAssetListUpdated`) + ## [10.2.0] ### Added diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 8fbd85f741..1f23e5f33f 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -1807,6 +1807,75 @@ describe('AssetsController', () => { }); }); + it('preserves balance 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 balance metadata when replaceCoveredChainBalances updates amount only', 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', + replaceCoveredChainBalances: true, + 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..63d22b7496 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 @@ -565,6 +570,16 @@ function normalizeResponse(response: DataResponse): DataResponse { return normalized; } +function mergeBalanceEntry( + previous: AssetBalance | undefined, + incoming: AssetBalance, +): AssetBalance { + return { + ...(previous ?? { amount: '0' }), + ...incoming, + }; +} + /** * Merge account balances from a data-source response into prior state. * @@ -582,7 +597,11 @@ function mergeAccountBalances( replaceCoveredChains: boolean, ): Record { if (!replaceCoveredChains) { - return { ...previousBalances, ...accountBalances }; + const next: Record = { ...previousBalances }; + for (const [assetId, balance] of Object.entries(accountBalances)) { + next[assetId] = mergeBalanceEntry(previousBalances[assetId], balance); + } + return next; } const coveredChains = new Set( @@ -596,7 +615,9 @@ function mergeAccountBalances( } } - Object.assign(next, accountBalances); + for (const [assetId, balance] of Object.entries(accountBalances)) { + next[assetId] = mergeBalanceEntry(previousBalances[assetId], balance); + } for (const customId of customAssetIds) { if (!Object.prototype.hasOwnProperty.call(next, customId)) { @@ -856,6 +877,7 @@ export class AssetsController extends BaseController< accountsApiDataSourceConfig, priceDataSourceConfig, stakedBalanceDataSourceConfig, + snapDataSourceConfig, isOnboarded, tempMigrateAssetsInfoMetadataAssets3346, }: AssetsControllerOptions) { @@ -921,6 +943,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..0b6491ad59 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 = { @@ -151,11 +157,13 @@ function createMockPermissions( * * @param accountAssets - Assets to return for keyring_listAccountAssets * @param balances - Balances to return for keyring_getAccountBalances + * @param accountAssetInfo - Asset info to return for getAccountAssetInfo * @returns Mock handler function */ function createMockHandleRequest( accountAssets: string[] = [], balances: Record = {}, + accountAssetInfo: Record = {}, ): jest.Mock { return jest.fn().mockImplementation((params) => { const { request } = params; @@ -165,6 +173,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 +185,18 @@ function setupController( installedSnaps?: Record; accountAssets?: string[]; balances?: Record; + accountAssetInfo?: Record; configuredNetworks?: ChainId[]; + isStellarEnabled?: () => boolean; } = {}, ): SetupResult { - const { installedSnaps = {}, accountAssets = [], balances = {} } = options; + const { + installedSnaps = {}, + accountAssets = [], + balances = {}, + accountAssetInfo = {}, + isStellarEnabled, + } = options; const rootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, @@ -226,7 +245,11 @@ function setupController( mockGetRunnableSnaps, ); - const mockHandleRequest = createMockHandleRequest(accountAssets, balances); + const mockHandleRequest = createMockHandleRequest( + accountAssets, + balances, + accountAssetInfo, + ); rootMessenger.registerActionHandler( 'SnapController:handleRequest', mockHandleRequest, @@ -249,6 +272,7 @@ function setupController( const controllerOptions: SnapDataSourceOptions = { messenger: controllerMessenger as unknown as AssetsControllerMessenger, onActiveChainsUpdated: activeChainsUpdateHandler, + ...(isStellarEnabled === undefined ? {} : { isStellarEnabled }), }; const controller = new SnapDataSource(controllerOptions); @@ -521,6 +545,177 @@ describe('SnapDataSource', () => { cleanup(); }); + it('fetch enriches Stellar assets with account asset info', async () => { + const { controller, mockHandleRequest, cleanup } = setupController({ + isStellarEnabled: () => 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({ + isStellarEnabled: () => 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 isStellarEnabled is false', async () => { + const { controller, mockHandleRequest, cleanup } = setupController({ + isStellarEnabled: () => 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 +795,54 @@ 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..ca936f158f 100644 --- a/packages/assets-controller/src/data-sources/SnapDataSource.ts +++ b/packages/assets-controller/src/data-sources/SnapDataSource.ts @@ -32,6 +32,11 @@ import type { DataSourceState, SubscriptionRequest, } from './AbstractDataSource'; +import { + filterEligibleAssetsToFetchMetadata, + getAssetInfoRequest, + shouldFetchAssetMetadata, +} from './stellar'; // ============================================================================ // SNAP KEYRING EVENT TYPES @@ -157,7 +162,15 @@ export type SnapDataSourceAllowedActions = // OPTIONS // ============================================================================ -export type SnapDataSourceOptions = { +export type SnapDataSourceConfig = { + /** + * When false, SnapDataSource skips `getAccountAssetInfo` enrichment. + * Evaluated at call time. Defaults to () => false. + */ + isStellarEnabled?: () => 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 +231,8 @@ export class SnapDataSource extends AbstractDataSource< /** Cache of KeyringClient instances per snap ID to avoid re-instantiation */ readonly #keyringClientCache: Map = new Map(); + readonly #isStellarEnabled: () => boolean; + constructor(options: SnapDataSourceOptions) { super(SNAP_DATA_SOURCE_NAME, { ...defaultSnapState, @@ -226,6 +241,7 @@ export class SnapDataSource extends AbstractDataSource< this.#messenger = options.messenger; this.#onActiveChainsUpdated = options.onActiveChainsUpdated; + this.#isStellarEnabled = options.isStellarEnabled ?? ((): boolean => false); // Bind handlers for cleanup in destroy() this.#handleSnapBalancesUpdatedBound = this.#handleSnapBalancesUpdated.bind( @@ -454,6 +470,8 @@ export class SnapDataSource extends AbstractDataSource< updateMode: 'merge', }; + const isStellarEnabled = this.#isStellarEnabled(); + // Fetch balances for each account using its snap ID from metadata for (const { account } of request.accountsWithSupportedChains) { // Skip accounts without snap metadata (non-snap accounts) @@ -501,6 +519,42 @@ export class SnapDataSource extends AbstractDataSource< } } } + + // Step 3: Fetch asset metadata for the account if needed, e.g Stellar Assets + const accountBalanceResults = results.assetsBalance?.[accountId]; + if ( + isStellarEnabled && + accountBalanceResults && + shouldFetchAssetMetadata( + accountAssets, + this.state.chainToSnap, + snapId, + ) + ) { + try { + const assetInfo = (await this.#messenger.call( + 'SnapController:handleRequest', + getAssetInfoRequest({ + snapId, + accountId, + assets: filterEligibleAssetsToFetchMetadata(accountAssets), + }), + )) as Record>; + + if (assetInfo) { + for (const [assetId, metadata] of Object.entries(assetInfo)) { + if (assetId in accountBalanceResults) { + accountBalanceResults[assetId as CaipAssetType] = { + ...accountBalanceResults[assetId as CaipAssetType], + metadata, + }; + } + } + } + } catch (error) { + log('Failed to fetch asset metadata', { error }); + } + } } catch { // Expected when account doesn't belong to this snap } 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/stellar.test.ts b/packages/assets-controller/src/data-sources/stellar.test.ts new file mode 100644 index 0000000000..01fdfc340e --- /dev/null +++ b/packages/assets-controller/src/data-sources/stellar.test.ts @@ -0,0 +1,84 @@ +import type { CaipAssetType, CaipChainId } from '@metamask/utils'; + +import type { Caip19AssetId } from '../types'; +import { + STELLAR_CHAIN_ID, + filterEligibleAssetsToFetchMetadata, + shouldFetchAssetMetadata, +} from './stellar'; + +const STELLAR_SNAP_ID = 'npm:@metamask/stellar-wallet-snap'; +const SOLANA_SNAP_ID = 'npm:@metamask/solana-wallet-snap'; + +const MOCK_STELLAR_NATIVE = 'stellar:pubnet/slip44:148' as Caip19AssetId; +const MOCK_STELLAR_USDC = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as Caip19AssetId; +const MOCK_SOL_ASSET = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as Caip19AssetId; + +describe('stellar helpers', () => { + describe('shouldFetchAssetMetadata', () => { + const chainToSnap: Record = { + [STELLAR_CHAIN_ID]: STELLAR_SNAP_ID, + }; + + it('returns true for Stellar native or trustline assets via the Stellar snap', () => { + expect( + shouldFetchAssetMetadata( + [MOCK_STELLAR_USDC], + chainToSnap, + STELLAR_SNAP_ID, + ), + ).toBe(true); + expect( + shouldFetchAssetMetadata( + [MOCK_STELLAR_NATIVE], + chainToSnap, + STELLAR_SNAP_ID, + ), + ).toBe(true); + }); + + it('returns false for non-Stellar assets', () => { + expect( + shouldFetchAssetMetadata( + [MOCK_SOL_ASSET], + chainToSnap, + STELLAR_SNAP_ID, + ), + ).toBe(false); + }); + + it('returns false when the snap is not mapped to Stellar pubnet', () => { + expect( + shouldFetchAssetMetadata( + [MOCK_STELLAR_USDC], + { [STELLAR_CHAIN_ID]: SOLANA_SNAP_ID }, + STELLAR_SNAP_ID, + ), + ).toBe(false); + }); + + it('returns false when called from a non-Stellar snap', () => { + expect( + shouldFetchAssetMetadata( + [MOCK_STELLAR_USDC], + chainToSnap, + SOLANA_SNAP_ID, + ), + ).toBe(false); + }); + }); + + describe('filterEligibleAssetsToFetchMetadata', () => { + it('keeps only Stellar pubnet native and trustline assets', () => { + expect( + filterEligibleAssetsToFetchMetadata([ + MOCK_STELLAR_USDC, + MOCK_STELLAR_NATIVE, + MOCK_SOL_ASSET, + ] as CaipAssetType[]), + ).toStrictEqual([MOCK_STELLAR_USDC, MOCK_STELLAR_NATIVE]); + }); + }); +}); diff --git a/packages/assets-controller/src/data-sources/stellar.ts b/packages/assets-controller/src/data-sources/stellar.ts new file mode 100644 index 0000000000..679ab34e73 --- /dev/null +++ b/packages/assets-controller/src/data-sources/stellar.ts @@ -0,0 +1,87 @@ +// 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 { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import type { CaipAssetType, CaipChainId } from '@metamask/utils'; + +export const STELLAR_CHAIN_ID = 'stellar:pubnet'; + +type GetAssetInfoRequestParams = { + snapId: string; + accountId: string; + assets: CaipAssetType[]; +}; + +type GetAssetInfoRequest = { + snapId: SnapId; + origin: 'metamask'; + handler: HandlerType.OnClientRequest; + request: { + jsonrpc: '2.0'; + method: 'getAccountAssetInfo'; + params: { + accountId: string; + scope: typeof STELLAR_CHAIN_ID; + assets: CaipAssetType[]; + }; + }; +}; + +export const getAssetInfoRequest = ({ + snapId, + accountId, + assets, +}: GetAssetInfoRequestParams): GetAssetInfoRequest => ({ + snapId: snapId as SnapId, + origin: 'metamask', + handler: HandlerType.OnClientRequest, + request: { + jsonrpc: '2.0', + method: 'getAccountAssetInfo', + params: { + accountId, + scope: STELLAR_CHAIN_ID, + assets, + }, + }, +}); + +function hasNativeOrTrustlineAsset(assetId: CaipAssetType): boolean { + return ( + assetId === 'stellar:pubnet/slip44:148' || + assetId.startsWith('stellar:pubnet/asset:') + ); +} + +/** + * Determines if asset metadata should be fetched for the given assets and snap. + * Ensure only assets from the Stellar pubnet are fetched. + * Ensure only Stellar Snap is used to fetch the asset metadata. + * + * @param assetIds - The CAIP-19 asset IDs to check. + * @param chainToSnap - A mapping of CAIP-2 chain IDs to snap IDs. + * @param snapId - The ID of the snap to check. + * @returns True if asset metadata should be fetched, false otherwise. + */ +export function shouldFetchAssetMetadata( + assetIds: CaipAssetType[], + chainToSnap: Record, + snapId: string, +): boolean { + return ( + chainToSnap[STELLAR_CHAIN_ID] === snapId && + assetIds.some((assetId) => hasNativeOrTrustlineAsset(assetId)) + ); +} + +/** + * Filters the given assets to only include those that are native or trustline assets. + * + * @param assetIds - The CAIP-19 asset IDs to filter. + * @returns The filtered CAIP-19 asset IDs. + */ +export function filterEligibleAssetsToFetchMetadata( + assetIds: CaipAssetType[], +): CaipAssetType[] { + return assetIds.filter((assetId) => hasNativeOrTrustlineAsset(assetId)); +} 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..7e0f97566b 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -270,6 +270,12 @@ export type AssetPrice = FungibleAssetPrice | NFTAssetPrice; // BALANCE TYPES (vary by asset type) // ============================================================================ +/** Per-asset enrichment keyed by CAIP-19 asset id from snap getAccountAssetInfo. */ +export type GetAccountAssetInfoResponse = Record< + Caip19AssetId, + Record +>; + /** * Balance data for fungible tokens (native, ERC20, SPL). */