From 96b6f5ca4fd273b7f6aef397f672eb413197ac2a 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 | 4 + .../src/AssetsController.test.ts | 33 ++++ .../assets-controller/src/AssetsController.ts | 9 +- .../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/core-backend/CHANGELOG.md | 5 + .../core-backend/src/api/accounts/types.ts | 16 +- packages/core-backend/src/types.ts | 9 + 11 files changed, 306 insertions(+), 8 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 8e1bbf0f5bd..2c5f9c5135f 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,6 +7,10 @@ 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)) + ### 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..b4cfd772aa5 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 = { diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index e3dffd5630b..470a9d29c4d 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -2814,7 +2814,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/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index db6bfcca02f..d1c368783e8 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/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` 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 - **BREAKING:** Accounts API `V2SupportedNetworksResponse` now uses CAIP-2 string arrays for both `fullSupport` and `partialSupport`, replacing decimal `fullSupport` and object-shaped `partialSupport.balances` ([#10144](https://github.com/MetaMask/core/pull/10144)) 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; }; /**