diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 413103a3d..bdc5853f0 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Read asset metadata from AssetsController when feature flag `enableAssetMigration` and local flag `enableAssetMigration` is on ([#493](https://github.com/MetaMask/internal-snaps/pull/493)) + - `enableAssetMigration` set to off. + ## [1.3.0] ### Added diff --git a/packages/stellar-wallet-snap/package.json b/packages/stellar-wallet-snap/package.json index 2706e39b1..5e0c1f54a 100644 --- a/packages/stellar-wallet-snap/package.json +++ b/packages/stellar-wallet-snap/package.json @@ -53,6 +53,8 @@ "@metamask/key-tree": "^10.1.1", "@metamask/keyring-api": "^24.1.0", "@metamask/keyring-snap-sdk": "^10.0.0", + "@metamask/messenger": "^2.0.0", + "@metamask/remote-feature-flag-controller": "^7.0.0", "@metamask/snap-networks-utils": "^3.0.0", "@metamask/snaps-cli": "^8.4.1", "@metamask/snaps-jest": "^10.2.1", diff --git a/packages/stellar-wallet-snap/snap.manifest.json b/packages/stellar-wallet-snap/snap.manifest.json index 2ab203101..7921e7c12 100644 --- a/packages/stellar-wallet-snap/snap.manifest.json +++ b/packages/stellar-wallet-snap/snap.manifest.json @@ -61,6 +61,15 @@ }, "endowment:assets": { "scopes": ["stellar:pubnet"] + }, + "endowment:messenger": { + "actions": [ + "RemoteFeatureFlagController:getState", + "AssetsController:getAccountAssetByID", + "AssetsController:getAccountAssetsByIDs", + "AssetsController:getAccountAssetsByScope", + "AssetsController:getAssetMetadata" + ] } }, "platformVersion": "12.0.1", diff --git a/packages/stellar-wallet-snap/src/api/core-messenger.ts b/packages/stellar-wallet-snap/src/api/core-messenger.ts new file mode 100644 index 000000000..38a612c95 --- /dev/null +++ b/packages/stellar-wallet-snap/src/api/core-messenger.ts @@ -0,0 +1,41 @@ +import type { + AssetsControllerGetAccountAssetByIDAction, + AssetsControllerGetAccountAssetsByIDsAction, + AssetsControllerGetAccountAssetsByScopeAction, + AssetsControllerGetAssetMetadataAction, +} from '@metamask/assets-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { AsyncMessenger } from '@metamask/snaps-sdk'; + +/** + * Namespace for this Snap's Core messenger endowment. + */ +export const STELLAR_WALLET_SNAP_MESSENGER_NAMESPACE = + 'StellarWalletSnap' as const; + +export type CoreMessengerActions = + | RemoteFeatureFlagControllerGetStateAction + | AssetsControllerGetAccountAssetByIDAction + | AssetsControllerGetAccountAssetsByIDsAction + | AssetsControllerGetAccountAssetsByScopeAction + | AssetsControllerGetAssetMetadataAction; + +/** + * Messenger type passed to `getMessenger` for Core controller actions. + */ +export type CoreMessenger = Messenger< + typeof STELLAR_WALLET_SNAP_MESSENGER_NAMESPACE, + CoreMessengerActions +>; + +/** + * Async messenger returned by `getMessenger` for Core controller actions + * available to this Snap via `endowment:messenger`. + */ +export type CoreMessengerClient = AsyncMessenger; + +/** + * Narrow dependency for services that only need to invoke Core actions. + */ +export type CoreMessengerCaller = Pick; diff --git a/packages/stellar-wallet-snap/src/api/integer.ts b/packages/stellar-wallet-snap/src/api/integer.ts index b01eaea19..d77e96f03 100644 --- a/packages/stellar-wallet-snap/src/api/integer.ts +++ b/packages/stellar-wallet-snap/src/api/integer.ts @@ -44,7 +44,7 @@ export const ValidStellarAmountStruct = refine( if ( (decimalPlaces && decimalPlaces > STELLAR_DECIMAL_PLACES) || // > Max value - toSmallestUnit(amount).gt(new BigNumber(MAX_INT64).toString()) + toSmallestUnit(amount).gt(MAX_INT64) ) { return 'Invalid amount'; } diff --git a/packages/stellar-wallet-snap/src/config.test.ts b/packages/stellar-wallet-snap/src/config.test.ts index 0922ad15a..1236badde 100644 --- a/packages/stellar-wallet-snap/src/config.test.ts +++ b/packages/stellar-wallet-snap/src/config.test.ts @@ -24,6 +24,7 @@ const FULL_ENVIRONMENT = { }, }, selectedNetwork: KnownCaip2ChainId.Mainnet, + enableAssetMigration: false, transaction: { timeout: '120', pollingAttempts: '5', @@ -125,6 +126,7 @@ describe('ConfigProvider', () => { }, }); expect(config.selectedNetwork).toBe(KnownCaip2ChainId.Mainnet); + expect(config.enableAssetMigration).toBe(false); }); it.each([ diff --git a/packages/stellar-wallet-snap/src/config.ts b/packages/stellar-wallet-snap/src/config.ts index 9384ec65f..16b57489b 100644 --- a/packages/stellar-wallet-snap/src/config.ts +++ b/packages/stellar-wallet-snap/src/config.ts @@ -6,7 +6,7 @@ import { UrlStruct, } from '@metamask/snap-networks-utils'; import type { Infer } from '@metamask/superstruct'; -import { enums, object } from '@metamask/superstruct'; +import { boolean, enums, object } from '@metamask/superstruct'; /* eslint-disable no-restricted-globals */ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ @@ -49,6 +49,7 @@ export const ConfigStruct = object({ [KnownCaip2ChainId.Mainnet]: networkConfigStruct, [KnownCaip2ChainId.Testnet]: networkConfigStruct, }), + enableAssetMigration: boolean(), selectedNetwork: enums(getSupportedScopes()), transaction: object({ timeout: parseIntegerStruct(100, 180), @@ -147,6 +148,7 @@ const ENVIRONMENT = { }, }, selectedNetwork: KnownCaip2ChainId.Mainnet, + enableAssetMigration: false, transaction: { timeout: process.env.STELLAR_TRANSACTION_TIMEOUT, pollingAttempts: process.env.STELLAR_TRANSACTION_POLLING_ATTEMPTS, diff --git a/packages/stellar-wallet-snap/src/context.ts b/packages/stellar-wallet-snap/src/context.ts index 6bb167a3a..c4114d68c 100644 --- a/packages/stellar-wallet-snap/src/context.ts +++ b/packages/stellar-wallet-snap/src/context.ts @@ -1,10 +1,18 @@ import { AnalyticsService, State, + AssetsProvider, + RemoteFeatureFlagsProvider, InMemoryCache, } from '@metamask/snap-networks-utils'; +import type { + AssetsProviderMessenger, + RemoteFeatureFlagsProviderMessenger, +} from '@metamask/snap-networks-utils'; +import { getMessenger } from '@metamask/snaps-sdk'; import { assert, object } from '@metamask/superstruct'; +import type { CoreMessenger } from './api/core-messenger'; import { AppConfig } from './config'; import { KeyringHandler, CronjobHandler, UserInputHandler } from './handlers'; import { AccountResolver } from './handlers/accountResolver'; @@ -44,6 +52,7 @@ import { AssetMetadataRepository, AssetMetadataService, } from './services/asset-metadata'; +import { AssetsService, CoreAssetsAdapter } from './services/assets'; import { NetworkService } from './services/network'; import { OnChainAccountRepository, @@ -86,10 +95,31 @@ const analyticsService = new AnalyticsService({ const appCache = new InMemoryCache(noOpLogger); const networkService = new NetworkService({ logger, cache: appCache }); +const coreMessenger = getMessenger(); +const remoteFeatureFlagsProvider = new RemoteFeatureFlagsProvider({ + messenger: coreMessenger as RemoteFeatureFlagsProviderMessenger, +}); +const assetsProvider = new AssetsProvider({ + messenger: coreMessenger as AssetsProviderMessenger, +}); +const coreAssetsAdapter = new CoreAssetsAdapter({ + logger, + getAccountAssetByID: assetsProvider.getAccountAssetByID.bind(assetsProvider), + getAccountAssetsByIDs: + assetsProvider.getAccountAssetsByIDs.bind(assetsProvider), + getAccountAssetsByScope: + assetsProvider.getAccountAssetsByScope.bind(assetsProvider), + getAssetMetadata: assetsProvider.getAssetMetadata.bind(assetsProvider), +}); +const assetsService = new AssetsService({ + coreAdapter: coreAssetsAdapter, + remoteFeatureFlagsProvider, +}); const assetMetadataService = new AssetMetadataService({ networkService, assetMetadataRepository, logger, + assetsService, }); const transactionBuilder = new TransactionBuilder(); diff --git a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.test.ts b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.test.ts index 074e644e5..c4f946d2a 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.test.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.test.ts @@ -3,12 +3,15 @@ import { buildUrl } from '@metamask/snap-networks-utils'; import { AssetType, KnownCaip2ChainId } from '../../api'; import type { KnownCaip19AssetId } from '../../api'; import { getSlip44AssetId, logger } from '../../utils'; +import { createMockAssetsService } from '../assets/__mocks__/assetsService.fixtures'; import type { NetworkService } from '../network'; +import { USDC_SEP41 } from './__mocks__/assets.fixtures'; import type { StellarAssetMetadata } from './api'; import type { AssetMetadataRepository } from './AssetMetadataRepository'; import { AssetMetadataService } from './AssetMetadataService'; import type { TokenMetadata } from './token-api/api'; import { TokenApiClient } from './token-api/TokenApiClient'; +import { toStellarAssetMetadata } from './utils'; /** Mainnet classic USDC (matches CAIP-19 pattern used across Stellar fixtures). */ const MAINNET_CLASSIC_USDC = @@ -88,14 +91,17 @@ function createService(deps: { getAssetsByChainId: mockGetAssetsByChainId, })); + const { service: assetsService } = createMockAssetsService(); const service = new AssetMetadataService({ networkService: network, assetMetadataRepository: repo, logger, + assetsService, }); return { service, + assetsService, getByAssetIds: repo.getByAssetIds as jest.MockedFunction< AssetMetadataRepository['getByAssetIds'] >, @@ -128,7 +134,7 @@ describe('AssetMetadataService', () => { const result = await service.resolve(slipId); expect(result.assetId).toBe(slipId); - expect(getByAssetIds).toHaveBeenCalledWith([]); + expect(getByAssetIds).not.toHaveBeenCalled(); expect(mockGetAssetsByAssetIds).not.toHaveBeenCalled(); }); @@ -160,20 +166,58 @@ describe('AssetMetadataService', () => { it('returns cached mainnet classic asset without calling token API', async () => { const classicId = MAINNET_CLASSIC_USDC; const cached = createCachedRow(classicId, KnownCaip2ChainId.Mainnet); - const { service, getByAssetIds, saveMany } = createService({ + const { service, assetsService, getByAssetIds, saveMany } = createService({ repo: { getByAssetIds: jest.fn().mockResolvedValue([cached]), }, }); + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(false); + const getAssetMetadata = jest + .spyOn(assetsService, 'getAssetMetadata') + .mockResolvedValue({ + symbol: 'USDC', + decimals: 7, + name: 'USD Coin', + }); const result = await service.resolve(classicId); expect(result).toStrictEqual(cached); + expect(getAssetMetadata).not.toHaveBeenCalled(); expect(getByAssetIds).toHaveBeenCalledWith([classicId]); expect(mockGetAssetsByAssetIds).not.toHaveBeenCalled(); expect(saveMany).not.toHaveBeenCalled(); }); + it('resolves requested SEP-41 metadata and persists it', async () => { + const tokenRow: TokenMetadata = { + assetId: USDC_SEP41, + decimals: 7, + name: 'USDC', + symbol: 'USDC', + }; + const { service, saveMany } = createService({}); + mockGetAssetsByAssetIds.mockResolvedValueOnce([tokenRow]); + + const result = await service.fetchAssetsByAssetIds([USDC_SEP41]); + + expect(result).toStrictEqual([ + expect.objectContaining({ + assetId: USDC_SEP41, + name: 'USDC', + symbol: 'USDC', + assetType: AssetType.Sep41, + }), + ]); + expect(mockGetAssetsByAssetIds).toHaveBeenCalledWith([USDC_SEP41]); + expect(saveMany).toHaveBeenCalledWith([ + expect.objectContaining({ + assetId: USDC_SEP41, + symbol: 'USDC', + }), + ]); + }); + it('delegates getPersistedSep41AssetsMetadata to repository', async () => { const sepRows: StellarAssetMetadata[] = []; const getByAssetType = jest.fn().mockResolvedValue(sepRows); @@ -362,4 +406,90 @@ describe('AssetMetadataService', () => { expect(result).toBe(catalog); }); }); + + it('persists catalog during synchronize when assets migration is on', async () => { + const sep41AssetId = + 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as KnownCaip19AssetId; + const { service, assetsService, saveMany } = createService({}); + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); + mockGetAssetsByChainId.mockResolvedValueOnce({ + data: [ + { + assetId: sep41AssetId, + decimals: 7, + name: 'Token A', + symbol: 'TA', + }, + ], + count: 1, + totalCount: 1, + }); + + await service.synchronize(KnownCaip2ChainId.Mainnet); + + expect(mockGetAssetsByChainId).toHaveBeenCalledWith( + KnownCaip2ChainId.Mainnet, + ); + expect(saveMany).toHaveBeenCalledWith( + [ + expect.objectContaining({ + assetId: sep41AssetId, + name: 'Token A', + symbol: 'TA', + }), + ], + true, + ); + }); + + it('maps AssetsController catalog metadata into StellarAssetMetadata without reading snap state', async () => { + const classicId = MAINNET_CLASSIC_USDC; + const { service, assetsService, getByAssetIds, saveMany } = createService( + {}, + ); + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); + const getAssetMetadata = jest + .spyOn(assetsService, 'getAssetMetadata') + .mockResolvedValue({ + symbol: 'USDC', + decimals: 7, + name: 'USD Coin', + }); + + const result = await service.resolve(classicId); + + expect(result).toStrictEqual( + toStellarAssetMetadata({ + assetId: classicId, + decimals: 7, + symbol: 'USDC', + name: 'USD Coin', + }), + ); + expect(getAssetMetadata).toHaveBeenCalledWith(classicId); + expect(getByAssetIds).not.toHaveBeenCalled(); + expect(mockGetAssetsByAssetIds).not.toHaveBeenCalled(); + expect(saveMany).not.toHaveBeenCalled(); + }); + + it('falls back to snap catalog when AssetsController misses', async () => { + const classicId = MAINNET_CLASSIC_USDC; + const cached = createCachedRow(classicId, KnownCaip2ChainId.Mainnet); + const { service, assetsService, getByAssetIds } = createService({ + repo: { + getByAssetIds: jest.fn().mockResolvedValue([cached]), + }, + }); + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); + const getAssetMetadata = jest + .spyOn(assetsService, 'getAssetMetadata') + .mockResolvedValue(null); + + const result = await service.resolve(classicId); + + expect(result).toStrictEqual(cached); + expect(getAssetMetadata).toHaveBeenCalledWith(classicId); + expect(getByAssetIds).toHaveBeenCalledWith([classicId]); + expect(mockGetAssetsByAssetIds).not.toHaveBeenCalled(); + }); }); diff --git a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts index 293e448cf..f04d59d25 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -16,6 +16,7 @@ import { isClassicAssetId, isSep41Id, } from '../../utils'; +import type { AssetsService } from '../assets'; import type { AssetDataResponse, NetworkService } from '../network'; import type { AssetUnit, StellarAssetMetadata } from './api'; import type { AssetMetadataRepository } from './AssetMetadataRepository'; @@ -50,14 +51,18 @@ export class AssetMetadataService { readonly #logger: Logger; + readonly #assetsService: AssetsService; + constructor({ networkService, assetMetadataRepository, logger, + assetsService, }: { networkService: NetworkService; assetMetadataRepository: AssetMetadataRepository; logger: Logger; + assetsService: AssetsService; }) { this.#networkService = networkService; this.#tokenApiClient = new TokenApiClient({ @@ -65,6 +70,7 @@ export class AssetMetadataService { }); this.#assetMetadataRepository = assetMetadataRepository; this.#logger = logger.withPrefix('[🪙 AssetMetadataService]'); + this.#assetsService = assetsService; } /** @@ -86,6 +92,21 @@ export class AssetMetadataService { return found; } + /** + * Resolves SEP-41 metadata for ids that are not in the watched catalog. + * + * Rows already stored in AssetsController or snap state are reused. Newly fetched rows + * are persisted, same as {@link resolve}. + * + * @param assetIds - SEP-41 asset ids to resolve. + * @returns Metadata for the ids that could be resolved. Missing ids are omitted. + */ + async fetchAssetsByAssetIds( + assetIds: KnownCaip19Sep41AssetId[], + ): Promise { + return this.#fetchAndPersistAssetsByAssetIds(assetIds); + } + /** * Returns all persisted SEP-41 assets for the given chain ID. * @@ -173,8 +194,11 @@ export class AssetMetadataService { result.push(getNativeAssetMetadata(chainId)); } - // fetch assets from state + // persisted: AssetsController then snap when migration is on; snap only when off const allNonNativeAssetIds = [...assetsByChainId.values()].flat(); + if (allNonNativeAssetIds.length === 0) { + return result; + } const { assets, missingAssetIds } = await this.#getPersistedAssetMetadata(allNonNativeAssetIds); @@ -203,16 +227,88 @@ export class AssetMetadataService { return result.concat(assets, missingAssets); } + /** + * Resolves persisted metadata. AssetsController first when the Stellar assets + * migration flag is on, then snap state for remaining ids. When the flag is + * off, skip AssetsController and read snap state only. + * + * @param assetIds - Non-native CAIP-19 ids to look up. + * @returns Hits plus ids still missing after persisted lookups. + */ async #getPersistedAssetMetadata(assetIds: KnownCaip19AssetId[]): Promise<{ assets: StellarAssetMetadata[]; missingAssetIds: KnownCaip19AssetId[]; + }> { + if (await this.#assetsService.isMigrationEnabled()) { + const { + hits: assetsControllerHits, + missing: missingAssetIdsFromAssetsController, + } = await this.#getAssetsControllerAssetMetadata(assetIds); + + if (missingAssetIdsFromAssetsController.length === 0) { + return { assets: assetsControllerHits, missingAssetIds: [] }; + } + + const { hits: snapHits, missing: missingAssetIds } = + await this.#getSnapAssetMetadata(missingAssetIdsFromAssetsController); + + return { + assets: [...assetsControllerHits, ...snapHits], + missingAssetIds, + }; + } + + const { hits, missing } = await this.#getSnapAssetMetadata(assetIds); + return { assets: hits, missingAssetIds: missing }; + } + + async #getSnapAssetMetadata(assetIds: KnownCaip19AssetId[]): Promise<{ + hits: StellarAssetMetadata[]; + missing: KnownCaip19AssetId[]; }> { const cachedAssets = await this.#assetMetadataRepository.getByAssetIds(assetIds); - const { hits: assets, missing: missingAssetIds } = - this.#partitionHitsAndMissingByArray(assetIds, cachedAssets); + return this.#partitionHitsAndMissingByArray(assetIds, cachedAssets); + } - return { assets, missingAssetIds }; + async #getAssetsControllerAssetMetadata( + assetIds: KnownCaip19AssetId[], + ): Promise<{ + hits: StellarAssetMetadata[]; + missing: KnownCaip19AssetId[]; + }> { + const hits: StellarAssetMetadata[] = []; + const missing: KnownCaip19AssetId[] = []; + + this.#logger.debug('Getting asset metadata from AssetsController', { + assetIds, + }); + if (assetIds.length > 0) { + // The order doesn't matter here, as we are not using the order of the assets. + await Promise.all( + assetIds.map(async (assetId) => { + const metadata = await this.#assetsService.getAssetMetadata(assetId); + if (metadata) { + hits.push( + toStellarAssetMetadata({ + assetId, + decimals: metadata.decimals, + symbol: metadata.symbol, + name: metadata.name, + iconUrl: metadata.image, + }), + ); + return; + } + missing.push(assetId); + }), + ); + } + this.#logger.debug('Asset metadata from AssetsController', { + hits, + missing, + }); + return { hits, missing }; } async #fetchMissingAssetsMetadata( @@ -289,7 +385,9 @@ export class AssetMetadataService { if (missingAssetIds.length > 0) { this.#logger.warn( - `Failed to fetch token metadata for assets: ${Array.from(missingAssetIds).join(', ')}`, + `Failed to fetch token metadata for assets: ${Array.from( + missingAssetIds, + ).join(', ')}`, ); } @@ -320,7 +418,9 @@ export class AssetMetadataService { if (missingAssetIds.length > 0) { this.#logger.warn( - `Failed to fetch token metadata for assets: ${Array.from(missingAssetIds).join(', ')}`, + `Failed to fetch token metadata for assets: ${Array.from( + missingAssetIds, + ).join(', ')}`, ); } diff --git a/packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts b/packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts index eb8edc3cf..8c3d1f10a 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts @@ -9,6 +9,7 @@ import { AssetType, KnownCaip2ChainId } from '../../../api'; import { NATIVE_ASSET_NAME, NATIVE_ASSET_SYMBOL } from '../../../constants'; import { getSlip44AssetId } from '../../../utils/caip'; import { logger, noOpLogger } from '../../../utils/logger'; +import { createMockAssetsService } from '../../assets/__mocks__/assetsService.fixtures'; import { NetworkService } from '../../network'; import type { AssetMetadataByAssetId, StellarAssetMetadata } from '../api'; import { AssetMetadataRepository } from '../AssetMetadataRepository'; @@ -79,6 +80,7 @@ export const getMockSep41Assets = (): StellarAssetMetadata[] => { }; export const createMockAssetMetadataService = () => { + const { service: assetsService } = createMockAssetsService(); const service = new AssetMetadataService({ networkService: new NetworkService({ logger, @@ -90,6 +92,7 @@ export const createMockAssetMetadataService = () => { }), ), logger, + assetsService, }); const assetMetadataRepositorySaveManySpy = jest.spyOn( diff --git a/packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts b/packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts index 3a7394846..3dadad30b 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts @@ -44,6 +44,7 @@ export function getIconUrl(assetId: KnownCaip19AssetIdOrSlip44Id): string { * @param assetData.decimals - Smallest-unit decimal count for the primary unit. * @param assetData.symbol - Ticker or short symbol for display. * @param assetData.name - Optional long name; defaults to `symbol` when omitted. + * @param assetData.iconUrl - Optional icon URL; defaults to the icon URL from the token API. * @returns Cached metadata including icon URL and units. */ export function toStellarAssetMetadata(assetData: { @@ -51,6 +52,7 @@ export function toStellarAssetMetadata(assetData: { decimals: number; symbol: string; name?: string; + iconUrl?: string; }): StellarAssetMetadata { const name = assetData.name ?? assetData.symbol; const { assetNamespace, chainId } = parseCaipAssetType(assetData.assetId); @@ -62,7 +64,7 @@ export function toStellarAssetMetadata(assetData: { chainId: chainId as KnownCaip2ChainId, assetType: assetNamespace as AssetType, fungible: true, - iconUrl: getIconUrl(assetData.assetId), + iconUrl: assetData.iconUrl ?? getIconUrl(assetData.assetId), units: [ { name, diff --git a/packages/stellar-wallet-snap/src/services/assets/AssetsService.test.ts b/packages/stellar-wallet-snap/src/services/assets/AssetsService.test.ts index c5b80da18..075c801a1 100644 --- a/packages/stellar-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/stellar-wallet-snap/src/services/assets/AssetsService.test.ts @@ -5,6 +5,7 @@ import { } from '@metamask/assets-controller'; import { KnownCaip19AssetIdOrSlip44Id, KnownCaip2ChainId } from '../../api'; +import { AppConfig } from '../../config'; import { MAX_INT64 } from '../../constants'; import { getSlip44AssetId } from '../../utils'; import { @@ -107,7 +108,24 @@ describe('AssetsService', () => { }; describe('isMigrationEnabled', () => { + const originalEnableAssetMigration = AppConfig.enableAssetMigration; + + afterEach(() => { + AppConfig.enableAssetMigration = originalEnableAssetMigration; + }); + + it('returns false without reading the feature flag when asset migration is disabled', async () => { + AppConfig.enableAssetMigration = false; + const { service, remoteFeatureFlagsProvider } = createMockAssetsService({ + migrationStage: SnapsAssetsMigrationStage.ReadAssetsControllerOnly, + }); + + expect(await service.isMigrationEnabled()).toBe(false); + expect(remoteFeatureFlagsProvider.getFeatureFlag).not.toHaveBeenCalled(); + }); + it('returns false when the Stellar flag is Off', async () => { + AppConfig.enableAssetMigration = true; const { service, remoteFeatureFlagsProvider } = createMockAssetsService(); expect(await service.isMigrationEnabled()).toBe(false); @@ -116,7 +134,8 @@ describe('AssetsService', () => { ); }); - it('returns true when the Stellar flag is active', async () => { + it('returns true when asset migration is enabled and the Stellar flag is active', async () => { + AppConfig.enableAssetMigration = true; const { service } = createMockAssetsService({ migrationStage: SnapsAssetsMigrationStage.ReadAssetsControllerOnly, }); diff --git a/packages/stellar-wallet-snap/src/services/assets/AssetsService.ts b/packages/stellar-wallet-snap/src/services/assets/AssetsService.ts index 4770ee755..6eb959ec5 100644 --- a/packages/stellar-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/stellar-wallet-snap/src/services/assets/AssetsService.ts @@ -9,6 +9,7 @@ import type { KnownCaip19AssetIdOrSlip44Id, KnownCaip2ChainId, } from '../../api'; +import { AppConfig } from '../../config'; import type { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; import { parseCoreStellarAsset, parseCoreAssetMetadata } from './api'; import type { CoreAsset, CoreAssetMetadata } from './api'; @@ -33,14 +34,19 @@ export class AssetsService { } /** - * Returns whether Stellar should read assets from - * AssetsController instead of snap state. + * Returns whether Stellar should read assets from AssetsController + * instead of snap state. * - * Any migration stage other than Off enables Core reads. + * `AppConfig.enableAssetMigration` stays false until the migration is ready + * to ship, so this returns false without reading the feature flag. When that + * config is true, any migration stage other than Off enables the read. * - * @returns Whether the Stellar assets migration flag is active. + * @returns Whether AssetsController reads are active. */ async isMigrationEnabled(): Promise { + if (!AppConfig.enableAssetMigration) { + return false; + } const flagValue = await this.#remoteFeatureFlagsProvider.getFeatureFlag( SNAPS_ASSETS_MIGRATION_FLAG_KEYS.stellar, ); diff --git a/packages/stellar-wallet-snap/src/services/assets/api.test.ts b/packages/stellar-wallet-snap/src/services/assets/api.test.ts index f428c6b52..36ed311f7 100644 --- a/packages/stellar-wallet-snap/src/services/assets/api.test.ts +++ b/packages/stellar-wallet-snap/src/services/assets/api.test.ts @@ -159,6 +159,53 @@ describe('parseCoreStellarAsset', () => { }); }); + it('scales a display trustline limit to stroops and leaves an integer limit unchanged', () => { + expect( + parseCoreStellarAsset({ + ...classicAsset, + balance: { + ...classicAsset.balance, + metadata: { + ...classicAsset.balance.metadata, + limit: '922337203685.4775807', + }, + }, + }), + ).toMatchObject({ + balance: { metadata: { limit: MAX_INT64 } }, + }); + + expect( + parseCoreStellarAsset({ + ...classicAsset, + balance: { + ...classicAsset.balance, + metadata: { + ...classicAsset.balance.metadata, + limit: '100000000', + }, + }, + }), + ).toMatchObject({ + balance: { metadata: { limit: '100000000' } }, + }); + + expect( + parseCoreStellarAsset({ + ...classicAsset, + balance: { + ...classicAsset.balance, + metadata: { + ...classicAsset.balance.metadata, + limit: '10.5', + }, + }, + }), + ).toMatchObject({ + balance: { metadata: { limit: '105000000' } }, + }); + }); + it('forces sponsored true when sponsor is a non-empty string', () => { const sponsor = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; expect( diff --git a/packages/stellar-wallet-snap/src/services/assets/api.ts b/packages/stellar-wallet-snap/src/services/assets/api.ts index 037d2b081..fa9d4ca0b 100644 --- a/packages/stellar-wallet-snap/src/services/assets/api.ts +++ b/packages/stellar-wallet-snap/src/services/assets/api.ts @@ -22,6 +22,8 @@ import { ValidStellarAmountStruct, ValidStellarInt64Struct, } from '../../api'; +import { STELLAR_DECIMAL_PLACES } from '../../constants'; +import { hasDecimals, toSmallestUnit } from '../../utils'; import { InvalidCoreAssetException } from './exceptions'; /** @@ -145,6 +147,23 @@ function isZeroTrustlineLimit(limit: string): boolean { return amount.isFinite() && amount.isZero(); } +/** + * Convert a trustline limit to a stroop string. + * + * - If it has a fractional part, it is scaled to stroops. + * - If it is an integer, it is returned unchanged. + * + * @param limit - Trustline limit. + * @returns Integer stroop string. + */ +function normalizeCoreTrustlineLimit(limit: string): string { + const amount = new BigNumber(limit); + if (!amount.isFinite() || !hasDecimals(amount)) { + return limit; + } + return toSmallestUnit(amount, STELLAR_DECIMAL_PLACES).toFixed(0); +} + /** * Whether classic trustline reserve is sponsored. * A non-empty sponsor on a removed trustline (limit 0) is stale and does not count. @@ -182,7 +201,9 @@ export function parseCoreStellarAsset(value: unknown): CoreAsset | null { throw new Error('Asset ID does not match chain ID'); } if (isCoreClassicAsset(asset)) { - const { sponsor, limit } = asset.balance.metadata; + const { sponsor } = asset.balance.metadata; + const limit = normalizeCoreTrustlineLimit(asset.balance.metadata.limit); + asset.balance.metadata.limit = limit; if (isZeroTrustlineLimit(limit)) { // A removed trustline must not keep the previous sponsor account id. asset.balance.metadata.sponsor = null; @@ -195,6 +216,7 @@ export function parseCoreStellarAsset(value: unknown): CoreAsset | null { `${value.id} is not a valid Stellar asset`, { cause: error, + data: value, }, ); } diff --git a/yarn.lock b/yarn.lock index e8b0b7d73..9c825e7da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3909,6 +3909,8 @@ __metadata: "@metamask/key-tree": "npm:^10.1.1" "@metamask/keyring-api": "npm:^24.1.0" "@metamask/keyring-snap-sdk": "npm:^10.0.0" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/remote-feature-flag-controller": "npm:^7.0.0" "@metamask/snap-networks-utils": "npm:^3.0.0" "@metamask/snaps-cli": "npm:^8.4.1" "@metamask/snaps-jest": "npm:^10.2.1"