From 7202278165604261df492e511c04fb48448771b6 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:54:49 +0800 Subject: [PATCH 01/50] chore: remove dead code getAssetsMetadataByAssetIds --- .../AssetMetadataService.test.ts | 79 ------------------- .../asset-metadata/AssetMetadataService.ts | 32 -------- .../__mocks__/assets.fixtures.ts | 6 -- 3 files changed, 117 deletions(-) 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 630e696fa..729c33ad3 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 @@ -167,85 +167,6 @@ describe('AssetMetadataService', () => { expect(saveMany).not.toHaveBeenCalled(); }); - it('fills keyring metadata map from snap state only (read-only)', async () => { - const classicId = MAINNET_CLASSIC_USDC; - const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); - const cached = createCachedRow(classicId, KnownCaip2ChainId.Mainnet); - const { service, saveMany, getClassicAssetData } = createService({ - repo: { - getByAssetIds: jest.fn().mockResolvedValue([cached]), - }, - }); - - const map = await service.getAssetsMetadataByAssetIds([classicId, slipId]); - - expect(map[classicId]).toMatchObject({ - fungible: true, - symbol: cached.symbol, - name: cached.name, - iconUrl: expect.any(String), - units: expect.any(Array), - }); - expect(map[slipId]).toMatchObject({ - fungible: true, - iconUrl: expect.any(String), - units: expect.any(Array), - symbol: expect.any(String), - name: expect.any(String), - }); - expect(mockGetAssetsByAssetIds).not.toHaveBeenCalled(); - expect(getClassicAssetData).not.toHaveBeenCalled(); - expect(saveMany).not.toHaveBeenCalled(); - }); - - it('returns null for missing assets without fetching or persisting', async () => { - const classicId = MAINNET_CLASSIC_USDC; - const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); - const { service, saveMany, getByAssetIds, getClassicAssetData } = - createService({}); - - const map = await service.getAssetsMetadataByAssetIds([classicId, slipId]); - - expect(map[classicId]).toBeNull(); - expect(map[slipId]).toMatchObject({ - symbol: NATIVE_ASSET_SYMBOL, - name: NATIVE_ASSET_NAME, - }); - expect(getByAssetIds).toHaveBeenCalledWith([classicId]); - expect(mockGetAssetsByAssetIds).not.toHaveBeenCalled(); - expect(getClassicAssetData).not.toHaveBeenCalled(); - expect(saveMany).not.toHaveBeenCalled(); - }); - - it('deduplicates duplicate asset ids when reading from state', async () => { - const classicId = MAINNET_CLASSIC_USDC; - const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); - const cached = createCachedRow(classicId, KnownCaip2ChainId.Mainnet); - const { service, getByAssetIds } = createService({ - repo: { - getByAssetIds: jest.fn().mockResolvedValue([cached]), - }, - }); - - const result = await service.getAssetsMetadataByAssetIds([ - classicId, - classicId, - slipId, - slipId, - ]); - - expect(getByAssetIds).toHaveBeenCalledWith([classicId]); - expect(mockGetAssetsByAssetIds).not.toHaveBeenCalled(); - expect(result[classicId]).toMatchObject({ - symbol: cached.symbol, - name: cached.name, - }); - expect(result[slipId]).toMatchObject({ - symbol: NATIVE_ASSET_SYMBOL, - name: NATIVE_ASSET_NAME, - }); - }); - it('delegates getPersistedSep41AssetsMetadata to repository', async () => { const sepRows: StellarAssetMetadata[] = []; const getByAssetType = jest.fn().mockResolvedValue(sepRows); 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 97a1ce6b7..0abaf1648 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -91,38 +91,6 @@ export class AssetMetadataService { return found; } - /** - * Returns keyring-format metadata for the given asset IDs from snap state only. - * - * Read-only — does not fetch missing assets or persist. Intended for - * keyring balance reads so they do not contend with catalog sync. Missing - * ids are `null` until filled by `synchronize` / `resolve`. - * - * @param assetIds - The asset IDs to look up. - * @returns A Promise that resolves to all assets metadata for the given asset IDs. - */ - async getAssetsMetadataByAssetIds( - assetIds: KnownCaip19AssetIdOrSlip44Id[], - ): Promise { - this.#logger.debug('Reading assets metadata by asset ids (read-only)', { - assetIds, - }); - - const metadataByAssetId = {} as KeyringAssetMetadataByAssetId; - - const list = await this.#getPersistedOrNativeAssetsByAssetIds(assetIds); - - for (const assetId of assetIds) { - metadataByAssetId[assetId] = null; - } - - for (const asset of list) { - metadataByAssetId[asset.assetId] = toKeyringAssetMetadata(asset); - } - - return metadataByAssetId; - } - /** * Returns all persisted SEP-41 assets for the given chain ID. * 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 7646f2061..362e85621 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 @@ -141,15 +141,9 @@ export const createMockAssetMetadataService = () => { 'getByAssetIds', ); - const getAssetsMetadataByAssetIdsSpy = jest.spyOn( - AssetMetadataService.prototype, - 'getAssetsMetadataByAssetIds', - ); - return { service, assetMetadataRepositorySaveManySpy, assetMetadataRepositoryGetByAssetIdsSpy, - getAssetsMetadataByAssetIdsSpy, }; }; From ec2a57a3286475513a7c22ab997314c9e3bd3a37 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:12:35 +0800 Subject: [PATCH 02/50] chore: add asset migration --- .../providers/assets/AssetsProvider.test.ts | 13 + .../src/providers/assets/AssetsProvider.ts | 17 +- packages/stellar-wallet-snap/package.json | 5 +- .../stellar-wallet-snap/snap.manifest.json | 13 +- .../src/api/core-messenger.ts | 42 ++++ packages/stellar-wallet-snap/src/context.ts | 34 +++ .../src/handlers/accountResolver.ts | 1 + .../src/handlers/keyring/keyring.ts | 1 + .../AssetMetadataService.test.ts | 66 ++++- .../asset-metadata/AssetMetadataService.ts | 67 ++++- .../__mocks__/assets.fixtures.ts | 3 + .../src/services/asset-metadata/utils.ts | 4 +- .../src/services/assets/AssetsService.test.ts | 143 +++++++++++ .../src/services/assets/AssetsService.ts | 96 ++++++++ .../__mocks__/assetsService.fixtures.ts | 38 +++ .../assets/adapters/CoreAssetsAdapter.ts | 107 ++++++++ .../src/services/assets/api.test.ts | 170 +++++++++++++ .../src/services/assets/api.ts | 124 ++++++++++ .../src/services/assets/index.ts | 19 ++ .../src/services/assets/utils.ts | 29 +++ .../services/network/NetworkService.test.ts | 112 +++++++++ .../src/services/network/NetworkService.ts | 57 ++++- .../src/services/network/api.ts | 15 ++ .../OnChainAccountService.test.ts | 233 ++++++++++++++++++ .../on-chain-account/OnChainAccountService.ts | 220 ++++++++++++++++- .../__mocks__/onChainAccount.fixtures.ts | 6 +- yarn.lock | 3 + 27 files changed, 1622 insertions(+), 16 deletions(-) create mode 100644 packages/stellar-wallet-snap/src/api/core-messenger.ts create mode 100644 packages/stellar-wallet-snap/src/services/assets/AssetsService.test.ts create mode 100644 packages/stellar-wallet-snap/src/services/assets/AssetsService.ts create mode 100644 packages/stellar-wallet-snap/src/services/assets/__mocks__/assetsService.fixtures.ts create mode 100644 packages/stellar-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts create mode 100644 packages/stellar-wallet-snap/src/services/assets/api.test.ts create mode 100644 packages/stellar-wallet-snap/src/services/assets/api.ts create mode 100644 packages/stellar-wallet-snap/src/services/assets/index.ts create mode 100644 packages/stellar-wallet-snap/src/services/assets/utils.ts diff --git a/packages/snap-networks-utils/src/providers/assets/AssetsProvider.test.ts b/packages/snap-networks-utils/src/providers/assets/AssetsProvider.test.ts index 3f80890f7..58175b19f 100644 --- a/packages/snap-networks-utils/src/providers/assets/AssetsProvider.test.ts +++ b/packages/snap-networks-utils/src/providers/assets/AssetsProvider.test.ts @@ -81,4 +81,17 @@ describe('AssetsProvider', () => { }); }); }); + + describe('getAssetMetadata', () => { + it('calls AssetsController:getAssetMetadata', async () => { + await withAssetsProvider(async ({ assetsProvider, mockMessenger }) => { + await assetsProvider.getAssetMetadata(ASSET_ID); + + expect(mockMessenger.call).toHaveBeenCalledWith( + 'AssetsController:getAssetMetadata', + ASSET_ID, + ); + }); + }); + }); }); diff --git a/packages/snap-networks-utils/src/providers/assets/AssetsProvider.ts b/packages/snap-networks-utils/src/providers/assets/AssetsProvider.ts index 25f22abd7..0ae1b0c84 100644 --- a/packages/snap-networks-utils/src/providers/assets/AssetsProvider.ts +++ b/packages/snap-networks-utils/src/providers/assets/AssetsProvider.ts @@ -3,6 +3,7 @@ import type { AssetsControllerGetAccountAssetByIDAction, AssetsControllerGetAccountAssetsByIDsAction, AssetsControllerGetAccountAssetsByScopeAction, + AssetsControllerGetAssetMetadataAction, Caip19AssetId, } from '@metamask/assets-controller'; import type { Messenger } from '@metamask/messenger'; @@ -20,7 +21,8 @@ export const ASSETS_PROVIDER_NAME = 'AssetsProvider' as const; export type AssetsProviderAllowedActions = | AssetsControllerGetAccountAssetByIDAction | AssetsControllerGetAccountAssetsByIDsAction - | AssetsControllerGetAccountAssetsByScopeAction; + | AssetsControllerGetAccountAssetsByScopeAction + | AssetsControllerGetAssetMetadataAction; /** * Messenger restricted to actions consumed by {@link AssetsProvider}. @@ -93,4 +95,17 @@ export class AssetsProvider { scope, ); } + + /** + * Returns metadata for a CAIP-19 asset from controller state, or `undefined` + * if the asset is unknown. + * + * @param assetId - CAIP-19 asset ID. + * @returns Controller asset metadata, or `undefined`. + */ + async getAssetMetadata( + assetId: Caip19AssetId, + ): Promise> { + return this.#messenger.call('AssetsController:getAssetMetadata', assetId); + } } diff --git a/packages/stellar-wallet-snap/package.json b/packages/stellar-wallet-snap/package.json index c98786843..c2a6c2705 100644 --- a/packages/stellar-wallet-snap/package.json +++ b/packages/stellar-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/stellar-wallet-snap", - "version": "1.0.0", + "version": "1.0.0-dev.11", "description": "A Stellar wallet Snap", "keywords": [ "Ethereum", @@ -47,10 +47,13 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "devDependencies": { + "@metamask/assets-controller": "^13.0.0", "@metamask/auto-changelog": "^6.1.1", "@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": "^5.0.0", "@metamask/snap-networks-utils": "^1.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 6b1021b91..a9271f0ac 100644 --- a/packages/stellar-wallet-snap/snap.manifest.json +++ b/packages/stellar-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.0.0", + "version": "1.0.0-dev.11", "description": "Manage Stellar using MetaMask", "proposedName": "Stellar", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "4+EyTgTFLM70gl0+i/itlLe7RKHXXzkqoZ+02DMefJw=", + "shasum": "wvTA12Gcn+AB2p/rGhrZKSY6w57nNwJOp9cISp+5flQ=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -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..14a5d393e --- /dev/null +++ b/packages/stellar-wallet-snap/src/api/core-messenger.ts @@ -0,0 +1,42 @@ +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; + \ No newline at end of file diff --git a/packages/stellar-wallet-snap/src/context.ts b/packages/stellar-wallet-snap/src/context.ts index 6aa91f924..0c0d64c68 100644 --- a/packages/stellar-wallet-snap/src/context.ts +++ b/packages/stellar-wallet-snap/src/context.ts @@ -1,4 +1,13 @@ import { State } from '@metamask/snap-networks-utils'; +import { + AssetsProvider, + RemoteFeatureFlagsProvider, +} 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 { AppConfig } from './config'; @@ -39,6 +48,7 @@ import { AssetMetadataRepository, AssetMetadataService, } from './services/asset-metadata'; +import { AssetsService, CoreAssetsAdapter } from './services/assets'; import { InMemoryCache } from './services/cache'; import { NetworkService } from './services/network'; import { @@ -60,6 +70,7 @@ import { import { WalletService } from './services/wallet'; import { ConfirmationUXController } from './ui/confirmation/controller'; import { logger, noOpLogger } from './utils'; +import type { CoreMessenger } from './api/core-messenger'; assert(AppConfig, object()); @@ -76,10 +87,31 @@ const assetMetadataRepository = new AssetMetadataRepository(state); 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({ @@ -99,6 +131,7 @@ const onChainAccountService = new OnChainAccountService({ logger, networkService, onChainAccountRepository, + assetsService, }); const transactionService = new TransactionService({ @@ -305,6 +338,7 @@ const clientRequestHandler = new ClientRequestHandler({ /** ------------------------------ Export Handlers ------------------------------ */ export { + assetsService, clientRequestHandler, cronjobHandler, keyringHandler, diff --git a/packages/stellar-wallet-snap/src/handlers/accountResolver.ts b/packages/stellar-wallet-snap/src/handlers/accountResolver.ts index 341da5985..755f423cc 100644 --- a/packages/stellar-wallet-snap/src/handlers/accountResolver.ts +++ b/packages/stellar-wallet-snap/src/handlers/accountResolver.ts @@ -163,6 +163,7 @@ export class AccountResolver { const onChainAccount = await this.#onChainAccountService.resolveOnChainAccountByKeyringAccountId( account.id, + account.address, scope, ); // It is a tradeoff when loading on-chain account from state, diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 5d3806446..f171c169d 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -496,6 +496,7 @@ export class KeyringHandler implements KeyringSnapRpc { const onChainAccount = await this.#onChainAccountService.resolveOnChainAccountByKeyringAccountId( accountId, + account.address, scope, ); 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 58740d8f3..819d47dda 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,14 @@ 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 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 = @@ -80,14 +82,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'] >, @@ -120,7 +125,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(); }); @@ -287,4 +292,63 @@ describe('AssetMetadataService', () => { }), ]); }); + + it('skips catalog persist during synchronize when Core migration is on', async () => { + const { service, assetsService, saveMany } = createService({}); + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); + + await service.synchronize(KnownCaip2ChainId.Mainnet); + + expect(mockGetAssetsByChainId).not.toHaveBeenCalled(); + expect(saveMany).not.toHaveBeenCalled(); + }); + + it('maps Core catalog metadata into StellarAssetMetadata without reading snap state', async () => { + const classicId = MAINNET_CLASSIC_USDC; + const { service, assetsService, getByAssetIds, saveMany } = createService( + {}, + ); + 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 Core misses', async () => { + const classicId = MAINNET_CLASSIC_USDC; + const cached = createCachedRow(classicId, KnownCaip2ChainId.Mainnet); + const { service, assetsService, getByAssetIds } = createService({ + repo: { + getByAssetIds: jest.fn().mockResolvedValue([cached]), + }, + }); + 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 097d8b3f0..320d7b016 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, CoreAssetMetadata } 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; } /** @@ -134,7 +140,8 @@ export class AssetMetadataService { this.#logger.debug('No assets found in the state, synchronizing assets'); // It is possible that the state is empty, due to the first sync. // Hence, we synchronize the assets once. - await this.synchronize(scope); + const tokensMetadata = await this.#getAssetsByChainId(scope); + await this.#assetMetadataRepository.saveMany(tokensMetadata); } const sep41Assets = await this.getPersistedSep41AssetsMetadata(scope); @@ -149,9 +156,19 @@ export class AssetMetadataService { /** * Fetches and persists all Assets for the given chain ID from the token API. * + * When the Stellar assets migration flag is on, skips catalog persist; + * AssetsController owns fungible metadata. + * * @param scope - The chain ID to fetch and persist assets for. */ async synchronize(scope: KnownCaip2ChainId): Promise { + if (await this.#assetsService.isMigrationEnabled()) { + this.#logger.debug( + 'Skipping asset metadata catalog sync; Core migration is on', + ); + return; + } + const tokensMetadata = await this.#getAssetsByChainId(scope); await this.#assetMetadataRepository.saveMany(tokensMetadata); } @@ -167,7 +184,7 @@ export class AssetMetadataService { result.push(getNativeAssetMetadata(chainId)); } - // fetch assets from state + // fetch assets from Core, then snap state const allNonNativeAssetIds = [...assetsByChainId.values()].flat(); const { assets, missingAssetIds } = await this.#getPersistedAssetMetadata(allNonNativeAssetIds); @@ -201,12 +218,50 @@ export class AssetMetadataService { assets: StellarAssetMetadata[]; missingAssetIds: KnownCaip19AssetId[]; }> { + const { hits: coreHits, missing: missingAssetIdsFromCore } = + await this.#getCoreAssetMetadata(assetIds); + + if (missingAssetIdsFromCore.length === 0) { + return { assets: coreHits, missingAssetIds: [] }; + } + const cachedAssets = - await this.#assetMetadataRepository.getByAssetIds(assetIds); - const { hits: assets, missing: missingAssetIds } = - this.#partitionHitsAndMissingByArray(assetIds, cachedAssets); + await this.#assetMetadataRepository.getByAssetIds(missingAssetIdsFromCore); + const { hits: snapHits, missing: missingAssetIds } = + this.#partitionHitsAndMissingByArray(missingAssetIdsFromCore, cachedAssets); - return { assets, missingAssetIds }; + return { assets: [...coreHits, ...snapHits], missingAssetIds }; + } + + async #getCoreAssetMetadata(assetIds: KnownCaip19AssetId[]): Promise<{ + hits: StellarAssetMetadata[]; + missing: KnownCaip19AssetId[]; + }> { + const hits: StellarAssetMetadata[] = []; + const missing: KnownCaip19AssetId[] = []; + + if (assetIds.length > 0) { + // The order doesnt 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, + })) + } + missing.push(assetId);; + } + ), + ); + } + return { hits, missing }; } async #fetchMissingAssetsMetadata( 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 55a1ed079..a0246ec19 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 @@ -5,6 +5,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 { InMemoryCache } from '../../cache'; import { NetworkService } from '../../network'; import type { AssetMetadataByAssetId, StellarAssetMetadata } from '../api'; @@ -74,6 +75,7 @@ export const getMockSep41Assets = (): StellarAssetMetadata[] => { }; export const createMockAssetMetadataService = () => { + const { service: assetsService } = createMockAssetsService(); const service = new AssetMetadataService({ networkService: new NetworkService({ logger, @@ -85,6 +87,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 new file mode 100644 index 000000000..d0be8d100 --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/assets/AssetsService.test.ts @@ -0,0 +1,143 @@ +import { SnapsAssetsMigrationStage } from '@metamask/assets-controller'; +import type { Asset } from '@metamask/assets-controller'; +import type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils'; + +import { KnownCaip2ChainId } from '../../api'; +import { getSlip44AssetId } from '../../utils'; +import { + USDC_CLASSIC, + USDC_SEP41, +} from '../asset-metadata/__mocks__/assets.fixtures'; +import type { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; +import { AssetsService } from './AssetsService'; +import { createMockAssetsService } from './__mocks__/assetsService.fixtures'; + +describe('AssetsService', () => { + const assetId = USDC_CLASSIC; + const coreMetadata = { + symbol: 'USDC', + decimals: 7, + address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }; + + it('parses Core catalog metadata into CoreAssetMetadata', async () => { + const { service, getAssetMetadata } = createMockAssetsService(); + getAssetMetadata.mockResolvedValue({ + ...coreMetadata, + name: 'USD Coin', + image: 'https://example.test/usdc.png', + }); + + await expect(service.getAssetMetadata(assetId)).resolves.toMatchObject( + coreMetadata, + ); + expect(getAssetMetadata).toHaveBeenCalledWith(assetId); + }); + + it('returns null when Core misses', async () => { + const { service, getAssetMetadata } = createMockAssetsService(); + + await expect(service.getAssetMetadata(assetId)).resolves.toBeNull(); + expect(getAssetMetadata).toHaveBeenCalledWith(assetId); + }); + + it('returns null when Core metadata is not Stellar-shaped', async () => { + const { service, getAssetMetadata } = createMockAssetsService(); + getAssetMetadata.mockResolvedValue({ symbol: 'USDC' }); + + await expect(service.getAssetMetadata(assetId)).resolves.toBeNull(); + }); + + it('validates account assets into CoreAsset and drops invalid rows', async () => { + const nativeId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const getAccountAssetsByScope = jest.fn().mockResolvedValue([ + { + id: nativeId, + chainId: KnownCaip2ChainId.Mainnet, + balance: { + amount: '5', + metadata: { + spendableBalance: '40000000', + minimumReserveBalance: '10000000', + decimal: 7, + }, + }, + metadata: { symbol: 'XLM', decimals: 7 }, + price: { value: 0, currency: 'usd' }, + fiatValue: 0, + }, + { + id: USDC_CLASSIC, + chainId: KnownCaip2ChainId.Mainnet, + balance: { + amount: '3', + metadata: { limit: '1000', authorized: true, sponsored: false }, + }, + metadata: { symbol: 'USDC', decimals: 7 }, + }, + { + id: USDC_SEP41, + chainId: KnownCaip2ChainId.Mainnet, + balance: { amount: '2' }, + metadata: { symbol: 'USDC', decimals: 7 }, + }, + { + id: 'eip155:1/slip44:60', + chainId: 'eip155:1', + balance: { amount: '1' }, + metadata: { symbol: 'ETH', decimals: 18 }, + }, + ] as Asset[]); + + const coreAdapter = { + getAssetMetadata: jest.fn(), + getAccountAssetsByScope, + } as unknown as CoreAssetsAdapter; + + const remoteFeatureFlagsProvider = { + getFeatureFlag: jest.fn().mockResolvedValue({ + value: { stage: SnapsAssetsMigrationStage.Off }, + }), + } as unknown as RemoteFeatureFlagsProvider; + + const service = new AssetsService({ + coreAdapter, + remoteFeatureFlagsProvider, + }); + + await expect( + service.getAccountAssetsByScope(KnownCaip2ChainId.Mainnet, 'acct-1'), + ).resolves.toMatchObject([ + { + id: nativeId, + chainId: KnownCaip2ChainId.Mainnet, + balance: { + amount: '5', + metadata: { + spendableBalance: '40000000', + minimumReserveBalance: '10000000', + decimal: 7, + }, + }, + metadata: { symbol: 'XLM', decimals: 7 }, + price: { value: 0, currency: 'usd' }, + fiatValue: 0, + }, + { + id: USDC_CLASSIC, + chainId: KnownCaip2ChainId.Mainnet, + balance: { + amount: '3', + metadata: { limit: '1000', authorized: true, sponsored: false }, + }, + metadata: { symbol: 'USDC', decimals: 7 }, + }, + { + id: USDC_SEP41, + chainId: KnownCaip2ChainId.Mainnet, + balance: { amount: '2' }, + metadata: { symbol: 'USDC', decimals: 7 }, + }, + ]); + }); +}); diff --git a/packages/stellar-wallet-snap/src/services/assets/AssetsService.ts b/packages/stellar-wallet-snap/src/services/assets/AssetsService.ts new file mode 100644 index 000000000..a411739e8 --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/assets/AssetsService.ts @@ -0,0 +1,96 @@ +import type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils'; + +import type { KnownCaip19AssetIdOrSlip44Id, KnownCaip2ChainId } from '../../api'; +import type { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; +import { parseCoreAsset, parseCoreAssetMetadata } from './api'; +import type { CoreAsset, CoreAssetMetadata } from './api'; +import { isAssetsMigrationEnabled } from './utils'; + +/** + * Core AssetsController facade. Account holdings stay behind the Stellar + * migration flag. {@link getAssetMetadata} is a Core-only lookup (no snap + * fallback); domain services consume it and stay the caller-facing API. + * + * Account-asset and catalog methods validate controller rows into + * {@link CoreAsset} / {@link CoreAssetMetadata} before returning. Domain services + * map those into snap types. + */ +export class AssetsService { + readonly #coreAdapter: CoreAssetsAdapter; + + readonly #remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; + + constructor({ + coreAdapter, + remoteFeatureFlagsProvider, + }: { + coreAdapter: CoreAssetsAdapter; + remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider; + }) { + this.#coreAdapter = coreAdapter; + this.#remoteFeatureFlagsProvider = remoteFeatureFlagsProvider; + } + + async isMigrationEnabled(): Promise { + return isAssetsMigrationEnabled(this.#remoteFeatureFlagsProvider); + } + + async getAssetMetadata( + assetId: KnownCaip19AssetIdOrSlip44Id, + ): Promise { + const metadata = await this.#coreAdapter.getAssetMetadata(assetId); + return parseCoreAssetMetadata(metadata); + } + + async getAccountAssetByID( + accountId: string, + assetId: KnownCaip19AssetIdOrSlip44Id, + ): Promise { + const asset = await this.#coreAdapter.getAccountAssetByID( + accountId, + assetId, + ); + + return parseCoreAsset(asset); + } + + async getAccountAssetsByIDs( + accountId: string, + assetIds: KnownCaip19AssetIdOrSlip44Id[], + ): Promise<(CoreAsset | null)[]> { + if (assetIds.length === 0) { + return []; + } + + const assets = await this.#coreAdapter.getAccountAssetsByIDs( + accountId, + assetIds, + ); + + return assets.map((asset) => parseCoreAsset(asset)); + } + + async getAccountAssetsByScope( + scope: KnownCaip2ChainId, + accountId: string, + ): Promise { + const assets = await this.#coreAdapter.getAccountAssetsByScope( + scope, + accountId, + ); + + return assets.flatMap((asset) => { + const parsed = parseCoreAsset(asset); + return parsed === null ? [] : [parsed]; + }); + } + + async getAccountAssets(accountId: string): Promise { + const assets = await this.#coreAdapter.getAccountAssets(accountId); + + return assets.flatMap((asset) => { + const parsed = parseCoreAsset(asset); + return parsed === null ? [] : [parsed]; + }); + } +} diff --git a/packages/stellar-wallet-snap/src/services/assets/__mocks__/assetsService.fixtures.ts b/packages/stellar-wallet-snap/src/services/assets/__mocks__/assetsService.fixtures.ts new file mode 100644 index 000000000..7473268bb --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/assets/__mocks__/assetsService.fixtures.ts @@ -0,0 +1,38 @@ +import { SnapsAssetsMigrationStage } from '@metamask/assets-controller'; +import type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils'; + +import type { CoreAssetsAdapter } from '../adapters/CoreAssetsAdapter'; +import { AssetsService } from '../AssetsService'; + +/** + * Builds an {@link AssetsService} with a mocked Core adapter. Flag defaults to Off. + * + * @param options - Optional Core / flag overrides. + * @returns The facade and the Core `getAssetMetadata` mock. + */ +export function createMockAssetsService({ + migrationStage = SnapsAssetsMigrationStage.Off, +}: { + migrationStage?: SnapsAssetsMigrationStage; +} = {}): { + service: AssetsService; + getAssetMetadata: jest.Mock; +} { + const getAssetMetadata = jest.fn().mockResolvedValue(null); + const coreAdapter = { + getAssetMetadata, + } as unknown as CoreAssetsAdapter; + + const remoteFeatureFlagsProvider = { + getFeatureFlag: jest.fn().mockResolvedValue({ + value: { stage: migrationStage }, + }), + } as unknown as RemoteFeatureFlagsProvider; + + const service = new AssetsService({ + coreAdapter, + remoteFeatureFlagsProvider, + }); + + return { service, getAssetMetadata }; +} diff --git a/packages/stellar-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts b/packages/stellar-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts new file mode 100644 index 000000000..4fc8ab23a --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts @@ -0,0 +1,107 @@ +import type { Asset, Caip19AssetId, AssetMetadata } from '@metamask/assets-controller'; +import type { AssetsProvider, Logger } from '@metamask/snap-networks-utils'; + +import { KnownCaip2ChainId } from '../../../api'; + +export type CoreAssetsAdapterOptions = { + logger: Logger; + getAccountAssetByID: AssetsProvider['getAccountAssetByID']; + getAccountAssetsByIDs: AssetsProvider['getAccountAssetsByIDs']; + getAccountAssetsByScope: AssetsProvider['getAccountAssetsByScope']; + getAssetMetadata: AssetsProvider['getAssetMetadata']; +}; + +/** + * Reads fungible holdings and metadata from AssetsController. + * + * Account holdings are returned as controller {@link Asset} rows. + * {@link AssetsService} validates them into the Stellar shape. + */ +export class CoreAssetsAdapter { + readonly #logger: Logger; + + readonly #getAccountAssetByID: AssetsProvider['getAccountAssetByID']; + + readonly #getAccountAssetsByIDs: AssetsProvider['getAccountAssetsByIDs']; + + readonly #getAccountAssetsByScope: AssetsProvider['getAccountAssetsByScope']; + + readonly #getAssetMetadata: AssetsProvider['getAssetMetadata']; + + constructor(options: CoreAssetsAdapterOptions) { + const { + logger, + getAccountAssetByID, + getAccountAssetsByIDs, + getAccountAssetsByScope, + getAssetMetadata, + } = options; + + this.#logger = logger.withPrefix('[CoreAssetsAdapter]'); + this.#getAccountAssetByID = getAccountAssetByID; + this.#getAccountAssetsByIDs = getAccountAssetsByIDs; + this.#getAccountAssetsByScope = getAccountAssetsByScope; + this.#getAssetMetadata = getAssetMetadata; + } + + async getAccountAssetByID( + accountId: string, + assetId: Caip19AssetId, + ): Promise { + this.#logger.debug('Getting account asset by ID', { accountId, assetId }); + const asset = await this.#getAccountAssetByID(accountId, assetId); + + return asset ?? null; + } + + async getAccountAssetsByIDs( + accountId: string, + assetIds: Caip19AssetId[], + ): Promise<(Asset | null)[]> { + this.#logger.debug('Getting account assets by IDs', { + accountId, + assetIds, + }); + const assets = await this.#getAccountAssetsByIDs(accountId, assetIds); + + return assetIds.map((assetId) => assets[assetId] ?? null); + } + + async getAccountAssetsByScope( + scope: KnownCaip2ChainId, + accountId: string, + ): Promise { + this.#logger.debug('Getting account assets by scope', { + scope, + accountId, + }); + const controllerAssets = await this.#getAccountAssetsByScope( + scope, + accountId, + ); + + return Object.values(controllerAssets); + } + + async getAccountAssets(accountId: string): Promise { + const [mainnetAssets, testnetAssets] = await Promise.all([ + this.getAccountAssetsByScope(KnownCaip2ChainId.Mainnet, accountId), + this.getAccountAssetsByScope(KnownCaip2ChainId.Testnet, accountId), + ]); + + return [...mainnetAssets, ...testnetAssets]; + } + + async getAssetMetadata( + assetId: Caip19AssetId, + ): Promise { + this.#logger.debug('Getting asset metadata', { assetId }); + const metadata = await this.#getAssetMetadata(assetId); + + if (!metadata) { + return null; + } + + return metadata; + } +} diff --git a/packages/stellar-wallet-snap/src/services/assets/api.test.ts b/packages/stellar-wallet-snap/src/services/assets/api.test.ts new file mode 100644 index 000000000..be2f4da55 --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/assets/api.test.ts @@ -0,0 +1,170 @@ +import { create, is } from '@metamask/superstruct'; + +import { KnownCaip2ChainId } from '../../api'; +import { getSlip44AssetId } from '../../utils'; +import { + USDC_CLASSIC, + USDC_SEP41, +} from '../asset-metadata/__mocks__/assets.fixtures'; +import { parseCoreAsset, parseCoreAssetMetadata, CoreAssetStruct } from './api'; + +const NATIVE_ID = getSlip44AssetId(KnownCaip2ChainId.Mainnet); +const USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + +const nativeAsset = { + id: NATIVE_ID, + chainId: KnownCaip2ChainId.Mainnet, + balance: { + amount: '5', + metadata: { + spendableBalance: '40000000', + minimumReserveBalance: '10000000', + decimal: 7, + }, + }, + metadata: { symbol: 'XLM', decimals: 7 }, + price: { value: 0, currency: 'usd' }, + fiatValue: 0, +}; + +const classicAsset = { + id: USDC_CLASSIC, + chainId: KnownCaip2ChainId.Mainnet, + balance: { + amount: '3', + metadata: { + limit: '1000', + authorized: true, + sponsored: false, + }, + }, + metadata: { symbol: 'USDC', decimals: 7, address: USDC_ISSUER }, +}; + +const sep41Asset = { + id: USDC_SEP41, + chainId: KnownCaip2ChainId.Mainnet, + balance: { amount: '2' }, + metadata: { symbol: 'USDC', decimals: 7 }, +}; + +describe('CoreAssetStruct', () => { + it('accepts native slip44 with amount + native balance metadata', () => { + const parsed = create(nativeAsset, CoreAssetStruct); + + expect(parsed).toMatchObject({ + id: NATIVE_ID, + chainId: KnownCaip2ChainId.Mainnet, + balance: { + amount: '5', + metadata: { + spendableBalance: '40000000', + minimumReserveBalance: '10000000', + decimal: 7, + }, + }, + metadata: { symbol: 'XLM', decimals: 7 }, + price: { value: 0, currency: 'usd' }, + fiatValue: 0, + }); + }); + + it('accepts classic assets with amount + classic balance metadata', () => { + expect(is(classicAsset, CoreAssetStruct)).toBe(true); + }); + + it('accepts SEP-41 assets with amount-only balance', () => { + expect(is(sep41Asset, CoreAssetStruct)).toBe(true); + }); + + it('rejects a non-Stellar CAIP-19 id', () => { + expect( + is( + { + ...nativeAsset, + id: 'eip155:1/slip44:60', + chainId: 'eip155:1', + }, + CoreAssetStruct, + ), + ).toBe(false); + }); + + it('rejects classic assets missing balance metadata', () => { + expect( + is( + { + ...classicAsset, + balance: { amount: '3' }, + }, + CoreAssetStruct, + ), + ).toBe(false); + }); + + it('rejects native assets missing balance metadata', () => { + expect( + is( + { + ...nativeAsset, + balance: { amount: '5' }, + }, + CoreAssetStruct, + ), + ).toBe(false); + }); + + it('rejects metadata without decimals', () => { + expect( + is( + { + ...sep41Asset, + metadata: { symbol: 'USDC' }, + }, + CoreAssetStruct, + ), + ).toBe(false); + }); +}); + +describe('parseCoreAsset', () => { + it('returns the Stellar-shaped asset for a valid Core payload', () => { + expect(parseCoreAsset(classicAsset)).toMatchObject({ + id: USDC_CLASSIC, + chainId: KnownCaip2ChainId.Mainnet, + balance: { + amount: '3', + metadata: { limit: '1000', authorized: true, sponsored: false }, + }, + metadata: { symbol: 'USDC', decimals: 7, address: USDC_ISSUER }, + }); + }); + + it('returns null when the payload is not Stellar-shaped', () => { + expect(parseCoreAsset({ id: 'not-an-asset' })).toBeNull(); + }); +}); + +describe('parseCoreAssetMetadata', () => { + it('returns symbol, decimals, and optional fields', () => { + expect( + parseCoreAssetMetadata({ + symbol: 'USDC', + decimals: 7, + address: USDC_ISSUER, + name: 'USD Coin', + image: 'https://example.test/usdc.png', + }), + ).toMatchObject({ + symbol: 'USDC', + decimals: 7, + address: USDC_ISSUER, + name: 'USD Coin', + image: 'https://example.test/usdc.png', + }); + }); + + it('returns null when decimals are missing', () => { + expect(parseCoreAssetMetadata({ symbol: 'USDC' })).toBeNull(); + }); +}); diff --git a/packages/stellar-wallet-snap/src/services/assets/api.ts b/packages/stellar-wallet-snap/src/services/assets/api.ts new file mode 100644 index 000000000..8dd967baf --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/assets/api.ts @@ -0,0 +1,124 @@ +import { boolean, create, defaulted, number, object, optional, string, type, union } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +import { + KnownCaip19ClassicAssetStruct, + KnownCaip19Sep41AssetStruct, + KnownCaip19Slip44IdStruct, + KnownCaip2ChainIdStruct, +} from '../../api'; + +/** + * Controller `Asset.metadata` fields Stellar needs from Core. + * Uses `type` so other metadata fields from Core are allowed. + */ +export const CoreAssetMetadataStruct = type({ + symbol: string(), + decimals: number(), + /** Classic issuer (or other address); optional for native / SEP-41. */ + address: optional(string()), + name: optional(string()), + image: optional(string()), +}); + +/** Core `Asset.balance` fields shared by all asset types. */ +export const CoreAssetBalanceStruct = type({ + amount: string(), +}); + +/** Core `Asset.balance` for slip44: amount + native keyring balance metadata. */ +export const CoreNativeBalanceStruct = type({ + amount: CoreAssetBalanceStruct.schema.amount, + metadata: object({ + spendableBalance: string(), + minimumReserveBalance: string(), + decimal: number(), + }), +}); + +/** Core `Asset.balance` for classic assets: amount + classic keyring balance metadata. */ +export const CoreClassicBalanceStruct = type({ + amount: CoreAssetBalanceStruct.schema.amount, + metadata: object({ + limit: string(), + authorized: defaulted(boolean(), true), + sponsored: defaulted(boolean(), false), + }), +}); + +const Slip44CoreAssetStruct = type({ + id: KnownCaip19Slip44IdStruct, + chainId: KnownCaip2ChainIdStruct, + balance: CoreNativeBalanceStruct, + metadata: CoreAssetMetadataStruct, +}); + +const ClassicCoreAssetStruct = type({ + id: KnownCaip19ClassicAssetStruct, + chainId: KnownCaip2ChainIdStruct, + balance: CoreClassicBalanceStruct, + metadata: CoreAssetMetadataStruct, +}); + +const Sep41CoreAssetStruct = type({ + id: KnownCaip19Sep41AssetStruct, + chainId: KnownCaip2ChainIdStruct, + balance: CoreAssetBalanceStruct, + metadata: CoreAssetMetadataStruct, +}); + +/** + * Stellar-shaped subset of AssetsController {@link Asset}: `id`, `chainId`, + * `balance`, and `metadata`. Uses `type` so extra controller fields are allowed. + */ +export const CoreAssetStruct = union([ + Slip44CoreAssetStruct, + ClassicCoreAssetStruct, + Sep41CoreAssetStruct, +]); + +export type CoreAsset = Infer; +export type CoreNativeAsset = Infer; +export type CoreClassicAsset = Infer; +export type CoreSep41Asset = Infer; +export type CoreAssetMetadata = Infer; + +export function isCoreNativeAsset(asset: CoreAsset): asset is CoreNativeAsset { + return Slip44CoreAssetStruct.is(asset); +} + +export function isCoreClassicAsset(asset: CoreAsset): asset is CoreClassicAsset { + return ClassicCoreAssetStruct.is(asset); +} + +export function isCoreSep41Asset(asset: CoreAsset): asset is CoreSep41Asset { + return Sep41CoreAssetStruct.is(asset); +} + +/** + * Validates a Core `Asset` into {@link CoreAsset}. + * + * @param value - Raw AssetsController asset. + * @returns The Stellar-shaped asset, or `null` when the payload is not Stellar. + */ +export function parseCoreAsset(value: unknown): CoreAsset | null { + try { + return create(value, CoreAssetStruct); + } catch { + return null; + } +} + +/** + * Validates a Core `Asset.metadata` into {@link CoreAssetMetadata}. + * + * @param value - Raw AssetsController asset metadata. + * @returns The Stellar-shaped metadata, or `null` when the payload is not Stellar. + */ +export function parseCoreAssetMetadata(value: unknown): CoreAssetMetadata | null { + try { + return create(value, CoreAssetMetadataStruct); + } catch { + return null; + } +} \ No newline at end of file diff --git a/packages/stellar-wallet-snap/src/services/assets/index.ts b/packages/stellar-wallet-snap/src/services/assets/index.ts new file mode 100644 index 000000000..222354417 --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/assets/index.ts @@ -0,0 +1,19 @@ +export { AssetsService } from './AssetsService'; +export { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; +export type { CoreAssetsAdapterOptions } from './adapters/CoreAssetsAdapter'; +export { + parseCoreAsset, + parseCoreAssetMetadata, + CoreAssetStruct, + isCoreNativeAsset, + isCoreClassicAsset, + isCoreSep41Asset, +} from './api'; +export type { + CoreAsset, + CoreNativeAsset, + CoreClassicAsset, + CoreSep41Asset, + CoreAssetMetadata, +} from './api'; +export { isAssetsMigrationEnabled } from './utils'; diff --git a/packages/stellar-wallet-snap/src/services/assets/utils.ts b/packages/stellar-wallet-snap/src/services/assets/utils.ts new file mode 100644 index 000000000..e8c150564 --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/assets/utils.ts @@ -0,0 +1,29 @@ +import { + SNAPS_ASSETS_MIGRATION_FLAG_KEYS, + SnapsAssetsMigrationStage, + parseSnapsAssetsMigrationStage, +} from '@metamask/assets-controller'; +import type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils'; + +/** + * Returns whether Stellar should read fungible holdings / catalog hits from + * AssetsController instead of snap state. + * + * Any stage other than {@link SnapsAssetsMigrationStage.Off} enables Core reads. + * + * @param remoteFeatureFlagsProvider - Remote feature-flag messenger wrapper. + * @returns Whether the Stellar assets migration flag is active. + */ +export async function isAssetsMigrationEnabled( + remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider, +): Promise { + return true; + const flagValue = await remoteFeatureFlagsProvider.getFeatureFlag( + SNAPS_ASSETS_MIGRATION_FLAG_KEYS.stellar, + ); + + return ( + parseSnapsAssetsMigrationStage(flagValue) !== + SnapsAssetsMigrationStage.Off + ); +} 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 454be3ec3..dae01ec1f 100644 --- a/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts +++ b/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts @@ -81,6 +81,10 @@ describe('NetworkService', () => { 'sendTransaction', ), getAccountSpy: jest.spyOn(StellarRpc.Server.prototype, 'getAccount'), + getAccountEntrySpy: jest.spyOn( + StellarRpc.Server.prototype, + 'getAccountEntry', + ), getLedgerEntriesSpy: jest.spyOn( StellarRpc.Server.prototype, 'getLedgerEntries', @@ -463,6 +467,114 @@ describe('NetworkService', () => { }); }); + describe('getAccountLedgerMeta', () => { + const testAddress = + 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG'; + + const mockAccountEntry = { + numSubEntries: () => 4, + seqNum: () => ({ + toString: () => '262764252333343491', + }), + balance: () => ({ + toString: () => '351010623', + }), + ext: () => ({ + switch: () => 1, + v1: () => ({ + ext: () => ({ + switch: () => 2, + v2: () => ({ + numSponsoring: () => 1, + numSponsored: () => 0, + }), + }), + }), + }), + }; + + it('returns sequence, subentry, and sponsorship counts from RPC getAccountEntry', async () => { + const { getAccountEntrySpy } = getRpcServerSpies(); + getAccountEntrySpy.mockResolvedValue(mockAccountEntry as never); + + await expect( + networkService.getAccountLedgerMeta(testAddress, scope), + ).resolves.toStrictEqual({ + sequenceNumber: '262764252333343491', + subentryCount: 4, + numSponsoring: 1, + numSponsored: 0, + rawNativeBalance: '351010623', + }); + expect(getAccountEntrySpy).toHaveBeenCalledWith(testAddress); + }); + + it('throws when the account v1 extension is missing', async () => { + const { getAccountEntrySpy } = getRpcServerSpies(); + getAccountEntrySpy.mockResolvedValue({ + numSubEntries: () => 3, + ext: () => ({ + switch: () => 0, + v1: () => { + throw new Error('v1 arm not set'); + }, + }), + } as never); + + await expect( + networkService.getAccountLedgerMeta(testAddress, scope), + ).rejects.toThrow( + `Failed to get account ledger meta for address: ${testAddress} for scope: ${scope}: expected account extension v1, got switch 0`, + ); + }); + + it('throws when the account v2 extension is missing', async () => { + const { getAccountEntrySpy } = getRpcServerSpies(); + getAccountEntrySpy.mockResolvedValue({ + numSubEntries: () => 3, + ext: () => ({ + switch: () => 1, + v1: () => ({ + ext: () => ({ + switch: () => 0, + v2: () => { + throw new Error('v2 arm not set'); + }, + }), + }), + }), + } as never); + + await expect( + networkService.getAccountLedgerMeta(testAddress, scope), + ).rejects.toThrow( + `Failed to get account ledger meta for address: ${testAddress} for scope: ${scope}: expected account extension v2, got switch 0`, + ); + }); + + it('throws AccountNotActivatedException when RPC uses Soroban missing-account error shape', async () => { + const { getAccountEntrySpy } = getRpcServerSpies(); + getAccountEntrySpy.mockRejectedValue( + new Error(`Account not found: ${testAddress}`), + ); + + await expect( + networkService.getAccountLedgerMeta(testAddress, scope), + ).rejects.toThrow(AccountNotActivatedException); + }); + + it('throws NetworkServiceException for other RPC errors', async () => { + const { getAccountEntrySpy } = getRpcServerSpies(); + getAccountEntrySpy.mockRejectedValue(new Error('RPC unavailable')); + + await expect( + networkService.getAccountLedgerMeta(testAddress, scope), + ).rejects.toThrow( + `Failed to get account ledger meta for address: ${testAddress} for scope: ${scope}`, + ); + }); + }); + describe('getSep41AssetsData', () => { it('throws NetworkServiceException when getLedgerEntries fails', async () => { const { getLedgerEntriesSpy } = getRpcServerSpies(); diff --git a/packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/packages/stellar-wallet-snap/src/services/network/NetworkService.ts index a74cd4415..36145f2d0 100644 --- a/packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -6,6 +6,7 @@ import { Horizon as StellarHorizon, NotFoundError, rpc, + Account, } from '@stellar/stellar-sdk'; import type { @@ -39,7 +40,7 @@ import { extractAssetDataFromContractData, getAddress, } from '../transaction/xdrParser'; -import type { AssetDataResponse } from './api'; +import type { AccountLedgerMeta, AssetDataResponse } from './api'; import { KnownRpcError } from './api'; import { AccountNotActivatedException, @@ -362,6 +363,60 @@ export class NetworkService { } } + /** + * Loads account ledger meta (sequence, subentries, sponsorship counts) from Soroban RPC. + * + * Uses RPC `getAccountEntry`. Sequence is `seqNum().toString()`, matching SDK `getAccount`. + * + * @param accountAddress - The Stellar account address (public key). + * @param scope - The CAIP-2 chain ID. + * @returns Sequence, subentry count, sponsorship counters, and native stroops. + * @throws {AccountNotActivatedException} If the account does not exist on the network. + * @throws {NetworkServiceException} If the RPC request fails, or the account entry is missing + * the v1 / v2 extensions required for sponsorship counts. + */ + async getAccountLedgerMeta( + accountAddress: string, + scope: KnownCaip2ChainId, + ): Promise { + try { + const client = this.#getRpcClient(scope); + const entry = await client.getAccountEntry(accountAddress); + + const accountExt = entry.ext(); + if (accountExt.switch() !== 1) { + throw new NetworkServiceException( + `Failed to get account ledger meta for address: ${accountAddress} for scope: ${scope}: expected account extension v1, got switch ${accountExt.switch()}`, + ); + } + const v1Ext = accountExt.v1().ext(); + if (v1Ext.switch() !== 2) { + throw new NetworkServiceException( + `Failed to get account ledger meta for address: ${accountAddress} for scope: ${scope}: expected account extension v2, got switch ${v1Ext.switch()}`, + ); + } + + const v2 = v1Ext.v2(); + return { + sequenceNumber: entry.seqNum().toString(), + subentryCount: entry.numSubEntries(), + numSponsoring: v2.numSponsoring(), + numSponsored: v2.numSponsored(), + rawNativeBalance: entry.balance().toString(), + }; + } catch (error: unknown) { + if (isAccountNotFoundError(error, accountAddress)) { + throw new AccountNotActivatedException(accountAddress, scope, { + cause: error, + }); + } + return this.#throwError({ + error, + fallbackError: `Failed to get account ledger meta for address: ${accountAddress} for scope: ${scope}`, + }); + } + } + /** * Batch loads token contract ledger entries over Soroban RPC. * diff --git a/packages/stellar-wallet-snap/src/services/network/api.ts b/packages/stellar-wallet-snap/src/services/network/api.ts index 0faa9fd76..b09c6f3e2 100644 --- a/packages/stellar-wallet-snap/src/services/network/api.ts +++ b/packages/stellar-wallet-snap/src/services/network/api.ts @@ -34,3 +34,18 @@ export type AssetDataResponse = { // CAIP-19 classic asset id (`…/asset:CODE-ISSUER`) from RPC / Stellar asset contract assetId: KnownCaip19AssetId; }; + +/** + * Account ledger fields from Soroban RPC `getAccountEntry`. + * + * `sequenceNumber` is `AccountEntry.seqNum` as a decimal string (same encoding + * as SDK `rpc.Server.getAccount` / Horizon `sequence`). + */ +export type AccountLedgerMeta = { + sequenceNumber: string; + subentryCount: number; + numSponsoring: number; + numSponsored: number; + /** Native XLM in stroops (`AccountEntry.balance`). */ + rawNativeBalance: string; +}; diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index 91309740d..7d026b2ab 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -2,6 +2,7 @@ import { hexToBytes } from '@metamask/utils'; import { Keypair } from '@stellar/stellar-sdk'; import { KnownCaip2ChainId } from '../../api'; +import { getSlip44AssetId } from '../../utils'; import { bufferToUint8Array } from '../../utils/buffer'; import { generateMockStellarKeyringAccounts, @@ -33,6 +34,10 @@ describe('OnChainAccountService', () => { const getNetworkServiceSpies = () => ({ getAccountSpy: jest.spyOn(NetworkService.prototype, 'getAccount'), + getAccountLedgerMetaSpy: jest.spyOn( + NetworkService.prototype, + 'getAccountLedgerMeta', + ), loadOnChainAccountSpy: jest.spyOn( NetworkService.prototype, 'loadOnChainAccount', @@ -163,6 +168,7 @@ describe('OnChainAccountService', () => { describe('resolveOnChainAccountByKeyringAccountId', () => { it('returns null when no snapshot exists for the keyring id and scope', async () => { const keyringAccountId = globalThis.crypto.randomUUID(); + const accountAddress = Keypair.random().publicKey(); const { onChainAccountService, onChainAccountRepository } = mockOnChainAccountService(); const findByAccountIdSpy = jest.spyOn( @@ -174,6 +180,7 @@ describe('OnChainAccountService', () => { const result = await onChainAccountService.resolveOnChainAccountByKeyringAccountId( keyringAccountId, + accountAddress, KnownCaip2ChainId.Mainnet, ); @@ -208,6 +215,7 @@ describe('OnChainAccountService', () => { const result = await onChainAccountService.resolveOnChainAccountByKeyringAccountId( keyringAccountId, + signer.publicKey(), KnownCaip2ChainId.Mainnet, ); @@ -220,6 +228,217 @@ describe('OnChainAccountService', () => { }); }); + describe('resolveOnChainAccountFromCore', () => { + const usdcIssuer = + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + const usdcId = + `stellar:pubnet/asset:USDC-${usdcIssuer}` as const; + const sep41Id = + 'stellar:pubnet/sep41:CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75' as const; + + it('returns null when Core migration is off', async () => { + const { onChainAccountService, assetsService } = + mockOnChainAccountService(); + const getAccountAssetsByScopeSpy = jest.spyOn( + assetsService, + 'getAccountAssetsByScope', + ); + + expect( + await onChainAccountService.resolveOnChainAccountFromCore( + KnownCaip2ChainId.Mainnet, + globalThis.crypto.randomUUID(), + Keypair.random().publicKey(), + ), + ).toBeNull(); + expect(getAccountAssetsByScopeSpy).not.toHaveBeenCalled(); + }); + + it('returns null when Core returns no holdings', async () => { + const { onChainAccountService, assetsService } = + mockOnChainAccountService(); + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); + jest.spyOn(assetsService, 'getAccountAssetsByScope').mockResolvedValue([]); + + expect( + await onChainAccountService.resolveOnChainAccountFromCore( + KnownCaip2ChainId.Mainnet, + globalThis.crypto.randomUUID(), + Keypair.random().publicKey(), + ), + ).toBeNull(); + }); + + it('binds native, classic, and SEP-41 holdings from Core', async () => { + const accountAddress = Keypair.random().publicKey(); + const keyringAccountId = globalThis.crypto.randomUUID(); + const { onChainAccountService, assetsService } = + mockOnChainAccountService(); + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); + const getAccountAssetsByScopeSpy = jest + .spyOn(assetsService, 'getAccountAssetsByScope') + .mockResolvedValue([ + { + id: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + chainId: KnownCaip2ChainId.Mainnet, + balance: { + amount: '5', + metadata: { + spendableBalance: '40000000', + minimumReserveBalance: '10000000', + decimal: 7, + }, + }, + metadata: { symbol: 'XLM', decimals: 7 }, + }, + { + id: usdcId, + chainId: KnownCaip2ChainId.Mainnet, + balance: { + amount: '3', + metadata: { + limit: '1000', + authorized: true, + sponsored: false, + }, + }, + metadata: { + symbol: 'USDC', + decimals: 7, + address: usdcIssuer, + }, + }, + { + id: sep41Id, + chainId: KnownCaip2ChainId.Mainnet, + balance: { amount: '2' }, + metadata: { symbol: 'TA', decimals: 7 }, + }, + ]); + + const result = + await onChainAccountService.resolveOnChainAccountFromCore( + KnownCaip2ChainId.Mainnet, + keyringAccountId, + accountAddress, + ); + + expect(result).toBeInstanceOf(OnChainAccount); + const full = result?.toSerializableFull(); + expect(full).toMatchObject({ + accountId: accountAddress, + sequenceNumber: '0', + scope: KnownCaip2ChainId.Mainnet, + rawNativeBalance: '50000000', + meta: { + subentryCount: 0, + numSponsoring: 0, + numSponsored: 0, + }, + }); + expect(full?.balances).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + assetId: usdcId, + balance: '30000000', + symbol: 'USDC', + address: usdcIssuer, + limit: '10000000000', + authorized: true, + sponsored: false, + }), + expect.objectContaining({ + assetId: sep41Id, + balance: '20000000', + symbol: 'TA', + decimals: 7, + }), + ]), + ); + expect(getAccountAssetsByScopeSpy).toHaveBeenCalledWith( + KnownCaip2ChainId.Mainnet, + keyringAccountId, + ); + }); + + it('overlays RPC ledger meta when resolveAccountFromNetwork is true', async () => { + const accountAddress = Keypair.random().publicKey(); + const { getAccountLedgerMetaSpy } = getNetworkServiceSpies(); + getAccountLedgerMetaSpy.mockResolvedValue({ + sequenceNumber: '99', + subentryCount: 4, + numSponsoring: 2, + numSponsored: 0, + rawNativeBalance: '351010623', + }); + + const { onChainAccountService, assetsService } = + mockOnChainAccountService(); + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); + jest.spyOn(assetsService, 'getAccountAssetsByScope').mockResolvedValue([ + { + id: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + chainId: KnownCaip2ChainId.Mainnet, + balance: { + amount: '1', + metadata: { + spendableBalance: '0', + minimumReserveBalance: '10000000', + decimal: 7, + }, + }, + metadata: { symbol: 'XLM', decimals: 7 }, + }, + ]); + + const result = + await onChainAccountService.resolveOnChainAccountFromCore( + KnownCaip2ChainId.Mainnet, + globalThis.crypto.randomUUID(), + accountAddress, + { resolveAccountFromNetwork: true }, + ); + + expect(result?.sequenceNumber).toBe('99'); + expect(result?.subentryCount).toBe(4); + expect(result?.numSponsoring).toBe(2); + expect(result?.numSponsored).toBe(0); + expect(result?.nativeRawBalance.toFixed(0)).toBe('351010623'); + expect(getAccountLedgerMetaSpy).toHaveBeenCalledWith( + accountAddress, + KnownCaip2ChainId.Mainnet, + ); + }); + + it('returns null when Horizon says the account is not activated', async () => { + const accountAddress = Keypair.random().publicKey(); + const { getAccountLedgerMetaSpy } = getNetworkServiceSpies(); + getAccountLedgerMetaSpy.mockRejectedValue( + new AccountNotActivatedException( + accountAddress, + KnownCaip2ChainId.Mainnet, + ), + ); + const { onChainAccountService, assetsService } = + mockOnChainAccountService(); + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); + const getAccountAssetsByScopeSpy = jest.spyOn( + assetsService, + 'getAccountAssetsByScope', + ); + + expect( + await onChainAccountService.resolveOnChainAccountFromCore( + KnownCaip2ChainId.Mainnet, + globalThis.crypto.randomUUID(), + accountAddress, + { resolveAccountFromNetwork: true }, + ), + ).toBeNull(); + expect(getAccountAssetsByScopeSpy).not.toHaveBeenCalled(); + }); + }); + describe('synchronize', () => { it('calls OnChainAccountSynchronizeService', async () => { const keyringAccounts = generateMockStellarKeyringAccounts( @@ -257,5 +476,19 @@ describe('OnChainAccountService', () => { [], ); }); + + it('skips persist when Core migration is on', async () => { + const { onChainAccountService, assetsService } = + mockOnChainAccountService(); + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); + const synchronizeSpy = jest.spyOn( + OnChainAccountSynchronizeService.prototype, + 'synchronize', + ); + + await onChainAccountService.synchronize([], KnownCaip2ChainId.Mainnet, []); + + expect(synchronizeSpy).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 3c611f8c6..edc61a639 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -1,13 +1,33 @@ import type { Logger } from '@metamask/snap-networks-utils'; +import { BigNumber } from 'bignumber.js'; import type { KnownCaip2ChainId } from '../../api'; +import { BASE_RESERVE_STROOPS } from '../../constants'; +import { + getAssetReference, + parseClassicAssetCodeIssuer, + toSmallestUnit, +} from '../../utils'; import { assertSameAddress } from '../account/utils'; import type { StellarAssetMetadata } from '../asset-metadata'; +import type { AssetsService } from '../assets'; +import { + isCoreClassicAsset, + isCoreNativeAsset, + isCoreSep41Asset, +} from '../assets/api'; +import type { CoreAsset } from '../assets/api'; import { AccountNotActivatedException } from '../network'; -import type { NetworkService } from '../network'; +import type { AccountLedgerMeta, NetworkService } from '../network'; import type { ActivatedAccountPair } from '../sync/api'; import { OnChainAccount } from './OnChainAccount'; import type { OnChainAccountRepository } from './OnChainAccountRepository'; +import { + OnChainAccountSerializableFullStruct, + SerializableClassicSpendableBalanceStruct, + SerializableSep41SpendableBalanceStruct, +} from './OnChainAccountSerializable'; +import type { SerializableSpendableBalance } from './OnChainAccountSerializable'; import { OnChainAccountSynchronizeService } from './OnChainAccountSynchronizeService'; /** @@ -21,14 +41,20 @@ export class OnChainAccountService { readonly #onChainAccountRepository: OnChainAccountRepository; + readonly #assetsService: AssetsService; + + readonly #logger: Logger; + constructor({ networkService, onChainAccountRepository, logger, + assetsService, }: { networkService: NetworkService; onChainAccountRepository: OnChainAccountRepository; logger: Logger; + assetsService: AssetsService; }) { this.#networkService = networkService; this.#onChainAccountSynchronizeService = @@ -37,7 +63,9 @@ export class OnChainAccountService { onChainAccountRepository, logger, }); + this.#logger = logger.withPrefix('šŸ’¼ OnChainAccountService'); this.#onChainAccountRepository = onChainAccountRepository; + this.#assetsService = assetsService; } /** @@ -87,16 +115,22 @@ export class OnChainAccountService { } /** - * Loads the on-chain account for the given keyring account id from the State. + * Loads the on-chain account for the given keyring account id from snap state. * * @param keyringAccountId - The keyring account id to load the on-chain account for. + * @param accountAddress - Stellar G-address for the account header. * @param scope - The CAIP-2 chain id to load the on-chain account for. * @returns The on-chain account, or `null` if not found. */ async resolveOnChainAccountByKeyringAccountId( keyringAccountId: string, + accountAddress: string, scope: KnownCaip2ChainId, ): Promise { + if ((await this.#assetsService.isMigrationEnabled())) { + return this.resolveOnChainAccountFromCore(scope, keyringAccountId, accountAddress); + } + const onChainAccount = await this.#onChainAccountRepository.findByKeyringAccountId( keyringAccountId, @@ -107,10 +141,187 @@ export class OnChainAccountService { : null; } + /** + * Best-effort {@link OnChainAccount} from Core holdings for fast read paths. + * + * Binds balances from AssetsController when the Stellar migration flag is on. + * Protocol fields (sequence, subentries, sponsorship, native stroops) come from + * {@link NetworkService.getAccountLedgerMeta} when `resolveAccountFromNetwork` is set; + * otherwise sequence is `0`, sponsorships are 0, and `subentryCount` is derived from Core + * native `minimumReserveBalance`. + * Not a substitute for live Horizon for send / fee / ChangeTrust. + * + * @param scope - CAIP-2 network. + * @param keyringAccountId - MetaMask keyring account id. + * @param accountAddress - Stellar G-address for the account header. + * @param options - Optional RPC ledger overlay. + * @param options.resolveAccountFromNetwork - When true, overlay sequence / meta / native + * from Soroban `getAccountEntry`. + * @returns Bound account, or `null` when migration is off, Core is empty, or the + * account is not activated on Horizon (when network load is requested). + */ + async resolveOnChainAccountFromCore( + scope: KnownCaip2ChainId, + keyringAccountId: string, + accountAddress: string, + options?: { + resolveAccountFromNetwork?: boolean; + }, + ): Promise { + if (!(await this.#assetsService.isMigrationEnabled())) { + return null; + } + + const { resolveAccountFromNetwork = false } = options ?? {}; + let ledger: AccountLedgerMeta | undefined; + + if (resolveAccountFromNetwork) { + ledger = await this.#getAccountLedgerMetaSafe(accountAddress, scope); + if (!ledger) { + return null; + } + } + + const assets = await this.#assetsService.getAccountAssetsByScope( + scope, + keyringAccountId, + ); + + if (assets.length === 0) { + // If the account has no assets from core, + // Possiblly not indexed, or not activated + // Return null to categorize it as not activated + return null; + } + + return OnChainAccount.fromSerializable( + this.#toSerializableFromCoreAssets({ + accountAddress, + scope, + assets, + ledger, + }), + ); + } + + /** + * Maps validated Core holdings into a full on-chain snapshot (best effort). + * + * @param params - Account header and Core assets for one scope. + * @param params.accountAddress - Stellar G-address for the snapshot header. + * @param params.scope - CAIP-2 network. + * @param params.assets - Assets that are held by the account from core client controller. + * @param params.ledger - Optional RPC ledger overlay (sequence, meta, native stroops). + * @returns Full serializable binding for {@link OnChainAccount.fromSerializable}. + */ + #toSerializableFromCoreAssets({ + accountAddress, + scope, + assets, + ledger, + }: { + accountAddress: string; + scope: KnownCaip2ChainId; + assets: CoreAsset[]; + ledger?: AccountLedgerMeta; + }): ReturnType { + const balances: SerializableSpendableBalance[] = []; + let rawNativeBalance = ledger?.rawNativeBalance ?? '0'; + let subentryCount = 0; + + for (const asset of assets) { + if (asset.chainId !== scope) { + continue; + } + + const { decimals, symbol } = asset.metadata; + const balance = toSmallestUnit( + new BigNumber(asset.balance.amount), + decimals, + ).toFixed(0); + + if (isCoreNativeAsset(asset)) { + if (ledger === undefined) { + rawNativeBalance = balance; + const { minimumReserveBalance } = asset.balance.metadata; + const reserveUnits = new BigNumber(minimumReserveBalance).div( + BASE_RESERVE_STROOPS, + ); + // minReserve = (2 + subentryCount) * BASE when sponsoring/sponsored are 0. + subentryCount = BigNumber.maximum(reserveUnits.minus(2), 0).toNumber(); + } + continue; + } + + if (isCoreClassicAsset(asset)) { + const { limit, authorized, sponsored } = asset.balance.metadata; + const address = + asset.metadata.address ?? + parseClassicAssetCodeIssuer(getAssetReference(asset.id)).assetIssuer; + balances.push( + SerializableClassicSpendableBalanceStruct.create({ + assetId: asset.id, + symbol, + balance, + limit: toSmallestUnit(new BigNumber(limit), decimals).toFixed(0), + address, + authorized, + sponsored, + }), + ); + continue; + } + + if (isCoreSep41Asset(asset)) { + balances.push( + SerializableSep41SpendableBalanceStruct.create({ + assetId: asset.id, + symbol, + balance, + decimals, + }), + ); + } + } + + return OnChainAccountSerializableFullStruct.create({ + accountId: accountAddress, + sequenceNumber: ledger?.sequenceNumber ?? '0', + scope, + meta: { + subentryCount: ledger?.subentryCount ?? subentryCount, + numSponsoring: ledger?.numSponsoring ?? 0, + numSponsored: ledger?.numSponsored ?? 0, + }, + balances, + rawNativeBalance, + }); + } + + async #getAccountLedgerMetaSafe( + accountAddress: string, + scope: KnownCaip2ChainId, + ): Promise { + try { + return await this.#networkService.getAccountLedgerMeta( + accountAddress, + scope, + ); + } catch (error: unknown) { + if (error instanceof AccountNotActivatedException) { + return undefined; + } + throw error; + } + } + /** * Enriches accounts with SEP-41 balances, persists snapshots, then notifies the keyring when * balances or the tracked asset set changed. Delegates to {@link OnChainAccountSynchronizeService}. * + * When the Stellar assets migration flag is on, skips persist and keyring events; + * AssetsController owns fungible holdings. + * * @param activatedAccountPairs - Activated account pairs to synchronize. * @param scope - CAIP-2 network. * @param sep41Assets - Preloaded SEP-41 assets from {@link SynchronizeService}. @@ -120,6 +331,11 @@ export class OnChainAccountService { scope: KnownCaip2ChainId, sep41Assets: StellarAssetMetadata[], ): Promise { + if (await this.#assetsService.isMigrationEnabled()) { + this.#logger.debug('Skipping on-chain account synchronization; Core migration is on'); + return; + } + await this.#onChainAccountSynchronizeService.synchronize( activatedAccountPairs, scope, diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts index cee06ed3b..ec831cf03 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts @@ -7,6 +7,7 @@ import type { KnownCaip2ChainId } from '../../../api'; import { logger, noOpLogger } from '../../../utils/logger'; import { AccountService } from '../../account/AccountService'; import { AccountsRepository } from '../../account/AccountsRepository'; +import { createMockAssetsService } from '../../assets/__mocks__/assetsService.fixtures'; import { InMemoryCache } from '../../cache'; import { NetworkService } from '../../network'; import { WalletService } from '../../wallet'; @@ -154,7 +155,7 @@ export const createMockAccountWithBalances = ( * Builds {@link OnChainAccountService} with {@link NetworkService}, plus {@link AccountService} * on shared {@link State} for tests that need derivation or persistence. * - * @returns On-chain service plus the account and wallet services wired to the same state. + * @returns On-chain service plus the account, wallet, and assets services wired for tests. */ export function mockOnChainAccountService() { const walletService = new WalletService(); @@ -172,10 +173,12 @@ export function mockOnChainAccountService() { cache: new InMemoryCache(noOpLogger), }); const onChainAccountRepository = new OnChainAccountRepository(state); + const { service: assetsService } = createMockAssetsService(); const onChainAccountService = new OnChainAccountService({ logger, networkService, onChainAccountRepository, + assetsService, }); return { @@ -183,6 +186,7 @@ export function mockOnChainAccountService() { onChainAccountRepository, accountService, walletService, + assetsService, }; } diff --git a/yarn.lock b/yarn.lock index ec3037768..6451e60b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3736,10 +3736,13 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/stellar-wallet-snap@workspace:packages/stellar-wallet-snap" dependencies: + "@metamask/assets-controller": "npm:^13.0.0" "@metamask/auto-changelog": "npm:^6.1.1" "@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:^5.0.0" "@metamask/snap-networks-utils": "npm:^1.0.0" "@metamask/snaps-cli": "npm:^8.4.1" "@metamask/snaps-jest": "npm:^10.2.1" From 2a59e1d758f9d24c325f77efa4e097f6b48b9ff2 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:37:42 +0800 Subject: [PATCH 03/50] chore: update code --- .../src/api/integer.test.ts | 23 ++- .../stellar-wallet-snap/src/api/integer.ts | 22 ++- packages/stellar-wallet-snap/src/constants.ts | 10 ++ .../src/handlers/accountResolver.test.ts | 1 + .../clientRequest/onAmountInput.test.ts | 2 + .../asset-metadata/AssetMetadataService.ts | 22 +-- .../src/services/assets/AssetsService.test.ts | 6 +- .../src/services/assets/api.test.ts | 33 ++-- .../src/services/assets/api.ts | 42 +++-- .../OnChainAccountService.test.ts | 15 +- .../on-chain-account/OnChainAccountService.ts | 153 ++++++++++-------- .../services/on-chain-account/utils.test.ts | 34 ++++ .../src/services/on-chain-account/utils.ts | 28 +++- 13 files changed, 273 insertions(+), 118 deletions(-) create mode 100644 packages/stellar-wallet-snap/src/services/on-chain-account/utils.test.ts diff --git a/packages/stellar-wallet-snap/src/api/integer.test.ts b/packages/stellar-wallet-snap/src/api/integer.test.ts index d10f155a6..6c620e7bf 100644 --- a/packages/stellar-wallet-snap/src/api/integer.test.ts +++ b/packages/stellar-wallet-snap/src/api/integer.test.ts @@ -1,9 +1,11 @@ import { assert, StructError } from '@metamask/superstruct'; +import { MAX_INT64 } from '../constants'; import { NonZeroValidStellarAmountStruct, - ValidStellarAmountStruct, ValidAmountStruct, + ValidStellarAmountStruct, + ValidStellarInt64Struct, } from './integer'; describe('ValidAmountStruct', () => { @@ -52,6 +54,25 @@ describe('ValidStellarAmountStruct', () => { }); }); +describe('ValidStellarInt64Struct', () => { + it('accepts integer stroops up to max int64', () => { + expect(() => assert('0', ValidStellarInt64Struct)).not.toThrow(); + expect(() => assert(MAX_INT64, ValidStellarInt64Struct)).not.toThrow(); + }); + + it('rejects a fractional amount', () => { + expect(() => assert('31.1111', ValidStellarInt64Struct)).toThrow( + StructError, + ); + }); + + it('rejects a value above max int64', () => { + expect(() => assert('9223372036854775808', ValidStellarInt64Struct)).toThrow( + StructError, + ); + }); +}); + describe('NonZeroValidStellarAmountStruct', () => { it('accepts a valid non-zero amount', () => { expect(() => diff --git a/packages/stellar-wallet-snap/src/api/integer.ts b/packages/stellar-wallet-snap/src/api/integer.ts index 9e489a531..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'; } @@ -55,6 +55,26 @@ export const ValidStellarAmountStruct = refine( }, ); +/** + * Non-empty non-negative integer string already in smallest units (stroops / + * trustline limit), not above {@link MAX_INT64}. Does not scale by decimals. + */ +export const ValidStellarInt64Struct = refine( + ValidAmountStruct, + 'valid_stellar_int64', + (value: string) => { + try { + const amount = new BigNumber(value); + if (!amount.isInteger() || amount.gt(MAX_INT64)) { + return 'Invalid amount'; + } + return true; + } catch { + return 'Invalid amount'; + } + }, +); + /** * Non-empty string that parses to a finite, non-negative {@link BigNumber} and is not zero. * The amount is converted to the smallest unit of the asset and validated against the maximum int64. diff --git a/packages/stellar-wallet-snap/src/constants.ts b/packages/stellar-wallet-snap/src/constants.ts index d655873d5..7eb903c19 100644 --- a/packages/stellar-wallet-snap/src/constants.ts +++ b/packages/stellar-wallet-snap/src/constants.ts @@ -32,6 +32,16 @@ export const STROOPS_PER_XLM = 10_000_000; */ export const BASE_RESERVE_STROOPS = XLM_PER_BASE_RESERVE * STROOPS_PER_XLM; +/** + * Base-reserve units reserved for the account entry itself (not subentries). + * + * Stellar minimum balance is + * `(2 + subentry_count + num_sponsoring āˆ’ num_sponsored) Ɨ base_reserve`. + * + * @see https://developers.stellar.org/docs/learn/fundamentals/stellar-data-structures/accounts#minimum-balance + */ +export const ACCOUNT_MINIMUM_BASE_RESERVE_UNIT = 2; + /** * Stellar's coin type * diff --git a/packages/stellar-wallet-snap/src/handlers/accountResolver.test.ts b/packages/stellar-wallet-snap/src/handlers/accountResolver.test.ts index 7257d1422..81eb84000 100644 --- a/packages/stellar-wallet-snap/src/handlers/accountResolver.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/accountResolver.test.ts @@ -176,6 +176,7 @@ describe('AccountResolver', () => { }); expect(resolveOnChainAccountByKeyringAccountIdSpy).toHaveBeenCalledWith( account.id, + account.address, scope, ); expect(resolveOnChainAccountSpy).not.toHaveBeenCalled(); diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts index 0ccfc6df7..36e2b60ab 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts @@ -188,6 +188,7 @@ describe('OnAmountInputHandler', () => { expect(result).toStrictEqual({ valid: true, errors: [] }); expect(resolveOnChainAccountByKeyringAccountIdSpy).toHaveBeenCalledWith( account.id, + account.address, scope, ); expect(createValidatedSendTransaction).toHaveBeenCalledWith({ @@ -213,6 +214,7 @@ describe('OnAmountInputHandler', () => { expect(resolveOnChainAccountByKeyringAccountIdSpy).toHaveBeenCalledWith( account.id, + account.address, KnownCaip2ChainId.Testnet, ); expect(createValidatedSendTransaction).toHaveBeenCalledWith( 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 320d7b016..d68fc5bdb 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -241,24 +241,24 @@ export class AssetMetadataService { const missing: KnownCaip19AssetId[] = []; if (assetIds.length > 0) { - // The order doesnt matter here, - // as we are not using the order of the assets + // 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({ + 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, - })) - } - missing.push(assetId);; + }), + ); + return; } - ), + missing.push(assetId); + }), ); } return { hits, missing }; 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 d0be8d100..3b38b9187 100644 --- a/packages/stellar-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/stellar-wallet-snap/src/services/assets/AssetsService.test.ts @@ -3,6 +3,7 @@ import type { Asset } from '@metamask/assets-controller'; import type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils'; import { KnownCaip2ChainId } from '../../api'; +import { MAX_INT64 } from '../../constants'; import { getSlip44AssetId } from '../../utils'; import { USDC_CLASSIC, @@ -17,7 +18,6 @@ describe('AssetsService', () => { const coreMetadata = { symbol: 'USDC', decimals: 7, - address: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', }; it('parses Core catalog metadata into CoreAssetMetadata', async () => { @@ -71,7 +71,7 @@ describe('AssetsService', () => { chainId: KnownCaip2ChainId.Mainnet, balance: { amount: '3', - metadata: { limit: '1000', authorized: true, sponsored: false }, + metadata: { limit: MAX_INT64, authorized: true, sponsored: false }, }, metadata: { symbol: 'USDC', decimals: 7 }, }, @@ -128,7 +128,7 @@ describe('AssetsService', () => { chainId: KnownCaip2ChainId.Mainnet, balance: { amount: '3', - metadata: { limit: '1000', authorized: true, sponsored: false }, + metadata: { limit: MAX_INT64, authorized: true, sponsored: false }, }, metadata: { symbol: 'USDC', decimals: 7 }, }, 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 be2f4da55..0fcfa6817 100644 --- a/packages/stellar-wallet-snap/src/services/assets/api.test.ts +++ b/packages/stellar-wallet-snap/src/services/assets/api.test.ts @@ -1,6 +1,7 @@ import { create, is } from '@metamask/superstruct'; import { KnownCaip2ChainId } from '../../api'; +import { MAX_INT64 } from '../../constants'; import { getSlip44AssetId } from '../../utils'; import { USDC_CLASSIC, @@ -9,7 +10,6 @@ import { import { parseCoreAsset, parseCoreAssetMetadata, CoreAssetStruct } from './api'; const NATIVE_ID = getSlip44AssetId(KnownCaip2ChainId.Mainnet); -const USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; const nativeAsset = { id: NATIVE_ID, @@ -31,14 +31,29 @@ const classicAsset = { id: USDC_CLASSIC, chainId: KnownCaip2ChainId.Mainnet, balance: { - amount: '3', + amount: '0.1630079', metadata: { - limit: '1000', + limit: MAX_INT64, authorized: true, sponsored: false, }, }, - metadata: { symbol: 'USDC', decimals: 7, address: USDC_ISSUER }, + metadata: { + aggregators: ['metamask', 'stellarUnified'], + decimals: 7, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/stellar/pubnet/asset/USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN.png', + name: 'USDC', + occurrences: 2, + symbol: 'USDC', + type: 'erc20', + }, + price: { + id: USDC_CLASSIC, + price: 0.999682, + usdPrice: 0.999682, + }, + fiatValue: 0.1629560634878, }; const sep41Asset = { @@ -69,7 +84,7 @@ describe('CoreAssetStruct', () => { }); }); - it('accepts classic assets with amount + classic balance metadata', () => { + it('accepts classic assets with human amount and stroop trustline limit', () => { expect(is(classicAsset, CoreAssetStruct)).toBe(true); }); @@ -133,10 +148,10 @@ describe('parseCoreAsset', () => { id: USDC_CLASSIC, chainId: KnownCaip2ChainId.Mainnet, balance: { - amount: '3', - metadata: { limit: '1000', authorized: true, sponsored: false }, + amount: '0.1630079', + metadata: { limit: MAX_INT64, authorized: true, sponsored: false }, }, - metadata: { symbol: 'USDC', decimals: 7, address: USDC_ISSUER }, + metadata: { symbol: 'USDC', decimals: 7, name: 'USDC' }, }); }); @@ -151,14 +166,12 @@ describe('parseCoreAssetMetadata', () => { parseCoreAssetMetadata({ symbol: 'USDC', decimals: 7, - address: USDC_ISSUER, name: 'USD Coin', image: 'https://example.test/usdc.png', }), ).toMatchObject({ symbol: 'USDC', decimals: 7, - address: USDC_ISSUER, name: 'USD Coin', image: 'https://example.test/usdc.png', }); diff --git a/packages/stellar-wallet-snap/src/services/assets/api.ts b/packages/stellar-wallet-snap/src/services/assets/api.ts index 8dd967baf..5a1b0a968 100644 --- a/packages/stellar-wallet-snap/src/services/assets/api.ts +++ b/packages/stellar-wallet-snap/src/services/assets/api.ts @@ -1,4 +1,4 @@ -import { boolean, create, defaulted, number, object, optional, string, type, union } from '@metamask/superstruct'; +import { boolean, create, defaulted, number, optional, string, type, union } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { @@ -6,41 +6,51 @@ import { KnownCaip19Sep41AssetStruct, KnownCaip19Slip44IdStruct, KnownCaip2ChainIdStruct, + ValidAmountStruct, + ValidStellarAmountStruct, + ValidStellarInt64Struct, } from '../../api'; /** * Controller `Asset.metadata` fields Stellar needs from Core. - * Uses `type` so other metadata fields from Core are allowed. + * Uses `type` so other catalog fields (`aggregators`, `type`, …) are allowed. + * Classic issuer is not read from here; parse it from the CAIP-19 `id`. */ export const CoreAssetMetadataStruct = type({ symbol: string(), decimals: number(), - /** Classic issuer (or other address); optional for native / SEP-41. */ - address: optional(string()), name: optional(string()), image: optional(string()), }); -/** Core `Asset.balance` fields shared by all asset types. */ +/** Core `Asset.balance` fields shared by all asset types. `amount` is human units. */ export const CoreAssetBalanceStruct = type({ - amount: string(), + amount: ValidAmountStruct, }); -/** Core `Asset.balance` for slip44: amount + native keyring balance metadata. */ +/** + * Core `Asset.balance` for slip44: human XLM `amount` plus native keyring + * metadata (`spendableBalance` / `minimumReserveBalance` are stroops). + */ export const CoreNativeBalanceStruct = type({ - amount: CoreAssetBalanceStruct.schema.amount, - metadata: object({ - spendableBalance: string(), - minimumReserveBalance: string(), - decimal: number(), + amount: ValidStellarAmountStruct, + metadata: type({ + spendableBalance: ValidStellarInt64Struct, + minimumReserveBalance: ValidStellarInt64Struct, + // **DEPRECATED:** Accounts API has deprecated the decimal field. + // Please don't use it in new code. + decimal: optional(number()), }), }); -/** Core `Asset.balance` for classic assets: amount + classic keyring balance metadata. */ +/** + * Core `Asset.balance` for classic assets: human `amount` plus trustline + * metadata (`limit` is unscaled int64 stroops). + */ export const CoreClassicBalanceStruct = type({ - amount: CoreAssetBalanceStruct.schema.amount, - metadata: object({ - limit: string(), + amount: ValidStellarAmountStruct, + metadata: type({ + limit: ValidStellarInt64Struct, authorized: defaulted(boolean(), true), sponsored: defaulted(boolean(), false), }), diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index 7d026b2ab..2a26fe58c 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -2,6 +2,7 @@ import { hexToBytes } from '@metamask/utils'; import { Keypair } from '@stellar/stellar-sdk'; import { KnownCaip2ChainId } from '../../api'; +import { MAX_INT64 } from '../../constants'; import { getSlip44AssetId } from '../../utils'; import { bufferToUint8Array } from '../../utils/buffer'; import { @@ -295,18 +296,14 @@ describe('OnChainAccountService', () => { id: usdcId, chainId: KnownCaip2ChainId.Mainnet, balance: { - amount: '3', + amount: '0.1630079', metadata: { - limit: '1000', + limit: MAX_INT64, authorized: true, sponsored: false, }, }, - metadata: { - symbol: 'USDC', - decimals: 7, - address: usdcIssuer, - }, + metadata: { symbol: 'USDC', decimals: 7 }, }, { id: sep41Id, @@ -340,10 +337,10 @@ describe('OnChainAccountService', () => { expect.arrayContaining([ expect.objectContaining({ assetId: usdcId, - balance: '30000000', + balance: '1630079', symbol: 'USDC', address: usdcIssuer, - limit: '10000000000', + limit: MAX_INT64, authorized: true, sponsored: false, }), diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index edc61a639..22ba048b1 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -2,11 +2,11 @@ import type { Logger } from '@metamask/snap-networks-utils'; import { BigNumber } from 'bignumber.js'; import type { KnownCaip2ChainId } from '../../api'; -import { BASE_RESERVE_STROOPS } from '../../constants'; import { getAssetReference, parseClassicAssetCodeIssuer, toSmallestUnit, + trackError, } from '../../utils'; import { assertSameAddress } from '../account/utils'; import type { StellarAssetMetadata } from '../asset-metadata'; @@ -27,8 +27,9 @@ import { SerializableClassicSpendableBalanceStruct, SerializableSep41SpendableBalanceStruct, } from './OnChainAccountSerializable'; -import type { SerializableSpendableBalance } from './OnChainAccountSerializable'; +import type { OnChainAccountSerializableFull, SerializableSpendableBalance } from './OnChainAccountSerializable'; import { OnChainAccountSynchronizeService } from './OnChainAccountSynchronizeService'; +import { subentryCountFromMinimumReserveStroops } from './utils'; /** * Stellar on-chain account operations: activation checks and loading {@link OnChainAccount} @@ -194,6 +195,13 @@ export class OnChainAccountService { return null; } + this.#logger.debug('Resolved on-chain account from core', { + accountAddress, + scope, + assets, + ledger, + }); + return OnChainAccount.fromSerializable( this.#toSerializableFromCoreAssets({ accountAddress, @@ -224,78 +232,93 @@ export class OnChainAccountService { scope: KnownCaip2ChainId; assets: CoreAsset[]; ledger?: AccountLedgerMeta; - }): ReturnType { - const balances: SerializableSpendableBalance[] = []; - let rawNativeBalance = ledger?.rawNativeBalance ?? '0'; - let subentryCount = 0; + }): OnChainAccountSerializableFull { + try { + const balances: SerializableSpendableBalance[] = []; + let rawNativeBalance = ledger?.rawNativeBalance ?? '0'; + let subentryCount = 0; - for (const asset of assets) { - if (asset.chainId !== scope) { - continue; - } + for (const asset of assets) { + if (asset.chainId !== scope) { + continue; + } - const { decimals, symbol } = asset.metadata; - const balance = toSmallestUnit( - new BigNumber(asset.balance.amount), - decimals, - ).toFixed(0); + const assetId = asset.id; + const { decimals, symbol } = asset.metadata; + const balance = toSmallestUnit( + new BigNumber(asset.balance.amount), + decimals, + ).toFixed(0); - if (isCoreNativeAsset(asset)) { - if (ledger === undefined) { - rawNativeBalance = balance; - const { minimumReserveBalance } = asset.balance.metadata; - const reserveUnits = new BigNumber(minimumReserveBalance).div( - BASE_RESERVE_STROOPS, + if (isCoreNativeAsset(asset)) { + // When RPC ledger meta is missing, derive subentryCount from Core + // `minimumReserveBalance` (stroops), assuming sponsoring fields are 0. + if (ledger === undefined) { + rawNativeBalance = balance; + const { minimumReserveBalance } = asset.balance.metadata; + subentryCount = subentryCountFromMinimumReserveStroops( + minimumReserveBalance, + ); + } + continue; + } + + if (isCoreClassicAsset(asset)) { + const { limit, authorized, sponsored } = asset.balance.metadata; + const { assetIssuer: address } = parseClassicAssetCodeIssuer( + getAssetReference(assetId), + ); + balances.push( + SerializableClassicSpendableBalanceStruct.create({ + assetId, + symbol, + balance, + limit, + address, + authorized, + sponsored, + }), ); - // minReserve = (2 + subentryCount) * BASE when sponsoring/sponsored are 0. - subentryCount = BigNumber.maximum(reserveUnits.minus(2), 0).toNumber(); + continue; } - continue; - } - if (isCoreClassicAsset(asset)) { - const { limit, authorized, sponsored } = asset.balance.metadata; - const address = - asset.metadata.address ?? - parseClassicAssetCodeIssuer(getAssetReference(asset.id)).assetIssuer; - balances.push( - SerializableClassicSpendableBalanceStruct.create({ - assetId: asset.id, - symbol, - balance, - limit: toSmallestUnit(new BigNumber(limit), decimals).toFixed(0), - address, - authorized, - sponsored, - }), - ); - continue; + if (isCoreSep41Asset(asset)) { + balances.push( + SerializableSep41SpendableBalanceStruct.create({ + assetId, + symbol, + balance, + decimals, + }), + ); + } } - if (isCoreSep41Asset(asset)) { - balances.push( - SerializableSep41SpendableBalanceStruct.create({ - assetId: asset.id, - symbol, - balance, - decimals, - }), - ); - } + return OnChainAccountSerializableFullStruct.create({ + accountId: accountAddress, + sequenceNumber: ledger?.sequenceNumber ?? '0', + scope, + meta: { + subentryCount: ledger?.subentryCount ?? subentryCount, + numSponsoring: ledger?.numSponsoring ?? 0, + numSponsored: ledger?.numSponsored ?? 0, + }, + balances, + rawNativeBalance, + }); + } catch (error: unknown) { + this.#logger.debug('Error serializing on-chain account from core assets', { + error, + accountAddress, + scope, + assets, + ledger, + }); + trackError(new Error('Error serializing on-chain account from core assets', { + cause: error, + })); + throw error; } - - return OnChainAccountSerializableFullStruct.create({ - accountId: accountAddress, - sequenceNumber: ledger?.sequenceNumber ?? '0', - scope, - meta: { - subentryCount: ledger?.subentryCount ?? subentryCount, - numSponsoring: ledger?.numSponsoring ?? 0, - numSponsored: ledger?.numSponsored ?? 0, - }, - balances, - rawNativeBalance, - }); } async #getAccountLedgerMetaSafe( diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/utils.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/utils.test.ts new file mode 100644 index 000000000..2e13a9e3a --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/utils.test.ts @@ -0,0 +1,34 @@ +import { BigNumber } from 'bignumber.js'; + +import { + ACCOUNT_MINIMUM_BASE_RESERVE_UNIT, + BASE_RESERVE_STROOPS, +} from '../../constants'; +import { + minimumBalanceStroops, + subentryCountFromMinimumReserveStroops, +} from './utils'; + +describe('subentryCountFromMinimumReserveStroops', () => { + it('returns 0 for an empty account (2 base reserves)', () => { + const emptyAccountReserve = new BigNumber(ACCOUNT_MINIMUM_BASE_RESERVE_UNIT) + .times(BASE_RESERVE_STROOPS) + .toFixed(0); + + expect(subentryCountFromMinimumReserveStroops(emptyAccountReserve)).toBe(0); + }); + + it('recovers subentries when sponsoring fields are 0', () => { + const stroops = minimumBalanceStroops({ + subentryCount: 4, + numSponsoring: 0, + numSponsored: 0, + }).toFixed(0); + + expect(subentryCountFromMinimumReserveStroops(stroops)).toBe(4); + }); + + it('clamps below the account base reserve at 0', () => { + expect(subentryCountFromMinimumReserveStroops('0')).toBe(0); + }); +}); diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts index 6d5f102e2..599e92233 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts @@ -1,6 +1,9 @@ import { BigNumber } from 'bignumber.js'; -import { BASE_RESERVE_STROOPS } from '../../constants'; +import { + ACCOUNT_MINIMUM_BASE_RESERVE_UNIT, + BASE_RESERVE_STROOPS, +} from '../../constants'; type CalculateSpendableBalanceParams = { nativeBalance: BigNumber; @@ -24,13 +27,34 @@ type MinimumBalanceLedgerMeta = { export function minimumBalanceStroops( meta: MinimumBalanceLedgerMeta, ): BigNumber { - return new BigNumber(2) + return new BigNumber(ACCOUNT_MINIMUM_BASE_RESERVE_UNIT) .plus(meta.subentryCount) .plus(meta.numSponsoring) .minus(meta.numSponsored) .times(BASE_RESERVE_STROOPS); } +/** + * Best-effort `subentry_count` from Core native `minimumReserveBalance` (stroops). + * + * Inverts {@link minimumBalanceStroops} when `numSponsoring` and `numSponsored` + * are 0: `subentryCount = minimumReserve / BASE_RESERVE āˆ’ 2`. Clamped at 0. + * + * @param minimumReserveBalanceStroops - Core `minimumReserveBalance` (integer stroops). + * @returns Estimated Horizon `subentry_count`. + */ +export function subentryCountFromMinimumReserveStroops( + minimumReserveBalanceStroops: string, +): number { + const reserveUnits = new BigNumber(minimumReserveBalanceStroops).div( + BASE_RESERVE_STROOPS, + ); + return BigNumber.maximum( + reserveUnits.minus(ACCOUNT_MINIMUM_BASE_RESERVE_UNIT), + 0, + ).toNumber(); +} + /** * Spendable native balance (stroops): total native minus minimum balance. * From 5cb3757b8741063a235e3c1ca3d6320bab1f10b7 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:39:08 +0800 Subject: [PATCH 04/50] chore: update package --- packages/stellar-wallet-snap/package.json | 2 +- packages/stellar-wallet-snap/snap.manifest.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stellar-wallet-snap/package.json b/packages/stellar-wallet-snap/package.json index c2a6c2705..ead55bb15 100644 --- a/packages/stellar-wallet-snap/package.json +++ b/packages/stellar-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/stellar-wallet-snap", - "version": "1.0.0-dev.11", + "version": "1.0.0", "description": "A Stellar wallet Snap", "keywords": [ "Ethereum", diff --git a/packages/stellar-wallet-snap/snap.manifest.json b/packages/stellar-wallet-snap/snap.manifest.json index a9271f0ac..f0eb7f63e 100644 --- a/packages/stellar-wallet-snap/snap.manifest.json +++ b/packages/stellar-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.0.0-dev.11", + "version": "1.0.0", "description": "Manage Stellar using MetaMask", "proposedName": "Stellar", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "wvTA12Gcn+AB2p/rGhrZKSY6w57nNwJOp9cISp+5flQ=", + "shasum": "ggsfBQcFXbDv/z5F57nfkKHgMG7du3ns0BsIru1Tqr4=", "location": { "npm": { "filePath": "dist/bundle.js", From 3f25320e62b2489090c3629efb2534401a08026d Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:36:52 +0800 Subject: [PATCH 05/50] fix: lint --- .../src/api/core-messenger.ts | 81 +++++++++---------- .../src/api/integer.test.ts | 6 +- packages/stellar-wallet-snap/src/context.ts | 2 +- .../asset-metadata/AssetMetadataService.ts | 10 ++- .../src/services/assets/AssetsService.test.ts | 2 +- .../src/services/assets/AssetsService.ts | 5 +- .../assets/adapters/CoreAssetsAdapter.ts | 6 +- .../src/services/assets/api.ts | 21 ++++- .../src/services/assets/utils.ts | 3 +- .../OnChainAccountService.test.ts | 37 +++++---- .../on-chain-account/OnChainAccountService.ts | 42 ++++++---- 11 files changed, 127 insertions(+), 88 deletions(-) diff --git a/packages/stellar-wallet-snap/src/api/core-messenger.ts b/packages/stellar-wallet-snap/src/api/core-messenger.ts index 14a5d393e..38a612c95 100644 --- a/packages/stellar-wallet-snap/src/api/core-messenger.ts +++ b/packages/stellar-wallet-snap/src/api/core-messenger.ts @@ -1,42 +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; - \ No newline at end of file + 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.test.ts b/packages/stellar-wallet-snap/src/api/integer.test.ts index 6c620e7bf..e9043b7b2 100644 --- a/packages/stellar-wallet-snap/src/api/integer.test.ts +++ b/packages/stellar-wallet-snap/src/api/integer.test.ts @@ -67,9 +67,9 @@ describe('ValidStellarInt64Struct', () => { }); it('rejects a value above max int64', () => { - expect(() => assert('9223372036854775808', ValidStellarInt64Struct)).toThrow( - StructError, - ); + expect(() => + assert('9223372036854775808', ValidStellarInt64Struct), + ).toThrow(StructError); }); }); diff --git a/packages/stellar-wallet-snap/src/context.ts b/packages/stellar-wallet-snap/src/context.ts index a8aa2d053..22997458c 100644 --- a/packages/stellar-wallet-snap/src/context.ts +++ b/packages/stellar-wallet-snap/src/context.ts @@ -12,6 +12,7 @@ import type { 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'; @@ -71,7 +72,6 @@ import { } from './services/transaction-scan'; import { WalletService } from './services/wallet'; import { ConfirmationUXController } from './ui/confirmation/controller'; -import type { CoreMessenger } from './api/core-messenger'; import { getSnapProvider, logger, noOpLogger, trackError } from './utils'; assert(AppConfig, object()); 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 d68fc5bdb..5771cad30 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -225,10 +225,14 @@ export class AssetMetadataService { return { assets: coreHits, missingAssetIds: [] }; } - const cachedAssets = - await this.#assetMetadataRepository.getByAssetIds(missingAssetIdsFromCore); + const cachedAssets = await this.#assetMetadataRepository.getByAssetIds( + missingAssetIdsFromCore, + ); const { hits: snapHits, missing: missingAssetIds } = - this.#partitionHitsAndMissingByArray(missingAssetIdsFromCore, cachedAssets); + this.#partitionHitsAndMissingByArray( + missingAssetIdsFromCore, + cachedAssets, + ); return { assets: [...coreHits, ...snapHits], missingAssetIds }; } 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 3b38b9187..aea670c57 100644 --- a/packages/stellar-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/stellar-wallet-snap/src/services/assets/AssetsService.test.ts @@ -9,9 +9,9 @@ import { USDC_CLASSIC, USDC_SEP41, } from '../asset-metadata/__mocks__/assets.fixtures'; +import { createMockAssetsService } from './__mocks__/assetsService.fixtures'; import type { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; import { AssetsService } from './AssetsService'; -import { createMockAssetsService } from './__mocks__/assetsService.fixtures'; describe('AssetsService', () => { const assetId = USDC_CLASSIC; diff --git a/packages/stellar-wallet-snap/src/services/assets/AssetsService.ts b/packages/stellar-wallet-snap/src/services/assets/AssetsService.ts index a411739e8..d6fb92192 100644 --- a/packages/stellar-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/stellar-wallet-snap/src/services/assets/AssetsService.ts @@ -1,6 +1,9 @@ import type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils'; -import type { KnownCaip19AssetIdOrSlip44Id, KnownCaip2ChainId } from '../../api'; +import type { + KnownCaip19AssetIdOrSlip44Id, + KnownCaip2ChainId, +} from '../../api'; import type { CoreAssetsAdapter } from './adapters/CoreAssetsAdapter'; import { parseCoreAsset, parseCoreAssetMetadata } from './api'; import type { CoreAsset, CoreAssetMetadata } from './api'; diff --git a/packages/stellar-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts b/packages/stellar-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts index 4fc8ab23a..f82d794fe 100644 --- a/packages/stellar-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts +++ b/packages/stellar-wallet-snap/src/services/assets/adapters/CoreAssetsAdapter.ts @@ -1,4 +1,8 @@ -import type { Asset, Caip19AssetId, AssetMetadata } from '@metamask/assets-controller'; +import type { + Asset, + Caip19AssetId, + AssetMetadata, +} from '@metamask/assets-controller'; import type { AssetsProvider, Logger } from '@metamask/snap-networks-utils'; import { KnownCaip2ChainId } from '../../../api'; diff --git a/packages/stellar-wallet-snap/src/services/assets/api.ts b/packages/stellar-wallet-snap/src/services/assets/api.ts index 5a1b0a968..3ff1c4180 100644 --- a/packages/stellar-wallet-snap/src/services/assets/api.ts +++ b/packages/stellar-wallet-snap/src/services/assets/api.ts @@ -1,4 +1,13 @@ -import { boolean, create, defaulted, number, optional, string, type, union } from '@metamask/superstruct'; +import { + boolean, + create, + defaulted, + number, + optional, + string, + type, + union, +} from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { @@ -97,7 +106,9 @@ export function isCoreNativeAsset(asset: CoreAsset): asset is CoreNativeAsset { return Slip44CoreAssetStruct.is(asset); } -export function isCoreClassicAsset(asset: CoreAsset): asset is CoreClassicAsset { +export function isCoreClassicAsset( + asset: CoreAsset, +): asset is CoreClassicAsset { return ClassicCoreAssetStruct.is(asset); } @@ -125,10 +136,12 @@ export function parseCoreAsset(value: unknown): CoreAsset | null { * @param value - Raw AssetsController asset metadata. * @returns The Stellar-shaped metadata, or `null` when the payload is not Stellar. */ -export function parseCoreAssetMetadata(value: unknown): CoreAssetMetadata | null { +export function parseCoreAssetMetadata( + value: unknown, +): CoreAssetMetadata | null { try { return create(value, CoreAssetMetadataStruct); } catch { return null; } -} \ No newline at end of file +} diff --git a/packages/stellar-wallet-snap/src/services/assets/utils.ts b/packages/stellar-wallet-snap/src/services/assets/utils.ts index e8c150564..5f8c9d459 100644 --- a/packages/stellar-wallet-snap/src/services/assets/utils.ts +++ b/packages/stellar-wallet-snap/src/services/assets/utils.ts @@ -23,7 +23,6 @@ export async function isAssetsMigrationEnabled( ); return ( - parseSnapsAssetsMigrationStage(flagValue) !== - SnapsAssetsMigrationStage.Off + parseSnapsAssetsMigrationStage(flagValue) !== SnapsAssetsMigrationStage.Off ); } diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index 2a26fe58c..8b6620e85 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -232,8 +232,7 @@ describe('OnChainAccountService', () => { describe('resolveOnChainAccountFromCore', () => { const usdcIssuer = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; - const usdcId = - `stellar:pubnet/asset:USDC-${usdcIssuer}` as const; + const usdcId = `stellar:pubnet/asset:USDC-${usdcIssuer}` as const; const sep41Id = 'stellar:pubnet/sep41:CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75' as const; @@ -259,7 +258,9 @@ describe('OnChainAccountService', () => { const { onChainAccountService, assetsService } = mockOnChainAccountService(); jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); - jest.spyOn(assetsService, 'getAccountAssetsByScope').mockResolvedValue([]); + jest + .spyOn(assetsService, 'getAccountAssetsByScope') + .mockResolvedValue([]); expect( await onChainAccountService.resolveOnChainAccountFromCore( @@ -313,12 +314,11 @@ describe('OnChainAccountService', () => { }, ]); - const result = - await onChainAccountService.resolveOnChainAccountFromCore( - KnownCaip2ChainId.Mainnet, - keyringAccountId, - accountAddress, - ); + const result = await onChainAccountService.resolveOnChainAccountFromCore( + KnownCaip2ChainId.Mainnet, + keyringAccountId, + accountAddress, + ); expect(result).toBeInstanceOf(OnChainAccount); const full = result?.toSerializableFull(); @@ -388,13 +388,12 @@ describe('OnChainAccountService', () => { }, ]); - const result = - await onChainAccountService.resolveOnChainAccountFromCore( - KnownCaip2ChainId.Mainnet, - globalThis.crypto.randomUUID(), - accountAddress, - { resolveAccountFromNetwork: true }, - ); + const result = await onChainAccountService.resolveOnChainAccountFromCore( + KnownCaip2ChainId.Mainnet, + globalThis.crypto.randomUUID(), + accountAddress, + { resolveAccountFromNetwork: true }, + ); expect(result?.sequenceNumber).toBe('99'); expect(result?.subentryCount).toBe(4); @@ -483,7 +482,11 @@ describe('OnChainAccountService', () => { 'synchronize', ); - await onChainAccountService.synchronize([], KnownCaip2ChainId.Mainnet, []); + await onChainAccountService.synchronize( + [], + KnownCaip2ChainId.Mainnet, + [], + ); expect(synchronizeSpy).not.toHaveBeenCalled(); }); diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 22ba048b1..e6a8ad9ba 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -27,7 +27,10 @@ import { SerializableClassicSpendableBalanceStruct, SerializableSep41SpendableBalanceStruct, } from './OnChainAccountSerializable'; -import type { OnChainAccountSerializableFull, SerializableSpendableBalance } from './OnChainAccountSerializable'; +import type { + OnChainAccountSerializableFull, + SerializableSpendableBalance, +} from './OnChainAccountSerializable'; import { OnChainAccountSynchronizeService } from './OnChainAccountSynchronizeService'; import { subentryCountFromMinimumReserveStroops } from './utils'; @@ -128,8 +131,12 @@ export class OnChainAccountService { accountAddress: string, scope: KnownCaip2ChainId, ): Promise { - if ((await this.#assetsService.isMigrationEnabled())) { - return this.resolveOnChainAccountFromCore(scope, keyringAccountId, accountAddress); + if (await this.#assetsService.isMigrationEnabled()) { + return this.resolveOnChainAccountFromCore( + scope, + keyringAccountId, + accountAddress, + ); } const onChainAccount = @@ -307,16 +314,21 @@ export class OnChainAccountService { rawNativeBalance, }); } catch (error: unknown) { - this.#logger.debug('Error serializing on-chain account from core assets', { - error, - accountAddress, - scope, - assets, - ledger, - }); - trackError(new Error('Error serializing on-chain account from core assets', { - cause: error, - })); + this.#logger.debug( + 'Error serializing on-chain account from core assets', + { + error, + accountAddress, + scope, + assets, + ledger, + }, + ); + trackError( + new Error('Error serializing on-chain account from core assets', { + cause: error, + }), + ); throw error; } } @@ -355,7 +367,9 @@ export class OnChainAccountService { sep41Assets: StellarAssetMetadata[], ): Promise { if (await this.#assetsService.isMigrationEnabled()) { - this.#logger.debug('Skipping on-chain account synchronization; Core migration is on'); + this.#logger.debug( + 'Skipping on-chain account synchronization; Core migration is on', + ); return; } From 54164d562d4c0ea08aaff3d2aee94a81816351d8 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:55:38 +0800 Subject: [PATCH 06/50] chore: add life data for resolve onChain Account --- packages/stellar-wallet-snap/src/context.ts | 1 + .../AssetMetadataService.test.ts | 41 ++- .../asset-metadata/AssetMetadataService.ts | 56 ++-- .../src/services/assets/AssetsService.ts | 7 +- .../OnChainAccountService.test.ts | 316 +++++++++++++++++- .../on-chain-account/OnChainAccountService.ts | 286 +++++++++++----- .../__mocks__/onChainAccount.fixtures.ts | 4 + .../services/on-chain-account/exceptions.ts | 21 +- 8 files changed, 609 insertions(+), 123 deletions(-) diff --git a/packages/stellar-wallet-snap/src/context.ts b/packages/stellar-wallet-snap/src/context.ts index 35106f12f..4b6b34169 100644 --- a/packages/stellar-wallet-snap/src/context.ts +++ b/packages/stellar-wallet-snap/src/context.ts @@ -138,6 +138,7 @@ const onChainAccountService = new OnChainAccountService({ networkService, onChainAccountRepository, assetsService, + assetMetadataService, }); const transactionService = new TransactionService({ 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 819d47dda..07d8c2cb0 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 @@ -157,15 +157,24 @@ 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(); @@ -293,14 +302,36 @@ describe('AssetMetadataService', () => { ]); }); - it('skips catalog persist during synchronize when Core migration is on', async () => { + it('persists catalog during synchronize when Core 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).not.toHaveBeenCalled(); - expect(saveMany).not.toHaveBeenCalled(); + expect(mockGetAssetsByChainId).toHaveBeenCalledWith( + KnownCaip2ChainId.Mainnet, + ); + expect(saveMany).toHaveBeenCalledWith([ + expect.objectContaining({ + assetId: sep41AssetId, + name: 'Token A', + symbol: 'TA', + }), + ]); }); it('maps Core catalog metadata into StellarAssetMetadata without reading snap state', async () => { @@ -308,6 +339,7 @@ describe('AssetMetadataService', () => { const { service, assetsService, getByAssetIds, saveMany } = createService( {}, ); + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); const getAssetMetadata = jest .spyOn(assetsService, 'getAssetMetadata') .mockResolvedValue({ @@ -340,6 +372,7 @@ describe('AssetMetadataService', () => { getByAssetIds: jest.fn().mockResolvedValue([cached]), }, }); + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); const getAssetMetadata = jest .spyOn(assetsService, 'getAssetMetadata') .mockResolvedValue(null); 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 5771cad30..2bfa3ad93 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -16,7 +16,7 @@ import { isClassicAssetId, isSep41Id, } from '../../utils'; -import type { AssetsService, CoreAssetMetadata } from '../assets'; +import type { AssetsService } from '../assets'; import type { AssetDataResponse, NetworkService } from '../network'; import type { AssetUnit, StellarAssetMetadata } from './api'; import type { AssetMetadataRepository } from './AssetMetadataRepository'; @@ -156,19 +156,9 @@ export class AssetMetadataService { /** * Fetches and persists all Assets for the given chain ID from the token API. * - * When the Stellar assets migration flag is on, skips catalog persist; - * AssetsController owns fungible metadata. - * * @param scope - The chain ID to fetch and persist assets for. */ async synchronize(scope: KnownCaip2ChainId): Promise { - if (await this.#assetsService.isMigrationEnabled()) { - this.#logger.debug( - 'Skipping asset metadata catalog sync; Core migration is on', - ); - return; - } - const tokensMetadata = await this.#getAssetsByChainId(scope); await this.#assetMetadataRepository.saveMany(tokensMetadata); } @@ -184,7 +174,7 @@ export class AssetMetadataService { result.push(getNativeAssetMetadata(chainId)); } - // fetch assets from Core, then snap state + // persisted: Core then snap when migration is on; snap only when off const allNonNativeAssetIds = [...assetsByChainId.values()].flat(); const { assets, missingAssetIds } = await this.#getPersistedAssetMetadata(allNonNativeAssetIds); @@ -214,27 +204,43 @@ export class AssetMetadataService { return result.concat(assets, missingAssets); } + /** + * Resolves persisted metadata. Core first when the Stellar assets migration + * flag is on, then snap state for remaining ids. When the flag is off, skip + * Core 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[]; }> { - const { hits: coreHits, missing: missingAssetIdsFromCore } = - await this.#getCoreAssetMetadata(assetIds); + if (await this.#assetsService.isMigrationEnabled()) { + const { hits: coreHits, missing: missingAssetIdsFromCore } = + await this.#getCoreAssetMetadata(assetIds); - if (missingAssetIdsFromCore.length === 0) { - return { assets: coreHits, missingAssetIds: [] }; + if (missingAssetIdsFromCore.length === 0) { + return { assets: coreHits, missingAssetIds: [] }; + } + + const { hits: snapHits, missing: missingAssetIds } = + await this.#getSnapAssetMetadata(missingAssetIdsFromCore); + + return { assets: [...coreHits, ...snapHits], missingAssetIds }; } - const cachedAssets = await this.#assetMetadataRepository.getByAssetIds( - missingAssetIdsFromCore, - ); - const { hits: snapHits, missing: missingAssetIds } = - this.#partitionHitsAndMissingByArray( - missingAssetIdsFromCore, - cachedAssets, - ); + const { hits, missing } = await this.#getSnapAssetMetadata(assetIds); + return { assets: hits, missingAssetIds: missing }; + } - return { assets: [...coreHits, ...snapHits], missingAssetIds }; + async #getSnapAssetMetadata(assetIds: KnownCaip19AssetId[]): Promise<{ + hits: StellarAssetMetadata[]; + missing: KnownCaip19AssetId[]; + }> { + const cachedAssets = + await this.#assetMetadataRepository.getByAssetIds(assetIds); + return this.#partitionHitsAndMissingByArray(assetIds, cachedAssets); } async #getCoreAssetMetadata(assetIds: KnownCaip19AssetId[]): Promise<{ diff --git a/packages/stellar-wallet-snap/src/services/assets/AssetsService.ts b/packages/stellar-wallet-snap/src/services/assets/AssetsService.ts index d6fb92192..5a3ee2815 100644 --- a/packages/stellar-wallet-snap/src/services/assets/AssetsService.ts +++ b/packages/stellar-wallet-snap/src/services/assets/AssetsService.ts @@ -10,10 +10,11 @@ import type { CoreAsset, CoreAssetMetadata } from './api'; import { isAssetsMigrationEnabled } from './utils'; /** - * Core AssetsController facade. Account holdings stay behind the Stellar - * migration flag. {@link getAssetMetadata} is a Core-only lookup (no snap - * fallback); domain services consume it and stay the caller-facing API. + * Core AssetsController facade. Getters always read Core; they do not check + * {@link isMigrationEnabled}. Domain services branch on the flag at the call + * site (Core vs snap state, skip persist, catalog fallback). * + * {@link getAssetMetadata} is a Core-only lookup (no snap fallback). * Account-asset and catalog methods validate controller rows into * {@link CoreAsset} / {@link CoreAssetMetadata} before returning. Domain services * map those into snap types. diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index 8b6620e85..3995ac508 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -1,15 +1,22 @@ import { hexToBytes } from '@metamask/utils'; import { Keypair } from '@stellar/stellar-sdk'; +import { BigNumber } from 'bignumber.js'; import { KnownCaip2ChainId } from '../../api'; import { MAX_INT64 } from '../../constants'; -import { getSlip44AssetId } from '../../utils'; +import { getSlip44AssetId, isSep41Id } from '../../utils'; import { bufferToUint8Array } from '../../utils/buffer'; import { generateMockStellarKeyringAccounts, generateStellarKeyringAccount, } from '../account/__mocks__/account.fixtures'; import { DerivedAccountAddressMismatchException } from '../account/exceptions'; +import { AssetMetadataService } from '../asset-metadata'; +import { + getMockSep41Assets, + USDC_SEP41, + USDT_SEP41, +} from '../asset-metadata/__mocks__/assets.fixtures'; import { AccountNotActivatedException, NetworkService } from '../network'; import { generateStellarAddress, @@ -21,6 +28,10 @@ import { horizonSource, mockOnChainAccountService, } from './__mocks__/onChainAccount.fixtures'; +import { + OnChainAccountBalanceNotAvailableException, + OnChainAccountSep41BalanceNotFoundException, +} from './exceptions'; import { OnChainAccount } from './OnChainAccount'; import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; import { OnChainAccountSynchronizeService } from './OnChainAccountSynchronizeService'; @@ -164,6 +175,309 @@ describe('OnChainAccountService', () => { ), ).rejects.toThrow(DerivedAccountAddressMismatchException); }); + + it('binds SEP-41 balances including zero when resolveWithFullBalance is set', async () => { + const signer = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); + const loadedAcc = createMockAccountWithBalances( + signer.publicKey(), + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + const loaded = new OnChainAccount( + loadedAcc, + KnownCaip2ChainId.Mainnet, + horizonSource(loadedAcc, KnownCaip2ChainId.Mainnet), + ); + const { loadOnChainAccountSpy } = getNetworkServiceSpies(); + loadOnChainAccountSpy.mockResolvedValue(loaded); + const sep41Assets = getMockSep41Assets(); + const [usdc, usdt] = sep41Assets; + if ( + usdc === undefined || + usdt === undefined || + !isSep41Id(usdc.assetId) || + !isSep41Id(usdt.assetId) + ) { + throw new Error('expected SEP-41 mock assets'); + } + const fetchSep41Spy = jest + .spyOn(AssetMetadataService.prototype, 'fetchSep41AssetsOrSyncOnce') + .mockResolvedValue(sep41Assets); + const getSep41AssetBalancesSpy = jest + .spyOn(NetworkService.prototype, 'getSep41AssetBalances') + .mockResolvedValue({ + [signer.publicKey()]: { + [usdc.assetId]: new BigNumber(10_000_000), + [usdt.assetId]: new BigNumber(0), + }, + }); + + const { onChainAccountService } = mockOnChainAccountService(); + const result = await onChainAccountService.resolveOnChainAccount( + signer.publicKey(), + KnownCaip2ChainId.Mainnet, + { resolveWithFullBalance: true }, + ); + + expect(getSep41AssetBalancesSpy).toHaveBeenCalledWith({ + accounts: [signer.publicKey()], + assetIds: [usdc.assetId, usdt.assetId], + scope: KnownCaip2ChainId.Mainnet, + }); + expect(result.getRawAsset(USDC_SEP41)?.balance.toFixed()).toBe( + '10000000', + ); + expect(result.getAsset(USDC_SEP41)?.symbol).toBe('USDC'); + expect(result.getRawAsset(USDT_SEP41)?.balance.toFixed()).toBe('0'); + expect(result.getAsset(USDT_SEP41)).toBeUndefined(); + fetchSep41Spy.mockRestore(); + getSep41AssetBalancesSpy.mockRestore(); + }); + + it('returns the Horizon account when the SEP-41 catalog is empty', async () => { + const signer = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); + const loadedAcc = createMockAccountWithBalances( + signer.publicKey(), + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + const loaded = new OnChainAccount( + loadedAcc, + KnownCaip2ChainId.Mainnet, + horizonSource(loadedAcc, KnownCaip2ChainId.Mainnet), + ); + const { loadOnChainAccountSpy } = getNetworkServiceSpies(); + loadOnChainAccountSpy.mockResolvedValue(loaded); + const fetchSep41Spy = jest + .spyOn(AssetMetadataService.prototype, 'fetchSep41AssetsOrSyncOnce') + .mockResolvedValue([]); + const getSep41AssetBalancesSpy = jest.spyOn( + NetworkService.prototype, + 'getSep41AssetBalances', + ); + + const { onChainAccountService } = mockOnChainAccountService(); + const result = await onChainAccountService.resolveOnChainAccount( + signer.publicKey(), + KnownCaip2ChainId.Mainnet, + { resolveWithFullBalance: true }, + ); + + expect(result.accountId).toStrictEqual(signer.publicKey()); + expect(getSep41AssetBalancesSpy).not.toHaveBeenCalled(); + fetchSep41Spy.mockRestore(); + getSep41AssetBalancesSpy.mockRestore(); + }); + + it('returns the Horizon account when testnet has no SEP-41 balance map', async () => { + const signer = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); + const loadedAcc = createMockAccountWithBalances( + signer.publicKey(), + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + const loaded = new OnChainAccount( + loadedAcc, + KnownCaip2ChainId.Testnet, + horizonSource(loadedAcc, KnownCaip2ChainId.Testnet), + ); + const { loadOnChainAccountSpy } = getNetworkServiceSpies(); + loadOnChainAccountSpy.mockResolvedValue(loaded); + const sep41Assets = getMockSep41Assets(); + const fetchSep41Spy = jest + .spyOn(AssetMetadataService.prototype, 'fetchSep41AssetsOrSyncOnce') + .mockResolvedValue(sep41Assets); + const getSep41AssetBalancesSpy = jest.spyOn( + NetworkService.prototype, + 'getSep41AssetBalances', + ); + + const { onChainAccountService } = mockOnChainAccountService(); + const result = await onChainAccountService.resolveOnChainAccount( + signer.publicKey(), + KnownCaip2ChainId.Testnet, + { resolveWithFullBalance: true }, + ); + + expect(getSep41AssetBalancesSpy).toHaveBeenCalledWith({ + accounts: [signer.publicKey()], + assetIds: sep41Assets.map((asset) => asset.assetId), + scope: KnownCaip2ChainId.Testnet, + }); + expect(result.accountId).toStrictEqual(signer.publicKey()); + expect(result.getRawAsset(USDC_SEP41)).toBeUndefined(); + expect(result.getRawAsset(USDT_SEP41)).toBeUndefined(); + fetchSep41Spy.mockRestore(); + getSep41AssetBalancesSpy.mockRestore(); + }); + + it('throws when mainnet returns no SEP-41 balance map for the account', async () => { + const signer = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); + const loadedAcc = createMockAccountWithBalances( + signer.publicKey(), + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + const loaded = new OnChainAccount( + loadedAcc, + KnownCaip2ChainId.Mainnet, + horizonSource(loadedAcc, KnownCaip2ChainId.Mainnet), + ); + const { loadOnChainAccountSpy } = getNetworkServiceSpies(); + loadOnChainAccountSpy.mockResolvedValue(loaded); + const sep41Assets = getMockSep41Assets(); + const fetchSep41Spy = jest + .spyOn(AssetMetadataService.prototype, 'fetchSep41AssetsOrSyncOnce') + .mockResolvedValue(sep41Assets); + const getSep41AssetBalancesSpy = jest + .spyOn(NetworkService.prototype, 'getSep41AssetBalances') + .mockResolvedValue({}); + + const { onChainAccountService } = mockOnChainAccountService(); + await expect( + onChainAccountService.resolveOnChainAccount( + signer.publicKey(), + KnownCaip2ChainId.Mainnet, + { resolveWithFullBalance: true }, + ), + ).rejects.toThrow( + new OnChainAccountSep41BalanceNotFoundException(signer.publicKey()), + ); + fetchSep41Spy.mockRestore(); + getSep41AssetBalancesSpy.mockRestore(); + }); + + it('throws when a requested SEP-41 balance cell was not read', async () => { + const signer = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); + const loadedAcc = createMockAccountWithBalances( + signer.publicKey(), + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + const loaded = new OnChainAccount( + loadedAcc, + KnownCaip2ChainId.Mainnet, + horizonSource(loadedAcc, KnownCaip2ChainId.Mainnet), + ); + const { loadOnChainAccountSpy } = getNetworkServiceSpies(); + loadOnChainAccountSpy.mockResolvedValue(loaded); + const sep41Assets = getMockSep41Assets(); + const [usdc, usdt] = sep41Assets; + if ( + usdc === undefined || + usdt === undefined || + !isSep41Id(usdc.assetId) || + !isSep41Id(usdt.assetId) + ) { + throw new Error('expected SEP-41 mock assets'); + } + const fetchSep41Spy = jest + .spyOn(AssetMetadataService.prototype, 'fetchSep41AssetsOrSyncOnce') + .mockResolvedValue(sep41Assets); + const getSep41AssetBalancesSpy = jest + .spyOn(NetworkService.prototype, 'getSep41AssetBalances') + .mockResolvedValue({ + [signer.publicKey()]: { + [usdc.assetId]: null, + [usdt.assetId]: new BigNumber(1), + }, + }); + + const { onChainAccountService } = mockOnChainAccountService(); + await expect( + onChainAccountService.resolveOnChainAccount( + signer.publicKey(), + KnownCaip2ChainId.Mainnet, + { resolveWithFullBalance: true }, + ), + ).rejects.toThrow( + new OnChainAccountBalanceNotAvailableException(usdc.assetId), + ); + fetchSep41Spy.mockRestore(); + getSep41AssetBalancesSpy.mockRestore(); + }); + + it('throws when a requested SEP-41 asset id is missing from the balance map', async () => { + const signer = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); + const loadedAcc = createMockAccountWithBalances( + signer.publicKey(), + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + const loaded = new OnChainAccount( + loadedAcc, + KnownCaip2ChainId.Mainnet, + horizonSource(loadedAcc, KnownCaip2ChainId.Mainnet), + ); + const { loadOnChainAccountSpy } = getNetworkServiceSpies(); + loadOnChainAccountSpy.mockResolvedValue(loaded); + const sep41Assets = getMockSep41Assets(); + const [usdc, usdt] = sep41Assets; + if ( + usdc === undefined || + usdt === undefined || + !isSep41Id(usdc.assetId) || + !isSep41Id(usdt.assetId) + ) { + throw new Error('expected SEP-41 mock assets'); + } + const fetchSep41Spy = jest + .spyOn(AssetMetadataService.prototype, 'fetchSep41AssetsOrSyncOnce') + .mockResolvedValue(sep41Assets); + const getSep41AssetBalancesSpy = jest + .spyOn(NetworkService.prototype, 'getSep41AssetBalances') + .mockResolvedValue({ + [signer.publicKey()]: { + [usdc.assetId]: new BigNumber(1), + }, + }); + + const { onChainAccountService } = mockOnChainAccountService(); + await expect( + onChainAccountService.resolveOnChainAccount( + signer.publicKey(), + KnownCaip2ChainId.Mainnet, + { resolveWithFullBalance: true }, + ), + ).rejects.toThrow( + new OnChainAccountBalanceNotAvailableException(usdt.assetId), + ); + fetchSep41Spy.mockRestore(); + getSep41AssetBalancesSpy.mockRestore(); + }); + + it('propagates a SEP-41 balance read failure', async () => { + const signer = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); + const loadedAcc = createMockAccountWithBalances( + signer.publicKey(), + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + const loaded = new OnChainAccount( + loadedAcc, + KnownCaip2ChainId.Mainnet, + horizonSource(loadedAcc, KnownCaip2ChainId.Mainnet), + ); + const { loadOnChainAccountSpy } = getNetworkServiceSpies(); + loadOnChainAccountSpy.mockResolvedValue(loaded); + const fetchSep41Spy = jest + .spyOn(AssetMetadataService.prototype, 'fetchSep41AssetsOrSyncOnce') + .mockResolvedValue(getMockSep41Assets()); + const getSep41AssetBalancesSpy = jest + .spyOn(NetworkService.prototype, 'getSep41AssetBalances') + .mockRejectedValue(new Error('Failed to load SEP-41 token balance')); + + const { onChainAccountService } = mockOnChainAccountService(); + await expect( + onChainAccountService.resolveOnChainAccount( + signer.publicKey(), + KnownCaip2ChainId.Mainnet, + { resolveWithFullBalance: true }, + ), + ).rejects.toThrow('Failed to load SEP-41 token balance'); + fetchSep41Spy.mockRestore(); + getSep41AssetBalancesSpy.mockRestore(); + }); }); describe('resolveOnChainAccountByKeyringAccountId', () => { diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index e6a8ad9ba..75ef93a72 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -1,15 +1,20 @@ import type { Logger } from '@metamask/snap-networks-utils'; import { BigNumber } from 'bignumber.js'; -import type { KnownCaip2ChainId } from '../../api'; +import { KnownCaip19Sep41AssetId, KnownCaip2ChainId } from '../../api'; import { + entries, getAssetReference, + isSep41Id, parseClassicAssetCodeIssuer, toSmallestUnit, trackError, } from '../../utils'; import { assertSameAddress } from '../account/utils'; -import type { StellarAssetMetadata } from '../asset-metadata'; +import type { + AssetMetadataService, + StellarAssetMetadata, +} from '../asset-metadata'; import type { AssetsService } from '../assets'; import { isCoreClassicAsset, @@ -20,6 +25,7 @@ import type { CoreAsset } from '../assets/api'; import { AccountNotActivatedException } from '../network'; import type { AccountLedgerMeta, NetworkService } from '../network'; import type { ActivatedAccountPair } from '../sync/api'; +import { OnChainAccountBalanceNotAvailableException, OnChainAccountSep41BalanceNotFoundException } from './exceptions'; import { OnChainAccount } from './OnChainAccount'; import type { OnChainAccountRepository } from './OnChainAccountRepository'; import { @@ -47,6 +53,8 @@ export class OnChainAccountService { readonly #assetsService: AssetsService; + readonly #assetMetadataService: AssetMetadataService; + readonly #logger: Logger; constructor({ @@ -54,11 +62,13 @@ export class OnChainAccountService { onChainAccountRepository, logger, assetsService, + assetMetadataService, }: { networkService: NetworkService; onChainAccountRepository: OnChainAccountRepository; logger: Logger; assetsService: AssetsService; + assetMetadataService: AssetMetadataService; }) { this.#networkService = networkService; this.#onChainAccountSynchronizeService = @@ -70,6 +80,7 @@ export class OnChainAccountService { this.#logger = logger.withPrefix('šŸ’¼ OnChainAccountService'); this.#onChainAccountRepository = onChainAccountRepository; this.#assetsService = assetsService; + this.#assetMetadataService = assetMetadataService; } /** @@ -102,22 +113,132 @@ export class OnChainAccountService { * * @param accountAddress - Stellar address (strkey) expected to match Horizon `account_id`. * @param scope - CAIP-2 network to load the account from (Horizon `loadAccount`). + * @param options - Optional extra loads. + * @param options.resolveWithFullBalance - When true, also read SEP-41 balances for the + * persisted catalog and bind them onto the Horizon account. * @returns Loaded {@link OnChainAccount} for simulation, fees, and sequence. * @throws {AccountNotActivatedException} When the account is not funded (from {@link NetworkService.loadOnChainAccount}). * @throws {DerivedAccountAddressMismatchException} When loaded id does not match `accountAddress`. + * @throws {OnChainAccountBalanceNotAvailableException} When `resolveWithFullBalance` is set and a + * SEP-41 cell comes back unread (`null` or missing). A zero balance is bound. + * @throws {OnChainAccountSep41BalanceNotFoundException} When `resolveWithFullBalance` is set and + * mainnet returns no SEP-41 map for the account. An empty catalog, or testnet, leaves SEP-41 + * unbound and does not throw. */ async resolveOnChainAccount( accountAddress: string, scope: KnownCaip2ChainId, + options?: { + resolveWithFullBalance?: boolean; + }, ): Promise { const loaded = await this.#networkService.loadOnChainAccount( accountAddress, scope, ); + + if (options?.resolveWithFullBalance) { + await this.#bindSep41Balances(loaded, scope); + } + assertSameAddress(accountAddress, loaded.accountId); return loaded; } + /** + * Reads SEP-41 balances for the persisted catalog and binds them onto `onChainAccount`. + * + * @param onChainAccount - Horizon account to attach SEP-41 entries to. + * @param scope - CAIP-2 network. + * @throws {OnChainAccountBalanceNotAvailableException} When a SEP-41 cell is `null` or missing. + * @throws {OnChainAccountSep41BalanceNotFoundException} When mainnet returns no map for the account. + */ + async #bindSep41Balances( + onChainAccount: OnChainAccount, + scope: KnownCaip2ChainId, + ): Promise { + const sep41Assets = + await this.#assetMetadataService.fetchSep41AssetsOrSyncOnce(scope); + const sep41AssetIds: KnownCaip19Sep41AssetId[] = []; + const assetMetadataByAssetId: Record< + KnownCaip19Sep41AssetId, + StellarAssetMetadata + > = {}; + + for (const asset of sep41Assets) { + const { assetId } = asset; + if (isSep41Id(assetId)) { + sep41AssetIds.push(assetId); + assetMetadataByAssetId[assetId] = asset; + } + } + + if (sep41AssetIds.length === 0) { + return; + } + + const balancesByAccount = await this.#networkService.getSep41AssetBalances({ + accounts: [onChainAccount.accountId], + assetIds: sep41AssetIds, + scope, + }); + const sep41Balances = balancesByAccount[onChainAccount.accountId]; + + // If it is testnet, we won't have any balances, so we return early. + // If it is mainnet, we throw an error as it is unexpected. + if (sep41Balances === undefined) { + if (scope === KnownCaip2ChainId.Mainnet) { + throw new OnChainAccountSep41BalanceNotFoundException(onChainAccount.accountId); + } + return; + } + + for (const [assetId, assetMetadata] of entries(assetMetadataByAssetId)) { + this.#setSep41BalanceForAccount({ + onChainAccount, + assetId, + balance: sep41Balances[assetId], + assetMetadata, + }); + } + } + + /** + * Binds one fetched SEP-41 balance, including zero, onto an account. + * + * @param params - Account, one SEP-41 id, its balance, and its metadata. + * @param params.onChainAccount - Horizon account that receives the entry. + * @param params.assetId - SEP-41 asset id to bind. + * @param params.balance - Balance in smallest units. `null` or `undefined` means the cell was not read. + * @param params.assetMetadata - Metadata for `assetId`, including symbol and decimals. + * @throws {OnChainAccountBalanceNotAvailableException} When the cell is `null` or missing. + */ + #setSep41BalanceForAccount({ + onChainAccount, + assetId, + balance, + assetMetadata, + }: { + onChainAccount: OnChainAccount; + assetId: KnownCaip19Sep41AssetId; + balance: BigNumber | null | undefined; + assetMetadata: StellarAssetMetadata; + }): void { + // Unread cell, not a zero balance. Omitting it would look like the token was removed. + if (balance === null || balance === undefined) { + throw new OnChainAccountBalanceNotAvailableException(assetId); + } + + const { decimals } = assetMetadata.units[0]; + const { symbol } = assetMetadata; + + onChainAccount.setAsset(assetId, { + balance, + symbol, + decimals, + }); + } + /** * Loads the on-chain account for the given keyring account id from snap state. * @@ -209,14 +330,22 @@ export class OnChainAccountService { ledger, }); - return OnChainAccount.fromSerializable( - this.#toSerializableFromCoreAssets({ + try { + const serializable = await this.#toSerializableFromCoreAssets({ accountAddress, scope, assets, ledger, - }), - ); + }) + return OnChainAccount.fromSerializable(serializable); + } catch (error: unknown) { + await trackError( + new Error('Error serializing on-chain account from core assets', { + cause: error, + }), + ); + throw error; + } } /** @@ -240,97 +369,78 @@ export class OnChainAccountService { assets: CoreAsset[]; ledger?: AccountLedgerMeta; }): OnChainAccountSerializableFull { - try { - const balances: SerializableSpendableBalance[] = []; - let rawNativeBalance = ledger?.rawNativeBalance ?? '0'; - let subentryCount = 0; + const balances: SerializableSpendableBalance[] = []; + let rawNativeBalance = ledger?.rawNativeBalance ?? '0'; + let subentryCount = 0; - for (const asset of assets) { - if (asset.chainId !== scope) { - continue; - } + for (const asset of assets) { + if (asset.chainId !== scope) { + continue; + } - const assetId = asset.id; - const { decimals, symbol } = asset.metadata; - const balance = toSmallestUnit( - new BigNumber(asset.balance.amount), - decimals, - ).toFixed(0); - - if (isCoreNativeAsset(asset)) { - // When RPC ledger meta is missing, derive subentryCount from Core - // `minimumReserveBalance` (stroops), assuming sponsoring fields are 0. - if (ledger === undefined) { - rawNativeBalance = balance; - const { minimumReserveBalance } = asset.balance.metadata; - subentryCount = subentryCountFromMinimumReserveStroops( - minimumReserveBalance, - ); - } - continue; - } + const assetId = asset.id; + const { decimals, symbol } = asset.metadata; + const balance = toSmallestUnit( + new BigNumber(asset.balance.amount), + decimals, + ).toFixed(0); - if (isCoreClassicAsset(asset)) { - const { limit, authorized, sponsored } = asset.balance.metadata; - const { assetIssuer: address } = parseClassicAssetCodeIssuer( - getAssetReference(assetId), - ); - balances.push( - SerializableClassicSpendableBalanceStruct.create({ - assetId, - symbol, - balance, - limit, - address, - authorized, - sponsored, - }), + if (isCoreNativeAsset(asset)) { + // When RPC ledger meta is missing, derive subentryCount from Core + // `minimumReserveBalance` (stroops), assuming sponsoring fields are 0. + if (ledger === undefined) { + rawNativeBalance = balance; + const { minimumReserveBalance } = asset.balance.metadata; + subentryCount = subentryCountFromMinimumReserveStroops( + minimumReserveBalance, ); - continue; } + continue; + } - if (isCoreSep41Asset(asset)) { - balances.push( - SerializableSep41SpendableBalanceStruct.create({ - assetId, - symbol, - balance, - decimals, - }), - ); - } + if (isCoreClassicAsset(asset)) { + const { limit, authorized, sponsored } = asset.balance.metadata; + const { assetIssuer: address } = parseClassicAssetCodeIssuer( + getAssetReference(assetId), + ); + balances.push( + SerializableClassicSpendableBalanceStruct.create({ + assetId, + symbol, + balance, + limit, + address, + authorized, + sponsored, + }), + ); + continue; } - return OnChainAccountSerializableFullStruct.create({ - accountId: accountAddress, - sequenceNumber: ledger?.sequenceNumber ?? '0', - scope, - meta: { - subentryCount: ledger?.subentryCount ?? subentryCount, - numSponsoring: ledger?.numSponsoring ?? 0, - numSponsored: ledger?.numSponsored ?? 0, - }, - balances, - rawNativeBalance, - }); - } catch (error: unknown) { - this.#logger.debug( - 'Error serializing on-chain account from core assets', - { - error, - accountAddress, - scope, - assets, - ledger, - }, - ); - trackError( - new Error('Error serializing on-chain account from core assets', { - cause: error, - }), - ); - throw error; + if (isCoreSep41Asset(asset)) { + balances.push( + SerializableSep41SpendableBalanceStruct.create({ + assetId, + symbol, + balance, + decimals, + }), + ); + } } + + return OnChainAccountSerializableFullStruct.create({ + accountId: accountAddress, + sequenceNumber: ledger?.sequenceNumber ?? '0', + scope, + meta: { + subentryCount: ledger?.subentryCount ?? subentryCount, + numSponsoring: ledger?.numSponsoring ?? 0, + numSponsored: ledger?.numSponsored ?? 0, + }, + balances, + rawNativeBalance, + }); } async #getAccountLedgerMetaSafe( diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts index df760a645..00a64964e 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/__mocks__/onChainAccount.fixtures.ts @@ -7,6 +7,7 @@ import type { KnownCaip2ChainId } from '../../../api'; import { logger, noOpLogger } from '../../../utils/logger'; import { AccountService } from '../../account/AccountService'; import { AccountsRepository } from '../../account/AccountsRepository'; +import { createMockAssetMetadataService } from '../../asset-metadata/__mocks__/assets.fixtures'; import { createMockAssetsService } from '../../assets/__mocks__/assetsService.fixtures'; import { NetworkService } from '../../network'; import { WalletService } from '../../wallet'; @@ -173,11 +174,13 @@ export function mockOnChainAccountService() { }); const onChainAccountRepository = new OnChainAccountRepository(state); const { service: assetsService } = createMockAssetsService(); + const { service: assetMetadataService } = createMockAssetMetadataService(); const onChainAccountService = new OnChainAccountService({ logger, networkService, onChainAccountRepository, assetsService, + assetMetadataService, }); return { @@ -186,6 +189,7 @@ export function mockOnChainAccountService() { accountService, walletService, assetsService, + assetMetadataService, }; } diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/exceptions.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/exceptions.ts index 4192e227a..295985f7e 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/exceptions.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/exceptions.ts @@ -3,13 +3,30 @@ import { StellarSnapException } from '../../utils'; export class OnChainAccountException extends StellarSnapException {} +/** + * Thrown when the balance is not available for the given asset. + */ export class OnChainAccountBalanceNotAvailableException extends OnChainAccountException { - constructor(assetId: KnownCaip19AssetIdOrSlip44Id) { - super(`Balance not available for asset ${assetId}`); + constructor(assetId?: KnownCaip19AssetIdOrSlip44Id) { + super(`Balance not available${assetId ? ` for asset ${assetId}` : ''}`); } } + +/** + * Thrown when the account metadata is not available. + */ export class OnChainAccountMetadataNotAvailableException extends OnChainAccountException { constructor() { super(`Account metadata not available`); } } + + +/** + * Thrown when mainnet returns no SEP-41 balance map for an account. + */ +export class OnChainAccountSep41BalanceNotFoundException extends OnChainAccountException { + constructor(accountAddress: string) { + super(`Balance not available for account ${accountAddress}`); + } +} From 63d52c3b075d97b540c63950691073a0e92438cf Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:33:54 +0800 Subject: [PATCH 07/50] chore: get live data from keyring method --- eslint-suppressions.json | 7 +- packages/stellar-wallet-snap/.env.example | 3 + packages/stellar-wallet-snap/snap.config.ts | 2 + .../stellar-wallet-snap/src/config.test.ts | 4 + packages/stellar-wallet-snap/src/config.ts | 4 + .../clientRequest/changeTrustOpt.test.ts | 3 +- .../clientRequest/confirmSend.test.ts | 8 +- .../clientRequest/onAmountInput.test.ts | 8 +- .../src/handlers/keyring/keyring.test.ts | 135 +++++++++--- .../src/handlers/keyring/keyring.ts | 109 +++++++--- .../__mocks__/assets.fixtures.ts | 14 +- .../src/services/assets/AssetsService.test.ts | 17 +- .../__mocks__/assetsService.fixtures.ts | 1 + .../services/network/NetworkService.test.ts | 205 ++++-------------- .../src/services/network/NetworkService.ts | 1 - .../__mocks__/networkService.fixtures.ts | 174 +++++++++++++++ .../OnChainAccountService.test.ts | 33 +-- .../on-chain-account/OnChainAccountService.ts | 29 ++- .../OnChainAccountSynchronizeService.test.ts | 5 +- .../services/on-chain-account/exceptions.ts | 1 - 20 files changed, 452 insertions(+), 311 deletions(-) create mode 100644 packages/stellar-wallet-snap/src/services/network/__mocks__/networkService.fixtures.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 903f8bff9..7019c2fe4 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1108,11 +1108,6 @@ "count": 2 } }, - "packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 4 - } - }, "packages/stellar-wallet-snap/src/services/network/utils.ts": { "no-restricted-syntax": { "count": 1 @@ -1432,4 +1427,4 @@ "count": 2 } } -} +} \ No newline at end of file diff --git a/packages/stellar-wallet-snap/.env.example b/packages/stellar-wallet-snap/.env.example index 88e5b55b7..287bd0377 100644 --- a/packages/stellar-wallet-snap/.env.example +++ b/packages/stellar-wallet-snap/.env.example @@ -50,6 +50,9 @@ SECURITY_ALERTS_API_BASE_URL=https://security-alerts.api.cx.metamask.io # Cache TTL Milliseconds for sep41 asset balance #STELLAR_SEP41_ASSET_BALANCE_TTL_MILLISECONDS= +# Cache TTL Milliseconds for the keyring live account reused by getAccountBalances +#STELLAR_KEYRING_LIVE_ACCOUNT_TTL_MILLISECONDS= + # Cache TTL Milliseconds for spot prices #STELLAR_SPOT_PRICES_TTL_MILLISECONDS= diff --git a/packages/stellar-wallet-snap/snap.config.ts b/packages/stellar-wallet-snap/snap.config.ts index 8560aaac3..2add3dbd6 100644 --- a/packages/stellar-wallet-snap/snap.config.ts +++ b/packages/stellar-wallet-snap/snap.config.ts @@ -45,6 +45,8 @@ const config: SnapConfig = { process.env.STELLAR_SIMULATE_TRANSACTION_TTL_MILLISECONDS ?? '', STELLAR_SEP41_ASSET_BALANCE_TTL_MILLISECONDS: process.env.STELLAR_SEP41_ASSET_BALANCE_TTL_MILLISECONDS ?? '', + STELLAR_KEYRING_LIVE_ACCOUNT_TTL_MILLISECONDS: + process.env.STELLAR_KEYRING_LIVE_ACCOUNT_TTL_MILLISECONDS ?? '', STELLAR_BASE_FEE_MULTIPLIER: process.env.STELLAR_BASE_FEE_MULTIPLIER ?? '', STELLAR_MAX_FEE_THRESHOLD_IN_XLM: process.env.STELLAR_MAX_FEE_THRESHOLD_IN_XLM ?? '', diff --git a/packages/stellar-wallet-snap/src/config.test.ts b/packages/stellar-wallet-snap/src/config.test.ts index a6454f512..f58b2d94b 100644 --- a/packages/stellar-wallet-snap/src/config.test.ts +++ b/packages/stellar-wallet-snap/src/config.test.ts @@ -54,6 +54,7 @@ const FULL_ENVIRONMENT = { loadOnChainAccount: '30000', simulateTransaction: '8000', sep41AssetBalance: '15000', + keyringLiveAccount: '20000', }, }, }; @@ -252,6 +253,7 @@ describe('ConfigProvider', () => { loadOnChainAccount: 30000, simulateTransaction: 8000, sep41AssetBalance: 15000, + keyringLiveAccount: 20000, }); }); @@ -266,6 +268,7 @@ describe('ConfigProvider', () => { loadOnChainAccount: '', simulateTransaction: undefined, sep41AssetBalance: '', + keyringLiveAccount: undefined, }, }, }, @@ -278,6 +281,7 @@ describe('ConfigProvider', () => { loadOnChainAccount: 10 * 60 * 1000, simulateTransaction: 10 * 1000, sep41AssetBalance: 30 * 1000, + keyringLiveAccount: 30 * 1000, }); }); }); diff --git a/packages/stellar-wallet-snap/src/config.ts b/packages/stellar-wallet-snap/src/config.ts index a7d819903..06ed1c34c 100644 --- a/packages/stellar-wallet-snap/src/config.ts +++ b/packages/stellar-wallet-snap/src/config.ts @@ -112,6 +112,8 @@ export const ConfigStruct = object({ simulateTransaction: parseIntegerStruct(1000, 10 * 1000), // SEP-41 balance reads (multicall on mainnet) sep41AssetBalance: parseIntegerStruct(1000, 30 * 1000), + // Keyring getAccountAssets result reused by the following getAccountBalances call + keyringLiveAccount: parseIntegerStruct(1000, 30 * 1000), }), }), }); @@ -181,6 +183,8 @@ const ENVIRONMENT = { process.env.STELLAR_SIMULATE_TRANSACTION_TTL_MILLISECONDS, sep41AssetBalance: process.env.STELLAR_SEP41_ASSET_BALANCE_TTL_MILLISECONDS, + keyringLiveAccount: + process.env.STELLAR_KEYRING_LIVE_ACCOUNT_TTL_MILLISECONDS, }, }, }; diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts index de2962ffa..65fdaf349 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/changeTrustOpt.test.ts @@ -4,7 +4,6 @@ import { Networks } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; import { KnownCaip2ChainId } from '../../api'; -import type { KnownCaip19ClassicAssetId } from '../../api'; import { METAMASK_ORIGIN } from '../../constants'; import { AccountService } from '../../services/account'; import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; @@ -73,7 +72,7 @@ describe('ChangeTrustOptHandler', () => { const accountId = '11111111-1111-4111-8111-111111111111'; const scope = KnownCaip2ChainId.Mainnet; - const assetId = USDC_CLASSIC as KnownCaip19ClassicAssetId; + const assetId = USDC_CLASSIC; const transactionHash = '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0e1'; const trustlineAsset = { diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts index 63e09885a..16c47d257 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/confirmSend.test.ts @@ -6,10 +6,6 @@ import { Networks } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; import { KnownCaip2ChainId } from '../../api'; -import type { - KnownCaip19ClassicAssetId, - KnownCaip19Sep41AssetId, -} from '../../api'; import { METAMASK_ORIGIN } from '../../constants'; import { AccountService } from '../../services/account'; import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; @@ -77,7 +73,7 @@ const destinationAddress = describe('ConfirmSendHandler', () => { const accountId = '11111111-1111-4111-8111-111111111111'; - const assetId = USDC_CLASSIC as KnownCaip19ClassicAssetId; + const assetId = USDC_CLASSIC; const scope = KnownCaip2ChainId.Mainnet; const transactionId = '7d4b0c5ef7498b223f45a10f461060fb64f53eb13caf18e8dc7de95a8cf9c0e1'; @@ -244,7 +240,7 @@ describe('ConfirmSendHandler', () => { } it('returns invalid when value has more decimal places than the asset supports', async () => { - const sep41AssetId = USDC_SEP41 as KnownCaip19Sep41AssetId; + const sep41AssetId = USDC_SEP41; const { handler, createDraftSendTransactionForConfirm, diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts index 36e2b60ab..ec6ab158f 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts @@ -2,10 +2,6 @@ import { InvalidParamsError } from '@metamask/snaps-sdk'; import { BigNumber } from 'bignumber.js'; import { KnownCaip2ChainId } from '../../api'; -import type { - KnownCaip19ClassicAssetId, - KnownCaip19Sep41AssetId, -} from '../../api'; import { AccountService } from '../../services/account'; import { generateStellarKeyringAccount } from '../../services/account/__mocks__/account.fixtures'; import type { StellarAssetMetadata } from '../../services/asset-metadata'; @@ -59,7 +55,7 @@ describe('OnAmountInputHandler', () => { }); const accountId = '11111111-1111-4111-8111-111111111111'; - const assetId = USDC_CLASSIC as KnownCaip19ClassicAssetId; + const assetId = USDC_CLASSIC; const scope = KnownCaip2ChainId.Mainnet; function setup() { @@ -154,7 +150,7 @@ describe('OnAmountInputHandler', () => { } it('returns invalid when value has more decimal places than the asset supports', async () => { - const sep41AssetId = USDC_SEP41 as KnownCaip19Sep41AssetId; + const sep41AssetId = USDC_SEP41; const { handler, createValidatedSendTransaction } = setup(); const assetMetadata = generateMockStellarAssetMetadata()[ sep41AssetId diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 4983e69f0..64a020b81 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -6,6 +6,7 @@ import type { JsonRpcRequest } from '@metamask/snaps-sdk'; import { create } from '@metamask/superstruct'; import { KnownCaip2ChainId } from '../../api'; +import { AppConfig } from '../../config'; import { METAMASK_ORIGIN } from '../../constants'; import { AccountService } from '../../services/account'; import type { StellarKeyringAccount } from '../../services/account'; @@ -14,6 +15,7 @@ import { generateStellarKeyringAccount, } from '../../services/account/__mocks__/account.fixtures'; import { AccountNotFoundException } from '../../services/account/exceptions'; +import { AccountNotActivatedException } from '../../services/network/exceptions'; import { OnChainAccountService } from '../../services/on-chain-account'; import { createMockAccountWithBalances, @@ -433,19 +435,21 @@ describe('KeyringHandler', () => { }); describe('getAccountAssets', () => { - it('returns on-chain asset ids for the account', async () => { + it('returns live on-chain asset ids and caches the account', async () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); const onChainAccount = createTestOnChainAccount(mockAccount.address); - jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) + const resolveOnChainAccountSpy = jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') .mockResolvedValue(onChainAccount); const result = await keyringHandler.getAccountAssets(mockAccountId); + expect(resolveOnChainAccountSpy).toHaveBeenCalledWith( + mockAccount.address, + KnownCaip2ChainId.Mainnet, + { resolveWithFullBalance: true }, + ); expect(result).toStrictEqual(onChainAccount.assetIds); }); @@ -454,11 +458,13 @@ describe('KeyringHandler', () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockResolvedValue(null); + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockRejectedValue( + new AccountNotActivatedException( + mockAccount.address, + KnownCaip2ChainId.Mainnet, + ), + ); const result = await keyringHandler.getAccountAssets(mockAccountId); @@ -469,10 +475,7 @@ describe('KeyringHandler', () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') .mockRejectedValue(new Error('Horizon unavailable')); await expect( @@ -575,17 +578,19 @@ describe('KeyringHandler', () => { ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, nativeBalance: 2.000001, }); - jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) + const resolveOnChainAccountSpy = jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') .mockResolvedValue(onChainAccount); const result = await keyringHandler.getAccountBalances(mockAccountId, [ slipId, ]); + expect(resolveOnChainAccountSpy).toHaveBeenCalledWith( + mockAccount.address, + KnownCaip2ChainId.Mainnet, + { resolveWithFullBalance: true }, + ); expect(result).toStrictEqual({ [slipId]: { unit: 'XLM', @@ -599,21 +604,72 @@ describe('KeyringHandler', () => { }); }); + it('reuses the account cached by getAccountAssets', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address, { + ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + nativeBalance: 2.000001, + }); + const resolveOnChainAccountSpy = jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockResolvedValue(onChainAccount); + + await keyringHandler.getAccountAssets(mockAccountId); + resolveOnChainAccountSpy.mockClear(); + + const result = await keyringHandler.getAccountBalances(mockAccountId, [ + slipId, + ]); + + expect(resolveOnChainAccountSpy).not.toHaveBeenCalled(); + expect(result[slipId]?.amount).toBe('2.000001'); + }); + + it('loads Horizon again when the cached account has expired', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address); + const resolveOnChainAccountSpy = jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockResolvedValue(onChainAccount); + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_000); + + await keyringHandler.getAccountAssets(mockAccountId); + nowSpy.mockReturnValue( + 1_000 + AppConfig.cache.ttlMilliseconds.keyringLiveAccount + 1, + ); + resolveOnChainAccountSpy.mockClear(); + + await keyringHandler.getAccountBalances(mockAccountId, [slipId]); + + expect(resolveOnChainAccountSpy).toHaveBeenCalledTimes(1); + nowSpy.mockRestore(); + }); + it('returns zero native balance when the account is not activated on-chain', async () => { const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockResolvedValue(null); + const resolveOnChainAccountSpy = jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockRejectedValue( + new AccountNotActivatedException( + mockAccount.address, + KnownCaip2ChainId.Mainnet, + ), + ); + + await keyringHandler.getAccountAssets(mockAccountId); + resolveOnChainAccountSpy.mockClear(); const result = await keyringHandler.getAccountBalances(mockAccountId, [ slipId, ]); + expect(resolveOnChainAccountSpy).not.toHaveBeenCalled(); expect(result).toStrictEqual({ [slipId]: { unit: 'XLM', @@ -627,15 +683,34 @@ describe('KeyringHandler', () => { }); }); + it('loads Horizon when a previous asset read failed and was not cached', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address); + const resolveOnChainAccountSpy = jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockRejectedValueOnce(new Error('Horizon unavailable')) + .mockResolvedValue(onChainAccount); + + await expect( + keyringHandler.getAccountAssets(mockAccountId), + ).rejects.toThrow('Horizon unavailable'); + + const result = await keyringHandler.getAccountBalances(mockAccountId, [ + slipId, + ]); + + expect(resolveOnChainAccountSpy).toHaveBeenCalledTimes(2); + expect(result[slipId]?.amount).toBe('1'); + }); + it('propagates errors when balance resolution fails for another reason', async () => { const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') .mockRejectedValue(new Error('Horizon unavailable')); await expect( diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 3364f96bd..2ddbf826a 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -19,6 +19,8 @@ import type { import { handleKeyringRequest } from '@metamask/keyring-snap-sdk/v2'; import { asStrictKeyringAccount, + InMemoryCache, + useCache, validateOrigin, } from '@metamask/snap-networks-utils'; import type { Logger } from '@metamask/snap-networks-utils'; @@ -38,21 +40,20 @@ import type { import { StellarSecretKeyStruct } from '../../api'; import { AppConfig } from '../../config'; import { originPermissions } from '../../permissions'; -import type { - AccountService, - StellarKeyringAccount, -} from '../../services/account'; +import type { AccountService } from '../../services/account'; import { AccountNotFoundException } from '../../services/account/exceptions'; -import type { - OnChainAccount, - OnChainAccountService, -} from '../../services/on-chain-account'; +import { AccountNotActivatedException } from '../../services/network/exceptions'; import { + OnChainAccount, toClassicBalanceEntry, getDefaultBalanceEntry, toNativeBalanceEntry, toStandardBalanceEntry, } from '../../services/on-chain-account'; +import type { + OnChainAccountSerializableFull, + OnChainAccountService, +} from '../../services/on-chain-account'; import type { TransactionService } from '../../services/transaction/TransactionService'; import type { WalletService } from '../../services/wallet'; import { @@ -95,6 +96,8 @@ export class KeyringHandler implements KeyringSnapRpc { readonly #handlers: Record; + readonly #liveOnChainAccountCache: InMemoryCache; + constructor({ logger, accountService, @@ -111,6 +114,7 @@ export class KeyringHandler implements KeyringSnapRpc { handlers: Record; }) { this.#logger = logger.withPrefix(KEYRING_HANDLER_LOGGER_PREFIX); + this.#liveOnChainAccountCache = new InMemoryCache(this.#logger); this.#accountService = accountService; this.#onChainAccountService = onChainAccountService; this.#transactionService = transactionService; @@ -213,13 +217,14 @@ export class KeyringHandler implements KeyringSnapRpc { validateRequest(accountId, ListAccountAssetsRequestStruct); const scope = AppConfig.selectedNetwork; - - const { onChainAccount } = await this.#resolveAccountByAccountId( + const onChainAccount = await this.#resolveAccountByAccountId({ accountId, scope, - ); + // Always refresh the cache to get the latest on-chain account. + refresh: true, + }); - // If the account is not activated or not yet synced, return the native asset with zero balance + // Unfunded accounts have no trustlines. Return native so the client can ask for a zero balance. if (onChainAccount === null) { return [getSlip44AssetId(scope)]; } @@ -320,12 +325,13 @@ export class KeyringHandler implements KeyringSnapRpc { const scope = AppConfig.selectedNetwork; const assetBalances = {} as Record; - const { onChainAccount } = await this.#resolveAccountByAccountId( + const onChainAccount = await this.#resolveAccountByAccountId({ accountId, scope, - ); + refresh: false, + }); - // If the account is not activated or not yet synced, return the native asset with zero balance + // Unfunded accounts have no trustlines. Return native zero when it was requested. if (onChainAccount === null) { const nativeAssetId = knownAssets.find(isSlip44Id); if (nativeAssetId !== undefined) { @@ -482,27 +488,66 @@ export class KeyringHandler implements KeyringSnapRpc { validateRequest(method, MultichainMethodStruct); } - async #resolveAccountByAccountId( - accountId: string, - scope: KnownCaip2ChainId, - ): Promise<{ - account: StellarKeyringAccount; - onChainAccount: OnChainAccount | null; - }> { + /** + * Loads a Horizon account for the keyring asset/balance pair. + * + * `getAccountAssets` always refreshes and stores the result. `getAccountBalances` + * reuses that entry until `AppConfig.cache.ttlMilliseconds.keyringLiveAccount`, then loads Horizon. + * A failed load is not cached. An unactivated account is cached as `null`. + * + * @param params - Account id, scope, and whether to skip the cache. + * @param params.accountId - Keyring account id. + * @param params.scope - CAIP-2 network. + * @param params.refresh - When true, load Horizon and replace the cache entry. + * @returns The live account, or `null` when it is not activated. + */ + async #resolveAccountByAccountId({ + accountId, + scope, + refresh, + }: { + accountId: string; + scope: KnownCaip2ChainId; + refresh: boolean; + }): Promise { const { account } = await this.#accountService.resolveAccount({ accountId, }); + const serialized = await useCache( + this.#resolveOnChainAccount.bind(this), + this.#liveOnChainAccountCache, + { + logger: this.#logger, + functionName: 'KeyringHandler:resolveOnChainAccount', + ttlMilliseconds: AppConfig.cache.ttlMilliseconds.keyringLiveAccount, + refreshCache: refresh, + }, + )(account.address, scope); - // We read the on-chain account from state, which is synced in the background. - // This improves performance compared to fetching account data from the network on every request. - // The trade-off is that the data can be slightly stale within the sync window. - const onChainAccount = - await this.#onChainAccountService.resolveOnChainAccountByKeyringAccountId( - accountId, - account.address, - scope, - ); + if (serialized === null) { + return null; + } + + return OnChainAccount.fromSerializable(serialized); + } - return { account, onChainAccount }; + async #resolveOnChainAccount( + accountAddress: string, + scope: KnownCaip2ChainId, + ): Promise { + try { + const onChainAccount = + await this.#onChainAccountService.resolveOnChainAccount( + accountAddress, + scope, + { resolveWithFullBalance: true }, + ); + return onChainAccount.toSerializableFull(); + } catch (error: unknown) { + if (error instanceof AccountNotActivatedException) { + return null; + } + throw error; + } } } 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 3c8550155..bcb641d89 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,6 +1,10 @@ import { InMemoryState, InMemoryCache } from '@metamask/snap-networks-utils'; -import type { KnownCaip19AssetIdOrSlip44Id } from '../../../api'; +import type { + KnownCaip19ClassicAssetId, + KnownCaip19Sep41AssetId, + KnownCaip19Slip44Id, +} from '../../../api'; import { AssetType, KnownCaip2ChainId } from '../../../api'; import { NATIVE_ASSET_NAME, NATIVE_ASSET_SYMBOL } from '../../../constants'; import { getSlip44AssetId } from '../../../utils/caip'; @@ -11,12 +15,12 @@ import type { AssetMetadataByAssetId, StellarAssetMetadata } from '../api'; import { AssetMetadataRepository } from '../AssetMetadataRepository'; import { AssetMetadataService } from '../AssetMetadataService'; -export const NATIVE: KnownCaip19AssetIdOrSlip44Id = `${getSlip44AssetId(KnownCaip2ChainId.Mainnet)}`; -export const USDC_CLASSIC: KnownCaip19AssetIdOrSlip44Id = +export const NATIVE: KnownCaip19Slip44Id = `${getSlip44AssetId(KnownCaip2ChainId.Mainnet)}`; +export const USDC_CLASSIC: KnownCaip19ClassicAssetId = 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; -export const USDC_SEP41: KnownCaip19AssetIdOrSlip44Id = +export const USDC_SEP41: KnownCaip19Sep41AssetId = 'stellar:pubnet/sep41:CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'; -export const USDT_SEP41: KnownCaip19AssetIdOrSlip44Id = +export const USDT_SEP41: KnownCaip19Sep41AssetId = 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J'; export const generateMockStellarAssetMetadata = (): AssetMetadataByAssetId => { 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 aea670c57..5be5dff61 100644 --- a/packages/stellar-wallet-snap/src/services/assets/AssetsService.test.ts +++ b/packages/stellar-wallet-snap/src/services/assets/AssetsService.test.ts @@ -28,16 +28,14 @@ describe('AssetsService', () => { image: 'https://example.test/usdc.png', }); - await expect(service.getAssetMetadata(assetId)).resolves.toMatchObject( - coreMetadata, - ); + expect(await service.getAssetMetadata(assetId)).toMatchObject(coreMetadata); expect(getAssetMetadata).toHaveBeenCalledWith(assetId); }); it('returns null when Core misses', async () => { const { service, getAssetMetadata } = createMockAssetsService(); - await expect(service.getAssetMetadata(assetId)).resolves.toBeNull(); + expect(await service.getAssetMetadata(assetId)).toBeNull(); expect(getAssetMetadata).toHaveBeenCalledWith(assetId); }); @@ -45,7 +43,7 @@ describe('AssetsService', () => { const { service, getAssetMetadata } = createMockAssetsService(); getAssetMetadata.mockResolvedValue({ symbol: 'USDC' }); - await expect(service.getAssetMetadata(assetId)).resolves.toBeNull(); + expect(await service.getAssetMetadata(assetId)).toBeNull(); }); it('validates account assets into CoreAsset and drops invalid rows', async () => { @@ -105,9 +103,12 @@ describe('AssetsService', () => { remoteFeatureFlagsProvider, }); - await expect( - service.getAccountAssetsByScope(KnownCaip2ChainId.Mainnet, 'acct-1'), - ).resolves.toMatchObject([ + expect( + await service.getAccountAssetsByScope( + KnownCaip2ChainId.Mainnet, + 'acct-1', + ), + ).toMatchObject([ { id: nativeId, chainId: KnownCaip2ChainId.Mainnet, diff --git a/packages/stellar-wallet-snap/src/services/assets/__mocks__/assetsService.fixtures.ts b/packages/stellar-wallet-snap/src/services/assets/__mocks__/assetsService.fixtures.ts index 7473268bb..1d6767a43 100644 --- a/packages/stellar-wallet-snap/src/services/assets/__mocks__/assetsService.fixtures.ts +++ b/packages/stellar-wallet-snap/src/services/assets/__mocks__/assetsService.fixtures.ts @@ -8,6 +8,7 @@ import { AssetsService } from '../AssetsService'; * Builds an {@link AssetsService} with a mocked Core adapter. Flag defaults to Off. * * @param options - Optional Core / flag overrides. + * @param options.migrationStage - The migration stage to use for the mock. * @returns The facade and the Core `getAssetMetadata` mock. */ export function createMockAssetsService({ 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 a107a6a06..7cb695cde 100644 --- a/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts +++ b/packages/stellar-wallet-snap/src/services/network/NetworkService.test.ts @@ -2,36 +2,39 @@ import { TransactionStatus } from '@metamask/keyring-api'; import { InMemoryCache } from '@metamask/snap-networks-utils'; import { Account, - Contract, Horizon as StellarHorizon, Networks, - nativeToScVal, NotFoundError, rpc as StellarRpc, - TransactionBuilder as StellarTransactionBuilder, } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; -import type { - KnownCaip19ClassicAssetId, - KnownCaip19Sep41AssetId, -} from '../../api'; +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 { USDC_SEP41 } from '../asset-metadata/__mocks__/assets.fixtures'; import { createMockAccountWithBalances } from '../on-chain-account/__mocks__/onChainAccount.fixtures'; import { OnChainAccount } from '../on-chain-account/OnChainAccount'; import { buildMockClassicTransaction, buildMockHorizonTransactionPage, buildMockHorizonTransactionRecord, - buildMockInvokeHostFunctionTransaction, } from '../transaction/__mocks__/transaction.fixtures'; import { InvalidInvokeContractStructureException } from '../transaction/exceptions'; import { Transaction } from '../transaction/Transaction'; import { generateStellarAddress } from '../wallet/__mocks__/wallet.fixtures'; +import { + buildTransactionWithTwoInvokeHostFunctionOps, + createMockAccountEntry, + createMockInvokeHostFunctionTransaction, + createMockTransaction, + getHorizonClientSpies, + getRpcServerSpies, + mockHorizonAccountTransactions, +} from './__mocks__/networkService.fixtures'; import { KnownRpcError } from './api'; import { AccountNotActivatedException, @@ -63,102 +66,6 @@ describe('NetworkService', () => { scope = KnownCaip2ChainId.Mainnet; }); - const getHorizonClientSpies = () => ({ - fetchBaseFeeSpy: jest.spyOn( - StellarHorizon.Server.prototype, - 'fetchBaseFee', - ), - loadAccountSpy: jest.spyOn(StellarHorizon.Server.prototype, 'loadAccount'), - }); - - const getRpcServerSpies = () => ({ - pollTransactionSpy: jest.spyOn( - StellarRpc.Server.prototype, - 'pollTransaction', - ), - sendTransactionSpy: jest.spyOn( - StellarRpc.Server.prototype, - 'sendTransaction', - ), - getAccountSpy: jest.spyOn(StellarRpc.Server.prototype, 'getAccount'), - getAccountEntrySpy: jest.spyOn( - StellarRpc.Server.prototype, - 'getAccountEntry', - ), - getLedgerEntriesSpy: jest.spyOn( - StellarRpc.Server.prototype, - 'getLedgerEntries', - ), - simulateTransactionSpy: jest.spyOn( - StellarRpc.Server.prototype, - 'simulateTransaction', - ), - }); - - const validSep41AssetId = - 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as KnownCaip19Sep41AssetId; - - const createMockTransaction = (accountId?: string) => { - return buildMockClassicTransaction( - [ - { - type: 'payment', - params: { - destination: accountId ?? generateStellarAddress(), - asset: 'native', - amount: '1', - }, - }, - ], - { - networkPassphrase: Networks.PUBLIC, - }, - ); - }; - - const mockHorizonAccountTransactions = ( - call: jest.Mock, - ): jest.SpyInstance => { - return jest - .spyOn(StellarHorizon.Server.prototype, 'transactions') - .mockReturnValue({ - forAccount: jest.fn().mockReturnValue({ - order: jest.fn().mockReturnValue({ - cursor: jest.fn().mockReturnValue({ - limit: jest.fn().mockReturnValue({ - includeFailed: jest.fn().mockReturnValue({ call }), - }), - }), - }), - }), - } as never); - }; - - const createMockInvokeHostFunctionTransaction = (accountId?: string) => { - return buildMockInvokeHostFunctionTransaction('invokeHostFunction', [], { - contractId: 'CASUP2OPFVEHCWGP2XLBXOV7DQIQIT42AQISG4MXAZGNLVFFN63X7WRT', - source: { - accountId: accountId ?? generateStellarAddress(), - sequence: '1', - }, - }); - }; - - const buildTransactionWithTwoInvokeHostFunctionOps = (): Transaction => { - const source = 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG'; - const stellarAccount = new Account(source, '1'); - const contract = new Contract( - 'CASUP2OPFVEHCWGP2XLBXOV7DQIQIT42AQISG4MXAZGNLVFFN63X7WRT', - ); - const builder = new StellarTransactionBuilder(stellarAccount, { - fee: '200', - networkPassphrase: Networks.PUBLIC, - }); - builder.addOperation(contract.call('fnA', nativeToScVal(1))); - builder.addOperation(contract.call('fnB', nativeToScVal(2))); - return new Transaction(builder.setTimeout(60).build()); - }; - describe('getBaseFee', () => { it('returns base fee as BigNumber', async () => { const { fetchBaseFeeSpy } = getHorizonClientSpies(); @@ -471,35 +378,13 @@ describe('NetworkService', () => { const testAddress = 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG'; - const mockAccountEntry = { - numSubEntries: () => 4, - seqNum: () => ({ - toString: () => '262764252333343491', - }), - balance: () => ({ - toString: () => '351010623', - }), - ext: () => ({ - switch: () => 1, - v1: () => ({ - ext: () => ({ - switch: () => 2, - v2: () => ({ - numSponsoring: () => 1, - numSponsored: () => 0, - }), - }), - }), - }), - }; - it('returns sequence, subentry, and sponsorship counts from RPC getAccountEntry', async () => { const { getAccountEntrySpy } = getRpcServerSpies(); - getAccountEntrySpy.mockResolvedValue(mockAccountEntry as never); + getAccountEntrySpy.mockResolvedValue(createMockAccountEntry()); - await expect( - networkService.getAccountLedgerMeta(testAddress, scope), - ).resolves.toStrictEqual({ + expect( + await networkService.getAccountLedgerMeta(testAddress, scope), + ).toStrictEqual({ sequenceNumber: '262764252333343491', subentryCount: 4, numSponsoring: 1, @@ -511,16 +396,11 @@ describe('NetworkService', () => { it('throws when the account v1 extension is missing', async () => { const { getAccountEntrySpy } = getRpcServerSpies(); - getAccountEntrySpy.mockResolvedValue({ - numSubEntries: () => 3, - ext: () => ({ - switch: () => 0, - v1: () => { - throw new Error('v1 arm not set'); - }, - }), - } as never); + getAccountEntrySpy.mockResolvedValue( + createMockAccountEntry({ accountExtSwitch: 0 }), + ); + await expect( networkService.getAccountLedgerMeta(testAddress, scope), ).rejects.toThrow( @@ -530,20 +410,9 @@ describe('NetworkService', () => { it('throws when the account v2 extension is missing', async () => { const { getAccountEntrySpy } = getRpcServerSpies(); - getAccountEntrySpy.mockResolvedValue({ - numSubEntries: () => 3, - ext: () => ({ - switch: () => 1, - v1: () => ({ - ext: () => ({ - switch: () => 0, - v2: () => { - throw new Error('v2 arm not set'); - }, - }), - }), - }), - } as never); + getAccountEntrySpy.mockResolvedValue( + createMockAccountEntry({ v1ExtSwitch: 0 }), + ); await expect( networkService.getAccountLedgerMeta(testAddress, scope), @@ -581,7 +450,7 @@ describe('NetworkService', () => { getLedgerEntriesSpy.mockRejectedValue(new Error('RPC error')); await expect( - networkService.getSep41AssetsData([validSep41AssetId], scope), + networkService.getSep41AssetsData([USDC_SEP41], scope), ).rejects.toThrow(NetworkServiceException); }); }); @@ -1345,7 +1214,7 @@ describe('NetworkService', () => { networkService.simulateSep41TransferWithCache({ transaction: mockTx, scope, - assetId: validSep41AssetId, + assetId: USDC_SEP41, fromAccountId: generateStellarAddress(), toAccountId: generateStellarAddress(), }), @@ -1359,7 +1228,7 @@ describe('NetworkService', () => { networkService.simulateSep41TransferWithCache({ transaction: invalidStructureTx, scope, - assetId: validSep41AssetId, + assetId: USDC_SEP41, fromAccountId: generateStellarAddress(), toAccountId: generateStellarAddress(), }), @@ -1377,7 +1246,7 @@ describe('NetworkService', () => { const first = await networkService.simulateSep41TransferWithCache({ transaction: mockInvoke, scope, - assetId: validSep41AssetId, + assetId: USDC_SEP41, fromAccountId, toAccountId, }); @@ -1386,7 +1255,7 @@ describe('NetworkService', () => { const second = await networkService.simulateSep41TransferWithCache({ transaction: mockInvoke, scope, - assetId: validSep41AssetId, + assetId: USDC_SEP41, fromAccountId, toAccountId, }); @@ -1406,7 +1275,7 @@ describe('NetworkService', () => { const params = { transaction: mockInvoke, scope, - assetId: validSep41AssetId, + assetId: USDC_SEP41, fromAccountId, toAccountId, }; @@ -1477,7 +1346,7 @@ describe('NetworkService', () => { it('returns empty object when accounts is empty', async () => { const result = await networkService.getSep41AssetBalances({ accounts: [], - assetIds: [validSep41AssetId], + assetIds: [USDC_SEP41], scope: KnownCaip2ChainId.Mainnet, }); expect(result).toStrictEqual({}); @@ -1499,12 +1368,12 @@ describe('NetworkService', () => { const result = await networkService.getSep41AssetBalances({ accounts: [account], - assetIds: [validSep41AssetId, secondAssetId], + assetIds: [USDC_SEP41, secondAssetId], scope: KnownCaip2ChainId.Mainnet, }); expect(simResultSpy).toHaveBeenCalled(); - expect(result[account]?.[validSep41AssetId]?.toFixed()).toBe('100'); + expect(result[account]?.[USDC_SEP41]?.toFixed()).toBe('100'); expect(result[account]?.[secondAssetId]?.toFixed()).toBe('200'); simResultSpy.mockRestore(); }); @@ -1516,11 +1385,11 @@ describe('NetworkService', () => { const result = await networkService.getSep41AssetBalances({ accounts: [account], - assetIds: [validSep41AssetId, secondAssetId], + assetIds: [USDC_SEP41, secondAssetId], scope: KnownCaip2ChainId.Mainnet, }); - expect(result[account]?.[validSep41AssetId]?.toFixed()).toBe('1'); + expect(result[account]?.[USDC_SEP41]?.toFixed()).toBe('1'); expect(result[account]?.[secondAssetId]).toBeNull(); simResultSpy.mockRestore(); }); @@ -1533,7 +1402,7 @@ describe('NetworkService', () => { await expect( networkService.getSep41AssetBalances({ accounts: [account], - assetIds: [validSep41AssetId, secondAssetId], + assetIds: [USDC_SEP41, secondAssetId], scope: KnownCaip2ChainId.Mainnet, }), ).rejects.toThrow(NetworkServiceException); @@ -1549,7 +1418,7 @@ describe('NetworkService', () => { await expect( networkService.getSep41AssetBalances({ accounts: [account], - assetIds: [validSep41AssetId], + assetIds: [USDC_SEP41], scope: KnownCaip2ChainId.Mainnet, }), ).rejects.toThrow(NetworkServiceException); @@ -1583,7 +1452,7 @@ describe('NetworkService', () => { .mockResolvedValue([BigInt('42')]); const params = { accounts: [account], - assetIds: [validSep41AssetId], + assetIds: [USDC_SEP41], scope: KnownCaip2ChainId.Mainnet, }; @@ -1594,7 +1463,7 @@ describe('NetworkService', () => { await networkService.getSep41AssetBalancesWithCache(params); expect(first).toStrictEqual(second); - expect(first[account]?.[validSep41AssetId]?.toFixed()).toBe('42'); + expect(first[account]?.[USDC_SEP41]?.toFixed()).toBe('42'); expect(simResultSpy).toHaveBeenCalledTimes(1); simResultSpy.mockRestore(); }); diff --git a/packages/stellar-wallet-snap/src/services/network/NetworkService.ts b/packages/stellar-wallet-snap/src/services/network/NetworkService.ts index 041dd9df2..27bb96b3e 100644 --- a/packages/stellar-wallet-snap/src/services/network/NetworkService.ts +++ b/packages/stellar-wallet-snap/src/services/network/NetworkService.ts @@ -11,7 +11,6 @@ import { Horizon as StellarHorizon, NotFoundError, rpc, - Account, } from '@stellar/stellar-sdk'; import type { diff --git a/packages/stellar-wallet-snap/src/services/network/__mocks__/networkService.fixtures.ts b/packages/stellar-wallet-snap/src/services/network/__mocks__/networkService.fixtures.ts new file mode 100644 index 000000000..94f12f498 --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/network/__mocks__/networkService.fixtures.ts @@ -0,0 +1,174 @@ +import { + Account, + Contract, + Horizon as StellarHorizon, + Networks, + nativeToScVal, + rpc as StellarRpc, + TransactionBuilder as StellarTransactionBuilder, +} from '@stellar/stellar-sdk'; + +import { + buildMockClassicTransaction, + buildMockInvokeHostFunctionTransaction, +} from '../../transaction/__mocks__/transaction.fixtures'; +import { Transaction } from '../../transaction/Transaction'; +import { generateStellarAddress } from '../../wallet/__mocks__/wallet.fixtures'; + +export const getHorizonClientSpies = (): { + fetchBaseFeeSpy: jest.SpyInstance; + loadAccountSpy: jest.SpyInstance; +} => ({ + fetchBaseFeeSpy: jest.spyOn(StellarHorizon.Server.prototype, 'fetchBaseFee'), + loadAccountSpy: jest.spyOn(StellarHorizon.Server.prototype, 'loadAccount'), +}); + +export const getRpcServerSpies = (): { + pollTransactionSpy: jest.SpyInstance; + sendTransactionSpy: jest.SpyInstance; + getAccountSpy: jest.SpyInstance; + getAccountEntrySpy: jest.SpyInstance; + getLedgerEntriesSpy: jest.SpyInstance; + simulateTransactionSpy: jest.SpyInstance; +} => ({ + pollTransactionSpy: jest.spyOn( + StellarRpc.Server.prototype, + 'pollTransaction', + ), + sendTransactionSpy: jest.spyOn( + StellarRpc.Server.prototype, + 'sendTransaction', + ), + getAccountSpy: jest.spyOn(StellarRpc.Server.prototype, 'getAccount'), + getAccountEntrySpy: jest.spyOn( + StellarRpc.Server.prototype, + 'getAccountEntry', + ), + getLedgerEntriesSpy: jest.spyOn( + StellarRpc.Server.prototype, + 'getLedgerEntries', + ), + simulateTransactionSpy: jest.spyOn( + StellarRpc.Server.prototype, + 'simulateTransaction', + ), +}); + +export const createMockTransaction = (accountId?: string): Transaction => { + return buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + destination: accountId ?? generateStellarAddress(), + asset: 'native', + amount: '1', + }, + }, + ], + { + networkPassphrase: Networks.PUBLIC, + }, + ); +}; + +export const mockHorizonAccountTransactions = ( + call: jest.Mock, +): jest.SpyInstance => { + return jest + .spyOn(StellarHorizon.Server.prototype, 'transactions') + .mockReturnValue({ + forAccount: jest.fn().mockReturnValue({ + order: jest.fn().mockReturnValue({ + cursor: jest.fn().mockReturnValue({ + limit: jest.fn().mockReturnValue({ + includeFailed: jest.fn().mockReturnValue({ call }), + }), + }), + }), + }), + } as never); +}; + +export const createMockInvokeHostFunctionTransaction = ( + accountId?: string, +): Transaction => { + return buildMockInvokeHostFunctionTransaction('invokeHostFunction', [], { + contractId: 'CASUP2OPFVEHCWGP2XLBXOV7DQIQIT42AQISG4MXAZGNLVFFN63X7WRT', + source: { + accountId: accountId ?? generateStellarAddress(), + sequence: '1', + }, + }); +}; + +export const buildTransactionWithTwoInvokeHostFunctionOps = (): Transaction => { + const source = 'GB5QOHJZ6RACA26NFDIEHD7I7SLROLC5P4NATSG43OJV2C5WUR4VEUKG'; + const stellarAccount = new Account(source, '1'); + const contract = new Contract( + 'CASUP2OPFVEHCWGP2XLBXOV7DQIQIT42AQISG4MXAZGNLVFFN63X7WRT', + ); + const builder = new StellarTransactionBuilder(stellarAccount, { + fee: '200', + networkPassphrase: Networks.PUBLIC, + }); + builder.addOperation(contract.call('fnA', nativeToScVal(1))); + builder.addOperation(contract.call('fnB', nativeToScVal(2))); + return new Transaction(builder.setTimeout(60).build()); +}; + +type MockAccountEntry = { + numSubEntries: () => number; + seqNum: () => { toString: () => string }; + balance: () => { toString: () => string }; + ext: () => { + switch: () => number; + v1: () => { + ext: () => { + switch: () => number; + v2: () => { + numSponsoring: () => number; + numSponsored: () => number; + }; + }; + }; + }; +}; + +export const createMockAccountEntry = ({ + numSubEntries = 4, + sequenceNumber = '262764252333343491', + rawNativeBalance = '351010623', + numSponsoring = 1, + numSponsored = 0, + accountExtSwitch = 1, + v1ExtSwitch = 2, +}: { + numSubEntries?: number; + sequenceNumber?: string; + rawNativeBalance?: string; + numSponsoring?: number; + numSponsored?: number; + accountExtSwitch?: number; + v1ExtSwitch?: number; +} = {}): MockAccountEntry => ({ + numSubEntries: () => numSubEntries, + seqNum: () => ({ + toString: () => sequenceNumber, + }), + balance: () => ({ + toString: () => rawNativeBalance, + }), + ext: () => ({ + switch: () => accountExtSwitch, + v1: () => ({ + ext: () => ({ + switch: () => v1ExtSwitch, + v2: () => ({ + numSponsoring: () => numSponsoring, + numSponsored: () => numSponsored, + }), + }), + }), + }), +}); diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index 3995ac508..d5d308a32 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -14,6 +14,7 @@ import { DerivedAccountAddressMismatchException } from '../account/exceptions'; import { AssetMetadataService } from '../asset-metadata'; import { getMockSep41Assets, + USDC_CLASSIC, USDC_SEP41, USDT_SEP41, } from '../asset-metadata/__mocks__/assets.fixtures'; @@ -544,29 +545,7 @@ describe('OnChainAccountService', () => { }); describe('resolveOnChainAccountFromCore', () => { - const usdcIssuer = - 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; - const usdcId = `stellar:pubnet/asset:USDC-${usdcIssuer}` as const; - const sep41Id = - 'stellar:pubnet/sep41:CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75' as const; - - it('returns null when Core migration is off', async () => { - const { onChainAccountService, assetsService } = - mockOnChainAccountService(); - const getAccountAssetsByScopeSpy = jest.spyOn( - assetsService, - 'getAccountAssetsByScope', - ); - - expect( - await onChainAccountService.resolveOnChainAccountFromCore( - KnownCaip2ChainId.Mainnet, - globalThis.crypto.randomUUID(), - Keypair.random().publicKey(), - ), - ).toBeNull(); - expect(getAccountAssetsByScopeSpy).not.toHaveBeenCalled(); - }); + const usdcIssuer = USDC_CLASSIC.split('-').at(1) as string; it('returns null when Core returns no holdings', async () => { const { onChainAccountService, assetsService } = @@ -608,7 +587,7 @@ describe('OnChainAccountService', () => { metadata: { symbol: 'XLM', decimals: 7 }, }, { - id: usdcId, + id: USDC_CLASSIC, chainId: KnownCaip2ChainId.Mainnet, balance: { amount: '0.1630079', @@ -621,7 +600,7 @@ describe('OnChainAccountService', () => { metadata: { symbol: 'USDC', decimals: 7 }, }, { - id: sep41Id, + id: USDC_SEP41, chainId: KnownCaip2ChainId.Mainnet, balance: { amount: '2' }, metadata: { symbol: 'TA', decimals: 7 }, @@ -650,7 +629,7 @@ describe('OnChainAccountService', () => { expect(full?.balances).toStrictEqual( expect.arrayContaining([ expect.objectContaining({ - assetId: usdcId, + assetId: USDC_CLASSIC, balance: '1630079', symbol: 'USDC', address: usdcIssuer, @@ -659,7 +638,7 @@ describe('OnChainAccountService', () => { sponsored: false, }), expect.objectContaining({ - assetId: sep41Id, + assetId: USDC_SEP41, balance: '20000000', symbol: 'TA', decimals: 7, diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 75ef93a72..5525dcc1c 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -25,7 +25,10 @@ import type { CoreAsset } from '../assets/api'; import { AccountNotActivatedException } from '../network'; import type { AccountLedgerMeta, NetworkService } from '../network'; import type { ActivatedAccountPair } from '../sync/api'; -import { OnChainAccountBalanceNotAvailableException, OnChainAccountSep41BalanceNotFoundException } from './exceptions'; +import { + OnChainAccountBalanceNotAvailableException, + OnChainAccountSep41BalanceNotFoundException, +} from './exceptions'; import { OnChainAccount } from './OnChainAccount'; import type { OnChainAccountRepository } from './OnChainAccountRepository'; import { @@ -183,12 +186,13 @@ export class OnChainAccountService { scope, }); const sep41Balances = balancesByAccount[onChainAccount.accountId]; - // If it is testnet, we won't have any balances, so we return early. // If it is mainnet, we throw an error as it is unexpected. if (sep41Balances === undefined) { if (scope === KnownCaip2ChainId.Mainnet) { - throw new OnChainAccountSep41BalanceNotFoundException(onChainAccount.accountId); + throw new OnChainAccountSep41BalanceNotFoundException( + onChainAccount.accountId, + ); } return; } @@ -240,7 +244,7 @@ export class OnChainAccountService { } /** - * Loads the on-chain account for the given keyring account id from snap state. + * Loads the on-chain account for the given keyring account id from snap state or core. * * @param keyringAccountId - The keyring account id to load the on-chain account for. * @param accountAddress - Stellar G-address for the account header. @@ -273,12 +277,9 @@ export class OnChainAccountService { /** * Best-effort {@link OnChainAccount} from Core holdings for fast read paths. * - * Binds balances from AssetsController when the Stellar migration flag is on. - * Protocol fields (sequence, subentries, sponsorship, native stroops) come from - * {@link NetworkService.getAccountLedgerMeta} when `resolveAccountFromNetwork` is set; - * otherwise sequence is `0`, sponsorships are 0, and `subentryCount` is derived from Core - * native `minimumReserveBalance`. - * Not a substitute for live Horizon for send / fee / ChangeTrust. + * - When `resolveAccountFromNetwork` is set, sequence, subentries, sponsorship, and native stroops come from {@link NetworkService.getAccountLedgerMeta}. + * - Otherwise sequence is `0`, sponsorships are `0`, and `subentryCount` is derived from Core native `minimumReserveBalance`. + * - Not a substitute for live Horizon for send, fee, or ChangeTrust. * * @param scope - CAIP-2 network. * @param keyringAccountId - MetaMask keyring account id. @@ -297,10 +298,6 @@ export class OnChainAccountService { resolveAccountFromNetwork?: boolean; }, ): Promise { - if (!(await this.#assetsService.isMigrationEnabled())) { - return null; - } - const { resolveAccountFromNetwork = false } = options ?? {}; let ledger: AccountLedgerMeta | undefined; @@ -331,12 +328,12 @@ export class OnChainAccountService { }); try { - const serializable = await this.#toSerializableFromCoreAssets({ + const serializable = this.#toSerializableFromCoreAssets({ accountAddress, scope, assets, ledger, - }) + }); return OnChainAccount.fromSerializable(serializable); } catch (error: unknown) { await trackError( diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts index 26f1efa8d..7394eaea7 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts @@ -6,7 +6,6 @@ import { hexToBytes } from '@metamask/utils'; import { Keypair } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; -import type { KnownCaip19Sep41AssetId } from '../../api'; import { KnownCaip2ChainId } from '../../api'; import { NATIVE_ASSET_SYMBOL, STELLAR_DECIMAL_PLACES } from '../../constants'; import { bufferToUint8Array } from '../../utils/buffer'; @@ -144,8 +143,8 @@ describe('OnChainAccountSynchronizeService', () => { const seed = hexToBytes( '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', ); - const sep41Id = USDC_SEP41 as KnownCaip19Sep41AssetId; - const backupSep41Id = USDT_SEP41 as KnownCaip19Sep41AssetId; + const sep41Id = USDC_SEP41; + const backupSep41Id = USDT_SEP41; const mockSep41Assets = getMockSep41Assets(); const getNetworkServiceSpies = () => ({ diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/exceptions.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/exceptions.ts index 295985f7e..0f6222581 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/exceptions.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/exceptions.ts @@ -21,7 +21,6 @@ export class OnChainAccountMetadataNotAvailableException extends OnChainAccountE } } - /** * Thrown when mainnet returns no SEP-41 balance map for an account. */ From 14d3591867f48ab50eefeb0063317d9124710716 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:51:15 +0800 Subject: [PATCH 08/50] fix: rename --- .../services/on-chain-account/OnChainAccountService.test.ts | 2 +- .../src/services/on-chain-account/OnChainAccountService.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index d5d308a32..35b0d5a0a 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -49,7 +49,7 @@ describe('OnChainAccountService', () => { getAccountSpy: jest.spyOn(NetworkService.prototype, 'getAccount'), getAccountLedgerMetaSpy: jest.spyOn( NetworkService.prototype, - 'getAccountLedgerMeta', + 'getAccountLedgerMetadata', ), loadOnChainAccountSpy: jest.spyOn( NetworkService.prototype, diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 5525dcc1c..5bc4c451d 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -277,7 +277,7 @@ export class OnChainAccountService { /** * Best-effort {@link OnChainAccount} from Core holdings for fast read paths. * - * - When `resolveAccountFromNetwork` is set, sequence, subentries, sponsorship, and native stroops come from {@link NetworkService.getAccountLedgerMeta}. + * - When `resolveAccountFromNetwork` is set, sequence, subentries, sponsorship, and native stroops come from {@link NetworkService.getAccountLedgerMetadata}. * - Otherwise sequence is `0`, sponsorships are `0`, and `subentryCount` is derived from Core native `minimumReserveBalance`. * - Not a substitute for live Horizon for send, fee, or ChangeTrust. * @@ -445,7 +445,7 @@ export class OnChainAccountService { scope: KnownCaip2ChainId, ): Promise { try { - return await this.#networkService.getAccountLedgerMeta( + return await this.#networkService.getAccountLedgerMetadata( accountAddress, scope, ); From aee1799304e7c5fba19d4d3f5af1288d80211050 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:25:49 +0800 Subject: [PATCH 09/50] fix: setSep41BalanceForAccount --- packages/stellar-wallet-snap/package.json | 2 +- .../OnChainAccountService.test.ts | 49 +++++++++---------- .../on-chain-account/OnChainAccountService.ts | 20 ++++---- yarn.lock | 2 +- 4 files changed, 35 insertions(+), 38 deletions(-) diff --git a/packages/stellar-wallet-snap/package.json b/packages/stellar-wallet-snap/package.json index d47457a1b..fe38b0f34 100644 --- a/packages/stellar-wallet-snap/package.json +++ b/packages/stellar-wallet-snap/package.json @@ -48,7 +48,7 @@ "test:watch": "jest --watch" }, "devDependencies": { - "@metamask/assets-controller": "^13.0.0", + "@metamask/assets-controller": "^15.0.0", "@metamask/auto-changelog": "^6.1.1", "@metamask/key-tree": "^10.1.1", "@metamask/keyring-api": "^24.1.0", diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index 35b0d5a0a..2b15eb72b 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -29,10 +29,7 @@ import { horizonSource, mockOnChainAccountService, } from './__mocks__/onChainAccount.fixtures'; -import { - OnChainAccountBalanceNotAvailableException, - OnChainAccountSep41BalanceNotFoundException, -} from './exceptions'; +import { OnChainAccountSep41BalanceNotFoundException } from './exceptions'; import { OnChainAccount } from './OnChainAccount'; import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; import { OnChainAccountSynchronizeService } from './OnChainAccountSynchronizeService'; @@ -348,7 +345,7 @@ describe('OnChainAccountService', () => { getSep41AssetBalancesSpy.mockRestore(); }); - it('throws when a requested SEP-41 balance cell was not read', async () => { + it('skips an unread SEP-41 cell and binds the rest', async () => { const signer = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); const loadedAcc = createMockAccountWithBalances( signer.publicKey(), @@ -385,20 +382,19 @@ describe('OnChainAccountService', () => { }); const { onChainAccountService } = mockOnChainAccountService(); - await expect( - onChainAccountService.resolveOnChainAccount( - signer.publicKey(), - KnownCaip2ChainId.Mainnet, - { resolveWithFullBalance: true }, - ), - ).rejects.toThrow( - new OnChainAccountBalanceNotAvailableException(usdc.assetId), + const result = await onChainAccountService.resolveOnChainAccount( + signer.publicKey(), + KnownCaip2ChainId.Mainnet, + { resolveWithFullBalance: true }, ); + + expect(result.getRawAsset(USDC_SEP41)).toBeUndefined(); + expect(result.getRawAsset(USDT_SEP41)?.balance.toFixed()).toBe('1'); fetchSep41Spy.mockRestore(); getSep41AssetBalancesSpy.mockRestore(); }); - it('throws when a requested SEP-41 asset id is missing from the balance map', async () => { + it('skips a SEP-41 asset id missing from the balance map and binds the rest', async () => { const signer = Keypair.fromRawEd25519Seed(bufferToUint8Array(seed)); const loadedAcc = createMockAccountWithBalances( signer.publicKey(), @@ -434,15 +430,14 @@ describe('OnChainAccountService', () => { }); const { onChainAccountService } = mockOnChainAccountService(); - await expect( - onChainAccountService.resolveOnChainAccount( - signer.publicKey(), - KnownCaip2ChainId.Mainnet, - { resolveWithFullBalance: true }, - ), - ).rejects.toThrow( - new OnChainAccountBalanceNotAvailableException(usdt.assetId), + const result = await onChainAccountService.resolveOnChainAccount( + signer.publicKey(), + KnownCaip2ChainId.Mainnet, + { resolveWithFullBalance: true }, ); + + expect(result.getRawAsset(USDC_SEP41)?.balance.toFixed()).toBe('1'); + expect(result.getRawAsset(USDT_SEP41)).toBeUndefined(); fetchSep41Spy.mockRestore(); getSep41AssetBalancesSpy.mockRestore(); }); @@ -485,8 +480,9 @@ describe('OnChainAccountService', () => { it('returns null when no snapshot exists for the keyring id and scope', async () => { const keyringAccountId = globalThis.crypto.randomUUID(); const accountAddress = Keypair.random().publicKey(); - const { onChainAccountService, onChainAccountRepository } = + const { onChainAccountService, onChainAccountRepository, assetsService } = mockOnChainAccountService(); + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(false); const findByAccountIdSpy = jest.spyOn( onChainAccountRepository, 'findByKeyringAccountId', @@ -520,8 +516,9 @@ describe('OnChainAccountService', () => { KnownCaip2ChainId.Mainnet, ) as OnChainAccountSerializableFull; - const { onChainAccountService, onChainAccountRepository } = + const { onChainAccountService, onChainAccountRepository, assetsService } = mockOnChainAccountService(); + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(false); const findByAccountIdSpy = jest.spyOn( onChainAccountRepository, 'findByKeyringAccountId', @@ -747,7 +744,9 @@ describe('OnChainAccountService', () => { ) as OnChainAccountSerializableFull, ), })); - const { onChainAccountService } = mockOnChainAccountService(); + const { onChainAccountService, assetsService } = + mockOnChainAccountService(); + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(false); const synchronizeSpy = jest.spyOn( OnChainAccountSynchronizeService.prototype, 'synchronize', diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 5bc4c451d..55af14077 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -25,10 +25,7 @@ import type { CoreAsset } from '../assets/api'; import { AccountNotActivatedException } from '../network'; import type { AccountLedgerMeta, NetworkService } from '../network'; import type { ActivatedAccountPair } from '../sync/api'; -import { - OnChainAccountBalanceNotAvailableException, - OnChainAccountSep41BalanceNotFoundException, -} from './exceptions'; +import { OnChainAccountSep41BalanceNotFoundException } from './exceptions'; import { OnChainAccount } from './OnChainAccount'; import type { OnChainAccountRepository } from './OnChainAccountRepository'; import { @@ -118,12 +115,11 @@ export class OnChainAccountService { * @param scope - CAIP-2 network to load the account from (Horizon `loadAccount`). * @param options - Optional extra loads. * @param options.resolveWithFullBalance - When true, also read SEP-41 balances for the - * persisted catalog and bind them onto the Horizon account. + * persisted catalog and bind them onto the Horizon account. Unread cells (`null` or missing) + * are skipped. A zero balance is bound. * @returns Loaded {@link OnChainAccount} for simulation, fees, and sequence. * @throws {AccountNotActivatedException} When the account is not funded (from {@link NetworkService.loadOnChainAccount}). * @throws {DerivedAccountAddressMismatchException} When loaded id does not match `accountAddress`. - * @throws {OnChainAccountBalanceNotAvailableException} When `resolveWithFullBalance` is set and a - * SEP-41 cell comes back unread (`null` or missing). A zero balance is bound. * @throws {OnChainAccountSep41BalanceNotFoundException} When `resolveWithFullBalance` is set and * mainnet returns no SEP-41 map for the account. An empty catalog, or testnet, leaves SEP-41 * unbound and does not throw. @@ -150,10 +146,10 @@ export class OnChainAccountService { /** * Reads SEP-41 balances for the persisted catalog and binds them onto `onChainAccount`. + * Unread cells (`null` or missing) are skipped. * * @param onChainAccount - Horizon account to attach SEP-41 entries to. * @param scope - CAIP-2 network. - * @throws {OnChainAccountBalanceNotAvailableException} When a SEP-41 cell is `null` or missing. * @throws {OnChainAccountSep41BalanceNotFoundException} When mainnet returns no map for the account. */ async #bindSep41Balances( @@ -209,13 +205,13 @@ export class OnChainAccountService { /** * Binds one fetched SEP-41 balance, including zero, onto an account. + * Returns without binding when the cell was not read. * * @param params - Account, one SEP-41 id, its balance, and its metadata. * @param params.onChainAccount - Horizon account that receives the entry. * @param params.assetId - SEP-41 asset id to bind. * @param params.balance - Balance in smallest units. `null` or `undefined` means the cell was not read. * @param params.assetMetadata - Metadata for `assetId`, including symbol and decimals. - * @throws {OnChainAccountBalanceNotAvailableException} When the cell is `null` or missing. */ #setSep41BalanceForAccount({ onChainAccount, @@ -228,9 +224,11 @@ export class OnChainAccountService { balance: BigNumber | null | undefined; assetMetadata: StellarAssetMetadata; }): void { - // Unread cell, not a zero balance. Omitting it would look like the token was removed. + // A cell can fail without failing the batch, for example reading a + // trustline asset through its SEP-41 contract when the account is not + // authorized to hold it. Skip that asset and keep the rest. if (balance === null || balance === undefined) { - throw new OnChainAccountBalanceNotAvailableException(assetId); + return; } const { decimals } = assetMetadata.units[0]; diff --git a/yarn.lock b/yarn.lock index 1c1e3fab0..f7b061362 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3813,7 +3813,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/stellar-wallet-snap@workspace:packages/stellar-wallet-snap" dependencies: - "@metamask/assets-controller": "npm:^13.0.0" + "@metamask/assets-controller": "npm:^15.0.0" "@metamask/auto-changelog": "npm:^6.1.1" "@metamask/key-tree": "npm:^10.1.1" "@metamask/keyring-api": "npm:^24.1.0" From e92e08270aab6154780f81c613335980da88d758 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:48:44 +0800 Subject: [PATCH 10/50] fix: lint and test --- eslint-suppressions.json | 2 +- .../on-chain-account/OnChainAccountSynchronizeService.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 7019c2fe4..7489a7e1b 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1427,4 +1427,4 @@ "count": 2 } } -} \ No newline at end of file +} diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts index 7394eaea7..876667fce 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts @@ -175,8 +175,10 @@ describe('OnChainAccountSynchronizeService', () => { }); const setupSynchronizeService = () => { - const { onChainAccountService, onChainAccountRepository } = + const { onChainAccountService, onChainAccountRepository, assetsService } = mockOnChainAccountService(); + // These cases cover the snap-state sync path, which is skipped when Core migration is on. + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(false); return { onChainAccountService, onChainAccountRepository, From 119fefe0694e23e0b1123b70ac3d9faee4ebc763 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:47:03 +0800 Subject: [PATCH 11/50] chore: update service --- .../src/handlers/keyring/keyring.test.ts | 73 +++++++++++++++++++ .../src/handlers/keyring/keyring.ts | 16 ++++ .../AssetMetadataService.test.ts | 30 ++++++++ .../asset-metadata/AssetMetadataService.ts | 23 +++++- .../OnChainAccountService.test.ts | 52 ++++++++++++- .../on-chain-account/OnChainAccountService.ts | 33 ++++++++- 6 files changed, 221 insertions(+), 6 deletions(-) diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 64a020b81..3438adb6d 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -4,6 +4,7 @@ import { asStrictKeyringAccount } from '@metamask/snap-networks-utils'; import { InvalidParamsError } from '@metamask/snaps-sdk'; import type { JsonRpcRequest } from '@metamask/snaps-sdk'; import { create } from '@metamask/superstruct'; +import { BigNumber } from 'bignumber.js'; import { KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; @@ -15,6 +16,10 @@ import { generateStellarKeyringAccount, } from '../../services/account/__mocks__/account.fixtures'; import { AccountNotFoundException } from '../../services/account/exceptions'; +import { + USDC_CLASSIC, + USDC_SEP41, +} from '../../services/asset-metadata/__mocks__/assets.fixtures'; import { AccountNotActivatedException } from '../../services/network/exceptions'; import { OnChainAccountService } from '../../services/on-chain-account'; import { @@ -705,6 +710,74 @@ describe('KeyringHandler', () => { expect(result[slipId]?.amount).toBe('1'); }); + it('binds a requested SEP-41 asset missing from the watched account', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address); + jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockResolvedValue(onChainAccount); + const fetchAndBindSpy = jest + .spyOn(OnChainAccountService.prototype, 'fetchAndBindSep41Balances') + .mockImplementation(async (account, _scope, assetIds) => { + for (const assetId of assetIds) { + account.setAsset(assetId, { + balance: new BigNumber(10_000_000), + symbol: 'USDC', + decimals: 7, + }); + } + return account; + }); + + const result = await keyringHandler.getAccountBalances(mockAccountId, [ + USDC_SEP41, + ]); + + expect(fetchAndBindSpy).toHaveBeenCalledWith( + onChainAccount, + KnownCaip2ChainId.Mainnet, + [USDC_SEP41], + ); + expect(result).toStrictEqual({ + [USDC_SEP41]: { + unit: 'USDC', + amount: '1', + }, + }); + }); + + it('does not fetch a SEP-41 balance already stored on the account', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address); + onChainAccount.setAsset(USDC_SEP41, { + balance: new BigNumber(10_000_000), + symbol: 'USDC', + decimals: 7, + }); + jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockResolvedValue(onChainAccount); + const fetchAndBindSpy = jest.spyOn( + OnChainAccountService.prototype, + 'fetchAndBindSep41Balances', + ); + + const result = await keyringHandler.getAccountBalances(mockAccountId, [ + USDC_SEP41, + USDC_CLASSIC, + ]); + + expect(fetchAndBindSpy).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + [USDC_SEP41]: { + unit: 'USDC', + amount: '1', + }, + }); + }); + it('propagates errors when balance resolution fails for another reason', async () => { const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); const { resolveAccountSpy } = getAccountServiceSpies(); diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 2ddbf826a..6d42545bb 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -35,6 +35,7 @@ import type { import type { KnownCaip19AssetIdOrSlip44Id, + KnownCaip19Sep41AssetId, KnownCaip2ChainId, } from '../../api'; import { StellarSecretKeyStruct } from '../../api'; @@ -60,6 +61,7 @@ import { Duration, getSlip44AssetId, isClassicAssetId, + isSep41Id, isSlip44Id, rethrowIfInstanceElseThrow, validateRequest, @@ -340,6 +342,20 @@ export class KeyringHandler implements KeyringSnapRpc { return assetBalances; } + // `#resolveAccountByAccountId` returns watched assets balance from Tokens API. + // But, the watched assets from Tokens API can omit SEP-41 ids the client asked for. + const missingSep41AssetIds = knownAssets.filter( + (assetId): assetId is KnownCaip19Sep41AssetId => + isSep41Id(assetId) && onChainAccount.getRawAsset(assetId) === undefined, + ); + if (missingSep41AssetIds.length > 0) { + await this.#onChainAccountService.fetchAndBindSep41Balances( + onChainAccount, + scope, + missingSep41AssetIds, + ); + } + for (const assetId of knownAssets) { const asset = onChainAccount.getAsset(assetId); // Skip when the asset is not visible (tombstone, zero SEP-41, or missing entry). 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 07d8c2cb0..5317db7ad 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 @@ -5,6 +5,7 @@ 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'; @@ -180,6 +181,35 @@ describe('AssetMetadataService', () => { 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.fetchSep41AssetsMetadata([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); 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 2bfa3ad93..43e47a5cd 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -92,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 Core 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 fetchSep41AssetsMetadata( + assetIds: KnownCaip19Sep41AssetId[], + ): Promise { + return this.#fetchAndPersistAssetsByAssetIds(assetIds); + } + /** * Returns all persisted SEP-41 assets for the given chain ID. * @@ -348,7 +363,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(', ')}`, ); } @@ -379,7 +396,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/on-chain-account/OnChainAccountService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index a75a5ecdb..9f94fe1ca 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -4,7 +4,7 @@ import { BigNumber } from 'bignumber.js'; import { KnownCaip2ChainId } from '../../api'; import { MAX_INT64 } from '../../constants'; -import { getSlip44AssetId, isSep41Id } from '../../utils'; +import { getSlip44AssetId } from '../../utils'; import { bufferToUint8Array } from '../../utils/buffer'; import { generateMockStellarKeyringAccounts, @@ -378,6 +378,56 @@ describe('OnChainAccountService', () => { }); }); + describe('fetchAndBindSep41Balances', () => { + it('binds balances resolved by fetchSep41AssetsMetadata', async () => { + const accountAddress = generateStellarAddress(); + const scope = KnownCaip2ChainId.Mainnet; + const loadedAcc = createMockAccountWithBalances( + accountAddress, + '1', + DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + ); + const onChainAccount = new OnChainAccount( + loadedAcc, + scope, + horizonSource(loadedAcc, scope), + ); + const { getSep41AssetBalancesSpy } = getNetworkServiceSpies(); + const { onChainAccountService } = mockOnChainAccountService(); + const usdcSep41 = getMockSep41Assets().find( + (asset) => asset.assetId === USDC_SEP41, + ); + if (usdcSep41 === undefined) { + throw new Error('expected USDC SEP-41 metadata'); + } + const fetchMetadataSpy = jest + .spyOn(AssetMetadataService.prototype, 'fetchSep41AssetsMetadata') + .mockResolvedValue([usdcSep41]); + getSep41AssetBalancesSpy.mockResolvedValue({ + [accountAddress]: { + [USDC_SEP41]: new BigNumber(10_000_000), + }, + }); + + const result = await onChainAccountService.fetchAndBindSep41Balances( + onChainAccount, + scope, + [USDC_SEP41], + ); + + expect(fetchMetadataSpy).toHaveBeenCalledWith([USDC_SEP41]); + expect(getSep41AssetBalancesSpy).toHaveBeenCalledWith({ + accounts: [accountAddress], + assetIds: [USDC_SEP41], + scope, + }); + expect(result.getRawAsset(USDC_SEP41)?.balance.toFixed()).toBe( + '10000000', + ); + expect(result.getAsset(USDC_SEP41)?.symbol).toBe('USDC'); + }); + }); + describe('resolveOnChainAccountByKeyringAccountId', () => { it('returns null when no snapshot exists for the keyring id and scope', async () => { const keyringAccountId = globalThis.crypto.randomUUID(); diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 038f5491a..69e2d8761 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -145,19 +145,46 @@ export class OnChainAccountService { } /** - * Reads SEP-41 balances for the persisted catalog and binds them onto `onChainAccount`. - * Empty balances record (`null` or missing) are skipped. + * Fetches SEP-41 balances for ids and binds + * them onto `onChainAccount`. + * + * @param onChainAccount - Horizon account to attach the SEP-41 entries to. + * @param scope - CAIP-2 network. + * @param assetIds - SEP-41 asset ids. + * @returns The same account, with any resolved balances bound. + * @throws {OnChainAccountSep41BalanceNotFoundException} When mainnet returns no map for the account. + */ + async fetchAndBindSep41Balances( + onChainAccount: OnChainAccount, + scope: KnownCaip2ChainId, + assetIds: KnownCaip19Sep41AssetId[], + ): Promise { + await this.#bindSep41Balances(onChainAccount, scope, assetIds); + return onChainAccount; + } + + /** + * Reads SEP-41 balances and binds them onto `onChainAccount`. + * Empty balance cells (`null` or missing) are skipped. + * + * When `assetIds` is omitted, balances are read for the persisted SEP-41 catalog. + * When `assetIds` is set, only those ids are resolved, and their metadata is persisted. * * @param onChainAccount - Horizon account to attach SEP-41 entries to. * @param scope - CAIP-2 network. + * @param assetIds - SEP-41 asset ids to fetch. Omit to use the persisted catalog. * @throws {OnChainAccountSep41BalanceNotFoundException} When mainnet returns no map for the account. */ async #bindSep41Balances( onChainAccount: OnChainAccount, scope: KnownCaip2ChainId, + assetIds?: KnownCaip19Sep41AssetId[], ): Promise { const sep41Assets = - await this.#assetMetadataService.fetchSep41AssetsOrSyncOnce(scope); + assetIds === undefined + ? await this.#assetMetadataService.fetchSep41AssetsOrSyncOnce(scope) + : await this.#assetMetadataService.fetchSep41AssetsMetadata(assetIds); + const sep41AssetIds: KnownCaip19Sep41AssetId[] = []; const assetMetadataByAssetId: Record< KnownCaip19Sep41AssetId, From 0a960c24b814fda5248c380b4ba4d2457f35fc70 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:10:25 +0800 Subject: [PATCH 12/50] Delete utils.ts --- .../src/services/assets/utils.ts | 28 ------------------- 1 file changed, 28 deletions(-) delete mode 100644 packages/stellar-wallet-snap/src/services/assets/utils.ts diff --git a/packages/stellar-wallet-snap/src/services/assets/utils.ts b/packages/stellar-wallet-snap/src/services/assets/utils.ts deleted file mode 100644 index 5f8c9d459..000000000 --- a/packages/stellar-wallet-snap/src/services/assets/utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - SNAPS_ASSETS_MIGRATION_FLAG_KEYS, - SnapsAssetsMigrationStage, - parseSnapsAssetsMigrationStage, -} from '@metamask/assets-controller'; -import type { RemoteFeatureFlagsProvider } from '@metamask/snap-networks-utils'; - -/** - * Returns whether Stellar should read fungible holdings / catalog hits from - * AssetsController instead of snap state. - * - * Any stage other than {@link SnapsAssetsMigrationStage.Off} enables Core reads. - * - * @param remoteFeatureFlagsProvider - Remote feature-flag messenger wrapper. - * @returns Whether the Stellar assets migration flag is active. - */ -export async function isAssetsMigrationEnabled( - remoteFeatureFlagsProvider: RemoteFeatureFlagsProvider, -): Promise { - return true; - const flagValue = await remoteFeatureFlagsProvider.getFeatureFlag( - SNAPS_ASSETS_MIGRATION_FLAG_KEYS.stellar, - ); - - return ( - parseSnapsAssetsMigrationStage(flagValue) !== SnapsAssetsMigrationStage.Off - ); -} From 1e54092a187abaae71f50dfa16da6679a7f2fec8 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:11:32 +0800 Subject: [PATCH 13/50] chore: update --- .../src/services/assets/api.test.ts | 69 +++++++++++++++++++ .../src/services/assets/api.ts | 45 +++++++++++- .../on-chain-account/OnChainAccount.test.ts | 44 ++++++++++++ .../on-chain-account/OnChainAccount.ts | 8 +-- .../OnChainAccountSynchronizeService.test.ts | 1 + .../on-chain-account/keyringBalance.test.ts | 4 ++ .../on-chain-account/keyringBalance.ts | 2 + 7 files changed, 166 insertions(+), 7 deletions(-) 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 81529e292..c5e874ec4 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,75 @@ describe('parseCoreStellarAsset', () => { }); }); + it('forces sponsored true when sponsor is a non-empty string', () => { + const sponsor = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + expect( + parseCoreStellarAsset({ + ...classicAsset, + balance: { + ...classicAsset.balance, + metadata: { + limit: MAX_INT64, + authorized: true, + sponsor, + sponsored: false, + }, + }, + }), + ).toMatchObject({ + balance: { + metadata: { sponsor, sponsored: true }, + }, + }); + }); + + it('clears a leftover sponsor when the trustline limit is zero', () => { + const sponsor = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + for (const limit of ['0', '0.0', '0.0000000']) { + expect( + parseCoreStellarAsset({ + ...classicAsset, + balance: { + ...classicAsset.balance, + metadata: { + limit, + authorized: true, + sponsor, + sponsored: true, + }, + }, + }), + ).toMatchObject({ + balance: { + metadata: { sponsor: null, sponsored: false }, + }, + }); + } + }); + + it('forces sponsored false when sponsor is empty, missing, or null', () => { + for (const sponsor of ['', undefined, null] as const) { + expect( + parseCoreStellarAsset({ + ...classicAsset, + balance: { + ...classicAsset.balance, + metadata: { + limit: MAX_INT64, + authorized: true, + ...(sponsor === undefined ? {} : { sponsor }), + sponsored: true, + }, + }, + }), + ).toMatchObject({ + balance: { + metadata: { sponsored: false }, + }, + }); + } + }); + it('returns null when the payload is not Stellar-shaped', () => { expect(parseCoreStellarAsset({ id: 'not-an-asset' })).toBeNull(); }); diff --git a/packages/stellar-wallet-snap/src/services/assets/api.ts b/packages/stellar-wallet-snap/src/services/assets/api.ts index 12f2ff3c5..cbe17ecf4 100644 --- a/packages/stellar-wallet-snap/src/services/assets/api.ts +++ b/packages/stellar-wallet-snap/src/services/assets/api.ts @@ -2,6 +2,7 @@ import { boolean, create, defaulted, + nullable, number, optional, string, @@ -9,6 +10,7 @@ import { union, } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; +import { BigNumber } from 'bignumber.js'; import { KnownCaip19AssetIdOrSlip44IdStruct, @@ -61,8 +63,10 @@ export const CoreNativeBalanceStruct = type({ export const CoreClassicBalanceStruct = type({ amount: ValidStellarAmountStruct, metadata: type({ - limit: ValidStellarInt64Struct, + limit: string(), authorized: defaulted(boolean(), true), + // Keyring may omit sponsor or send null / "" when not sponsored. + sponsor: optional(nullable(string())), sponsored: defaulted(boolean(), false), }), }); @@ -129,6 +133,36 @@ const StellarCoreAssetIdStruct = type({ id: KnownCaip19AssetIdOrSlip44IdStruct, }); +/** + * A removed trustline is stored with a zero display limit (`"0"`, `"0.0"`, `"0.0000000"`). + * + * @param limit - Classic trustline limit from Core balance metadata. + * @returns Whether the limit is numeric zero. + */ +function isZeroTrustlineLimit(limit: string): boolean { + const amount = new BigNumber(limit); + return amount.isFinite() && amount.isZero(); +} + +/** + * Whether classic trustline reserve is sponsored. + * A non-empty sponsor on a removed trustline (limit 0) is stale and does not count. + * + * @param sponsor - Trustline sponsor account address, if any. + * @param limit - Trustline limit. + * @returns `true` when `sponsor` is a non-empty string and `limit` is not zero. + */ +function isSponsored( + sponsor: string | null | undefined, + limit: string, +): boolean { + return ( + typeof sponsor === 'string' && + sponsor.length > 0 && + !isZeroTrustlineLimit(limit) + ); +} + /** * Validates a Stellar `Asset` into {@link CoreAsset}. * @@ -146,12 +180,21 @@ export function parseCoreStellarAsset(value: unknown): CoreAsset | null { if (!asset.id.startsWith(`${asset.chainId}/`)) { throw new Error('Asset ID does not match chain ID'); } + if (isCoreClassicAsset(asset)) { + const { sponsor, limit } = asset.balance.metadata; + if (isZeroTrustlineLimit(limit)) { + // A removed trustline must not keep the previous sponsor account id. + asset.balance.metadata.sponsor = null; + } + asset.balance.metadata.sponsored = isSponsored(sponsor, limit); + } return asset; } catch (error) { throw new InvalidCoreAssetException( `${value.id} is not a valid Stellar asset`, { cause: error, + data: value, }, ); } diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts index 4b8f852ed..14cd55738 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts @@ -506,6 +506,50 @@ describe('OnChainAccount', () => { ); expect(expectDefined(restored.getAsset(usdcId)).sponsor).toBe(sponsorId); }); + + it('drops sponsor when a removed trustline is stored as a tombstone', () => { + const { wallet } = createTestWallet(); + const sponsorId = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + const onChainAccount = OnChainAccount.fromHorizon( + createMockAccountWithBalances(wallet.address, '1', { + nativeBalance: 10, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + balance: 5, + sponsor: sponsorId, + }, + ], + }) as unknown as Horizon.AccountResponse, + KnownCaip2ChainId.Mainnet, + ); + const usdcId = toCaip19ClassicAssetId( + KnownCaip2ChainId.Mainnet, + 'USDC', + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + ); + const liveEntry = expectDefined(onChainAccount.getAsset(usdcId)); + + onChainAccount.setTombstoneClassicAsset(usdcId, liveEntry); + + const tombstone = expectDefined(onChainAccount.getRawAsset(usdcId)); + expect(tombstone.limit?.isZero()).toBe(true); + expect(tombstone.sponsored).toBe(false); + expect(tombstone.sponsor).toBeUndefined(); + + const serialized = onChainAccount.toSerializableFull().balances.find( + (row) => row.assetId === usdcId, + ); + expect(serialized).toMatchObject({ + limit: '0', + sponsored: false, + }); + expect(serialized).not.toHaveProperty('sponsor'); + }); }); describe('getRaw', () => { diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts index f2df89e01..95bd76709 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.ts @@ -243,12 +243,8 @@ export class OnChainAccount { limit: new BigNumber(0), address: assetEntry.address, authorized: assetEntry.authorized ?? true, - ...(assetEntry.sponsored === undefined - ? {} - : { sponsored: assetEntry.sponsored }), - ...(assetEntry.sponsor === undefined || assetEntry.sponsor.length === 0 - ? {} - : { sponsor: assetEntry.sponsor }), + sponsored: false, + sponsor: undefined, decimals: assetEntry.decimals, }); } diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts index 876667fce..1ff42ab2f 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts @@ -63,6 +63,7 @@ const classicBalanceEntry = ( limit, authorized: true, sponsor: '', + sponsored: false, }, }); diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.test.ts index 6e2de96fb..9d3084c50 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.test.ts @@ -38,6 +38,7 @@ describe('toClassicBalanceEntry', () => { limit: new BigNumber(100_000_000), authorized: false, sponsor: 'GSPONSOR', + sponsored: true, }), ).toStrictEqual({ unit: 'USDC', @@ -46,6 +47,7 @@ describe('toClassicBalanceEntry', () => { limit: '10', authorized: false, sponsor: 'GSPONSOR', + sponsored: true, }, }); }); @@ -64,6 +66,7 @@ describe('toClassicBalanceEntry', () => { limit: '0', authorized: true, sponsor: '', + sponsored: false, }, }); }); @@ -85,6 +88,7 @@ describe('toClassicBalanceEntry', () => { limit: '5', authorized: true, sponsor: '', + sponsored: false, }, }); }); diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.ts index 5bfcdb77a..401d88dc0 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.ts @@ -44,6 +44,7 @@ export const ClassicBalanceEntryStruct = assign( limit: string(), authorized: defaulted(boolean(), true), sponsor: defaulted(string(), ''), + sponsored: defaulted(boolean(), false), }), ), }), @@ -110,6 +111,7 @@ export function toClassicBalanceEntry( limit: toDisplayBalance(asset.limit ?? new BigNumber(0), asset.decimals), authorized: asset.authorized, sponsor: asset.sponsor, + sponsored: asset.sponsored, }, }); } From 5a40f1ee836843b2ef384df1ed067a80ddb7c529 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:54:33 +0800 Subject: [PATCH 14/50] chore: use stroop limit --- .../clientRequest/onAmountInput.test.ts | 4 + .../handlers/clientRequest/onAmountInput.ts | 3 + .../asset-metadata/AssetMetadataService.ts | 2 + .../src/services/assets/api.test.ts | 49 +++- .../src/services/assets/api.ts | 24 +- .../on-chain-account/OnChainAccount.test.ts | 6 +- .../OnChainAccountSynchronizeService.test.ts | 8 +- .../on-chain-account/keyringBalance.test.ts | 4 +- .../on-chain-account/keyringBalance.ts | 2 +- .../transaction/TransactionSimulator.test.ts | 223 ++++++++++++++++++ .../transaction/simulation/simulators.ts | 34 ++- 11 files changed, 340 insertions(+), 19 deletions(-) diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts index ec6ab158f..270f7db0b 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts @@ -30,6 +30,7 @@ import { InsufficientBalanceException, InsufficientBalanceToCoverFeeException, TransactionValidationException, + TrustlineExceedLimitException, XdrParseException, } from '../../services/transaction/exceptions'; import { WalletService } from '../../services/wallet'; @@ -193,6 +194,7 @@ describe('OnAmountInputHandler', () => { assetId, amount: new BigNumber('10000000'), destination: onChainAccount.accountId, + skipExceptions: [TrustlineExceedLimitException], useCache: true, }); }); @@ -217,6 +219,7 @@ describe('OnAmountInputHandler', () => { expect.objectContaining({ scope: KnownCaip2ChainId.Testnet, assetId: testnetAssetId, + skipExceptions: [TrustlineExceedLimitException], }), ); }); @@ -232,6 +235,7 @@ describe('OnAmountInputHandler', () => { assetId, amount: new BigNumber('10000000'), destination: onChainAccount.accountId, + skipExceptions: [TrustlineExceedLimitException], useCache: true, }); }); diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts index 5891734b8..42bbdf6e3 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.ts @@ -7,6 +7,7 @@ import type { TransactionService } from '../../services/transaction'; import { InsufficientBalanceException, InsufficientBalanceToCoverFeeException, + TrustlineExceedLimitException, } from '../../services/transaction/exceptions'; import { hasDecimals, toSmallestUnit } from '../../utils'; import type { @@ -96,6 +97,8 @@ export class OnAmountInputHandler extends BaseClientRequestHandler< // Always use self account as destination to bypass the error from the destination validation. // We validate the destination in the send transaction handler. destination: onChainAccount.accountId, + // Skip the trustline exceed limit exception because we are validating the transaction sending to self. + skipExceptions: [TrustlineExceedLimitException], // Use cached network reads so repeated amount checks stay fast. useCache: true, }); 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 bd910c229..a2726d348 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -270,6 +270,7 @@ export class AssetMetadataService { const hits: StellarAssetMetadata[] = []; const missing: KnownCaip19AssetId[] = []; + this.#logger.debug('Getting asset metadata from Core', { assetIds }); if (assetIds.length > 0) { // The order doesn't matter here, as we are not using the order of the assets. await Promise.all( @@ -291,6 +292,7 @@ export class AssetMetadataService { }), ); } + this.#logger.debug('Asset metadata from Core', { hits, missing }); return { hits, missing }; } 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 c5e874ec4..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( @@ -199,7 +246,7 @@ describe('parseCoreStellarAsset', () => { }), ).toMatchObject({ balance: { - metadata: { sponsor: null, sponsored: false }, + metadata: { limit, sponsor: null, sponsored: false }, }, }); } diff --git a/packages/stellar-wallet-snap/src/services/assets/api.ts b/packages/stellar-wallet-snap/src/services/assets/api.ts index cbe17ecf4..10f04cc25 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'; /** @@ -144,6 +146,24 @@ 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. + * @param decimals - Asset decimals used to scale a display amount. + * @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. @@ -181,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; diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts index 14cd55738..30ae51cc5 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccount.test.ts @@ -541,9 +541,9 @@ describe('OnChainAccount', () => { expect(tombstone.sponsored).toBe(false); expect(tombstone.sponsor).toBeUndefined(); - const serialized = onChainAccount.toSerializableFull().balances.find( - (row) => row.assetId === usdcId, - ); + const serialized = onChainAccount + .toSerializableFull() + .balances.find((row) => row.assetId === usdcId); expect(serialized).toMatchObject({ limit: '0', sponsored: false, diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts index 1ff42ab2f..ee242b8c1 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts @@ -7,7 +7,11 @@ import { Keypair } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; import { KnownCaip2ChainId } from '../../api'; -import { NATIVE_ASSET_SYMBOL, STELLAR_DECIMAL_PLACES } from '../../constants'; +import { + MAX_INT64, + NATIVE_ASSET_SYMBOL, + STELLAR_DECIMAL_PLACES, +} from '../../constants'; import { bufferToUint8Array } from '../../utils/buffer'; import * as errorsUtils from '../../utils/errors'; import { logger } from '../../utils/logger'; @@ -55,7 +59,7 @@ const NATIVE_BALANCE_ENTRY = { const classicBalanceEntry = ( unit: string, amount: string, - limit = '922337203685.4775807', + limit = MAX_INT64, ) => ({ unit, amount, diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.test.ts index 9d3084c50..2674ab2dc 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.test.ts @@ -44,7 +44,7 @@ describe('toClassicBalanceEntry', () => { unit: 'USDC', amount: '1.25', metadata: { - limit: '10', + limit: '100000000', authorized: false, sponsor: 'GSPONSOR', sponsored: true, @@ -85,7 +85,7 @@ describe('toClassicBalanceEntry', () => { unit: 'TOKEN', amount: '1', metadata: { - limit: '5', + limit: '5000', authorized: true, sponsor: '', sponsored: false, diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.ts index 401d88dc0..39c3f3928 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.ts @@ -108,7 +108,7 @@ export function toClassicBalanceEntry( unit: asset.symbol, amount: toDisplayBalance(asset.balance, asset.decimals), metadata: { - limit: toDisplayBalance(asset.limit ?? new BigNumber(0), asset.decimals), + limit: (asset.limit ?? new BigNumber(0)).toFixed(0), authorized: asset.authorized, sponsor: asset.sponsor, sponsored: asset.sponsored, diff --git a/packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts index 9e2832d63..c5978e5a3 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/TransactionSimulator.test.ts @@ -884,6 +884,176 @@ describe('TransactionSimulator', () => { ).toThrow(TrustlineExceedLimitException); }); + it('skips the destination trustline limit when TrustlineExceedLimitException is in skipExceptions', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: USDC_ISSUER, + balance: 100, + }, + ], + }); + + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: destinationAddress, + asset: MOCK_USDC_ASSET, + amount: '50', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect( + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [ + onChainFromMockBalances(destinationAddress, '1', { + nativeBalance: 50, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: USDC_ISSUER, + balance: 99, + limit: '100', + }, + ], + }), + ], + skipExceptions: [TrustlineExceedLimitException], + }), + ).toHaveLength(2); + }); + + it('throws for a self-payment when the current balance is already above the trustline limit', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: USDC_ISSUER, + balance: 150, + limit: '100', + }, + ], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: wallet.address, + asset: MOCK_USDC_ASSET, + amount: '1', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + expectedOPTypes: [SupportedOperations.Payment], + }), + ).toThrow(TrustlineExceedLimitException); + }); + + it('skips that self-payment limit check when TrustlineExceedLimitException is in skipExceptions', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: USDC_ISSUER, + balance: 150, + limit: '100', + }, + ], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: wallet.address, + asset: MOCK_USDC_ASSET, + amount: '1', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect( + simulator.simulate(tx, onChainAccount, { + expectedOPTypes: [SupportedOperations.Payment], + skipExceptions: [TrustlineExceedLimitException], + }), + ).toHaveLength(2); + }); + + it('still throws when the destination trustline is missing and the limit check is skipped', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: USDC_ISSUER, + balance: 100, + }, + ], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'payment', + params: { + source: wallet.address, + destination: destinationAddress, + asset: MOCK_USDC_ASSET, + amount: '1', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + preloadedAccounts: [ + onChainFromMockBalances(destinationAddress, '1', { + nativeBalance: 50, + subentryCount: 0, + assets: [], + }), + ], + skipExceptions: [TrustlineExceedLimitException], + }), + ).toThrow(TrustlineNotFoundException); + }); + it('succeeds for credit asset self-payment when naive balance+amount would exceed trustline limit', () => { const wallet = getTestWallet(); const onChainAccount = onChainFromMockBalances(wallet.address, '1', { @@ -1127,6 +1297,59 @@ describe('TransactionSimulator', () => { ).toHaveLength(2); }); + it('skips a path-payment trustline limit when TrustlineExceedLimitException is in skipExceptions', () => { + const wallet = getTestWallet(); + const onChainAccount = onChainFromMockBalances(wallet.address, '1', { + nativeBalance: 500, + subentryCount: 0, + assets: [], + }); + const destination = onChainFromMockBalances(destinationAddress, '1', { + nativeBalance: 50, + subentryCount: 1, + assets: [ + { + assetType: 'credit_alphanum4', + assetCode: 'USDC', + assetIssuer: USDC_ISSUER, + balance: 99, + limit: '100', + }, + ], + }); + const tx = buildMockClassicTransaction( + [ + { + type: 'pathPaymentStrictSend', + params: { + source: wallet.address, + sendAsset: 'native', + sendAmount: '10', + destination: destinationAddress, + destAsset: MOCK_USDC_ASSET, + destMin: '50', + }, + }, + ], + mainnetSimulatorTxOptions(wallet.address, '1'), + ); + + expect(() => + simulator.simulate(tx, onChainAccount, { + expectedOPTypes: [SupportedOperations.PathPayment], + preloadedAccounts: [destination], + }), + ).toThrow(TrustlineExceedLimitException); + + expect( + simulator.simulate(tx, onChainAccount, { + expectedOPTypes: [SupportedOperations.PathPayment], + preloadedAccounts: [destination], + skipExceptions: [TrustlineExceedLimitException], + }), + ).toHaveLength(2); + }); + it('allows changeTrust before a strict send path payment in the same transaction', () => { const wallet = getTestWallet(); const onChainAccount = onChainFromMockBalances(wallet.address, '1', { diff --git a/packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts b/packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts index ad187ca77..c4ee93b20 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/simulation/simulators.ts @@ -9,6 +9,7 @@ import type { } from '../../../api'; import { BASE_RESERVE_STROOPS, MAX_INT64 } from '../../../constants'; import { + AnyErrorConstructor, getSlip44AssetId, isSlip44Id, stellarAssetToCaip19, @@ -132,8 +133,16 @@ function validateCredit(params: { assetId: ClassicAssetId; amount: BigNumber; balanceAfterDebit?: BigNumber; + skipExceptions?: readonly AnyErrorConstructor[]; }): void { - const { account, accountId, assetId, amount, balanceAfterDebit } = params; + const { + account, + accountId, + assetId, + amount, + balanceAfterDebit, + skipExceptions, + } = params; if (isSlip44Id(assetId)) { const nativeBalance = balanceAfterDebit ?? account.nativeRawBalance; @@ -159,6 +168,14 @@ function validateCredit(params: { const trustlineBalance = balanceAfterDebit ?? line.balance; const newBalance = trustlineBalance.plus(amount); if (newBalance.isGreaterThan(line.limit)) { + if ( + shouldSkipValidationException( + skipExceptions, + TrustlineExceedLimitException, + ) + ) { + return; + } throw new TrustlineExceedLimitException(assetId); } } @@ -218,7 +235,7 @@ function applyCredit(params: { export class PaymentOPSimulator implements OperationSimulator { validate(ctx: ValidateContext, op: Operation.Payment): void { const payment = op; - const { opIndex } = ctx; + const { opIndex, skipExceptions } = ctx; const { assetId, payAmt, source, dest, sourceId, destId } = this.#getContextData(ctx, op); @@ -231,7 +248,7 @@ export class PaymentOPSimulator implements OperationSimulator { // SEP-29 memo_required applies to inbound payments from other accounts; skip for self-payments. if ( sourceId !== destId && - !shouldSkipValidationException(ctx.skipExceptions, RequiresMemoException) + !shouldSkipValidationException(skipExceptions, RequiresMemoException) ) { assertMemoWhenDestinationRequires( ctx.transaction, @@ -266,6 +283,7 @@ export class PaymentOPSimulator implements OperationSimulator { assetId, amount: payAmt, balanceAfterDebit, + skipExceptions, }); } @@ -311,6 +329,7 @@ export class PaymentOPSimulator implements OperationSimulator { export class PathPaymentOPSimulator implements OperationSimulator { validate(ctx: ValidateContext, op: PathPaymentOP): void { + const { skipExceptions, transaction } = ctx; const { source, sourceId, sendAssetId, sendAmount } = this.#sourceData( ctx, op, @@ -323,13 +342,9 @@ export class PathPaymentOPSimulator implements OperationSimulator { // SEP-29 memo_required applies to inbound payments from other accounts; skip for self-payments. if ( sourceId !== destId && - !shouldSkipValidationException(ctx.skipExceptions, RequiresMemoException) + !shouldSkipValidationException(skipExceptions, RequiresMemoException) ) { - assertMemoWhenDestinationRequires( - ctx.transaction, - destId, - dest.requiresMemo, - ); + assertMemoWhenDestinationRequires(transaction, destId, dest.requiresMemo); } validateDebit({ @@ -358,6 +373,7 @@ export class PathPaymentOPSimulator implements OperationSimulator { assetId: destAssetId, amount: destAmount, balanceAfterDebit, + skipExceptions, }); } From b7a16c93c5adfa19d78e85c41cf25e496d165964 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:41:24 +0800 Subject: [PATCH 15/50] fix: test --- .../asset-metadata/AssetMetadataService.test.ts | 17 ++++++++++------- .../asset-metadata/AssetMetadataService.ts | 5 ++++- 2 files changed, 14 insertions(+), 8 deletions(-) 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 de5a46c2d..bd09c6023 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 @@ -430,13 +430,16 @@ describe('AssetMetadataService', () => { expect(mockGetAssetsByChainId).toHaveBeenCalledWith( KnownCaip2ChainId.Mainnet, ); - expect(saveMany).toHaveBeenCalledWith([ - expect.objectContaining({ - assetId: sep41AssetId, - name: 'Token A', - symbol: 'TA', - }), - ]); + expect(saveMany).toHaveBeenCalledWith( + [ + expect.objectContaining({ + assetId: sep41AssetId, + name: 'Token A', + symbol: 'TA', + }), + ], + true, + ); }); it('maps Core catalog metadata into StellarAssetMetadata without reading snap state', async () => { 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 a2726d348..3a9995a38 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -101,7 +101,7 @@ export class AssetMetadataService { * @param assetIds - SEP-41 asset ids to resolve. * @returns Metadata for the ids that could be resolved. Missing ids are omitted. */ - async fetchSep41AssetsMetadata( + async fetchSep41AssetsByAssetIds( assetIds: KnownCaip19Sep41AssetId[], ): Promise { return this.#fetchAndPersistAssetsByAssetIds(assetIds); @@ -196,6 +196,9 @@ export class AssetMetadataService { // persisted: Core 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); From 89752450dd98e5fdfc5682e771b21664511c880d Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:37:58 +0800 Subject: [PATCH 16/50] fix: test --- .../services/on-chain-account/OnChainAccountService.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index 9f94fe1ca..93b5aa4c3 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -379,7 +379,7 @@ describe('OnChainAccountService', () => { }); describe('fetchAndBindSep41Balances', () => { - it('binds balances resolved by fetchSep41AssetsMetadata', async () => { + it('binds balances resolved by fetchSep41AssetsByAssetIds', async () => { const accountAddress = generateStellarAddress(); const scope = KnownCaip2ChainId.Mainnet; const loadedAcc = createMockAccountWithBalances( @@ -401,7 +401,7 @@ describe('OnChainAccountService', () => { throw new Error('expected USDC SEP-41 metadata'); } const fetchMetadataSpy = jest - .spyOn(AssetMetadataService.prototype, 'fetchSep41AssetsMetadata') + .spyOn(AssetMetadataService.prototype, 'fetchSep41AssetsByAssetIds') .mockResolvedValue([usdcSep41]); getSep41AssetBalancesSpy.mockResolvedValue({ [accountAddress]: { From 099f36f9b242284d08b57c834d7bad032c50f14e Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:38:34 +0800 Subject: [PATCH 17/50] Update AssetMetadataService.test.ts --- .../src/services/asset-metadata/AssetMetadataService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 bd09c6023..7fb1124ae 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 @@ -199,7 +199,7 @@ describe('AssetMetadataService', () => { const { service, saveMany } = createService({}); mockGetAssetsByAssetIds.mockResolvedValueOnce([tokenRow]); - const result = await service.fetchSep41AssetsMetadata([USDC_SEP41]); + const result = await service.fetchSep41AssetsByAssetIds([USDC_SEP41]); expect(result).toStrictEqual([ expect.objectContaining({ From 9912431fe201e1b274cac49d00eaada3dd0ea6a1 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:51:39 +0800 Subject: [PATCH 18/50] chore: add testing script --- .../scripts/export-stellar-keys-from-srp.mjs | 116 + .../scripts/horizon-event-fixture.mjs | 1913 +++++++++++++++++ .../src/handlers/keyring/keyring.ts | 2 + 3 files changed, 2031 insertions(+) create mode 100644 packages/stellar-wallet-snap/scripts/export-stellar-keys-from-srp.mjs create mode 100644 packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs diff --git a/packages/stellar-wallet-snap/scripts/export-stellar-keys-from-srp.mjs b/packages/stellar-wallet-snap/scripts/export-stellar-keys-from-srp.mjs new file mode 100644 index 000000000..5c1e88695 --- /dev/null +++ b/packages/stellar-wallet-snap/scripts/export-stellar-keys-from-srp.mjs @@ -0,0 +1,116 @@ +/** + * Derive Stellar secret seeds (S…) and public keys (G…) from an SRP the same + * way the Stellar snap does: SLIP-10 ed25519 at m/44'/148'/index'. + * + * STELLAR_SRP="word1 … word12/24" node scripts/export-stellar-keys-from-srp.mjs + * STELLAR_SRP="…" node scripts/export-stellar-keys-from-srp.mjs --indices 0,1 + * + * Optional: + * STELLAR_SRP BIP-39 mnemonic (preferred over typing it as an argv) + * STELLAR_ACCOUNT_INDICES comma-separated, default 0,1 + * + * Prints STELLAR_SECRET_1 / STELLAR_SECRET_2 lines you can paste into .env + * for horizon-event-fixture.mjs. Do not commit those values. + */ + +import { SLIP10Node } from '@metamask/key-tree'; +import { Keypair } from '@stellar/stellar-sdk'; +import { stdin as input, stdout as output } from 'node:process'; +import { createInterface } from 'node:readline/promises'; + +const DEFAULT_INDICES = [0, 1]; + +function parseIndices() { + const flagIndex = process.argv.indexOf('--indices'); + const fromFlag = flagIndex >= 0 ? process.argv[flagIndex + 1] : undefined; + const raw = fromFlag ?? process.env.STELLAR_ACCOUNT_INDICES ?? ''; + if (!raw.trim()) { + return DEFAULT_INDICES; + } + return raw.split(',').map((part) => { + const index = Number(part.trim()); + if (!Number.isInteger(index) || index < 0) { + throw new Error(`Invalid account index: ${part}`); + } + return index; + }); +} + +async function readMnemonic() { + const fromEnv = process.env.STELLAR_SRP?.trim(); + if (fromEnv) { + return fromEnv.replace(/\s+/gu, ' '); + } + + const rl = createInterface({ input, output }); + const typed = await rl.question( + 'Paste SRP (spaces between words; input is not saved): ', + ); + rl.close(); + const mnemonic = typed.trim().replace(/\s+/gu, ' '); + if (!mnemonic) { + throw new Error('Missing SRP. Set STELLAR_SRP or paste it when prompted.'); + } + return mnemonic; +} + +async function deriveAtIndex(mnemonic, index) { + const node = await SLIP10Node.fromDerivationPath({ + curve: 'ed25519', + derivationPath: [ + `bip39:${mnemonic}`, + `slip10:44'`, + `slip10:148'`, + `slip10:${index}'`, + ], + }); + if (!node.privateKey) { + throw new Error(`No private key at m/44'/148'/${index}'`); + } + const hex = node.privateKey.replace(/^0x/iu, ''); + const seed = Uint8Array.from(Buffer.from(hex, 'hex')); + return Keypair.fromRawEd25519Seed(seed); +} + +async function main() { + const indices = parseIndices(); + const mnemonic = await readMnemonic(); + const wordCount = mnemonic.split(' ').length; + if ( + wordCount !== 12 && + wordCount !== 15 && + wordCount !== 18 && + wordCount !== 21 && + wordCount !== 24 + ) { + throw new Error( + `Unexpected SRP length (${wordCount} words). Expected 12–24.`, + ); + } + + console.log( + `Deriving snap path m/44'/148'/' (ed25519) for indices: ${indices.join(', ')}`, + ); + console.log(''); + + const envLines = []; + for (const [ordinal, index] of indices.entries()) { + const keypair = await deriveAtIndex(mnemonic, index); + const slot = ordinal + 1; + console.log(`Account ${slot} index ${index} m/44'/148'/${index}'`); + console.log(` public ${keypair.publicKey()}`); + console.log(` secret ${keypair.secret()}`); + console.log(''); + envLines.push(`STELLAR_SECRET_${slot}=${keypair.secret()}`); + } + + console.log('Paste into packages/stellar-wallet-snap/.env:'); + for (const line of envLines) { + console.log(line); + } +} + +main().catch((error) => { + console.error(error.message ?? error); + process.exitCode = 1; +}); diff --git a/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs b/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs new file mode 100644 index 000000000..722362726 --- /dev/null +++ b/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs @@ -0,0 +1,1913 @@ +/** + * Mainnet Horizon event fixture: sign + broadcast from two secret keys, then roll back. + * + * Secrets (never commit these). Either pair works: + * STELLAR_SECRET_1 / STELLAR_SECRET_2 + * STELLAR_PK_1 / STELLAR_PK_2 + * + * Optional: + * STELLAR_SECRET_SPONSORED / STELLAR_PK_SPONSORED + * required by `account-merge` (that account merges into account 1) + * optional for `sponsored-account`; random when omitted + * HORIZON_URL default https://horizon.stellar.org + * XLM_AMOUNT default 0.0001 (native payment / path / claimable) + * TRUSTLINE_AMOUNT default same as XLM_AMOUNT + * TRUSTLINE_CODE default AQUA + * TRUSTLINE_ISSUER default GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA + * AQUA_LIMIT default 1000 (non-zero limit update in trustline-metadata) + * BASE_FEE_STROOPS default 100 + * DRY_RUN=1 build + sign, print XDR, do not submit + * YES=1 skip the interactive mainnet confirm + * + * Usage (from packages/stellar-wallet-snap): + * node --env-file=.env scripts/horizon-event-fixture.mjs status + * node --env-file=.env scripts/horizon-event-fixture.mjs list + * node --env-file=.env scripts/horizon-event-fixture.mjs setup balance-move + * node --env-file=.env scripts/horizon-event-fixture.mjs teardown balance-move + * node --env-file=.env scripts/horizon-event-fixture.mjs setup fee-bump + * + * Cases that need an id or an ephemeral key (claimable, offers, sponsored + * accounts) save it in scripts/.horizon-fixture-state.json. That file is + * gitignored. Run teardown to roll the case back. + */ + +import { + Asset, + Claimant, + getLiquidityPoolId, + Horizon, + Keypair, + LiquidityPoolAsset, + LiquidityPoolFeeV18, + Networks, + Operation, + TransactionBuilder, +} from '@stellar/stellar-sdk'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { stdin as input, stdout as output } from 'node:process'; +import { createInterface } from 'node:readline/promises'; + +const HORIZON_URL = process.env.HORIZON_URL ?? 'https://horizon.stellar.org'; +const NETWORK_PASSPHRASE = Networks.PUBLIC; +const EXPLORER_TX = 'https://stellar.expert/explorer/public/tx'; +const XLM_AMOUNT = process.env.XLM_AMOUNT ?? '0.0001'; +const TRUSTLINE_AMOUNT = process.env.TRUSTLINE_AMOUNT ?? XLM_AMOUNT; +const TRUSTLINE_ASSET = new Asset( + process.env.TRUSTLINE_CODE ?? 'AQUA', + process.env.TRUSTLINE_ISSUER ?? + 'GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA', +); +const BASE_FEE = Number(process.env.BASE_FEE_STROOPS ?? '100'); +const DRY_RUN = process.env.DRY_RUN === '1'; +const SKIP_CONFIRM = process.env.YES === '1'; + +const AQUA = TRUSTLINE_ASSET; +const EURC = new Asset( + 'EURC', + 'GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2', +); +const PYUSD = new Asset( + 'PYUSD', + 'GDQE7IXJ4HUHV6RQHIUPRJSEZE4DRS5WY577O2FY6YQ5LVWZ7JZTU2V5', +); +const AQUA_LIMIT = process.env.AQUA_LIMIT ?? '1000'; + +const server = new Horizon.Server(HORIZON_URL); + +/** + * `balance-move` / `balance-move-trustline` credit/debit existing accounts. + * `trustline-metadata` covers add / limit-update / sponsored-create. + * `fee-bump` wraps an account2 inner changeTrust; account1 pays the fee. + * `authorized` is issuer-only (`setTrustLineFlags`); skip for EURC/AQUA/PYUSD. + * Clawback of a holder's balance is intentionally not a case. + * `claimable-clawback` only runs when account1 is the issuer of TRUSTLINE_ASSET. + */ + +/** + * @typedef {object} AccountBinding + * @property {import('@stellar/stellar-sdk').Keypair} keypair + * @property {import('@stellar/stellar-sdk').Horizon.AccountResponse} account + */ + +/** + * @typedef {object} Accounts + * @property {AccountBinding} account1 + * @property {AccountBinding} account2 + * @property {AccountBinding} [sponsoredAccount] + * Set when `STELLAR_SECRET_SPONSORED` or `STELLAR_PK_SPONSORED` is present. + * `account` is null when that key is not on chain yet. + */ + +/** + * @typedef {object} PlannedTx + * @property {string} label + * @property {import('@stellar/stellar-sdk').Keypair} signer + * @property {import('@stellar/stellar-sdk').xdr.Operation[]} operations + * @property {import('@stellar/stellar-sdk').Keypair[]} [signers] + * @property {import('@stellar/stellar-sdk').Keypair} [feeSource] + * When set, wrap the inner tx (source = `signer`) in a fee-bump paid by this key. + */ + +/** + * @typedef {object} SubmitResult + * @property {string} hash + * @property {unknown} [raw] + */ + +/** + * @typedef {object} CaseHelpers + * @property {(plan: PlannedTx) => Promise} submit + */ + +/** + * @typedef {object} Case + * @property {string} description + * @property {(accounts: Accounts, helpers: CaseHelpers) => Promise} setup + * @property {(accounts: Accounts, helpers: CaseHelpers) => Promise} teardown + */ + +function assetLabel(asset) { + return asset.isNative() ? 'XLM' : `${asset.getCode()}-${asset.getIssuer()}`; +} + +function paymentOp(destination, asset, amount) { + return Operation.payment({ + destination, + asset, + amount, + }); +} + +function pathSendOp(destination, asset, amount) { + return Operation.pathPaymentStrictSend({ + sendAsset: asset, + sendAmount: amount, + destination, + destAsset: asset, + destMin: amount, + path: [], + }); +} + +function pathReceiveOp(destination, asset, amount) { + return Operation.pathPaymentStrictReceive({ + sendAsset: asset, + sendMax: amount, + destination, + destAsset: asset, + destAmount: amount, + path: [], + }); +} + +function nativePayment(destination, amount = XLM_AMOUNT) { + return paymentOp(destination, Asset.native(), amount); +} + +function heldTrustline(account) { + const row = account.balances.find( + (entry) => + (entry.asset_type === 'credit_alphanum4' || + entry.asset_type === 'credit_alphanum12') && + Number(entry.balance) > 0, + ); + if (!row) { + throw new Error('account1 has no trustline asset with a balance'); + } + const amount = + Number(row.balance) < Number(TRUSTLINE_AMOUNT) + ? row.balance + : TRUSTLINE_AMOUNT; + return { + asset: new Asset(row.asset_code, row.asset_issuer), + amount, + }; +} + +function findTrustline(account, asset) { + if (asset.isNative()) { + const row = account.balances.find((entry) => entry.asset_type === 'native'); + return { exists: true, balance: row?.balance ?? '0' }; + } + const row = account.balances.find( + (entry) => + entry.asset_code === asset.getCode() && + entry.asset_issuer === asset.getIssuer(), + ); + return row + ? { + exists: true, + balance: row.balance, + limit: row.limit, + sponsor: row.sponsor, + } + : { exists: false, balance: '0' }; +} + +function nonZeroLimitAtLeastBalance(line, requested) { + const requestedN = Number(requested); + const balanceN = line.exists ? Number(line.balance) : 0; + let limit = Number.isFinite(requestedN) && requestedN > 0 ? requestedN : 1000; + if (limit < balanceN) { + limit = balanceN; + } + if (line.exists && Number(line.limit) === limit) { + limit += 1; + } + return String(limit); +} + +function isIssuer(binding, asset) { + return !asset.isNative() && binding.keypair.publicKey() === asset.getIssuer(); +} + +async function ensureTrustlineIfNeeded(binding, asset, submit, label) { + if (asset.isNative() || isIssuer(binding, asset)) { + return; + } + const { exists } = findTrustline(binding.account, asset); + if (exists) { + return; + } + await submit({ + label: `${label}: changeTrust ${assetLabel(asset)}`, + signer: binding.keypair, + operations: [Operation.changeTrust({ asset })], + }); + binding.account = await server.loadAccount(binding.keypair.publicKey()); +} + +function assertSpendable(binding, asset, amount) { + if (isIssuer(binding, asset)) { + return; + } + const needed = Number(amount) * 4; + const { exists, balance } = findTrustline(binding.account, asset); + if (!exists) { + throw new Error( + `account1 has no trustline for ${assetLabel(asset)}; enable it first or run setup so it is added`, + ); + } + if (Number(balance) < needed) { + throw new Error( + `account1 ${assetLabel(asset)} balance ${balance} < ${needed} needed (4Ɨ ${amount})`, + ); + } +} + +async function runBalanceMoveSetup({ + account1, + account2, + submit, + asset, + amount, +}) { + const dest2 = account2.keypair.publicKey(); + const label = assetLabel(asset); + + await ensureTrustlineIfNeeded(account2, asset, submit, 'account2'); + await ensureTrustlineIfNeeded(account1, asset, submit, 'account1'); + account1.account = await server.loadAccount(account1.keypair.publicKey()); + assertSpendable(account1, asset, amount); + + const tx1 = await submit({ + label: `account1: payment, pathSend, pathReceive, createClaimable (${label})`, + signer: account1.keypair, + operations: [ + paymentOp(dest2, asset, amount), + pathSendOp(dest2, asset, amount), + pathReceiveOp(dest2, asset, amount), + Operation.createClaimableBalance({ + asset, + amount, + claimants: [new Claimant(dest2, Claimant.predicateUnconditional())], + }), + ], + }); + + if (DRY_RUN) { + console.log(' DRY_RUN: skip claimClaimableBalance'); + return; + } + + const claimableBalanceId = await claimableBalanceIdFromTx(tx1.hash); + console.log(` claimable ${claimableBalanceId}`); + + await submit({ + label: `account2: claimClaimableBalance (${label})`, + signer: account2.keypair, + operations: [ + Operation.claimClaimableBalance({ + balanceId: claimableBalanceId, + }), + ], + }); +} + +async function runBalanceMoveTeardown({ + account1, + account2, + submit, + asset, + amount, +}) { + const label = assetLabel(asset); + const dest1 = account1.keypair.publicKey(); + + if (asset.isNative()) { + const returned = String(Number(amount) * 4); + await submit({ + label: `account2: return ${returned} ${label} (4Ɨ ${amount})`, + signer: account2.keypair, + operations: [paymentOp(dest1, asset, returned)], + }); + return; + } + + account2.account = await server.loadAccount(account2.keypair.publicKey()); + const line = findTrustline(account2.account, asset); + if (!line.exists) { + console.log(` account2 has no ${label} trustline; nothing to remove`); + return; + } + + const operations = []; + if (Number(line.balance) > 0) { + operations.push(paymentOp(dest1, asset, line.balance)); + } + operations.push(Operation.changeTrust({ asset, limit: '0' })); + + await submit({ + label: + Number(line.balance) > 0 + ? `account2: send remaining ${line.balance} ${label} to account1, then remove trustline` + : `account2: remove ${label} trustline (balance already 0)`, + signer: account2.keypair, + operations, + }); +} + +async function claimableBalanceIdFromTx(hash) { + const page = await server.effects().forTransaction(hash).limit(50).call(); + const created = page.records.find( + (row) => row.type === 'claimable_balance_created', + ); + if (!created?.balance_id) { + throw new Error( + `No claimable_balance_created effect on ${hash}. Set DRY_RUN=0 and submit setup tx1 first.`, + ); + } + return created.balance_id; +} + +const STATE_PATH = new URL('./.horizon-fixture-state.json', import.meta.url); + +function readStateFile() { + try { + return JSON.parse(readFileSync(STATE_PATH, 'utf8')); + } catch (error) { + if (error.code === 'ENOENT') { + return {}; + } + throw error; + } +} + +function writeCaseState(caseName, value) { + const state = readStateFile(); + if (value === undefined) { + delete state[caseName]; + } else { + state[caseName] = value; + } + writeFileSync(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`); +} + +function remember(caseName, value) { + if (DRY_RUN) { + console.log(` DRY_RUN: not saving state for ${caseName}`); + return; + } + writeCaseState(caseName, value); +} + +function recall(caseName) { + const value = readStateFile()[caseName]; + if (!value) { + throw new Error( + `No saved state for ${caseName}. Run setup before teardown.`, + ); + } + return value; +} + +function forget(caseName) { + if (!DRY_RUN) { + writeCaseState(caseName, undefined); + } +} + +function requireAccount(binding, label) { + if (!binding.account) { + throw new Error(`${label} ${binding.keypair.publicKey()} does not exist`); + } +} + +/** + * @param {AccountBinding | undefined} binding + * @returns {asserts binding is AccountBinding} + */ +function requireSponsoredAccount(binding) { + if (!binding) { + throw new Error( + 'Missing STELLAR_SECRET_SPONSORED (or STELLAR_PK_SPONSORED)', + ); + } +} + +function assertBalanceAtLeast(binding, asset, amount) { + if (isIssuer(binding, asset)) { + return; + } + const { exists, balance } = findTrustline(binding.account, asset); + if (!exists) { + throw new Error(`${assetLabel(asset)} trustline is missing`); + } + if (Number(balance) < Number(amount)) { + throw new Error( + `${assetLabel(asset)} balance ${balance} < ${amount} required`, + ); + } +} + +function effectMatchesAsset(entry, asset) { + if (asset.isNative()) { + return entry.asset_type === 'native'; + } + return ( + entry.asset_code === asset.getCode() && + entry.asset_issuer === asset.getIssuer() + ); +} + +async function creditedAmount(hash, publicKey, asset) { + const page = await server.effects().forTransaction(hash).limit(100).call(); + const row = page.records.find( + (entry) => + entry.type === 'account_credited' && + entry.account === publicKey && + effectMatchesAsset(entry, asset), + ); + if (!row?.amount) { + throw new Error( + `No credit of ${assetLabel(asset)} for ${publicKey} on ${hash}`, + ); + } + return row.amount; +} + +async function listOfferIds(publicKey) { + const page = await server.offers().forAccount(publicKey).limit(200).call(); + return page.records.map((row) => row.id); +} + +/** + * Offer id created by `hash` that the same transaction did not remove. + * + * @param {string} hash - Submitted transaction hash. + * @returns {Promise} + */ +async function restingOfferIdFromTx(hash) { + const page = await server.effects().forTransaction(hash).limit(50).call(); + const removed = new Set( + page.records + .filter((row) => row.type === 'offer_removed') + .map((row) => String(row.offer_id)), + ); + const created = page.records.find( + (row) => row.type === 'offer_created' && !removed.has(String(row.offer_id)), + ); + return created ? String(created.offer_id) : null; +} + +/** + * @param {string} offerId + * @returns {Promise} + */ +async function loadOfferIfExists(offerId) { + try { + return await server.offers().offer(offerId).call(); + } catch (error) { + if (isNotFound(error)) { + return null; + } + throw error; + } +} + +/** + * @param {{ asset_type: string, asset_code?: string, asset_issuer?: string }} asset + * @returns {import('@stellar/stellar-sdk').Asset} + */ +function horizonAsset(asset) { + if (asset.asset_type === 'native') { + return Asset.native(); + } + return new Asset(asset.asset_code, asset.asset_issuer); +} + +function feeXlm(opCount = 1) { + return (BASE_FEE * opCount) / 10_000_000; +} + +function trimDecimal(value) { + return Number(value).toFixed(7).replace(/0+$/u, '').replace(/\.$/u, ''); +} + +function amountToStroops(amount) { + const [whole, frac = ''] = String(amount).split('.'); + if (!/^\d+$/u.test(whole) || !/^\d*$/u.test(frac) || frac.length > 7) { + throw new Error(`Invalid amount ${amount}`); + } + return BigInt(whole) * 10_000_000n + BigInt(frac.padEnd(7, '0')); +} + +function stroopsToAmount(stroops) { + const negative = stroops < 0n; + const value = negative ? -stroops : stroops; + const whole = value / 10_000_000n; + const frac = (value % 10_000_000n) + .toString() + .padStart(7, '0') + .replace(/0+$/u, ''); + const text = frac.length > 0 ? `${whole}.${frac}` : `${whole}`; + return negative ? `-${text}` : text; +} + +function constantProductPool() { + const native = Asset.native(); + const [assetA, assetB] = + Asset.compare(native, TRUSTLINE_ASSET) < 0 + ? [native, TRUSTLINE_ASSET] + : [TRUSTLINE_ASSET, native]; + const fee = LiquidityPoolFeeV18; + return { + assetA, + assetB, + poolAsset: new LiquidityPoolAsset(assetA, assetB, fee), + poolId: Buffer.from( + getLiquidityPoolId('constant_product', { assetA, assetB, fee }), + ).toString('hex'), + }; +} + +function poolShare(account, poolId) { + const row = account.balances.find( + (entry) => + entry.asset_type === 'liquidity_pool_shares' && + entry.liquidity_pool_id === poolId, + ); + return row + ? { exists: true, balance: row.balance } + : { exists: false, balance: '0' }; +} + +function pathSwapOp(destination, sendAsset, sendAmount, destAsset) { + return Operation.pathPaymentStrictSend({ + sendAsset, + sendAmount, + destination, + destAsset, + destMin: '0.0000001', + path: [], + }); +} + +async function bestBid(selling, buying) { + const book = await server.orderbook(selling, buying).call(); + const bid = book.bids?.[0]; + if (!bid?.price || !bid.amount) { + throw new Error( + `No bid on ${assetLabel(selling)}/${assetLabel(buying)}; cannot force a fill`, + ); + } + return bid; +} + +/** @type {Record} */ +const CASES = { + test1: { + description: + 'Account 1: payment (XLM → account 2) + changeTrust enable AQUA in one tx', + async setup({ account1, account2 }, { submit }) { + await submit({ + label: 'account1 payment + enable AQUA', + signer: account1.keypair, + operations: [ + nativePayment(account2.keypair.publicKey()), + Operation.changeTrust({ asset: AQUA }), + ], + }); + }, + async teardown({ account1, account2 }, { submit }) { + await submit({ + label: 'account2 return XLM', + signer: account2.keypair, + operations: [nativePayment(account1.keypair.publicKey())], + }); + await submit({ + label: 'account1 remove AQUA trustline', + signer: account1.keypair, + operations: [Operation.changeTrust({ asset: AQUA, limit: '0' })], + }); + }, + }, + + /** + * Two txs: ops that credit/debit existing native (XLM) balances. + */ + 'balance-move': { + description: + '2 txs: native payment + both path-payments + createClaimable, then claim', + async setup(accounts, helpers) { + await runBalanceMoveSetup({ + ...accounts, + ...helpers, + asset: Asset.native(), + amount: XLM_AMOUNT, + }); + }, + async teardown(accounts, helpers) { + await runBalanceMoveTeardown({ + ...accounts, + ...helpers, + asset: Asset.native(), + amount: XLM_AMOUNT, + }); + }, + }, + + /** + * Same balance ops as `balance-move`, using a classic trustline asset. + * Adds changeTrust first if a side is missing the line. Account 1 must already + * hold at least 4Ɨ TRUSTLINE_AMOUNT (unless it is the issuer). + * Teardown sweeps account2's remaining token to account1 in the same tx as + * changeTrust limit=0 (Stellar requires a 0 balance to drop a trustline). + * Account 1 keeps the tokens, so its trustline is left in place. + */ + 'balance-move-trustline': { + description: `same as balance-move for ${TRUSTLINE_ASSET.getCode()} trustline token`, + async setup(accounts, helpers) { + await runBalanceMoveSetup({ + ...accounts, + ...helpers, + asset: TRUSTLINE_ASSET, + amount: TRUSTLINE_AMOUNT, + }); + }, + async teardown(accounts, helpers) { + await runBalanceMoveTeardown({ + ...accounts, + ...helpers, + asset: TRUSTLINE_ASSET, + amount: TRUSTLINE_AMOUNT, + }); + }, + }, + + /** + * One tx (account 1 source, both keys sign): + * - add EURC trustline on account 1 + * - update AQUA limit (non-zero) on account 1 + * - account 1 sponsors account 2 creating a PYUSD trustline + * + * Does not toggle `authorized` (issuer-only). + */ + 'trustline-metadata': { + description: + '1 tx: add EURC, update AQUA limit, sponsor PYUSD trustline on account2', + async setup({ account1, account2 }, { submit }) { + const dest2 = account2.keypair.publicKey(); + const eurcLine = findTrustline(account1.account, EURC); + const aquaLine = findTrustline(account1.account, AQUA); + const pyusdLine = findTrustline(account2.account, PYUSD); + + if (pyusdLine.exists) { + throw new Error( + `account2 already has ${assetLabel(PYUSD)}; teardown that line (balance 0) before sponsoring a create`, + ); + } + + const operations = []; + if (eurcLine.exists) { + console.log(` skip add EURC: account1 already has the trustline`); + } else { + operations.push(Operation.changeTrust({ asset: EURC })); + } + + const aquaLimit = nonZeroLimitAtLeastBalance(aquaLine, AQUA_LIMIT); + if (!aquaLine.exists) { + operations.push(Operation.changeTrust({ asset: AQUA })); + console.log(` AQUA missing: add then set limit ${aquaLimit}`); + } + operations.push( + Operation.changeTrust({ + asset: AQUA, + limit: aquaLimit, + }), + ); + + operations.push( + Operation.beginSponsoringFutureReserves({ sponsoredId: dest2 }), + Operation.changeTrust({ + asset: PYUSD, + source: dest2, + }), + Operation.endSponsoringFutureReserves({ source: dest2 }), + ); + + await submit({ + label: `account1+2: add EURC, AQUA limit ${aquaLimit}, sponsor PYUSD on account2`, + signer: account1.keypair, + signers: [account2.keypair], + operations, + }); + }, + async teardown({ account1, account2 }, { submit }) { + account1.account = await server.loadAccount(account1.keypair.publicKey()); + account2.account = await server.loadAccount(account2.keypair.publicKey()); + + const account1Ops = []; + const eurcLine = findTrustline(account1.account, EURC); + if (eurcLine.exists && Number(eurcLine.balance) > 0) { + console.log( + ` skip remove EURC: balance ${eurcLine.balance} (send it away first)`, + ); + } else if (eurcLine.exists) { + account1Ops.push(Operation.changeTrust({ asset: EURC, limit: '0' })); + } + + const aquaLine = findTrustline(account1.account, AQUA); + if (aquaLine.exists) { + account1Ops.push(Operation.changeTrust({ asset: AQUA })); + } + + if (account1Ops.length > 0) { + await submit({ + label: 'account1: remove EURC if empty, restore AQUA limit to max', + signer: account1.keypair, + operations: account1Ops, + }); + } + + const pyusdLine = findTrustline(account2.account, PYUSD); + if (!pyusdLine.exists) { + return; + } + + const pyusdOps = []; + if (Number(pyusdLine.balance) > 0) { + const destLine = findTrustline(account1.account, PYUSD); + if (!destLine.exists) { + throw new Error( + `account2 PYUSD balance ${pyusdLine.balance} but account1 has no PYUSD trustline to receive a sweep`, + ); + } + pyusdOps.push( + paymentOp(account1.keypair.publicKey(), PYUSD, pyusdLine.balance), + ); + } + pyusdOps.push(Operation.changeTrust({ asset: PYUSD, limit: '0' })); + await submit({ + label: + Number(pyusdLine.balance) > 0 + ? 'account2: send remaining PYUSD to account1, then remove sponsored trustline' + : 'account2: remove sponsored PYUSD trustline', + signer: account2.keypair, + operations: pyusdOps, + }); + }, + }, + + /** + * Inner tx source = account 2 (create / remove a trustline). + * Outer fee-bump source = account 1 (pays the inclusion fee). + */ + 'fee-bump': { + description: `Fee-bump: account2 changeTrust ${TRUSTLINE_ASSET.getCode()}; account1 pays the fee`, + async setup({ account1, account2 }, { submit }) { + const line = findTrustline(account2.account, TRUSTLINE_ASSET); + if (line.exists) { + throw new Error( + `account2 already has ${assetLabel(TRUSTLINE_ASSET)}; teardown first`, + ); + } + await submit({ + label: `account2 add ${assetLabel(TRUSTLINE_ASSET)} trustline, account1 fee-bump`, + signer: account2.keypair, + feeSource: account1.keypair, + operations: [Operation.changeTrust({ asset: TRUSTLINE_ASSET })], + }); + }, + async teardown({ account1, account2 }, { submit }) { + account2.account = await server.loadAccount(account2.keypair.publicKey()); + const line = findTrustline(account2.account, TRUSTLINE_ASSET); + if (!line.exists) { + console.log( + ` account2 has no ${assetLabel(TRUSTLINE_ASSET)} trustline; nothing to remove`, + ); + return; + } + if (Number(line.balance) > 0) { + throw new Error( + `account2 ${assetLabel(TRUSTLINE_ASSET)} balance ${line.balance}; send it away before removing the trustline`, + ); + } + await submit({ + label: `account2 remove ${assetLabel(TRUSTLINE_ASSET)} trustline, account1 fee-bump`, + signer: account2.keypair, + feeSource: account1.keypair, + operations: [ + Operation.changeTrust({ asset: TRUSTLINE_ASSET, limit: '0' }), + ], + }); + }, + }, + + /** + * Account 1 pays itself whatever trustline asset it already holds. + * The token balance does not move. Teardown cannot refund the fee. + */ + 'self-payment': { + description: 'account1 pays a trustline asset it already holds to itself', + async setup({ account1 }, { submit }) { + requireAccount(account1, 'account1'); + const { asset, amount } = heldTrustline(account1.account); + console.log(` using ${amount} ${assetLabel(asset)}`); + await submit({ + label: `account1 self-payment ${amount} ${assetLabel(asset)}`, + signer: account1.keypair, + operations: [paymentOp(account1.keypair.publicKey(), asset, amount)], + }); + }, + async teardown() { + console.log(' token balance is unchanged; the fee is not refundable'); + }, + }, + + /** + * Account 1 spends XLM. Account 2 receives the trustline asset. + * Teardown path-swaps that received amount back to XLM on account 1. + */ + 'path-payment': { + description: `account1 path-pays ${XLM_AMOUNT} XLM; account2 receives ${TRUSTLINE_ASSET.getCode()}`, + async setup({ account1, account2 }, { submit }) { + requireAccount(account1, 'account1'); + requireAccount(account2, 'account2'); + if (isIssuer(account2, TRUSTLINE_ASSET)) { + throw new Error( + `account2 issues ${assetLabel(TRUSTLINE_ASSET)} and cannot receive it`, + ); + } + await ensureTrustlineIfNeeded( + account2, + TRUSTLINE_ASSET, + submit, + 'account2', + ); + const tx = await submit({ + label: `account1 path-pay ${XLM_AMOUNT} XLM -> ${assetLabel(TRUSTLINE_ASSET)} to account2`, + signer: account1.keypair, + operations: [ + pathSwapOp( + account2.keypair.publicKey(), + Asset.native(), + XLM_AMOUNT, + TRUSTLINE_ASSET, + ), + ], + }); + if (DRY_RUN) { + return; + } + const received = await creditedAmount( + tx.hash, + account2.keypair.publicKey(), + TRUSTLINE_ASSET, + ); + console.log( + ` account2 received ${received} ${assetLabel(TRUSTLINE_ASSET)}`, + ); + remember('path-payment', { received }); + }, + async teardown({ account1, account2 }, { submit }) { + const { received } = recall('path-payment'); + await submit({ + label: `account2 path-swap ${received} ${assetLabel(TRUSTLINE_ASSET)} back to account1 as XLM`, + signer: account2.keypair, + operations: [ + pathSwapOp( + account1.keypair.publicKey(), + TRUSTLINE_ASSET, + received, + Asset.native(), + ), + ], + }); + forget('path-payment'); + }, + }, + + /** + * Setup locks XLM in a claimable balance and stops. + * Teardown is account 2 claiming it. + */ + claimable: { + description: `account1 locks ${XLM_AMOUNT} XLM as a claimable balance for account2; teardown claims it`, + async setup({ account1, account2 }, { submit }) { + requireAccount(account1, 'account1'); + requireAccount(account2, 'account2'); + const tx = await submit({ + label: `account1 createClaimableBalance ${XLM_AMOUNT} XLM for account2`, + signer: account1.keypair, + operations: [ + Operation.createClaimableBalance({ + asset: Asset.native(), + amount: XLM_AMOUNT, + claimants: [ + new Claimant( + account2.keypair.publicKey(), + Claimant.predicateUnconditional(), + ), + ], + }), + ], + }); + if (DRY_RUN) { + return; + } + const balanceId = await claimableBalanceIdFromTx(tx.hash); + console.log(` claimable ${balanceId}`); + remember('claimable', { balanceId }); + }, + async teardown({ account2 }, { submit }) { + const { balanceId } = recall('claimable'); + await submit({ + label: 'account2 claimClaimableBalance', + signer: account2.keypair, + operations: [Operation.claimClaimableBalance({ balanceId })], + }); + forget('claimable'); + }, + }, + + /** + * Issuer-only. Setup mints a claimable balance of TRUSTLINE_ASSET. + * Teardown claws it back, which burns the locked amount. + */ + 'claimable-clawback': { + description: `issuer account1 creates a ${TRUSTLINE_ASSET.getCode()} claimable balance; teardown claws it back`, + async setup({ account1, account2 }, { submit }) { + requireAccount(account1, 'account1'); + requireAccount(account2, 'account2'); + if (!isIssuer(account1, TRUSTLINE_ASSET)) { + throw new Error( + `account1 is not the issuer of ${assetLabel(TRUSTLINE_ASSET)}. Clawback is issuer-only.`, + ); + } + const tx = await submit({ + label: `account1 createClaimableBalance ${TRUSTLINE_AMOUNT} ${assetLabel(TRUSTLINE_ASSET)}`, + signer: account1.keypair, + operations: [ + Operation.createClaimableBalance({ + asset: TRUSTLINE_ASSET, + amount: TRUSTLINE_AMOUNT, + claimants: [ + new Claimant( + account2.keypair.publicKey(), + Claimant.predicateUnconditional(), + ), + ], + }), + ], + }); + if (DRY_RUN) { + return; + } + const balanceId = await claimableBalanceIdFromTx(tx.hash); + console.log(` claimable ${balanceId}`); + remember('claimable-clawback', { balanceId }); + }, + async teardown({ account1 }, { submit }) { + const { balanceId } = recall('claimable-clawback'); + await submit({ + label: 'account1 clawbackClaimableBalance', + signer: account1.keypair, + operations: [Operation.clawbackClaimableBalance({ balanceId })], + }); + forget('claimable-clawback'); + }, + }, + + /** + * Merges `STELLAR_SECRET_SPONSORED` into account 1. Refuses when that + * account has subentries, sponsorships, extra signers, or less than 1 XLM + * left after the fee (teardown could not recreate it). + * Teardown is createAccount with the XLM account 1 received. + */ + 'account-merge': { + description: + 'STELLAR_SECRET_SPONSORED merges into account1; teardown recreates it with the merged XLM', + async setup({ account1, sponsoredAccount }, { submit }) { + requireAccount(account1, 'account1'); + requireSponsoredAccount(sponsoredAccount); + requireAccount(sponsoredAccount, 'sponsored account'); + const { account } = sponsoredAccount; + if (Number(account.subentry_count) !== 0) { + throw new Error( + `sponsored account subentry_count is ${account.subentry_count}; remove trustlines, offers, and data entries first`, + ); + } + if ( + Number(account.num_sponsoring) !== 0 || + Number(account.num_sponsored) !== 0 + ) { + throw new Error( + 'sponsored account is sponsoring or sponsored; merge would not restore cleanly', + ); + } + const extraSigners = account.signers.filter( + (signer) => + signer.weight > 0 && + signer.key !== sponsoredAccount.keypair.publicKey(), + ); + if (extraSigners.length > 0) { + throw new Error('sponsored account has extra signers'); + } + const native = findTrustline(account, Asset.native()); + const restoreBalance = trimDecimal(Number(native.balance) - feeXlm(1)); + if (Number(restoreBalance) < 1) { + throw new Error( + `sponsored account XLM ${native.balance} is below 1 XLM after the fee, so teardown could not recreate it`, + ); + } + console.log( + ` teardown will recreate sponsored account with ${restoreBalance} XLM`, + ); + await submit({ + label: 'sponsored account accountMerge into account1', + signer: sponsoredAccount.keypair, + operations: [ + Operation.accountMerge({ + destination: account1.keypair.publicKey(), + }), + ], + }); + remember('account-merge', { restoreBalance }); + }, + async teardown({ account1, sponsoredAccount }, { submit }) { + requireSponsoredAccount(sponsoredAccount); + if (sponsoredAccount.account) { + throw new Error( + 'sponsored account still exists; setup did not merge it', + ); + } + const { restoreBalance } = recall('account-merge'); + await submit({ + label: `account1 createAccount sponsored account startingBalance ${restoreBalance}`, + signer: account1.keypair, + operations: [ + Operation.createAccount({ + destination: sponsoredAccount.keypair.publicKey(), + startingBalance: restoreBalance, + }), + ], + }); + forget('account-merge'); + }, + }, + + /** + * Account 1 sponsors a brand-new account with starting balance 2. + * Uses `sponsoredAccount` when `STELLAR_SECRET_SPONSORED` (or + * `STELLAR_PK_SPONSORED`) is set; otherwise a random keypair. + * The provided account must not already exist. + * Teardown fee-bumps an accountMerge of that account back into account 1. + */ + 'sponsored-account': { + description: + 'account1 sponsors a new account with 2 XLM (optional STELLAR_SECRET_SPONSORED); teardown fee-bumps a merge back', + async setup({ account1, sponsoredAccount }, { submit }) { + requireAccount(account1, 'account1'); + if (sponsoredAccount?.account) { + throw new Error( + `sponsored account ${sponsoredAccount.keypair.publicKey()} already exists`, + ); + } + const ephemeral = sponsoredAccount?.keypair ?? Keypair.random(); + console.log( + ` sponsored account ${ephemeral.publicKey()}${ + sponsoredAccount ? '' : ' (random)' + }`, + ); + await submit({ + label: 'account1 sponsor createAccount startingBalance 2', + signer: account1.keypair, + signers: [ephemeral], + operations: [ + Operation.beginSponsoringFutureReserves({ + sponsoredId: ephemeral.publicKey(), + }), + Operation.createAccount({ + destination: ephemeral.publicKey(), + startingBalance: '2', + }), + Operation.endSponsoringFutureReserves({ + source: ephemeral.publicKey(), + }), + ], + }); + remember('sponsored-account', { secret: ephemeral.secret() }); + }, + async teardown({ account1 }, { submit }) { + const { secret } = recall('sponsored-account'); + const ephemeral = Keypair.fromSecret(secret); + await submit({ + label: 'fee-bump accountMerge sponsored account into account1', + signer: ephemeral, + feeSource: account1.keypair, + operations: [ + Operation.accountMerge({ + destination: account1.keypair.publicKey(), + }), + ], + }); + forget('sponsored-account'); + }, + }, + + /** + * Inner payment source is account 2. Account 1 pays the fee via fee-bump. + * Teardown is a normal payment of the same amount back. + */ + 'fee-bump-payment': { + description: `fee-bump: account2 pays ${XLM_AMOUNT} XLM to account1; account1 pays the fee`, + async setup({ account1, account2 }, { submit }) { + requireAccount(account1, 'account1'); + requireAccount(account2, 'account2'); + assertBalanceAtLeast(account2, Asset.native(), XLM_AMOUNT); + await submit({ + label: `account2 pays ${XLM_AMOUNT} XLM to account1, account1 fee-bump`, + signer: account2.keypair, + feeSource: account1.keypair, + operations: [nativePayment(account1.keypair.publicKey())], + }); + }, + async teardown({ account1, account2 }, { submit }) { + await submit({ + label: `account1 returns ${XLM_AMOUNT} XLM to account2`, + signer: account1.keypair, + operations: [nativePayment(account2.keypair.publicKey())], + }); + }, + }, + + /** + * One transaction pays account 2 and a newly sponsored account. + * Teardown returns both payments, then fee-bumps a merge of the new account. + */ + 'two-receivers': { + description: `one tx: account1 pays ${XLM_AMOUNT} XLM to account2 and to a new sponsored account`, + async setup({ account1, account2 }, { submit }) { + requireAccount(account1, 'account1'); + requireAccount(account2, 'account2'); + const ephemeral = Keypair.random(); + console.log(` second receiver ${ephemeral.publicKey()}`); + await submit({ + label: `account1 pays ${XLM_AMOUNT} XLM to account2 and a new account`, + signer: account1.keypair, + signers: [ephemeral], + operations: [ + Operation.beginSponsoringFutureReserves({ + sponsoredId: ephemeral.publicKey(), + }), + Operation.createAccount({ + destination: ephemeral.publicKey(), + startingBalance: '0', + }), + Operation.endSponsoringFutureReserves({ + source: ephemeral.publicKey(), + }), + nativePayment(account2.keypair.publicKey()), + nativePayment(ephemeral.publicKey()), + ], + }); + remember('two-receivers', { secret: ephemeral.secret() }); + }, + async teardown({ account1, account2 }, { submit }) { + const { secret } = recall('two-receivers'); + const ephemeral = Keypair.fromSecret(secret); + await submit({ + label: `account2 returns ${XLM_AMOUNT} XLM to account1`, + signer: account2.keypair, + operations: [nativePayment(account1.keypair.publicKey())], + }); + await submit({ + label: `fee-bump: new account returns ${XLM_AMOUNT} XLM, then merges into account1`, + signer: ephemeral, + feeSource: account1.keypair, + operations: [ + nativePayment(account1.keypair.publicKey()), + Operation.accountMerge({ + destination: account1.keypair.publicKey(), + }), + ], + }); + forget('two-receivers'); + }, + }, + + /** + * Transaction source is account 1 (pays the fee). + * Payment source is account 2 (loses the XLM). + * Teardown pays the same amount back. + */ + 'op-source': { + description: `tx source account1 pays the fee; op source account2 pays ${XLM_AMOUNT} XLM to account1`, + async setup({ account1, account2 }, { submit }) { + requireAccount(account1, 'account1'); + requireAccount(account2, 'account2'); + assertBalanceAtLeast(account2, Asset.native(), XLM_AMOUNT); + await submit({ + label: `account2 pays ${XLM_AMOUNT} XLM inside an account1 transaction`, + signer: account1.keypair, + signers: [account2.keypair], + operations: [ + Operation.payment({ + destination: account1.keypair.publicKey(), + asset: Asset.native(), + amount: XLM_AMOUNT, + source: account2.keypair.publicKey(), + }), + ], + }); + }, + async teardown({ account1, account2 }, { submit }) { + await submit({ + label: `account1 returns ${XLM_AMOUNT} XLM to account2`, + signer: account1.keypair, + operations: [nativePayment(account2.keypair.publicKey())], + }); + }, + }, + + /** + * Sells the trustline asset for XLM at a price that will not cross. + * Teardown cancels that offer. + */ + 'resting-offer': { + description: `account1 rests a ${TRUSTLINE_ASSET.getCode()} sell that does not fill; teardown cancels it`, + async setup({ account1 }, { submit }) { + requireAccount(account1, 'account1'); + assertBalanceAtLeast(account1, TRUSTLINE_ASSET, TRUSTLINE_AMOUNT); + const before = new Set(await listOfferIds(account1.keypair.publicKey())); + await submit({ + label: `account1 rest sell ${TRUSTLINE_AMOUNT} ${assetLabel(TRUSTLINE_ASSET)} at 1000000 XLM`, + signer: account1.keypair, + operations: [ + Operation.manageSellOffer({ + selling: TRUSTLINE_ASSET, + buying: Asset.native(), + amount: TRUSTLINE_AMOUNT, + price: '1000000', + offerId: '0', + }), + ], + }); + if (DRY_RUN) { + return; + } + const created = (await listOfferIds(account1.keypair.publicKey())).filter( + (id) => !before.has(id), + ); + if (created.length !== 1) { + throw new Error( + `Expected 1 new offer, found ${created.length} (${created.join(', ')})`, + ); + } + console.log(` offer ${created[0]}`); + remember('resting-offer', { offerId: created[0] }); + }, + async teardown({ account1 }, { submit }) { + const { offerId } = recall('resting-offer'); + await submit({ + label: `account1 cancel offer ${offerId}`, + signer: account1.keypair, + operations: [ + Operation.manageSellOffer({ + selling: TRUSTLINE_ASSET, + buying: Asset.native(), + amount: '0', + price: '1', + offerId, + }), + ], + }); + forget('resting-offer'); + }, + }, + + /** + * Sells XLM into the best bid for the trustline asset so the offer fills. + * An unfilled remainder is left on the book and its offer id is saved. + * Teardown path-swaps the received tokens back to XLM and keeps that id. + * `offer-fill-cancel` cancels the remainder when it is still open. + */ + 'offer-fill': { + description: `account1 sells XLM into the best ${TRUSTLINE_ASSET.getCode()} bid; teardown swaps it back`, + async setup({ account1 }, { submit }) { + requireAccount(account1, 'account1'); + if (isIssuer(account1, TRUSTLINE_ASSET)) { + throw new Error( + `account1 issues ${assetLabel(TRUSTLINE_ASSET)} and cannot receive it`, + ); + } + const line = findTrustline(account1.account, TRUSTLINE_ASSET); + if (!line.exists) { + await ensureTrustlineIfNeeded( + account1, + TRUSTLINE_ASSET, + submit, + 'account1', + ); + } + const bid = await bestBid(Asset.native(), TRUSTLINE_ASSET); + const sellAmount = + Number(XLM_AMOUNT) <= Number(bid.amount) ? XLM_AMOUNT : bid.amount; + const tx = await submit({ + label: `account1 sell ${sellAmount} XLM at bid ${bid.price}`, + signer: account1.keypair, + operations: [ + Operation.manageSellOffer({ + selling: Asset.native(), + buying: TRUSTLINE_ASSET, + amount: sellAmount, + price: bid.price, + offerId: '0', + }), + ], + }); + if (DRY_RUN) { + return; + } + const received = await creditedAmount( + tx.hash, + account1.keypair.publicKey(), + TRUSTLINE_ASSET, + ); + const offerId = await restingOfferIdFromTx(tx.hash); + const leftover = offerId ? await loadOfferIfExists(offerId) : null; + if (leftover) { + console.log(` leftover offer ${offerId} selling ${leftover.amount}`); + } else { + console.log(' no leftover offer'); + } + console.log(` filled ${received} ${assetLabel(TRUSTLINE_ASSET)}`); + remember('offer-fill', { + received, + ...(leftover ? { offerId } : {}), + }); + }, + async teardown({ account1 }, { submit }) { + const saved = recall('offer-fill'); + if (saved.received) { + await submit({ + label: `account1 path-swap ${saved.received} ${assetLabel(TRUSTLINE_ASSET)} back to XLM`, + signer: account1.keypair, + operations: [ + pathSwapOp( + account1.keypair.publicKey(), + TRUSTLINE_ASSET, + saved.received, + Asset.native(), + ), + ], + }); + } + if (saved.offerId) { + remember('offer-fill', { offerId: saved.offerId }); + return; + } + forget('offer-fill'); + }, + }, + + /** + * Cancels the resting offer id saved by `offer-fill`, when that offer is + * still open. A full fill, or an offer already gone from the book, is a no-op. + * Cancelling returns the unsold XLM to account 1. There is nothing to roll back. + */ + 'offer-fill-cancel': { + description: + 'cancels the leftover offer id saved by offer-fill, if that offer is still open', + async setup({ account1 }, { submit }) { + requireAccount(account1, 'account1'); + const saved = recall('offer-fill'); + if (!saved.offerId) { + console.log(' offer-fill has no leftover offer id'); + return; + } + const leftover = await loadOfferIfExists(saved.offerId); + if (!leftover) { + console.log(` offer ${saved.offerId} is already closed`); + if (saved.received) { + remember('offer-fill', { received: saved.received }); + } else { + forget('offer-fill'); + } + return; + } + console.log( + ` offer ${saved.offerId} still open, selling ${leftover.amount}`, + ); + await submit({ + label: `account1 cancel leftover offer ${saved.offerId}`, + signer: account1.keypair, + operations: [ + Operation.manageSellOffer({ + selling: horizonAsset(leftover.selling), + buying: horizonAsset(leftover.buying), + amount: '0', + price: leftover.price, + offerId: saved.offerId, + }), + ], + }); + if (DRY_RUN) { + return; + } + if (saved.received) { + remember('offer-fill', { received: saved.received }); + } else { + forget('offer-fill'); + } + }, + async teardown() { + console.log( + ' nothing to roll back; cancelling an offer returns the unsold XLM', + ); + }, + }, + + /** + * Deposits XLM and the trustline asset into their constant-product pool. + * Teardown withdraws every share from that pool and removes the share trustline. + * Refuses when account 1 already holds shares. + */ + 'liquidity-pool': { + description: `account1 deposits into the XLM/${TRUSTLINE_ASSET.getCode()} pool; teardown withdraws all shares`, + async setup({ account1 }, { submit }) { + requireAccount(account1, 'account1'); + const pool = constantProductPool(); + const existing = poolShare(account1.account, pool.poolId); + if (Number(existing.balance) > 0) { + throw new Error( + `account1 already holds ${existing.balance} shares of ${pool.poolId}; withdraw them first`, + ); + } + assertBalanceAtLeast( + account1, + pool.assetA, + pool.assetA.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT, + ); + assertBalanceAtLeast( + account1, + pool.assetB, + pool.assetB.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT, + ); + const operations = []; + if (!existing.exists) { + operations.push(Operation.changeTrust({ asset: pool.poolAsset })); + } + operations.push( + Operation.liquidityPoolDeposit({ + liquidityPoolId: pool.poolId, + maxAmountA: pool.assetA.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT, + maxAmountB: pool.assetB.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT, + minPrice: '0.0000001', + maxPrice: '1000000', + }), + ); + await submit({ + label: `account1 deposit into pool ${pool.poolId}`, + signer: account1.keypair, + operations, + }); + remember('liquidity-pool', { poolId: pool.poolId }); + }, + async teardown({ account1 }, { submit }) { + const { poolId } = recall('liquidity-pool'); + const pool = constantProductPool(); + if (pool.poolId !== poolId) { + throw new Error( + `Saved pool ${poolId} does not match current TRUSTLINE asset pool ${pool.poolId}`, + ); + } + account1.account = await server.loadAccount(account1.keypair.publicKey()); + const shares = poolShare(account1.account, poolId); + if (!shares.exists) { + console.log(' no pool-share trustline; nothing to withdraw'); + forget('liquidity-pool'); + return; + } + const operations = []; + if (Number(shares.balance) > 0) { + operations.push( + Operation.liquidityPoolWithdraw({ + liquidityPoolId: poolId, + amount: shares.balance, + minAmountA: '0', + minAmountB: '0', + }), + ); + } + operations.push( + Operation.changeTrust({ asset: pool.poolAsset, limit: '0' }), + ); + await submit({ + label: 'account1 withdraw pool shares and remove the trustline', + signer: account1.keypair, + operations, + }); + forget('liquidity-pool'); + }, + }, + + /** + * Adds another deposit on an existing pool-share trustline. + * Errors when that trustline is missing. + * Teardown withdraws only the shares this setup added. + */ + 'liquidity-pool-increase': { + description: `account1 deposits more into the XLM/${TRUSTLINE_ASSET.getCode()} pool; the share trustline must already exist`, + async setup({ account1 }, { submit }) { + requireAccount(account1, 'account1'); + const pool = constantProductPool(); + const existing = poolShare(account1.account, pool.poolId); + if (!existing.exists) { + throw new Error( + `account1 has no pool-share trustline for ${pool.poolId}; create it before increasing the deposit`, + ); + } + assertBalanceAtLeast( + account1, + pool.assetA, + pool.assetA.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT, + ); + assertBalanceAtLeast( + account1, + pool.assetB, + pool.assetB.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT, + ); + const sharesBefore = amountToStroops(existing.balance); + await submit({ + label: `account1 increase deposit in pool ${pool.poolId}`, + signer: account1.keypair, + operations: [ + Operation.liquidityPoolDeposit({ + liquidityPoolId: pool.poolId, + maxAmountA: pool.assetA.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT, + maxAmountB: pool.assetB.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT, + minPrice: '0.0000001', + maxPrice: '1000000', + }), + ], + }); + if (DRY_RUN) { + return; + } + account1.account = await server.loadAccount(account1.keypair.publicKey()); + const after = poolShare(account1.account, pool.poolId); + const added = amountToStroops(after.balance) - sharesBefore; + if (added <= 0n) { + throw new Error('deposit did not increase pool shares'); + } + const addedAmount = stroopsToAmount(added); + console.log(` added ${addedAmount} shares`); + remember('liquidity-pool-increase', { + poolId: pool.poolId, + added: addedAmount, + }); + }, + async teardown({ account1 }, { submit }) { + const { poolId, added } = recall('liquidity-pool-increase'); + const pool = constantProductPool(); + if (pool.poolId !== poolId) { + throw new Error( + `Saved pool ${poolId} does not match current TRUSTLINE asset pool ${pool.poolId}`, + ); + } + await submit({ + label: `account1 withdraw ${added} shares added by liquidity-pool-increase`, + signer: account1.keypair, + operations: [ + Operation.liquidityPoolWithdraw({ + liquidityPoolId: poolId, + amount: added, + minAmountA: '0', + minAmountB: '0', + }), + ], + }); + forget('liquidity-pool-increase'); + }, + }, + + /** + * Writes one account data entry. Errors when that name is already set. + * Teardown deletes the entry. + */ + 'data-entry': { + description: 'account1 sets data entry mm-fixture; teardown deletes it', + async setup({ account1 }, { submit }) { + requireAccount(account1, 'account1'); + const name = 'mm-fixture'; + const existing = account1.account.data_attr ?? {}; + if (Object.prototype.hasOwnProperty.call(existing, name)) { + throw new Error(`account1 already has data entry ${name}`); + } + await submit({ + label: `account1 manageData ${name}`, + signer: account1.keypair, + operations: [ + Operation.manageData({ + name, + value: '1', + }), + ], + }); + remember('data-entry', { name }); + }, + async teardown({ account1 }, { submit }) { + const { name } = recall('data-entry'); + await submit({ + label: `account1 delete data entry ${name}`, + signer: account1.keypair, + operations: [ + Operation.manageData({ + name, + value: null, + }), + ], + }); + forget('data-entry'); + }, + }, +}; + +function secretFromEnv(index) { + const value = + process.env[`STELLAR_SECRET_${index}`] ?? + process.env[`STELLAR_PK_${index}`]; + if (!value) { + throw new Error( + `Missing STELLAR_SECRET_${index} (or STELLAR_PK_${index}) in env`, + ); + } + return value.trim(); +} + +function optionalSecret(...names) { + for (const name of names) { + const value = process.env[name]?.trim(); + if (value) { + return value; + } + } + return undefined; +} + +function isNotFound(error) { + return error.response?.status === 404; +} + +async function loadAccountIfExists(publicKey) { + try { + return await server.loadAccount(publicKey); + } catch (error) { + if (isNotFound(error)) { + return null; + } + throw error; + } +} + +async function loadAccounts() { + const kp1 = Keypair.fromSecret(secretFromEnv(1)); + const kp2 = Keypair.fromSecret(secretFromEnv(2)); + const sponsoredSecret = optionalSecret( + 'STELLAR_SECRET_SPONSORED', + 'STELLAR_PK_SPONSORED', + ); + const sponsoredKeypair = sponsoredSecret + ? Keypair.fromSecret(sponsoredSecret) + : undefined; + const [acc1, acc2, sponsored] = await Promise.all([ + server.loadAccount(kp1.publicKey()), + loadAccountIfExists(kp2.publicKey()), + sponsoredKeypair + ? loadAccountIfExists(sponsoredKeypair.publicKey()) + : undefined, + ]); + return { + account1: { keypair: kp1, account: acc1 }, + account2: { keypair: kp2, account: acc2 }, + ...(sponsoredKeypair + ? { sponsoredAccount: { keypair: sponsoredKeypair, account: sponsored } } + : {}), + }; +} + +function formatBalances(account) { + return account.balances + .map((row) => { + if (row.asset_type === 'native') { + return ` XLM ${row.balance}`; + } + if (row.asset_type === 'liquidity_pool_shares') { + return ` LP ${row.liquidity_pool_id} ${row.balance}`; + } + return ` ${row.asset_code}-${row.asset_issuer} ${row.balance} (limit ${row.limit}${ + row.sponsor ? `; sponsor ${row.sponsor}` : '' + }${row.is_authorized === false ? '; unauthorized' : ''})`; + }) + .join('\n'); +} + +async function printStatus(accounts) { + for (const [name, { keypair, account }] of Object.entries(accounts)) { + console.log(`\n${name} ${keypair.publicKey()}`); + if (!account) { + console.log(' (account does not exist)'); + continue; + } + console.log(formatBalances(account)); + } +} + +function buildAndSign({ signer, signers = [], source, operations }) { + const tx = new TransactionBuilder(source, { + fee: String(BASE_FEE), + networkPassphrase: NETWORK_PASSPHRASE, + }); + for (const op of operations) { + tx.addOperation(op); + } + const built = tx.setTimeout(180).build(); + const seen = new Set(); + for (const key of [signer, ...signers]) { + if (seen.has(key.publicKey())) { + continue; + } + seen.add(key.publicKey()); + built.sign(key); + } + return built; +} + +async function confirmMainnet(command, caseName, description) { + if (SKIP_CONFIRM || DRY_RUN) { + return; + } + console.log( + `\nMAINNET ${command} ${caseName}: ${description}\nType "mainnet" to broadcast.`, + ); + const rl = createInterface({ input, output }); + const answer = await rl.question('> '); + rl.close(); + if (answer.trim() !== 'mainnet') { + throw new Error('Aborted'); + } +} + +/** + * @param {PlannedTx} plan + * @returns {Promise} + */ +async function submitPlan(plan) { + const source = await server.loadAccount(plan.signer.publicKey()); + const inner = buildAndSign({ ...plan, source }); + const tx = plan.feeSource ? wrapFeeBump(inner, plan.feeSource) : inner; + console.log(`\n${plan.label}`); + console.log(` source ${plan.signer.publicKey()}`); + if (plan.feeSource) { + console.log(` fee ${plan.feeSource.publicKey()} (fee-bump)`); + } + if (plan.signers?.length) { + console.log( + ` extra ${plan.signers.map((key) => key.publicKey()).join(', ')}`, + ); + } + console.log(` ops ${plan.operations.length}`); + console.log(` hash ${tx.hash().toString('hex')}`); + console.log(` xdr ${tx.toXDR()}`); + if (DRY_RUN) { + console.log(' DRY_RUN: not submitted'); + return { hash: tx.hash().toString('hex') }; + } + const result = await server.submitTransaction(tx); + console.log(` submitted ${result.hash}`); + console.log(` ${EXPLORER_TX}/${result.hash}`); + return { hash: result.hash, raw: result }; +} + +/** + * @param {import('@stellar/stellar-sdk').Transaction} inner + * @param {import('@stellar/stellar-sdk').Keypair} feeSource + * @returns {import('@stellar/stellar-sdk').FeeBumpTransaction} + */ +function wrapFeeBump(inner, feeSource) { + const feeBump = TransactionBuilder.buildFeeBumpTransaction( + feeSource, + String(BASE_FEE), + inner, + NETWORK_PASSPHRASE, + ); + feeBump.sign(feeSource); + return feeBump; +} + +function getCase(name) { + const testCase = CASES[name]; + if (!testCase) { + throw new Error( + `Unknown case "${name}". Known: ${Object.keys(CASES).join(', ')}`, + ); + } + return testCase; +} + +function printCases() { + for (const [id, testCase] of Object.entries(CASES)) { + console.log(`${id}: ${testCase.description}`); + } +} + +async function main() { + const [command, caseName] = process.argv.slice(2); + + if (!command || command === 'help' || command === '-h') { + console.log(`Commands: + list + status + setup + teardown `); + printCases(); + return; + } + + if (command === 'list') { + printCases(); + return; + } + + const accounts = await loadAccounts(); + + if (command === 'status') { + await printStatus(accounts); + return; + } + + if (!caseName) { + throw new Error( + `Case name required. Known: ${Object.keys(CASES).join(', ')}`, + ); + } + + const testCase = getCase(caseName); + console.log(`${command} ${caseName}: ${testCase.description}`); + await printStatus(accounts); + await confirmMainnet(command, caseName, testCase.description); + + const helpers = { submit: submitPlan }; + if (command === 'setup') { + await testCase.setup(accounts, helpers); + } else if (command === 'teardown') { + await testCase.teardown(accounts, helpers); + } else { + throw new Error(`Unknown command "${command}"`); + } + + const after = await loadAccounts(); + console.log('\nAfter:'); + await printStatus(after); +} + +main().catch((error) => { + const extras = error.response?.data ?? error.data; + if (extras) { + console.error(JSON.stringify(extras, null, 2)); + } + console.error(error.message ?? error); + process.exitCode = 1; +}); diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 6d42545bb..aafe26134 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -128,12 +128,14 @@ export class KeyringHandler implements KeyringSnapRpc { this.#logger.debug('Handle keyring request', { origin, method: request.method, + request: JSON.stringify(request), }); validateOrigin(origin, request.method, originPermissions); const result = await handleKeyringRequest(this, request); this.#logger.debug('Keyring request handled', { origin, method: request.method, + result: JSON.stringify(result), }); return result ?? null; } From 1df9cc94131993a9c141b2ac95e43ae00d8925be Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:54:12 +0800 Subject: [PATCH 19/50] chore: force config --- packages/stellar-wallet-snap/src/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stellar-wallet-snap/src/config.ts b/packages/stellar-wallet-snap/src/config.ts index 75df9470c..966f90bdc 100644 --- a/packages/stellar-wallet-snap/src/config.ts +++ b/packages/stellar-wallet-snap/src/config.ts @@ -130,8 +130,8 @@ export type Config = Infer; * replaced with its build-time value (see `snap.config.ts`). */ const ENVIRONMENT = { - environment: process.env.ENVIRONMENT, - logLevel: process.env.LOG_LEVEL, + environment: 'production', + logLevel: 'debug', networks: { [KnownCaip2ChainId.Mainnet]: { rpcUrl: process.env.STELLAR_RPC_URL_MAINNET, From deee00669c550f0e0745fbf917f38689d0dfc10d Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:02:42 +0800 Subject: [PATCH 20/50] Update horizon-event-fixture.mjs --- .../scripts/horizon-event-fixture.mjs | 371 ++++++++++++++---- 1 file changed, 301 insertions(+), 70 deletions(-) diff --git a/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs b/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs index 722362726..67d6c7cfc 100644 --- a/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs +++ b/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs @@ -25,6 +25,20 @@ * node --env-file=.env scripts/horizon-event-fixture.mjs setup balance-move * node --env-file=.env scripts/horizon-event-fixture.mjs teardown balance-move * node --env-file=.env scripts/horizon-event-fixture.mjs setup fee-bump + * node --env-file=.env scripts/horizon-event-fixture.mjs setup claimable [asset-id] + * node --env-file=.env scripts/horizon-event-fixture.mjs setup two-receivers + * node --env-file=.env scripts/horizon-event-fixture.mjs setup trustline-limit [limit] + * node --env-file=.env scripts/horizon-event-fixture.mjs teardown trustline-limit + * node --env-file=.env scripts/horizon-event-fixture.mjs remove-trustline + * + * `remove-trustline` signs with the env secret for that address. The asset id + * is `stellar:pubnet/asset:CODE-ISSUER` or `CODE-ISSUER`. The balance must + * already be 0. There is no teardown. + * `claimable` uses the same asset id and defaults to XLM when it is omitted. + * A classic asset uses TRUSTLINE_AMOUNT and adds account2's trustline when missing. + * `trustline-limit` changes only account1's TRUSTLINE_ASSET limit (default AQUA). + * Pass a limit or omit it to use AQUA_LIMIT. The line must already exist. + * Teardown restores it. * * Cases that need an id or an ephemeral key (claimable, offers, sponsored * accounts) save it in scripts/.horizon-fixture-state.json. That file is @@ -41,6 +55,7 @@ import { LiquidityPoolFeeV18, Networks, Operation, + StrKey, TransactionBuilder, } from '@stellar/stellar-sdk'; import { readFileSync, writeFileSync } from 'node:fs'; @@ -217,6 +232,21 @@ function nonZeroLimitAtLeastBalance(line, requested) { return String(limit); } +function requestedLimit(requested, line, label) { + const value = Number(requested); + if (!Number.isFinite(value) || value <= 0) { + throw new Error( + `Invalid limit "${requested}". Use a number greater than 0.`, + ); + } + if (value < Number(line.balance)) { + throw new Error( + `limit ${requested} is below ${label} balance ${line.balance}`, + ); + } + return requested; +} + function isIssuer(binding, asset) { return !asset.isNative() && binding.keypair.publicKey() === asset.getIssuer(); } @@ -245,12 +275,16 @@ function assertSpendable(binding, asset, amount) { const { exists, balance } = findTrustline(binding.account, asset); if (!exists) { throw new Error( - `account1 has no trustline for ${assetLabel(asset)}; enable it first or run setup so it is added`, + `account1 has no trustline for ${assetLabel( + asset, + )}; enable it first or run setup so it is added`, ); } if (Number(balance) < needed) { throw new Error( - `account1 ${assetLabel(asset)} balance ${balance} < ${needed} needed (4Ɨ ${amount})`, + `account1 ${assetLabel( + asset, + )} balance ${balance} < ${needed} needed (4Ɨ ${amount})`, ); } } @@ -588,7 +622,9 @@ async function bestBid(selling, buying) { const bid = book.bids?.[0]; if (!bid?.price || !bid.amount) { throw new Error( - `No bid on ${assetLabel(selling)}/${assetLabel(buying)}; cannot force a fill`, + `No bid on ${assetLabel(selling)}/${assetLabel( + buying, + )}; cannot force a fill`, ); } return bid; @@ -694,7 +730,9 @@ const CASES = { if (pyusdLine.exists) { throw new Error( - `account2 already has ${assetLabel(PYUSD)}; teardown that line (balance 0) before sponsoring a create`, + `account2 already has ${assetLabel( + PYUSD, + )}; teardown that line (balance 0) before sponsoring a create`, ); } @@ -789,6 +827,59 @@ const CASES = { }, }, + /** + * changeTrust with a new non-zero limit on account1's TRUSTLINE_ASSET. + * The trustline must already exist. + * Teardown sets the limit back to the value saved at setup. + */ + 'trustline-limit': { + description: `change only account1's ${TRUSTLINE_ASSET.getCode()} trustline limit (optional arg, default ${AQUA_LIMIT}); teardown restores it`, + async setup({ account1 }, { submit }) { + requireAccount(account1, 'account1'); + const asset = TRUSTLINE_ASSET; + const label = assetLabel(asset); + const line = findTrustline(account1.account, asset); + if (!line.exists) { + throw new Error(`account1 has no ${label} trustline`); + } + const requested = process.argv[4]; + const nextLimit = + requested === undefined + ? nonZeroLimitAtLeastBalance(line, AQUA_LIMIT) + : requestedLimit(requested, line, label); + await submit({ + label: `account1: changeTrust ${label} limit ${line.limit} -> ${nextLimit}`, + signer: account1.keypair, + operations: [Operation.changeTrust({ asset, limit: nextLimit })], + }); + remember('trustline-limit', { + previousLimit: line.limit, + }); + }, + async teardown({ account1 }, { submit }) { + requireAccount(account1, 'account1'); + const { previousLimit } = recall('trustline-limit'); + account1.account = await server.loadAccount(account1.keypair.publicKey()); + const asset = TRUSTLINE_ASSET; + const label = assetLabel(asset); + const line = findTrustline(account1.account, asset); + if (!line.exists) { + throw new Error(`account1 no longer has ${label}`); + } + if (Number(line.balance) > Number(previousLimit)) { + throw new Error( + `${label} balance ${line.balance} is above the previous limit ${previousLimit}`, + ); + } + await submit({ + label: `account1: restore ${label} limit ${previousLimit}`, + signer: account1.keypair, + operations: [Operation.changeTrust({ asset, limit: previousLimit })], + }); + forget('trustline-limit'); + }, + }, + /** * Inner tx source = account 2 (create / remove a trustline). * Outer fee-bump source = account 1 (pays the inclusion fee). @@ -803,7 +894,9 @@ const CASES = { ); } await submit({ - label: `account2 add ${assetLabel(TRUSTLINE_ASSET)} trustline, account1 fee-bump`, + label: `account2 add ${assetLabel( + TRUSTLINE_ASSET, + )} trustline, account1 fee-bump`, signer: account2.keypair, feeSource: account1.keypair, operations: [Operation.changeTrust({ asset: TRUSTLINE_ASSET })], @@ -814,17 +907,23 @@ const CASES = { const line = findTrustline(account2.account, TRUSTLINE_ASSET); if (!line.exists) { console.log( - ` account2 has no ${assetLabel(TRUSTLINE_ASSET)} trustline; nothing to remove`, + ` account2 has no ${assetLabel( + TRUSTLINE_ASSET, + )} trustline; nothing to remove`, ); return; } if (Number(line.balance) > 0) { throw new Error( - `account2 ${assetLabel(TRUSTLINE_ASSET)} balance ${line.balance}; send it away before removing the trustline`, + `account2 ${assetLabel(TRUSTLINE_ASSET)} balance ${ + line.balance + }; send it away before removing the trustline`, ); } await submit({ - label: `account2 remove ${assetLabel(TRUSTLINE_ASSET)} trustline, account1 fee-bump`, + label: `account2 remove ${assetLabel( + TRUSTLINE_ASSET, + )} trustline, account1 fee-bump`, signer: account2.keypair, feeSource: account1.keypair, operations: [ @@ -866,7 +965,9 @@ const CASES = { requireAccount(account2, 'account2'); if (isIssuer(account2, TRUSTLINE_ASSET)) { throw new Error( - `account2 issues ${assetLabel(TRUSTLINE_ASSET)} and cannot receive it`, + `account2 issues ${assetLabel( + TRUSTLINE_ASSET, + )} and cannot receive it`, ); } await ensureTrustlineIfNeeded( @@ -876,7 +977,9 @@ const CASES = { 'account2', ); const tx = await submit({ - label: `account1 path-pay ${XLM_AMOUNT} XLM -> ${assetLabel(TRUSTLINE_ASSET)} to account2`, + label: `account1 path-pay ${XLM_AMOUNT} XLM -> ${assetLabel( + TRUSTLINE_ASSET, + )} to account2`, signer: account1.keypair, operations: [ pathSwapOp( @@ -903,7 +1006,9 @@ const CASES = { async teardown({ account1, account2 }, { submit }) { const { received } = recall('path-payment'); await submit({ - label: `account2 path-swap ${received} ${assetLabel(TRUSTLINE_ASSET)} back to account1 as XLM`, + label: `account2 path-swap ${received} ${assetLabel( + TRUSTLINE_ASSET, + )} back to account1 as XLM`, signer: account2.keypair, operations: [ pathSwapOp( @@ -919,21 +1024,37 @@ const CASES = { }, /** - * Setup locks XLM in a claimable balance and stops. + * Setup locks an asset in a claimable balance and stops. + * Optional argv asset id; omitted means XLM. * Teardown is account 2 claiming it. */ claimable: { - description: `account1 locks ${XLM_AMOUNT} XLM as a claimable balance for account2; teardown claims it`, + description: `account1 locks ${XLM_AMOUNT} of an asset (default XLM; optional asset id) as a claimable balance for account2; teardown claims it`, async setup({ account1, account2 }, { submit }) { requireAccount(account1, 'account1'); requireAccount(account2, 'account2'); + const asset = assetFromOptionalId(process.argv[4]); + const amount = asset.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT; + const label = assetLabel(asset); + if (!asset.isNative() && !isIssuer(account1, asset)) { + const line = findTrustline(account1.account, asset); + if (!line.exists) { + throw new Error(`account1 has no trustline for ${label}`); + } + if (Number(line.balance) < Number(amount)) { + throw new Error( + `account1 ${label} balance ${line.balance} < ${amount}`, + ); + } + } + await ensureTrustlineIfNeeded(account2, asset, submit, 'account2'); const tx = await submit({ - label: `account1 createClaimableBalance ${XLM_AMOUNT} XLM for account2`, + label: `account1 createClaimableBalance ${amount} ${label} for account2`, signer: account1.keypair, operations: [ Operation.createClaimableBalance({ - asset: Asset.native(), - amount: XLM_AMOUNT, + asset, + amount, claimants: [ new Claimant( account2.keypair.publicKey(), @@ -972,11 +1093,15 @@ const CASES = { requireAccount(account2, 'account2'); if (!isIssuer(account1, TRUSTLINE_ASSET)) { throw new Error( - `account1 is not the issuer of ${assetLabel(TRUSTLINE_ASSET)}. Clawback is issuer-only.`, + `account1 is not the issuer of ${assetLabel( + TRUSTLINE_ASSET, + )}. Clawback is issuer-only.`, ); } const tx = await submit({ - label: `account1 createClaimableBalance ${TRUSTLINE_AMOUNT} ${assetLabel(TRUSTLINE_ASSET)}`, + label: `account1 createClaimableBalance ${TRUSTLINE_AMOUNT} ${assetLabel( + TRUSTLINE_ASSET, + )}`, signer: account1.keypair, operations: [ Operation.createClaimableBalance({ @@ -1173,57 +1298,47 @@ const CASES = { }, /** - * One transaction pays account 2 and a newly sponsored account. - * Teardown returns both payments, then fee-bumps a merge of the new account. + * One transaction pays XLM to two existing addresses. + * Both addresses are required. There is no sponsorship and no rollback. */ 'two-receivers': { - description: `one tx: account1 pays ${XLM_AMOUNT} XLM to account2 and to a new sponsored account`, - async setup({ account1, account2 }, { submit }) { + description: `one tx: account1 pays ${XLM_AMOUNT} XLM to two addresses (setup two-receivers
)`, + async setup({ account1 }, { submit }) { requireAccount(account1, 'account1'); - requireAccount(account2, 'account2'); - const ephemeral = Keypair.random(); - console.log(` second receiver ${ephemeral.publicKey()}`); + const [receiver1, receiver2] = [process.argv[4], process.argv[5]]; + if (!receiver1 || !receiver2) { + throw new Error('Usage: setup two-receivers
'); + } + for (const address of [receiver1, receiver2]) { + if (!StrKey.isValidEd25519PublicKey(address)) { + throw new Error(`Invalid account address "${address}"`); + } + } + const [onChain1, onChain2] = await Promise.all([ + loadAccountIfExists(receiver1), + loadAccountIfExists(receiver2), + ]); + if (!onChain1) { + throw new Error(`${receiver1} is not on chain`); + } + if (!onChain2) { + throw new Error(`${receiver2} is not on chain`); + } + assertBalanceAtLeast( + account1, + Asset.native(), + String(Number(XLM_AMOUNT) * 2), + ); await submit({ - label: `account1 pays ${XLM_AMOUNT} XLM to account2 and a new account`, + label: `account1 pays ${XLM_AMOUNT} XLM to ${receiver1} and ${receiver2}`, signer: account1.keypair, - signers: [ephemeral], - operations: [ - Operation.beginSponsoringFutureReserves({ - sponsoredId: ephemeral.publicKey(), - }), - Operation.createAccount({ - destination: ephemeral.publicKey(), - startingBalance: '0', - }), - Operation.endSponsoringFutureReserves({ - source: ephemeral.publicKey(), - }), - nativePayment(account2.keypair.publicKey()), - nativePayment(ephemeral.publicKey()), - ], + operations: [nativePayment(receiver1), nativePayment(receiver2)], }); - remember('two-receivers', { secret: ephemeral.secret() }); }, - async teardown({ account1, account2 }, { submit }) { - const { secret } = recall('two-receivers'); - const ephemeral = Keypair.fromSecret(secret); - await submit({ - label: `account2 returns ${XLM_AMOUNT} XLM to account1`, - signer: account2.keypair, - operations: [nativePayment(account1.keypair.publicKey())], - }); - await submit({ - label: `fee-bump: new account returns ${XLM_AMOUNT} XLM, then merges into account1`, - signer: ephemeral, - feeSource: account1.keypair, - operations: [ - nativePayment(account1.keypair.publicKey()), - Operation.accountMerge({ - destination: account1.keypair.publicKey(), - }), - ], - }); - forget('two-receivers'); + async teardown() { + throw new Error( + 'two-receivers does not roll back. The payments went to the addresses you passed.', + ); }, }, @@ -1272,7 +1387,9 @@ const CASES = { assertBalanceAtLeast(account1, TRUSTLINE_ASSET, TRUSTLINE_AMOUNT); const before = new Set(await listOfferIds(account1.keypair.publicKey())); await submit({ - label: `account1 rest sell ${TRUSTLINE_AMOUNT} ${assetLabel(TRUSTLINE_ASSET)} at 1000000 XLM`, + label: `account1 rest sell ${TRUSTLINE_AMOUNT} ${assetLabel( + TRUSTLINE_ASSET, + )} at 1000000 XLM`, signer: account1.keypair, operations: [ Operation.manageSellOffer({ @@ -1292,7 +1409,9 @@ const CASES = { ); if (created.length !== 1) { throw new Error( - `Expected 1 new offer, found ${created.length} (${created.join(', ')})`, + `Expected 1 new offer, found ${created.length} (${created.join( + ', ', + )})`, ); } console.log(` offer ${created[0]}`); @@ -1329,7 +1448,9 @@ const CASES = { requireAccount(account1, 'account1'); if (isIssuer(account1, TRUSTLINE_ASSET)) { throw new Error( - `account1 issues ${assetLabel(TRUSTLINE_ASSET)} and cannot receive it`, + `account1 issues ${assetLabel( + TRUSTLINE_ASSET, + )} and cannot receive it`, ); } const line = findTrustline(account1.account, TRUSTLINE_ASSET); @@ -1382,7 +1503,9 @@ const CASES = { const saved = recall('offer-fill'); if (saved.received) { await submit({ - label: `account1 path-swap ${saved.received} ${assetLabel(TRUSTLINE_ASSET)} back to XLM`, + label: `account1 path-swap ${saved.received} ${assetLabel( + TRUSTLINE_ASSET, + )} back to XLM`, signer: account1.keypair, operations: [ pathSwapOp( @@ -1737,9 +1860,11 @@ function formatBalances(account) { if (row.asset_type === 'liquidity_pool_shares') { return ` LP ${row.liquidity_pool_id} ${row.balance}`; } - return ` ${row.asset_code}-${row.asset_issuer} ${row.balance} (limit ${row.limit}${ - row.sponsor ? `; sponsor ${row.sponsor}` : '' - }${row.is_authorized === false ? '; unauthorized' : ''})`; + return ` ${row.asset_code}-${row.asset_issuer} ${row.balance} (limit ${ + row.limit + }${row.sponsor ? `; sponsor ${row.sponsor}` : ''}${ + row.is_authorized === false ? '; unauthorized' : '' + })`; }) .join('\n'); } @@ -1837,6 +1962,102 @@ function wrapFeeBump(inner, feeSource) { return feeBump; } +/** + * @param {string} assetId - `stellar:/asset:CODE-ISSUER` or `CODE-ISSUER`. + * @returns {import('@stellar/stellar-sdk').Asset} + */ +function assetFromId(assetId) { + const slash = assetId.lastIndexOf('/'); + let reference = slash === -1 ? assetId : assetId.slice(slash + 1); + if (reference.startsWith('asset:')) { + reference = reference.slice('asset:'.length); + } + const separator = reference.includes(':') ? ':' : '-'; + const splitAt = reference.indexOf(separator); + const code = splitAt > 0 ? reference.slice(0, splitAt) : ''; + const issuer = splitAt > 0 ? reference.slice(splitAt + 1) : ''; + if (!code || code.length > 12 || !issuer.startsWith('G')) { + throw new Error( + `Invalid asset id "${assetId}". Use stellar:pubnet/asset:CODE-ISSUER or CODE-ISSUER`, + ); + } + return new Asset(code, issuer); +} + +/** + * @param {string} [assetId] - Omitted, `XLM`, slip44:148, or a classic asset id. + * @returns {import('@stellar/stellar-sdk').Asset} + */ +function assetFromOptionalId(assetId) { + if ( + assetId === undefined || + assetId === 'XLM' || + assetId === 'native' || + assetId === 'slip44:148' || + assetId.endsWith('/slip44:148') + ) { + return Asset.native(); + } + return assetFromId(assetId); +} + +/** + * @param {Accounts} accounts + * @param {string} address + * @returns {AccountBinding} + */ +function bindingForAddress(accounts, address) { + const binding = Object.values(accounts).find( + (entry) => entry?.keypair.publicKey() === address, + ); + if (!binding) { + throw new Error( + `No secret for ${address}. It must be STELLAR_SECRET_1, STELLAR_SECRET_2, or STELLAR_SECRET_SPONSORED.`, + ); + } + if (!binding.account) { + throw new Error(`${address} is not on chain`); + } + return binding; +} + +/** + * Submits changeTrust limit 0. Does not move a remaining balance. + * + * @param {object} params + * @param {Accounts} params.accounts + * @param {string} params.accountAddress + * @param {string} params.assetId + */ +async function removeTrustline({ accounts, accountAddress, assetId }) { + const binding = bindingForAddress(accounts, accountAddress); + const asset = assetFromId(assetId); + const label = assetLabel(asset); + const line = findTrustline(binding.account, asset); + if (!line.exists) { + console.log( + ` ${accountAddress} has no ${label} trustline; nothing to remove`, + ); + return; + } + if (Number(line.balance) > 0) { + throw new Error( + `${label} balance is ${line.balance}. Remove the trustline only when the balance is 0.`, + ); + } + + await confirmMainnet( + 'remove-trustline', + accountAddress, + `changeTrust ${label} limit 0`, + ); + await submitPlan({ + label: `${accountAddress}: remove ${label} trustline`, + signer: binding.keypair, + operations: [Operation.changeTrust({ asset, limit: '0' })], + }); +} + function getCase(name) { const testCase = CASES[name]; if (!testCase) { @@ -1861,7 +2082,8 @@ async function main() { list status setup - teardown `); + teardown + remove-trustline `); printCases(); return; } @@ -1878,6 +2100,15 @@ async function main() { return; } + if (command === 'remove-trustline') { + const [accountAddress, assetId] = process.argv.slice(3); + if (!accountAddress || !assetId) { + throw new Error('Usage: remove-trustline '); + } + await removeTrustline({ accounts, accountAddress, assetId }); + return; + } + if (!caseName) { throw new Error( `Case name required. Known: ${Object.keys(CASES).join(', ')}`, From 55559707ff5de7688010a1e2f5236fec7502c3d9 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:56:50 +0800 Subject: [PATCH 21/50] Update horizon-event-fixture.mjs --- .../scripts/horizon-event-fixture.mjs | 63 ++++++++++--------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs b/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs index 67d6c7cfc..03f69d8cb 100644 --- a/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs +++ b/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs @@ -14,7 +14,7 @@ * TRUSTLINE_AMOUNT default same as XLM_AMOUNT * TRUSTLINE_CODE default AQUA * TRUSTLINE_ISSUER default GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA - * AQUA_LIMIT default 1000 (non-zero limit update in trustline-metadata) + * TRUSTLINE_LIMIT default 1000 (non-zero limit update in trustline-metadata) * BASE_FEE_STROOPS default 100 * DRY_RUN=1 build + sign, print XDR, do not submit * YES=1 skip the interactive mainnet confirm @@ -37,7 +37,7 @@ * `claimable` uses the same asset id and defaults to XLM when it is omitted. * A classic asset uses TRUSTLINE_AMOUNT and adds account2's trustline when missing. * `trustline-limit` changes only account1's TRUSTLINE_ASSET limit (default AQUA). - * Pass a limit or omit it to use AQUA_LIMIT. The line must already exist. + * Pass a limit or omit it to use TRUSTLINE_LIMIT. The line must already exist. * Teardown restores it. * * Cases that need an id or an ephemeral key (claimable, offers, sponsored @@ -76,7 +76,6 @@ const BASE_FEE = Number(process.env.BASE_FEE_STROOPS ?? '100'); const DRY_RUN = process.env.DRY_RUN === '1'; const SKIP_CONFIRM = process.env.YES === '1'; -const AQUA = TRUSTLINE_ASSET; const EURC = new Asset( 'EURC', 'GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2', @@ -85,7 +84,7 @@ const PYUSD = new Asset( 'PYUSD', 'GDQE7IXJ4HUHV6RQHIUPRJSEZE4DRS5WY577O2FY6YQ5LVWZ7JZTU2V5', ); -const AQUA_LIMIT = process.env.AQUA_LIMIT ?? '1000'; +const TRUSTLINE_LIMIT = process.env.TRUSTLINE_LIMIT ?? '1000'; const server = new Horizon.Server(HORIZON_URL); @@ -93,7 +92,7 @@ const server = new Horizon.Server(HORIZON_URL); * `balance-move` / `balance-move-trustline` credit/debit existing accounts. * `trustline-metadata` covers add / limit-update / sponsored-create. * `fee-bump` wraps an account2 inner changeTrust; account1 pays the fee. - * `authorized` is issuer-only (`setTrustLineFlags`); skip for EURC/AQUA/PYUSD. + * `authorized` is issuer-only (`setTrustLineFlags`); skip for EURC, TRUSTLINE_ASSET, and PYUSD. * Clawback of a holder's balance is intentionally not a case. * `claimable-clawback` only runs when account1 is the issuer of TRUSTLINE_ASSET. */ @@ -633,15 +632,14 @@ async function bestBid(selling, buying) { /** @type {Record} */ const CASES = { test1: { - description: - 'Account 1: payment (XLM → account 2) + changeTrust enable AQUA in one tx', + description: `Account 1: payment (XLM → account 2) + changeTrust enable ${TRUSTLINE_ASSET.getCode()} in one tx`, async setup({ account1, account2 }, { submit }) { await submit({ - label: 'account1 payment + enable AQUA', + label: `account1 payment + enable ${assetLabel(TRUSTLINE_ASSET)}`, signer: account1.keypair, operations: [ nativePayment(account2.keypair.publicKey()), - Operation.changeTrust({ asset: AQUA }), + Operation.changeTrust({ asset: TRUSTLINE_ASSET }), ], }); }, @@ -652,9 +650,11 @@ const CASES = { operations: [nativePayment(account1.keypair.publicKey())], }); await submit({ - label: 'account1 remove AQUA trustline', + label: `account1 remove ${assetLabel(TRUSTLINE_ASSET)} trustline`, signer: account1.keypair, - operations: [Operation.changeTrust({ asset: AQUA, limit: '0' })], + operations: [ + Operation.changeTrust({ asset: TRUSTLINE_ASSET, limit: '0' }), + ], }); }, }, @@ -714,18 +714,17 @@ const CASES = { /** * One tx (account 1 source, both keys sign): * - add EURC trustline on account 1 - * - update AQUA limit (non-zero) on account 1 + * - update TRUSTLINE_ASSET limit (non-zero) on account 1 * - account 1 sponsors account 2 creating a PYUSD trustline * * Does not toggle `authorized` (issuer-only). */ 'trustline-metadata': { - description: - '1 tx: add EURC, update AQUA limit, sponsor PYUSD trustline on account2', + description: `1 tx: add EURC, update ${TRUSTLINE_ASSET.getCode()} limit, sponsor PYUSD trustline on account2`, async setup({ account1, account2 }, { submit }) { const dest2 = account2.keypair.publicKey(); const eurcLine = findTrustline(account1.account, EURC); - const aquaLine = findTrustline(account1.account, AQUA); + const heldLine = findTrustline(account1.account, TRUSTLINE_ASSET); const pyusdLine = findTrustline(account2.account, PYUSD); if (pyusdLine.exists) { @@ -743,15 +742,19 @@ const CASES = { operations.push(Operation.changeTrust({ asset: EURC })); } - const aquaLimit = nonZeroLimitAtLeastBalance(aquaLine, AQUA_LIMIT); - if (!aquaLine.exists) { - operations.push(Operation.changeTrust({ asset: AQUA })); - console.log(` AQUA missing: add then set limit ${aquaLimit}`); + const heldLimit = nonZeroLimitAtLeastBalance(heldLine, TRUSTLINE_LIMIT); + if (!heldLine.exists) { + operations.push(Operation.changeTrust({ asset: TRUSTLINE_ASSET })); + console.log( + ` ${assetLabel( + TRUSTLINE_ASSET, + )} missing: add then set limit ${heldLimit}`, + ); } operations.push( Operation.changeTrust({ - asset: AQUA, - limit: aquaLimit, + asset: TRUSTLINE_ASSET, + limit: heldLimit, }), ); @@ -765,7 +768,9 @@ const CASES = { ); await submit({ - label: `account1+2: add EURC, AQUA limit ${aquaLimit}, sponsor PYUSD on account2`, + label: `account1+2: add EURC, ${assetLabel( + TRUSTLINE_ASSET, + )} limit ${heldLimit}, sponsor PYUSD on account2`, signer: account1.keypair, signers: [account2.keypair], operations, @@ -785,14 +790,16 @@ const CASES = { account1Ops.push(Operation.changeTrust({ asset: EURC, limit: '0' })); } - const aquaLine = findTrustline(account1.account, AQUA); - if (aquaLine.exists) { - account1Ops.push(Operation.changeTrust({ asset: AQUA })); + const heldLine = findTrustline(account1.account, TRUSTLINE_ASSET); + if (heldLine.exists) { + account1Ops.push(Operation.changeTrust({ asset: TRUSTLINE_ASSET })); } if (account1Ops.length > 0) { await submit({ - label: 'account1: remove EURC if empty, restore AQUA limit to max', + label: `account1: remove EURC if empty, restore ${assetLabel( + TRUSTLINE_ASSET, + )} limit to max`, signer: account1.keypair, operations: account1Ops, }); @@ -833,7 +840,7 @@ const CASES = { * Teardown sets the limit back to the value saved at setup. */ 'trustline-limit': { - description: `change only account1's ${TRUSTLINE_ASSET.getCode()} trustline limit (optional arg, default ${AQUA_LIMIT}); teardown restores it`, + description: `change only account1's ${TRUSTLINE_ASSET.getCode()} trustline limit (optional arg, default ${TRUSTLINE_LIMIT}); teardown restores it`, async setup({ account1 }, { submit }) { requireAccount(account1, 'account1'); const asset = TRUSTLINE_ASSET; @@ -845,7 +852,7 @@ const CASES = { const requested = process.argv[4]; const nextLimit = requested === undefined - ? nonZeroLimitAtLeastBalance(line, AQUA_LIMIT) + ? nonZeroLimitAtLeastBalance(line, TRUSTLINE_LIMIT) : requestedLimit(requested, line, label); await submit({ label: `account1: changeTrust ${label} limit ${line.limit} -> ${nextLimit}`, From c75da39ea3801e1c1f5b8139624c1a16a891eb42 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:32:06 +0800 Subject: [PATCH 22/50] chore: clean up --- eslint.config.mjs | 19 ++++++++++++ packages/stellar-wallet-snap/CHANGELOG.md | 6 ++++ packages/stellar-wallet-snap/package.json | 2 +- .../scripts/export-stellar-keys-from-srp.mjs | 10 +++--- .../scripts/horizon-event-fixture.mjs | 6 ++-- .../stellar-wallet-snap/src/config.test.ts | 8 ++--- packages/stellar-wallet-snap/src/config.ts | 4 +-- .../src/handlers/keyring/keyring.test.ts | 6 ++-- .../src/handlers/keyring/keyring.ts | 31 +++++++++---------- .../src/services/assets/api.ts | 1 - .../OnChainAccountService.test.ts | 16 +++++----- .../on-chain-account/OnChainAccountService.ts | 12 +++---- yarn.lock | 2 +- 13 files changed, 75 insertions(+), 48 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 5b0b91a48..2e450df8f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -232,6 +232,25 @@ const config = createConfig([ sourceType: 'module', }, }, + { + // Local operator CLIs. They read env, keep a sync state file, and prompt + // on stdin. eslint-plugin-n still marks `readline/promises` experimental + // on the Node 22 range this repo supports. + files: [ + 'packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs', + 'packages/stellar-wallet-snap/scripts/export-stellar-keys-from-srp.mjs', + ], + rules: { + 'n/no-process-env': 'off', + 'n/no-sync': 'off', + 'n/no-unsupported-features/node-builtins': 'off', + 'require-atomic-updates': 'off', + 'jsdoc/require-description': 'off', + 'jsdoc/require-param-description': 'off', + 'jsdoc/require-returns-description': 'off', + 'jsdoc/require-property-description': 'off', + }, + }, // Prevent cross-package relative imports { files: ['packages/*/src/**/*.ts'], diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 413103a3d..e6db90f8d 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Read Stellar account holdings and asset metadata from Core when the Stellar snaps assets migration flag is not off ([#331](https://github.com/MetaMask/internal-snaps/pull/331)) + - Request `endowment:messenger` for `RemoteFeatureFlagController:getState` and AssetsController account-asset and metadata actions. + - Include SEP-41 balances on keyring `getAccountAssets` and `getAccountBalances`. + ## [1.3.0] ### Added diff --git a/packages/stellar-wallet-snap/package.json b/packages/stellar-wallet-snap/package.json index 255ae5e5b..5e0c1f54a 100644 --- a/packages/stellar-wallet-snap/package.json +++ b/packages/stellar-wallet-snap/package.json @@ -54,7 +54,7 @@ "@metamask/keyring-api": "^24.1.0", "@metamask/keyring-snap-sdk": "^10.0.0", "@metamask/messenger": "^2.0.0", - "@metamask/remote-feature-flag-controller": "^5.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/scripts/export-stellar-keys-from-srp.mjs b/packages/stellar-wallet-snap/scripts/export-stellar-keys-from-srp.mjs index 5c1e88695..0fadc5c0a 100644 --- a/packages/stellar-wallet-snap/scripts/export-stellar-keys-from-srp.mjs +++ b/packages/stellar-wallet-snap/scripts/export-stellar-keys-from-srp.mjs @@ -15,7 +15,6 @@ import { SLIP10Node } from '@metamask/key-tree'; import { Keypair } from '@stellar/stellar-sdk'; -import { stdin as input, stdout as output } from 'node:process'; import { createInterface } from 'node:readline/promises'; const DEFAULT_INDICES = [0, 1]; @@ -42,7 +41,10 @@ async function readMnemonic() { return fromEnv.replace(/\s+/gu, ' '); } - const rl = createInterface({ input, output }); + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); const typed = await rl.question( 'Paste SRP (spaces between words; input is not saved): ', ); @@ -67,8 +69,8 @@ async function deriveAtIndex(mnemonic, index) { if (!node.privateKey) { throw new Error(`No private key at m/44'/148'/${index}'`); } - const hex = node.privateKey.replace(/^0x/iu, ''); - const seed = Uint8Array.from(Buffer.from(hex, 'hex')); + const privateKeyHex = node.privateKey.replace(/^0x/iu, ''); + const seed = Uint8Array.from(Buffer.from(privateKeyHex, 'hex')); return Keypair.fromRawEd25519Seed(seed); } diff --git a/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs b/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs index 03f69d8cb..1aaff8e9e 100644 --- a/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs +++ b/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs @@ -59,7 +59,6 @@ import { TransactionBuilder, } from '@stellar/stellar-sdk'; import { readFileSync, writeFileSync } from 'node:fs'; -import { stdin as input, stdout as output } from 'node:process'; import { createInterface } from 'node:readline/promises'; const HORIZON_URL = process.env.HORIZON_URL ?? 'https://horizon.stellar.org'; @@ -1914,7 +1913,10 @@ async function confirmMainnet(command, caseName, description) { console.log( `\nMAINNET ${command} ${caseName}: ${description}\nType "mainnet" to broadcast.`, ); - const rl = createInterface({ input, output }); + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); const answer = await rl.question('> '); rl.close(); if (answer.trim() !== 'mainnet') { diff --git a/packages/stellar-wallet-snap/src/config.test.ts b/packages/stellar-wallet-snap/src/config.test.ts index 63fce2d9e..ce1827da0 100644 --- a/packages/stellar-wallet-snap/src/config.test.ts +++ b/packages/stellar-wallet-snap/src/config.test.ts @@ -54,7 +54,7 @@ const FULL_ENVIRONMENT = { loadOnChainAccount: '30000', simulateTransaction: '8000', sep41AssetBalance: '15000', - keyringLiveAccount: '20000', + fetchOnChainAccount: '20000', assetMetadata: '900000', }, }, @@ -254,7 +254,7 @@ describe('ConfigProvider', () => { loadOnChainAccount: 30000, simulateTransaction: 8000, sep41AssetBalance: 15000, - keyringLiveAccount: 20000, + fetchOnChainAccount: 20000, assetMetadata: 900000, }); }); @@ -270,7 +270,7 @@ describe('ConfigProvider', () => { loadOnChainAccount: '', simulateTransaction: undefined, sep41AssetBalance: '', - keyringLiveAccount: undefined, + fetchOnChainAccount: undefined, assetMetadata: '', }, }, @@ -284,7 +284,7 @@ describe('ConfigProvider', () => { loadOnChainAccount: 10 * 60 * 1000, simulateTransaction: 10 * 1000, sep41AssetBalance: 30 * 1000, - keyringLiveAccount: 30 * 1000, + fetchOnChainAccount: 30 * 1000, assetMetadata: 60 * 60 * 1000, }); }); diff --git a/packages/stellar-wallet-snap/src/config.ts b/packages/stellar-wallet-snap/src/config.ts index 966f90bdc..27896b4fa 100644 --- a/packages/stellar-wallet-snap/src/config.ts +++ b/packages/stellar-wallet-snap/src/config.ts @@ -113,7 +113,7 @@ export const ConfigStruct = object({ // SEP-41 balance reads (multicall on mainnet) sep41AssetBalance: parseIntegerStruct(1000, 30 * 1000), // Keyring getAccountAssets result reused by the following getAccountBalances call - keyringLiveAccount: parseIntegerStruct(1000, 30 * 1000), + fetchOnChainAccount: parseIntegerStruct(1000, 30 * 1000), // 1 hour — token API asset catalog. Account sync hot-reloads when older. assetMetadata: parseIntegerStruct(1000, 60 * 60 * 1000), }), @@ -185,7 +185,7 @@ const ENVIRONMENT = { process.env.STELLAR_SIMULATE_TRANSACTION_TTL_MILLISECONDS, sep41AssetBalance: process.env.STELLAR_SEP41_ASSET_BALANCE_TTL_MILLISECONDS, - keyringLiveAccount: + fetchOnChainAccount: process.env.STELLAR_KEYRING_LIVE_ACCOUNT_TTL_MILLISECONDS, assetMetadata: process.env.STELLAR_ASSET_METADATA_TTL_MILLISECONDS, }, diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index bde4558b9..a828fab11 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -461,7 +461,7 @@ describe('KeyringHandler', () => { expect(resolveOnChainAccountSpy).toHaveBeenCalledWith( mockAccount.address, KnownCaip2ChainId.Mainnet, - { resolveWithFullBalance: true }, + { fetchWithSep41Balances: true }, ); expect(result).toStrictEqual(onChainAccount.assetIds); }); @@ -602,7 +602,7 @@ describe('KeyringHandler', () => { expect(resolveOnChainAccountSpy).toHaveBeenCalledWith( mockAccount.address, KnownCaip2ChainId.Mainnet, - { resolveWithFullBalance: true }, + { fetchWithSep41Balances: true }, ); expect(result).toStrictEqual({ [slipId]: { @@ -652,7 +652,7 @@ describe('KeyringHandler', () => { await keyringHandler.getAccountAssets(mockAccountId); nowSpy.mockReturnValue( - 1_000 + AppConfig.cache.ttlMilliseconds.keyringLiveAccount + 1, + 1_000 + AppConfig.cache.ttlMilliseconds.fetchOnChainAccount + 1, ); resolveOnChainAccountSpy.mockClear(); diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index aafe26134..106484d5e 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -98,7 +98,7 @@ export class KeyringHandler implements KeyringSnapRpc { readonly #handlers: Record; - readonly #liveOnChainAccountCache: InMemoryCache; + readonly #fetchOnChainAccountCache: InMemoryCache; constructor({ logger, @@ -116,7 +116,7 @@ export class KeyringHandler implements KeyringSnapRpc { handlers: Record; }) { this.#logger = logger.withPrefix(KEYRING_HANDLER_LOGGER_PREFIX); - this.#liveOnChainAccountCache = new InMemoryCache(this.#logger); + this.#fetchOnChainAccountCache = new InMemoryCache(this.#logger); this.#accountService = accountService; this.#onChainAccountService = onChainAccountService; this.#transactionService = transactionService; @@ -128,14 +128,12 @@ export class KeyringHandler implements KeyringSnapRpc { this.#logger.debug('Handle keyring request', { origin, method: request.method, - request: JSON.stringify(request), }); validateOrigin(origin, request.method, originPermissions); const result = await handleKeyringRequest(this, request); this.#logger.debug('Keyring request handled', { origin, method: request.method, - result: JSON.stringify(result), }); return result ?? null; } @@ -221,7 +219,7 @@ export class KeyringHandler implements KeyringSnapRpc { validateRequest(accountId, ListAccountAssetsRequestStruct); const scope = AppConfig.selectedNetwork; - const onChainAccount = await this.#resolveAccountByAccountId({ + const onChainAccount = await this.#fetchOnChainAccountByAccountId({ accountId, scope, // Always refresh the cache to get the latest on-chain account. @@ -329,7 +327,7 @@ export class KeyringHandler implements KeyringSnapRpc { const scope = AppConfig.selectedNetwork; const assetBalances = {} as Record; - const onChainAccount = await this.#resolveAccountByAccountId({ + const onChainAccount = await this.#fetchOnChainAccountByAccountId({ accountId, scope, refresh: false, @@ -344,12 +342,13 @@ export class KeyringHandler implements KeyringSnapRpc { return assetBalances; } - // `#resolveAccountByAccountId` returns watched assets balance from Tokens API. - // But, the watched assets from Tokens API can omit SEP-41 ids the client asked for. + // `#fetchOnChainAccountByAccountId` returns watched assets balance from Tokens API. + // But, the watched assets from Tokens API may not contain the SEP-41 ids the client asked for. const missingSep41AssetIds = knownAssets.filter( (assetId): assetId is KnownCaip19Sep41AssetId => isSep41Id(assetId) && onChainAccount.getRawAsset(assetId) === undefined, ); + // Try to fetch the SEP-41 balances for the missing SEP-41 asset ids. if (missingSep41AssetIds.length > 0) { await this.#onChainAccountService.fetchAndBindSep41Balances( onChainAccount, @@ -510,7 +509,7 @@ export class KeyringHandler implements KeyringSnapRpc { * Loads a Horizon account for the keyring asset/balance pair. * * `getAccountAssets` always refreshes and stores the result. `getAccountBalances` - * reuses that entry until `AppConfig.cache.ttlMilliseconds.keyringLiveAccount`, then loads Horizon. + * reuses that entry until `AppConfig.cache.ttlMilliseconds.fetchOnChainAccount`, then loads Horizon. * A failed load is not cached. An unactivated account is cached as `null`. * * @param params - Account id, scope, and whether to skip the cache. @@ -519,7 +518,7 @@ export class KeyringHandler implements KeyringSnapRpc { * @param params.refresh - When true, load Horizon and replace the cache entry. * @returns The live account, or `null` when it is not activated. */ - async #resolveAccountByAccountId({ + async #fetchOnChainAccountByAccountId({ accountId, scope, refresh, @@ -532,12 +531,12 @@ export class KeyringHandler implements KeyringSnapRpc { accountId, }); const serialized = await useCache( - this.#resolveOnChainAccount.bind(this), - this.#liveOnChainAccountCache, + this.#fetchOnChainAccount.bind(this), + this.#fetchOnChainAccountCache, { logger: this.#logger, - functionName: 'KeyringHandler:resolveOnChainAccount', - ttlMilliseconds: AppConfig.cache.ttlMilliseconds.keyringLiveAccount, + functionName: 'KeyringHandler:fetchOnChainAccount', + ttlMilliseconds: AppConfig.cache.ttlMilliseconds.fetchOnChainAccount, refreshCache: refresh, }, )(account.address, scope); @@ -549,7 +548,7 @@ export class KeyringHandler implements KeyringSnapRpc { return OnChainAccount.fromSerializable(serialized); } - async #resolveOnChainAccount( + async #fetchOnChainAccount( accountAddress: string, scope: KnownCaip2ChainId, ): Promise { @@ -558,7 +557,7 @@ export class KeyringHandler implements KeyringSnapRpc { await this.#onChainAccountService.resolveOnChainAccount( accountAddress, scope, - { resolveWithFullBalance: true }, + { fetchWithSep41Balances: true }, ); return onChainAccount.toSerializableFull(); } catch (error: unknown) { diff --git a/packages/stellar-wallet-snap/src/services/assets/api.ts b/packages/stellar-wallet-snap/src/services/assets/api.ts index 34710a90b..fa9d4ca0b 100644 --- a/packages/stellar-wallet-snap/src/services/assets/api.ts +++ b/packages/stellar-wallet-snap/src/services/assets/api.ts @@ -154,7 +154,6 @@ function isZeroTrustlineLimit(limit: string): boolean { * - If it is an integer, it is returned unchanged. * * @param limit - Trustline limit. - * @param decimals - Asset decimals used to scale a display amount. * @returns Integer stroop string. */ function normalizeCoreTrustlineLimit(limit: string): string { diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index 93b5aa4c3..6de213a42 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -227,7 +227,7 @@ describe('OnChainAccountService', () => { ).rejects.toThrow(DerivedAccountAddressMismatchException); }); - it('binds SEP-41 balances including zero when resolveWithFullBalance is set', async () => { + it('binds SEP-41 balances including zero when fetchWithSep41Balances is set', async () => { const { accountAddress, scope, @@ -245,7 +245,7 @@ describe('OnChainAccountService', () => { const result = await onChainAccountService.resolveOnChainAccount( accountAddress, scope, - { resolveWithFullBalance: true }, + { fetchWithSep41Balances: true }, ); expect(getSep41AssetBalancesSpy).toHaveBeenCalledWith({ @@ -274,7 +274,7 @@ describe('OnChainAccountService', () => { const result = await onChainAccountService.resolveOnChainAccount( accountAddress, scope, - { resolveWithFullBalance: true }, + { fetchWithSep41Balances: true }, ); expect(result.accountId).toStrictEqual(accountAddress); @@ -295,7 +295,7 @@ describe('OnChainAccountService', () => { const result = await onChainAccountService.resolveOnChainAccount( accountAddress, scope, - { resolveWithFullBalance: true }, + { fetchWithSep41Balances: true }, ); expect(getSep41AssetBalancesSpy).toHaveBeenCalledWith({ @@ -316,7 +316,7 @@ describe('OnChainAccountService', () => { await expect( onChainAccountService.resolveOnChainAccount(accountAddress, scope, { - resolveWithFullBalance: true, + fetchWithSep41Balances: true, }), ).rejects.toThrow( new OnChainAccountSep41BalanceNotFoundException(accountAddress), @@ -337,7 +337,7 @@ describe('OnChainAccountService', () => { const result = await onChainAccountService.resolveOnChainAccount( accountAddress, scope, - { resolveWithFullBalance: true }, + { fetchWithSep41Balances: true }, ); expect(result.getRawAsset(USDC_SEP41)).toBeUndefined(); @@ -357,7 +357,7 @@ describe('OnChainAccountService', () => { const result = await onChainAccountService.resolveOnChainAccount( accountAddress, scope, - { resolveWithFullBalance: true }, + { fetchWithSep41Balances: true }, ); expect(result.getRawAsset(USDC_SEP41)?.balance.toFixed()).toBe('1'); @@ -372,7 +372,7 @@ describe('OnChainAccountService', () => { await expect( onChainAccountService.resolveOnChainAccount(accountAddress, scope, { - resolveWithFullBalance: true, + fetchWithSep41Balances: true, }), ).rejects.toThrow('Failed to load SEP-41 token balance'); }); diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 69e2d8761..c75056de0 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -115,19 +115,19 @@ export class OnChainAccountService { * @param accountAddress - Stellar address (strkey) expected to match Horizon `account_id`. * @param scope - CAIP-2 network to load the account from (Horizon `loadAccount`). * @param options - Optional extra loads. - * @param options.resolveWithFullBalance - When true, also read SEP-41 balances for the + * @param options.fetchWithSep41Balances - When true, also read SEP-41 balances for the * persisted catalog and bind them onto the Horizon account. * @returns Loaded {@link OnChainAccount} for simulation, fees, and sequence. * @throws {AccountNotActivatedException} When the account is not funded (from {@link NetworkService.loadOnChainAccount}). * @throws {DerivedAccountAddressMismatchException} When loaded id does not match `accountAddress`. - * @throws {OnChainAccountSep41BalanceNotFoundException} When `resolveWithFullBalance` is set and + * @throws {OnChainAccountSep41BalanceNotFoundException} When `fetchWithSep41Balances` is set and * mainnet returns no SEP-41 map for the account. */ async resolveOnChainAccount( accountAddress: string, scope: KnownCaip2ChainId, options?: { - resolveWithFullBalance?: boolean; + fetchWithSep41Balances?: boolean; }, ): Promise { const loaded = await this.#networkService.loadOnChainAccount( @@ -137,7 +137,7 @@ export class OnChainAccountService { assertSameAddress(accountAddress, loaded.accountId); - if (options?.resolveWithFullBalance) { + if (options?.fetchWithSep41Balances) { await this.#bindSep41Balances(loaded, scope); } @@ -183,7 +183,7 @@ export class OnChainAccountService { const sep41Assets = assetIds === undefined ? await this.#assetMetadataService.fetchSep41AssetsOrSyncOnce(scope) - : await this.#assetMetadataService.fetchSep41AssetsMetadata(assetIds); + : await this.#assetMetadataService.fetchSep41AssetsByAssetIds(assetIds); const sep41AssetIds: KnownCaip19Sep41AssetId[] = []; const assetMetadataByAssetId: Record< @@ -340,7 +340,7 @@ export class OnChainAccountService { if (assets.length === 0) { // If the account has no assets from core, - // Possiblly not indexed, or not activated + // Possibly not indexed, or not activated // Return null to categorize it as not activated return null; } diff --git a/yarn.lock b/yarn.lock index bd36407ec..7c877e5bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3910,7 +3910,7 @@ __metadata: "@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:^5.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" From 97053f7b6ec252862430ee17f5e9110dc05dec06 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:34:10 +0800 Subject: [PATCH 23/50] Update config.ts --- packages/stellar-wallet-snap/src/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stellar-wallet-snap/src/config.ts b/packages/stellar-wallet-snap/src/config.ts index 27896b4fa..df5ad5954 100644 --- a/packages/stellar-wallet-snap/src/config.ts +++ b/packages/stellar-wallet-snap/src/config.ts @@ -130,8 +130,8 @@ export type Config = Infer; * replaced with its build-time value (see `snap.config.ts`). */ const ENVIRONMENT = { - environment: 'production', - logLevel: 'debug', + environment: process.env.ENVIRONMENT, + logLevel: process.env.LOG_LEVEL, networks: { [KnownCaip2ChainId.Mainnet]: { rpcUrl: process.env.STELLAR_RPC_URL_MAINNET, From ace48bf0906cd68c58bcf5c7c9ad9d736e999db5 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:36:06 +0800 Subject: [PATCH 24/50] Update yarn.lock --- yarn.lock | 173 ++++++++++++++++++++++-------------------------------- 1 file changed, 69 insertions(+), 104 deletions(-) diff --git a/yarn.lock b/yarn.lock index 7c877e5bf..9c825e7da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7593,12 +7593,12 @@ __metadata: languageName: node linkType: hard -"baseline-browser-mapping@npm:^2.10.12": - version: 2.10.32 - resolution: "baseline-browser-mapping@npm:2.10.32" +"baseline-browser-mapping@npm:^2.11.26": + version: 2.11.28 + resolution: "baseline-browser-mapping@npm:2.11.28" bin: baseline-browser-mapping: dist/cli.cjs - checksum: 10/65e8fa5ae614ed266d24e69aab517e46a7da5bbe16c46fa8373ae22007ab9f492c05d7308240e0a8b55bd0e899c67fd82a0e225f78ecb7e9164f23b0791f2784 + checksum: 10/8561ad5d30b36e17d1c2b7c6e492eb4a6792d3441335669f7e95806149d759b6af3fc9f151586a65044636e65cf848b15c83fdd34c86d22d0da970c905fc7ac8 languageName: node linkType: hard @@ -7826,21 +7826,12 @@ __metadata: linkType: hard "brace-expansion@npm:^1.1.7": - version: 1.1.11 - resolution: "brace-expansion@npm:1.1.11" + version: 1.1.21 + resolution: "brace-expansion@npm:1.1.21" dependencies: balanced-match: "npm:^1.0.0" concat-map: "npm:0.0.1" - checksum: 10/faf34a7bb0c3fcf4b59c7808bc5d2a96a40988addf2e7e09dfbb67a2251800e0d14cd2bfc1aa79174f2f5095c54ff27f46fb1289fe2d77dac755b5eb3434cc07 - languageName: node - linkType: hard - -"brace-expansion@npm:^2.0.1": - version: 2.1.0 - resolution: "brace-expansion@npm:2.1.0" - dependencies: - balanced-match: "npm:^1.0.0" - checksum: 10/c77a7a64aabf94b8d5913955adb4f36957917565374461355bb4276830c027a313d981f32410cea9e38f52573e7eb776d02fe05091c3a79a061958d97e4d2b43 + checksum: 10/6c0ed34107cb3bd3f44c01d90ec8c01baa3ed75b7755617e2e5797b5908bbf20b313edf575612b200a783b84c7d1826aec2f7a2f03de9905bbfae5dc3dea6a95 languageName: node linkType: hard @@ -7953,17 +7944,17 @@ __metadata: linkType: hard "browserslist@npm:^4.24.0, browserslist@npm:^4.28.1": - version: 4.28.2 - resolution: "browserslist@npm:4.28.2" - dependencies: - baseline-browser-mapping: "npm:^2.10.12" - caniuse-lite: "npm:^1.0.30001782" - electron-to-chromium: "npm:^1.5.328" - node-releases: "npm:^2.0.36" - update-browserslist-db: "npm:^1.2.3" + version: 4.29.3 + resolution: "browserslist@npm:4.29.3" + dependencies: + baseline-browser-mapping: "npm:^2.11.26" + caniuse-lite: "npm:^1.0.30001813" + electron-to-chromium: "npm:^1.5.439" + node-releases: "npm:^2.0.57" + update-browserslist-db: "npm:^1.3.3" bin: browserslist: cli.js - checksum: 10/cff88386e5b5ba5614c9063bd32ef94865bba22b6a381844c7d09ea1eea62a2247e7106e516abdbfda6b75b9986044c991dfe45f92f10add5ad63dccc07589ec + checksum: 10/9649d4d73fff11f7c0b19948f08f9f287c184feb0965ac0b489cee7e1a9f430e747809db0d6014a30fdbcc42755e7a4dd047dfd7e28f62982072dcd0f8291eb0 languageName: node linkType: hard @@ -8167,10 +8158,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.30001782": - version: 1.0.30001793 - resolution: "caniuse-lite@npm:1.0.30001793" - checksum: 10/5a1ac39f2f174e86d8320f394a5dfbeab98041722b13d02a21fe47afc2723bc754ea3716a1f276f8462bcbbc3016e82e6f155ebde0881e537af463b5eac1816b +"caniuse-lite@npm:^1.0.30001813": + version: 1.0.30001815 + resolution: "caniuse-lite@npm:1.0.30001815" + checksum: 10/975bc77d07201295d56ce25c0f796bede1b7c2447c46c4a4b1819cc67de3a505897a3e22926bb5195cf5cd4c31c32aed90fa2d8533a10e379c0a6a16916a8380 languageName: node linkType: hard @@ -8456,19 +8447,19 @@ __metadata: linkType: hard "concurrently@npm:^10.0.3": - version: 10.0.3 - resolution: "concurrently@npm:10.0.3" + version: 10.0.6 + resolution: "concurrently@npm:10.0.6" dependencies: chalk: "npm:5.6.2" rxjs: "npm:7.8.2" - shell-quote: "npm:1.8.4" + shell-quote: "npm:1.12.0" supports-color: "npm:10.2.2" tree-kill: "npm:1.2.2" yargs: "npm:18.0.0" bin: conc: dist/bin/index.js concurrently: dist/bin/index.js - checksum: 10/ef1ffc31df0df3663af33fb39c4f7f40b5e84affc0d397537b586e7cfedefeb513b643273afbbf00bbc252b8539c336dc854afb52f6cb74e6dbe3e5c812bd256 + checksum: 10/52b7817ff389036e963a2f1a2b0e1180e76a85a169e067c497c7cc3c6e3da93617c69ae44440c9e62ace07d87326e5b9edd304eba564f41fa8fa3843df0c4b74 languageName: node linkType: hard @@ -9014,10 +9005,10 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.5.328": - version: 1.5.361 - resolution: "electron-to-chromium@npm:1.5.361" - checksum: 10/1144dc40c7e0588c179fbc64ac97215a9af897f95137191a857961f2ea889126a8ccc412bab35990906cf65645251b45245403f91a7cd293a970c22cbb58a905 +"electron-to-chromium@npm:^1.5.439": + version: 1.5.452 + resolution: "electron-to-chromium@npm:1.5.452" + checksum: 10/0c256b0f0a48afe69f57f8074319a0777599a429d0b71e895e6bcd6ca5ff979a2f6afa2b3bc57921126dd4e2af1a7fa4c999f5c652a22e5ac54080dcdd31ac4d languageName: node linkType: hard @@ -10054,9 +10045,9 @@ __metadata: linkType: hard "fast-uri@npm:^3.0.1": - version: 3.1.2 - resolution: "fast-uri@npm:3.1.2" - checksum: 10/1dff04865b2a38d3e0659deadfbf72efdf83a776bfbf9667e4aa9e5a3ec31bc341cda9622136b32b7652a857c8ba11896794186e8f876f8b2b72731fce8622f6 + version: 3.1.8 + resolution: "fast-uri@npm:3.1.8" + checksum: 10/d3dfa29fd2e5fa2371833699f3f78c975b79f65e52e0ce37604db10dffb85b5b448b5075e93453272cb95fb1c6a759495164e60649ffd69e59ec43f20d44f791 languageName: node linkType: hard @@ -10237,9 +10228,9 @@ __metadata: linkType: hard "flatted@npm:^3.2.9": - version: 3.3.1 - resolution: "flatted@npm:3.3.1" - checksum: 10/7b8376061d5be6e0d3658bbab8bde587647f68797cf6bfeae9dea0e5137d9f27547ab92aaff3512dd9d1299086a6d61be98e9d48a56d17531b634f77faadbc49 + version: 3.4.4 + resolution: "flatted@npm:3.4.4" + checksum: 10/5a6682ab87ed15e125419c61352c4bef26f310adceece03eae5f22c119d64ed47bfc0ee1948a77d0122559eaa86395ea06ef45b59bfe3db4fb13fb4cdff876de languageName: node linkType: hard @@ -10611,10 +10602,10 @@ __metadata: linkType: hard "handlebars@npm:^4.7.9": - version: 4.7.9 - resolution: "handlebars@npm:4.7.9" + version: 4.7.10 + resolution: "handlebars@npm:4.7.10" dependencies: - minimist: "npm:^1.2.5" + minimist: "npm:^1.2.8" neo-async: "npm:^2.6.2" source-map: "npm:^0.6.1" uglify-js: "npm:^3.1.4" @@ -10624,7 +10615,7 @@ __metadata: optional: true bin: handlebars: bin/handlebars - checksum: 10/e755433d652e8a15fc02f83d7478e652359e7a4d354c4328818853ed4f8a39d4a09e1d22dad3c7213c5240864a65b3c840970b8b181745575dd957dd258f2b8d + checksum: 10/e7df9b9986594384de11ea27d2f9c694f90dca9ff768ac28851b4cf89f6268a3177ec50c9bb1601c8841f67bee25598b37bdcd53d1f7acd3744619f938ad1f53 languageName: node linkType: hard @@ -12060,25 +12051,25 @@ __metadata: linkType: hard "js-yaml@npm:^3.13.1, js-yaml@npm:^3.14.1": - version: 3.14.1 - resolution: "js-yaml@npm:3.14.1" + version: 3.15.2 + resolution: "js-yaml@npm:3.15.2" dependencies: argparse: "npm:^1.0.7" esprima: "npm:^4.0.0" bin: js-yaml: bin/js-yaml.js - checksum: 10/9e22d80b4d0105b9899135365f746d47466ed53ef4223c529b3c0f7a39907743fdbd3c4379f94f1106f02755b5e90b2faaf84801a891135544e1ea475d1a1379 + checksum: 10/320b5471c1b7309a15d9c0068ba273e2da7382a369038a9746c92ef649087c3d6d2a5c676810154efb4eb2ca9584179c8ae14e2fc2e4bd558d964e6bf4fafd3d languageName: node linkType: hard "js-yaml@npm:^4.1.0, js-yaml@npm:^4.1.1": - version: 4.2.0 - resolution: "js-yaml@npm:4.2.0" + version: 4.3.2 + resolution: "js-yaml@npm:4.3.2" dependencies: argparse: "npm:^2.0.1" bin: js-yaml: bin/js-yaml.js - checksum: 10/51de2067a2b44b07ba5206132e56005f8b568ff279bb4d2f645068958c56fa4827d40a6841c983234671fa0a134bf094d0b0717873c2a3d319185297af145a6d + checksum: 10/05c44b9c73e4901d92703b155e76518df64bf01ac62e4c036b47de4b391e19b72e32656e8954d51b436307f08cc9d0c0d4ec617d061cf2f65fffee9f3114bee7 languageName: node linkType: hard @@ -12624,11 +12615,11 @@ __metadata: linkType: hard "minimatch@npm:^7.4.6": - version: 7.4.6 - resolution: "minimatch@npm:7.4.6" + version: 7.4.9 + resolution: "minimatch@npm:7.4.9" dependencies: - brace-expansion: "npm:^2.0.1" - checksum: 10/0046ba1161ac6414bde1b07c440792ebcdb2ed93e6714c85c73974332b709b7e692801550bc9da22028a8613407b3f13861e17dd0dd44f4babdeacd44950430b + brace-expansion: "npm:^2.0.2" + checksum: 10/9bc60b593dafb71d68b1a671a0c1a4bb9a71ef2f8daa8ed4b8b6199bb2d522163fb2e94d82ca40518eaa3b00218f587ad7ab2ed40e56e4c57a8bddb6c2bd1d27 languageName: node linkType: hard @@ -12641,7 +12632,7 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.2.5": +"minimist@npm:^1.2.8": version: 1.2.8 resolution: "minimist@npm:1.2.8" checksum: 10/908491b6cc15a6c440ba5b22780a0ba89b9810e1aea684e253e43c4e3b8d56ec1dcdd7ea96dde119c29df59c936cde16062159eae4225c691e19c70b432b6e6f @@ -12721,7 +12712,7 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^3.3.10, nanoid@npm:^3.3.8": +"nanoid@npm:^3.3.10, nanoid@npm:^3.3.19, nanoid@npm:^3.3.8": version: 3.3.20 resolution: "nanoid@npm:3.3.20" bin: @@ -12730,15 +12721,6 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^3.3.12": - version: 3.3.12 - resolution: "nanoid@npm:3.3.12" - bin: - nanoid: bin/nanoid.cjs - checksum: 10/6eec280694e2088d18fb802b1e3bfc4578e27b665b7ecfbe36c7356612fea2f814277056e671e2a1529dff551588a652efdc0bfa39f8a3185bc2247be311872e - languageName: node - linkType: hard - "napi-postinstall@npm:^0.3.4": version: 0.3.4 resolution: "napi-postinstall@npm:0.3.4" @@ -12884,10 +12866,10 @@ __metadata: languageName: node linkType: hard -"node-releases@npm:^2.0.36": - version: 2.0.46 - resolution: "node-releases@npm:2.0.46" - checksum: 10/47a1fd18bc8330282c3e628ea38629dd137e9f1f16d7b7d3bb033ea24f5c3f917d78e25fbc90bb004d1971c624b1cc3d48023a20d7c214ea23326a75aebb908f +"node-releases@npm:^2.0.57": + version: 2.0.58 + resolution: "node-releases@npm:2.0.58" + checksum: 10/06039e07aef63b25e2f2597c4d68e147139977edbe1f147159d2d0379eb16a179709ab91ac25b294309cca0ff980266a7c4cffd079b6c5ac3dd9769204628051 languageName: node linkType: hard @@ -13485,13 +13467,13 @@ __metadata: linkType: hard "postcss@npm:^8.4.40": - version: 8.5.15 - resolution: "postcss@npm:8.5.15" + version: 8.5.29 + resolution: "postcss@npm:8.5.29" dependencies: - nanoid: "npm:^3.3.12" + nanoid: "npm:^3.3.19" picocolors: "npm:^1.1.1" - source-map-js: "npm:^1.2.1" - checksum: 10/d02ad19eb1e0fa53a1229ee6d53807eb88f903f2b9a8cac66993367f3ac7dd3b97238c783a54ccbf4145f82f6ca9a5cbd58f089846285d759c8a3259fbea8318 + source-map-js: "npm:^1.2.2" + checksum: 10/801c352ab8aa59aaea145c11e59f1f134db834be0398e15e16969a1ca19dabbd2aa709cdca285d796a8462cee1a1a2626d435b4b63d1a1010100934e9fac3a1b languageName: node linkType: hard @@ -14391,10 +14373,10 @@ __metadata: languageName: node linkType: hard -"shell-quote@npm:1.8.4": - version: 1.8.4 - resolution: "shell-quote@npm:1.8.4" - checksum: 10/a3e3796385f2cd5cf0b78207a4439f0c7395c0833fc75b2473084b5d298c109c5c0fa687fcd1c04e4b4484866e5bb8eaae7efae443b80fff71ea7e29baf11f0c +"shell-quote@npm:1.12.0": + version: 1.12.0 + resolution: "shell-quote@npm:1.12.0" + checksum: 10/18b3e382272804e79fd3187c18877cfd4e736b5afb576bf1ab66a561b0a0e26eada4b7c8eb45473d1f17268ffcf12d0aaa90b9235132123eb673df5d0a5df943 languageName: node linkType: hard @@ -14534,20 +14516,13 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:^1.2.0": +"source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.2": version: 1.2.2 resolution: "source-map-js@npm:1.2.2" checksum: 10/4807c894bb6280c8b84886159b96080e3996f42c6e8015e647881edfd4f955f6024ceebb8bb40d33ce8b106e24b2e29c133575e1c60c01c8aae57a05fdf17761 languageName: node linkType: hard -"source-map-js@npm:^1.2.1": - version: 1.2.1 - resolution: "source-map-js@npm:1.2.1" - checksum: 10/ff9d8c8bf096d534a5b7707e0382ef827b4dd360a577d3f34d2b9f48e12c9d230b5747974ee7c607f0df65113732711bb701fe9ece3c7edbd43cb2294d707df3 - languageName: node - linkType: hard - "source-map-support@npm:0.5.13": version: 0.5.13 resolution: "source-map-support@npm:0.5.13" @@ -14987,7 +14962,7 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.12": +"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15": version: 0.2.17 resolution: "tinyglobby@npm:0.2.17" dependencies: @@ -14997,16 +14972,6 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.15": - version: 0.2.15 - resolution: "tinyglobby@npm:0.2.15" - dependencies: - fdir: "npm:^6.5.0" - picomatch: "npm:^4.0.3" - checksum: 10/d72bd826a8b0fa5fa3929e7fe5ba48fceb2ae495df3a231b6c5408cd7d8c00b58ab5a9c2a76ba56a62ee9b5e083626f1f33599734bed1ffc4b792406408f0ca2 - languageName: node - linkType: hard - "tinypool@npm:2.1.2": version: 2.1.2 resolution: "tinypool@npm:2.1.2" @@ -15503,9 +15468,9 @@ __metadata: languageName: node linkType: hard -"update-browserslist-db@npm:^1.2.3": - version: 1.2.3 - resolution: "update-browserslist-db@npm:1.2.3" +"update-browserslist-db@npm:^1.3.3": + version: 1.3.4 + resolution: "update-browserslist-db@npm:1.3.4" dependencies: escalade: "npm:^3.2.0" picocolors: "npm:^1.1.1" @@ -15513,7 +15478,7 @@ __metadata: browserslist: ">= 4.21.0" bin: update-browserslist-db: cli.js - checksum: 10/059f774300efb4b084a49293143c511f3ae946d40397b5c30914e900cd5691a12b8e61b41dd54ed73d3b56c8204165a0333107dd784ccf8f8c81790bcc423175 + checksum: 10/b572dffa5e87f5b31e2934fa72d91f537dc219fec97ee9d13d744ed2ded8d540118d3cd8fb10236b984106b27ac8a074a32d606ebe18b55b0377aad8854a7a86 languageName: node linkType: hard From c2021a62a424688c6a19bf2a1499db72a646cf1b Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:20:34 +0800 Subject: [PATCH 25/50] chore: update --- .../src/handlers/keyring/keyring.test.ts | 2 +- .../OnChainAccountService.test.ts | 123 ++++----- .../on-chain-account/OnChainAccountService.ts | 243 ++++++++++-------- .../services/on-chain-account/utils.test.ts | 10 +- .../src/services/on-chain-account/utils.ts | 6 +- 5 files changed, 194 insertions(+), 190 deletions(-) diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index a828fab11..7f6817a2f 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -727,7 +727,7 @@ describe('KeyringHandler', () => { .mockResolvedValue(onChainAccount); const fetchAndBindSpy = jest .spyOn(OnChainAccountService.prototype, 'fetchAndBindSep41Balances') - .mockImplementation(async (account, _scope, assetIds) => { + .mockImplementation(async (account, _scope, assetIds = []) => { for (const assetId of assetIds) { account.setAsset(assetId, { balance: new BigNumber(10_000_000), diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index 6de213a42..682df4957 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -29,7 +29,10 @@ import { horizonSource, mockOnChainAccountService, } from './__mocks__/onChainAccount.fixtures'; -import { OnChainAccountSep41BalanceNotFoundException } from './exceptions'; +import { + OnChainAccountException, + OnChainAccountSep41BalanceNotFoundException, +} from './exceptions'; import { OnChainAccount } from './OnChainAccount'; import type { OnChainAccountSerializableFull } from './OnChainAccountSerializable'; import { OnChainAccountService } from './OnChainAccountService'; @@ -45,10 +48,6 @@ describe('OnChainAccountService', () => { const getNetworkServiceSpies = () => ({ getAccountSpy: jest.spyOn(NetworkService.prototype, 'getAccount'), - getAccountLedgerMetaSpy: jest.spyOn( - NetworkService.prototype, - 'getAccountLedgerMetadata', - ), loadOnChainAccountSpy: jest.spyOn( NetworkService.prototype, 'loadOnChainAccount', @@ -491,31 +490,38 @@ describe('OnChainAccountService', () => { KnownCaip2ChainId.Mainnet, ); }); - }); - describe('resolveOnChainAccountFromCore', () => { - const usdcIssuer = USDC_CLASSIC.split('-').at(1) as string; - - it('returns null when Core returns no holdings', async () => { - const { onChainAccountService, assetsService } = + it('returns null when migration is on and AssetsController returns no holdings', async () => { + const keyringAccountId = globalThis.crypto.randomUUID(); + const { onChainAccountService, assetsService, onChainAccountRepository } = mockOnChainAccountService(); jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); - jest + const getAccountAssetsByScopeSpy = jest .spyOn(assetsService, 'getAccountAssetsByScope') .mockResolvedValue([]); + const findByAccountIdSpy = jest.spyOn( + onChainAccountRepository, + 'findByKeyringAccountId', + ); expect( - await onChainAccountService.resolveOnChainAccountFromCore( - KnownCaip2ChainId.Mainnet, - globalThis.crypto.randomUUID(), + await onChainAccountService.resolveOnChainAccountByKeyringAccountId( + keyringAccountId, Keypair.random().publicKey(), + KnownCaip2ChainId.Mainnet, ), ).toBeNull(); + expect(getAccountAssetsByScopeSpy).toHaveBeenCalledWith( + KnownCaip2ChainId.Mainnet, + keyringAccountId, + ); + expect(findByAccountIdSpy).not.toHaveBeenCalled(); }); - it('binds native, classic, and SEP-41 holdings from Core', async () => { + it('binds native, classic, and SEP-41 holdings from AssetsController when migration is on', async () => { const accountAddress = Keypair.random().publicKey(); const keyringAccountId = globalThis.crypto.randomUUID(); + const usdcIssuer = USDC_CLASSIC.split('-').at(1) as string; const { onChainAccountService, assetsService } = mockOnChainAccountService(); jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); @@ -556,11 +562,12 @@ describe('OnChainAccountService', () => { }, ]); - const result = await onChainAccountService.resolveOnChainAccountFromCore( - KnownCaip2ChainId.Mainnet, - keyringAccountId, - accountAddress, - ); + const result = + await onChainAccountService.resolveOnChainAccountByKeyringAccountId( + keyringAccountId, + accountAddress, + KnownCaip2ChainId.Mainnet, + ); expect(result).toBeInstanceOf(OnChainAccount); const full = result?.toSerializableFull(); @@ -600,17 +607,9 @@ describe('OnChainAccountService', () => { ); }); - it('overlays RPC ledger meta when resolveAccountFromNetwork is true', async () => { + it('wraps an AssetsController asset mapping failure and keeps the cause', async () => { const accountAddress = Keypair.random().publicKey(); - const { getAccountLedgerMetaSpy } = getNetworkServiceSpies(); - getAccountLedgerMetaSpy.mockResolvedValue({ - sequenceNumber: '99', - subentryCount: 4, - numSponsoring: 2, - numSponsored: 0, - rawNativeBalance: '351010623', - }); - + const keyringAccountId = globalThis.crypto.randomUUID(); const { onChainAccountService, assetsService } = mockOnChainAccountService(); jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); @@ -629,51 +628,31 @@ describe('OnChainAccountService', () => { metadata: { symbol: 'XLM', decimals: 7 }, }, ]); + const cause = new Error('invalid snapshot'); + const fromSerializableSpy = jest + .spyOn(OnChainAccount, 'fromSerializable') + .mockImplementation(() => { + throw cause; + }); - const result = await onChainAccountService.resolveOnChainAccountFromCore( - KnownCaip2ChainId.Mainnet, - globalThis.crypto.randomUUID(), - accountAddress, - { resolveAccountFromNetwork: true }, - ); - - expect(result?.sequenceNumber).toBe('99'); - expect(result?.subentryCount).toBe(4); - expect(result?.numSponsoring).toBe(2); - expect(result?.numSponsored).toBe(0); - expect(result?.nativeRawBalance.toFixed(0)).toBe('351010623'); - expect(getAccountLedgerMetaSpy).toHaveBeenCalledWith( - accountAddress, - KnownCaip2ChainId.Mainnet, - ); - }); - - it('returns null when Horizon says the account is not activated', async () => { - const accountAddress = Keypair.random().publicKey(); - const { getAccountLedgerMetaSpy } = getNetworkServiceSpies(); - getAccountLedgerMetaSpy.mockRejectedValue( - new AccountNotActivatedException( + let caught: unknown; + try { + await onChainAccountService.resolveOnChainAccountByKeyringAccountId( + keyringAccountId, accountAddress, KnownCaip2ChainId.Mainnet, - ), - ); - const { onChainAccountService, assetsService } = - mockOnChainAccountService(); - jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); - const getAccountAssetsByScopeSpy = jest.spyOn( - assetsService, - 'getAccountAssetsByScope', - ); + ); + } catch (error: unknown) { + caught = error; + } finally { + fromSerializableSpy.mockRestore(); + } - expect( - await onChainAccountService.resolveOnChainAccountFromCore( - KnownCaip2ChainId.Mainnet, - globalThis.crypto.randomUUID(), - accountAddress, - { resolveAccountFromNetwork: true }, - ), - ).toBeNull(); - expect(getAccountAssetsByScopeSpy).not.toHaveBeenCalled(); + expect(caught).toBeInstanceOf(OnChainAccountException); + expect(caught).toMatchObject({ + message: `Failed to map AssetsController assets for account ${accountAddress}`, + cause, + }); }); }); diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index c75056de0..5f5626222 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -7,7 +7,9 @@ import { entries, getAssetReference, isSep41Id, + keys, parseClassicAssetCodeIssuer, + rethrowIfInstanceElseThrow, toSmallestUnit, trackError, } from '../../utils'; @@ -26,7 +28,10 @@ import type { CoreAsset } from '../assets/api'; import { AccountNotActivatedException } from '../network'; import type { AccountLedgerMeta, NetworkService } from '../network'; import type { ActivatedAccountPair } from '../sync/api'; -import { OnChainAccountSep41BalanceNotFoundException } from './exceptions'; +import { + OnChainAccountException, + OnChainAccountSep41BalanceNotFoundException, +} from './exceptions'; import { OnChainAccount } from './OnChainAccount'; import type { OnChainAccountRepository } from './OnChainAccountRepository'; import { @@ -39,7 +44,7 @@ import type { SerializableSpendableBalance, } from './OnChainAccountSerializable'; import { OnChainAccountSynchronizeService } from './OnChainAccountSynchronizeService'; -import { subentryCountFromMinimumReserveStroops } from './utils'; +import { calculateSubentryFromMinReserve } from './utils'; /** * Stellar on-chain account operations: activation checks and loading {@link OnChainAccount} @@ -138,54 +143,86 @@ export class OnChainAccountService { assertSameAddress(accountAddress, loaded.accountId); if (options?.fetchWithSep41Balances) { - await this.#bindSep41Balances(loaded, scope); + await this.fetchAndBindSep41Balances(loaded, scope); } return loaded; } /** - * Fetches SEP-41 balances for ids and binds - * them onto `onChainAccount`. + * Fetches SEP-41 balances and binds them onto `onChainAccount`. + * + * When `assetIds` is omitted, balances are read for the persisted SEP-41 catalog. + * When `assetIds` is set, only those ids are resolved. + * Empty balance cells (`null` or missing) are skipped. * * @param onChainAccount - Horizon account to attach the SEP-41 entries to. * @param scope - CAIP-2 network. - * @param assetIds - SEP-41 asset ids. + * @param assetIds - Optional SEP-41 asset ids. * @returns The same account, with any resolved balances bound. * @throws {OnChainAccountSep41BalanceNotFoundException} When mainnet returns no map for the account. */ async fetchAndBindSep41Balances( onChainAccount: OnChainAccount, scope: KnownCaip2ChainId, - assetIds: KnownCaip19Sep41AssetId[], + assetIds?: KnownCaip19Sep41AssetId[], ): Promise { - await this.#bindSep41Balances(onChainAccount, scope, assetIds); + const assetMetadataByAssetId = await this.#getSep41AssetMetadata( + scope, + assetIds, + ); + const assetIdsWithMetadata = keys(assetMetadataByAssetId); + + // if there are no asset metadata, we can return early. + // This can happen if there are no SEP-41 assets for the scope. + if (assetIdsWithMetadata.length === 0) { + return onChainAccount; + } + + const sep41Balances = await this.#fetchSep41Balances( + onChainAccount.accountId, + assetIdsWithMetadata, + scope, + ); + + for (const [assetId, balance] of entries(sep41Balances)) { + const assetMetadata = assetMetadataByAssetId[assetId]; + // Asset metadata is important for setting the correct decimals and symbol for the balance. + if (assetMetadata === undefined) { + // This is unexpected, but we can continue. + await trackError( + new Error(`Asset metadata not found for asset id: ${assetId}`), + ); + continue; + } + this.#bindSep41BalanceForAccount({ + onChainAccount, + assetId, + balance, + assetMetadata, + }); + } + return onChainAccount; } /** - * Reads SEP-41 balances and binds them onto `onChainAccount`. - * Empty balance cells (`null` or missing) are skipped. - * - * When `assetIds` is omitted, balances are read for the persisted SEP-41 catalog. - * When `assetIds` is set, only those ids are resolved, and their metadata is persisted. + * Loads SEP-41 metadata for the requested ids, or the persisted catalog when ids are omitted. + * Non-SEP-41 rows are skipped. * - * @param onChainAccount - Horizon account to attach SEP-41 entries to. * @param scope - CAIP-2 network. - * @param assetIds - SEP-41 asset ids to fetch. Omit to use the persisted catalog. - * @throws {OnChainAccountSep41BalanceNotFoundException} When mainnet returns no map for the account. + * @param assetIds - SEP-41 asset ids. Omit to use the persisted catalog. + * @returns Metadata keyed by SEP-41 asset id. */ - async #bindSep41Balances( - onChainAccount: OnChainAccount, + async #getSep41AssetMetadata( scope: KnownCaip2ChainId, assetIds?: KnownCaip19Sep41AssetId[], - ): Promise { + ): Promise> { const sep41Assets = assetIds === undefined ? await this.#assetMetadataService.fetchSep41AssetsOrSyncOnce(scope) : await this.#assetMetadataService.fetchSep41AssetsByAssetIds(assetIds); - const sep41AssetIds: KnownCaip19Sep41AssetId[] = []; const assetMetadataByAssetId: Record< KnownCaip19Sep41AssetId, StellarAssetMetadata @@ -195,39 +232,42 @@ export class OnChainAccountService { if (!isSep41Id(assetId)) { continue; } - sep41AssetIds.push(assetId); assetMetadataByAssetId[assetId] = assetMetadata; } - if (sep41AssetIds.length === 0) { - return; - } + return assetMetadataByAssetId; + } + /** + * Reads SEP-41 balances for one account. + * + * @param accountAddress - Stellar G-address. + * @param assetIds - SEP-41 asset ids to read. + * @param scope - CAIP-2 network. + * @returns Balance by asset id. Empty on testnet when no map is returned. + * @throws {OnChainAccountSep41BalanceNotFoundException} When mainnet returns no map for the account. + */ + async #fetchSep41Balances( + accountAddress: string, + assetIds: KnownCaip19Sep41AssetId[], + scope: KnownCaip2ChainId, + ): Promise> { const balancesByAccount = await this.#networkService.getSep41AssetBalances({ - accounts: [onChainAccount.accountId], - assetIds: sep41AssetIds, + accounts: [accountAddress], + assetIds, scope, }); - const sep41Balances = balancesByAccount[onChainAccount.accountId]; + const sep41Balances = balancesByAccount[accountAddress]; // If it is testnet, we won't have any balances, so we return early. // If it is mainnet, we throw an error as it is unexpected. if (sep41Balances === undefined) { if (scope === KnownCaip2ChainId.Mainnet) { - throw new OnChainAccountSep41BalanceNotFoundException( - onChainAccount.accountId, - ); + throw new OnChainAccountSep41BalanceNotFoundException(accountAddress); } - return; + return {}; } - for (const [assetId, assetMetadata] of entries(assetMetadataByAssetId)) { - this.#setSep41BalanceForAccount({ - onChainAccount, - assetId, - balance: sep41Balances[assetId], - assetMetadata, - }); - } + return sep41Balances; } /** @@ -240,7 +280,7 @@ export class OnChainAccountService { * @param params.balance - Balance in smallest units. `null` or `undefined` means the cell was not read. * @param params.assetMetadata - Metadata for `assetId`, including symbol and decimals. */ - #setSep41BalanceForAccount({ + #bindSep41BalanceForAccount({ onChainAccount, assetId, balance, @@ -269,12 +309,13 @@ export class OnChainAccountService { } /** - * Loads the on-chain account for the given keyring account id from snap state or core. + * Loads the on-chain account for the given keyring account id from snap state or AssetsController. * * @param keyringAccountId - The keyring account id to load the on-chain account for. * @param accountAddress - Stellar G-address for the account header. * @param scope - The CAIP-2 chain id to load the on-chain account for. * @returns The on-chain account, or `null` if not found. + * @throws {OnChainAccountException} When the assets migration is on and AssetsController holdings cannot be mapped. */ async resolveOnChainAccountByKeyringAccountId( keyringAccountId: string, @@ -282,13 +323,26 @@ export class OnChainAccountService { scope: KnownCaip2ChainId, ): Promise { if (await this.#assetsService.isMigrationEnabled()) { - return this.resolveOnChainAccountFromCore( + return this.#getOnChainAccountFromCore( scope, keyringAccountId, accountAddress, ); } + return this.#getOnChainAccountFromState(keyringAccountId, scope); + } + /** + * Gets a persisted on-chain snapshot for a keyring account. + * + * @param keyringAccountId - MetaMask keyring account id. + * @param scope - CAIP-2 network. + * @returns The on-chain account, or `null` when no snapshot exists. + */ + async #getOnChainAccountFromState( + keyringAccountId: string, + scope: KnownCaip2ChainId, + ): Promise { const onChainAccount = await this.#onChainAccountRepository.findByKeyringAccountId( keyringAccountId, @@ -300,87 +354,71 @@ export class OnChainAccountService { } /** - * Best-effort {@link OnChainAccount} from Core holdings for fast read paths. - * - * - When `resolveAccountFromNetwork` is set, sequence, subentries, sponsorship, and native stroops come from {@link NetworkService.getAccountLedgerMetadata}. - * - Otherwise sequence is `0`, sponsorships are `0`, and `subentryCount` is derived from Core native `minimumReserveBalance`. - * - Not a substitute for live Horizon for send, fee, or ChangeTrust. + * Gets an {@link OnChainAccount} from AssetsController holdings. * * @param scope - CAIP-2 network. * @param keyringAccountId - MetaMask keyring account id. * @param accountAddress - Stellar G-address for the account header. - * @param options - Optional RPC ledger overlay. - * @param options.resolveAccountFromNetwork - When true, overlay sequence / meta / native - * from Soroban `getAccountEntry`. - * @returns Bound account, or `null` when migration is off, Core is empty, or the - * account is not activated on Horizon (when network load is requested). + * @returns Bound account, or `null` when AssetsController has no holdings for the account. + * @throws {OnChainAccountException} When AssetsController holdings cannot be mapped into a snapshot. */ - async resolveOnChainAccountFromCore( + async #getOnChainAccountFromCore( scope: KnownCaip2ChainId, keyringAccountId: string, accountAddress: string, - options?: { - resolveAccountFromNetwork?: boolean; - }, ): Promise { - const { resolveAccountFromNetwork = false } = options ?? {}; - let ledger: AccountLedgerMeta | undefined; - - if (resolveAccountFromNetwork) { - ledger = await this.#getAccountLedgerMetaSafe(accountAddress, scope); - if (!ledger) { - return null; - } - } - const assets = await this.#assetsService.getAccountAssetsByScope( scope, keyringAccountId, ); if (assets.length === 0) { - // If the account has no assets from core, + // If the account has no assets from AssetsController, // Possibly not indexed, or not activated // Return null to categorize it as not activated return null; } - this.#logger.debug('Resolved on-chain account from core', { + this.#logger.debug('Resolved on-chain account from AssetsController', { accountAddress, scope, assets, - ledger, }); try { - const serializable = this.#toSerializableFromCoreAssets({ + const serializable = this.#mapCoreAssetsToSerializable({ accountAddress, scope, assets, - ledger, }); return OnChainAccount.fromSerializable(serializable); } catch (error: unknown) { - await trackError( - new Error('Error serializing on-chain account from core assets', { - cause: error, - }), + return rethrowIfInstanceElseThrow( + error, + [OnChainAccountException], + new OnChainAccountException( + `Failed to map AssetsController assets for account ${accountAddress}`, + { cause: error }, + ), ); - throw error; } } /** - * Maps validated Core holdings into a full on-chain snapshot (best effort). + * Maps AssetsController holdings into a full on-chain snapshot. * - * @param params - Account header and Core assets for one scope. + * Best-effort account ledger metadata from AssetsController: + * - Derived `subentryCount` from native balance metadata `minimumReserveBalance` + * - Set `sequenceNumber` to `0`, `numSponsoring` to `0`, and `numSponsored` to `0` + * + * @param params - Account header and AssetsController assets for one scope. * @param params.accountAddress - Stellar G-address for the snapshot header. * @param params.scope - CAIP-2 network. - * @param params.assets - Assets that are held by the account from core client controller. - * @param params.ledger - Optional RPC ledger overlay (sequence, meta, native stroops). + * @param params.assets - Assets held by the account. + * @param params.ledger - Optional ledger overlay for sequence, meta, and native stroops. * @returns Full serializable binding for {@link OnChainAccount.fromSerializable}. */ - #toSerializableFromCoreAssets({ + #mapCoreAssetsToSerializable({ accountAddress, scope, assets, @@ -392,8 +430,13 @@ export class OnChainAccountService { ledger?: AccountLedgerMeta; }): OnChainAccountSerializableFull { const balances: SerializableSpendableBalance[] = []; - let rawNativeBalance = ledger?.rawNativeBalance ?? '0'; - let subentryCount = 0; + const accountLedgerMeta: AccountLedgerMeta = ledger ?? { + sequenceNumber: '0', + subentryCount: 0, + numSponsoring: 0, + numSponsored: 0, + rawNativeBalance: '0', + }; for (const asset of assets) { if (asset.chainId !== scope) { @@ -408,12 +451,11 @@ export class OnChainAccountService { ).toFixed(0); if (isCoreNativeAsset(asset)) { - // When RPC ledger meta is missing, derive subentryCount from Core - // `minimumReserveBalance` (stroops), assuming sponsoring fields are 0. + // When ledger meta is missing, derive subentryCount from AssetsController XLM metadata. if (ledger === undefined) { - rawNativeBalance = balance; const { minimumReserveBalance } = asset.balance.metadata; - subentryCount = subentryCountFromMinimumReserveStroops( + accountLedgerMeta.rawNativeBalance = balance; + accountLedgerMeta.subentryCount = calculateSubentryFromMinReserve( minimumReserveBalance, ); } @@ -453,35 +495,18 @@ export class OnChainAccountService { return OnChainAccountSerializableFullStruct.create({ accountId: accountAddress, - sequenceNumber: ledger?.sequenceNumber ?? '0', + sequenceNumber: accountLedgerMeta.sequenceNumber, scope, meta: { - subentryCount: ledger?.subentryCount ?? subentryCount, - numSponsoring: ledger?.numSponsoring ?? 0, - numSponsored: ledger?.numSponsored ?? 0, + subentryCount: accountLedgerMeta.subentryCount, + numSponsoring: accountLedgerMeta.numSponsoring, + numSponsored: accountLedgerMeta.numSponsored, }, balances, - rawNativeBalance, + rawNativeBalance: accountLedgerMeta.rawNativeBalance, }); } - async #getAccountLedgerMetaSafe( - accountAddress: string, - scope: KnownCaip2ChainId, - ): Promise { - try { - return await this.#networkService.getAccountLedgerMetadata( - accountAddress, - scope, - ); - } catch (error: unknown) { - if (error instanceof AccountNotActivatedException) { - return undefined; - } - throw error; - } - } - /** * Enriches accounts with SEP-41 balances, persists snapshots, then notifies the keyring when * balances or the tracked asset set changed. Delegates to {@link OnChainAccountSynchronizeService}. diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/utils.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/utils.test.ts index 2e13a9e3a..bcaeaebb6 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/utils.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/utils.test.ts @@ -6,16 +6,16 @@ import { } from '../../constants'; import { minimumBalanceStroops, - subentryCountFromMinimumReserveStroops, + calculateSubentryFromMinReserve, } from './utils'; -describe('subentryCountFromMinimumReserveStroops', () => { +describe('calculateSubentryFromMinReserve', () => { it('returns 0 for an empty account (2 base reserves)', () => { const emptyAccountReserve = new BigNumber(ACCOUNT_MINIMUM_BASE_RESERVE_UNIT) .times(BASE_RESERVE_STROOPS) .toFixed(0); - expect(subentryCountFromMinimumReserveStroops(emptyAccountReserve)).toBe(0); + expect(calculateSubentryFromMinReserve(emptyAccountReserve)).toBe(0); }); it('recovers subentries when sponsoring fields are 0', () => { @@ -25,10 +25,10 @@ describe('subentryCountFromMinimumReserveStroops', () => { numSponsored: 0, }).toFixed(0); - expect(subentryCountFromMinimumReserveStroops(stroops)).toBe(4); + expect(calculateSubentryFromMinReserve(stroops)).toBe(4); }); it('clamps below the account base reserve at 0', () => { - expect(subentryCountFromMinimumReserveStroops('0')).toBe(0); + expect(calculateSubentryFromMinReserve('0')).toBe(0); }); }); diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts index 599e92233..69fba3743 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts @@ -35,15 +35,15 @@ export function minimumBalanceStroops( } /** - * Best-effort `subentry_count` from Core native `minimumReserveBalance` (stroops). + * Best-effort `subentry_count` from AssetsController native `minimumReserveBalance` (stroops). * * Inverts {@link minimumBalanceStroops} when `numSponsoring` and `numSponsored` * are 0: `subentryCount = minimumReserve / BASE_RESERVE āˆ’ 2`. Clamped at 0. * - * @param minimumReserveBalanceStroops - Core `minimumReserveBalance` (integer stroops). + * @param minimumReserveBalanceStroops - AssetsController `minimumReserveBalance` (integer stroops). * @returns Estimated Horizon `subentry_count`. */ -export function subentryCountFromMinimumReserveStroops( +export function calculateSubentryFromMinReserve( minimumReserveBalanceStroops: string, ): number { const reserveUnits = new BigNumber(minimumReserveBalanceStroops).div( From 87d24ea0c42bd3ba3adfe42d196fdcd31fd6906d Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:24:22 +0800 Subject: [PATCH 26/50] chore: update jsdoc --- packages/stellar-wallet-snap/CHANGELOG.md | 2 +- .../AssetMetadataService.test.ts | 6 +-- .../asset-metadata/AssetMetadataService.ts | 40 ++++++++++++------- .../OnChainAccountService.test.ts | 2 +- .../on-chain-account/OnChainAccountService.ts | 10 ++--- .../OnChainAccountSynchronizeService.test.ts | 2 +- 6 files changed, 37 insertions(+), 25 deletions(-) diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index e6db90f8d..11d671ac5 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Read Stellar account holdings and asset metadata from Core when the Stellar snaps assets migration flag is not off ([#331](https://github.com/MetaMask/internal-snaps/pull/331)) +- Read Stellar account holdings and asset metadata from AssetsController when the Stellar snaps assets migration flag is not off ([#331](https://github.com/MetaMask/internal-snaps/pull/331)) - Request `endowment:messenger` for `RemoteFeatureFlagController:getState` and AssetsController account-asset and metadata actions. - Include SEP-41 balances on keyring `getAccountAssets` and `getAccountBalances`. 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 7fb1124ae..1181b51ef 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 @@ -407,7 +407,7 @@ describe('AssetMetadataService', () => { }); }); - it('persists catalog during synchronize when Core migration is on', async () => { + it('persists catalog during synchronize when assets migration is on', async () => { const sep41AssetId = 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as KnownCaip19AssetId; const { service, assetsService, saveMany } = createService({}); @@ -442,7 +442,7 @@ describe('AssetMetadataService', () => { ); }); - it('maps Core catalog metadata into StellarAssetMetadata without reading snap state', async () => { + it('maps AssetsController catalog metadata into StellarAssetMetadata without reading snap state', async () => { const classicId = MAINNET_CLASSIC_USDC; const { service, assetsService, getByAssetIds, saveMany } = createService( {}, @@ -472,7 +472,7 @@ describe('AssetMetadataService', () => { expect(saveMany).not.toHaveBeenCalled(); }); - it('falls back to snap catalog when Core misses', async () => { + 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({ 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 3a9995a38..b442b98ff 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -95,7 +95,7 @@ export class AssetMetadataService { /** * Resolves SEP-41 metadata for ids that are not in the watched catalog. * - * Rows already stored in Core or snap state are reused. Newly fetched rows + * 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. @@ -194,7 +194,7 @@ export class AssetMetadataService { result.push(getNativeAssetMetadata(chainId)); } - // persisted: Core then snap when migration is on; snap only when off + // persisted: AssetsController then snap when migration is on; snap only when off const allNonNativeAssetIds = [...assetsByChainId.values()].flat(); if (allNonNativeAssetIds.length === 0) { return result; @@ -228,9 +228,9 @@ export class AssetMetadataService { } /** - * Resolves persisted metadata. Core first when the Stellar assets migration - * flag is on, then snap state for remaining ids. When the flag is off, skip - * Core and read snap state only. + * 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. @@ -240,17 +240,22 @@ export class AssetMetadataService { missingAssetIds: KnownCaip19AssetId[]; }> { if (await this.#assetsService.isMigrationEnabled()) { - const { hits: coreHits, missing: missingAssetIdsFromCore } = - await this.#getCoreAssetMetadata(assetIds); + const { + hits: assetsControllerHits, + missing: missingAssetIdsFromAssetsController, + } = await this.#getAssetsControllerAssetMetadata(assetIds); - if (missingAssetIdsFromCore.length === 0) { - return { assets: coreHits, missingAssetIds: [] }; + if (missingAssetIdsFromAssetsController.length === 0) { + return { assets: assetsControllerHits, missingAssetIds: [] }; } const { hits: snapHits, missing: missingAssetIds } = - await this.#getSnapAssetMetadata(missingAssetIdsFromCore); + await this.#getSnapAssetMetadata(missingAssetIdsFromAssetsController); - return { assets: [...coreHits, ...snapHits], missingAssetIds }; + return { + assets: [...assetsControllerHits, ...snapHits], + missingAssetIds, + }; } const { hits, missing } = await this.#getSnapAssetMetadata(assetIds); @@ -266,14 +271,18 @@ export class AssetMetadataService { return this.#partitionHitsAndMissingByArray(assetIds, cachedAssets); } - async #getCoreAssetMetadata(assetIds: KnownCaip19AssetId[]): Promise<{ + async #getAssetsControllerAssetMetadata( + assetIds: KnownCaip19AssetId[], + ): Promise<{ hits: StellarAssetMetadata[]; missing: KnownCaip19AssetId[]; }> { const hits: StellarAssetMetadata[] = []; const missing: KnownCaip19AssetId[] = []; - this.#logger.debug('Getting asset metadata from Core', { assetIds }); + 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( @@ -295,7 +304,10 @@ export class AssetMetadataService { }), ); } - this.#logger.debug('Asset metadata from Core', { hits, missing }); + this.#logger.debug('Asset metadata from AssetsController', { + hits, + missing, + }); return { hits, missing }; } diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index 682df4957..46f8b8a36 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -696,7 +696,7 @@ describe('OnChainAccountService', () => { ); }); - it('skips persist when Core migration is on', async () => { + it('skips persist when assets migration is on', async () => { const { onChainAccountService, assetsService } = mockOnChainAccountService(); jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 5f5626222..3d6dad71e 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -323,7 +323,7 @@ export class OnChainAccountService { scope: KnownCaip2ChainId, ): Promise { if (await this.#assetsService.isMigrationEnabled()) { - return this.#getOnChainAccountFromCore( + return this.#getOnChainAccountFromAssetsController( scope, keyringAccountId, accountAddress, @@ -362,7 +362,7 @@ export class OnChainAccountService { * @returns Bound account, or `null` when AssetsController has no holdings for the account. * @throws {OnChainAccountException} When AssetsController holdings cannot be mapped into a snapshot. */ - async #getOnChainAccountFromCore( + async #getOnChainAccountFromAssetsController( scope: KnownCaip2ChainId, keyringAccountId: string, accountAddress: string, @@ -386,7 +386,7 @@ export class OnChainAccountService { }); try { - const serializable = this.#mapCoreAssetsToSerializable({ + const serializable = this.#mapAssetsControllerAssetsToSerializable({ accountAddress, scope, assets, @@ -418,7 +418,7 @@ export class OnChainAccountService { * @param params.ledger - Optional ledger overlay for sequence, meta, and native stroops. * @returns Full serializable binding for {@link OnChainAccount.fromSerializable}. */ - #mapCoreAssetsToSerializable({ + #mapAssetsControllerAssetsToSerializable({ accountAddress, scope, assets, @@ -525,7 +525,7 @@ export class OnChainAccountService { ): Promise { if (await this.#assetsService.isMigrationEnabled()) { this.#logger.debug( - 'Skipping on-chain account synchronization; Core migration is on', + 'Skipping on-chain account synchronization; assets migration is on', ); return; } diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts index ee242b8c1..87b606030 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts @@ -182,7 +182,7 @@ describe('OnChainAccountSynchronizeService', () => { const setupSynchronizeService = () => { const { onChainAccountService, onChainAccountRepository, assetsService } = mockOnChainAccountService(); - // These cases cover the snap-state sync path, which is skipped when Core migration is on. + // These cases cover the snap-state sync path, which is skipped when assets migration is on. jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(false); return { onChainAccountService, From 17385d99c6b2b1ce423bb7f68e100ad70f9c38a6 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:36:43 +0800 Subject: [PATCH 27/50] chore: update method name --- .../asset-metadata/AssetMetadataService.test.ts | 2 +- .../services/asset-metadata/AssetMetadataService.ts | 11 ++++------- .../on-chain-account/OnChainAccountService.ts | 2 +- 3 files changed, 6 insertions(+), 9 deletions(-) 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 c4f946d2a..750409f17 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 @@ -199,7 +199,7 @@ describe('AssetMetadataService', () => { const { service, saveMany } = createService({}); mockGetAssetsByAssetIds.mockResolvedValueOnce([tokenRow]); - const result = await service.fetchAssetsByAssetIds([USDC_SEP41]); + const result = await service.fetchAndPersistAssetsByAssetIds([USDC_SEP41]); expect(result).toStrictEqual([ expect.objectContaining({ 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 f04d59d25..04fcf57a7 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -93,16 +93,13 @@ export class AssetMetadataService { } /** - * Resolves SEP-41 metadata for ids that are not in the watched catalog. + * Resolves asset 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. + * @param assetIds - Aasset ids to resolve. * @returns Metadata for the ids that could be resolved. Missing ids are omitted. */ - async fetchAssetsByAssetIds( - assetIds: KnownCaip19Sep41AssetId[], + async fetchAndPersistAssetsByAssetIds( + assetIds: KnownCaip19AssetId[], ): Promise { return this.#fetchAndPersistAssetsByAssetIds(assetIds); } diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 3d6dad71e..6dbcb78dd 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -221,7 +221,7 @@ export class OnChainAccountService { const sep41Assets = assetIds === undefined ? await this.#assetMetadataService.fetchSep41AssetsOrSyncOnce(scope) - : await this.#assetMetadataService.fetchSep41AssetsByAssetIds(assetIds); + : await this.#assetMetadataService.fetchAndPersistAssetsByAssetIds(assetIds); const assetMetadataByAssetId: Record< KnownCaip19Sep41AssetId, From 9a0c1919c00a980e1c337086f38aafdec9d874e2 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:43:16 +0800 Subject: [PATCH 28/50] chore(stellar): use integer unit for trustline limit --- .../OnChainAccountSynchronizeService.test.ts | 8 ++++++-- .../src/services/on-chain-account/keyringBalance.test.ts | 4 ++-- .../src/services/on-chain-account/keyringBalance.ts | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts index eefaad894..4f62fd1aa 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountSynchronizeService.test.ts @@ -7,7 +7,11 @@ import { Keypair } from '@stellar/stellar-sdk'; import { BigNumber } from 'bignumber.js'; import { KnownCaip2ChainId } from '../../api'; -import { NATIVE_ASSET_SYMBOL, STELLAR_DECIMAL_PLACES } from '../../constants'; +import { + MAX_INT64, + NATIVE_ASSET_SYMBOL, + STELLAR_DECIMAL_PLACES, +} from '../../constants'; import { bufferToUint8Array } from '../../utils/buffer'; import * as errorsUtils from '../../utils/errors'; import { logger } from '../../utils/logger'; @@ -55,7 +59,7 @@ const NATIVE_BALANCE_ENTRY = { const classicBalanceEntry = ( unit: string, amount: string, - limit = '922337203685.4775807', + limit = MAX_INT64, ) => ({ unit, amount, diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.test.ts index 9d3084c50..2674ab2dc 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.test.ts @@ -44,7 +44,7 @@ describe('toClassicBalanceEntry', () => { unit: 'USDC', amount: '1.25', metadata: { - limit: '10', + limit: '100000000', authorized: false, sponsor: 'GSPONSOR', sponsored: true, @@ -85,7 +85,7 @@ describe('toClassicBalanceEntry', () => { unit: 'TOKEN', amount: '1', metadata: { - limit: '5', + limit: '5000', authorized: true, sponsor: '', sponsored: false, diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.ts index 401d88dc0..39c3f3928 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/keyringBalance.ts @@ -108,7 +108,7 @@ export function toClassicBalanceEntry( unit: asset.symbol, amount: toDisplayBalance(asset.balance, asset.decimals), metadata: { - limit: toDisplayBalance(asset.limit ?? new BigNumber(0), asset.decimals), + limit: (asset.limit ?? new BigNumber(0)).toFixed(0), authorized: asset.authorized, sponsor: asset.sponsor, sponsored: asset.sponsored, From 1fc061553b3207622de5407a74524d0265208ef4 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:47:44 +0800 Subject: [PATCH 29/50] Update CHANGELOG.md --- packages/stellar-wallet-snap/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index bdc5853f0..05a1b01d3 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +### Changed + +- **BREAKING:** Return classic keyring balance metadata `limit` in integer stroop string ([#495](https://github.com/MetaMask/internal-snaps/pull/495)) + ## [1.3.0] ### Added From 6de375c6309d6470aaff0fec69986b0913d51e89 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:55:14 +0800 Subject: [PATCH 30/50] Update CHANGELOG.md --- packages/stellar-wallet-snap/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 05a1b01d3..4a8162202 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **BREAKING:** Return classic keyring balance metadata `limit` in integer stroop string ([#495](https://github.com/MetaMask/internal-snaps/pull/495)) + - Keyring `getAccountBalances` and the `AccountBalancesUpdated` event return this `limit` in stroops. ## [1.3.0] From 83f312f6b8d07e25cf1d1c550eccc83f64ef33b1 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:21:12 +0800 Subject: [PATCH 31/50] chore: update keyring --- .../src/handlers/keyring/keyring.test.ts | 711 ++++++++++++------ .../src/handlers/keyring/keyring.ts | 84 ++- 2 files changed, 560 insertions(+), 235 deletions(-) diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 7f6817a2f..1aa47e1cb 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -107,6 +107,22 @@ describe('KeyringHandler', () => { ); }; + const bindRequestedSep41Balances = (): jest.SpiedFunction< + OnChainAccountService['fetchAndBindSep41Balances'] + > => + jest + .spyOn(OnChainAccountService.prototype, 'fetchAndBindSep41Balances') + .mockImplementation(async (account, _scope, assetIds = []) => { + for (const assetId of assetIds) { + account.setAsset(assetId, { + balance: new BigNumber(10_000_000), + symbol: 'USDC', + decimals: 7, + }); + } + return account; + }); + beforeEach(() => { jest.clearAllMocks(); jest.mocked(getDefaultEntropySource).mockResolvedValue(entropySourceId); @@ -268,7 +284,7 @@ describe('KeyringHandler', () => { (index: number) => Promise > => { const walletResolver = jest.fn( - async (_index: number) => ({ address: mockAccount.address }) as Wallet, + async (_index: number) => ({ address: mockAccount.address } as Wallet), ); jest .spyOn(WalletService.prototype, 'getWalletResolver') @@ -448,52 +464,127 @@ describe('KeyringHandler', () => { }); describe('getAccountAssets', () => { - it('returns live on-chain asset ids and caches the account', async () => { - const { resolveAccountSpy } = getAccountServiceSpies(); - resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - const onChainAccount = createTestOnChainAccount(mockAccount.address); - const resolveOnChainAccountSpy = jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') - .mockResolvedValue(onChainAccount); - - const result = await keyringHandler.getAccountAssets(mockAccountId); + const originalEnableAssetMigration = AppConfig.enableAssetMigration; - expect(resolveOnChainAccountSpy).toHaveBeenCalledWith( - mockAccount.address, - KnownCaip2ChainId.Mainnet, - { fetchWithSep41Balances: true }, - ); - expect(result).toStrictEqual(onChainAccount.assetIds); + afterEach(() => { + AppConfig.enableAssetMigration = originalEnableAssetMigration; }); - it('returns native asset id when the account is not activated on-chain', async () => { - const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); - const { resolveAccountSpy } = getAccountServiceSpies(); - resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') - .mockRejectedValue( - new AccountNotActivatedException( - mockAccount.address, - KnownCaip2ChainId.Mainnet, - ), + describe('when local config asset migration is disabled', () => { + beforeEach(() => { + AppConfig.enableAssetMigration = false; + }); + + it('returns indexed asset ids without loading Horizon', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address); + const resolveByKeyringAccountIdSpy = jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue(onChainAccount); + const resolveOnChainAccountSpy = jest.spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccount', + ); + + const result = await keyringHandler.getAccountAssets(mockAccountId); + + expect(resolveByKeyringAccountIdSpy).toHaveBeenCalledWith( + mockAccountId, + mockAccount.address, + KnownCaip2ChainId.Mainnet, ); + expect(resolveOnChainAccountSpy).not.toHaveBeenCalled(); + expect(result).toStrictEqual(onChainAccount.assetIds); + }); - const result = await keyringHandler.getAccountAssets(mockAccountId); + it('returns native asset id when the account is not indexed', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue(null); - expect(result).toStrictEqual([slipId]); + const result = await keyringHandler.getAccountAssets(mockAccountId); + + expect(result).toStrictEqual([slipId]); + }); + + it('propagates errors when the indexed account cannot be read', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockRejectedValue(new Error('state unavailable')); + + await expect( + keyringHandler.getAccountAssets(mockAccountId), + ).rejects.toThrow('state unavailable'); + }); }); - it('propagates errors when listing assets fails for another reason', async () => { - const { resolveAccountSpy } = getAccountServiceSpies(); - resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') - .mockRejectedValue(new Error('Horizon unavailable')); + describe('when asset migration is enabled', () => { + beforeEach(() => { + AppConfig.enableAssetMigration = true; + }); - await expect( - keyringHandler.getAccountAssets(mockAccountId), - ).rejects.toThrow('Horizon unavailable'); + it('returns live on-chain asset ids and caches the account', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address); + const resolveOnChainAccountSpy = jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockResolvedValue(onChainAccount); + + const result = await keyringHandler.getAccountAssets(mockAccountId); + + expect(resolveOnChainAccountSpy).toHaveBeenCalledWith( + mockAccount.address, + KnownCaip2ChainId.Mainnet, + { fetchWithSep41Balances: true }, + ); + expect(result).toStrictEqual(onChainAccount.assetIds); + }); + + it('returns native asset id when the account is not activated on-chain', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockRejectedValue( + new AccountNotActivatedException( + mockAccount.address, + KnownCaip2ChainId.Mainnet, + ), + ); + + const result = await keyringHandler.getAccountAssets(mockAccountId); + + expect(result).toStrictEqual([slipId]); + }); + + it('propagates errors when listing assets fails for another reason', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockRejectedValue(new Error('Horizon unavailable')); + + await expect( + keyringHandler.getAccountAssets(mockAccountId), + ).rejects.toThrow('Horizon unavailable'); + }); }); it('rejects invalid account id', async () => { @@ -583,220 +674,404 @@ describe('KeyringHandler', () => { }); describe('getAccountBalances', () => { - it('returns balances for assets with positive balance and metadata', async () => { - const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); - const { resolveAccountSpy } = getAccountServiceSpies(); - resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - const onChainAccount = createTestOnChainAccount(mockAccount.address, { - ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, - nativeBalance: 2.000001, + const originalEnableAssetMigration = AppConfig.enableAssetMigration; + + afterEach(() => { + AppConfig.enableAssetMigration = originalEnableAssetMigration; + }); + + describe('when local config asset migration is disabled', () => { + beforeEach(() => { + AppConfig.enableAssetMigration = false; }); - const resolveOnChainAccountSpy = jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') - .mockResolvedValue(onChainAccount); - const result = await keyringHandler.getAccountBalances(mockAccountId, [ - slipId, - ]); + it('returns balances from the same indexed account as getAccountAssets', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address, { + ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + nativeBalance: 2.000001, + }); + const resolveByKeyringAccountIdSpy = jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue(onChainAccount); + const resolveOnChainAccountSpy = jest.spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccount', + ); - expect(resolveOnChainAccountSpy).toHaveBeenCalledWith( - mockAccount.address, - KnownCaip2ChainId.Mainnet, - { fetchWithSep41Balances: true }, - ); - expect(result).toStrictEqual({ - [slipId]: { - unit: 'XLM', - amount: '2.000001', - metadata: { - spendableBalance: '10000010', - minimumReserveBalance: '10000000', - decimal: 7, + const assets = await keyringHandler.getAccountAssets(mockAccountId); + const balances = await keyringHandler.getAccountBalances( + mockAccountId, + [slipId], + ); + + expect(resolveOnChainAccountSpy).not.toHaveBeenCalled(); + expect(resolveByKeyringAccountIdSpy).toHaveBeenCalledTimes(2); + expect(resolveByKeyringAccountIdSpy).toHaveBeenNthCalledWith( + 1, + mockAccountId, + mockAccount.address, + KnownCaip2ChainId.Mainnet, + ); + expect(resolveByKeyringAccountIdSpy).toHaveBeenNthCalledWith( + 2, + mockAccountId, + mockAccount.address, + KnownCaip2ChainId.Mainnet, + ); + expect(assets).toStrictEqual(onChainAccount.assetIds); + expect(balances).toStrictEqual({ + [slipId]: { + unit: 'XLM', + amount: '2.000001', + metadata: { + spendableBalance: '10000010', + minimumReserveBalance: '10000000', + decimal: 7, + }, }, - }, + }); }); - }); - it('reuses the account cached by getAccountAssets', async () => { - const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); - const { resolveAccountSpy } = getAccountServiceSpies(); - resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - const onChainAccount = createTestOnChainAccount(mockAccount.address, { - ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, - nativeBalance: 2.000001, - }); - const resolveOnChainAccountSpy = jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') - .mockResolvedValue(onChainAccount); + it('returns zero native balance when the account is not indexed', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue(null); + const fetchAndBindSpy = jest.spyOn( + OnChainAccountService.prototype, + 'fetchAndBindSep41Balances', + ); - await keyringHandler.getAccountAssets(mockAccountId); - resolveOnChainAccountSpy.mockClear(); + const result = await keyringHandler.getAccountBalances(mockAccountId, [ + slipId, + USDC_SEP41, + ]); + + expect(fetchAndBindSpy).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + [slipId]: { + unit: 'XLM', + amount: '0', + metadata: { + spendableBalance: '0', + minimumReserveBalance: '0', + decimal: 7, + }, + }, + }); + }); - const result = await keyringHandler.getAccountBalances(mockAccountId, [ - slipId, - ]); + it('fetches a requested SEP-41 asset that was not indexed', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue(onChainAccount); + const fetchAndBindSpy = bindRequestedSep41Balances(); + + const result = await keyringHandler.getAccountBalances(mockAccountId, [ + USDC_SEP41, + ]); + + expect(fetchAndBindSpy).toHaveBeenCalledWith( + onChainAccount, + KnownCaip2ChainId.Mainnet, + [USDC_SEP41], + ); + expect(result).toStrictEqual({ + [USDC_SEP41]: { + unit: 'USDC', + amount: '1', + }, + }); + }); - expect(resolveOnChainAccountSpy).not.toHaveBeenCalled(); - expect(result[slipId]?.amount).toBe('2.000001'); - }); + it('does not fetch a SEP-41 balance already indexed', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address); + onChainAccount.setAsset(USDC_SEP41, { + balance: new BigNumber(10_000_000), + symbol: 'USDC', + decimals: 7, + }); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockResolvedValue(onChainAccount); + const fetchAndBindSpy = jest.spyOn( + OnChainAccountService.prototype, + 'fetchAndBindSep41Balances', + ); - it('loads Horizon again when the cached account has expired', async () => { - const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); - const { resolveAccountSpy } = getAccountServiceSpies(); - resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - const onChainAccount = createTestOnChainAccount(mockAccount.address); - const resolveOnChainAccountSpy = jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') - .mockResolvedValue(onChainAccount); - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_000); - - await keyringHandler.getAccountAssets(mockAccountId); - nowSpy.mockReturnValue( - 1_000 + AppConfig.cache.ttlMilliseconds.fetchOnChainAccount + 1, - ); - resolveOnChainAccountSpy.mockClear(); + const result = await keyringHandler.getAccountBalances(mockAccountId, [ + USDC_SEP41, + USDC_CLASSIC, + ]); - await keyringHandler.getAccountBalances(mockAccountId, [slipId]); + expect(fetchAndBindSpy).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + [USDC_SEP41]: { + unit: 'USDC', + amount: '1', + }, + }); + }); - expect(resolveOnChainAccountSpy).toHaveBeenCalledTimes(1); - nowSpy.mockRestore(); + it('propagates errors when the indexed account cannot be read', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn( + OnChainAccountService.prototype, + 'resolveOnChainAccountByKeyringAccountId', + ) + .mockRejectedValue(new Error('state unavailable')); + + await expect( + keyringHandler.getAccountBalances(mockAccountId, [slipId]), + ).rejects.toThrow('state unavailable'); + }); }); - it('returns zero native balance when the account is not activated on-chain', async () => { - const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); - const { resolveAccountSpy } = getAccountServiceSpies(); - resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - const resolveOnChainAccountSpy = jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') - .mockRejectedValue( - new AccountNotActivatedException( - mockAccount.address, - KnownCaip2ChainId.Mainnet, - ), - ); + describe('when local config asset migration is enabled', () => { + beforeEach(() => { + AppConfig.enableAssetMigration = true; + }); - await keyringHandler.getAccountAssets(mockAccountId); - resolveOnChainAccountSpy.mockClear(); + it('returns balances for assets with positive balance and metadata', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address, { + ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + nativeBalance: 2.000001, + }); + const resolveOnChainAccountSpy = jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockResolvedValue(onChainAccount); - const result = await keyringHandler.getAccountBalances(mockAccountId, [ - slipId, - ]); + const result = await keyringHandler.getAccountBalances(mockAccountId, [ + slipId, + ]); - expect(resolveOnChainAccountSpy).not.toHaveBeenCalled(); - expect(result).toStrictEqual({ - [slipId]: { - unit: 'XLM', - amount: '0', - metadata: { - spendableBalance: '0', - minimumReserveBalance: '0', - decimal: 7, + expect(resolveOnChainAccountSpy).toHaveBeenCalledWith( + mockAccount.address, + KnownCaip2ChainId.Mainnet, + { fetchWithSep41Balances: true }, + ); + expect(result).toStrictEqual({ + [slipId]: { + unit: 'XLM', + amount: '2.000001', + metadata: { + spendableBalance: '10000010', + minimumReserveBalance: '10000000', + decimal: 7, + }, }, - }, + }); }); - }); - it('loads Horizon when a previous asset read failed and was not cached', async () => { - const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); - const { resolveAccountSpy } = getAccountServiceSpies(); - resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - const onChainAccount = createTestOnChainAccount(mockAccount.address); - const resolveOnChainAccountSpy = jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') - .mockRejectedValueOnce(new Error('Horizon unavailable')) - .mockResolvedValue(onChainAccount); + it('reuses the account cached by getAccountAssets', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address, { + ...DEFAULT_MOCK_ACCOUNT_WITH_BALANCES, + nativeBalance: 2.000001, + }); + const resolveOnChainAccountSpy = jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockResolvedValue(onChainAccount); - await expect( - keyringHandler.getAccountAssets(mockAccountId), - ).rejects.toThrow('Horizon unavailable'); + await keyringHandler.getAccountAssets(mockAccountId); + resolveOnChainAccountSpy.mockClear(); - const result = await keyringHandler.getAccountBalances(mockAccountId, [ - slipId, - ]); + const result = await keyringHandler.getAccountBalances(mockAccountId, [ + slipId, + ]); - expect(resolveOnChainAccountSpy).toHaveBeenCalledTimes(2); - expect(result[slipId]?.amount).toBe('1'); - }); + expect(resolveOnChainAccountSpy).not.toHaveBeenCalled(); + expect(result[slipId]?.amount).toBe('2.000001'); + }); - it('binds a requested SEP-41 asset missing from the watched account', async () => { - const { resolveAccountSpy } = getAccountServiceSpies(); - resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - const onChainAccount = createTestOnChainAccount(mockAccount.address); - jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') - .mockResolvedValue(onChainAccount); - const fetchAndBindSpy = jest - .spyOn(OnChainAccountService.prototype, 'fetchAndBindSep41Balances') - .mockImplementation(async (account, _scope, assetIds = []) => { - for (const assetId of assetIds) { - account.setAsset(assetId, { - balance: new BigNumber(10_000_000), - symbol: 'USDC', - decimals: 7, - }); - } - return account; - }); + it('loads Horizon again when the cached account has expired', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address); + const resolveOnChainAccountSpy = jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockResolvedValue(onChainAccount); + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1_000); + + await keyringHandler.getAccountAssets(mockAccountId); + nowSpy.mockReturnValue( + 1_000 + AppConfig.cache.ttlMilliseconds.fetchOnChainAccount + 1, + ); + resolveOnChainAccountSpy.mockClear(); - const result = await keyringHandler.getAccountBalances(mockAccountId, [ - USDC_SEP41, - ]); + await keyringHandler.getAccountBalances(mockAccountId, [slipId]); - expect(fetchAndBindSpy).toHaveBeenCalledWith( - onChainAccount, - KnownCaip2ChainId.Mainnet, - [USDC_SEP41], - ); - expect(result).toStrictEqual({ - [USDC_SEP41]: { - unit: 'USDC', - amount: '1', - }, + expect(resolveOnChainAccountSpy).toHaveBeenCalledTimes(1); + nowSpy.mockRestore(); }); - }); - it('does not fetch a SEP-41 balance already stored on the account', async () => { - const { resolveAccountSpy } = getAccountServiceSpies(); - resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - const onChainAccount = createTestOnChainAccount(mockAccount.address); - onChainAccount.setAsset(USDC_SEP41, { - balance: new BigNumber(10_000_000), - symbol: 'USDC', - decimals: 7, + it('returns zero native balance when the account is not activated on-chain', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const resolveOnChainAccountSpy = jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockRejectedValue( + new AccountNotActivatedException( + mockAccount.address, + KnownCaip2ChainId.Mainnet, + ), + ); + + await keyringHandler.getAccountAssets(mockAccountId); + resolveOnChainAccountSpy.mockClear(); + + const result = await keyringHandler.getAccountBalances(mockAccountId, [ + slipId, + ]); + + expect(resolveOnChainAccountSpy).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + [slipId]: { + unit: 'XLM', + amount: '0', + metadata: { + spendableBalance: '0', + minimumReserveBalance: '0', + decimal: 7, + }, + }, + }); }); - jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') - .mockResolvedValue(onChainAccount); - const fetchAndBindSpy = jest.spyOn( - OnChainAccountService.prototype, - 'fetchAndBindSep41Balances', - ); - const result = await keyringHandler.getAccountBalances(mockAccountId, [ - USDC_SEP41, - USDC_CLASSIC, - ]); + it('loads Horizon when a previous asset read failed and was not cached', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address); + const resolveOnChainAccountSpy = jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockRejectedValueOnce(new Error('Horizon unavailable')) + .mockResolvedValue(onChainAccount); + + await expect( + keyringHandler.getAccountAssets(mockAccountId), + ).rejects.toThrow('Horizon unavailable'); + + const result = await keyringHandler.getAccountBalances(mockAccountId, [ + slipId, + ]); + + expect(resolveOnChainAccountSpy).toHaveBeenCalledTimes(2); + expect(result[slipId]?.amount).toBe('1'); + }); - expect(fetchAndBindSpy).not.toHaveBeenCalled(); - expect(result).toStrictEqual({ - [USDC_SEP41]: { - unit: 'USDC', - amount: '1', - }, + it('binds a requested SEP-41 asset missing from the watched account', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address); + jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockResolvedValue(onChainAccount); + const fetchAndBindSpy = jest + .spyOn(OnChainAccountService.prototype, 'fetchAndBindSep41Balances') + .mockImplementation(async (account, _scope, assetIds = []) => { + for (const assetId of assetIds) { + account.setAsset(assetId, { + balance: new BigNumber(10_000_000), + symbol: 'USDC', + decimals: 7, + }); + } + return account; + }); + + const result = await keyringHandler.getAccountBalances(mockAccountId, [ + USDC_SEP41, + ]); + + expect(fetchAndBindSpy).toHaveBeenCalledWith( + onChainAccount, + KnownCaip2ChainId.Mainnet, + [USDC_SEP41], + ); + expect(result).toStrictEqual({ + [USDC_SEP41]: { + unit: 'USDC', + amount: '1', + }, + }); }); - }); - it('propagates errors when balance resolution fails for another reason', async () => { - const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); - const { resolveAccountSpy } = getAccountServiceSpies(); - resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - jest - .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') - .mockRejectedValue(new Error('Horizon unavailable')); + it('does not fetch a SEP-41 balance already stored on the account', async () => { + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + const onChainAccount = createTestOnChainAccount(mockAccount.address); + onChainAccount.setAsset(USDC_SEP41, { + balance: new BigNumber(10_000_000), + symbol: 'USDC', + decimals: 7, + }); + jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockResolvedValue(onChainAccount); + const fetchAndBindSpy = jest.spyOn( + OnChainAccountService.prototype, + 'fetchAndBindSep41Balances', + ); - await expect( - keyringHandler.getAccountBalances(mockAccountId, [slipId]), - ).rejects.toThrow('Horizon unavailable'); + const result = await keyringHandler.getAccountBalances(mockAccountId, [ + USDC_SEP41, + USDC_CLASSIC, + ]); + + expect(fetchAndBindSpy).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + [USDC_SEP41]: { + unit: 'USDC', + amount: '1', + }, + }); + }); + + it('propagates errors when balance resolution fails for another reason', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') + .mockRejectedValue(new Error('Horizon unavailable')); + + await expect( + keyringHandler.getAccountBalances(mockAccountId, [slipId]), + ).rejects.toThrow('Horizon unavailable'); + }); }); }); diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 106484d5e..fa9118ee2 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -218,13 +218,23 @@ export class KeyringHandler implements KeyringSnapRpc { async getAccountAssets(accountId: string): Promise { validateRequest(accountId, ListAccountAssetsRequestStruct); - const scope = AppConfig.selectedNetwork; - const onChainAccount = await this.#fetchOnChainAccountByAccountId({ - accountId, - scope, - // Always refresh the cache to get the latest on-chain account. - refresh: true, - }); + const { enableAssetMigration, selectedNetwork: scope } = AppConfig; + + let onChainAccount: OnChainAccount | null = null; + + if (enableAssetMigration) { + onChainAccount = await this.#fetchOnChainAccountWithCache({ + accountId, + scope, + // Always refresh the cache to get the latest on-chain account. + refresh: true, + }); + } else { + onChainAccount = await this.#getOnChainAccountFromState({ + accountId, + scope, + }); + } // Unfunded accounts have no trustlines. Return native so the client can ask for a zero balance. if (onChainAccount === null) { @@ -324,14 +334,25 @@ export class KeyringHandler implements KeyringSnapRpc { GetAccountBalancesRequestStruct, ); - const scope = AppConfig.selectedNetwork; + const { enableAssetMigration, selectedNetwork: scope } = AppConfig; + const assetBalances = {} as Record; - const onChainAccount = await this.#fetchOnChainAccountByAccountId({ - accountId, - scope, - refresh: false, - }); + let onChainAccount: OnChainAccount | null = null; + + if (enableAssetMigration) { + onChainAccount = await this.#fetchOnChainAccountWithCache({ + accountId, + scope, + // Use the cached on-chain account. + refresh: false, + }); + } else { + onChainAccount = await this.#getOnChainAccountFromState({ + accountId, + scope, + }); + } // Unfunded accounts have no trustlines. Return native zero when it was requested. if (onChainAccount === null) { @@ -342,8 +363,8 @@ export class KeyringHandler implements KeyringSnapRpc { return assetBalances; } - // `#fetchOnChainAccountByAccountId` returns watched assets balance from Tokens API. - // But, the watched assets from Tokens API may not contain the SEP-41 ids the client asked for. + // Indexed snapshots and the Tokens API watchlist can both omit a SEP-41 id + // the client asked for. Fetch those balances when nothing was stored. const missingSep41AssetIds = knownAssets.filter( (assetId): assetId is KnownCaip19Sep41AssetId => isSep41Id(assetId) && onChainAccount.getRawAsset(assetId) === undefined, @@ -506,19 +527,48 @@ export class KeyringHandler implements KeyringSnapRpc { } /** - * Loads a Horizon account for the keyring asset/balance pair. + * Loads the indexed on-chain snapshot for a keyring account. + * + * Used when `AppConfig.enableAssetMigration` is false. Does not call Horizon. + * + * @param params - Account id and scope. + * @param params.accountId - Keyring account id. + * @param params.scope - CAIP-2 network. + * @returns The indexed account, or `null` when no snapshot exists. + */ + async #getOnChainAccountFromState({ + accountId, + scope, + }: { + accountId: string; + scope: KnownCaip2ChainId; + }): Promise { + const { account } = await this.#accountService.resolveAccount({ + accountId, + }); + return this.#onChainAccountService.resolveOnChainAccountByKeyringAccountId( + accountId, + account.address, + scope, + ); + } + + /** + * Loads a Horizon account for the keyring asset and balance reads. * * `getAccountAssets` always refreshes and stores the result. `getAccountBalances` * reuses that entry until `AppConfig.cache.ttlMilliseconds.fetchOnChainAccount`, then loads Horizon. * A failed load is not cached. An unactivated account is cached as `null`. * + * Used when `AppConfig.enableAssetMigration` is true. + * * @param params - Account id, scope, and whether to skip the cache. * @param params.accountId - Keyring account id. * @param params.scope - CAIP-2 network. * @param params.refresh - When true, load Horizon and replace the cache entry. * @returns The live account, or `null` when it is not activated. */ - async #fetchOnChainAccountByAccountId({ + async #fetchOnChainAccountWithCache({ accountId, scope, refresh, From ac054049d610035b54632af1e6e7638c1aae12f3 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:55:29 +0800 Subject: [PATCH 32/50] fix: lint --- .../src/handlers/keyring/keyring.test.ts | 2 +- .../stellar-wallet-snap/src/handlers/keyring/keyring.ts | 8 -------- .../services/on-chain-account/OnChainAccountService.ts | 4 +++- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index 1aa47e1cb..c0d37d64f 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -284,7 +284,7 @@ describe('KeyringHandler', () => { (index: number) => Promise > => { const walletResolver = jest.fn( - async (_index: number) => ({ address: mockAccount.address } as Wallet), + async (_index: number) => ({ address: mockAccount.address }) as Wallet, ); jest .spyOn(WalletService.prototype, 'getWalletResolver') diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index fa9118ee2..fd171ad6d 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -529,8 +529,6 @@ export class KeyringHandler implements KeyringSnapRpc { /** * Loads the indexed on-chain snapshot for a keyring account. * - * Used when `AppConfig.enableAssetMigration` is false. Does not call Horizon. - * * @param params - Account id and scope. * @param params.accountId - Keyring account id. * @param params.scope - CAIP-2 network. @@ -556,12 +554,6 @@ export class KeyringHandler implements KeyringSnapRpc { /** * Loads a Horizon account for the keyring asset and balance reads. * - * `getAccountAssets` always refreshes and stores the result. `getAccountBalances` - * reuses that entry until `AppConfig.cache.ttlMilliseconds.fetchOnChainAccount`, then loads Horizon. - * A failed load is not cached. An unactivated account is cached as `null`. - * - * Used when `AppConfig.enableAssetMigration` is true. - * * @param params - Account id, scope, and whether to skip the cache. * @param params.accountId - Keyring account id. * @param params.scope - CAIP-2 network. diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 6dbcb78dd..2b0e2c154 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -221,7 +221,9 @@ export class OnChainAccountService { const sep41Assets = assetIds === undefined ? await this.#assetMetadataService.fetchSep41AssetsOrSyncOnce(scope) - : await this.#assetMetadataService.fetchAndPersistAssetsByAssetIds(assetIds); + : await this.#assetMetadataService.fetchAndPersistAssetsByAssetIds( + assetIds, + ); const assetMetadataByAssetId: Record< KnownCaip19Sep41AssetId, From f63fead78cd6601c9d1ba6edd76264d0ddb986d6 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:00:03 +0800 Subject: [PATCH 33/50] fix: lint --- .../on-chain-account/OnChainAccountService.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index 46f8b8a36..44a756dbe 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -378,7 +378,7 @@ describe('OnChainAccountService', () => { }); describe('fetchAndBindSep41Balances', () => { - it('binds balances resolved by fetchSep41AssetsByAssetIds', async () => { + it('binds balances resolved by fetchAndPersistAssetsByAssetIds', async () => { const accountAddress = generateStellarAddress(); const scope = KnownCaip2ChainId.Mainnet; const loadedAcc = createMockAccountWithBalances( @@ -400,7 +400,10 @@ describe('OnChainAccountService', () => { throw new Error('expected USDC SEP-41 metadata'); } const fetchMetadataSpy = jest - .spyOn(AssetMetadataService.prototype, 'fetchSep41AssetsByAssetIds') + .spyOn( + AssetMetadataService.prototype, + 'fetchAndPersistAssetsByAssetIds', + ) .mockResolvedValue([usdcSep41]); getSep41AssetBalancesSpy.mockResolvedValue({ [accountAddress]: { From a422f72a4a24946378cc3695ccbc71fa302e604e Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:04:43 +0800 Subject: [PATCH 34/50] fix: comment --- .../src/handlers/keyring/keyring.ts | 23 +++++++++++++++++++ .../asset-metadata/AssetMetadataService.ts | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index fd171ad6d..3188c759e 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -215,6 +215,16 @@ export class KeyringHandler implements KeyringSnapRpc { return createdAccounts.map(asStrictKeyringAccount); } + /** + * Returns the assets of an account. + * + * It gated by local config `enableAssetMigration`. + * When `enableAssetMigration` is true, the account is fetched from on-chain. + * When `enableAssetMigration` is false, the account is fetched from the state. + * + * @param accountId - The id of the account to get the assets for. + * @returns The assets of the account. + */ async getAccountAssets(accountId: string): Promise { validateRequest(accountId, ListAccountAssetsRequestStruct); @@ -222,6 +232,7 @@ export class KeyringHandler implements KeyringSnapRpc { let onChainAccount: OnChainAccount | null = null; + // TDOO: remove this gate after asset migration is fully enabled. if (enableAssetMigration) { onChainAccount = await this.#fetchOnChainAccountWithCache({ accountId, @@ -325,6 +336,17 @@ export class KeyringHandler implements KeyringSnapRpc { return activityOnScopes.some((active) => active); } + /** + * Returns the balances of an account for the given assets. + * + * It gated by local config `enableAssetMigration`. + * When `enableAssetMigration` is true, the account is fetched from on-chain. + * When `enableAssetMigration` is false, the account is fetched from the state. + * + * @param accountId - The id of the account to get the balances for. + * @param assets - The assets to get the balances for. + * @returns The balances of the account. + */ async getAccountBalances( accountId: string, assets: CaipAssetType[], @@ -340,6 +362,7 @@ export class KeyringHandler implements KeyringSnapRpc { let onChainAccount: OnChainAccount | null = null; + // TDOO: remove this gate after asset migration is fully enabled. if (enableAssetMigration) { onChainAccount = await this.#fetchOnChainAccountWithCache({ accountId, 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 04fcf57a7..ed2bcd20d 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -95,7 +95,7 @@ export class AssetMetadataService { /** * Resolves asset metadata for ids that are not in the watched catalog. * - * @param assetIds - Aasset ids to resolve. + * @param assetIds - Asset ids to resolve. * @returns Metadata for the ids that could be resolved. Missing ids are omitted. */ async fetchAndPersistAssetsByAssetIds( From a8cdc553babe42b8c45ad52dabc18782cd973430 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:39:52 +0800 Subject: [PATCH 35/50] fix: rename method --- .../src/handlers/accountResolver.test.ts | 26 +++++------- .../src/handlers/accountResolver.ts | 2 +- .../clientRequest/onAmountInput.test.ts | 21 +++++----- .../src/handlers/keyring/keyring.test.ts | 40 ++++--------------- .../src/handlers/keyring/keyring.ts | 6 +-- .../OnChainAccountService.test.ts | 39 +++++++++--------- .../on-chain-account/OnChainAccountService.ts | 3 +- 7 files changed, 51 insertions(+), 86 deletions(-) diff --git a/packages/stellar-wallet-snap/src/handlers/accountResolver.test.ts b/packages/stellar-wallet-snap/src/handlers/accountResolver.test.ts index 81eb84000..f6cd67f74 100644 --- a/packages/stellar-wallet-snap/src/handlers/accountResolver.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/accountResolver.test.ts @@ -65,9 +65,9 @@ describe('AccountResolver', () => { const resolveOnChainAccountSpy = jest .spyOn(OnChainAccountService.prototype, 'resolveOnChainAccount') .mockResolvedValue(onChainAccount); - const resolveOnChainAccountByKeyringAccountIdSpy = jest.spyOn( + const getOnChainAccountSnapshotSpy = jest.spyOn( OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', + 'getOnChainAccountSnapshot', ); const resolveWalletSpy = jest .spyOn(WalletService.prototype, 'resolveWallet') @@ -86,7 +86,7 @@ describe('AccountResolver', () => { onChainAccount, resolveAccountSpy, resolveOnChainAccountSpy, - resolveOnChainAccountByKeyringAccountIdSpy, + getOnChainAccountSnapshotSpy, resolveWalletSpy, }; } @@ -156,12 +156,10 @@ describe('AccountResolver', () => { account, wallet, onChainAccount, - resolveOnChainAccountByKeyringAccountIdSpy, + getOnChainAccountSnapshotSpy, resolveOnChainAccountSpy, } = setup(); - resolveOnChainAccountByKeyringAccountIdSpy.mockResolvedValue( - onChainAccount, - ); + getOnChainAccountSnapshotSpy.mockResolvedValue(onChainAccount); const result = await accountResolver.resolveAccount({ accountId: keyringAccountId, @@ -174,7 +172,7 @@ describe('AccountResolver', () => { onChainAccount, wallet, }); - expect(resolveOnChainAccountByKeyringAccountIdSpy).toHaveBeenCalledWith( + expect(getOnChainAccountSnapshotSpy).toHaveBeenCalledWith( account.id, account.address, scope, @@ -183,9 +181,8 @@ describe('AccountResolver', () => { }); it('throws AccountNotActivatedException when state has no on-chain snapshot', async () => { - const { accountResolver, resolveOnChainAccountByKeyringAccountIdSpy } = - setup(); - resolveOnChainAccountByKeyringAccountIdSpy.mockResolvedValue(null); + const { accountResolver, getOnChainAccountSnapshotSpy } = setup(); + getOnChainAccountSnapshotSpy.mockResolvedValue(null); await expect( accountResolver.resolveAccount({ @@ -197,8 +194,7 @@ describe('AccountResolver', () => { }); it('throws DerivedAccountAddressMismatchException when state snapshot address differs from keyring', async () => { - const { accountResolver, resolveOnChainAccountByKeyringAccountIdSpy } = - setup(); + const { accountResolver, getOnChainAccountSnapshotSpy } = setup(); const otherAddress = generateStellarAddress(); const mockRawAccount = createMockAccountWithBalances( @@ -211,9 +207,7 @@ describe('AccountResolver', () => { scope, horizonSource(mockRawAccount, scope), ); - resolveOnChainAccountByKeyringAccountIdSpy.mockResolvedValue( - mismatchedOnChainAccount, - ); + getOnChainAccountSnapshotSpy.mockResolvedValue(mismatchedOnChainAccount); await expect( accountResolver.resolveAccount({ diff --git a/packages/stellar-wallet-snap/src/handlers/accountResolver.ts b/packages/stellar-wallet-snap/src/handlers/accountResolver.ts index 755f423cc..1f29f724d 100644 --- a/packages/stellar-wallet-snap/src/handlers/accountResolver.ts +++ b/packages/stellar-wallet-snap/src/handlers/accountResolver.ts @@ -161,7 +161,7 @@ export class AccountResolver { ); } const onChainAccount = - await this.#onChainAccountService.resolveOnChainAccountByKeyringAccountId( + await this.#onChainAccountService.getOnChainAccountSnapshot( account.id, account.address, scope, diff --git a/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts index 270f7db0b..b1a87cf3d 100644 --- a/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts @@ -83,11 +83,8 @@ describe('OnAmountInputHandler', () => { jest.spyOn(AccountService.prototype, 'resolveAccount').mockResolvedValue({ account, }); - const resolveOnChainAccountByKeyringAccountIdSpy = jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) + const getOnChainAccountSnapshotSpy = jest + .spyOn(OnChainAccountService.prototype, 'getOnChainAccountSnapshot') .mockResolvedValue(onChainAccount); jest .spyOn(WalletService.prototype, 'resolveWallet') @@ -125,7 +122,7 @@ describe('OnAmountInputHandler', () => { onChainAccount, wallet, createValidatedSendTransaction, - resolveOnChainAccountByKeyringAccountIdSpy, + getOnChainAccountSnapshotSpy, }; } @@ -177,13 +174,13 @@ describe('OnAmountInputHandler', () => { account, onChainAccount, createValidatedSendTransaction, - resolveOnChainAccountByKeyringAccountIdSpy, + getOnChainAccountSnapshotSpy, } = setup(); const result = await handler.handle(baseRequest()); expect(result).toStrictEqual({ valid: true, errors: [] }); - expect(resolveOnChainAccountByKeyringAccountIdSpy).toHaveBeenCalledWith( + expect(getOnChainAccountSnapshotSpy).toHaveBeenCalledWith( account.id, account.address, scope, @@ -205,12 +202,12 @@ describe('OnAmountInputHandler', () => { handler, account, createValidatedSendTransaction, - resolveOnChainAccountByKeyringAccountIdSpy, + getOnChainAccountSnapshotSpy, } = setup(); await handler.handle(baseRequest({ assetId: testnetAssetId })); - expect(resolveOnChainAccountByKeyringAccountIdSpy).toHaveBeenCalledWith( + expect(getOnChainAccountSnapshotSpy).toHaveBeenCalledWith( account.id, account.address, KnownCaip2ChainId.Testnet, @@ -291,8 +288,8 @@ describe('OnAmountInputHandler', () => { }); it('returns invalid when keyring state has no on-chain snapshot', async () => { - const { handler, resolveOnChainAccountByKeyringAccountIdSpy } = setup(); - resolveOnChainAccountByKeyringAccountIdSpy.mockResolvedValueOnce(null); + const { handler, getOnChainAccountSnapshotSpy } = setup(); + getOnChainAccountSnapshotSpy.mockResolvedValueOnce(null); expect(await handler.handle(baseRequest())).toStrictEqual({ valid: false, diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts index c0d37d64f..eab9b0e68 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -480,10 +480,7 @@ describe('KeyringHandler', () => { resolveAccountSpy.mockResolvedValue({ account: mockAccount }); const onChainAccount = createTestOnChainAccount(mockAccount.address); const resolveByKeyringAccountIdSpy = jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) + .spyOn(OnChainAccountService.prototype, 'getOnChainAccountSnapshot') .mockResolvedValue(onChainAccount); const resolveOnChainAccountSpy = jest.spyOn( OnChainAccountService.prototype, @@ -506,10 +503,7 @@ describe('KeyringHandler', () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) + .spyOn(OnChainAccountService.prototype, 'getOnChainAccountSnapshot') .mockResolvedValue(null); const result = await keyringHandler.getAccountAssets(mockAccountId); @@ -521,10 +515,7 @@ describe('KeyringHandler', () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) + .spyOn(OnChainAccountService.prototype, 'getOnChainAccountSnapshot') .mockRejectedValue(new Error('state unavailable')); await expect( @@ -694,10 +685,7 @@ describe('KeyringHandler', () => { nativeBalance: 2.000001, }); const resolveByKeyringAccountIdSpy = jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) + .spyOn(OnChainAccountService.prototype, 'getOnChainAccountSnapshot') .mockResolvedValue(onChainAccount); const resolveOnChainAccountSpy = jest.spyOn( OnChainAccountService.prototype, @@ -743,10 +731,7 @@ describe('KeyringHandler', () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) + .spyOn(OnChainAccountService.prototype, 'getOnChainAccountSnapshot') .mockResolvedValue(null); const fetchAndBindSpy = jest.spyOn( OnChainAccountService.prototype, @@ -777,10 +762,7 @@ describe('KeyringHandler', () => { resolveAccountSpy.mockResolvedValue({ account: mockAccount }); const onChainAccount = createTestOnChainAccount(mockAccount.address); jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) + .spyOn(OnChainAccountService.prototype, 'getOnChainAccountSnapshot') .mockResolvedValue(onChainAccount); const fetchAndBindSpy = bindRequestedSep41Balances(); @@ -811,10 +793,7 @@ describe('KeyringHandler', () => { decimals: 7, }); jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) + .spyOn(OnChainAccountService.prototype, 'getOnChainAccountSnapshot') .mockResolvedValue(onChainAccount); const fetchAndBindSpy = jest.spyOn( OnChainAccountService.prototype, @@ -840,10 +819,7 @@ describe('KeyringHandler', () => { const { resolveAccountSpy } = getAccountServiceSpies(); resolveAccountSpy.mockResolvedValue({ account: mockAccount }); jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) + .spyOn(OnChainAccountService.prototype, 'getOnChainAccountSnapshot') .mockRejectedValue(new Error('state unavailable')); await expect( diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 3188c759e..3bbd0bdab 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -218,7 +218,7 @@ export class KeyringHandler implements KeyringSnapRpc { /** * Returns the assets of an account. * - * It gated by local config `enableAssetMigration`. + * It is gated by local config `enableAssetMigration`. * When `enableAssetMigration` is true, the account is fetched from on-chain. * When `enableAssetMigration` is false, the account is fetched from the state. * @@ -339,7 +339,7 @@ export class KeyringHandler implements KeyringSnapRpc { /** * Returns the balances of an account for the given assets. * - * It gated by local config `enableAssetMigration`. + * It is gated by local config `enableAssetMigration`. * When `enableAssetMigration` is true, the account is fetched from on-chain. * When `enableAssetMigration` is false, the account is fetched from the state. * @@ -567,7 +567,7 @@ export class KeyringHandler implements KeyringSnapRpc { const { account } = await this.#accountService.resolveAccount({ accountId, }); - return this.#onChainAccountService.resolveOnChainAccountByKeyringAccountId( + return this.#onChainAccountService.getOnChainAccountSnapshot( accountId, account.address, scope, diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index 44a756dbe..31d5fdf16 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -430,7 +430,7 @@ describe('OnChainAccountService', () => { }); }); - describe('resolveOnChainAccountByKeyringAccountId', () => { + describe('getOnChainAccountSnapshot', () => { it('returns null when no snapshot exists for the keyring id and scope', async () => { const keyringAccountId = globalThis.crypto.randomUUID(); const accountAddress = Keypair.random().publicKey(); @@ -443,12 +443,11 @@ describe('OnChainAccountService', () => { ); findByAccountIdSpy.mockResolvedValue(null); - const result = - await onChainAccountService.resolveOnChainAccountByKeyringAccountId( - keyringAccountId, - accountAddress, - KnownCaip2ChainId.Mainnet, - ); + const result = await onChainAccountService.getOnChainAccountSnapshot( + keyringAccountId, + accountAddress, + KnownCaip2ChainId.Mainnet, + ); expect(result).toBeNull(); expect(findByAccountIdSpy).toHaveBeenCalledWith( @@ -479,12 +478,11 @@ describe('OnChainAccountService', () => { ); findByAccountIdSpy.mockResolvedValue(binding); - const result = - await onChainAccountService.resolveOnChainAccountByKeyringAccountId( - keyringAccountId, - signer.publicKey(), - KnownCaip2ChainId.Mainnet, - ); + const result = await onChainAccountService.getOnChainAccountSnapshot( + keyringAccountId, + signer.publicKey(), + KnownCaip2ChainId.Mainnet, + ); expect(result).toBeInstanceOf(OnChainAccount); expect(result?.accountId).toStrictEqual(signer.publicKey()); @@ -508,7 +506,7 @@ describe('OnChainAccountService', () => { ); expect( - await onChainAccountService.resolveOnChainAccountByKeyringAccountId( + await onChainAccountService.getOnChainAccountSnapshot( keyringAccountId, Keypair.random().publicKey(), KnownCaip2ChainId.Mainnet, @@ -565,12 +563,11 @@ describe('OnChainAccountService', () => { }, ]); - const result = - await onChainAccountService.resolveOnChainAccountByKeyringAccountId( - keyringAccountId, - accountAddress, - KnownCaip2ChainId.Mainnet, - ); + const result = await onChainAccountService.getOnChainAccountSnapshot( + keyringAccountId, + accountAddress, + KnownCaip2ChainId.Mainnet, + ); expect(result).toBeInstanceOf(OnChainAccount); const full = result?.toSerializableFull(); @@ -640,7 +637,7 @@ describe('OnChainAccountService', () => { let caught: unknown; try { - await onChainAccountService.resolveOnChainAccountByKeyringAccountId( + await onChainAccountService.getOnChainAccountSnapshot( keyringAccountId, accountAddress, KnownCaip2ChainId.Mainnet, diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 2b0e2c154..5db0e2280 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -313,13 +313,14 @@ export class OnChainAccountService { /** * Loads the on-chain account for the given keyring account id from snap state or AssetsController. * + * @note Do not use this method to build a transaction for sign and execute. * @param keyringAccountId - The keyring account id to load the on-chain account for. * @param accountAddress - Stellar G-address for the account header. * @param scope - The CAIP-2 chain id to load the on-chain account for. * @returns The on-chain account, or `null` if not found. * @throws {OnChainAccountException} When the assets migration is on and AssetsController holdings cannot be mapped. */ - async resolveOnChainAccountByKeyringAccountId( + async getOnChainAccountSnapshot( keyringAccountId: string, accountAddress: string, scope: KnownCaip2ChainId, From 853c0701ccf040fc7565d91a6e37ccda8f092fae Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:04:36 +0800 Subject: [PATCH 36/50] Delete horizon-event-fixture.mjs --- .../scripts/horizon-event-fixture.mjs | 2153 ----------------- 1 file changed, 2153 deletions(-) delete mode 100644 packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs diff --git a/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs b/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs deleted file mode 100644 index 1aaff8e9e..000000000 --- a/packages/stellar-wallet-snap/scripts/horizon-event-fixture.mjs +++ /dev/null @@ -1,2153 +0,0 @@ -/** - * Mainnet Horizon event fixture: sign + broadcast from two secret keys, then roll back. - * - * Secrets (never commit these). Either pair works: - * STELLAR_SECRET_1 / STELLAR_SECRET_2 - * STELLAR_PK_1 / STELLAR_PK_2 - * - * Optional: - * STELLAR_SECRET_SPONSORED / STELLAR_PK_SPONSORED - * required by `account-merge` (that account merges into account 1) - * optional for `sponsored-account`; random when omitted - * HORIZON_URL default https://horizon.stellar.org - * XLM_AMOUNT default 0.0001 (native payment / path / claimable) - * TRUSTLINE_AMOUNT default same as XLM_AMOUNT - * TRUSTLINE_CODE default AQUA - * TRUSTLINE_ISSUER default GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA - * TRUSTLINE_LIMIT default 1000 (non-zero limit update in trustline-metadata) - * BASE_FEE_STROOPS default 100 - * DRY_RUN=1 build + sign, print XDR, do not submit - * YES=1 skip the interactive mainnet confirm - * - * Usage (from packages/stellar-wallet-snap): - * node --env-file=.env scripts/horizon-event-fixture.mjs status - * node --env-file=.env scripts/horizon-event-fixture.mjs list - * node --env-file=.env scripts/horizon-event-fixture.mjs setup balance-move - * node --env-file=.env scripts/horizon-event-fixture.mjs teardown balance-move - * node --env-file=.env scripts/horizon-event-fixture.mjs setup fee-bump - * node --env-file=.env scripts/horizon-event-fixture.mjs setup claimable [asset-id] - * node --env-file=.env scripts/horizon-event-fixture.mjs setup two-receivers - * node --env-file=.env scripts/horizon-event-fixture.mjs setup trustline-limit [limit] - * node --env-file=.env scripts/horizon-event-fixture.mjs teardown trustline-limit - * node --env-file=.env scripts/horizon-event-fixture.mjs remove-trustline - * - * `remove-trustline` signs with the env secret for that address. The asset id - * is `stellar:pubnet/asset:CODE-ISSUER` or `CODE-ISSUER`. The balance must - * already be 0. There is no teardown. - * `claimable` uses the same asset id and defaults to XLM when it is omitted. - * A classic asset uses TRUSTLINE_AMOUNT and adds account2's trustline when missing. - * `trustline-limit` changes only account1's TRUSTLINE_ASSET limit (default AQUA). - * Pass a limit or omit it to use TRUSTLINE_LIMIT. The line must already exist. - * Teardown restores it. - * - * Cases that need an id or an ephemeral key (claimable, offers, sponsored - * accounts) save it in scripts/.horizon-fixture-state.json. That file is - * gitignored. Run teardown to roll the case back. - */ - -import { - Asset, - Claimant, - getLiquidityPoolId, - Horizon, - Keypair, - LiquidityPoolAsset, - LiquidityPoolFeeV18, - Networks, - Operation, - StrKey, - TransactionBuilder, -} from '@stellar/stellar-sdk'; -import { readFileSync, writeFileSync } from 'node:fs'; -import { createInterface } from 'node:readline/promises'; - -const HORIZON_URL = process.env.HORIZON_URL ?? 'https://horizon.stellar.org'; -const NETWORK_PASSPHRASE = Networks.PUBLIC; -const EXPLORER_TX = 'https://stellar.expert/explorer/public/tx'; -const XLM_AMOUNT = process.env.XLM_AMOUNT ?? '0.0001'; -const TRUSTLINE_AMOUNT = process.env.TRUSTLINE_AMOUNT ?? XLM_AMOUNT; -const TRUSTLINE_ASSET = new Asset( - process.env.TRUSTLINE_CODE ?? 'AQUA', - process.env.TRUSTLINE_ISSUER ?? - 'GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA', -); -const BASE_FEE = Number(process.env.BASE_FEE_STROOPS ?? '100'); -const DRY_RUN = process.env.DRY_RUN === '1'; -const SKIP_CONFIRM = process.env.YES === '1'; - -const EURC = new Asset( - 'EURC', - 'GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2', -); -const PYUSD = new Asset( - 'PYUSD', - 'GDQE7IXJ4HUHV6RQHIUPRJSEZE4DRS5WY577O2FY6YQ5LVWZ7JZTU2V5', -); -const TRUSTLINE_LIMIT = process.env.TRUSTLINE_LIMIT ?? '1000'; - -const server = new Horizon.Server(HORIZON_URL); - -/** - * `balance-move` / `balance-move-trustline` credit/debit existing accounts. - * `trustline-metadata` covers add / limit-update / sponsored-create. - * `fee-bump` wraps an account2 inner changeTrust; account1 pays the fee. - * `authorized` is issuer-only (`setTrustLineFlags`); skip for EURC, TRUSTLINE_ASSET, and PYUSD. - * Clawback of a holder's balance is intentionally not a case. - * `claimable-clawback` only runs when account1 is the issuer of TRUSTLINE_ASSET. - */ - -/** - * @typedef {object} AccountBinding - * @property {import('@stellar/stellar-sdk').Keypair} keypair - * @property {import('@stellar/stellar-sdk').Horizon.AccountResponse} account - */ - -/** - * @typedef {object} Accounts - * @property {AccountBinding} account1 - * @property {AccountBinding} account2 - * @property {AccountBinding} [sponsoredAccount] - * Set when `STELLAR_SECRET_SPONSORED` or `STELLAR_PK_SPONSORED` is present. - * `account` is null when that key is not on chain yet. - */ - -/** - * @typedef {object} PlannedTx - * @property {string} label - * @property {import('@stellar/stellar-sdk').Keypair} signer - * @property {import('@stellar/stellar-sdk').xdr.Operation[]} operations - * @property {import('@stellar/stellar-sdk').Keypair[]} [signers] - * @property {import('@stellar/stellar-sdk').Keypair} [feeSource] - * When set, wrap the inner tx (source = `signer`) in a fee-bump paid by this key. - */ - -/** - * @typedef {object} SubmitResult - * @property {string} hash - * @property {unknown} [raw] - */ - -/** - * @typedef {object} CaseHelpers - * @property {(plan: PlannedTx) => Promise} submit - */ - -/** - * @typedef {object} Case - * @property {string} description - * @property {(accounts: Accounts, helpers: CaseHelpers) => Promise} setup - * @property {(accounts: Accounts, helpers: CaseHelpers) => Promise} teardown - */ - -function assetLabel(asset) { - return asset.isNative() ? 'XLM' : `${asset.getCode()}-${asset.getIssuer()}`; -} - -function paymentOp(destination, asset, amount) { - return Operation.payment({ - destination, - asset, - amount, - }); -} - -function pathSendOp(destination, asset, amount) { - return Operation.pathPaymentStrictSend({ - sendAsset: asset, - sendAmount: amount, - destination, - destAsset: asset, - destMin: amount, - path: [], - }); -} - -function pathReceiveOp(destination, asset, amount) { - return Operation.pathPaymentStrictReceive({ - sendAsset: asset, - sendMax: amount, - destination, - destAsset: asset, - destAmount: amount, - path: [], - }); -} - -function nativePayment(destination, amount = XLM_AMOUNT) { - return paymentOp(destination, Asset.native(), amount); -} - -function heldTrustline(account) { - const row = account.balances.find( - (entry) => - (entry.asset_type === 'credit_alphanum4' || - entry.asset_type === 'credit_alphanum12') && - Number(entry.balance) > 0, - ); - if (!row) { - throw new Error('account1 has no trustline asset with a balance'); - } - const amount = - Number(row.balance) < Number(TRUSTLINE_AMOUNT) - ? row.balance - : TRUSTLINE_AMOUNT; - return { - asset: new Asset(row.asset_code, row.asset_issuer), - amount, - }; -} - -function findTrustline(account, asset) { - if (asset.isNative()) { - const row = account.balances.find((entry) => entry.asset_type === 'native'); - return { exists: true, balance: row?.balance ?? '0' }; - } - const row = account.balances.find( - (entry) => - entry.asset_code === asset.getCode() && - entry.asset_issuer === asset.getIssuer(), - ); - return row - ? { - exists: true, - balance: row.balance, - limit: row.limit, - sponsor: row.sponsor, - } - : { exists: false, balance: '0' }; -} - -function nonZeroLimitAtLeastBalance(line, requested) { - const requestedN = Number(requested); - const balanceN = line.exists ? Number(line.balance) : 0; - let limit = Number.isFinite(requestedN) && requestedN > 0 ? requestedN : 1000; - if (limit < balanceN) { - limit = balanceN; - } - if (line.exists && Number(line.limit) === limit) { - limit += 1; - } - return String(limit); -} - -function requestedLimit(requested, line, label) { - const value = Number(requested); - if (!Number.isFinite(value) || value <= 0) { - throw new Error( - `Invalid limit "${requested}". Use a number greater than 0.`, - ); - } - if (value < Number(line.balance)) { - throw new Error( - `limit ${requested} is below ${label} balance ${line.balance}`, - ); - } - return requested; -} - -function isIssuer(binding, asset) { - return !asset.isNative() && binding.keypair.publicKey() === asset.getIssuer(); -} - -async function ensureTrustlineIfNeeded(binding, asset, submit, label) { - if (asset.isNative() || isIssuer(binding, asset)) { - return; - } - const { exists } = findTrustline(binding.account, asset); - if (exists) { - return; - } - await submit({ - label: `${label}: changeTrust ${assetLabel(asset)}`, - signer: binding.keypair, - operations: [Operation.changeTrust({ asset })], - }); - binding.account = await server.loadAccount(binding.keypair.publicKey()); -} - -function assertSpendable(binding, asset, amount) { - if (isIssuer(binding, asset)) { - return; - } - const needed = Number(amount) * 4; - const { exists, balance } = findTrustline(binding.account, asset); - if (!exists) { - throw new Error( - `account1 has no trustline for ${assetLabel( - asset, - )}; enable it first or run setup so it is added`, - ); - } - if (Number(balance) < needed) { - throw new Error( - `account1 ${assetLabel( - asset, - )} balance ${balance} < ${needed} needed (4Ɨ ${amount})`, - ); - } -} - -async function runBalanceMoveSetup({ - account1, - account2, - submit, - asset, - amount, -}) { - const dest2 = account2.keypair.publicKey(); - const label = assetLabel(asset); - - await ensureTrustlineIfNeeded(account2, asset, submit, 'account2'); - await ensureTrustlineIfNeeded(account1, asset, submit, 'account1'); - account1.account = await server.loadAccount(account1.keypair.publicKey()); - assertSpendable(account1, asset, amount); - - const tx1 = await submit({ - label: `account1: payment, pathSend, pathReceive, createClaimable (${label})`, - signer: account1.keypair, - operations: [ - paymentOp(dest2, asset, amount), - pathSendOp(dest2, asset, amount), - pathReceiveOp(dest2, asset, amount), - Operation.createClaimableBalance({ - asset, - amount, - claimants: [new Claimant(dest2, Claimant.predicateUnconditional())], - }), - ], - }); - - if (DRY_RUN) { - console.log(' DRY_RUN: skip claimClaimableBalance'); - return; - } - - const claimableBalanceId = await claimableBalanceIdFromTx(tx1.hash); - console.log(` claimable ${claimableBalanceId}`); - - await submit({ - label: `account2: claimClaimableBalance (${label})`, - signer: account2.keypair, - operations: [ - Operation.claimClaimableBalance({ - balanceId: claimableBalanceId, - }), - ], - }); -} - -async function runBalanceMoveTeardown({ - account1, - account2, - submit, - asset, - amount, -}) { - const label = assetLabel(asset); - const dest1 = account1.keypair.publicKey(); - - if (asset.isNative()) { - const returned = String(Number(amount) * 4); - await submit({ - label: `account2: return ${returned} ${label} (4Ɨ ${amount})`, - signer: account2.keypair, - operations: [paymentOp(dest1, asset, returned)], - }); - return; - } - - account2.account = await server.loadAccount(account2.keypair.publicKey()); - const line = findTrustline(account2.account, asset); - if (!line.exists) { - console.log(` account2 has no ${label} trustline; nothing to remove`); - return; - } - - const operations = []; - if (Number(line.balance) > 0) { - operations.push(paymentOp(dest1, asset, line.balance)); - } - operations.push(Operation.changeTrust({ asset, limit: '0' })); - - await submit({ - label: - Number(line.balance) > 0 - ? `account2: send remaining ${line.balance} ${label} to account1, then remove trustline` - : `account2: remove ${label} trustline (balance already 0)`, - signer: account2.keypair, - operations, - }); -} - -async function claimableBalanceIdFromTx(hash) { - const page = await server.effects().forTransaction(hash).limit(50).call(); - const created = page.records.find( - (row) => row.type === 'claimable_balance_created', - ); - if (!created?.balance_id) { - throw new Error( - `No claimable_balance_created effect on ${hash}. Set DRY_RUN=0 and submit setup tx1 first.`, - ); - } - return created.balance_id; -} - -const STATE_PATH = new URL('./.horizon-fixture-state.json', import.meta.url); - -function readStateFile() { - try { - return JSON.parse(readFileSync(STATE_PATH, 'utf8')); - } catch (error) { - if (error.code === 'ENOENT') { - return {}; - } - throw error; - } -} - -function writeCaseState(caseName, value) { - const state = readStateFile(); - if (value === undefined) { - delete state[caseName]; - } else { - state[caseName] = value; - } - writeFileSync(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`); -} - -function remember(caseName, value) { - if (DRY_RUN) { - console.log(` DRY_RUN: not saving state for ${caseName}`); - return; - } - writeCaseState(caseName, value); -} - -function recall(caseName) { - const value = readStateFile()[caseName]; - if (!value) { - throw new Error( - `No saved state for ${caseName}. Run setup before teardown.`, - ); - } - return value; -} - -function forget(caseName) { - if (!DRY_RUN) { - writeCaseState(caseName, undefined); - } -} - -function requireAccount(binding, label) { - if (!binding.account) { - throw new Error(`${label} ${binding.keypair.publicKey()} does not exist`); - } -} - -/** - * @param {AccountBinding | undefined} binding - * @returns {asserts binding is AccountBinding} - */ -function requireSponsoredAccount(binding) { - if (!binding) { - throw new Error( - 'Missing STELLAR_SECRET_SPONSORED (or STELLAR_PK_SPONSORED)', - ); - } -} - -function assertBalanceAtLeast(binding, asset, amount) { - if (isIssuer(binding, asset)) { - return; - } - const { exists, balance } = findTrustline(binding.account, asset); - if (!exists) { - throw new Error(`${assetLabel(asset)} trustline is missing`); - } - if (Number(balance) < Number(amount)) { - throw new Error( - `${assetLabel(asset)} balance ${balance} < ${amount} required`, - ); - } -} - -function effectMatchesAsset(entry, asset) { - if (asset.isNative()) { - return entry.asset_type === 'native'; - } - return ( - entry.asset_code === asset.getCode() && - entry.asset_issuer === asset.getIssuer() - ); -} - -async function creditedAmount(hash, publicKey, asset) { - const page = await server.effects().forTransaction(hash).limit(100).call(); - const row = page.records.find( - (entry) => - entry.type === 'account_credited' && - entry.account === publicKey && - effectMatchesAsset(entry, asset), - ); - if (!row?.amount) { - throw new Error( - `No credit of ${assetLabel(asset)} for ${publicKey} on ${hash}`, - ); - } - return row.amount; -} - -async function listOfferIds(publicKey) { - const page = await server.offers().forAccount(publicKey).limit(200).call(); - return page.records.map((row) => row.id); -} - -/** - * Offer id created by `hash` that the same transaction did not remove. - * - * @param {string} hash - Submitted transaction hash. - * @returns {Promise} - */ -async function restingOfferIdFromTx(hash) { - const page = await server.effects().forTransaction(hash).limit(50).call(); - const removed = new Set( - page.records - .filter((row) => row.type === 'offer_removed') - .map((row) => String(row.offer_id)), - ); - const created = page.records.find( - (row) => row.type === 'offer_created' && !removed.has(String(row.offer_id)), - ); - return created ? String(created.offer_id) : null; -} - -/** - * @param {string} offerId - * @returns {Promise} - */ -async function loadOfferIfExists(offerId) { - try { - return await server.offers().offer(offerId).call(); - } catch (error) { - if (isNotFound(error)) { - return null; - } - throw error; - } -} - -/** - * @param {{ asset_type: string, asset_code?: string, asset_issuer?: string }} asset - * @returns {import('@stellar/stellar-sdk').Asset} - */ -function horizonAsset(asset) { - if (asset.asset_type === 'native') { - return Asset.native(); - } - return new Asset(asset.asset_code, asset.asset_issuer); -} - -function feeXlm(opCount = 1) { - return (BASE_FEE * opCount) / 10_000_000; -} - -function trimDecimal(value) { - return Number(value).toFixed(7).replace(/0+$/u, '').replace(/\.$/u, ''); -} - -function amountToStroops(amount) { - const [whole, frac = ''] = String(amount).split('.'); - if (!/^\d+$/u.test(whole) || !/^\d*$/u.test(frac) || frac.length > 7) { - throw new Error(`Invalid amount ${amount}`); - } - return BigInt(whole) * 10_000_000n + BigInt(frac.padEnd(7, '0')); -} - -function stroopsToAmount(stroops) { - const negative = stroops < 0n; - const value = negative ? -stroops : stroops; - const whole = value / 10_000_000n; - const frac = (value % 10_000_000n) - .toString() - .padStart(7, '0') - .replace(/0+$/u, ''); - const text = frac.length > 0 ? `${whole}.${frac}` : `${whole}`; - return negative ? `-${text}` : text; -} - -function constantProductPool() { - const native = Asset.native(); - const [assetA, assetB] = - Asset.compare(native, TRUSTLINE_ASSET) < 0 - ? [native, TRUSTLINE_ASSET] - : [TRUSTLINE_ASSET, native]; - const fee = LiquidityPoolFeeV18; - return { - assetA, - assetB, - poolAsset: new LiquidityPoolAsset(assetA, assetB, fee), - poolId: Buffer.from( - getLiquidityPoolId('constant_product', { assetA, assetB, fee }), - ).toString('hex'), - }; -} - -function poolShare(account, poolId) { - const row = account.balances.find( - (entry) => - entry.asset_type === 'liquidity_pool_shares' && - entry.liquidity_pool_id === poolId, - ); - return row - ? { exists: true, balance: row.balance } - : { exists: false, balance: '0' }; -} - -function pathSwapOp(destination, sendAsset, sendAmount, destAsset) { - return Operation.pathPaymentStrictSend({ - sendAsset, - sendAmount, - destination, - destAsset, - destMin: '0.0000001', - path: [], - }); -} - -async function bestBid(selling, buying) { - const book = await server.orderbook(selling, buying).call(); - const bid = book.bids?.[0]; - if (!bid?.price || !bid.amount) { - throw new Error( - `No bid on ${assetLabel(selling)}/${assetLabel( - buying, - )}; cannot force a fill`, - ); - } - return bid; -} - -/** @type {Record} */ -const CASES = { - test1: { - description: `Account 1: payment (XLM → account 2) + changeTrust enable ${TRUSTLINE_ASSET.getCode()} in one tx`, - async setup({ account1, account2 }, { submit }) { - await submit({ - label: `account1 payment + enable ${assetLabel(TRUSTLINE_ASSET)}`, - signer: account1.keypair, - operations: [ - nativePayment(account2.keypair.publicKey()), - Operation.changeTrust({ asset: TRUSTLINE_ASSET }), - ], - }); - }, - async teardown({ account1, account2 }, { submit }) { - await submit({ - label: 'account2 return XLM', - signer: account2.keypair, - operations: [nativePayment(account1.keypair.publicKey())], - }); - await submit({ - label: `account1 remove ${assetLabel(TRUSTLINE_ASSET)} trustline`, - signer: account1.keypair, - operations: [ - Operation.changeTrust({ asset: TRUSTLINE_ASSET, limit: '0' }), - ], - }); - }, - }, - - /** - * Two txs: ops that credit/debit existing native (XLM) balances. - */ - 'balance-move': { - description: - '2 txs: native payment + both path-payments + createClaimable, then claim', - async setup(accounts, helpers) { - await runBalanceMoveSetup({ - ...accounts, - ...helpers, - asset: Asset.native(), - amount: XLM_AMOUNT, - }); - }, - async teardown(accounts, helpers) { - await runBalanceMoveTeardown({ - ...accounts, - ...helpers, - asset: Asset.native(), - amount: XLM_AMOUNT, - }); - }, - }, - - /** - * Same balance ops as `balance-move`, using a classic trustline asset. - * Adds changeTrust first if a side is missing the line. Account 1 must already - * hold at least 4Ɨ TRUSTLINE_AMOUNT (unless it is the issuer). - * Teardown sweeps account2's remaining token to account1 in the same tx as - * changeTrust limit=0 (Stellar requires a 0 balance to drop a trustline). - * Account 1 keeps the tokens, so its trustline is left in place. - */ - 'balance-move-trustline': { - description: `same as balance-move for ${TRUSTLINE_ASSET.getCode()} trustline token`, - async setup(accounts, helpers) { - await runBalanceMoveSetup({ - ...accounts, - ...helpers, - asset: TRUSTLINE_ASSET, - amount: TRUSTLINE_AMOUNT, - }); - }, - async teardown(accounts, helpers) { - await runBalanceMoveTeardown({ - ...accounts, - ...helpers, - asset: TRUSTLINE_ASSET, - amount: TRUSTLINE_AMOUNT, - }); - }, - }, - - /** - * One tx (account 1 source, both keys sign): - * - add EURC trustline on account 1 - * - update TRUSTLINE_ASSET limit (non-zero) on account 1 - * - account 1 sponsors account 2 creating a PYUSD trustline - * - * Does not toggle `authorized` (issuer-only). - */ - 'trustline-metadata': { - description: `1 tx: add EURC, update ${TRUSTLINE_ASSET.getCode()} limit, sponsor PYUSD trustline on account2`, - async setup({ account1, account2 }, { submit }) { - const dest2 = account2.keypair.publicKey(); - const eurcLine = findTrustline(account1.account, EURC); - const heldLine = findTrustline(account1.account, TRUSTLINE_ASSET); - const pyusdLine = findTrustline(account2.account, PYUSD); - - if (pyusdLine.exists) { - throw new Error( - `account2 already has ${assetLabel( - PYUSD, - )}; teardown that line (balance 0) before sponsoring a create`, - ); - } - - const operations = []; - if (eurcLine.exists) { - console.log(` skip add EURC: account1 already has the trustline`); - } else { - operations.push(Operation.changeTrust({ asset: EURC })); - } - - const heldLimit = nonZeroLimitAtLeastBalance(heldLine, TRUSTLINE_LIMIT); - if (!heldLine.exists) { - operations.push(Operation.changeTrust({ asset: TRUSTLINE_ASSET })); - console.log( - ` ${assetLabel( - TRUSTLINE_ASSET, - )} missing: add then set limit ${heldLimit}`, - ); - } - operations.push( - Operation.changeTrust({ - asset: TRUSTLINE_ASSET, - limit: heldLimit, - }), - ); - - operations.push( - Operation.beginSponsoringFutureReserves({ sponsoredId: dest2 }), - Operation.changeTrust({ - asset: PYUSD, - source: dest2, - }), - Operation.endSponsoringFutureReserves({ source: dest2 }), - ); - - await submit({ - label: `account1+2: add EURC, ${assetLabel( - TRUSTLINE_ASSET, - )} limit ${heldLimit}, sponsor PYUSD on account2`, - signer: account1.keypair, - signers: [account2.keypair], - operations, - }); - }, - async teardown({ account1, account2 }, { submit }) { - account1.account = await server.loadAccount(account1.keypair.publicKey()); - account2.account = await server.loadAccount(account2.keypair.publicKey()); - - const account1Ops = []; - const eurcLine = findTrustline(account1.account, EURC); - if (eurcLine.exists && Number(eurcLine.balance) > 0) { - console.log( - ` skip remove EURC: balance ${eurcLine.balance} (send it away first)`, - ); - } else if (eurcLine.exists) { - account1Ops.push(Operation.changeTrust({ asset: EURC, limit: '0' })); - } - - const heldLine = findTrustline(account1.account, TRUSTLINE_ASSET); - if (heldLine.exists) { - account1Ops.push(Operation.changeTrust({ asset: TRUSTLINE_ASSET })); - } - - if (account1Ops.length > 0) { - await submit({ - label: `account1: remove EURC if empty, restore ${assetLabel( - TRUSTLINE_ASSET, - )} limit to max`, - signer: account1.keypair, - operations: account1Ops, - }); - } - - const pyusdLine = findTrustline(account2.account, PYUSD); - if (!pyusdLine.exists) { - return; - } - - const pyusdOps = []; - if (Number(pyusdLine.balance) > 0) { - const destLine = findTrustline(account1.account, PYUSD); - if (!destLine.exists) { - throw new Error( - `account2 PYUSD balance ${pyusdLine.balance} but account1 has no PYUSD trustline to receive a sweep`, - ); - } - pyusdOps.push( - paymentOp(account1.keypair.publicKey(), PYUSD, pyusdLine.balance), - ); - } - pyusdOps.push(Operation.changeTrust({ asset: PYUSD, limit: '0' })); - await submit({ - label: - Number(pyusdLine.balance) > 0 - ? 'account2: send remaining PYUSD to account1, then remove sponsored trustline' - : 'account2: remove sponsored PYUSD trustline', - signer: account2.keypair, - operations: pyusdOps, - }); - }, - }, - - /** - * changeTrust with a new non-zero limit on account1's TRUSTLINE_ASSET. - * The trustline must already exist. - * Teardown sets the limit back to the value saved at setup. - */ - 'trustline-limit': { - description: `change only account1's ${TRUSTLINE_ASSET.getCode()} trustline limit (optional arg, default ${TRUSTLINE_LIMIT}); teardown restores it`, - async setup({ account1 }, { submit }) { - requireAccount(account1, 'account1'); - const asset = TRUSTLINE_ASSET; - const label = assetLabel(asset); - const line = findTrustline(account1.account, asset); - if (!line.exists) { - throw new Error(`account1 has no ${label} trustline`); - } - const requested = process.argv[4]; - const nextLimit = - requested === undefined - ? nonZeroLimitAtLeastBalance(line, TRUSTLINE_LIMIT) - : requestedLimit(requested, line, label); - await submit({ - label: `account1: changeTrust ${label} limit ${line.limit} -> ${nextLimit}`, - signer: account1.keypair, - operations: [Operation.changeTrust({ asset, limit: nextLimit })], - }); - remember('trustline-limit', { - previousLimit: line.limit, - }); - }, - async teardown({ account1 }, { submit }) { - requireAccount(account1, 'account1'); - const { previousLimit } = recall('trustline-limit'); - account1.account = await server.loadAccount(account1.keypair.publicKey()); - const asset = TRUSTLINE_ASSET; - const label = assetLabel(asset); - const line = findTrustline(account1.account, asset); - if (!line.exists) { - throw new Error(`account1 no longer has ${label}`); - } - if (Number(line.balance) > Number(previousLimit)) { - throw new Error( - `${label} balance ${line.balance} is above the previous limit ${previousLimit}`, - ); - } - await submit({ - label: `account1: restore ${label} limit ${previousLimit}`, - signer: account1.keypair, - operations: [Operation.changeTrust({ asset, limit: previousLimit })], - }); - forget('trustline-limit'); - }, - }, - - /** - * Inner tx source = account 2 (create / remove a trustline). - * Outer fee-bump source = account 1 (pays the inclusion fee). - */ - 'fee-bump': { - description: `Fee-bump: account2 changeTrust ${TRUSTLINE_ASSET.getCode()}; account1 pays the fee`, - async setup({ account1, account2 }, { submit }) { - const line = findTrustline(account2.account, TRUSTLINE_ASSET); - if (line.exists) { - throw new Error( - `account2 already has ${assetLabel(TRUSTLINE_ASSET)}; teardown first`, - ); - } - await submit({ - label: `account2 add ${assetLabel( - TRUSTLINE_ASSET, - )} trustline, account1 fee-bump`, - signer: account2.keypair, - feeSource: account1.keypair, - operations: [Operation.changeTrust({ asset: TRUSTLINE_ASSET })], - }); - }, - async teardown({ account1, account2 }, { submit }) { - account2.account = await server.loadAccount(account2.keypair.publicKey()); - const line = findTrustline(account2.account, TRUSTLINE_ASSET); - if (!line.exists) { - console.log( - ` account2 has no ${assetLabel( - TRUSTLINE_ASSET, - )} trustline; nothing to remove`, - ); - return; - } - if (Number(line.balance) > 0) { - throw new Error( - `account2 ${assetLabel(TRUSTLINE_ASSET)} balance ${ - line.balance - }; send it away before removing the trustline`, - ); - } - await submit({ - label: `account2 remove ${assetLabel( - TRUSTLINE_ASSET, - )} trustline, account1 fee-bump`, - signer: account2.keypair, - feeSource: account1.keypair, - operations: [ - Operation.changeTrust({ asset: TRUSTLINE_ASSET, limit: '0' }), - ], - }); - }, - }, - - /** - * Account 1 pays itself whatever trustline asset it already holds. - * The token balance does not move. Teardown cannot refund the fee. - */ - 'self-payment': { - description: 'account1 pays a trustline asset it already holds to itself', - async setup({ account1 }, { submit }) { - requireAccount(account1, 'account1'); - const { asset, amount } = heldTrustline(account1.account); - console.log(` using ${amount} ${assetLabel(asset)}`); - await submit({ - label: `account1 self-payment ${amount} ${assetLabel(asset)}`, - signer: account1.keypair, - operations: [paymentOp(account1.keypair.publicKey(), asset, amount)], - }); - }, - async teardown() { - console.log(' token balance is unchanged; the fee is not refundable'); - }, - }, - - /** - * Account 1 spends XLM. Account 2 receives the trustline asset. - * Teardown path-swaps that received amount back to XLM on account 1. - */ - 'path-payment': { - description: `account1 path-pays ${XLM_AMOUNT} XLM; account2 receives ${TRUSTLINE_ASSET.getCode()}`, - async setup({ account1, account2 }, { submit }) { - requireAccount(account1, 'account1'); - requireAccount(account2, 'account2'); - if (isIssuer(account2, TRUSTLINE_ASSET)) { - throw new Error( - `account2 issues ${assetLabel( - TRUSTLINE_ASSET, - )} and cannot receive it`, - ); - } - await ensureTrustlineIfNeeded( - account2, - TRUSTLINE_ASSET, - submit, - 'account2', - ); - const tx = await submit({ - label: `account1 path-pay ${XLM_AMOUNT} XLM -> ${assetLabel( - TRUSTLINE_ASSET, - )} to account2`, - signer: account1.keypair, - operations: [ - pathSwapOp( - account2.keypair.publicKey(), - Asset.native(), - XLM_AMOUNT, - TRUSTLINE_ASSET, - ), - ], - }); - if (DRY_RUN) { - return; - } - const received = await creditedAmount( - tx.hash, - account2.keypair.publicKey(), - TRUSTLINE_ASSET, - ); - console.log( - ` account2 received ${received} ${assetLabel(TRUSTLINE_ASSET)}`, - ); - remember('path-payment', { received }); - }, - async teardown({ account1, account2 }, { submit }) { - const { received } = recall('path-payment'); - await submit({ - label: `account2 path-swap ${received} ${assetLabel( - TRUSTLINE_ASSET, - )} back to account1 as XLM`, - signer: account2.keypair, - operations: [ - pathSwapOp( - account1.keypair.publicKey(), - TRUSTLINE_ASSET, - received, - Asset.native(), - ), - ], - }); - forget('path-payment'); - }, - }, - - /** - * Setup locks an asset in a claimable balance and stops. - * Optional argv asset id; omitted means XLM. - * Teardown is account 2 claiming it. - */ - claimable: { - description: `account1 locks ${XLM_AMOUNT} of an asset (default XLM; optional asset id) as a claimable balance for account2; teardown claims it`, - async setup({ account1, account2 }, { submit }) { - requireAccount(account1, 'account1'); - requireAccount(account2, 'account2'); - const asset = assetFromOptionalId(process.argv[4]); - const amount = asset.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT; - const label = assetLabel(asset); - if (!asset.isNative() && !isIssuer(account1, asset)) { - const line = findTrustline(account1.account, asset); - if (!line.exists) { - throw new Error(`account1 has no trustline for ${label}`); - } - if (Number(line.balance) < Number(amount)) { - throw new Error( - `account1 ${label} balance ${line.balance} < ${amount}`, - ); - } - } - await ensureTrustlineIfNeeded(account2, asset, submit, 'account2'); - const tx = await submit({ - label: `account1 createClaimableBalance ${amount} ${label} for account2`, - signer: account1.keypair, - operations: [ - Operation.createClaimableBalance({ - asset, - amount, - claimants: [ - new Claimant( - account2.keypair.publicKey(), - Claimant.predicateUnconditional(), - ), - ], - }), - ], - }); - if (DRY_RUN) { - return; - } - const balanceId = await claimableBalanceIdFromTx(tx.hash); - console.log(` claimable ${balanceId}`); - remember('claimable', { balanceId }); - }, - async teardown({ account2 }, { submit }) { - const { balanceId } = recall('claimable'); - await submit({ - label: 'account2 claimClaimableBalance', - signer: account2.keypair, - operations: [Operation.claimClaimableBalance({ balanceId })], - }); - forget('claimable'); - }, - }, - - /** - * Issuer-only. Setup mints a claimable balance of TRUSTLINE_ASSET. - * Teardown claws it back, which burns the locked amount. - */ - 'claimable-clawback': { - description: `issuer account1 creates a ${TRUSTLINE_ASSET.getCode()} claimable balance; teardown claws it back`, - async setup({ account1, account2 }, { submit }) { - requireAccount(account1, 'account1'); - requireAccount(account2, 'account2'); - if (!isIssuer(account1, TRUSTLINE_ASSET)) { - throw new Error( - `account1 is not the issuer of ${assetLabel( - TRUSTLINE_ASSET, - )}. Clawback is issuer-only.`, - ); - } - const tx = await submit({ - label: `account1 createClaimableBalance ${TRUSTLINE_AMOUNT} ${assetLabel( - TRUSTLINE_ASSET, - )}`, - signer: account1.keypair, - operations: [ - Operation.createClaimableBalance({ - asset: TRUSTLINE_ASSET, - amount: TRUSTLINE_AMOUNT, - claimants: [ - new Claimant( - account2.keypair.publicKey(), - Claimant.predicateUnconditional(), - ), - ], - }), - ], - }); - if (DRY_RUN) { - return; - } - const balanceId = await claimableBalanceIdFromTx(tx.hash); - console.log(` claimable ${balanceId}`); - remember('claimable-clawback', { balanceId }); - }, - async teardown({ account1 }, { submit }) { - const { balanceId } = recall('claimable-clawback'); - await submit({ - label: 'account1 clawbackClaimableBalance', - signer: account1.keypair, - operations: [Operation.clawbackClaimableBalance({ balanceId })], - }); - forget('claimable-clawback'); - }, - }, - - /** - * Merges `STELLAR_SECRET_SPONSORED` into account 1. Refuses when that - * account has subentries, sponsorships, extra signers, or less than 1 XLM - * left after the fee (teardown could not recreate it). - * Teardown is createAccount with the XLM account 1 received. - */ - 'account-merge': { - description: - 'STELLAR_SECRET_SPONSORED merges into account1; teardown recreates it with the merged XLM', - async setup({ account1, sponsoredAccount }, { submit }) { - requireAccount(account1, 'account1'); - requireSponsoredAccount(sponsoredAccount); - requireAccount(sponsoredAccount, 'sponsored account'); - const { account } = sponsoredAccount; - if (Number(account.subentry_count) !== 0) { - throw new Error( - `sponsored account subentry_count is ${account.subentry_count}; remove trustlines, offers, and data entries first`, - ); - } - if ( - Number(account.num_sponsoring) !== 0 || - Number(account.num_sponsored) !== 0 - ) { - throw new Error( - 'sponsored account is sponsoring or sponsored; merge would not restore cleanly', - ); - } - const extraSigners = account.signers.filter( - (signer) => - signer.weight > 0 && - signer.key !== sponsoredAccount.keypair.publicKey(), - ); - if (extraSigners.length > 0) { - throw new Error('sponsored account has extra signers'); - } - const native = findTrustline(account, Asset.native()); - const restoreBalance = trimDecimal(Number(native.balance) - feeXlm(1)); - if (Number(restoreBalance) < 1) { - throw new Error( - `sponsored account XLM ${native.balance} is below 1 XLM after the fee, so teardown could not recreate it`, - ); - } - console.log( - ` teardown will recreate sponsored account with ${restoreBalance} XLM`, - ); - await submit({ - label: 'sponsored account accountMerge into account1', - signer: sponsoredAccount.keypair, - operations: [ - Operation.accountMerge({ - destination: account1.keypair.publicKey(), - }), - ], - }); - remember('account-merge', { restoreBalance }); - }, - async teardown({ account1, sponsoredAccount }, { submit }) { - requireSponsoredAccount(sponsoredAccount); - if (sponsoredAccount.account) { - throw new Error( - 'sponsored account still exists; setup did not merge it', - ); - } - const { restoreBalance } = recall('account-merge'); - await submit({ - label: `account1 createAccount sponsored account startingBalance ${restoreBalance}`, - signer: account1.keypair, - operations: [ - Operation.createAccount({ - destination: sponsoredAccount.keypair.publicKey(), - startingBalance: restoreBalance, - }), - ], - }); - forget('account-merge'); - }, - }, - - /** - * Account 1 sponsors a brand-new account with starting balance 2. - * Uses `sponsoredAccount` when `STELLAR_SECRET_SPONSORED` (or - * `STELLAR_PK_SPONSORED`) is set; otherwise a random keypair. - * The provided account must not already exist. - * Teardown fee-bumps an accountMerge of that account back into account 1. - */ - 'sponsored-account': { - description: - 'account1 sponsors a new account with 2 XLM (optional STELLAR_SECRET_SPONSORED); teardown fee-bumps a merge back', - async setup({ account1, sponsoredAccount }, { submit }) { - requireAccount(account1, 'account1'); - if (sponsoredAccount?.account) { - throw new Error( - `sponsored account ${sponsoredAccount.keypair.publicKey()} already exists`, - ); - } - const ephemeral = sponsoredAccount?.keypair ?? Keypair.random(); - console.log( - ` sponsored account ${ephemeral.publicKey()}${ - sponsoredAccount ? '' : ' (random)' - }`, - ); - await submit({ - label: 'account1 sponsor createAccount startingBalance 2', - signer: account1.keypair, - signers: [ephemeral], - operations: [ - Operation.beginSponsoringFutureReserves({ - sponsoredId: ephemeral.publicKey(), - }), - Operation.createAccount({ - destination: ephemeral.publicKey(), - startingBalance: '2', - }), - Operation.endSponsoringFutureReserves({ - source: ephemeral.publicKey(), - }), - ], - }); - remember('sponsored-account', { secret: ephemeral.secret() }); - }, - async teardown({ account1 }, { submit }) { - const { secret } = recall('sponsored-account'); - const ephemeral = Keypair.fromSecret(secret); - await submit({ - label: 'fee-bump accountMerge sponsored account into account1', - signer: ephemeral, - feeSource: account1.keypair, - operations: [ - Operation.accountMerge({ - destination: account1.keypair.publicKey(), - }), - ], - }); - forget('sponsored-account'); - }, - }, - - /** - * Inner payment source is account 2. Account 1 pays the fee via fee-bump. - * Teardown is a normal payment of the same amount back. - */ - 'fee-bump-payment': { - description: `fee-bump: account2 pays ${XLM_AMOUNT} XLM to account1; account1 pays the fee`, - async setup({ account1, account2 }, { submit }) { - requireAccount(account1, 'account1'); - requireAccount(account2, 'account2'); - assertBalanceAtLeast(account2, Asset.native(), XLM_AMOUNT); - await submit({ - label: `account2 pays ${XLM_AMOUNT} XLM to account1, account1 fee-bump`, - signer: account2.keypair, - feeSource: account1.keypair, - operations: [nativePayment(account1.keypair.publicKey())], - }); - }, - async teardown({ account1, account2 }, { submit }) { - await submit({ - label: `account1 returns ${XLM_AMOUNT} XLM to account2`, - signer: account1.keypair, - operations: [nativePayment(account2.keypair.publicKey())], - }); - }, - }, - - /** - * One transaction pays XLM to two existing addresses. - * Both addresses are required. There is no sponsorship and no rollback. - */ - 'two-receivers': { - description: `one tx: account1 pays ${XLM_AMOUNT} XLM to two addresses (setup two-receivers
)`, - async setup({ account1 }, { submit }) { - requireAccount(account1, 'account1'); - const [receiver1, receiver2] = [process.argv[4], process.argv[5]]; - if (!receiver1 || !receiver2) { - throw new Error('Usage: setup two-receivers
'); - } - for (const address of [receiver1, receiver2]) { - if (!StrKey.isValidEd25519PublicKey(address)) { - throw new Error(`Invalid account address "${address}"`); - } - } - const [onChain1, onChain2] = await Promise.all([ - loadAccountIfExists(receiver1), - loadAccountIfExists(receiver2), - ]); - if (!onChain1) { - throw new Error(`${receiver1} is not on chain`); - } - if (!onChain2) { - throw new Error(`${receiver2} is not on chain`); - } - assertBalanceAtLeast( - account1, - Asset.native(), - String(Number(XLM_AMOUNT) * 2), - ); - await submit({ - label: `account1 pays ${XLM_AMOUNT} XLM to ${receiver1} and ${receiver2}`, - signer: account1.keypair, - operations: [nativePayment(receiver1), nativePayment(receiver2)], - }); - }, - async teardown() { - throw new Error( - 'two-receivers does not roll back. The payments went to the addresses you passed.', - ); - }, - }, - - /** - * Transaction source is account 1 (pays the fee). - * Payment source is account 2 (loses the XLM). - * Teardown pays the same amount back. - */ - 'op-source': { - description: `tx source account1 pays the fee; op source account2 pays ${XLM_AMOUNT} XLM to account1`, - async setup({ account1, account2 }, { submit }) { - requireAccount(account1, 'account1'); - requireAccount(account2, 'account2'); - assertBalanceAtLeast(account2, Asset.native(), XLM_AMOUNT); - await submit({ - label: `account2 pays ${XLM_AMOUNT} XLM inside an account1 transaction`, - signer: account1.keypair, - signers: [account2.keypair], - operations: [ - Operation.payment({ - destination: account1.keypair.publicKey(), - asset: Asset.native(), - amount: XLM_AMOUNT, - source: account2.keypair.publicKey(), - }), - ], - }); - }, - async teardown({ account1, account2 }, { submit }) { - await submit({ - label: `account1 returns ${XLM_AMOUNT} XLM to account2`, - signer: account1.keypair, - operations: [nativePayment(account2.keypair.publicKey())], - }); - }, - }, - - /** - * Sells the trustline asset for XLM at a price that will not cross. - * Teardown cancels that offer. - */ - 'resting-offer': { - description: `account1 rests a ${TRUSTLINE_ASSET.getCode()} sell that does not fill; teardown cancels it`, - async setup({ account1 }, { submit }) { - requireAccount(account1, 'account1'); - assertBalanceAtLeast(account1, TRUSTLINE_ASSET, TRUSTLINE_AMOUNT); - const before = new Set(await listOfferIds(account1.keypair.publicKey())); - await submit({ - label: `account1 rest sell ${TRUSTLINE_AMOUNT} ${assetLabel( - TRUSTLINE_ASSET, - )} at 1000000 XLM`, - signer: account1.keypair, - operations: [ - Operation.manageSellOffer({ - selling: TRUSTLINE_ASSET, - buying: Asset.native(), - amount: TRUSTLINE_AMOUNT, - price: '1000000', - offerId: '0', - }), - ], - }); - if (DRY_RUN) { - return; - } - const created = (await listOfferIds(account1.keypair.publicKey())).filter( - (id) => !before.has(id), - ); - if (created.length !== 1) { - throw new Error( - `Expected 1 new offer, found ${created.length} (${created.join( - ', ', - )})`, - ); - } - console.log(` offer ${created[0]}`); - remember('resting-offer', { offerId: created[0] }); - }, - async teardown({ account1 }, { submit }) { - const { offerId } = recall('resting-offer'); - await submit({ - label: `account1 cancel offer ${offerId}`, - signer: account1.keypair, - operations: [ - Operation.manageSellOffer({ - selling: TRUSTLINE_ASSET, - buying: Asset.native(), - amount: '0', - price: '1', - offerId, - }), - ], - }); - forget('resting-offer'); - }, - }, - - /** - * Sells XLM into the best bid for the trustline asset so the offer fills. - * An unfilled remainder is left on the book and its offer id is saved. - * Teardown path-swaps the received tokens back to XLM and keeps that id. - * `offer-fill-cancel` cancels the remainder when it is still open. - */ - 'offer-fill': { - description: `account1 sells XLM into the best ${TRUSTLINE_ASSET.getCode()} bid; teardown swaps it back`, - async setup({ account1 }, { submit }) { - requireAccount(account1, 'account1'); - if (isIssuer(account1, TRUSTLINE_ASSET)) { - throw new Error( - `account1 issues ${assetLabel( - TRUSTLINE_ASSET, - )} and cannot receive it`, - ); - } - const line = findTrustline(account1.account, TRUSTLINE_ASSET); - if (!line.exists) { - await ensureTrustlineIfNeeded( - account1, - TRUSTLINE_ASSET, - submit, - 'account1', - ); - } - const bid = await bestBid(Asset.native(), TRUSTLINE_ASSET); - const sellAmount = - Number(XLM_AMOUNT) <= Number(bid.amount) ? XLM_AMOUNT : bid.amount; - const tx = await submit({ - label: `account1 sell ${sellAmount} XLM at bid ${bid.price}`, - signer: account1.keypair, - operations: [ - Operation.manageSellOffer({ - selling: Asset.native(), - buying: TRUSTLINE_ASSET, - amount: sellAmount, - price: bid.price, - offerId: '0', - }), - ], - }); - if (DRY_RUN) { - return; - } - const received = await creditedAmount( - tx.hash, - account1.keypair.publicKey(), - TRUSTLINE_ASSET, - ); - const offerId = await restingOfferIdFromTx(tx.hash); - const leftover = offerId ? await loadOfferIfExists(offerId) : null; - if (leftover) { - console.log(` leftover offer ${offerId} selling ${leftover.amount}`); - } else { - console.log(' no leftover offer'); - } - console.log(` filled ${received} ${assetLabel(TRUSTLINE_ASSET)}`); - remember('offer-fill', { - received, - ...(leftover ? { offerId } : {}), - }); - }, - async teardown({ account1 }, { submit }) { - const saved = recall('offer-fill'); - if (saved.received) { - await submit({ - label: `account1 path-swap ${saved.received} ${assetLabel( - TRUSTLINE_ASSET, - )} back to XLM`, - signer: account1.keypair, - operations: [ - pathSwapOp( - account1.keypair.publicKey(), - TRUSTLINE_ASSET, - saved.received, - Asset.native(), - ), - ], - }); - } - if (saved.offerId) { - remember('offer-fill', { offerId: saved.offerId }); - return; - } - forget('offer-fill'); - }, - }, - - /** - * Cancels the resting offer id saved by `offer-fill`, when that offer is - * still open. A full fill, or an offer already gone from the book, is a no-op. - * Cancelling returns the unsold XLM to account 1. There is nothing to roll back. - */ - 'offer-fill-cancel': { - description: - 'cancels the leftover offer id saved by offer-fill, if that offer is still open', - async setup({ account1 }, { submit }) { - requireAccount(account1, 'account1'); - const saved = recall('offer-fill'); - if (!saved.offerId) { - console.log(' offer-fill has no leftover offer id'); - return; - } - const leftover = await loadOfferIfExists(saved.offerId); - if (!leftover) { - console.log(` offer ${saved.offerId} is already closed`); - if (saved.received) { - remember('offer-fill', { received: saved.received }); - } else { - forget('offer-fill'); - } - return; - } - console.log( - ` offer ${saved.offerId} still open, selling ${leftover.amount}`, - ); - await submit({ - label: `account1 cancel leftover offer ${saved.offerId}`, - signer: account1.keypair, - operations: [ - Operation.manageSellOffer({ - selling: horizonAsset(leftover.selling), - buying: horizonAsset(leftover.buying), - amount: '0', - price: leftover.price, - offerId: saved.offerId, - }), - ], - }); - if (DRY_RUN) { - return; - } - if (saved.received) { - remember('offer-fill', { received: saved.received }); - } else { - forget('offer-fill'); - } - }, - async teardown() { - console.log( - ' nothing to roll back; cancelling an offer returns the unsold XLM', - ); - }, - }, - - /** - * Deposits XLM and the trustline asset into their constant-product pool. - * Teardown withdraws every share from that pool and removes the share trustline. - * Refuses when account 1 already holds shares. - */ - 'liquidity-pool': { - description: `account1 deposits into the XLM/${TRUSTLINE_ASSET.getCode()} pool; teardown withdraws all shares`, - async setup({ account1 }, { submit }) { - requireAccount(account1, 'account1'); - const pool = constantProductPool(); - const existing = poolShare(account1.account, pool.poolId); - if (Number(existing.balance) > 0) { - throw new Error( - `account1 already holds ${existing.balance} shares of ${pool.poolId}; withdraw them first`, - ); - } - assertBalanceAtLeast( - account1, - pool.assetA, - pool.assetA.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT, - ); - assertBalanceAtLeast( - account1, - pool.assetB, - pool.assetB.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT, - ); - const operations = []; - if (!existing.exists) { - operations.push(Operation.changeTrust({ asset: pool.poolAsset })); - } - operations.push( - Operation.liquidityPoolDeposit({ - liquidityPoolId: pool.poolId, - maxAmountA: pool.assetA.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT, - maxAmountB: pool.assetB.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT, - minPrice: '0.0000001', - maxPrice: '1000000', - }), - ); - await submit({ - label: `account1 deposit into pool ${pool.poolId}`, - signer: account1.keypair, - operations, - }); - remember('liquidity-pool', { poolId: pool.poolId }); - }, - async teardown({ account1 }, { submit }) { - const { poolId } = recall('liquidity-pool'); - const pool = constantProductPool(); - if (pool.poolId !== poolId) { - throw new Error( - `Saved pool ${poolId} does not match current TRUSTLINE asset pool ${pool.poolId}`, - ); - } - account1.account = await server.loadAccount(account1.keypair.publicKey()); - const shares = poolShare(account1.account, poolId); - if (!shares.exists) { - console.log(' no pool-share trustline; nothing to withdraw'); - forget('liquidity-pool'); - return; - } - const operations = []; - if (Number(shares.balance) > 0) { - operations.push( - Operation.liquidityPoolWithdraw({ - liquidityPoolId: poolId, - amount: shares.balance, - minAmountA: '0', - minAmountB: '0', - }), - ); - } - operations.push( - Operation.changeTrust({ asset: pool.poolAsset, limit: '0' }), - ); - await submit({ - label: 'account1 withdraw pool shares and remove the trustline', - signer: account1.keypair, - operations, - }); - forget('liquidity-pool'); - }, - }, - - /** - * Adds another deposit on an existing pool-share trustline. - * Errors when that trustline is missing. - * Teardown withdraws only the shares this setup added. - */ - 'liquidity-pool-increase': { - description: `account1 deposits more into the XLM/${TRUSTLINE_ASSET.getCode()} pool; the share trustline must already exist`, - async setup({ account1 }, { submit }) { - requireAccount(account1, 'account1'); - const pool = constantProductPool(); - const existing = poolShare(account1.account, pool.poolId); - if (!existing.exists) { - throw new Error( - `account1 has no pool-share trustline for ${pool.poolId}; create it before increasing the deposit`, - ); - } - assertBalanceAtLeast( - account1, - pool.assetA, - pool.assetA.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT, - ); - assertBalanceAtLeast( - account1, - pool.assetB, - pool.assetB.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT, - ); - const sharesBefore = amountToStroops(existing.balance); - await submit({ - label: `account1 increase deposit in pool ${pool.poolId}`, - signer: account1.keypair, - operations: [ - Operation.liquidityPoolDeposit({ - liquidityPoolId: pool.poolId, - maxAmountA: pool.assetA.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT, - maxAmountB: pool.assetB.isNative() ? XLM_AMOUNT : TRUSTLINE_AMOUNT, - minPrice: '0.0000001', - maxPrice: '1000000', - }), - ], - }); - if (DRY_RUN) { - return; - } - account1.account = await server.loadAccount(account1.keypair.publicKey()); - const after = poolShare(account1.account, pool.poolId); - const added = amountToStroops(after.balance) - sharesBefore; - if (added <= 0n) { - throw new Error('deposit did not increase pool shares'); - } - const addedAmount = stroopsToAmount(added); - console.log(` added ${addedAmount} shares`); - remember('liquidity-pool-increase', { - poolId: pool.poolId, - added: addedAmount, - }); - }, - async teardown({ account1 }, { submit }) { - const { poolId, added } = recall('liquidity-pool-increase'); - const pool = constantProductPool(); - if (pool.poolId !== poolId) { - throw new Error( - `Saved pool ${poolId} does not match current TRUSTLINE asset pool ${pool.poolId}`, - ); - } - await submit({ - label: `account1 withdraw ${added} shares added by liquidity-pool-increase`, - signer: account1.keypair, - operations: [ - Operation.liquidityPoolWithdraw({ - liquidityPoolId: poolId, - amount: added, - minAmountA: '0', - minAmountB: '0', - }), - ], - }); - forget('liquidity-pool-increase'); - }, - }, - - /** - * Writes one account data entry. Errors when that name is already set. - * Teardown deletes the entry. - */ - 'data-entry': { - description: 'account1 sets data entry mm-fixture; teardown deletes it', - async setup({ account1 }, { submit }) { - requireAccount(account1, 'account1'); - const name = 'mm-fixture'; - const existing = account1.account.data_attr ?? {}; - if (Object.prototype.hasOwnProperty.call(existing, name)) { - throw new Error(`account1 already has data entry ${name}`); - } - await submit({ - label: `account1 manageData ${name}`, - signer: account1.keypair, - operations: [ - Operation.manageData({ - name, - value: '1', - }), - ], - }); - remember('data-entry', { name }); - }, - async teardown({ account1 }, { submit }) { - const { name } = recall('data-entry'); - await submit({ - label: `account1 delete data entry ${name}`, - signer: account1.keypair, - operations: [ - Operation.manageData({ - name, - value: null, - }), - ], - }); - forget('data-entry'); - }, - }, -}; - -function secretFromEnv(index) { - const value = - process.env[`STELLAR_SECRET_${index}`] ?? - process.env[`STELLAR_PK_${index}`]; - if (!value) { - throw new Error( - `Missing STELLAR_SECRET_${index} (or STELLAR_PK_${index}) in env`, - ); - } - return value.trim(); -} - -function optionalSecret(...names) { - for (const name of names) { - const value = process.env[name]?.trim(); - if (value) { - return value; - } - } - return undefined; -} - -function isNotFound(error) { - return error.response?.status === 404; -} - -async function loadAccountIfExists(publicKey) { - try { - return await server.loadAccount(publicKey); - } catch (error) { - if (isNotFound(error)) { - return null; - } - throw error; - } -} - -async function loadAccounts() { - const kp1 = Keypair.fromSecret(secretFromEnv(1)); - const kp2 = Keypair.fromSecret(secretFromEnv(2)); - const sponsoredSecret = optionalSecret( - 'STELLAR_SECRET_SPONSORED', - 'STELLAR_PK_SPONSORED', - ); - const sponsoredKeypair = sponsoredSecret - ? Keypair.fromSecret(sponsoredSecret) - : undefined; - const [acc1, acc2, sponsored] = await Promise.all([ - server.loadAccount(kp1.publicKey()), - loadAccountIfExists(kp2.publicKey()), - sponsoredKeypair - ? loadAccountIfExists(sponsoredKeypair.publicKey()) - : undefined, - ]); - return { - account1: { keypair: kp1, account: acc1 }, - account2: { keypair: kp2, account: acc2 }, - ...(sponsoredKeypair - ? { sponsoredAccount: { keypair: sponsoredKeypair, account: sponsored } } - : {}), - }; -} - -function formatBalances(account) { - return account.balances - .map((row) => { - if (row.asset_type === 'native') { - return ` XLM ${row.balance}`; - } - if (row.asset_type === 'liquidity_pool_shares') { - return ` LP ${row.liquidity_pool_id} ${row.balance}`; - } - return ` ${row.asset_code}-${row.asset_issuer} ${row.balance} (limit ${ - row.limit - }${row.sponsor ? `; sponsor ${row.sponsor}` : ''}${ - row.is_authorized === false ? '; unauthorized' : '' - })`; - }) - .join('\n'); -} - -async function printStatus(accounts) { - for (const [name, { keypair, account }] of Object.entries(accounts)) { - console.log(`\n${name} ${keypair.publicKey()}`); - if (!account) { - console.log(' (account does not exist)'); - continue; - } - console.log(formatBalances(account)); - } -} - -function buildAndSign({ signer, signers = [], source, operations }) { - const tx = new TransactionBuilder(source, { - fee: String(BASE_FEE), - networkPassphrase: NETWORK_PASSPHRASE, - }); - for (const op of operations) { - tx.addOperation(op); - } - const built = tx.setTimeout(180).build(); - const seen = new Set(); - for (const key of [signer, ...signers]) { - if (seen.has(key.publicKey())) { - continue; - } - seen.add(key.publicKey()); - built.sign(key); - } - return built; -} - -async function confirmMainnet(command, caseName, description) { - if (SKIP_CONFIRM || DRY_RUN) { - return; - } - console.log( - `\nMAINNET ${command} ${caseName}: ${description}\nType "mainnet" to broadcast.`, - ); - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - }); - const answer = await rl.question('> '); - rl.close(); - if (answer.trim() !== 'mainnet') { - throw new Error('Aborted'); - } -} - -/** - * @param {PlannedTx} plan - * @returns {Promise} - */ -async function submitPlan(plan) { - const source = await server.loadAccount(plan.signer.publicKey()); - const inner = buildAndSign({ ...plan, source }); - const tx = plan.feeSource ? wrapFeeBump(inner, plan.feeSource) : inner; - console.log(`\n${plan.label}`); - console.log(` source ${plan.signer.publicKey()}`); - if (plan.feeSource) { - console.log(` fee ${plan.feeSource.publicKey()} (fee-bump)`); - } - if (plan.signers?.length) { - console.log( - ` extra ${plan.signers.map((key) => key.publicKey()).join(', ')}`, - ); - } - console.log(` ops ${plan.operations.length}`); - console.log(` hash ${tx.hash().toString('hex')}`); - console.log(` xdr ${tx.toXDR()}`); - if (DRY_RUN) { - console.log(' DRY_RUN: not submitted'); - return { hash: tx.hash().toString('hex') }; - } - const result = await server.submitTransaction(tx); - console.log(` submitted ${result.hash}`); - console.log(` ${EXPLORER_TX}/${result.hash}`); - return { hash: result.hash, raw: result }; -} - -/** - * @param {import('@stellar/stellar-sdk').Transaction} inner - * @param {import('@stellar/stellar-sdk').Keypair} feeSource - * @returns {import('@stellar/stellar-sdk').FeeBumpTransaction} - */ -function wrapFeeBump(inner, feeSource) { - const feeBump = TransactionBuilder.buildFeeBumpTransaction( - feeSource, - String(BASE_FEE), - inner, - NETWORK_PASSPHRASE, - ); - feeBump.sign(feeSource); - return feeBump; -} - -/** - * @param {string} assetId - `stellar:/asset:CODE-ISSUER` or `CODE-ISSUER`. - * @returns {import('@stellar/stellar-sdk').Asset} - */ -function assetFromId(assetId) { - const slash = assetId.lastIndexOf('/'); - let reference = slash === -1 ? assetId : assetId.slice(slash + 1); - if (reference.startsWith('asset:')) { - reference = reference.slice('asset:'.length); - } - const separator = reference.includes(':') ? ':' : '-'; - const splitAt = reference.indexOf(separator); - const code = splitAt > 0 ? reference.slice(0, splitAt) : ''; - const issuer = splitAt > 0 ? reference.slice(splitAt + 1) : ''; - if (!code || code.length > 12 || !issuer.startsWith('G')) { - throw new Error( - `Invalid asset id "${assetId}". Use stellar:pubnet/asset:CODE-ISSUER or CODE-ISSUER`, - ); - } - return new Asset(code, issuer); -} - -/** - * @param {string} [assetId] - Omitted, `XLM`, slip44:148, or a classic asset id. - * @returns {import('@stellar/stellar-sdk').Asset} - */ -function assetFromOptionalId(assetId) { - if ( - assetId === undefined || - assetId === 'XLM' || - assetId === 'native' || - assetId === 'slip44:148' || - assetId.endsWith('/slip44:148') - ) { - return Asset.native(); - } - return assetFromId(assetId); -} - -/** - * @param {Accounts} accounts - * @param {string} address - * @returns {AccountBinding} - */ -function bindingForAddress(accounts, address) { - const binding = Object.values(accounts).find( - (entry) => entry?.keypair.publicKey() === address, - ); - if (!binding) { - throw new Error( - `No secret for ${address}. It must be STELLAR_SECRET_1, STELLAR_SECRET_2, or STELLAR_SECRET_SPONSORED.`, - ); - } - if (!binding.account) { - throw new Error(`${address} is not on chain`); - } - return binding; -} - -/** - * Submits changeTrust limit 0. Does not move a remaining balance. - * - * @param {object} params - * @param {Accounts} params.accounts - * @param {string} params.accountAddress - * @param {string} params.assetId - */ -async function removeTrustline({ accounts, accountAddress, assetId }) { - const binding = bindingForAddress(accounts, accountAddress); - const asset = assetFromId(assetId); - const label = assetLabel(asset); - const line = findTrustline(binding.account, asset); - if (!line.exists) { - console.log( - ` ${accountAddress} has no ${label} trustline; nothing to remove`, - ); - return; - } - if (Number(line.balance) > 0) { - throw new Error( - `${label} balance is ${line.balance}. Remove the trustline only when the balance is 0.`, - ); - } - - await confirmMainnet( - 'remove-trustline', - accountAddress, - `changeTrust ${label} limit 0`, - ); - await submitPlan({ - label: `${accountAddress}: remove ${label} trustline`, - signer: binding.keypair, - operations: [Operation.changeTrust({ asset, limit: '0' })], - }); -} - -function getCase(name) { - const testCase = CASES[name]; - if (!testCase) { - throw new Error( - `Unknown case "${name}". Known: ${Object.keys(CASES).join(', ')}`, - ); - } - return testCase; -} - -function printCases() { - for (const [id, testCase] of Object.entries(CASES)) { - console.log(`${id}: ${testCase.description}`); - } -} - -async function main() { - const [command, caseName] = process.argv.slice(2); - - if (!command || command === 'help' || command === '-h') { - console.log(`Commands: - list - status - setup - teardown - remove-trustline `); - printCases(); - return; - } - - if (command === 'list') { - printCases(); - return; - } - - const accounts = await loadAccounts(); - - if (command === 'status') { - await printStatus(accounts); - return; - } - - if (command === 'remove-trustline') { - const [accountAddress, assetId] = process.argv.slice(3); - if (!accountAddress || !assetId) { - throw new Error('Usage: remove-trustline '); - } - await removeTrustline({ accounts, accountAddress, assetId }); - return; - } - - if (!caseName) { - throw new Error( - `Case name required. Known: ${Object.keys(CASES).join(', ')}`, - ); - } - - const testCase = getCase(caseName); - console.log(`${command} ${caseName}: ${testCase.description}`); - await printStatus(accounts); - await confirmMainnet(command, caseName, testCase.description); - - const helpers = { submit: submitPlan }; - if (command === 'setup') { - await testCase.setup(accounts, helpers); - } else if (command === 'teardown') { - await testCase.teardown(accounts, helpers); - } else { - throw new Error(`Unknown command "${command}"`); - } - - const after = await loadAccounts(); - console.log('\nAfter:'); - await printStatus(after); -} - -main().catch((error) => { - const extras = error.response?.data ?? error.data; - if (extras) { - console.error(JSON.stringify(extras, null, 2)); - } - console.error(error.message ?? error); - process.exitCode = 1; -}); From f82f24a896b5918a246240711e61c05f619d8fec Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:48:42 +0800 Subject: [PATCH 37/50] fix: comment --- packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts | 4 ++-- .../src/services/on-chain-account/OnChainAccountService.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 3bbd0bdab..2f58fbd3c 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -232,7 +232,7 @@ export class KeyringHandler implements KeyringSnapRpc { let onChainAccount: OnChainAccount | null = null; - // TDOO: remove this gate after asset migration is fully enabled. + // TODO: remove this gate after asset migration is fully enabled. if (enableAssetMigration) { onChainAccount = await this.#fetchOnChainAccountWithCache({ accountId, @@ -362,7 +362,7 @@ export class KeyringHandler implements KeyringSnapRpc { let onChainAccount: OnChainAccount | null = null; - // TDOO: remove this gate after asset migration is fully enabled. + // TODO: remove this gate after asset migration is fully enabled. if (enableAssetMigration) { onChainAccount = await this.#fetchOnChainAccountWithCache({ accountId, diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 5db0e2280..c8f760519 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -479,6 +479,9 @@ export class OnChainAccountService { address, authorized, sponsored, + ...(asset.balance.metadata.sponsor + ? { sponsor: asset.balance.metadata.sponsor } + : {}), }), ); continue; 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 38/50] 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 39/50] 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 40/50] 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 41/50] 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 42/50] 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 }; } From cd67ed96751712411656dc52f1f4b74355207c84 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:11:32 +0800 Subject: [PATCH 43/50] Merge branch 'dev/stellar/drop-token-metadata-rpc-fallback' into dev/stellar/asset-migration --- .../AssetMetadataService.test.ts | 157 +++++++++++++----- .../asset-metadata/AssetMetadataService.ts | 93 ++++++----- .../OnChainAccountService.test.ts | 2 +- .../on-chain-account/OnChainAccountService.ts | 116 +++++-------- .../services/sync/SynchronizeService.test.ts | 56 ++++--- .../src/services/sync/SynchronizeService.ts | 7 +- 6 files changed, 250 insertions(+), 181 deletions(-) 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 ae9f1e0fd..7fab7bb51 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 @@ -172,7 +172,7 @@ describe('AssetMetadataService', () => { const { service, saveMany } = createService({}); mockGetAssetsByAssetIds.mockResolvedValueOnce([tokenRow]); - const result = await service.fetchAssetsByAssetIds([ + const result = await service.fetchAndPersistAssetsByAssetIds([ USDC_SEP41, TESTNET_SEP41_USDC, ]); @@ -258,24 +258,6 @@ describe('AssetMetadataService', () => { ]); }); - it('delegates getPersistedSep41AssetsMetadata to repository', async () => { - const sepRows: Sep41StellarAssetMetadata[] = []; - const getByAssetType = jest.fn().mockResolvedValue(sepRows); - const { service } = createService({ - repo: { getByAssetType }, - }); - - const result = await service.getPersistedSep41AssetsMetadata( - KnownCaip2ChainId.Mainnet, - ); - - expect(result).toBe(sepRows); - expect(getByAssetType).toHaveBeenCalledWith( - AssetType.Sep41, - KnownCaip2ChainId.Mainnet, - ); - }); - it('resolves mainnet classic from token API when API returns metadata', async () => { const classicId = MAINNET_CLASSIC_USDC; const tokenRow: TokenMetadata = { @@ -382,28 +364,14 @@ describe('AssetMetadataService', () => { ); }); - describe('fetchSep41AssetsOrSyncOnce', () => { + describe('synchronizeIfStale', () => { const sep41AssetId = 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as KnownCaip19Sep41AssetId; - const freshCatalogRow = (): Sep41StellarAssetMetadata => ({ - assetId: sep41AssetId, - assetType: AssetType.Sep41, - chainId: KnownCaip2ChainId.Mainnet, - name: 'Cached', - symbol: 'CCH', - fungible: true, - iconUrl: 'https://example.test/icon.png', - units: [{ name: 'Cached', symbol: 'CCH', decimals: 7 }], - persistedAt: Date.now(), - }); - it('synchronizes when the catalog is stale', async () => { - const sepRows = [freshCatalogRow()]; const isStale = jest.fn().mockResolvedValue(true); - const getByAssetType = jest.fn().mockResolvedValue(sepRows); const { service, saveMany } = createService({ - repo: { isStale, getByAssetType }, + repo: { isStale }, }); mockGetAssetsByChainId.mockResolvedValueOnce({ data: [ @@ -418,9 +386,7 @@ describe('AssetMetadataService', () => { totalCount: 1, }); - const result = await service.fetchSep41AssetsOrSyncOnce( - KnownCaip2ChainId.Mainnet, - ); + await service.synchronizeIfStale(KnownCaip2ChainId.Mainnet); expect(isStale).toHaveBeenCalledWith(60 * 60 * 1000); expect(mockGetAssetsByChainId).toHaveBeenCalledWith( @@ -430,26 +396,131 @@ describe('AssetMetadataService', () => { [expect.objectContaining({ assetId: sep41AssetId })], true, ); + }); + + it('skips synchronize when the catalog is fresh', async () => { + const isStale = jest.fn().mockResolvedValue(false); + const { service, saveMany } = createService({ + repo: { isStale }, + }); + + await service.synchronizeIfStale(KnownCaip2ChainId.Mainnet); + + expect(isStale).toHaveBeenCalledWith(60 * 60 * 1000); + expect(mockGetAssetsByChainId).not.toHaveBeenCalled(); + expect(saveMany).not.toHaveBeenCalled(); + }); + }); + + describe('getAssetsByScopeAndType', () => { + it('delegates to the repository for the scope and asset type', async () => { + const sep41AssetId = + 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as KnownCaip19Sep41AssetId; + const sepRows: Sep41StellarAssetMetadata[] = [ + { + assetId: sep41AssetId, + assetType: AssetType.Sep41, + chainId: KnownCaip2ChainId.Mainnet, + name: 'Cached', + symbol: 'CCH', + fungible: true, + iconUrl: 'https://example.test/icon.png', + units: [{ name: 'Cached', symbol: 'CCH', decimals: 7 }], + }, + ]; + const getByAssetType = jest.fn().mockResolvedValue(sepRows); + const { service } = createService({ + repo: { getByAssetType }, + }); + + const result = await service.getAssetsByScopeAndType( + KnownCaip2ChainId.Mainnet, + AssetType.Sep41, + ); + expect(result).toBe(sepRows); + expect(getByAssetType).toHaveBeenCalledWith( + AssetType.Sep41, + KnownCaip2ChainId.Mainnet, + ); + }); + }); + + describe('fetchAndPersistSep41AssetMetadata', () => { + const sep41AssetId = + 'stellar:pubnet/sep41:CAUP7NFABXE5TJRL3FKTPMWRLC7IAXYDCTHQRFSCLR5TMGKHOOQO772J' as KnownCaip19Sep41AssetId; + + const freshCatalogRow = (): Sep41StellarAssetMetadata => ({ + assetId: sep41AssetId, + assetType: AssetType.Sep41, + chainId: KnownCaip2ChainId.Mainnet, + name: 'Cached', + symbol: 'CCH', + fungible: true, + iconUrl: 'https://example.test/icon.png', + units: [{ name: 'Cached', symbol: 'CCH', decimals: 7 }], + persistedAt: Date.now(), + }); + + it('returns fetched SEP-41 rows and skips other asset types', async () => { + const sepRow = freshCatalogRow(); + const { service } = createService({}); + const fetchByIds = jest + .spyOn(service, 'fetchAndPersistAssetsByAssetIds') + .mockResolvedValue([ + createCachedRow(MAINNET_CLASSIC_USDC, KnownCaip2ChainId.Mainnet), + sepRow, + ]); + + const result = await service.fetchAndPersistSep41AssetMetadata({ + scope: KnownCaip2ChainId.Mainnet, + assetIds: [sep41AssetId], + }); + + expect(fetchByIds).toHaveBeenCalledWith([sep41AssetId]); + expect(result).toStrictEqual([sepRow]); }); - it('returns persisted SEP-41 assets without syncing when the catalog is fresh', async () => { + it('returns the persisted catalog without syncing when ids are omitted', async () => { const catalog = [freshCatalogRow()]; - const isStale = jest.fn().mockResolvedValue(false); + const isStale = jest.fn().mockResolvedValue(true); const getByAssetType = jest.fn().mockResolvedValue(catalog); const { service, saveMany } = createService({ repo: { isStale, getByAssetType }, }); - const result = await service.fetchSep41AssetsOrSyncOnce( + const result = await service.fetchAndPersistSep41AssetMetadata({ + scope: KnownCaip2ChainId.Mainnet, + }); + + expect(getByAssetType).toHaveBeenCalledWith( + AssetType.Sep41, KnownCaip2ChainId.Mainnet, ); - - expect(isStale).toHaveBeenCalledWith(60 * 60 * 1000); + expect(isStale).not.toHaveBeenCalled(); expect(mockGetAssetsByChainId).not.toHaveBeenCalled(); expect(saveMany).not.toHaveBeenCalled(); expect(result).toBe(catalog); }); + + it('returns the persisted catalog when asset ids are empty', async () => { + const catalog = [freshCatalogRow()]; + const getByAssetType = jest.fn().mockResolvedValue(catalog); + const { service } = createService({ + repo: { getByAssetType }, + }); + + const result = await service.fetchAndPersistSep41AssetMetadata({ + scope: KnownCaip2ChainId.Mainnet, + assetIds: [], + }); + + expect(result).toBe(catalog); + expect(getByAssetType).toHaveBeenCalledWith( + AssetType.Sep41, + KnownCaip2ChainId.Mainnet, + ); + }); }); it('persists catalog during synchronize when assets migration is on', async () => { 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 97db102a1..7b1d333d9 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -87,23 +87,6 @@ export class AssetMetadataService { return this.#fetchAndPersistAssetsByAssetIds(assetIds); } - /** - * Returns all persisted SEP-41 assets for the given chain ID. - * - * @param scope - The chain ID to look up. - * @returns A Promise that resolves to all persisted SEP-41 assets for the given chain ID. - */ - async getPersistedSep41AssetsMetadata( - scope: KnownCaip2ChainId, - ): Promise { - const persistedAssets = await this.#assetMetadataRepository.getByAssetType( - AssetType.Sep41, - scope, - ); - - return persistedAssets; - } - /** * Returns all persisted assets for the given scope. * @@ -117,37 +100,47 @@ export class AssetMetadataService { } /** - * Returns persisted SEP-41 assets for the given scope, refreshing the token API - * catalog first when {@link AssetMetadataRepository.isStale} reports the last - * full catalog sync has expired (or never completed). - * - * Used during account / transaction sync so newly listed SEP-41 tokens are - * picked up without waiting for the hourly `synchronizeAssets` cron. + * Returns all persisted assets for the given scope and type. * * @param scope - The chain ID to look up. - * @returns A Promise that resolves to all SEP-41 assets for the given scope. + * @param assetType - The asset type to look up. + * @returns A Promise that resolves to all persisted assets for the given scope and type. */ - async fetchSep41AssetsOrSyncOnce( + async getAssetsByScopeAndType( scope: KnownCaip2ChainId, - ): Promise { - if ( - await this.#assetMetadataRepository.isStale( - AppConfig.cache.ttlMilliseconds.assetMetadata, - ) - ) { - this.#logger.debug('Asset catalog is stale, synchronizing assets', { - scope, - }); - await this.synchronize(scope); - } + assetType: TAssetType, + ): Promise[]> { + const assetsMetadata = await this.#assetMetadataRepository.getByAssetType( + assetType, + scope, + ); - const sep41Assets = await this.getPersistedSep41AssetsMetadata(scope); + return assetsMetadata; + } - this.#logger.debug('SEP-41 assets found in the state', { - noOfAssets: sep41Assets.length, - }); + /** + * Loads SEP-41 metadata for the requested ids, or the persisted catalog when ids are omitted. + * Non-SEP-41 rows are skipped. + * + * @param params - SEP-41 metadata lookup. + * @param params.scope - CAIP-2 network. + * @param params.assetIds - SEP-41 asset ids. Omit or pass an empty list to use the persisted catalog. + * @returns SEP-41 metadata for the requested ids, or the persisted catalog. + */ + async fetchAndPersistSep41AssetMetadata(params: { + scope: KnownCaip2ChainId; + assetIds?: KnownCaip19Sep41AssetId[]; + }): Promise { + const { scope, assetIds = [] } = params; + if (assetIds.length > 0) { + const assetsMetadata = + await this.fetchAndPersistAssetsByAssetIds(assetIds); + return assetsMetadata.filter( + (asset) => asset.assetType === AssetType.Sep41, + ); + } - return sep41Assets; + return this.getAssetsByScopeAndType(scope, AssetType.Sep41); } /** @@ -163,6 +156,24 @@ export class AssetMetadataService { await this.#assetMetadataRepository.saveMany(tokensMetadata, true); } + /** + * Synchronizes the asset catalog if it is stale. + * + * @param scope - The chain ID to synchronize the asset catalog for. + */ + async synchronizeIfStale(scope: KnownCaip2ChainId): Promise { + if ( + await this.#assetMetadataRepository.isStale( + AppConfig.cache.ttlMilliseconds.assetMetadata, + ) + ) { + this.#logger.debug('Asset catalog is stale, synchronizing assets', { + scope, + }); + await this.synchronize(scope); + } + } + async #fetchAndPersistAssetsByAssetIds( assetIds: KnownCaip19AssetIdOrSlip44Id[], ): Promise { diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index 31d5fdf16..f1f2b9a70 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -164,7 +164,7 @@ describe('OnChainAccountService', () => { const fetchSep41Spy = jest.spyOn( AssetMetadataService.prototype, - 'fetchSep41AssetsOrSyncOnce', + 'fetchAndPersistSep41AssetMetadata', ); const shouldStubSep41 = stubSep41Catalog || diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index dc5824f04..89b22651e 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -2,8 +2,15 @@ import type { Logger } from '@metamask/snap-networks-utils'; import { BigNumber } from 'bignumber.js'; import type { KnownCaip19Sep41AssetId } from '../../api'; -import { AssetType, KnownCaip2ChainId } from '../../api'; -import { entries } from '../../utils'; +import { KnownCaip2ChainId } from '../../api'; +import { + entries, + getAssetReference, + parseClassicAssetCodeIssuer, + rethrowIfInstanceElseThrow, + toSmallestUnit, + trackError, +} from '../../utils'; import { assertSameAddress } from '../account/utils'; import type { AssetMetadataService, @@ -158,26 +165,47 @@ export class OnChainAccountService { scope: KnownCaip2ChainId, assetIds?: KnownCaip19Sep41AssetId[], ): Promise { - const assetMetadataByAssetId = await this.#getSep41AssetMetadata( - scope, - assetIds, - ); - const assetIdsWithMetadata = keys(assetMetadataByAssetId); + const sep41Assets = + await this.#assetMetadataService.fetchAndPersistSep41AssetMetadata({ + scope, + assetIds, + }); // if there are no asset metadata, we can return early. // This can happen if there are no SEP-41 assets for the scope. - if (assetIdsWithMetadata.length === 0) { + if (sep41Assets.length === 0) { return onChainAccount; } - const sep41Balances = await this.#fetchSep41Balances( - onChainAccount.accountId, - assetIdsWithMetadata, + const sep41AssetIds: KnownCaip19Sep41AssetId[] = []; + const sep41AssetMetadataMap: Map< + KnownCaip19Sep41AssetId, + Sep41StellarAssetMetadata + > = new Map(); + for (const asset of sep41Assets) { + sep41AssetMetadataMap.set(asset.assetId, asset); + sep41AssetIds.push(asset.assetId); + } + + const balancesByAccount = await this.#networkService.getSep41AssetBalances({ + accounts: [onChainAccount.accountId], + assetIds: sep41AssetIds, scope, - ); + }); + // Safe guard to throw an error if it is mainnet + // It is unexpected we don't have any balances. + if ( + scope === KnownCaip2ChainId.Mainnet && + balancesByAccount[onChainAccount.accountId] === undefined + ) { + throw new OnChainAccountSep41BalanceNotFoundException( + onChainAccount.accountId, + ); + } + const sep41Balances = balancesByAccount[onChainAccount.accountId] ?? {}; for (const [assetId, balance] of entries(sep41Balances)) { - const assetMetadata = assetMetadataByAssetId[assetId]; + const assetMetadata = sep41AssetMetadataMap.get(assetId); // Asset metadata is important for setting the correct decimals and symbol for the balance. if (assetMetadata === undefined) { // This is unexpected, but we can continue. @@ -197,68 +225,6 @@ export class OnChainAccountService { return onChainAccount; } - /** - * Loads SEP-41 metadata for the requested ids, or the persisted catalog when ids are omitted. - * Non-SEP-41 rows are skipped. - * - * @param scope - CAIP-2 network. - * @param assetIds - SEP-41 asset ids. Omit to use the persisted catalog. - * @returns Metadata keyed by SEP-41 asset id. - */ - async #getSep41AssetMetadata( - scope: KnownCaip2ChainId, - assetIds?: KnownCaip19Sep41AssetId[], - ): Promise> { - const sep41Assets = - await this.#assetMetadataService.fetchSep41AssetsOrSyncOnce(scope); - const sep41AssetIds: KnownCaip19Sep41AssetId[] = []; - const assetMetadataByAssetId: Partial< - Record - > = {}; - for (const assetMetadata of sep41Assets) { - if (assetMetadata.assetType !== AssetType.Sep41) { - continue; - } - const { assetId } = assetMetadata; - sep41AssetIds.push(assetId); - assetMetadataByAssetId[assetId] = assetMetadata; - } - - return assetMetadataByAssetId; - } - - /** - * Reads SEP-41 balances for one account. - * - * @param accountAddress - Stellar G-address. - * @param assetIds - SEP-41 asset ids to read. - * @param scope - CAIP-2 network. - * @returns Balance by asset id. Empty on testnet when no map is returned. - * @throws {OnChainAccountSep41BalanceNotFoundException} When mainnet returns no map for the account. - */ - async #fetchSep41Balances( - accountAddress: string, - assetIds: KnownCaip19Sep41AssetId[], - scope: KnownCaip2ChainId, - ): Promise> { - const balancesByAccount = await this.#networkService.getSep41AssetBalances({ - accounts: [accountAddress], - assetIds, - scope, - }); - const sep41Balances = balancesByAccount[accountAddress]; - // If it is testnet, we won't have any balances, so we return early. - // If it is mainnet, we throw an error as it is unexpected. - if (sep41Balances === undefined) { - if (scope === KnownCaip2ChainId.Mainnet) { - throw new OnChainAccountSep41BalanceNotFoundException(accountAddress); - } - return {}; - } - - return sep41Balances; - } - /** * Binds one fetched SEP-41 balance, including zero, onto an account. * Returns without binding when the balance is `null` or `undefined`. diff --git a/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.test.ts b/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.test.ts index 872adcb07..3697d622e 100644 --- a/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.test.ts +++ b/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.test.ts @@ -1,11 +1,10 @@ -import { KnownCaip2ChainId } from '../../api'; +import { AssetType, KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; import { BackgroundEventMethod } from '../../handlers/cronjob/api'; import { Duration, scheduleBackgroundEvent } from '../../utils'; import { logger } from '../../utils/logger'; import type { StellarKeyringAccount } from '../account'; import { generateMockStellarKeyringAccounts } from '../account/__mocks__/account.fixtures'; -import { AssetMetadataService } from '../asset-metadata'; import { createMockAssetMetadataService, getMockSep41Assets, @@ -59,8 +58,11 @@ describe('SynchronizeService', () => { const { transactionService } = createMockTransactionService(); const { service: assetMetadataService } = createMockAssetMetadataService(); const sep41Assets = getMockSep41Assets(); - const fetchSep41AssetsOrSyncOnceSpy = jest - .spyOn(AssetMetadataService.prototype, 'fetchSep41AssetsOrSyncOnce') + const synchronizeIfStaleSpy = jest + .spyOn(assetMetadataService, 'synchronizeIfStale') + .mockResolvedValue(undefined); + const getSep41AssetsSpy = jest + .spyOn(assetMetadataService, 'getAssetsByScopeAndType') .mockResolvedValue(sep41Assets); const service = new SynchronizeService({ logger, @@ -75,7 +77,8 @@ describe('SynchronizeService', () => { transactionService, assetMetadataService, sep41Assets, - fetchSep41AssetsOrSyncOnceSpy, + synchronizeIfStaleSpy, + getSep41AssetsSpy, onChainSynchronizeSpy: jest .spyOn(onChainAccountService, 'synchronize') .mockResolvedValue(undefined), @@ -95,13 +98,15 @@ describe('SynchronizeService', () => { onChainSynchronizeSpy, transactionSynchronizeSpy, resolveOnChainAccountSpy, - fetchSep41AssetsOrSyncOnceSpy, + synchronizeIfStaleSpy, + getSep41AssetsSpy, } = setup(); await service.synchronize([]); expect(resolveOnChainAccountSpy).not.toHaveBeenCalled(); - expect(fetchSep41AssetsOrSyncOnceSpy).not.toHaveBeenCalled(); + expect(synchronizeIfStaleSpy).not.toHaveBeenCalled(); + expect(getSep41AssetsSpy).not.toHaveBeenCalled(); expect(onChainSynchronizeSpy).not.toHaveBeenCalled(); expect(transactionSynchronizeSpy).not.toHaveBeenCalled(); }); @@ -112,7 +117,8 @@ describe('SynchronizeService', () => { onChainSynchronizeSpy, transactionSynchronizeSpy, resolveOnChainAccountSpy, - fetchSep41AssetsOrSyncOnceSpy, + synchronizeIfStaleSpy, + getSep41AssetsSpy, sep41Assets, } = setup(); const [account] = generateMockStellarKeyringAccounts( @@ -142,7 +148,8 @@ describe('SynchronizeService', () => { syncTransactions: true, }); - expect(fetchSep41AssetsOrSyncOnceSpy).toHaveBeenCalledWith(scope); + expect(synchronizeIfStaleSpy).toHaveBeenCalledWith(scope); + expect(getSep41AssetsSpy).toHaveBeenCalledWith(scope, AssetType.Sep41); expect(onChainSynchronizeSpy).toHaveBeenCalledWith( [{ keyringAccount: account, onChainAccount }], scope, @@ -231,7 +238,8 @@ describe('SynchronizeService', () => { onChainSynchronizeSpy, transactionSynchronizeSpy, resolveOnChainAccountSpy, - fetchSep41AssetsOrSyncOnceSpy, + synchronizeIfStaleSpy, + getSep41AssetsSpy, } = setup(); const [account] = generateMockStellarKeyringAccounts( 1, @@ -252,13 +260,12 @@ describe('SynchronizeService', () => { ), ); - fetchSep41AssetsOrSyncOnceSpy.mockRejectedValue( - new Error('token api down'), - ); + synchronizeIfStaleSpy.mockRejectedValue(new Error('token api down')); resolveOnChainAccountSpy.mockResolvedValue(onChainAccount); await service.synchronize([account], { scope }); + expect(getSep41AssetsSpy).not.toHaveBeenCalled(); expect(onChainSynchronizeSpy).toHaveBeenCalledWith( [{ keyringAccount: account, onChainAccount }], scope, @@ -321,7 +328,8 @@ describe('SynchronizeService', () => { onChainSynchronizeSpy, transactionSynchronizeSpy, resolveOnChainAccountSpy, - fetchSep41AssetsOrSyncOnceSpy, + synchronizeIfStaleSpy, + getSep41AssetsSpy, } = setup(); const [account] = generateMockStellarKeyringAccounts( 1, @@ -349,7 +357,8 @@ describe('SynchronizeService', () => { await service.synchronize([account], { scope }); - expect(fetchSep41AssetsOrSyncOnceSpy).toHaveBeenCalledTimes(1); + expect(synchronizeIfStaleSpy).toHaveBeenCalledTimes(1); + expect(getSep41AssetsSpy).toHaveBeenCalledTimes(1); expect(onChainSynchronizeSpy).not.toHaveBeenCalled(); expect(transactionSynchronizeSpy).not.toHaveBeenCalled(); expect(scheduleBackgroundEventMock).not.toHaveBeenCalled(); @@ -367,7 +376,8 @@ describe('SynchronizeService', () => { onChainSynchronizeSpy, transactionSynchronizeSpy, resolveOnChainAccountSpy, - fetchSep41AssetsOrSyncOnceSpy, + synchronizeIfStaleSpy, + getSep41AssetsSpy, } = setup(); const [firstAccount, secondAccount] = generateMockStellarKeyringAccounts( 2, @@ -395,7 +405,8 @@ describe('SynchronizeService', () => { await service.synchronize([secondAccount], { scope }); - expect(fetchSep41AssetsOrSyncOnceSpy).toHaveBeenCalledTimes(1); + expect(synchronizeIfStaleSpy).toHaveBeenCalledTimes(1); + expect(getSep41AssetsSpy).toHaveBeenCalledTimes(1); expect(onChainSynchronizeSpy).not.toHaveBeenCalled(); expect(transactionSynchronizeSpy).not.toHaveBeenCalled(); expect(scheduleBackgroundEventMock).toHaveBeenCalledWith({ @@ -412,8 +423,12 @@ describe('SynchronizeService', () => { }); it('schedules only accounts that are not already syncing', async () => { - const { service, resolveOnChainAccountSpy, fetchSep41AssetsOrSyncOnceSpy } = - setup(); + const { + service, + resolveOnChainAccountSpy, + synchronizeIfStaleSpy, + getSep41AssetsSpy, + } = setup(); const [firstAccount, secondAccount] = generateMockStellarKeyringAccounts( 2, 'sync-mutex-partial-delay-entropy', @@ -440,7 +455,8 @@ describe('SynchronizeService', () => { await service.synchronize([firstAccount, secondAccount], { scope }); - expect(fetchSep41AssetsOrSyncOnceSpy).toHaveBeenCalledTimes(1); + expect(synchronizeIfStaleSpy).toHaveBeenCalledTimes(1); + expect(getSep41AssetsSpy).toHaveBeenCalledTimes(1); expect(scheduleBackgroundEventMock).toHaveBeenCalledWith({ method: BackgroundEventMethod.SynchronizeAccounts, params: { accountIds: [secondAccount.id] }, diff --git a/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.ts b/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.ts index 060f9808f..9ebd0ae6b 100644 --- a/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.ts +++ b/packages/stellar-wallet-snap/src/services/sync/SynchronizeService.ts @@ -2,6 +2,7 @@ import type { Logger } from '@metamask/snap-networks-utils'; import { E_ALREADY_LOCKED, Mutex, tryAcquire } from 'async-mutex'; import type { KnownCaip2ChainId } from '../../api'; +import { AssetType } from '../../api'; import { AppConfig } from '../../config'; import { BackgroundEventMethod } from '../../handlers/cronjob/api'; import { Duration, scheduleBackgroundEvent, trackError } from '../../utils'; @@ -223,7 +224,11 @@ export class SynchronizeService { scope: KnownCaip2ChainId, ): Promise { try { - return await this.#assetMetadataService.fetchSep41AssetsOrSyncOnce(scope); + await this.#assetMetadataService.synchronizeIfStale(scope); + return await this.#assetMetadataService.getAssetsByScopeAndType( + scope, + AssetType.Sep41, + ); } catch (error: unknown) { await trackError(error); From 2d6d8b7b3d940441b1431595a9499607d8a7ccf4 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:22:10 +0800 Subject: [PATCH 44/50] chore: cache 5 second only --- packages/stellar-wallet-snap/src/config.ts | 2 +- .../stellar-wallet-snap/src/handlers/keyring/keyring.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/stellar-wallet-snap/src/config.ts b/packages/stellar-wallet-snap/src/config.ts index 4253a8350..0b54358f5 100644 --- a/packages/stellar-wallet-snap/src/config.ts +++ b/packages/stellar-wallet-snap/src/config.ts @@ -114,7 +114,7 @@ export const ConfigStruct = object({ // SEP-41 balance reads (multicall on mainnet) sep41AssetBalance: parseIntegerStruct(1000, 30 * 1000), // Keyring getAccountAssets result reused by the following getAccountBalances call - fetchOnChainAccount: parseIntegerStruct(1000, 30 * 1000), + fetchOnChainAccount: parseIntegerStruct(1000, 5 * 1000), // 1 hour — token API asset catalog. Account sync hot-reloads when older. assetMetadata: parseIntegerStruct(1000, 60 * 60 * 1000), }), diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index 2f58fbd3c..a0395e50b 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -241,7 +241,7 @@ export class KeyringHandler implements KeyringSnapRpc { refresh: true, }); } else { - onChainAccount = await this.#getOnChainAccountFromState({ + onChainAccount = await this.#getOnChainAccountSnapshot({ accountId, scope, }); @@ -371,7 +371,7 @@ export class KeyringHandler implements KeyringSnapRpc { refresh: false, }); } else { - onChainAccount = await this.#getOnChainAccountFromState({ + onChainAccount = await this.#getOnChainAccountSnapshot({ accountId, scope, }); @@ -557,7 +557,7 @@ export class KeyringHandler implements KeyringSnapRpc { * @param params.scope - CAIP-2 network. * @returns The indexed account, or `null` when no snapshot exists. */ - async #getOnChainAccountFromState({ + async #getOnChainAccountSnapshot({ accountId, scope, }: { From 735fe9ec76ed204f76214becac12871edfb0ca20 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:26:14 +0800 Subject: [PATCH 45/50] Update config.test.ts --- packages/stellar-wallet-snap/src/config.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stellar-wallet-snap/src/config.test.ts b/packages/stellar-wallet-snap/src/config.test.ts index d3c97ef3e..cfd13ece4 100644 --- a/packages/stellar-wallet-snap/src/config.test.ts +++ b/packages/stellar-wallet-snap/src/config.test.ts @@ -55,7 +55,7 @@ const FULL_ENVIRONMENT = { loadOnChainAccount: '30000', simulateTransaction: '8000', sep41AssetBalance: '15000', - fetchOnChainAccount: '20000', + fetchOnChainAccount: '5000', assetMetadata: '900000', }, }, From d4357c375ec815f599faaab1bdd324f41e882a39 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:32:48 +0800 Subject: [PATCH 46/50] Update config.test.ts --- packages/stellar-wallet-snap/src/config.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stellar-wallet-snap/src/config.test.ts b/packages/stellar-wallet-snap/src/config.test.ts index cfd13ece4..338eb7f10 100644 --- a/packages/stellar-wallet-snap/src/config.test.ts +++ b/packages/stellar-wallet-snap/src/config.test.ts @@ -256,7 +256,7 @@ describe('ConfigProvider', () => { loadOnChainAccount: 30000, simulateTransaction: 8000, sep41AssetBalance: 15000, - fetchOnChainAccount: 20000, + fetchOnChainAccount: 5000, assetMetadata: 900000, }); }); @@ -286,7 +286,7 @@ describe('ConfigProvider', () => { loadOnChainAccount: 10 * 60 * 1000, simulateTransaction: 10 * 1000, sep41AssetBalance: 30 * 1000, - fetchOnChainAccount: 30 * 1000, + fetchOnChainAccount: 5 * 1000, assetMetadata: 60 * 60 * 1000, }); }); From 2f05b41107f6040a436d6400606a1c3b8d5e36b8 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:41:48 +0800 Subject: [PATCH 47/50] chore: update doc --- .../stellar-wallet-snap/docs/misc/synchronization/assets.md | 2 +- .../src/services/asset-metadata/AssetMetadataService.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stellar-wallet-snap/docs/misc/synchronization/assets.md b/packages/stellar-wallet-snap/docs/misc/synchronization/assets.md index 6f57c1aab..480279ea5 100644 --- a/packages/stellar-wallet-snap/docs/misc/synchronization/assets.md +++ b/packages/stellar-wallet-snap/docs/misc/synchronization/assets.md @@ -32,7 +32,7 @@ Wire format: [SIP-29 Snap Assets API](https://metamask.github.io/SIPs/SIPS/sip-2 4. Persist catalog via `AssetMetadataRepository.saveMany`. 5. Failures are logged / tracked; the cron does not fail the whole Snap lifecycle. -During account / transaction sync, `SynchronizeService` also preloads SEP-41 metadata via `fetchSep41AssetsOrSyncOnce`. That path refreshes the token API catalog when `assetMetadataLastSyncedAt` is missing **or** older than `cache.ttlMilliseconds.assetMetadata` (default 1 hour). The timestamp is stamped only by a successful non-empty full catalog sync (`saveMany(..., true)`), so point-lookup `saveMany` calls cannot postpone the hot reload. +During account / transaction sync, `SynchronizeService` preloads SEP-41 metadata by calling [`AssetMetadataService.synchronizeIfStale`](../../../src/services/asset-metadata/AssetMetadataService.ts), then [`AssetMetadataService.getAssetsByScopeAndType`](../../../src/services/asset-metadata/AssetMetadataService.ts) (`AssetType.Sep41`). `synchronizeIfStale` refreshes the token API catalog when `assetMetadataLastSyncedAt` is missing **or** older than `cache.ttlMilliseconds.assetMetadata` (default 1 hour). `getAssetsByScopeAndType` then returns the persisted rows for that scope and type. The timestamp is stamped only by a successful non-empty full catalog sync (`saveMany(..., true)`), so point-lookup `saveMany` calls cannot postpone the hot reload. ## Sequence 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 7b1d333d9..f8dbdcbaf 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -78,7 +78,7 @@ export class AssetMetadataService { * rows are persisted, same as {@link resolve}. Ids missing from the token API * are omitted. * - * @param assetIds - SEP-41 asset ids to resolve. + * @param assetIds - Classic or SEP-41 CAIP-19 asset ids to resolve. * @returns Metadata for the ids that could be resolved. Missing ids are omitted. */ async fetchAndPersistAssetsByAssetIds( From cf48e48932c901f62e60c6b772f8deb7667639a5 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:46:17 +0800 Subject: [PATCH 48/50] fix: comment --- .../src/handlers/keyring/keyring.ts | 5 +- .../OnChainAccountService.test.ts | 42 +++++++++++++++ .../on-chain-account/OnChainAccountService.ts | 16 +++--- .../services/on-chain-account/utils.test.ts | 53 ++++++++++++++++--- .../src/services/on-chain-account/utils.ts | 36 +++++++++---- 5 files changed, 127 insertions(+), 25 deletions(-) diff --git a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts index a0395e50b..564bead82 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.ts @@ -60,6 +60,7 @@ import type { WalletService } from '../../services/wallet'; import { Duration, getSlip44AssetId, + getStellarAssetChainId, isClassicAssetId, isSep41Id, isSlip44Id, @@ -390,7 +391,9 @@ export class KeyringHandler implements KeyringSnapRpc { // the client asked for. Fetch those balances when nothing was stored. const missingSep41AssetIds = knownAssets.filter( (assetId): assetId is KnownCaip19Sep41AssetId => - isSep41Id(assetId) && onChainAccount.getRawAsset(assetId) === undefined, + isSep41Id(assetId) && + onChainAccount.getRawAsset(assetId) === undefined && + getStellarAssetChainId(assetId) === scope, ); // Try to fetch the SEP-41 balances for the missing SEP-41 asset ids. if (missingSep41AssetIds.length > 0) { diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts index f1f2b9a70..b60ca8708 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.test.ts @@ -605,6 +605,48 @@ describe('OnChainAccountService', () => { KnownCaip2ChainId.Mainnet, keyringAccountId, ); + expect(result?.nativeSpendableBalance.toFixed(0)).toBe('40000000'); + expect(result?.minimumReserveBalance.toFixed(0)).toBe('10000000'); + }); + + it('preserves native spendable when AssetsController reserve is below two base reserves', async () => { + const accountAddress = Keypair.random().publicKey(); + const keyringAccountId = globalThis.crypto.randomUUID(); + const { onChainAccountService, assetsService } = + mockOnChainAccountService(); + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(true); + jest.spyOn(assetsService, 'getAccountAssetsByScope').mockResolvedValue([ + { + id: getSlip44AssetId(KnownCaip2ChainId.Mainnet), + chainId: KnownCaip2ChainId.Mainnet, + balance: { + amount: '5', + metadata: { + spendableBalance: '45000000', + minimumReserveBalance: '5000000', + decimal: 7, + }, + }, + metadata: { symbol: 'XLM', decimals: 7 }, + }, + ]); + + const result = await onChainAccountService.getOnChainAccountSnapshot( + keyringAccountId, + accountAddress, + KnownCaip2ChainId.Mainnet, + ); + + expect(result?.toSerializableFull()).toMatchObject({ + rawNativeBalance: '50000000', + meta: { + subentryCount: 0, + numSponsoring: 0, + numSponsored: 1, + }, + }); + expect(result?.minimumReserveBalance.toFixed(0)).toBe('5000000'); + expect(result?.nativeSpendableBalance.toFixed(0)).toBe('45000000'); }); it('wraps an AssetsController asset mapping failure and keeps the cause', async () => { diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts index 89b22651e..05edeed33 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountService.ts @@ -42,7 +42,7 @@ import type { SerializableSpendableBalance, } from './OnChainAccountSerializable'; import { OnChainAccountSynchronizeService } from './OnChainAccountSynchronizeService'; -import { calculateSubentryFromMinReserve } from './utils'; +import { calculateLedgerMetaFromMinReserve } from './utils'; /** * Stellar on-chain account operations: activation checks and loading {@link OnChainAccount} @@ -364,8 +364,9 @@ export class OnChainAccountService { * Maps AssetsController holdings into a full on-chain snapshot. * * Best-effort account ledger metadata from AssetsController: - * - Derived `subentryCount` from native balance metadata `minimumReserveBalance` - * - Set `sequenceNumber` to `0`, `numSponsoring` to `0`, and `numSponsored` to `0` + * - Derived reserve meta from native balance metadata `minimumReserveBalance` + * (including `numSponsored` when the reserve is below two base reserves) + * - Set `sequenceNumber` to `0` when no ledger overlay is supplied * * @param params - Account header and AssetsController assets for one scope. * @param params.accountAddress - Stellar G-address for the snapshot header. @@ -407,13 +408,16 @@ export class OnChainAccountService { ).toFixed(0); if (isCoreNativeAsset(asset)) { - // When ledger meta is missing, derive subentryCount from AssetsController XLM metadata. + // When ledger meta is missing, derive reserve meta from AssetsController XLM metadata. if (ledger === undefined) { const { minimumReserveBalance } = asset.balance.metadata; - accountLedgerMeta.rawNativeBalance = balance; - accountLedgerMeta.subentryCount = calculateSubentryFromMinReserve( + const reserveMeta = calculateLedgerMetaFromMinReserve( minimumReserveBalance, ); + accountLedgerMeta.rawNativeBalance = balance; + accountLedgerMeta.subentryCount = reserveMeta.subentryCount; + accountLedgerMeta.numSponsoring = reserveMeta.numSponsoring; + accountLedgerMeta.numSponsored = reserveMeta.numSponsored; } continue; } diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/utils.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/utils.test.ts index bcaeaebb6..f67d50e6c 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/utils.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/utils.test.ts @@ -5,30 +5,67 @@ import { BASE_RESERVE_STROOPS, } from '../../constants'; import { + calculateLedgerMetaFromMinReserve, + calculateSpendableBalance, minimumBalanceStroops, - calculateSubentryFromMinReserve, } from './utils'; -describe('calculateSubentryFromMinReserve', () => { - it('returns 0 for an empty account (2 base reserves)', () => { +describe('calculateLedgerMetaFromMinReserve', () => { + it('returns zero sponsorship meta for an empty account (2 base reserves)', () => { const emptyAccountReserve = new BigNumber(ACCOUNT_MINIMUM_BASE_RESERVE_UNIT) .times(BASE_RESERVE_STROOPS) .toFixed(0); - expect(calculateSubentryFromMinReserve(emptyAccountReserve)).toBe(0); + expect(calculateLedgerMetaFromMinReserve(emptyAccountReserve)).toStrictEqual( + { + subentryCount: 0, + numSponsoring: 0, + numSponsored: 0, + }, + ); }); - it('recovers subentries when sponsoring fields are 0', () => { + it('recovers subentries when the reserve is at or above two base reserves', () => { const stroops = minimumBalanceStroops({ subentryCount: 4, numSponsoring: 0, numSponsored: 0, }).toFixed(0); - expect(calculateSubentryFromMinReserve(stroops)).toBe(4); + expect(calculateLedgerMetaFromMinReserve(stroops)).toStrictEqual({ + subentryCount: 4, + numSponsoring: 0, + numSponsored: 0, + }); }); - it('clamps below the account base reserve at 0', () => { - expect(calculateSubentryFromMinReserve('0')).toBe(0); + it('encodes a sponsored reserve below two base reserves as numSponsored', () => { + const oneBaseReserve = new BigNumber(BASE_RESERVE_STROOPS).toFixed(0); + + expect(calculateLedgerMetaFromMinReserve(oneBaseReserve)).toStrictEqual({ + subentryCount: 0, + numSponsoring: 0, + numSponsored: 1, + }); + expect( + minimumBalanceStroops(calculateLedgerMetaFromMinReserve(oneBaseReserve)), + ).toStrictEqual(new BigNumber(oneBaseReserve)); + }); + + it('preserves spendable when the reserve is fully sponsored', () => { + const nativeBalance = new BigNumber(50_000_000); + const meta = calculateLedgerMetaFromMinReserve('0'); + + expect(meta).toStrictEqual({ + subentryCount: 0, + numSponsoring: 0, + numSponsored: 2, + }); + expect( + calculateSpendableBalance({ + nativeBalance, + ...meta, + }), + ).toStrictEqual(nativeBalance); }); }); diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts index 69fba3743..acf18a26c 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/utils.ts @@ -35,24 +35,40 @@ export function minimumBalanceStroops( } /** - * Best-effort `subentry_count` from AssetsController native `minimumReserveBalance` (stroops). + * Best-effort ledger meta from AssetsController native `minimumReserveBalance` (stroops). * - * Inverts {@link minimumBalanceStroops} when `numSponsoring` and `numSponsored` - * are 0: `subentryCount = minimumReserve / BASE_RESERVE āˆ’ 2`. Clamped at 0. + * Inverts {@link minimumBalanceStroops} into meta that reproduces the same reserve: + * `(2 + subentry_count + num_sponsoring āˆ’ num_sponsored) Ɨ base_reserve`. + * + * When the reserve is at or above two base reserves, encodes the surplus as + * `subentryCount` with sponsorship fields at 0. When sponsorship reduces the + * reserve below two base reserves, encodes the deficit as `numSponsored` so + * {@link calculateSpendableBalance} is not understated. * * @param minimumReserveBalanceStroops - AssetsController `minimumReserveBalance` (integer stroops). - * @returns Estimated Horizon `subentry_count`. + * @returns Ledger meta that preserves the controller minimum reserve. */ -export function calculateSubentryFromMinReserve( +export function calculateLedgerMetaFromMinReserve( minimumReserveBalanceStroops: string, -): number { +): MinimumBalanceLedgerMeta { const reserveUnits = new BigNumber(minimumReserveBalanceStroops).div( BASE_RESERVE_STROOPS, ); - return BigNumber.maximum( - reserveUnits.minus(ACCOUNT_MINIMUM_BASE_RESERVE_UNIT), - 0, - ).toNumber(); + const netUnits = reserveUnits.minus(ACCOUNT_MINIMUM_BASE_RESERVE_UNIT); + + if (netUnits.isGreaterThanOrEqualTo(0)) { + return { + subentryCount: netUnits.toNumber(), + numSponsoring: 0, + numSponsored: 0, + }; + } + + return { + subentryCount: 0, + numSponsoring: 0, + numSponsored: netUnits.abs().toNumber(), + }; } /** From 9a0f0403a41608942a1a5bd4ee7d312fe10dfa5b Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:48:51 +0800 Subject: [PATCH 49/50] Update utils.test.ts --- .../src/services/on-chain-account/utils.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/utils.test.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/utils.test.ts index f67d50e6c..46acb2c12 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/utils.test.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/utils.test.ts @@ -16,13 +16,13 @@ describe('calculateLedgerMetaFromMinReserve', () => { .times(BASE_RESERVE_STROOPS) .toFixed(0); - expect(calculateLedgerMetaFromMinReserve(emptyAccountReserve)).toStrictEqual( - { - subentryCount: 0, - numSponsoring: 0, - numSponsored: 0, - }, - ); + expect( + calculateLedgerMetaFromMinReserve(emptyAccountReserve), + ).toStrictEqual({ + subentryCount: 0, + numSponsoring: 0, + numSponsored: 0, + }); }); it('recovers subentries when the reserve is at or above two base reserves', () => { From e6e4f274511c6c583985ac18d136fa26806c55c8 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:47:00 +0800 Subject: [PATCH 50/50] Clarify asset balance reading in changelog Updated changelog to clarify asset balance reading conditions and included SEP-41 balances. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/stellar-wallet-snap/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 0d4c91876..97f939105 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Read account asset balance from AssetsController whfeature flag `enableAssetMigration` and local flag `enableAssetMigration` is on ([#331](https://github.com/MetaMask/internal-snaps/pull/331)) +- Read account asset balances from AssetsController when the feature flag `enableAssetMigration` and local flag `enableAssetMigration` are on ([#331](https://github.com/MetaMask/internal-snaps/pull/331)) - Include SEP-41 balances on keyring `getAccountAssets` and `getAccountBalances`. - 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.