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..0695722d2 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **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. - **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..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,14 +7,14 @@ 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 { TESTNET_SEP41_USDC, USDC_SEP41 } from './__mocks__/assets.fixtures'; import type { ClassicStellarAssetMetadata, Sep41StellarAssetMetadata, } 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'; @@ -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: { @@ -67,10 +70,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 +86,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 +93,6 @@ function createService(deps: { const { service: assetsService } = createMockAssetsService(); const service = new AssetMetadataService({ - networkService: network, assetMetadataRepository: repo, logger, assetsService, @@ -114,12 +107,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 +131,76 @@ 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), + 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(classicId); + const result = await service.resolve(TESTNET_CLASSIC_USDC); - expect(result.assetId).toBe(classicId); - expect(result.symbol).toBe('USDC'); - expect(mockGetAssetsByAssetIds).toHaveBeenCalled(); - expect(getClassicAssetData).toHaveBeenCalledWith( - classicId, - KnownCaip2ChainId.Mainnet, + 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({}); + + 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 +284,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 +306,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..9c4d32d56 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,11 @@ 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. + * + * 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. @@ -96,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. @@ -189,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) { @@ -207,25 +187,16 @@ export class AssetMetadataService { return result.concat(assets); } - // fetch missing assets by chain id - const missingAssets: StellarAssetMetadata[] = []; - const { assets: missingAssetIdsByChainId } = - groupAssetsByChainId(missingAssetIds); + // A missing token API row is not persisted. + const { assets: assetsToPersist } = + await this.#fetchTokenAssetsFromApi(missingAssetIds); - for (const [chainId, chainAssetIds] of missingAssetIdsByChainId) { - const fetchedAssets = await this.#fetchMissingAssetsMetadata( - chainAssetIds, - chainId, - ); - missingAssets.push(...fetchedAssets); + // backfill in state + if (assetsToPersist.length > 0) { + await this.#assetMetadataRepository.saveMany(assetsToPersist); } - // Backfill in state - if (missingAssets.length > 0) { - await this.#assetMetadataRepository.saveMany(missingAssets); - } - - return result.concat(assets, missingAssets); + return result.concat(assets, assetsToPersist); } /** @@ -312,41 +283,6 @@ export class AssetMetadataService { return { hits, missing }; } - async #fetchMissingAssetsMetadata( - 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 - } - - const [sepTokenAssets, classicTokenAssets] = await Promise.all([ - this.#fetchSepTokenAssets(missingSep41AssetIds, scope), - this.#fetchClassicTokenAssets(missingClassicAssetIds, scope), - ]); - return [...apiTokenAssets, ...sepTokenAssets, ...classicTokenAssets]; - } - async #fetchTokenAssetsFromApi(assetIds: KnownCaip19AssetId[]): Promise<{ assets: StellarAssetMetadata[]; missingAssetIds: KnownCaip19AssetId[]; @@ -359,107 +295,10 @@ 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 }; } - 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';