From 0ae6ba5f57e3951173b9e57e7e6b174a4ee186d7 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:03:44 +0800 Subject: [PATCH 1/5] chore(stellar): tighten chain id --- .../src/handlers/clientRequest/api.ts | 11 ++++---- .../clientRequest/signAndSendTransaction.ts | 6 ++--- .../src/services/asset-metadata/utils.ts | 13 ++++----- .../src/services/network/NetworkService.ts | 10 +++---- .../transaction/TransactionBuilder.ts | 4 +-- .../src/services/transaction/utils.ts | 7 ++--- .../ConfirmSignChangeTrustOptIn.tsx | 5 ++-- .../ConfirmSignChangeTrustOptOut.tsx | 5 ++-- .../src/utils/caip.test.ts | 27 +++++++++++++++++++ .../stellar-wallet-snap/src/utils/caip.ts | 18 +++++++++++++ 10 files changed, 72 insertions(+), 34 deletions(-) diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts index 569ef888b..b355b7e46 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/api.ts @@ -24,7 +24,7 @@ import { pattern, } from '@metamask/superstruct'; import type { JsonRpcRequest } from '@metamask/utils'; -import { CaipAssetTypeStruct, parseCaipAssetType } from '@metamask/utils'; +import { CaipAssetTypeStruct } from '@metamask/utils'; import { JsonRpcRequestStruct, @@ -40,7 +40,7 @@ import { ValidStellarAmountStruct, SwapTransactionXdrStruct, } from '../../api'; -import { isSep41Id } from '../../utils'; +import { getStellarAssetChainId, isSep41Id } from '../../utils'; import { parseProofOfOwnershipMessage } from './utils'; /** @@ -159,8 +159,7 @@ export const ChangeTrustOptJsonRpcRequestStruct = refine( ), 'change-trust-asset-id-scope-match', ({ params }) => { - const result = - parseCaipAssetType(params.assetId).chainId === String(params.scope); + const result = getStellarAssetChainId(params.assetId) === params.scope; if (result) { return true; } @@ -272,7 +271,7 @@ const OnAmountInputJsonRpcRequestCoercedStruct = coerce( ...request, params: { ...request.params, - scope: parseCaipAssetType(request.params.assetId).chainId, + scope: getStellarAssetChainId(request.params.assetId), }, }), ); @@ -348,7 +347,7 @@ export const ConfirmSendJsonRpcRequestCoercedStruct = coerce( params: { ...request.params, accountId: request.params.fromAccountId, - scope: parseCaipAssetType(request.params.assetId).chainId, + scope: getStellarAssetChainId(request.params.assetId), }, }), ); diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts index f3193acea..9147fc96f 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/signAndSendTransaction.ts @@ -5,7 +5,6 @@ import { } from '@metamask/snap-networks-utils'; import type { AnalyticsService, Logger } from '@metamask/snap-networks-utils'; import type { CaipAssetType } from '@metamask/utils'; -import { parseCaipAssetType } from '@metamask/utils'; import type { KnownCaip19AssetIdOrSlip44Id, @@ -27,6 +26,7 @@ import type { Transaction } from '../../services/transaction/Transaction'; import type { TransactionService } from '../../services/transaction/TransactionService'; import { isPathPaymentOperation } from '../../services/transaction/utils'; import { + getAssetReference, isClassicAssetId, isSlip44Id, parseClassicAssetCodeIssuer, @@ -326,7 +326,7 @@ export class SignAndSendTransactionHandler extends BaseClientRequestHandler< if (isSlip44Id(assetId)) { return getNativeAssetMetadata(scope); } else if (isClassicAssetId(assetId)) { - const { assetReference } = parseCaipAssetType(assetId); + const assetReference = getAssetReference(assetId); const { assetCode } = parseClassicAssetCodeIssuer(assetReference); return toStellarAssetMetadata({ assetId, @@ -342,7 +342,7 @@ export class SignAndSendTransactionHandler extends BaseClientRequestHandler< 'Failed to resolve asset metadata; using fallback symbol', { assetId, error }, ); - const { assetReference } = parseCaipAssetType(assetId); + const assetReference = getAssetReference(assetId); return toStellarAssetMetadata({ assetId, decimals: STELLAR_DECIMAL_PLACES, 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 3af68fcf3..e66f6335c 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/utils.ts @@ -1,7 +1,5 @@ import { buildUrl } from '@metamask/snap-networks-utils'; -import { assert } from '@metamask/superstruct'; import type { NonEmptyArray } from '@metamask/utils'; -import { parseCaipAssetType } from '@metamask/utils'; import type { KnownCaip2ChainId, @@ -11,7 +9,7 @@ import type { KnownCaip19Slip44Id, KnownCaip19AssetId, } from '../../api'; -import { AssetType, KnownCaip2ChainIdStruct } from '../../api'; +import { AssetType } from '../../api'; import { AppConfig } from '../../config'; import { NATIVE_ASSET_NAME, @@ -20,6 +18,7 @@ import { } from '../../constants'; import { getSlip44AssetId, + getStellarAssetChainId, isClassicAssetId, isSep41Id, isSlip44Id, @@ -86,7 +85,7 @@ export function toStellarAssetMetadata( assetData: ToStellarAssetMetadataInput, ): StellarAssetMetadata { const name = assetData.name ?? assetData.symbol; - const { chainId } = parseCaipAssetType(assetData.assetId); + const chainId = getStellarAssetChainId(assetData.assetId); const { assetId } = assetData; const units: NonEmptyArray = [ { @@ -99,7 +98,7 @@ export function toStellarAssetMetadata( const metadata = { name, symbol: assetData.symbol, - chainId: chainId as KnownCaip2ChainId, + chainId, fungible: true as const, iconUrl: assetData.iconUrl ?? getIconUrl(assetId), units, @@ -172,9 +171,7 @@ export function groupAssetsByChainId( } uniqueAssetIds.add(assetId); - const { chainId } = parseCaipAssetType(assetId); - - assert(chainId, KnownCaip2ChainIdStruct); + const chainId = getStellarAssetChainId(assetId); if (isSlip44Id(assetId)) { if (!nativeAssets.has(chainId)) { diff --git a/packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/packages/stellar-wallet-snap/src/services/network/NetworkService.ts index 34921e58e..8a2cc2a80 100644 --- a/packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -4,7 +4,6 @@ import type { Serializable, } from '@metamask/snap-networks-utils'; import { useCache } from '@metamask/snap-networks-utils'; -import { parseCaipAssetType } from '@metamask/utils'; import { Address, Contract, @@ -34,6 +33,7 @@ import { rethrowIfInstanceElseThrow, batchesAllSettled, getAddress, + getAssetReference, } from '../../utils'; import { OnChainAccount } from '../on-chain-account/OnChainAccount'; import { InvalidInvokeContractStructureException } from '../transaction/exceptions'; @@ -431,9 +431,7 @@ export class NetworkService { // getLedgerEntries returns only entries that exist; missing contracts are omitted. const ledgerEntries = await client.getLedgerEntries( ...assetIds.map((assetId) => - new Contract( - parseCaipAssetType(assetId).assetReference, - ).getFootprint(), + new Contract(getAssetReference(assetId)).getFootprint(), ), ); @@ -495,7 +493,7 @@ export class NetworkService { try { const client = this.#getHorizonClient(scope); const { assetCode, assetIssuer } = parseClassicAssetCodeIssuer( - parseCaipAssetType(assetId).assetReference, + getAssetReference(assetId), ); const assetData = await client .assets() @@ -572,7 +570,7 @@ export class NetworkService { for (const assetId of assetIds) { invocations.push( new InvocationV1({ - contract: parseCaipAssetType(assetId).assetReference, + contract: getAssetReference(assetId), method: 'balance', args: [new Address(account).toScVal()], // Allow the batch simulation to continue when a cell fails (missing contract, etc.). diff --git a/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts index 81aa70148..0d5aa9190 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/TransactionBuilder.ts @@ -1,4 +1,3 @@ -import { parseCaipAssetType } from '@metamask/utils'; import type { Transaction as StellarSdkTransaction, xdr, @@ -22,6 +21,7 @@ import type { } from '../../api'; import { AppConfig } from '../../config'; import { + getAssetReference, isSep41Id, isSlip44Id, normalizeAmount, @@ -129,7 +129,7 @@ export class TransactionBuilder { try { // If it is a SEP-41 asset, the asset reference is the token address - const { assetReference: tokenAddress } = parseCaipAssetType(assetId); + const tokenAddress = getAssetReference(assetId); const token = new Contract(tokenAddress); // Contract token transfer function expects the amount in the token's smallest units (i128), diff --git a/packages/stellar-wallet-snap/src/services/transaction/utils.ts b/packages/stellar-wallet-snap/src/services/transaction/utils.ts index 50413e39e..e2c8339f3 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/utils.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/utils.ts @@ -1,7 +1,6 @@ import { KeyringEvent, TransactionStatus } from '@metamask/keyring-api'; import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; -import { parseCaipAssetType } from '@metamask/utils'; import type { Operation, OperationRecord } from '@stellar/stellar-sdk'; import { Asset } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; @@ -15,8 +14,10 @@ import { SwapTransactionXdrStruct } from '../../api'; import { AppConfig } from '../../config'; import { DUST_XLM_AMOUNT } from '../../constants'; import { + getAssetReference, getSlip44AssetId, getSnapProvider, + getStellarAssetChainId, isClassicAssetId, isSlip44Id, parseClassicAssetCodeIssuer, @@ -52,7 +53,7 @@ export function caip19ToStellarAsset( return Asset.native(); } if (isClassicAssetId(assetId)) { - const { assetReference } = parseCaipAssetType(assetId); + const assetReference = getAssetReference(assetId); const { assetCode, assetIssuer } = parseClassicAssetCodeIssuer(assetReference); return new Asset(assetCode, assetIssuer); @@ -142,7 +143,7 @@ export function assertAssetScopeMatch( assetId: KnownCaip19AssetIdOrSlip44Id, expectedScope: KnownCaip2ChainId, ): void { - const { chainId } = parseCaipAssetType(assetId); + const chainId = getStellarAssetChainId(assetId); if (chainId !== String(expectedScope)) { throw new TransactionValidationException( `Asset ${assetId} scope does not match expected scope ${expectedScope}`, diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx index 0dd34c87d..5d4085318 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptIn/ConfirmSignChangeTrustOptIn.tsx @@ -9,12 +9,11 @@ import { Text as SnapText, Tooltip, } from '@metamask/snaps-sdk/jsx'; -import { parseCaipAssetType } from '@metamask/utils'; import type { StellarKeyringAccount } from '../../../../services/account'; import type { StellarAssetMetadata } from '../../../../services/asset-metadata'; import type { Locale } from '../../../../utils'; -import { i18n } from '../../../../utils'; +import { getAssetReference, i18n } from '../../../../utils'; import type { ContextWithPrices, ConfirmationBaseProps, @@ -117,7 +116,7 @@ export const ConfirmSignChangeTrustOptIn = ({ symbol={assetMetadata.symbol} iconUrl={assetMetadata.iconUrl} link={getClassicAssetExplorerUrl( - parseCaipAssetType(assetMetadata.assetId).assetReference, + getAssetReference(assetMetadata.assetId), )} /> diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx index 756a0bd1d..eda785e89 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/views/ConfirmSignChangeTrustOptOut/ConfirmSignChangeTrustOptOut.tsx @@ -9,12 +9,11 @@ import { Text as SnapText, Tooltip, } from '@metamask/snaps-sdk/jsx'; -import { parseCaipAssetType } from '@metamask/utils'; import type { StellarKeyringAccount } from '../../../../services/account'; import type { StellarAssetMetadata } from '../../../../services/asset-metadata'; import type { Locale } from '../../../../utils'; -import { i18n } from '../../../../utils'; +import { getAssetReference, i18n } from '../../../../utils'; import type { ConfirmationBaseProps, ContextWithPrices, @@ -117,7 +116,7 @@ export const ConfirmSignChangeTrustOptOut = ({ symbol={assetMetadata.symbol} iconUrl={assetMetadata.iconUrl} link={getClassicAssetExplorerUrl( - parseCaipAssetType(assetMetadata.assetId).assetReference, + getAssetReference(assetMetadata.assetId), )} /> diff --git a/packages/stellar-wallet-snap/src/utils/caip.test.ts b/packages/stellar-wallet-snap/src/utils/caip.test.ts index 347f6a5ab..1419b52ae 100644 --- a/packages/stellar-wallet-snap/src/utils/caip.test.ts +++ b/packages/stellar-wallet-snap/src/utils/caip.test.ts @@ -1,9 +1,11 @@ import { Asset } from '@stellar/stellar-sdk'; +import type { KnownCaip19AssetIdOrSlip44Id } from '../api'; import { AssetType, KnownCaip19Slip44IdMap, KnownCaip2ChainId } from '../api'; import { getAssetReference, getSlip44AssetId, + getStellarAssetChainId, isClassicAssetId, isSep41Id, isSlip44Id, @@ -97,6 +99,31 @@ describe('getAssetReference', () => { }); }); +describe('getStellarAssetChainId', () => { + it('returns the known chain id for classic, sep41, and slip44 ids', () => { + expect(getStellarAssetChainId(CLASSIC_ASSET_ID)).toBe( + KnownCaip2ChainId.Mainnet, + ); + expect(getStellarAssetChainId(SEP41_ASSET_ID)).toBe( + KnownCaip2ChainId.Mainnet, + ); + expect(getStellarAssetChainId(SLIP44_ASSET_ID)).toBe( + KnownCaip2ChainId.Mainnet, + ); + expect( + getStellarAssetChainId(KnownCaip19Slip44IdMap[KnownCaip2ChainId.Testnet]), + ).toBe(KnownCaip2ChainId.Testnet); + }); + + it('throws when the parsed chain id is not a known Stellar chain', () => { + expect(() => + getStellarAssetChainId( + 'eip155:1/slip44:60' as KnownCaip19AssetIdOrSlip44Id, + ), + ).toThrow('Invalid chain id: eip155:1'); + }); +}); + describe('getSlip44AssetId', () => { it('returns the slip44 id for the given chain scope', () => { expect(getSlip44AssetId(KnownCaip2ChainId.Mainnet)).toBe( diff --git a/packages/stellar-wallet-snap/src/utils/caip.ts b/packages/stellar-wallet-snap/src/utils/caip.ts index 58a943a62..8328c11da 100644 --- a/packages/stellar-wallet-snap/src/utils/caip.ts +++ b/packages/stellar-wallet-snap/src/utils/caip.ts @@ -14,6 +14,7 @@ import { KnownCaip19Sep41AssetStruct, KnownCaip19Slip44IdMap, KnownCaip19Slip44IdStruct, + KnownCaip2ChainIdStruct, } from '../api'; /** @@ -105,6 +106,23 @@ export function getAssetReference( return assetReference; } +/** + * Returns the known Stellar chain id from a CAIP-19 asset id. + * + * @param assetId - CAIP-19 asset id. + * @returns The CAIP-2 chain id, `stellar:pubnet` or `stellar:testnet`. + * @throws {Error} When the parsed chain id is not a known Stellar chain. + */ +export function getStellarAssetChainId( + assetId: KnownCaip19AssetIdOrSlip44Id, +): KnownCaip2ChainId { + const { chainId } = parseCaipAssetType(assetId); + if (!KnownCaip2ChainIdStruct.is(chainId)) { + throw new Error(`Invalid chain id: ${chainId}`); + } + return chainId; +} + /** * Returns the slip44 asset ID for the given scope. * From 04f29428756a04c74a7b2865f4eb467b386e7a72 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:18:08 +0800 Subject: [PATCH 2/5] chore(stellar): drop rpc fallback for asset metadata --- eslint-suppressions.json | 2 +- packages/stellar-wallet-snap/CHANGELOG.md | 1 + packages/stellar-wallet-snap/src/context.ts | 1 - .../AssetMetadataService.test.ts | 51 ++---- .../asset-metadata/AssetMetadataService.ts | 159 +----------------- .../__mocks__/assets.fixtures.ts | 9 +- .../services/network/NetworkService.test.ts | 110 ------------ .../src/services/network/NetworkService.ts | 133 +-------------- .../src/services/network/api.ts | 15 -- .../src/services/transaction/xdrParser.ts | 111 ------------ .../stellar-wallet-snap/src/utils/index.ts | 1 - 11 files changed, 25 insertions(+), 568 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 12a786f4d..502a55ebc 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1051,7 +1051,7 @@ "count": 1 }, "jest/unbound-method": { - "count": 4 + "count": 2 } }, "packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts": { diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 78da1743c..756c78c63 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Resolve asset metadata that is missing from the local catalog through the token API only - **BREAKING:** Return classic keyring balance metadata `limit` in integer stroop string ([#495](https://github.com/MetaMask/internal-snaps/pull/495)) - Keyring method `getAccountBalances` and the Keyring event `AccountBalancesUpdated` now return this `limit` in stroops. diff --git a/packages/stellar-wallet-snap/src/context.ts b/packages/stellar-wallet-snap/src/context.ts index b14fa88db..67748665c 100644 --- a/packages/stellar-wallet-snap/src/context.ts +++ b/packages/stellar-wallet-snap/src/context.ts @@ -114,7 +114,6 @@ const assetsService = new AssetsService({ remoteFeatureFlagsProvider, }); const assetMetadataService = new AssetMetadataService({ - networkService, assetMetadataRepository, logger, assetsService, 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 1a7a0b592..03f1bea4f 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 @@ -7,7 +7,6 @@ import type { import { AssetType, KnownCaip2ChainId } 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 { ClassicStellarAssetMetadata, @@ -15,6 +14,7 @@ import type { } from './api'; import type { AssetMetadataRepository } from './AssetMetadataRepository'; import { AssetMetadataService } from './AssetMetadataService'; +import { AssetMetadataServiceException } from './exceptions'; import type { TokenMetadata } from './token-api/api'; import { TokenApiClient } from './token-api/TokenApiClient'; import { toStellarAssetMetadata } from './utils'; @@ -67,10 +67,7 @@ function createCachedRow( }; } -function createService(deps: { - repo?: Partial; - network?: Partial; -}) { +function createService(deps: { repo?: Partial }) { const defaults = { getByAssetIds: jest.fn().mockResolvedValue([]), saveMany: jest.fn().mockResolvedValue(undefined), @@ -86,12 +83,6 @@ function createService(deps: { ...deps.repo, } as unknown as AssetMetadataRepository; - const network = { - getSep41AssetsData: jest.fn().mockResolvedValue([]), - getClassicAssetData: jest.fn(), - ...deps.network, - } as unknown as NetworkService; - (TokenApiClient as jest.Mock).mockImplementation(() => ({ getAssetsByAssetIds: mockGetAssetsByAssetIds, getAssetsByChainId: mockGetAssetsByChainId, @@ -99,7 +90,6 @@ function createService(deps: { const { service: assetsService } = createMockAssetsService(); const service = new AssetMetadataService({ - networkService: network, assetMetadataRepository: repo, logger, assetsService, @@ -114,12 +104,6 @@ function createService(deps: { saveMany: repo.saveMany as jest.MockedFunction< AssetMetadataRepository['saveMany'] >, - getSep41AssetsData: network.getSep41AssetsData as jest.MockedFunction< - NetworkService['getSep41AssetsData'] - >, - getClassicAssetData: network.getClassicAssetData as jest.MockedFunction< - NetworkService['getClassicAssetData'] - >, }; } @@ -144,29 +128,15 @@ describe('AssetMetadataService', () => { expect(mockGetAssetsByAssetIds).not.toHaveBeenCalled(); }); - it('loads mainnet classic from Horizon when token API and cache miss', async () => { - const classicId = MAINNET_CLASSIC_USDC; - const rpcRow = { - assetId: classicId, - symbol: 'USDC', - decimals: 7, - name: 'USD Coin', - }; - const { service, getClassicAssetData } = createService({ - network: { - getClassicAssetData: jest.fn().mockResolvedValue(rpcRow), - }, - }); - - const result = await service.resolve(classicId); + it('throws when the token API does not return metadata for a mainnet classic asset', async () => { + const { service } = createService({}); - expect(result.assetId).toBe(classicId); - expect(result.symbol).toBe('USDC'); - expect(mockGetAssetsByAssetIds).toHaveBeenCalled(); - expect(getClassicAssetData).toHaveBeenCalledWith( - classicId, - KnownCaip2ChainId.Mainnet, + await expect(service.resolve(MAINNET_CLASSIC_USDC)).rejects.toThrow( + AssetMetadataServiceException, ); + expect(mockGetAssetsByAssetIds).toHaveBeenCalledWith([ + MAINNET_CLASSIC_USDC, + ]); }); it('returns cached mainnet classic asset without calling token API', async () => { @@ -250,7 +220,7 @@ describe('AssetMetadataService', () => { name: 'USD Coin', symbol: 'USDC', }; - const { service, getClassicAssetData, saveMany } = createService({}); + const { service, saveMany } = createService({}); mockGetAssetsByAssetIds.mockResolvedValueOnce([tokenRow]); const result = await service.resolve(classicId); @@ -272,7 +242,6 @@ describe('AssetMetadataService', () => { encodePathParams: false, }), }); - expect(getClassicAssetData).not.toHaveBeenCalled(); expect(saveMany).toHaveBeenCalledWith([ expect.objectContaining({ assetId: classicId }), ]); 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 8504c9349..a5cd8c2ac 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -3,24 +3,12 @@ import type { Logger } from '@metamask/snap-networks-utils'; import type { KnownCaip19AssetId, KnownCaip19AssetIdOrSlip44Id, - KnownCaip19ClassicAssetId, KnownCaip19Sep41AssetId, } from '../../api'; import { KnownCaip2ChainId, AssetType } from '../../api'; import { AppConfig } from '../../config'; -import { - batchesAllSettled, - batchesAllSettledWithChunks, - isClassicAssetId, - isSep41Id, -} from '../../utils'; import type { AssetsService } from '../assets'; -import type { AssetDataResponse, NetworkService } from '../network'; -import type { - ClassicStellarAssetMetadata, - Sep41StellarAssetMetadata, - StellarAssetMetadata, -} from './api'; +import type { Sep41StellarAssetMetadata, StellarAssetMetadata } from './api'; import type { AssetMetadataRepository } from './AssetMetadataRepository'; import { AssetMetadataServiceException } from './exceptions'; import type { TokenMetadata } from './token-api/api'; @@ -35,17 +23,6 @@ import { * Resolves CAIP-19 asset identifiers and caches fungible asset metadata for lookups. */ export class AssetMetadataService { - // Batch sizes for fetching assets from RPC, it is lower because we can fetch multiple assets at once - readonly #sepAssetBatchSize = 5; - - // Chunk size for fetching SEP-41 assets from RPC - readonly #sepAssetChunkSize = 10; - - // Batch sizes for fetching assets from Horizon - readonly #classicAssetBatchSize = 10; - - readonly #networkService: NetworkService; - readonly #tokenApiClient: TokenApiClient; readonly #assetMetadataRepository: AssetMetadataRepository; @@ -55,17 +32,14 @@ export class AssetMetadataService { readonly #assetsService: AssetsService; constructor({ - networkService, assetMetadataRepository, logger, assetsService, }: { - networkService: NetworkService; assetMetadataRepository: AssetMetadataRepository; logger: Logger; assetsService: AssetsService; }) { - this.#networkService = networkService; this.#tokenApiClient = new TokenApiClient({ baseUrl: AppConfig.api.tokenApi.baseUrl, }); @@ -75,7 +49,7 @@ export class AssetMetadataService { } /** - * Loads decimals for the asset; for SEP-41, fetches symbol and contract metadata from the token contract. + * Loads asset metadata from persisted state, then the token API. * * @param assetId - Native, classic, or SEP-41 CAIP-19 asset id. * @returns Resolved asset data for wallet / transaction use. @@ -316,35 +290,13 @@ export class AssetMetadataService { assetIds: KnownCaip19AssetId[], scope: KnownCaip2ChainId, ): Promise { - let missingAssetIds: KnownCaip19AssetId[] = []; - let apiTokenAssets: StellarAssetMetadata[] = []; - - if (scope === KnownCaip2ChainId.Mainnet) { - // No scope required for the token API, as it only supports mainnet - const apiResult = await this.#fetchTokenAssetsFromApi(assetIds); - apiTokenAssets = apiResult.assets; - missingAssetIds = apiResult.missingAssetIds; - } else { - missingAssetIds = assetIds; - } - - const missingSep41AssetIds: KnownCaip19Sep41AssetId[] = []; - const missingClassicAssetIds: KnownCaip19ClassicAssetId[] = []; - - for (const assetId of missingAssetIds) { - if (isSep41Id(assetId)) { - missingSep41AssetIds.push(assetId); - } else if (isClassicAssetId(assetId)) { - missingClassicAssetIds.push(assetId); - } - // there is no other asset type that is not SEP-41 or classic + if (scope !== KnownCaip2ChainId.Mainnet) { + return []; } - const [sepTokenAssets, classicTokenAssets] = await Promise.all([ - this.#fetchSepTokenAssets(missingSep41AssetIds, scope), - this.#fetchClassicTokenAssets(missingClassicAssetIds, scope), - ]); - return [...apiTokenAssets, ...sepTokenAssets, ...classicTokenAssets]; + // The token API only supports mainnet. + const { assets } = await this.#fetchTokenAssetsFromApi(assetIds); + return assets; } async #fetchTokenAssetsFromApi(assetIds: KnownCaip19AssetId[]): Promise<{ @@ -363,103 +315,6 @@ export class AssetMetadataService { return { assets, missingAssetIds }; } - async #fetchSepTokenAssets( - assetIds: KnownCaip19Sep41AssetId[], - scope: KnownCaip2ChainId, - ): Promise { - if (assetIds.length === 0) { - return []; - } - - this.#logger.debug('Fetching SEP-41 token assets from RPC', { assetIds }); - - const settled = await batchesAllSettledWithChunks( - assetIds, - this.#sepAssetChunkSize, - this.#sepAssetBatchSize, - async (chunk: KnownCaip19Sep41AssetId[]) => - this.#networkService.getSep41AssetsData(chunk, scope), - ); - - const { assets, missingAssetIds } = - this.#extractSuccessAndMissingFromSettled(settled, assetIds); - - if (missingAssetIds.length > 0) { - this.#logger.warn( - `Failed to fetch token metadata for assets: ${Array.from( - missingAssetIds, - ).join(', ')}`, - ); - } - - return assets; - } - - async #fetchClassicTokenAssets( - assetIds: KnownCaip19ClassicAssetId[], - scope: KnownCaip2ChainId, - ): Promise { - if (assetIds.length === 0) { - return []; - } - - this.#logger.debug('Fetching Classic token assets from Horizon', { - assetIds, - }); - - const settled = await batchesAllSettled( - assetIds, - this.#classicAssetBatchSize, - async (assetId: KnownCaip19ClassicAssetId) => - this.#networkService.getClassicAssetData(assetId, scope), - ); - - const { assets, missingAssetIds } = - this.#extractSuccessAndMissingFromSettled(settled, assetIds); - - if (missingAssetIds.length > 0) { - this.#logger.warn( - `Failed to fetch token metadata for assets: ${Array.from( - missingAssetIds, - ).join(', ')}`, - ); - } - - return assets.filter( - (asset): asset is ClassicStellarAssetMetadata => - asset.assetType === AssetType.Token, - ); - } - - #extractSuccessAndMissingFromSettled( - settled: PromiseSettledResult[], - assetIds: KnownCaip19AssetId[], - ): { assets: StellarAssetMetadata[]; missingAssetIds: string[] } { - const assets: StellarAssetMetadata[] = []; - const missingTokenAssetIds = new Set(assetIds); - - for (const entry of settled) { - if (entry.status === 'rejected') { - this.#logger.warn('Failed to fetch assets metadata', { - error: entry.reason, - }); - continue; - } - - if (Array.isArray(entry.value)) { - for (const asset of entry.value) { - assets.push(toStellarAssetMetadata(asset)); - missingTokenAssetIds.delete(asset.assetId); - } - } else { - assets.push(toStellarAssetMetadata(entry.value)); - missingTokenAssetIds.delete(entry.value.assetId); - } - } - - return { assets, missingAssetIds: Array.from(missingTokenAssetIds) }; - } - async #getAssetsByAssetIds( assetIds: KnownCaip19AssetId[], ): Promise { 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 4a2c03bbe..ec0585c7c 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 @@ -1,4 +1,4 @@ -import { InMemoryState, InMemoryCache } from '@metamask/snap-networks-utils'; +import { InMemoryState } from '@metamask/snap-networks-utils'; import type { KnownCaip19ClassicAssetId, @@ -8,9 +8,8 @@ import type { 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 { logger } from '../../../utils/logger'; import { createMockAssetsService } from '../../assets/__mocks__/assetsService.fixtures'; -import { NetworkService } from '../../network'; import type { AssetMetadataByAssetId, Sep41StellarAssetMetadata } from '../api'; import { AssetMetadataRepository } from '../AssetMetadataRepository'; import { AssetMetadataService } from '../AssetMetadataService'; @@ -85,10 +84,6 @@ export const getMockSep41Assets = (): Sep41StellarAssetMetadata[] => { export const createMockAssetMetadataService = () => { const { service: assetsService } = createMockAssetsService(); const service = new AssetMetadataService({ - networkService: new NetworkService({ - logger, - cache: new InMemoryCache(noOpLogger), - }), assetMetadataRepository: new AssetMetadataRepository( new InMemoryState({ assets: generateMockStellarAssetMetadata(), diff --git a/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts b/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts index 23d33a3cd..ae524d71e 100644 --- a/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts +++ b/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts @@ -9,10 +9,8 @@ import { } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; -import type { KnownCaip19ClassicAssetId } from '../../api'; import { KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; -import { STELLAR_DECIMAL_PLACES } from '../../constants'; import { toSmallestUnit } from '../../utils'; import { logger } from '../../utils/logger'; import { @@ -455,114 +453,6 @@ describe('NetworkService', () => { }); }); - describe('getSep41AssetsData', () => { - it('throws NetworkServiceException when getLedgerEntries fails', async () => { - const { getLedgerEntriesSpy } = getRpcServerSpies(); - getLedgerEntriesSpy.mockRejectedValue(new Error('RPC error')); - - await expect( - networkService.getSep41AssetsData([USDC_SEP41], scope), - ).rejects.toThrow(NetworkServiceException); - }); - }); - - describe('getClassicAssetData', () => { - const classicAssetId = - 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as KnownCaip19ClassicAssetId; - const issuer = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; - - it('returns classic asset metadata from Horizon', async () => { - const call = jest.fn().mockResolvedValue({ - records: [ - { - asset_code: 'USDC', - asset_issuer: issuer, - }, - ], - }); - const assetsSpy = jest - .spyOn(StellarHorizon.Server.prototype, 'assets') - .mockReturnValue({ - forCode: jest.fn().mockReturnValue({ - forIssuer: jest.fn().mockReturnValue({ call }), - }), - } as never); - - const result = await networkService.getClassicAssetData( - classicAssetId, - scope, - ); - - expect(result).toStrictEqual({ - assetId: classicAssetId, - symbol: 'USDC', - decimals: STELLAR_DECIMAL_PLACES, - name: 'USDC', - }); - expect(call).toHaveBeenCalled(); - assetsSpy.mockRestore(); - }); - - it('throws NetworkServiceException when Horizon returns no rows', async () => { - const call = jest.fn().mockResolvedValue({ records: [] }); - const assetsSpy = jest - .spyOn(StellarHorizon.Server.prototype, 'assets') - .mockReturnValue({ - forCode: jest.fn().mockReturnValue({ - forIssuer: jest.fn().mockReturnValue({ call }), - }), - } as never); - - await expect( - networkService.getClassicAssetData(classicAssetId, scope), - ).rejects.toThrow(NetworkServiceException); - - assetsSpy.mockRestore(); - }); - - it('throws NetworkServiceException when the matching row issuer does not match', async () => { - const call = jest.fn().mockResolvedValue({ - records: [ - { - asset_code: 'USDC', - asset_issuer: - 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', - }, - ], - }); - const assetsSpy = jest - .spyOn(StellarHorizon.Server.prototype, 'assets') - .mockReturnValue({ - forCode: jest.fn().mockReturnValue({ - forIssuer: jest.fn().mockReturnValue({ call }), - }), - } as never); - - await expect( - networkService.getClassicAssetData(classicAssetId, scope), - ).rejects.toThrow(NetworkServiceException); - - assetsSpy.mockRestore(); - }); - - it('wraps unexpected Horizon errors in NetworkServiceException', async () => { - const call = jest.fn().mockRejectedValue(new Error('Horizon outage')); - const assetsSpy = jest - .spyOn(StellarHorizon.Server.prototype, 'assets') - .mockReturnValue({ - forCode: jest.fn().mockReturnValue({ - forIssuer: jest.fn().mockReturnValue({ call }), - }), - } as never); - - await expect( - networkService.getClassicAssetData(classicAssetId, scope), - ).rejects.toThrow(NetworkServiceException); - - assetsSpy.mockRestore(); - }); - }); - describe('pollTransaction', () => { it('returns transaction hash when status is SUCCESS', async () => { const { pollTransactionSpy } = getRpcServerSpies(); diff --git a/packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/packages/stellar-wallet-snap/src/services/network/NetworkService.ts index 8a2cc2a80..d16164ad7 100644 --- a/packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -6,41 +6,31 @@ import type { import { useCache } from '@metamask/snap-networks-utils'; import { Address, - Contract, Horizon as StellarHorizon, NotFoundError, rpc, } from '@stellar/stellar-sdk'; -import type { - KnownCaip19ClassicAssetId, - KnownCaip19Sep41AssetId, -} from '../../api'; +import type { KnownCaip19Sep41AssetId } from '../../api'; import { KnownCaip2ChainId } from '../../api'; import type { NetworkConfig } from '../../config'; import { AppConfig } from '../../config'; import { MAX_TRANSACTION_SCAN_PAGES, MAX_TRANSACTIONS_PAGE_SIZE, - STELLAR_DECIMAL_PLACES, } from '../../constants'; import type { AnyErrorConstructor } from '../../utils'; import { isSameStr, - parseClassicAssetCodeIssuer, - toCaip19ClassicAssetId, - toCaip19Sep41AssetId, rethrowIfInstanceElseThrow, batchesAllSettled, - getAddress, getAssetReference, } from '../../utils'; import { OnChainAccount } from '../on-chain-account/OnChainAccount'; import { InvalidInvokeContractStructureException } from '../transaction/exceptions'; import { Transaction } from '../transaction/Transaction'; import { assertInvokeHostFunctionSoleOperation } from '../transaction/utils'; -import { extractAssetDataFromContractData } from '../transaction/xdrParser'; -import type { AccountLedgerMeta, AssetDataResponse } from './api'; +import type { AccountLedgerMeta } from './api'; import { KnownRpcError } from './api'; import { AccountNotActivatedException, @@ -65,9 +55,8 @@ import { /** * Stellar network access through **Horizon** and **Soroban RPC**: base fee, account loading (full - * Horizon account vs RPC sequence-only), contract token metadata (`getLedgerEntries`), SEP-41 - * balance simulation, Soroban simulation / fee computation, transaction submission, and optional - * post-submit polling. + * Horizon account vs RPC sequence-only), SEP-41 balance simulation, Soroban simulation / fee + * computation, transaction submission, and optional post-submit polling. */ export class NetworkService { readonly #logger: Logger; @@ -412,120 +401,6 @@ export class NetworkService { } } - /** - * Batch loads token contract ledger entries over Soroban RPC. - * - * @param assetIds - SEP-41 CAIP-19 asset ids (`…/sep41:C…`) for contracts on `scope`. - * @param scope - The CAIP-2 chain ID. - * @returns One {@link AssetDataResponse} per returned ledger entry, in RPC order. Ids with no matching - * contract entry are omitted (this is not an error). - * @throws {NetworkServiceException} When the RPC request fails. - */ - async getSep41AssetsData( - assetIds: KnownCaip19Sep41AssetId[], - scope: KnownCaip2ChainId, - ): Promise { - try { - const client = this.#getRpcClient(scope); - - // getLedgerEntries returns only entries that exist; missing contracts are omitted. - const ledgerEntries = await client.getLedgerEntries( - ...assetIds.map((assetId) => - new Contract(getAssetReference(assetId)).getFootprint(), - ), - ); - - const assets: AssetDataResponse[] = []; - for (const ledgerEntry of ledgerEntries.entries) { - if (ledgerEntry.val.type !== 'contractData') { - continue; - } - const { contractData } = ledgerEntry.val; - const contractAddress = getAddress(contractData.contract); - - const extractedAssetData = extractAssetDataFromContractData( - contractData, - contractAddress, - ); - - if (extractedAssetData.isStellarClassicAsset) { - const { assetCode, assetIssuer } = parseClassicAssetCodeIssuer( - extractedAssetData.name, - ); - assets.push({ - // Normalize to use CAIP-19 classic asset id - ${CAIP_2_CHAIN_ID}/token:${ASSET_CODE}-${ASSET_ISSUER} - assetId: toCaip19ClassicAssetId(scope, assetCode, assetIssuer), - symbol: extractedAssetData.symbol, - decimals: extractedAssetData.decimals, - name: assetCode, - }); - } else { - assets.push({ - // Normalize to use CAIP-19 SEP-41 asset id - ${CAIP_2_CHAIN_ID}/sep41:${CONTRACT_ADDRESS} - assetId: toCaip19Sep41AssetId(scope, extractedAssetData.name), - name: extractedAssetData.name, - symbol: extractedAssetData.symbol, - decimals: extractedAssetData.decimals, - }); - } - } - return assets; - } catch (error: unknown) { - return this.#throwError({ - error, - fallbackError: 'Failed to get assets data from Soroban', - }); - } - } - - /** - * Fetches classic asset data from Horizon via `assets` for a CAIP-19 classic asset id. - * - * @param assetId - CAIP-19 classic asset id (`…/asset:CODE-ISSUER`). - * @param scope - The CAIP-2 chain ID. - * @returns for the classic asset. - * @throws {NetworkServiceException} When Horizon returns no entry for this asset or the request fails. - */ - async getClassicAssetData( - assetId: KnownCaip19ClassicAssetId, - scope: KnownCaip2ChainId, - ): Promise { - try { - const client = this.#getHorizonClient(scope); - const { assetCode, assetIssuer } = parseClassicAssetCodeIssuer( - getAssetReference(assetId), - ); - const assetData = await client - .assets() - .forCode(assetCode) - .forIssuer(assetIssuer) - .call(); - - if ( - !assetData || - assetData.records.length === 0 || - assetData.records[0]?.asset_code !== assetCode || - assetData.records[0]?.asset_issuer !== assetIssuer - ) { - throw new NetworkServiceException( - `Failed to get assets data from Horizon for asset id: ${assetId}`, - ); - } - - return { - assetId, - symbol: assetCode, - decimals: STELLAR_DECIMAL_PLACES, - name: assetCode, - }; - } catch (error: unknown) { - return this.#throwError({ - error, - fallbackError: 'Failed to get assets data from Horizon', - }); - } - } - /** * Fetches SEP-41 asset balances for multiple accounts via Soroban simulation of `balance(Address)`. * diff --git a/packages/stellar-wallet-snap/src/services/network/api.ts b/packages/stellar-wallet-snap/src/services/network/api.ts index b09c6f3e2..f9df7911d 100644 --- a/packages/stellar-wallet-snap/src/services/network/api.ts +++ b/packages/stellar-wallet-snap/src/services/network/api.ts @@ -1,5 +1,3 @@ -import type { KnownCaip19AssetId } from '../../api'; - /** * The known RPC error codes for the Stellar network. * The error code is shared with Horizon API and Soroban RPC. @@ -22,19 +20,6 @@ export const KnownRpcError = { export type KnownRpcError = (typeof KnownRpcError)[keyof typeof KnownRpcError]; -/** - * Asset data for a Stellar classic asset. - */ -export type AssetDataResponse = { - name?: string; - // Symbol of the asset - symbol: string; - // Number of decimal places of the asset - decimals: number; - // CAIP-19 classic asset id (`…/asset:CODE-ISSUER`) from RPC / Stellar asset contract - assetId: KnownCaip19AssetId; -}; - /** * Account ledger fields from Soroban RPC `getAccountEntry`. * diff --git a/packages/stellar-wallet-snap/src/services/transaction/xdrParser.ts b/packages/stellar-wallet-snap/src/services/transaction/xdrParser.ts index 77db70c46..2f352f6ea 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/xdrParser.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/xdrParser.ts @@ -443,117 +443,6 @@ function toReadableJsonValue(value: unknown): Json { return null; } -/** - * Extracts token metadata from a contract data ledger entry. - * - * @param contractData - The contract data entry. - * @param contractAddress - Token contract id strkey (`C…`) for error context and wasm token `assetRef`. - * @returns Token name, symbol, decimals, and whether the contract wraps a classic asset. - */ -export function extractAssetDataFromContractData( - contractData: xdr.ContractDataEntry, - contractAddress: string, -): { - name: string; - symbol: string; - decimals: number; - isStellarClassicAsset: boolean; -} { - try { - const contractVal = contractData.val; - if (contractVal.type !== 'scvContractInstance') { - throw new XdrParseException( - `Contract ${contractAddress} is not a contract instance`, - ); - } - const contractDataInstance = contractVal.instance; - - // contractDataName is either contractExecutableWasm or contractExecutableStellarAsset - // contractExecutableWasm: Wasm contract - // contractExecutableStellarAsset: Stellar asset contract - const contractDataName = contractDataInstance.executable.type; - - const isStellarClassicAsset = - contractDataName === 'contractExecutableStellarAsset'; - - const assetData = { - symbol: '', - decimals: -1, - name: '', - isStellarClassicAsset, - }; - - // it is possible to have empty storage, such as when the contract is not a token contract - for (const entry of contractDataInstance.storage ?? []) { - const { key } = entry; - if (key.type !== 'scvSymbol' || key.sym.toString() !== 'METADATA') { - continue; - } - - if (entry.val.type !== 'scvMap') { - continue; - } - - for (const mapEntry of entry.val.map ?? []) { - if (mapEntry.key.type !== 'scvSymbol') { - continue; - } - const fieldName = mapEntry.key.sym.toString(); - const value = mapEntry.val; - - switch (fieldName) { - case 'name': - // if it is a Stellar asset contract, the name is ${ASSET_CODE}:${ASSET_ISSUER} - // if it is a Wasm contract, the "name" is set to the contract address (used as the SEP-41 assetRef/identifier) - assetData.name = contractAddress; - if (isStellarClassicAsset && value.type === 'scvString') { - assetData.name = value.str.toString(); - } - break; - case 'symbol': - if (value.type === 'scvString') { - assetData.symbol = value.str.toString(); - } - break; - case 'decimal': - if (value.type === 'scvU32') { - assetData.decimals = value.u32; - } - break; - default: - break; - } - } - } - if (assetData.name === '') { - throw new XdrParseException( - `Name is empty for contract ${contractAddress}`, - ); - } - if (assetData.symbol === '') { - throw new XdrParseException( - `Symbol is empty for contract ${contractAddress}`, - ); - } - if (assetData.decimals === -1) { - throw new XdrParseException( - `Decimals is empty for contract ${contractAddress}`, - ); - } - - return assetData; - } catch (error) { - return rethrowIfInstanceElseThrow( - error, - [XdrParseException], - new XdrParseException( - `Error extracting asset data from contract ${contractAddress}`, - { cause: error }, - ), - ); - } -} - /** * Best-effort walk of contract events in Horizon `result_meta_xdr`. * diff --git a/packages/stellar-wallet-snap/src/utils/index.ts b/packages/stellar-wallet-snap/src/utils/index.ts index 85ee4f90a..330a6c62c 100644 --- a/packages/stellar-wallet-snap/src/utils/index.ts +++ b/packages/stellar-wallet-snap/src/utils/index.ts @@ -10,7 +10,6 @@ export * from './buffer'; export { batchesAll, batchesAllSettled, - batchesAllSettledWithChunks, batchesAllWithChunks, chunks, } from '@metamask/snap-networks-utils'; From 30f02bd8df72133a83f1d4edc693f9d7cffd2965 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:26:05 +0800 Subject: [PATCH 3/5] Update CHANGELOG.md --- packages/stellar-wallet-snap/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 756c78c63..5efffd199 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -14,7 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Resolve asset metadata that is missing from the local catalog through the token API only +- Stop falling back to Soroban RPC and Horizon for asset metadata ([#505](https://github.com/MetaMask/internal-snaps/pull/505)) + - Fetch asset metadata from the local catalog or the token API. - **BREAKING:** Return classic keyring balance metadata `limit` in integer stroop string ([#495](https://github.com/MetaMask/internal-snaps/pull/495)) - Keyring method `getAccountBalances` and the Keyring event `AccountBalancesUpdated` now return this `limit` in stroops. From eb81989aa79acd38a542f1bc58523ac75231618f Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:36:35 +0800 Subject: [PATCH 4/5] Update CHANGELOG.md --- packages/stellar-wallet-snap/CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 5efffd199..a9648f595 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -14,8 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Stop falling back to Soroban RPC and Horizon for asset metadata ([#505](https://github.com/MetaMask/internal-snaps/pull/505)) - - Fetch asset metadata from the local catalog or the token API. +- **BREAKING:** Stop falling back to Soroban RPC and Horizon for asset metadata ([#505](https://github.com/MetaMask/internal-snaps/pull/505)) + - Fetch asset metadata from the local catalog or the mainnet token API. + - Uncached non-native testnet assets no longer resolve. - **BREAKING:** Return classic keyring balance metadata `limit` in integer stroop string ([#495](https://github.com/MetaMask/internal-snaps/pull/495)) - Keyring method `getAccountBalances` and the Keyring event `AccountBalancesUpdated` now return this `limit` in stroops. From 19963ad5f1b1b6608303e2a73a225079b9cf931b Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:05:36 +0800 Subject: [PATCH 5/5] fix: comment --- packages/stellar-wallet-snap/CHANGELOG.md | 1 - .../AssetMetadataService.test.ts | 66 ++++++++++++++++++- .../asset-metadata/AssetMetadataService.ts | 48 +++++--------- 3 files changed, 81 insertions(+), 34 deletions(-) diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index a9648f595..0695722d2 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -16,7 +16,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Stop falling back to Soroban RPC and Horizon for asset metadata ([#505](https://github.com/MetaMask/internal-snaps/pull/505)) - Fetch asset metadata from the local catalog or the mainnet token API. - - Uncached non-native testnet assets no longer resolve. - **BREAKING:** Return classic keyring balance metadata `limit` in integer stroop string ([#495](https://github.com/MetaMask/internal-snaps/pull/495)) - Keyring method `getAccountBalances` and the Keyring event `AccountBalancesUpdated` now return this `limit` in stroops. 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 03f1bea4f..12a1943f2 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 @@ -7,7 +7,7 @@ import type { import { AssetType, KnownCaip2ChainId } from '../../api'; import { getSlip44AssetId, logger } from '../../utils'; import { createMockAssetsService } from '../assets/__mocks__/assetsService.fixtures'; -import { USDC_SEP41 } from './__mocks__/assets.fixtures'; +import { TESTNET_SEP41_USDC, USDC_SEP41 } from './__mocks__/assets.fixtures'; import type { ClassicStellarAssetMetadata, Sep41StellarAssetMetadata, @@ -23,6 +23,9 @@ import { toStellarAssetMetadata } from './utils'; const MAINNET_CLASSIC_USDC = 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as KnownCaip19ClassicAssetId; +const TESTNET_CLASSIC_USDC = + 'stellar:testnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as KnownCaip19ClassicAssetId; + jest.mock('../../config', () => ({ AppConfig: { api: { @@ -128,6 +131,67 @@ describe('AssetMetadataService', () => { expect(mockGetAssetsByAssetIds).not.toHaveBeenCalled(); }); + it('throws for an uncached testnet classic asset when the token API returns no row', async () => { + const { service, saveMany } = createService({}); + + await expect(service.resolve(TESTNET_CLASSIC_USDC)).rejects.toThrow( + AssetMetadataServiceException, + ); + expect(mockGetAssetsByAssetIds).toHaveBeenCalledWith([ + TESTNET_CLASSIC_USDC, + ]); + expect(saveMany).not.toHaveBeenCalled(); + }); + + it('returns a cached testnet classic asset without calling the token API', async () => { + const cached = createCachedRow( + TESTNET_CLASSIC_USDC, + KnownCaip2ChainId.Testnet, + ); + const { service, getByAssetIds, saveMany } = createService({ + repo: { + getByAssetIds: jest.fn().mockResolvedValue([cached]), + }, + }); + + const result = await service.resolve(TESTNET_CLASSIC_USDC); + + expect(result).toStrictEqual(cached); + expect(getByAssetIds).toHaveBeenCalledWith([TESTNET_CLASSIC_USDC]); + expect(mockGetAssetsByAssetIds).not.toHaveBeenCalled(); + expect(saveMany).not.toHaveBeenCalled(); + }); + + it('persists only the ids returned by the token API', 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, + TESTNET_SEP41_USDC, + ]); + + expect(mockGetAssetsByAssetIds).toHaveBeenCalledWith([ + USDC_SEP41, + TESTNET_SEP41_USDC, + ]); + expect(result).toStrictEqual([ + expect.objectContaining({ + assetId: USDC_SEP41, + assetType: AssetType.Sep41, + }), + ]); + expect(saveMany).toHaveBeenCalledWith([ + expect.objectContaining({ assetId: USDC_SEP41 }), + ]); + }); + it('throws when the token API does not return metadata for a mainnet classic asset', async () => { const { service } = createService({}); 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 a5cd8c2ac..9c4d32d56 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -51,6 +51,10 @@ export class AssetMetadataService { /** * Loads asset metadata from persisted state, then the token API. * + * Native assets resolve locally. Ids the token API does not return are left + * unresolved and are not persisted, including unknown mainnet assets and + * testnet assets. + * * @param assetId - Native, classic, or SEP-41 CAIP-19 asset id. * @returns Resolved asset data for wallet / transaction use. */ @@ -70,8 +74,9 @@ export class AssetMetadataService { /** * 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}. + * Rows already stored in AssetsController or snap state are reused. Newly fetched + * rows are persisted, same as {@link resolve}. Ids missing from the token API + * are omitted. * * @param assetIds - SEP-41 asset ids to resolve. * @returns Metadata for the ids that could be resolved. Missing ids are omitted. @@ -163,6 +168,7 @@ export class AssetMetadataService { ): Promise { const { nativeAssets: nativeAssetsByChainId, assets: assetsByChainId } = groupAssetsByChainId(assetIds); + const result: StellarAssetMetadata[] = []; // fetch native assets for (const [chainId] of nativeAssetsByChainId) { @@ -181,25 +187,16 @@ export class AssetMetadataService { return result.concat(assets); } - // fetch missing assets by chain id - const missingAssets: StellarAssetMetadata[] = []; - const { assets: missingAssetIdsByChainId } = - groupAssetsByChainId(missingAssetIds); - - for (const [chainId, chainAssetIds] of missingAssetIdsByChainId) { - const fetchedAssets = await this.#fetchMissingAssetsMetadata( - chainAssetIds, - chainId, - ); - missingAssets.push(...fetchedAssets); - } + // A missing token API row is not persisted. + const { assets: assetsToPersist } = + await this.#fetchTokenAssetsFromApi(missingAssetIds); - // Backfill in state - if (missingAssets.length > 0) { - await this.#assetMetadataRepository.saveMany(missingAssets); + // backfill in state + if (assetsToPersist.length > 0) { + await this.#assetMetadataRepository.saveMany(assetsToPersist); } - return result.concat(assets, missingAssets); + return result.concat(assets, assetsToPersist); } /** @@ -286,19 +283,6 @@ export class AssetMetadataService { return { hits, missing }; } - async #fetchMissingAssetsMetadata( - assetIds: KnownCaip19AssetId[], - scope: KnownCaip2ChainId, - ): Promise { - if (scope !== KnownCaip2ChainId.Mainnet) { - return []; - } - - // The token API only supports mainnet. - const { assets } = await this.#fetchTokenAssetsFromApi(assetIds); - return assets; - } - async #fetchTokenAssetsFromApi(assetIds: KnownCaip19AssetId[]): Promise<{ assets: StellarAssetMetadata[]; missingAssetIds: KnownCaip19AssetId[]; @@ -311,7 +295,7 @@ export class AssetMetadataService { const tokensMetadata = await this.#getAssetsByAssetIds(assetIds); const { hits: assets, missing: missingAssetIds } = this.#partitionHitsAndMissingByArray(assetIds, tokensMetadata); - this.#logger.debug('Token assets from API', { missingAssetIds }); + this.#logger.debug('Token assets missing from API', { missingAssetIds }); return { assets, missingAssetIds }; }