diff --git a/packages/stellar-wallet-snap/.env.example b/packages/stellar-wallet-snap/.env.example index ace1de1eb..86e05503c 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/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 0695722d2..97f939105 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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. 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/snap.config.ts b/packages/stellar-wallet-snap/snap.config.ts index cc7714465..a3a0eb5ac 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_ASSET_METADATA_TTL_MILLISECONDS: process.env.STELLAR_ASSET_METADATA_TTL_MILLISECONDS ?? '', STELLAR_BASE_FEE_MULTIPLIER: process.env.STELLAR_BASE_FEE_MULTIPLIER ?? '', diff --git a/packages/stellar-wallet-snap/src/config.test.ts b/packages/stellar-wallet-snap/src/config.test.ts index 1236badde..338eb7f10 100644 --- a/packages/stellar-wallet-snap/src/config.test.ts +++ b/packages/stellar-wallet-snap/src/config.test.ts @@ -55,6 +55,7 @@ const FULL_ENVIRONMENT = { loadOnChainAccount: '30000', simulateTransaction: '8000', sep41AssetBalance: '15000', + fetchOnChainAccount: '5000', assetMetadata: '900000', }, }, @@ -255,6 +256,7 @@ describe('ConfigProvider', () => { loadOnChainAccount: 30000, simulateTransaction: 8000, sep41AssetBalance: 15000, + fetchOnChainAccount: 5000, assetMetadata: 900000, }); }); @@ -270,6 +272,7 @@ describe('ConfigProvider', () => { loadOnChainAccount: '', simulateTransaction: undefined, sep41AssetBalance: '', + fetchOnChainAccount: undefined, assetMetadata: '', }, }, @@ -283,6 +286,7 @@ describe('ConfigProvider', () => { loadOnChainAccount: 10 * 60 * 1000, simulateTransaction: 10 * 1000, sep41AssetBalance: 30 * 1000, + fetchOnChainAccount: 5 * 1000, assetMetadata: 60 * 60 * 1000, }); }); diff --git a/packages/stellar-wallet-snap/src/config.ts b/packages/stellar-wallet-snap/src/config.ts index 16b57489b..0b54358f5 100644 --- a/packages/stellar-wallet-snap/src/config.ts +++ b/packages/stellar-wallet-snap/src/config.ts @@ -113,6 +113,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 + fetchOnChainAccount: parseIntegerStruct(1000, 5 * 1000), // 1 hour — token API asset catalog. Account sync hot-reloads when older. assetMetadata: parseIntegerStruct(1000, 60 * 60 * 1000), }), @@ -185,6 +187,8 @@ const ENVIRONMENT = { process.env.STELLAR_SIMULATE_TRANSACTION_TTL_MILLISECONDS, sep41AssetBalance: process.env.STELLAR_SEP41_ASSET_BALANCE_TTL_MILLISECONDS, + 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/constants.ts b/packages/stellar-wallet-snap/src/constants.ts index 7ee4dfda7..457446ab4 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/context.ts b/packages/stellar-wallet-snap/src/context.ts index 67748665c..28f97c55d 100644 --- a/packages/stellar-wallet-snap/src/context.ts +++ b/packages/stellar-wallet-snap/src/context.ts @@ -134,6 +134,7 @@ const onChainAccountService = new OnChainAccountService({ logger, networkService, onChainAccountRepository, + assetsService, assetMetadataService, }); @@ -357,6 +358,7 @@ const clientRequestHandler = new ClientRequestHandler({ /** ------------------------------ Export Handlers ------------------------------ */ export { + assetsService, clientRequestHandler, cronjobHandler, keyringHandler, diff --git a/packages/stellar-wallet-snap/src/handlers/accountResolver.test.ts b/packages/stellar-wallet-snap/src/handlers/accountResolver.test.ts index 7257d1422..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,17 +172,17 @@ describe('AccountResolver', () => { onChainAccount, wallet, }); - expect(resolveOnChainAccountByKeyringAccountIdSpy).toHaveBeenCalledWith( + expect(getOnChainAccountSnapshotSpy).toHaveBeenCalledWith( account.id, + account.address, scope, ); expect(resolveOnChainAccountSpy).not.toHaveBeenCalled(); }); 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({ @@ -196,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( @@ -210,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 341da5985..1f29f724d 100644 --- a/packages/stellar-wallet-snap/src/handlers/accountResolver.ts +++ b/packages/stellar-wallet-snap/src/handlers/accountResolver.ts @@ -161,8 +161,9 @@ export class AccountResolver { ); } const onChainAccount = - await this.#onChainAccountService.resolveOnChainAccountByKeyringAccountId( + await this.#onChainAccountService.getOnChainAccountSnapshot( 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/clientRequest/onAmountInput.test.ts b/packages/stellar-wallet-snap/src/handlers/clientRequest/onAmountInput.test.ts index 4470f2f32..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,14 +174,15 @@ 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, ); expect(createValidatedSendTransaction).toHaveBeenCalledWith({ @@ -204,13 +202,14 @@ 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, ); expect(createValidatedSendTransaction).toHaveBeenCalledWith( @@ -289,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 e55473825..eab9b0e68 100644 --- a/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts +++ b/packages/stellar-wallet-snap/src/handlers/keyring/keyring.test.ts @@ -7,8 +7,10 @@ import { 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'; import { AccountService } from '../../services/account'; import type { StellarKeyringAccount } from '../../services/account'; import { @@ -16,6 +18,11 @@ 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 { createMockAccountWithBalances, @@ -100,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); @@ -441,51 +464,118 @@ describe('KeyringHandler', () => { }); describe('getAccountAssets', () => { - it('returns on-chain asset ids for the account', async () => { - const { resolveAccountSpy } = getAccountServiceSpies(); - resolveAccountSpy.mockResolvedValue({ account: mockAccount }); - const onChainAccount = createTestOnChainAccount(mockAccount.address); - jest - .spyOn( - OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockResolvedValue(onChainAccount); + const originalEnableAssetMigration = AppConfig.enableAssetMigration; - const result = await keyringHandler.getAccountAssets(mockAccountId); - - 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( + 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, 'getOnChainAccountSnapshot') + .mockResolvedValue(onChainAccount); + const resolveOnChainAccountSpy = jest.spyOn( OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockResolvedValue(null); + 'resolveOnChainAccount', + ); - const result = await keyringHandler.getAccountAssets(mockAccountId); + const result = await keyringHandler.getAccountAssets(mockAccountId); - expect(result).toStrictEqual([slipId]); + expect(resolveByKeyringAccountIdSpy).toHaveBeenCalledWith( + mockAccountId, + mockAccount.address, + KnownCaip2ChainId.Mainnet, + ); + expect(resolveOnChainAccountSpy).not.toHaveBeenCalled(); + expect(result).toStrictEqual(onChainAccount.assetIds); + }); + + 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, 'getOnChainAccountSnapshot') + .mockResolvedValue(null); + + 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, 'getOnChainAccountSnapshot') + .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, - 'resolveOnChainAccountByKeyringAccountId', - ) - .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 () => { @@ -575,80 +665,389 @@ 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; }); - jest - .spyOn( + + 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, 'getOnChainAccountSnapshot') + .mockResolvedValue(onChainAccount); + const resolveOnChainAccountSpy = jest.spyOn( OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockResolvedValue(onChainAccount); + 'resolveOnChainAccount', + ); - const result = await keyringHandler.getAccountBalances(mockAccountId, [ - slipId, - ]); + const assets = await keyringHandler.getAccountAssets(mockAccountId); + const balances = await keyringHandler.getAccountBalances( + mockAccountId, + [slipId], + ); - expect(result).toStrictEqual({ - [slipId]: { - unit: 'XLM', - amount: '2.000001', - metadata: { - spendableBalance: '10000010', - minimumReserveBalance: '10000000', - decimal: 7, + 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('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( + 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, 'getOnChainAccountSnapshot') + .mockResolvedValue(null); + const fetchAndBindSpy = jest.spyOn( OnChainAccountService.prototype, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockResolvedValue(null); + 'fetchAndBindSep41Balances', + ); - const result = await keyringHandler.getAccountBalances(mockAccountId, [ - slipId, - ]); + 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, + }, + }, + }); + }); - expect(result).toStrictEqual({ - [slipId]: { - unit: 'XLM', - amount: '0', - metadata: { - spendableBalance: '0', - minimumReserveBalance: '0', - decimal: 7, + 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, 'getOnChainAccountSnapshot') + .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', }, - }, + }); + }); + + 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, 'getOnChainAccountSnapshot') + .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 the indexed account cannot be read', async () => { + const slipId = getSlip44AssetId(KnownCaip2ChainId.Mainnet); + const { resolveAccountSpy } = getAccountServiceSpies(); + resolveAccountSpy.mockResolvedValue({ account: mockAccount }); + jest + .spyOn(OnChainAccountService.prototype, 'getOnChainAccountSnapshot') + .mockRejectedValue(new Error('state unavailable')); + + await expect( + keyringHandler.getAccountBalances(mockAccountId, [slipId]), + ).rejects.toThrow('state unavailable'); }); }); - 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( + describe('when local config asset migration is enabled', () => { + beforeEach(() => { + AppConfig.enableAssetMigration = true; + }); + + 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, + ]); + + 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('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.fetchOnChainAccount + 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 }); + 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, + }, + }, + }); + }); + + 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('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, - 'resolveOnChainAccountByKeyringAccountId', - ) - .mockRejectedValue(new Error('Horizon unavailable')); + '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 12c835268..564bead82 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'; @@ -33,32 +35,34 @@ import type { import type { KnownCaip19AssetIdOrSlip44Id, + KnownCaip19Sep41AssetId, KnownCaip2ChainId, } from '../../api'; 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 { Duration, getSlip44AssetId, + getStellarAssetChainId, isClassicAssetId, + isSep41Id, isSlip44Id, rethrowIfInstanceElseThrow, validateRequest, @@ -95,6 +99,8 @@ export class KeyringHandler implements KeyringSnapRpc { readonly #handlers: Record; + readonly #fetchOnChainAccountCache: InMemoryCache; + constructor({ logger, accountService, @@ -111,6 +117,7 @@ export class KeyringHandler implements KeyringSnapRpc { handlers: Record; }) { this.#logger = logger.withPrefix(KEYRING_HANDLER_LOGGER_PREFIX); + this.#fetchOnChainAccountCache = new InMemoryCache(this.#logger); this.#accountService = accountService; this.#onChainAccountService = onChainAccountService; this.#transactionService = transactionService; @@ -209,17 +216,39 @@ export class KeyringHandler implements KeyringSnapRpc { return createdAccounts.map(asStrictKeyringAccount); } + /** + * Returns the assets of an account. + * + * 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. + * + * @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); - const scope = AppConfig.selectedNetwork; + const { enableAssetMigration, selectedNetwork: scope } = AppConfig; - const { onChainAccount } = await this.#resolveAccountByAccountId( - accountId, - scope, - ); + let onChainAccount: OnChainAccount | null = null; - // If the account is not activated or not yet synced, return the native asset with zero balance + // TODO: remove this gate after asset migration is fully enabled. + 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.#getOnChainAccountSnapshot({ + accountId, + scope, + }); + } + + // Unfunded accounts have no trustlines. Return native so the client can ask for a zero balance. if (onChainAccount === null) { return [getSlip44AssetId(scope)]; } @@ -308,6 +337,17 @@ export class KeyringHandler implements KeyringSnapRpc { return activityOnScopes.some((active) => active); } + /** + * Returns the balances of an account for the given assets. + * + * 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. + * + * @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[], @@ -317,15 +357,28 @@ export class KeyringHandler implements KeyringSnapRpc { GetAccountBalancesRequestStruct, ); - const scope = AppConfig.selectedNetwork; + const { enableAssetMigration, selectedNetwork: scope } = AppConfig; + const assetBalances = {} as Record; - const { onChainAccount } = await this.#resolveAccountByAccountId( - accountId, - scope, - ); + let onChainAccount: OnChainAccount | null = null; + + // TODO: remove this gate after asset migration is fully enabled. + if (enableAssetMigration) { + onChainAccount = await this.#fetchOnChainAccountWithCache({ + accountId, + scope, + // Use the cached on-chain account. + refresh: false, + }); + } else { + onChainAccount = await this.#getOnChainAccountSnapshot({ + accountId, + scope, + }); + } - // 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) { @@ -334,6 +387,23 @@ export class KeyringHandler implements KeyringSnapRpc { return assetBalances; } + // 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 && + getStellarAssetChainId(assetId) === scope, + ); + // Try to fetch the SEP-41 balances for the missing SEP-41 asset ids. + 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). @@ -482,26 +552,87 @@ export class KeyringHandler implements KeyringSnapRpc { validateRequest(method, MultichainMethodStruct); } - async #resolveAccountByAccountId( - accountId: string, - scope: KnownCaip2ChainId, - ): Promise<{ - account: StellarKeyringAccount; - onChainAccount: OnChainAccount | null; - }> { + /** + * Loads the indexed on-chain snapshot for a keyring account. + * + * @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 #getOnChainAccountSnapshot({ + accountId, + scope, + }: { + accountId: string; + scope: KnownCaip2ChainId; + }): Promise { const { account } = await this.#accountService.resolveAccount({ accountId, }); + return this.#onChainAccountService.getOnChainAccountSnapshot( + accountId, + 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, - scope, - ); + /** + * Loads a Horizon account for the keyring asset and balance reads. + * + * @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 #fetchOnChainAccountWithCache({ + accountId, + scope, + refresh, + }: { + accountId: string; + scope: KnownCaip2ChainId; + refresh: boolean; + }): Promise { + const { account } = await this.#accountService.resolveAccount({ + accountId, + }); + const serialized = await useCache( + this.#fetchOnChainAccount.bind(this), + this.#fetchOnChainAccountCache, + { + logger: this.#logger, + functionName: 'KeyringHandler:fetchOnChainAccount', + ttlMilliseconds: AppConfig.cache.ttlMilliseconds.fetchOnChainAccount, + refreshCache: refresh, + }, + )(account.address, scope); - return { account, onChainAccount }; + if (serialized === null) { + return null; + } + + return OnChainAccount.fromSerializable(serialized); + } + + async #fetchOnChainAccount( + accountAddress: string, + scope: KnownCaip2ChainId, + ): Promise { + try { + const onChainAccount = + await this.#onChainAccountService.resolveOnChainAccount( + accountAddress, + scope, + { fetchWithSep41Balances: 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/AssetMetadataService.test.ts b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.test.ts index 12a1943f2..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, ]); @@ -239,7 +239,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({ @@ -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 9c4d32d56..f8dbdcbaf 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataService.ts @@ -72,38 +72,21 @@ 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}. 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 fetchAssetsByAssetIds( - assetIds: KnownCaip19Sep41AssetId[], + async fetchAndPersistAssetsByAssetIds( + assetIds: KnownCaip19AssetId[], ): Promise { 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 449b260fa..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 @@ -3,6 +3,8 @@ 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 { bufferToUint8Array } from '../../utils/buffer'; import { generateMockStellarKeyringAccounts, @@ -12,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'; @@ -26,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'; @@ -158,7 +164,7 @@ describe('OnChainAccountService', () => { const fetchSep41Spy = jest.spyOn( AssetMetadataService.prototype, - 'fetchSep41AssetsOrSyncOnce', + 'fetchAndPersistSep41AssetMetadata', ); const shouldStubSep41 = stubSep41Catalog || @@ -220,7 +226,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, @@ -238,7 +244,7 @@ describe('OnChainAccountService', () => { const result = await onChainAccountService.resolveOnChainAccount( accountAddress, scope, - { resolveWithFullBalance: true }, + { fetchWithSep41Balances: true }, ); expect(getSep41AssetBalancesSpy).toHaveBeenCalledWith({ @@ -267,7 +273,7 @@ describe('OnChainAccountService', () => { const result = await onChainAccountService.resolveOnChainAccount( accountAddress, scope, - { resolveWithFullBalance: true }, + { fetchWithSep41Balances: true }, ); expect(result.accountId).toStrictEqual(accountAddress); @@ -288,7 +294,7 @@ describe('OnChainAccountService', () => { const result = await onChainAccountService.resolveOnChainAccount( accountAddress, scope, - { resolveWithFullBalance: true }, + { fetchWithSep41Balances: true }, ); expect(getSep41AssetBalancesSpy).toHaveBeenCalledWith({ @@ -309,7 +315,7 @@ describe('OnChainAccountService', () => { await expect( onChainAccountService.resolveOnChainAccount(accountAddress, scope, { - resolveWithFullBalance: true, + fetchWithSep41Balances: true, }), ).rejects.toThrow( new OnChainAccountSep41BalanceNotFoundException(accountAddress), @@ -330,7 +336,7 @@ describe('OnChainAccountService', () => { const result = await onChainAccountService.resolveOnChainAccount( accountAddress, scope, - { resolveWithFullBalance: true }, + { fetchWithSep41Balances: true }, ); expect(result.getRawAsset(USDC_SEP41)).toBeUndefined(); @@ -350,7 +356,7 @@ describe('OnChainAccountService', () => { const result = await onChainAccountService.resolveOnChainAccount( accountAddress, scope, - { resolveWithFullBalance: true }, + { fetchWithSep41Balances: true }, ); expect(result.getRawAsset(USDC_SEP41)?.balance.toFixed()).toBe('1'); @@ -365,28 +371,83 @@ describe('OnChainAccountService', () => { await expect( onChainAccountService.resolveOnChainAccount(accountAddress, scope, { - resolveWithFullBalance: true, + fetchWithSep41Balances: true, }), ).rejects.toThrow('Failed to load SEP-41 token balance'); }); }); - describe('resolveOnChainAccountByKeyringAccountId', () => { + describe('fetchAndBindSep41Balances', () => { + it('binds balances resolved by fetchAndPersistAssetsByAssetIds', 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, + 'fetchAndPersistAssetsByAssetIds', + ) + .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('getOnChainAccountSnapshot', () => { it('returns null when no snapshot exists for the keyring id and scope', async () => { const keyringAccountId = globalThis.crypto.randomUUID(); - const { onChainAccountService, onChainAccountRepository } = + const accountAddress = Keypair.random().publicKey(); + const { onChainAccountService, onChainAccountRepository, assetsService } = mockOnChainAccountService(); + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(false); const findByAccountIdSpy = jest.spyOn( onChainAccountRepository, 'findByKeyringAccountId', ); findByAccountIdSpy.mockResolvedValue(null); - const result = - await onChainAccountService.resolveOnChainAccountByKeyringAccountId( - keyringAccountId, - KnownCaip2ChainId.Mainnet, - ); + const result = await onChainAccountService.getOnChainAccountSnapshot( + keyringAccountId, + accountAddress, + KnownCaip2ChainId.Mainnet, + ); expect(result).toBeNull(); expect(findByAccountIdSpy).toHaveBeenCalledWith( @@ -408,19 +469,20 @@ 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', ); findByAccountIdSpy.mockResolvedValue(binding); - const result = - await onChainAccountService.resolveOnChainAccountByKeyringAccountId( - keyringAccountId, - KnownCaip2ChainId.Mainnet, - ); + const result = await onChainAccountService.getOnChainAccountSnapshot( + keyringAccountId, + signer.publicKey(), + KnownCaip2ChainId.Mainnet, + ); expect(result).toBeInstanceOf(OnChainAccount); expect(result?.accountId).toStrictEqual(signer.publicKey()); @@ -429,6 +491,211 @@ describe('OnChainAccountService', () => { KnownCaip2ChainId.Mainnet, ); }); + + 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); + const getAccountAssetsByScopeSpy = jest + .spyOn(assetsService, 'getAccountAssetsByScope') + .mockResolvedValue([]); + const findByAccountIdSpy = jest.spyOn( + onChainAccountRepository, + 'findByKeyringAccountId', + ); + + expect( + await onChainAccountService.getOnChainAccountSnapshot( + 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 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); + 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: USDC_CLASSIC, + chainId: KnownCaip2ChainId.Mainnet, + balance: { + amount: '0.1630079', + metadata: { + limit: MAX_INT64, + authorized: true, + sponsored: false, + }, + }, + metadata: { symbol: 'USDC', decimals: 7 }, + }, + { + id: USDC_SEP41, + chainId: KnownCaip2ChainId.Mainnet, + balance: { amount: '2' }, + metadata: { symbol: 'TA', decimals: 7 }, + }, + ]); + + const result = await onChainAccountService.getOnChainAccountSnapshot( + keyringAccountId, + accountAddress, + KnownCaip2ChainId.Mainnet, + ); + + 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: USDC_CLASSIC, + balance: '1630079', + symbol: 'USDC', + address: usdcIssuer, + limit: MAX_INT64, + authorized: true, + sponsored: false, + }), + expect.objectContaining({ + assetId: USDC_SEP41, + balance: '20000000', + symbol: 'TA', + decimals: 7, + }), + ]), + ); + expect(getAccountAssetsByScopeSpy).toHaveBeenCalledWith( + 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 () => { + 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: '1', + metadata: { + spendableBalance: '0', + minimumReserveBalance: '10000000', + decimal: 7, + }, + }, + metadata: { symbol: 'XLM', decimals: 7 }, + }, + ]); + const cause = new Error('invalid snapshot'); + const fromSerializableSpy = jest + .spyOn(OnChainAccount, 'fromSerializable') + .mockImplementation(() => { + throw cause; + }); + + let caught: unknown; + try { + await onChainAccountService.getOnChainAccountSnapshot( + keyringAccountId, + accountAddress, + KnownCaip2ChainId.Mainnet, + ); + } catch (error: unknown) { + caught = error; + } finally { + fromSerializableSpy.mockRestore(); + } + + expect(caught).toBeInstanceOf(OnChainAccountException); + expect(caught).toMatchObject({ + message: `Failed to map AssetsController assets for account ${accountAddress}`, + cause, + }); + }); }); describe('synchronize', () => { @@ -450,7 +717,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', @@ -468,5 +737,23 @@ describe('OnChainAccountService', () => { [], ); }); + + it('skips persist when assets 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 6d099078f..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 @@ -2,20 +2,47 @@ 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, Sep41StellarAssetMetadata, } 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 { OnChainAccountSep41BalanceNotFoundException } from './exceptions'; +import { + OnChainAccountException, + OnChainAccountSep41BalanceNotFoundException, +} from './exceptions'; import { OnChainAccount } from './OnChainAccount'; import type { OnChainAccountRepository } from './OnChainAccountRepository'; +import { + OnChainAccountSerializableFullStruct, + SerializableClassicSpendableBalanceStruct, + SerializableSep41SpendableBalanceStruct, +} from './OnChainAccountSerializable'; +import type { + OnChainAccountSerializableFull, + SerializableSpendableBalance, +} from './OnChainAccountSerializable'; import { OnChainAccountSynchronizeService } from './OnChainAccountSynchronizeService'; +import { calculateLedgerMetaFromMinReserve } from './utils'; /** * Stellar on-chain account operations: activation checks and loading {@link OnChainAccount} @@ -28,17 +55,23 @@ export class OnChainAccountService { readonly #onChainAccountRepository: OnChainAccountRepository; + readonly #assetsService: AssetsService; + readonly #assetMetadataService: AssetMetadataService; + readonly #logger: Logger; + constructor({ networkService, onChainAccountRepository, logger, + assetsService, assetMetadataService, }: { networkService: NetworkService; onChainAccountRepository: OnChainAccountRepository; logger: Logger; + assetsService: AssetsService; assetMetadataService: AssetMetadataService; }) { this.#networkService = networkService; @@ -48,7 +81,9 @@ export class OnChainAccountService { onChainAccountRepository, logger, }); + this.#logger = logger.withPrefix('💼 OnChainAccountService'); this.#onChainAccountRepository = onChainAccountRepository; + this.#assetsService = assetsService; this.#assetMetadataService = assetMetadataService; } @@ -83,19 +118,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( @@ -105,42 +140,51 @@ export class OnChainAccountService { assertSameAddress(accountAddress, loaded.accountId); - if (options?.resolveWithFullBalance) { - await this.#bindSep41Balances(loaded, scope); + if (options?.fetchWithSep41Balances) { + await this.fetchAndBindSep41Balances(loaded, scope); } return loaded; } /** - * 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 and binds them onto `onChainAccount`. * - * @param onChainAccount - Horizon account to attach SEP-41 entries to. + * 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 - 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 #bindSep41Balances( + async fetchAndBindSep41Balances( onChainAccount: OnChainAccount, scope: KnownCaip2ChainId, - ): Promise { + 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; + 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 (sep41Assets.length === 0) { + return onChainAccount; } - if (sep41AssetIds.length === 0) { - return; + 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({ @@ -148,27 +192,37 @@ export class OnChainAccountService { 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]; - // 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, + const sep41Balances = balancesByAccount[onChainAccount.accountId] ?? {}; + for (const [assetId, balance] of entries(sep41Balances)) { + 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. + await trackError( + new Error(`Asset metadata not found for asset id: ${assetId}`), ); + continue; } - return; - } - - for (const [assetId, assetMetadata] of entries(assetMetadataByAssetId)) { - this.#setSep41BalanceForAccount({ + this.#bindSep41BalanceForAccount({ onChainAccount, assetId, - balance: sep41Balances[assetId], + balance, assetMetadata, }); } + + return onChainAccount; } /** @@ -181,7 +235,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, @@ -210,13 +264,38 @@ 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 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, + ): Promise { + if (await this.#assetsService.isMigrationEnabled()) { + return this.#getOnChainAccountFromAssetsController( + 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 { @@ -230,10 +309,174 @@ export class OnChainAccountService { : null; } + /** + * 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. + * @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 #getOnChainAccountFromAssetsController( + scope: KnownCaip2ChainId, + keyringAccountId: string, + accountAddress: string, + ): Promise { + const assets = await this.#assetsService.getAccountAssetsByScope( + scope, + keyringAccountId, + ); + + if (assets.length === 0) { + // 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 AssetsController', { + accountAddress, + scope, + assets, + }); + + try { + const serializable = this.#mapAssetsControllerAssetsToSerializable({ + accountAddress, + scope, + assets, + }); + return OnChainAccount.fromSerializable(serializable); + } catch (error: unknown) { + return rethrowIfInstanceElseThrow( + error, + [OnChainAccountException], + new OnChainAccountException( + `Failed to map AssetsController assets for account ${accountAddress}`, + { cause: error }, + ), + ); + } + } + + /** + * Maps AssetsController holdings into a full on-chain snapshot. + * + * Best-effort account ledger metadata from AssetsController: + * - 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. + * @param params.scope - CAIP-2 network. + * @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}. + */ + #mapAssetsControllerAssetsToSerializable({ + accountAddress, + scope, + assets, + ledger, + }: { + accountAddress: string; + scope: KnownCaip2ChainId; + assets: CoreAsset[]; + ledger?: AccountLedgerMeta; + }): OnChainAccountSerializableFull { + const balances: SerializableSpendableBalance[] = []; + const accountLedgerMeta: AccountLedgerMeta = ledger ?? { + sequenceNumber: '0', + subentryCount: 0, + numSponsoring: 0, + numSponsored: 0, + rawNativeBalance: '0', + }; + + 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 ledger meta is missing, derive reserve meta from AssetsController XLM metadata. + if (ledger === undefined) { + const { minimumReserveBalance } = asset.balance.metadata; + const reserveMeta = calculateLedgerMetaFromMinReserve( + minimumReserveBalance, + ); + accountLedgerMeta.rawNativeBalance = balance; + accountLedgerMeta.subentryCount = reserveMeta.subentryCount; + accountLedgerMeta.numSponsoring = reserveMeta.numSponsoring; + accountLedgerMeta.numSponsored = reserveMeta.numSponsored; + } + 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, + ...(asset.balance.metadata.sponsor + ? { sponsor: asset.balance.metadata.sponsor } + : {}), + }), + ); + continue; + } + + if (isCoreSep41Asset(asset)) { + balances.push( + SerializableSep41SpendableBalanceStruct.create({ + assetId, + symbol, + balance, + decimals, + }), + ); + } + } + + return OnChainAccountSerializableFullStruct.create({ + accountId: accountAddress, + sequenceNumber: accountLedgerMeta.sequenceNumber, + scope, + meta: { + subentryCount: accountLedgerMeta.subentryCount, + numSponsoring: accountLedgerMeta.numSponsoring, + numSponsored: accountLedgerMeta.numSponsored, + }, + balances, + rawNativeBalance: accountLedgerMeta.rawNativeBalance, + }); + } + /** * 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}. @@ -243,6 +486,13 @@ export class OnChainAccountService { scope: KnownCaip2ChainId, sep41Assets: Sep41StellarAssetMetadata[], ): Promise { + if (await this.#assetsService.isMigrationEnabled()) { + this.#logger.debug( + 'Skipping on-chain account synchronization; assets migration is on', + ); + return; + } + await this.#onChainAccountSynchronizeService.synchronize( activatedAccountPairs, scope, 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 eb0847886..0abae0c66 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 @@ -180,8 +180,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 assets migration is on. + jest.spyOn(assetsService, 'isMigrationEnabled').mockResolvedValue(false); return { onChainAccountService, onChainAccountRepository, 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 6f17928c7..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 @@ -8,6 +8,7 @@ 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'; import { OnChainAccount } from '../OnChainAccount'; @@ -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,11 +173,13 @@ export function mockOnChainAccountService() { cache: new InMemoryCache(noOpLogger), }); const onChainAccountRepository = new OnChainAccountRepository(state); + const { service: assetsService } = createMockAssetsService(); const { service: assetMetadataService } = createMockAssetMetadataService(); const onChainAccountService = new OnChainAccountService({ logger, networkService, onChainAccountRepository, + assetsService, assetMetadataService, }); @@ -185,6 +188,7 @@ export function mockOnChainAccountService() { onChainAccountRepository, 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 128b030df..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 @@ -3,11 +3,18 @@ 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`); 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..46acb2c12 --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/utils.test.ts @@ -0,0 +1,71 @@ +import { BigNumber } from 'bignumber.js'; + +import { + ACCOUNT_MINIMUM_BASE_RESERVE_UNIT, + BASE_RESERVE_STROOPS, +} from '../../constants'; +import { + calculateLedgerMetaFromMinReserve, + calculateSpendableBalance, + minimumBalanceStroops, +} from './utils'; + +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( + calculateLedgerMetaFromMinReserve(emptyAccountReserve), + ).toStrictEqual({ + subentryCount: 0, + numSponsoring: 0, + numSponsored: 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(calculateLedgerMetaFromMinReserve(stroops)).toStrictEqual({ + subentryCount: 4, + numSponsoring: 0, + numSponsored: 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 6d5f102e2..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 @@ -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,50 @@ 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 ledger meta from AssetsController native `minimumReserveBalance` (stroops). + * + * 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 Ledger meta that preserves the controller minimum reserve. + */ +export function calculateLedgerMetaFromMinReserve( + minimumReserveBalanceStroops: string, +): MinimumBalanceLedgerMeta { + const reserveUnits = new BigNumber(minimumReserveBalanceStroops).div( + BASE_RESERVE_STROOPS, + ); + 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(), + }; +} + /** * Spendable native balance (stroops): total native minus minimum balance. * 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);