From 2b070e29ca84caaf25fd885ffc9d65a82cd841c5 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Fri, 15 May 2026 10:31:04 +0200 Subject: [PATCH] feat(assets-controllers): Stellar classic trustline inactive tracking --- package.json | 2 +- packages/assets-controllers/CHANGELOG.md | 11 + packages/assets-controllers/package.json | 2 +- .../MultichainAssetsController.test.ts | 164 +++++++++++- .../MultichainAssetsController.ts | 244 +++++++++++++++++- .../constant.ts | 2 +- .../MultichainBalancesController.test.ts | 1 + .../MultichainBalancesController.ts | 58 ++++- packages/assets-controllers/src/index.ts | 5 + .../src/multichain/stellarTrustline.test.ts | 69 +++++ .../src/multichain/stellarTrustline.ts | 48 ++++ .../src/selectors/token-selectors.test.ts | 2 + .../src/selectors/token-selectors.ts | 10 + 13 files changed, 596 insertions(+), 22 deletions(-) create mode 100644 packages/assets-controllers/src/multichain/stellarTrustline.test.ts create mode 100644 packages/assets-controllers/src/multichain/stellarTrustline.ts diff --git a/package.json b/package.json index d45a691947..58e9e342cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "963.0.0", + "version": "963.0.0-dev.5", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 61004f023c..503315f33d 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `MultichainAssetsController`: track Stellar classic (`asset:`) CAIP-19 tokens added via `addAssets` in `stellarClassicTrustlineInactiveAssetIds` until the keyring lists the same id in `AccountsController:accountAssetListUpdated` `added` (or the asset is removed); one-time backfill for classic ids already in `accountsAssets` when `stellarTrustlineInactiveBackfillComplete` is false (legacy imports before this feature) ([#TODO](https://github.com/MetaMask/core/pull/TODO)) +- `selectAllMultichainAssets`: optional `isStellarTrustlineInactive` on multichain `Asset` when the id is in that map ([#TODO](https://github.com/MetaMask/core/pull/TODO)) +- `isStellarClassicAssetCaip19` helper; `isStellarTrustlineTrackedAsset` now applies only to classic `asset:` ids, not `sep41:` ([#TODO](https://github.com/MetaMask/core/pull/TODO)) + +### Fixed + +- `MultichainAssetsController`: after `addAssets`, re-fetch the Snap `listAccountAssets` list for Stellar classic (`asset:`) ids and clear `stellarClassicTrustlineInactiveAssetIds` when the keyring already reports the same CAIP-19 id (e.g. hide token then re-add while trustline is still active) ([#TODO](https://github.com/MetaMask/core/pull/TODO)) +- `MultichainBalancesController`: when `MultichainAssetsController:accountAssetListUpdated` fires while the vault is locked, balance fetches are skipped and were never retried after unlock, leaving multichain token lists empty despite assets in state; subscribe to `KeyringController:stateChange` and refetch balances for snap-backed accounts that still have no cached balances after unlock. ([#TODO](https://github.com/MetaMask/core/pull/TODO)) + ## [106.0.0] ### Added diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index e842ea6e70..db6b150a83 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controllers", - "version": "106.0.0", + "version": "106.0.0-dev.2", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ "Ethereum", diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts index f1bcd86cec..658d7d08cf 100644 --- a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts @@ -23,6 +23,7 @@ import type { SubjectPermissions } from '@metamask/permission-controller'; import type { BulkTokenScanResponse } from '@metamask/phishing-controller'; import { TokenScanResultType } from '@metamask/phishing-controller'; import type { Snap } from '@metamask/snaps-utils'; +import { HandlerType } from '@metamask/snaps-utils'; import { v4 as uuidv4 } from 'uuid'; import { @@ -197,6 +198,17 @@ const mockGetPermissionsReturnValue = [ }, ]; +const STELLAR_CLASSIC_USDC = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as CaipAssetType; + +const STELLAR_CLASSIC_USDC_METADATA = { + name: 'USD Coin', + symbol: 'USDC', + fungible: true, + iconUrl: '', + units: [{ name: 'USD Coin', symbol: 'USDC', decimals: 7 }], +}; + const mockGetMetadataReturnValue: AssetMetadataResponse | undefined = { assets: { 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501': { @@ -380,9 +392,51 @@ describe('MultichainAssetsController', () => { accountsAssets: {}, assetsMetadata: {}, allIgnoredAssets: {}, + stellarClassicTrustlineInactiveAssetIds: {}, + stellarTrustlineInactiveBackfillComplete: true, }); }); + it('backfills Stellar classic trustline-inactive for assets already in accountsAssets on first load', () => { + const accountId = 'stellar-legacy'; + const { controller } = setupController({ + state: { + accountsAssets: { + [accountId]: [STELLAR_CLASSIC_USDC], + }, + assetsMetadata: {}, + allIgnoredAssets: {}, + stellarClassicTrustlineInactiveAssetIds: {}, + } as MultichainAssetsControllerState, + }); + + expect(controller.state.stellarTrustlineInactiveBackfillComplete).toBe( + true, + ); + expect( + controller.state.stellarClassicTrustlineInactiveAssetIds[accountId], + ).toStrictEqual([STELLAR_CLASSIC_USDC]); + }); + + it('skips Stellar trustline backfill when already completed', () => { + const accountId = 'stellar-legacy'; + const { controller } = setupController({ + state: { + accountsAssets: { + [accountId]: [STELLAR_CLASSIC_USDC], + }, + assetsMetadata: {}, + allIgnoredAssets: {}, + stellarClassicTrustlineInactiveAssetIds: {}, + stellarTrustlineInactiveBackfillComplete: true, + } as MultichainAssetsControllerState, + }); + + expect( + controller.state.stellarClassicTrustlineInactiveAssetIds[accountId], + ).toBeUndefined(); + }); + it('does not update state when new account added is EVM', async () => { const { controller, messenger } = setupController(); @@ -397,6 +451,8 @@ describe('MultichainAssetsController', () => { accountsAssets: {}, assetsMetadata: {}, allIgnoredAssets: {}, + stellarClassicTrustlineInactiveAssetIds: {}, + stellarTrustlineInactiveBackfillComplete: true, }); }); @@ -430,6 +486,8 @@ describe('MultichainAssetsController', () => { }, assetsMetadata: mockGetMetadataReturnValue.assets, allIgnoredAssets: {}, + stellarClassicTrustlineInactiveAssetIds: {}, + stellarTrustlineInactiveBackfillComplete: true, }); }); @@ -499,6 +557,8 @@ describe('MultichainAssetsController', () => { ...mockGetMetadataReturnValue.assets, }, allIgnoredAssets: {}, + stellarClassicTrustlineInactiveAssetIds: {}, + stellarTrustlineInactiveBackfillComplete: true, }); }); @@ -556,6 +616,8 @@ describe('MultichainAssetsController', () => { ...mockGetMetadataReturnValue.assets, }, allIgnoredAssets: {}, + stellarClassicTrustlineInactiveAssetIds: {}, + stellarTrustlineInactiveBackfillComplete: true, }); }); @@ -591,6 +653,8 @@ describe('MultichainAssetsController', () => { assetsMetadata: mockGetMetadataReturnValue.assets, allIgnoredAssets: {}, + stellarClassicTrustlineInactiveAssetIds: {}, + stellarTrustlineInactiveBackfillComplete: true, }); // Remove an EVM account messenger.publish('AccountsController:accountRemoved', mockEthAccount.id); @@ -604,6 +668,8 @@ describe('MultichainAssetsController', () => { assetsMetadata: mockGetMetadataReturnValue.assets, allIgnoredAssets: {}, + stellarClassicTrustlineInactiveAssetIds: {}, + stellarTrustlineInactiveBackfillComplete: true, }); }); @@ -639,6 +705,8 @@ describe('MultichainAssetsController', () => { assetsMetadata: mockGetMetadataReturnValue.assets, allIgnoredAssets: {}, + stellarClassicTrustlineInactiveAssetIds: {}, + stellarTrustlineInactiveBackfillComplete: true, }); // Remove the added solana account messenger.publish( @@ -653,10 +721,46 @@ describe('MultichainAssetsController', () => { assetsMetadata: mockGetMetadataReturnValue.assets, allIgnoredAssets: {}, + stellarClassicTrustlineInactiveAssetIds: {}, + stellarTrustlineInactiveBackfillComplete: true, }); }); describe('handleAccountAssetListUpdated', () => { + it('clears Stellar trustline-inactive when keyring re-announces an existing classic asset', async () => { + const accountId = 'stellar-test-account'; + const { messenger, controller } = setupController({ + state: { + accountsAssets: { + [accountId]: [STELLAR_CLASSIC_USDC], + }, + assetsMetadata: { + ...mockGetMetadataReturnValue.assets, + [STELLAR_CLASSIC_USDC]: STELLAR_CLASSIC_USDC_METADATA, + }, + allIgnoredAssets: {}, + stellarClassicTrustlineInactiveAssetIds: { + [accountId]: [STELLAR_CLASSIC_USDC], + }, + } as MultichainAssetsControllerState, + }); + + messenger.publish('AccountsController:accountAssetListUpdated', { + assets: { + [accountId]: { + added: [STELLAR_CLASSIC_USDC], + removed: [], + }, + }, + }); + + await jestAdvanceTime({ duration: 1 }); + + expect( + controller.state.stellarClassicTrustlineInactiveAssetIds[accountId], + ).toBeUndefined(); + }); + it('updates the assets list for an account when a new asset is added', async () => { const mockSolanaAccountId1 = 'account1'; const mockSolanaAccountId2 = 'account2'; @@ -1067,6 +1171,55 @@ describe('MultichainAssetsController', () => { ).toStrictEqual([assetToAdd]); }); + it('adds Stellar classic assets to trustline-inactive when added via addAssets', async () => { + const { controller } = setupController({ + state: { + accountsAssets: {}, + assetsMetadata: { + [STELLAR_CLASSIC_USDC]: STELLAR_CLASSIC_USDC_METADATA, + }, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + await controller.addAssets([STELLAR_CLASSIC_USDC], mockSolanaAccount.id); + + expect( + controller.state.stellarClassicTrustlineInactiveAssetIds[ + mockSolanaAccount.id + ], + ).toStrictEqual([STELLAR_CLASSIC_USDC]); + }); + + it('clears Stellar classic trustline-inactive when Snap listAccountAssets includes the asset', async () => { + const { controller, mockSnapHandleRequest } = setupController({ + state: { + accountsAssets: {}, + assetsMetadata: { + [STELLAR_CLASSIC_USDC]: STELLAR_CLASSIC_USDC_METADATA, + }, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + mockSnapHandleRequest.mockImplementation( + (params: { handler: string }) => { + if (params.handler === HandlerType.OnKeyringRequest) { + return Promise.resolve([STELLAR_CLASSIC_USDC]); + } + return Promise.resolve(mockHandleRequestOnAssetsLookupReturnValue); + }, + ); + + await controller.addAssets([STELLAR_CLASSIC_USDC], mockSolanaAccount.id); + + expect( + controller.state.stellarClassicTrustlineInactiveAssetIds[ + mockSolanaAccount.id + ], + ).toBeUndefined(); + }); + it('should publish accountAssetListUpdated event when asset is added', async () => { const { controller, messenger } = setupController({ state: { @@ -1709,7 +1862,9 @@ describe('MultichainAssetsController', () => { const tokens = Array.from( { length: 150 }, (_, i) => - `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token${String(i).padStart(3, '0')}`, + `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token${String( + i, + ).padStart(3, '0')}`, ); const { controller, messenger, mockBulkScanTokens } = setupController({ @@ -1778,7 +1933,9 @@ describe('MultichainAssetsController', () => { const tokens = Array.from( { length: 120 }, (_, i) => - `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token${String(i).padStart(3, '0')}`, + `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token${String( + i, + ).padStart(3, '0')}`, ); const { controller, messenger, mockBulkScanTokens } = setupController({ @@ -2021,6 +2178,8 @@ describe('MultichainAssetsController', () => { "accountsAssets": {}, "allIgnoredAssets": {}, "assetsMetadata": {}, + "stellarClassicTrustlineInactiveAssetIds": {}, + "stellarTrustlineInactiveBackfillComplete": true, } `); }); @@ -2039,6 +2198,7 @@ describe('MultichainAssetsController', () => { "accountsAssets": {}, "allIgnoredAssets": {}, "assetsMetadata": {}, + "stellarClassicTrustlineInactiveAssetIds": {}, } `); }); diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts index 3bd5ee7e12..0dd0591ef3 100644 --- a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts @@ -42,6 +42,7 @@ import type { MutexInterface } from 'async-mutex'; import { Mutex } from 'async-mutex'; import type { MultichainAssetsControllerMethodActions } from './MultichainAssetsController-method-action-types'; +import { isStellarClassicAssetCaip19 } from '../multichain/stellarTrustline'; import { getChainIdsCaveat } from './utils'; const controllerName = 'MultichainAssetsController'; @@ -52,6 +53,19 @@ export type MultichainAssetsControllerState = { }; accountsAssets: { [account: string]: CaipAssetType[] }; allIgnoredAssets: { [account: string]: CaipAssetType[] }; + /** + * Stellar classic (`asset:`) CAIP-19 ids added via import and not yet reported + * by the Snap in `accountAssetListUpdated` `added` — treated as no trustline for UI. + */ + stellarClassicTrustlineInactiveAssetIds: { + [account: string]: CaipAssetType[]; + }; + /** + * After the first run, `true` means legacy Stellar classic (`asset:`) rows in + * `accountsAssets` were merged into `stellarClassicTrustlineInactiveAssetIds` + * so pre-feature imports get the “no trustline” flag without re-importing. + */ + stellarTrustlineInactiveBackfillComplete: boolean; }; // Represents the response of the asset snap's onAssetLookup handler @@ -75,7 +89,13 @@ export type MultichainAssetsControllerAccountAssetListUpdatedEvent = { * @returns The default {@link MultichainAssetsController} state. */ export function getDefaultMultichainAssetsControllerState(): MultichainAssetsControllerState { - return { accountsAssets: {}, assetsMetadata: {}, allIgnoredAssets: {} }; + return { + accountsAssets: {}, + assetsMetadata: {}, + allIgnoredAssets: {}, + stellarClassicTrustlineInactiveAssetIds: {}, + stellarTrustlineInactiveBackfillComplete: false, + }; } /** @@ -175,6 +195,18 @@ const assetsControllerMetadata: StateMetadata = includeInDebugSnapshot: false, usedInUi: true, }, + stellarClassicTrustlineInactiveAssetIds: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + stellarTrustlineInactiveBackfillComplete: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, }; const MESSENGER_EXPOSED_METHODS = [ @@ -256,6 +288,37 @@ export class MultichainAssetsController extends StaticIntervalPollingController< ); messenger.registerMethodActionHandlers(this, MESSENGER_EXPOSED_METHODS); + + this.#backfillStellarTrustlineInactiveIfNeeded(); + } + + /** + * One-time merge: Stellar classic ids already present in `accountsAssets` from + * before `stellarClassicTrustlineInactiveAssetIds` existed are added to that + * map so the UI can show “no trustline” until the keyring clears them. + */ + #backfillStellarTrustlineInactiveIfNeeded(): void { + if (this.state.stellarTrustlineInactiveBackfillComplete) { + return; + } + this.update((state) => { + for (const [accountId, assets] of Object.entries(state.accountsAssets)) { + for (const assetId of assets) { + if (!isStellarClassicAssetCaip19(assetId)) { + continue; + } + if (!state.stellarClassicTrustlineInactiveAssetIds[accountId]) { + state.stellarClassicTrustlineInactiveAssetIds[accountId] = []; + } + const inactive = + state.stellarClassicTrustlineInactiveAssetIds[accountId]; + if (!inactive.includes(assetId)) { + inactive.push(assetId); + } + } + } + state.stellarTrustlineInactiveBackfillComplete = true; + }); } async _executePoll(_input: null): Promise { @@ -341,6 +404,12 @@ export class MultichainAssetsController extends StaticIntervalPollingController< ].filter((asset) => !assetsToIgnore.includes(asset)); } + this.#removeStellarTrustlineInactiveAssetIds( + state, + accountId, + assetsToIgnore, + ); + if (!state.allIgnoredAssets[accountId]) { state.allIgnoredAssets[accountId] = []; } @@ -410,6 +479,19 @@ export class MultichainAssetsController extends StaticIntervalPollingController< delete state.allIgnoredAssets[accountId]; } } + + for (const assetId of addedAssets) { + if (isStellarClassicAssetCaip19(assetId)) { + if (!state.stellarClassicTrustlineInactiveAssetIds[accountId]) { + state.stellarClassicTrustlineInactiveAssetIds[accountId] = []; + } + const inactive = + state.stellarClassicTrustlineInactiveAssetIds[accountId]; + if (!inactive.includes(assetId)) { + inactive.push(assetId); + } + } + } }); // Publish event to notify other controllers (balances, rates) about the new assets @@ -424,6 +506,11 @@ export class MultichainAssetsController extends StaticIntervalPollingController< }); } + await this.#reconcileStellarClassicTrustlineInactiveWithSnap( + accountId, + addedAssets, + ); + return this.state.accountsAssets[accountId] || []; }); } @@ -439,6 +526,90 @@ export class MultichainAssetsController extends StaticIntervalPollingController< return this.state.allIgnoredAssets[accountId]?.includes(asset) ?? false; } + /** + * After `addAssets`, Stellar classic (`asset:`) ids are marked trustline-inactive until + * the keyring confirms them. Re-fetch the account asset list from the Snap and clear + * that flag for any added classic id the Snap already reports (e.g. hide → re-add with + * an existing trustline), without relying on a later `accountAssetListUpdated` event. + * + * @param accountId - Account the assets were added to. + * @param addedAssets - Assets that were newly added in this `addAssets` call. + */ + async #reconcileStellarClassicTrustlineInactiveWithSnap( + accountId: string, + addedAssets: CaipAssetType[], + ): Promise { + const stellarAdded = addedAssets.filter(isStellarClassicAssetCaip19); + if (stellarAdded.length === 0) { + return; + } + + const accounts = this.messenger.call( + 'AccountsController:listMultichainAccounts', + ); + const account = accounts.find((a) => a.id === accountId); + if (!account || !this.#isNonEvmAccount(account)) { + return; + } + + const snapId = account.metadata.snap?.id; + if (!snapId) { + return; + } + + let snapAssets: CaipAssetTypeOrId[]; + try { + snapAssets = await this.#getAssetsList(accountId, snapId); + } catch { + return; + } + + const snapAssetSet = new Set(snapAssets.filter(isCaipAssetType)); + const confirmedTrustline = stellarAdded.filter((asset) => + snapAssetSet.has(asset), + ); + + if (confirmedTrustline.length === 0) { + return; + } + + this.update((state) => { + this.#removeStellarTrustlineInactiveAssetIds( + state, + accountId, + confirmedTrustline, + ); + }); + } + + /** + * Drops CAIP-19 asset ids from the per-account "Stellar import / no trustline" set. + * + * @param state - Controller draft state. + * @param accountId - Account id. + * @param assetIds - Asset ids to remove from the inactive set. + */ + #removeStellarTrustlineInactiveAssetIds( + state: MultichainAssetsControllerState, + accountId: string, + assetIds: readonly CaipAssetType[], + ): void { + if (assetIds.length === 0) { + return; + } + const list = state.stellarClassicTrustlineInactiveAssetIds[accountId]; + if (!list) { + return; + } + const drop = new Set(assetIds); + const next = list.filter((a) => !drop.has(a)); + if (next.length === 0) { + delete state.stellarClassicTrustlineInactiveAssetIds[accountId]; + } else { + state.stellarClassicTrustlineInactiveAssetIds[accountId] = next; + } + } + /** * Function to update the assets list for an account * @@ -453,6 +624,13 @@ export class MultichainAssetsController extends StaticIntervalPollingController< const assetsForMetadataRefresh = new Set([]); const accountsAndAssetsToUpdate: AccountAssetListUpdatedEventPayload['assets'] = {}; + /** + * When the keyring re-announces a Stellar classic asset id the user had already + * imported, `preFiltered` excludes it; we still clear "no trustline" UI state. + */ + const clearStellarTrustlineInactiveByAccount: { + [account: string]: CaipAssetType[]; + } = {}; for (const [accountId, { added, removed }] of Object.entries( event.assets, )) { @@ -472,6 +650,21 @@ export class MultichainAssetsController extends StaticIntervalPollingController< const filteredToBeAddedAssets = await this.#filterBlockaidSpamTokensOnAdd(preFilteredToBeAddedAssets); + const filteredToBeAddedSet = new Set( + filteredToBeAddedAssets, + ); + const snapConfirmsStellarTrustline = added.filter( + (asset): asset is CaipAssetType => + isCaipAssetType(asset) && + !this.#isAssetIgnored(asset, accountId) && + isStellarClassicAssetCaip19(asset) && + (existing.includes(asset) || filteredToBeAddedSet.has(asset)), + ); + if (snapConfirmsStellarTrustline.length > 0) { + clearStellarTrustlineInactiveByAccount[accountId] = + snapConfirmsStellarTrustline; + } + // In case accountsAndAssetsToUpdate event is fired with "removed" assets that don't exist, we don't want to remove them const filteredToBeRemovedAssets = removed.filter( (asset) => existing.includes(asset) && isCaipAssetType(asset), @@ -499,19 +692,39 @@ export class MultichainAssetsController extends StaticIntervalPollingController< } } + const accountIdsToUpdate = new Set([ + ...Object.keys(accountsAndAssetsToUpdate), + ...Object.keys(clearStellarTrustlineInactiveByAccount), + ]); + this.update((state) => { - for (const [accountId, { added, removed }] of Object.entries( - accountsAndAssetsToUpdate, - )) { - const assets = new Set([ - ...(state.accountsAssets[accountId] || []), - ...added, - ]); - for (const asset of removed) { - assets.delete(asset); + for (const accountId of accountIdsToUpdate) { + const toMerge = accountsAndAssetsToUpdate[accountId]; + if (toMerge) { + const { added, removed: removedAssets } = toMerge; + const assets = new Set([ + ...(state.accountsAssets[accountId] || []), + ...added, + ]); + for (const asset of removedAssets) { + assets.delete(asset); + } + + state.accountsAssets[accountId] = Array.from(assets); } - state.accountsAssets[accountId] = Array.from(assets); + this.#removeStellarTrustlineInactiveAssetIds( + state, + accountId, + clearStellarTrustlineInactiveByAccount[accountId] ?? [], + ); + if (toMerge) { + this.#removeStellarTrustlineInactiveAssetIds( + state, + accountId, + toMerge.removed, + ); + } } }); @@ -565,6 +778,7 @@ export class MultichainAssetsController extends StaticIntervalPollingController< await this.#refreshAssetsMetadata(assets); this.update((state) => { state.accountsAssets[account.id] = assets; + delete state.stellarClassicTrustlineInactiveAssetIds[account.id]; }); this.messenger.publish(`${controllerName}:accountAssetListUpdated`, { assets: { @@ -590,6 +804,9 @@ export class MultichainAssetsController extends StaticIntervalPollingController< if (state.allIgnoredAssets[accountId]) { delete state.allIgnoredAssets[accountId]; } + if (state.stellarClassicTrustlineInactiveAssetIds[accountId]) { + delete state.stellarClassicTrustlineInactiveAssetIds[accountId]; + } // TODO: We are not deleting the assetsMetadata because we will soon make this controller extends StaticIntervalPollingController // and update all assetsMetadata once a day. }); @@ -742,7 +959,7 @@ export class MultichainAssetsController extends StaticIntervalPollingController< snapId: string, ): Promise { try { - return (await this.messenger.call('SnapController:handleRequest', { + const response = (await this.messenger.call('SnapController:handleRequest', { snapId: snapId as SnapId, origin: 'metamask', handler: HandlerType.OnAssetsLookup, @@ -753,7 +970,8 @@ export class MultichainAssetsController extends StaticIntervalPollingController< assets, }, }, - })) as Promise; + })) as AssetMetadataResponse; + return response; } catch (error) { // Ignore console.error(error); diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/constant.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/constant.ts index 2fef0e8155..e0610f49f3 100644 --- a/packages/assets-controllers/src/MultichainAssetsRatesController/constant.ts +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/constant.ts @@ -26,7 +26,7 @@ export const MAP_CAIP_CURRENCIES: { // XRP mainnet xrp: 'xrpl:mainnet/slip44:144', - // Stellar Lumens mainnet + // Stellar Lumens (spot/fiat map uses pubnet; per-chain testnet balances use stellar:testnet/slip44:148 when wired) xlm: 'stellar:pubnet/slip44:148', // Chainlink (ERC20 on Ethereum mainnet) diff --git a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts index 84fec30388..fb0a15208d 100644 --- a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts +++ b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts @@ -158,6 +158,7 @@ function getRestrictedMessenger( 'AccountsController:accountRemoved', 'AccountsController:accountBalancesUpdated', 'MultichainAssetsController:accountAssetListUpdated', + 'KeyringController:stateChanged', ], }); return multichainBalancesControllerMessenger; diff --git a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts index a8c58ed4a6..626051e35f 100644 --- a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts +++ b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts @@ -9,6 +9,7 @@ import type { StateMetadata, ControllerGetStateAction, ControllerStateChangeEvent, + ControllerStateChangedEvent, } from '@metamask/base-controller'; import { isEvmAccountType } from '@metamask/keyring-api'; import type { @@ -16,7 +17,10 @@ import type { CaipAssetType, AccountBalancesUpdatedEventPayload, } from '@metamask/keyring-api'; -import type { KeyringControllerGetStateAction } from '@metamask/keyring-controller'; +import type { + KeyringControllerGetStateAction, + KeyringControllerState, +} from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { KeyringClient } from '@metamask/keyring-snap-client'; import type { Messenger } from '@metamask/messenger'; @@ -105,7 +109,8 @@ type AllowedEvents = | AccountsControllerAccountAddedEvent | AccountsControllerAccountRemovedEvent | AccountsControllerAccountBalancesUpdatesEvent - | MultichainAssetsControllerAccountAssetListUpdatedEvent; + | MultichainAssetsControllerAccountAssetListUpdatedEvent + | ControllerStateChangedEvent<'KeyringController', KeyringControllerState>; /** * Messenger type for the MultichainBalancesController. */ @@ -190,6 +195,47 @@ export class MultichainBalancesController extends BaseController< await this.#handleOnAccountAssetListUpdated(updatedAccountAssets); }, ); + + // When the keyring transitions from locked → unlocked, fetch balances for + // any non-EVM account that had its balance fetch skipped while locked. + // We cannot read KeyringController state in the constructor (restricted), + // so the first `stateChanged` establishes the baseline; if the vault is + // already unlocked on that first event, we refetch once (covers unlock as + // the only keyring update after construction). + let previousKeyringIsUnlocked: boolean | undefined; + this.messenger.subscribe( + 'KeyringController:stateChanged', + (keyringState: KeyringControllerState) => { + const { isUnlocked } = keyringState; + if (previousKeyringIsUnlocked === undefined) { + previousKeyringIsUnlocked = isUnlocked; + if (isUnlocked) { + this.#refetchBalancesForAccountsMissingFromState(); + } + return; + } + const justUnlocked = isUnlocked && !previousKeyringIsUnlocked; + previousKeyringIsUnlocked = isUnlocked; + if (justUnlocked) { + this.#refetchBalancesForAccountsMissingFromState(); + } + }, + ); + } + + /** + * Fetches balances for non-EVM accounts that have no cached balances yet. + */ + #refetchBalancesForAccountsMissingFromState(): void { + for (const account of this.#listAccounts()) { + const hasBalance = + this.state.balances[account.id] && + Object.keys(this.state.balances[account.id]).length > 0; + if (!hasBalance) { + // eslint-disable-next-line no-void + void this.updateBalance(account.id); + } + } } /** @@ -392,7 +438,9 @@ export class MultichainBalancesController extends BaseController< this.update((state: Draft) => { Object.entries(balanceUpdate.balances).forEach( ([accountId, assetBalances]) => { - if (accountId in state.balances) { + if ( + Object.prototype.hasOwnProperty.call(state.balances, accountId) + ) { Object.assign(state.balances[accountId], assetBalances); } }, @@ -406,7 +454,9 @@ export class MultichainBalancesController extends BaseController< * @param accountId - The account ID being removed. */ async #handleOnAccountRemoved(accountId: string): Promise { - if (accountId in this.state.balances) { + if ( + Object.prototype.hasOwnProperty.call(this.state.balances, accountId) + ) { this.update((state: Draft) => { delete state.balances[accountId]; }); diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 8bc2c17247..55ec907a8f 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -217,6 +217,11 @@ export type { MultichainAssetsControllerAccountAssetListUpdatedEvent, MultichainAssetsControllerMessenger, } from './MultichainAssetsController'; +export { + isStellarCaipChain, + isStellarClassicAssetCaip19, + isStellarTrustlineTrackedAsset, +} from './multichain/stellarTrustline'; export type { MultichainAssetsControllerGetAssetMetadataAction, MultichainAssetsControllerIgnoreAssetsAction, diff --git a/packages/assets-controllers/src/multichain/stellarTrustline.test.ts b/packages/assets-controllers/src/multichain/stellarTrustline.test.ts new file mode 100644 index 0000000000..84a3b73776 --- /dev/null +++ b/packages/assets-controllers/src/multichain/stellarTrustline.test.ts @@ -0,0 +1,69 @@ +import type { CaipAssetType, CaipChainId } from '@metamask/utils'; + +import { + isStellarCaipChain, + isStellarClassicAssetCaip19, + isStellarTrustlineTrackedAsset, +} from './stellarTrustline'; + +describe('stellarTrustline', () => { + describe('isStellarCaipChain', () => { + it('returns true for Stellar pubnet CAIP-2 id', () => { + expect(isStellarCaipChain('stellar:pubnet' as CaipChainId)).toBe(true); + }); + + it('returns false for Solana chain id', () => { + expect( + isStellarCaipChain( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId, + ), + ).toBe(false); + }); + }); + + describe('isStellarClassicAssetCaip19', () => { + const stellarClassicAsset = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as CaipAssetType; + + it('returns true for Stellar classic asset id', () => { + expect(isStellarClassicAssetCaip19(stellarClassicAsset)).toBe(true); + }); + + it('returns false for Stellar native slip44', () => { + const native = 'stellar:pubnet/slip44:148' as CaipAssetType; + expect(isStellarClassicAssetCaip19(native)).toBe(false); + }); + + it('returns false for Stellar sep41 (Soroban) asset', () => { + const sep41 = + 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as CaipAssetType; + expect(isStellarClassicAssetCaip19(sep41)).toBe(false); + }); + }); + + describe('isStellarTrustlineTrackedAsset', () => { + const stellarClassicAsset = + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as CaipAssetType; + + it('returns true for Stellar classic asset', () => { + expect(isStellarTrustlineTrackedAsset(stellarClassicAsset)).toBe(true); + }); + + it('returns false for Stellar native slip44', () => { + const native = 'stellar:pubnet/slip44:148' as CaipAssetType; + expect(isStellarTrustlineTrackedAsset(native)).toBe(false); + }); + + it('returns false for Stellar sep41 (Soroban) asset', () => { + const sep41 = + 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as CaipAssetType; + expect(isStellarTrustlineTrackedAsset(sep41)).toBe(false); + }); + + it('returns false for Solana SPL token', () => { + const spl = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN' as CaipAssetType; + expect(isStellarTrustlineTrackedAsset(spl)).toBe(false); + }); + }); +}); diff --git a/packages/assets-controllers/src/multichain/stellarTrustline.ts b/packages/assets-controllers/src/multichain/stellarTrustline.ts new file mode 100644 index 0000000000..0481109255 --- /dev/null +++ b/packages/assets-controllers/src/multichain/stellarTrustline.ts @@ -0,0 +1,48 @@ +import type { CaipAssetType, CaipChainId } from '@metamask/utils'; +import { KnownCaipNamespace, parseCaipAssetType, parseCaipChainId } from '@metamask/utils'; + +/** + * Returns true when the CAIP-2 chain id uses the Stellar namespace. + * + * @param chainId - CAIP-2 chain identifier. + * @returns Whether this is a Stellar chain. + */ +export function isStellarCaipChain(chainId: CaipChainId): boolean { + try { + return parseCaipChainId(chainId).namespace === KnownCaipNamespace.Stellar; + } catch { + return false; + } +} + +/** + * Returns true for Stellar classic (non-Soroban) fungible assets: `asset:CODE-ISSUER`. + * Excludes native XLM (`slip44`), Soroban SEP-41 (`sep41:`), and other namespaces. + * + * @param caipAssetType - CAIP-19 asset type identifier. + * @returns Whether this is a Stellar classic asset id. + */ +export function isStellarClassicAssetCaip19(caipAssetType: CaipAssetType): boolean { + try { + const parsed = parseCaipAssetType(caipAssetType); + if (!isStellarCaipChain(parsed.chainId)) { + return false; + } + return parsed.assetNamespace === 'asset'; + } catch { + return false; + } +} + +/** + * Stellar trust lines apply to classic (non-Soroban) assets only. Native XLM uses + * slip44. Soroban tokens use `sep41:` and do not use trust-line semantics in this UI. + * + * @param caipAssetType - CAIP-19 asset type identifier. + * @returns Whether trust-line active/inactive should be tracked for this asset. + */ +export function isStellarTrustlineTrackedAsset( + caipAssetType: CaipAssetType, +): boolean { + return isStellarClassicAssetCaip19(caipAssetType); +} diff --git a/packages/assets-controllers/src/selectors/token-selectors.test.ts b/packages/assets-controllers/src/selectors/token-selectors.test.ts index ccbac2f2d2..8b0e4372b4 100644 --- a/packages/assets-controllers/src/selectors/token-selectors.test.ts +++ b/packages/assets-controllers/src/selectors/token-selectors.test.ts @@ -278,6 +278,8 @@ const mockMultichainAssetsControllerState: MultichainAssetsControllerState = { }, }, allIgnoredAssets: {}, + stellarClassicTrustlineInactiveAssetIds: {}, + stellarTrustlineInactiveBackfillComplete: true, }; const mockAccountTreeControllerState = { diff --git a/packages/assets-controllers/src/selectors/token-selectors.ts b/packages/assets-controllers/src/selectors/token-selectors.ts index 4b6e7dc2b7..d82e89d6b9 100644 --- a/packages/assets-controllers/src/selectors/token-selectors.ts +++ b/packages/assets-controllers/src/selectors/token-selectors.ts @@ -89,6 +89,8 @@ export type Asset = ( conversionRate: number; } | undefined; + /** Stellar classic `asset:` row added via import only (no keyring confirmation yet). */ + isStellarTrustlineInactive?: boolean; rwaData?: TokenRwaData; }; @@ -103,6 +105,7 @@ export type AssetListState = { currencyRates: CurrencyRateState['currencyRates']; accountsAssets: MultichainAssetsControllerState['accountsAssets']; allIgnoredAssets: MultichainAssetsControllerState['allIgnoredAssets']; + stellarClassicTrustlineInactiveAssetIds: MultichainAssetsControllerState['stellarClassicTrustlineInactiveAssetIds']; assetsMetadata: MultichainAssetsControllerState['assetsMetadata']; balances: MultichainBalancesControllerState['balances']; conversionRates: MultichainAssetsRatesControllerState['conversionRates']; @@ -356,6 +359,7 @@ const selectAllMultichainAssets = createAssetListSelector( selectAccountsToGroupIdMap, (state) => state.accountsAssets, (state) => state.allIgnoredAssets, + (state) => state.stellarClassicTrustlineInactiveAssetIds, (state) => state.assetsMetadata, (state) => state.balances, (state) => state.conversionRates, @@ -365,6 +369,7 @@ const selectAllMultichainAssets = createAssetListSelector( accountsMap, multichainTokens, ignoredMultichainAssets, + stellarTrustlineInactiveByAccount, multichainAssetsMetadata, multichainBalances, multichainConversionRates, @@ -430,6 +435,10 @@ const selectAllMultichainAssets = createAssetListSelector( assetId, ); + const isStellarTrustlineInactive = ( + stellarTrustlineInactiveByAccount[accountId] ?? [] + ).includes(assetId); + // TODO: We shouldn't have to rely on fallbacks for name and symbol, they should not be optional groupChainAssets.push({ accountType: type as MultichainAccountType, @@ -450,6 +459,7 @@ const selectAllMultichainAssets = createAssetListSelector( } : undefined, chainId, + ...(isStellarTrustlineInactive && { isStellarTrustlineInactive: true }), }); } }