From f638386ffe15b1c8e39bf1af83db434b3b848d92 Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Fri, 11 Sep 2026 14:16:32 +0200 Subject: [PATCH] feat(assets-controller): retrieve and store balance metadata --- packages/assets-controller/CHANGELOG.md | 5 + .../src/AssetsController.test.ts | 118 ++++++++++++ .../assets-controller/src/AssetsController.ts | 16 +- .../AccountActivityDataSource.test.ts | 49 +++++ .../data-sources/AccountActivityDataSource.ts | 1 + .../AccountsApiDataSource.test.ts | 173 +++++++++++++++++- .../src/data-sources/AccountsApiDataSource.ts | 9 +- packages/assets-controller/src/types.ts | 6 + packages/assets-controller/src/utils/index.ts | 3 + .../src/utils/native-assets.test.ts | 35 +++- .../src/utils/native-assets.ts | 168 ++++++++++++++++- packages/core-backend/CHANGELOG.md | 2 + .../core-backend/src/api/accounts/types.ts | 16 +- packages/core-backend/src/types.ts | 9 + 14 files changed, 589 insertions(+), 21 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 8e1bbf0f5bd..31e4cd6ebf7 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 `metadata` on fungible `assetsBalance` entries from the Accounts API and Account Activity websocket ([#10194](https://github.com/MetaMask/core/pull/10194)) +- Register the Solana, Stellar and Tron native assets (SOL, XLM, TRX) so accounts holding no assets on those networks now surface a zero-balance native entry, matching the existing EVM behavior ([#10194](https://github.com/MetaMask/core/pull/10194)) + ### Changed - Bump `@metamask/account-tree-controller` from `^10.0.0` to `^10.0.1` ([#10166](https://github.com/MetaMask/core/pull/10166)) diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index dc31450f492..805de23f998 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -2764,6 +2764,39 @@ describe('AssetsController', () => { }); }); + it('keeps existing metadata when a merge update omits it', async () => { + const stellarMetadata = { + spendableBalance: '8944804518', + minimumReserveBalance: '200000000', + decimal: 7, + }; + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { amount: '1', metadata: stellarMetadata }, + }, + }, + }; + + 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: stellarMetadata }); + }); + }); + it('updates state from AccountActivityService:balanceUpdated', async () => { const arbNative = 'eip155:42161/slip44:60' as Caip19AssetId; const initialState: Partial = { @@ -3469,6 +3502,91 @@ describe('AssetsController', () => { }); }); + it('seeds a zero native balance for a Solana account with no assets', async () => { + // The Accounts API returns nothing at all for an account that holds no + // assets — not even a zero native balance — so the controller has to + // supply SOL itself, the same way it supplies ETH on EVM. + const solanaChainId = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + const solanaNativeAssetId = + `${solanaChainId}/slip44:501` as Caip19AssetId; + const solanaAccountId = 'mock-solana-account-id'; + + await withController(async ({ controller, getSelectedAccountsMock }) => { + getSelectedAccountsMock.mockReturnValue([ + createMockInternalAccount({ + id: solanaAccountId, + address: 'FhRuTg4d2vbVbY1AhPWFGaJgMxNWUxUJUcNhjT5rFQZg', + type: 'solana:data-account', + scopes: [solanaChainId as `${string}:${string}`], + }), + ]); + + (controller.messenger.publish as CallableFunction)( + 'NetworkEnablementController:stateChange', + { + enabledNetworkMap: { + eip155: { '1': true }, + solana: { [solanaChainId]: true }, + }, + nativeAssetIdentifiers: {}, + }, + [], + ); + + await new Promise(process.nextTick); + + expect( + controller.state.assetsBalance[solanaAccountId]?.[ + solanaNativeAssetId + ], + ).toStrictEqual({ amount: '0' }); + }); + }); + + it('seeds a Stellar native with zero spendable and reserve metadata when the account has no assets', async () => { + const stellarChainId = 'stellar:pubnet'; + const stellarNativeAssetId = + `${stellarChainId}/slip44:148` as Caip19AssetId; + const stellarAccountId = 'mock-stellar-account-id'; + + await withController(async ({ controller, getSelectedAccountsMock }) => { + getSelectedAccountsMock.mockReturnValue([ + createMockInternalAccount({ + id: stellarAccountId, + address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + type: 'stellar:data-account', + scopes: [stellarChainId as `${string}:${string}`], + }), + ]); + + (controller.messenger.publish as CallableFunction)( + 'NetworkEnablementController:stateChange', + { + enabledNetworkMap: { + eip155: { '1': true }, + stellar: { [stellarChainId]: true }, + }, + nativeAssetIdentifiers: {}, + }, + [], + ); + + await new Promise(process.nextTick); + + expect( + controller.state.assetsBalance[stellarAccountId]?.[ + stellarNativeAssetId + ], + ).toStrictEqual({ + amount: '0', + metadata: { + minimumReserveBalance: '0', + spendableBalance: '0', + }, + }); + }); + }); + it('handles network being disabled', async () => { await withController(async ({ messenger }) => { (messenger.publish as CallableFunction)( diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index e3dffd5630b..a0aa4eccbcc 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -158,6 +158,7 @@ import { formatStateForTransactionPay, buildNativeAssetsFromConstant, buildNativeAssetsFromApi, + getDefaultNativeAssetBalance, } from './utils/index.js'; import type { BridgeExchangeRatesFormat, @@ -2592,7 +2593,8 @@ export class AssetsController extends BaseController< nativeAssetId, ) ) { - balances[accountId][nativeAssetId] = { amount: '0' }; + balances[accountId][nativeAssetId] = + getDefaultNativeAssetBalance(nativeAssetId); } } } @@ -2792,7 +2794,8 @@ export class AssetsController extends BaseController< if ( !Object.prototype.hasOwnProperty.call(effective, nativeAssetId) ) { - effective[nativeAssetId] = { amount: '0' } as AssetBalance; + effective[nativeAssetId] = + getDefaultNativeAssetBalance(nativeAssetId); } } @@ -2814,7 +2817,14 @@ export class AssetsController extends BaseController< (balance as { amount: unknown }).amount, assetDecimals, ); - effective[assetId] = { ...balance, amount: newAmount }; + // Keep existing metadata when the incoming update is + // amount-only (e.g. Account Activity websocket). Incoming + // metadata still wins when present. + effective[assetId] = { + ...previousBalance, + ...balance, + amount: newAmount, + }; const oldAmount = previousBalance?.amount; const isNewDefaultNativeZero = oldAmount === undefined && diff --git a/packages/assets-controller/src/data-sources/AccountActivityDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountActivityDataSource.test.ts index d374539928b..8f65cc3092b 100644 --- a/packages/assets-controller/src/data-sources/AccountActivityDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountActivityDataSource.test.ts @@ -341,6 +341,55 @@ describe('AccountActivityDataSource', () => { cleanup(); }); + it('persists Stellar trustline metadata from postBalance', async () => { + const STELLAR_CHAIN = 'stellar:pubnet' as ChainId; + const STELLAR_ADDRESS = + 'GCRTHNJHYCV4F4JOAIMUE2ALYPE3C7Q53XTSUVGYJ4UXYIKWZAK7FWPG'; + const STELLAR_USDC = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as Caip19AssetId; + const trustlineMetadata = { + authorized: true, + limit: '9223372036854775807', + }; + const account = createMockAccount({ + address: STELLAR_ADDRESS, + type: 'stellar:ss58', + scopes: [STELLAR_CHAIN], + }); + const { onAssetsUpdate, triggerBalanceUpdated, cleanup } = setup({ + groupAccounts: [account], + }); + + triggerBalanceUpdated({ + address: STELLAR_ADDRESS, + chain: STELLAR_CHAIN, + updates: [ + createBalanceUpdate({ + asset: { + type: STELLAR_USDC, + unit: 'USDC', + decimals: 7, + }, + postBalance: { + amount: '201421', + metadata: trustlineMetadata, + }, + }), + ], + }); + + await Promise.resolve(); + + expect(onAssetsUpdate).toHaveBeenCalledTimes(1); + const [response] = onAssetsUpdate.mock.calls[0]; + expect(response.assetsBalance[account.id][STELLAR_USDC]).toStrictEqual({ + amount: '0.0201421', + metadata: trustlineMetadata, + }); + + cleanup(); + }); + it('resolves the asset type via the injected getAssetType', async () => { const { getAssetType, triggerBalanceUpdated, cleanup } = setup({ getAssetType: () => 'erc20', diff --git a/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts b/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts index 576d1dceafd..99516d72690 100644 --- a/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts @@ -87,6 +87,7 @@ function processAccountActivityBalanceUpdates( assetsBalance[accountId][assetId] = { amount: humanReadableAmount, + ...(postBalance.metadata ? { metadata: postBalance.metadata } : {}), }; assetsMetadata[assetId] = { diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index 0ade66456c8..6ae265b00ee 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -93,17 +93,31 @@ function createMockV6BalanceItem( assetId: string, balance: string, object: 'token' | 'defi' = 'token', - type: 'native' | 'erc20' = 'erc20', + type: string = 'erc20', + metadata?: V6BalanceItem['metadata'], ): V6BalanceItem { - return { accountId, object, type, assetId, balance } as V6BalanceItem; + return { + accountId, + object, + type, + assetId, + balance, + ...(metadata ? { metadata } : {}), + } as V6BalanceItem; } function createMockBalanceItem( accountId: string, assetId: string, balance: string, + metadata?: V5BalanceItem['metadata'], ): V5BalanceItem { - return { accountId, assetId, balance } as V5BalanceItem; + return { + accountId, + assetId, + balance, + ...(metadata ? { metadata } : {}), + } as V5BalanceItem; } function createDataRequest( @@ -636,6 +650,80 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); + it('fetch persists Stellar native and trustline metadata from v5 balances', async () => { + const STELLAR_CHAIN_ID = 'stellar:pubnet' as ChainId; + const stellarAddress = + 'GCRTHNJHYCV4F4JOAIMUE2ALYPE3C7Q53XTSUVGYJ4UXYIKWZAK7FWPG'; + const nativeAssetId = 'stellar:pubnet/slip44:148'; + const usdcAssetId = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + const nativeMetadata = { + spendableBalance: '8944804518', + minimumReserveBalance: '200000000', + decimal: 7, + }; + const trustlineMetadata = { + limit: '9223372036854775807', + authorized: true, + sponsored: false, + }; + + const { controller } = await setupController({ + supportedChains: [1, STELLAR_CHAIN_ID as unknown as number], + remoteFeatureFlags: { + [SNAPS_ASSETS_MIGRATION_FLAG_KEYS.stellar]: { + stage: SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback, + }, + }, + balances: [ + createMockBalanceItem( + `${STELLAR_CHAIN_ID}:${stellarAddress}`, + nativeAssetId, + '914.4804518', + nativeMetadata, + ), + createMockBalanceItem( + `${STELLAR_CHAIN_ID}:${stellarAddress}`, + usdcAssetId, + '0', + trustlineMetadata, + ), + ], + }); + + const response = await controller.fetch( + createDataRequest({ + chainIds: [STELLAR_CHAIN_ID], + accounts: [ + createMockAccount({ + address: stellarAddress, + type: 'stellar:ss58', + scopes: [STELLAR_CHAIN_ID], + }), + ], + }), + ); + + expect( + response.assetsBalance?.['mock-account-id']?.[ + nativeAssetId as Caip19AssetId + ], + ).toStrictEqual({ + amount: '914.4804518', + metadata: nativeMetadata, + }); + expect( + response.assetsBalance?.['mock-account-id']?.[ + usdcAssetId as Caip19AssetId + ], + ).toStrictEqual({ + amount: '0', + metadata: trustlineMetadata, + }); + + controller.destroy(); + }); + it('excludes staking contract asset IDs from v5 balance response', async () => { const stakingAssetId = 'eip155:1/erc20:0x4fef9d741011476750a243ac70b9789a63dd47df'; @@ -826,6 +914,85 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); + it('persists Stellar native and trustline metadata from v6 balances', async () => { + const STELLAR_CHAIN_ID = 'stellar:pubnet' as ChainId; + const stellarAddress = + 'GDZRSRB4DOK3372HO2OKYVJKGTL5MYF5VUSO5CD5CJJNQVG35HMBQT6U'; + const nativeAssetId = 'stellar:pubnet/slip44:148'; + const aquaAssetId = + 'stellar:pubnet/asset:AQUA-GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA'; + const nativeMetadata = { + spendableBalance: '8944803018', + minimumReserveBalance: '200000000', + decimal: 7, + }; + const trustlineMetadata = { + limit: '9223372036854775807', + authorized: true, + sponsored: false, + }; + + const { controller } = await setupController({ + supportedChains: [1, STELLAR_CHAIN_ID as unknown as number], + remoteFeatureFlags: { + assetsAccountsApiV6: { value: true }, + [SNAPS_ASSETS_MIGRATION_FLAG_KEYS.stellar]: { + stage: SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback, + }, + }, + v6Balances: [ + createMockV6BalanceItem( + `${STELLAR_CHAIN_ID}:${stellarAddress}`, + aquaAssetId, + '0', + 'token', + 'token', + trustlineMetadata, + ), + createMockV6BalanceItem( + `${STELLAR_CHAIN_ID}:${stellarAddress}`, + nativeAssetId, + '914.4803018', + 'token', + 'native', + nativeMetadata, + ), + ], + }); + + const response = await controller.fetch( + createDataRequest({ + chainIds: [STELLAR_CHAIN_ID], + accounts: [ + createMockAccount({ + address: stellarAddress, + type: 'stellar:ss58', + scopes: [STELLAR_CHAIN_ID], + }), + ], + }), + ); + + expect( + response.assetsBalance?.['mock-account-id']?.[ + aquaAssetId as Caip19AssetId + ], + ).toStrictEqual({ + amount: '0', + metadata: trustlineMetadata, + }); + expect( + response.assetsBalance?.['mock-account-id']?.[ + nativeAssetId as Caip19AssetId + ], + ).toStrictEqual({ + amount: '914.4803018', + metadata: nativeMetadata, + }); + + controller.destroy(); + }); + it('ignores v6 defi positions', async () => { const accountId = `eip155:1:${MOCK_ADDRESS}`; const { controller } = await setupController({ diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index 4779f8928a7..d00040e1a36 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -4,6 +4,7 @@ import type { RemoteFeatureFlagControllerGetStateAction, RemoteFeatureFlagControllerStateChangeEvent, } from '@metamask/remote-feature-flag-controller'; +import type { Json } from '@metamask/utils'; import { isCaipChainId, KnownCaipNamespace, @@ -660,9 +661,11 @@ export class AccountsApiDataSource extends AbstractDataSource< continue; } - // Store balance as returned by API + // Store balance as returned by API, along with any network-specific + // metadata (e.g. Stellar trustline / native reserve fields). assetsBalance[accountId][normalizedAssetId] = { amount: item.balance, + ...(item.metadata ? { metadata: item.metadata as Json } : {}), }; } @@ -730,9 +733,11 @@ export class AccountsApiDataSource extends AbstractDataSource< continue; } - // Store balance as returned by API + // Store balance as returned by API, along with any network-specific + // metadata (e.g. Stellar trustline / native reserve fields). assetsBalance[accountId][normalizedAssetId] = { amount: item.balance, + ...(item.metadata ? { metadata: item.metadata as Json } : {}), }; } diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index 501220b6e5f..a1c7bd80d28 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -276,6 +276,12 @@ export type AssetPrice = FungibleAssetPrice | NFTAssetPrice; export type FungibleAssetBalance = { /** Raw balance amount as string (e.g., "1000000000" for 1000 USDC) */ amount: string; + /** + * Network-specific balance fields, when the source provides them. Stellar + * native rows carry `spendableBalance` / `minimumReserveBalance`, and + * trustlines carry `limit` / `authorized` / `sponsored`. + */ + metadata?: Json; }; /** diff --git a/packages/assets-controller/src/utils/index.ts b/packages/assets-controller/src/utils/index.ts index c49f5a496f2..1fa61bc7c5a 100644 --- a/packages/assets-controller/src/utils/index.ts +++ b/packages/assets-controller/src/utils/index.ts @@ -26,4 +26,7 @@ export type { export { buildNativeAssetsFromConstant, buildNativeAssetsFromApi, + getDefaultNativeAssetBalance, + NATIVE_ASSETS, + STELLAR_NATIVE_ZERO_BALANCE_METADATA, } from './native-assets.js'; diff --git a/packages/assets-controller/src/utils/native-assets.test.ts b/packages/assets-controller/src/utils/native-assets.test.ts index b82cedfdeb9..13d126610d6 100644 --- a/packages/assets-controller/src/utils/native-assets.test.ts +++ b/packages/assets-controller/src/utils/native-assets.test.ts @@ -1,9 +1,10 @@ -import { SPOT_PRICES_SUPPORT_INFO } from '@metamask/assets-controllers'; import { fetchWithErrorHandling } from '@metamask/controller-utils'; import { buildNativeAssetsFromConstant, buildNativeAssetsFromApi, + getDefaultNativeAssetBalance, + NATIVE_ASSETS, } from './native-assets.js'; import { normalizeAssetId } from './normalizeAssetId.js'; @@ -15,16 +16,40 @@ jest.mock('@metamask/controller-utils', () => ({ const fetchWithErrorHandlingMock = jest.mocked(fetchWithErrorHandling); describe('buildNativeAssetsFromConstant', () => { - it('includes a normalized entry for every value in SPOT_PRICES_SUPPORT_INFO', () => { + it('includes a normalized entry for every NATIVE_ASSETS chain', () => { const result = buildNativeAssetsFromConstant(); - const supportInfoValues = Object.values(SPOT_PRICES_SUPPORT_INFO); - for (const assetId of supportInfoValues) { - expect(Object.values(result)).toContain(normalizeAssetId(assetId)); + for (const [chainId, assetId] of Object.entries(NATIVE_ASSETS)) { + expect(result[chainId]).toBe(normalizeAssetId(assetId)); } }); }); +describe('getDefaultNativeAssetBalance', () => { + it('seeds Stellar natives with zero spendable and reserve metadata', () => { + expect( + getDefaultNativeAssetBalance('stellar:pubnet/slip44:148'), + ).toStrictEqual({ + amount: '0', + metadata: { + minimumReserveBalance: '0', + spendableBalance: '0', + }, + }); + }); + + it('seeds non-Stellar natives as a plain zero amount', () => { + expect( + getDefaultNativeAssetBalance( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ), + ).toStrictEqual({ amount: '0' }); + expect(getDefaultNativeAssetBalance('eip155:1/slip44:60')).toStrictEqual({ + amount: '0', + }); + }); +}); + describe('buildNativeAssetsFromApi', () => { beforeEach(() => { fetchWithErrorHandlingMock.mockReset(); diff --git a/packages/assets-controller/src/utils/native-assets.ts b/packages/assets-controller/src/utils/native-assets.ts index 5cf251974aa..9164471b458 100644 --- a/packages/assets-controller/src/utils/native-assets.ts +++ b/packages/assets-controller/src/utils/native-assets.ts @@ -1,8 +1,7 @@ -import { SPOT_PRICES_SUPPORT_INFO } from '@metamask/assets-controllers'; import { fetchWithErrorHandling } from '@metamask/controller-utils'; import { parseCaipAssetType } from '@metamask/utils'; -import type { Caip19AssetId, ChainId } from '../types.js'; +import type { Caip19AssetId, ChainId, FungibleAssetBalance } from '../types.js'; import { normalizeAssetId } from './normalizeAssetId.js'; const CHAINID_NETWORK_URL = 'https://chainid.network/chains.json'; @@ -13,7 +12,165 @@ type ChainIdNetworkEntry = { }; /** - * Builds a native asset map from the hardcoded SPOT_PRICES_SUPPORT_INFO constant. + * Seed native CAIP-19 asset IDs, keyed by CAIP-2 chain ID. + * + * Covers Price API v3/spot-prices EVM natives plus the Solana, Stellar and + * Tron natives this controller ingests. chainid.network only fills extra + * `eip155` gaps, so without the non-EVM rows an account holding nothing on + * those networks gets an empty list instead of SOL/XLM/TRX at 0. + * + * Price API v3/spot-prices chains only for EVM — verify support before adding: + * https://github.com/consensys-vertical-apps/va-mmcx-price-api/blob/main/src/constants/slip44.ts + * Include chain name + native symbol. Keep sorted by chain ID. + */ +export const NATIVE_ASSETS: Readonly> = { + 'eip155:1': 'eip155:1/slip44:60', // Ethereum Mainnet - Native symbol: ETH + 'eip155:10': 'eip155:10/slip44:60', // OP Mainnet - Native symbol: ETH + 'eip155:25': 'eip155:25/slip44:394', // Cronos Mainnet - Native symbol: CRO + 'eip155:30': 'eip155:30/slip44:137', // Rootstock Mainnet - Native symbol: RBTC + 'eip155:42': 'eip155:42/erc20:0x0000000000000000000000000000000000000000', // Lukso - native symbol: LYX + 'eip155:50': 'eip155:50/erc20:0x0000000000000000000000000000000000000000', // xdc-network - native symbol: XDC + 'eip155:56': 'eip155:56/slip44:714', // BNB Smart Chain Mainnet - Native symbol: BNB + 'eip155:57': 'eip155:57/slip44:57', // Syscoin Mainnet - Native symbol: SYS + 'eip155:82': 'eip155:82/slip44:18000', // Meter Mainnet - Native symbol: MTR + 'eip155:88': 'eip155:88/slip44:889', // TomoChain - Native symbol: TOMO + 'eip155:100': 'eip155:100/erc20:0x0000000000000000000000000000000000000000', // Gnosis (formerly xDAI Chain) - Native symbol: xDAI + 'eip155:106': 'eip155:106/slip44:5655640', // Velas EVM Mainnet - Native symbol: VLX + 'eip155:122': 'eip155:122/erc20:0x0000000000000000000000000000000000000000', // Fuse Mainnet - Native symbol: FUSE + 'eip155:128': 'eip155:128/slip44:1010', // Huobi ECO Chain Mainnet - Native symbol: HT + 'eip155:137': 'eip155:137/slip44:966', // Polygon Mainnet - Native symbol: POL + 'eip155:143': 'eip155:143/slip44:268435779', // Monad Mainnet - Native symbol: MON + 'eip155:146': 'eip155:146/slip44:10007', // Sonic Mainnet - Native symbol: S + 'eip155:196': 'eip155:196/erc20:0x0000000000000000000000000000000000000000', // X Layer Mainnet - Native symbol: OKB + 'eip155:232': 'eip155:232/erc20:0x0000000000000000000000000000000000000000', // Lens Mainnet - Native symbol: GHO + 'eip155:250': 'eip155:250/slip44:1007', // Fantom Opera - Native symbol: FTM + 'eip155:252': 'eip155:252/erc20:0x0000000000000000000000000000000000000000', // Fraxtal - native symbol: FRAX + 'eip155:288': 'eip155:288/slip44:60', // Boba Network (Ethereum L2) - Native symbol: ETH + 'eip155:321': 'eip155:321/slip44:641', // KCC Mainnet - Native symbol: KCS + 'eip155:324': 'eip155:324/slip44:60', // zkSync Era Mainnet (Ethereum L2) - Native symbol: ETH + 'eip155:336': 'eip155:336/slip44:809', // Shiden - Native symbol: SDN + 'eip155:361': 'eip155:361/slip44:589', // Theta Mainnet - Native symbol: TFUEL + 'eip155:747': 'eip155:747/slip44:539', // Flow evm - Native symbol: Flow + 'eip155:988': 'eip155:988/erc20:0x0000000000000000000000000000000000000000', // Stable - Native symbol: USDT0 + 'eip155:999': 'eip155:999/slip44:2457', // HyperEVM - Native symbol: HYPE + 'eip155:1088': 'eip155:1088/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // Metis Andromeda Mainnet (Ethereum L2) - Native symbol: METIS + 'eip155:1101': 'eip155:1101/slip44:60', // Polygon zkEVM mainnet - Native symbol: ETH + 'eip155:1284': 'eip155:1284/slip44:1284', // Moonbeam - Native symbol: GLMR + 'eip155:1285': 'eip155:1285/slip44:1285', // Moonriver - Native symbol: MOVR + 'eip155:1329': 'eip155:1329/slip44:19000118', // Sei Mainnet - Native symbol: SEI + 'eip155:1776': 'eip155:1776/slip44:22000119', // Injective Mainnet - Native symbol: INJ + 'eip155:1868': 'eip155:1868/erc20:0x0000000000000000000000000000000000000000', // Soneium - Native symbol: ETH + 'eip155:2525': 'eip155:2525/erc20:0x0000000000000000000000000000000000000000', // inEVM Mainnet - Native symbol: INV + 'eip155:2741': 'eip155:2741/erc20:0x0000000000000000000000000000000000000000', // Abstract - Native symbol: ETH + 'eip155:4217': 'eip155:4217/slip44:60', // Tempo Mainnet - No native asset + 'eip155:4326': 'eip155:4326/erc20:0x0000000000000000000000000000000000000000', // MegaETH Mainnet - Native symbol: ETH + 'eip155:5000': 'eip155:5000/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // Mantle - Native symbol: MNT + 'eip155:5031': 'eip155:5031/slip44:5031', // Somnia Mainnet - Native symbol: SOMI + 'eip155:5042': 'eip155:5042/slip44:5042', // Arc - Native symbol: USDC + 'eip155:7000': 'eip155:7000/slip44:7000', // ZetaChain - Native symbol: ZETA + 'eip155:8453': 'eip155:8453/slip44:60', // Base - Native symbol: ETH + 'eip155:4663': 'eip155:4663/slip44:60', // Robinhood Chain - Native symbol: ETH + 'eip155:9745': 'eip155:9745/erc20:0x0000000000000000000000000000000000000000', // Plasma mainnet - native symbol: XPL + 'eip155:10000': 'eip155:10000/slip44:145', // Smart Bitcoin Cash - Native symbol: BCH + 'eip155:33139': + 'eip155:33139/erc20:0x0000000000000000000000000000000000000000', // Apechain Mainnet - Native symbol: APE + 'eip155:41923': + 'eip155:41923/erc20:0x0000000000000000000000000000000000000000', // EDU Chain - Native symbol: EDU + 'eip155:42161': 'eip155:42161/slip44:60', // Arbitrum One - Native symbol: ETH + 'eip155:42220': 'eip155:42220/slip44:52752', // Celo Mainnet - Native symbol: CELO + 'eip155:42262': 'eip155:42262/slip44:474', // Oasis Emerald - Native symbol: ROSE + 'eip155:42431': 'eip155:42431/slip44:60', // Tempo Testnet Moderato - No native asset + 'eip155:42793': + 'eip155:42793/erc20:0x0000000000000000000000000000000000000000', // Etherlink - Native symbol: XTZ (Tezos L2) + 'eip155:43111': + 'eip155:43111/erc20:0x0000000000000000000000000000000000000000', // Hemi - Native symbol: ETH + 'eip155:43114': 'eip155:43114/slip44:9005', // Avalanche C-Chain - Native symbol: AVAX + 'eip155:57073': 'eip155:57073/slip44:60', // Ink Mainnet - Native symbol: ETH + 'eip155:59144': 'eip155:59144/slip44:60', // Linea Mainnet - Native symbol: ETH + 'eip155:60808': + 'eip155:60808/erc20:0x0000000000000000000000000000000000000000', // BOB - Native symbol: ETH + 'eip155:68414': + 'eip155:68414/erc20:0x0000000000000000000000000000000000000000', // MapleStory Universe - no slip44 + 'eip155:73115': + 'eip155:73115/erc20:0x0000000000000000000000000000000000000000', // ICB Network - Native symbol: ICBX + 'eip155:80094': + 'eip155:80094/erc20:0x0000000000000000000000000000000000000000', // Berachain - Native symbol: Bera + 'eip155:81457': 'eip155:81457/slip44:60', // Blast Mainnet - Native symbol: ETH + 'eip155:88888': + 'eip155:88888/erc20:0x0000000000000000000000000000000000000000', // Chiliz Chain - Native symbol: CHZ + 'eip155:97741': + 'eip155:97741/erc20:0x0000000000000000000000000000000000000000', // Pepe Unchained Mainnet - Native symbol: PEPU + 'eip155:98866': + 'eip155:98866/erc20:0x0000000000000000000000000000000000000000', // Plume Mainnet - Native symbol: Plume + 'eip155:167000': 'eip155:167000/slip44:60', // Taiko Mainnet - Native symbol: ETH + 'eip155:333999': 'eip155:333999/slip44:1997', // Polis Mainnet - Native symbol: POLIS + 'eip155:534352': 'eip155:534352/slip44:60', // Scroll Mainnet - Native symbol: ETH + 'eip155:747474': + 'eip155:747474/erc20:0x0000000000000000000000000000000000000000', // katana - Native symbol: ETH + 'eip155:984122': + 'eip155:984122/erc20:0x0000000000000000000000000000000000000000', // Forma - Native symbol: TIA (Celestia) + 'eip155:1440000': + 'eip155:1440000/erc20:0x0000000000000000000000000000000000000000', // xrpl-evm - native symbol: XRP + 'eip155:1313161554': 'eip155:1313161554/slip44:60', // Aurora Mainnet (Ethereum L2 on NEAR) - Native symbol: ETH + 'eip155:1666600000': 'eip155:1666600000/slip44:1023', // Harmony Mainnet Shard 0 - Native symbol: ONE + 'eip155:16661': 'eip155:16661/slip44:1111116661', // 0G Chain - Native symbol: 0G + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', // Solana Mainnet - Native symbol: SOL + 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z': + 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z/slip44:501', // Solana Testnet - Native symbol: SOL + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1': + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', // Solana Devnet - Native symbol: SOL + 'stellar:pubnet': 'stellar:pubnet/slip44:148', // Stellar Pubnet - Native symbol: XLM + 'stellar:testnet': 'stellar:testnet/slip44:148', // Stellar Testnet - Native symbol: XLM + 'tron:728126428': 'tron:728126428/slip44:195', // Tron Mainnet - Native symbol: TRX + 'tron:3448148188': 'tron:3448148188/slip44:195', // Tron Nile - Native symbol: TRX + 'tron:2494104990': 'tron:2494104990/slip44:195', // Tron Shasta - Native symbol: TRX +}; + +/** + * Balance-row metadata the Accounts API attaches to Stellar native XLM. + * Seeded at zero so an unfunded account still has a well-formed native row + * instead of a bare `{ amount: '0' }`. + */ +export const STELLAR_NATIVE_ZERO_BALANCE_METADATA = { + minimumReserveBalance: '0', + spendableBalance: '0', +} as const; + +const ZERO_NATIVE_BALANCE: FungibleAssetBalance = { amount: '0' }; + +/** + * Default native balance to insert when a data source (typically Accounts API) + * returns no row for the chain's native asset. + * + * Stellar natives include `spendableBalance` / `minimumReserveBalance` at 0 so + * consumers that read those fields on XLM do not have to special-case a missing + * metadata object. Other natives are a plain zero amount. + * + * @param nativeAssetId - The CAIP-19 native asset ID being seeded. + * @returns A zero-balance entry for that native. + */ +export function getDefaultNativeAssetBalance( + nativeAssetId: Caip19AssetId, +): FungibleAssetBalance { + try { + const { chain } = parseCaipAssetType(nativeAssetId); + if (chain.namespace === 'stellar') { + return { + amount: '0', + metadata: { ...STELLAR_NATIVE_ZERO_BALANCE_METADATA }, + }; + } + } catch { + // Malformed IDs fall through to a plain zero amount. + } + + return { ...ZERO_NATIVE_BALANCE }; +} + +/** + * Builds a native asset map from {@link NATIVE_ASSETS}, normalizing each + * CAIP-19 ID (ERC-20 addresses become EIP-55 checksummed). * * @returns A record mapping CAIP-2 chain IDs to their CAIP-19 native asset IDs. */ @@ -22,9 +179,8 @@ export function buildNativeAssetsFromConstant(): Record< Caip19AssetId > { const nativeAssetsMap: Record = {}; - for (const nativeAssetId of Object.values(SPOT_PRICES_SUPPORT_INFO)) { - const { chainId } = parseCaipAssetType(nativeAssetId); - nativeAssetsMap[chainId] = normalizeAssetId(nativeAssetId); + for (const [chainId, nativeAssetId] of Object.entries(NATIVE_ASSETS)) { + nativeAssetsMap[chainId as ChainId] = normalizeAssetId(nativeAssetId); } return nativeAssetsMap; } diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index 5874507a0d3..0cd0a521169 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add optional `apiUrls` option to `ApiPlatformClientOptions`, allowing API base URLs (accounts, prices, token, tokens) to be overridden per client instance, e.g. from client env vars; unspecified services fall back to the production `API_URLS` ([#10196](https://github.com/MetaMask/core/pull/10196)) +- Add optional `metadata` to `V5BalanceItem` and to the Account Activity `Balance` type ([#10194](https://github.com/MetaMask/core/pull/10194)) +- Add optional `sponsored`, `spendableBalance`, and `minimumReserveBalance` Stellar fields to `V6TokenMetadata` ([#10194](https://github.com/MetaMask/core/pull/10194)) ### Changed diff --git a/packages/core-backend/src/api/accounts/types.ts b/packages/core-backend/src/api/accounts/types.ts index e26e343f502..a8adb33eb24 100644 --- a/packages/core-backend/src/api/accounts/types.ts +++ b/packages/core-backend/src/api/accounts/types.ts @@ -19,6 +19,11 @@ export type V5BalanceItem = { assetId: string; balance: string; accountId: string; + /** + * Token-level metadata such as Stellar trustline / native reserve fields. + * Present when the upstream balance row carries it. + */ + metadata?: V6TokenMetadata; }; /** V5 Multi-account balances response */ @@ -100,14 +105,21 @@ export type V6BalanceMetadata = { }; /** - * Token-level metadata attached to an `object: token` row in the v6 balances - * response, e.g. Stellar trustline metadata. Additional keys may be present. + * Token-level metadata attached to an `object: token` row in the v5/v6 + * balances responses, e.g. Stellar trustline and native reserve metadata. + * Additional keys may be present. */ export type V6TokenMetadata = { /** Stellar trustline limit. */ limit?: string; /** Whether the Stellar trustline is authorized. */ authorized?: boolean; + /** Whether the Stellar trustline is sponsored. */ + sponsored?: boolean; + /** Stellar native spendable balance (unscaled stroops). */ + spendableBalance?: string; + /** Stellar native minimum / reserve balance (unscaled stroops). */ + minimumReserveBalance?: string; [key: string]: unknown; }; diff --git a/packages/core-backend/src/types.ts b/packages/core-backend/src/types.ts index 9d82cbe526b..21c47c48002 100644 --- a/packages/core-backend/src/types.ts +++ b/packages/core-backend/src/types.ts @@ -1,3 +1,5 @@ +import type { Json } from '@metamask/utils'; + /** * Basic transaction information */ @@ -38,6 +40,13 @@ export type Balance = { amount: string; /** Optional error message */ error?: string; + /** + * Network-specific balance properties from Account Activity websocket + * messages. Stellar trustlines typically include `limit`, `authorized`, and + * optionally `sponsor` (sponsor address). This is the stream payload, not + * the Accounts API camelCase fields (`sponsored`, `spendableBalance`, …). + */ + metadata?: Json; }; /**