diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 5b13d4f15..651267199 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -287,9 +287,6 @@ }, "no-restricted-syntax": { "count": 1 - }, - "no-unused-private-class-members": { - "count": 1 } }, "packages/solana-wallet-snap/src/core/handlers/onRpcRequest/index.ts": { @@ -363,9 +360,6 @@ "@typescript-eslint/explicit-function-return-type": { "count": 10 }, - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, "no-unused-private-class-members": { "count": 2 } @@ -519,11 +513,6 @@ "count": 1 } }, - "packages/solana-wallet-snap/src/core/services/send/SendService.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "packages/solana-wallet-snap/src/core/services/send/SendSolBuilder.test.ts": { "import-x/no-named-as-default": { "count": 1 @@ -1256,9 +1245,6 @@ "@typescript-eslint/no-explicit-any": { "count": 1 }, - "@typescript-eslint/no-shadow": { - "count": 1 - }, "jest/unbound-method": { "count": 2 } diff --git a/package.json b/package.json index b4ac811b6..8c9afc43a 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "test:packages": "yarn test:verbose --silent --collectCoverage=false --reporters=jest-silent-reporter", "test:scripts": "NODE_OPTIONS=--experimental-vm-modules yarn jest --config ./jest.config.scripts.js --silent", "test:verbose": "yarn workspaces foreach --all --exclude @metamask/sample-snap --parallel --verbose run test:verbose", - "typecheck": "yarn workspaces foreach --all --parallel --verbose exec tsc --noEmit", + "typecheck": "yarn workspace @metamask/snap-networks-utils run build && yarn workspaces foreach --all --parallel --verbose exec tsc --noEmit", "workspaces:list-versions": "./scripts/list-workspace-versions.sh" }, "devDependencies": { @@ -91,6 +91,7 @@ "resolutions": { "@metamask/snaps-execution-environments": "11.2.0", "@metamask/snaps-sdk": "11.2.0", + "@solana/addresses": "2.1.0", "@solana/kit": "2.1.0", "@types/react": "18.2.4", "@types/react-dom": "18.2.4", diff --git a/packages/solana-wallet-snap/CHANGELOG.md b/packages/solana-wallet-snap/CHANGELOG.md index b7acba42f..58816430c 100644 --- a/packages/solana-wallet-snap/CHANGELOG.md +++ b/packages/solana-wallet-snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Align `AssetsService` read API with `snap-networks-utils` / AssetsController shapes by adding `getAccountAssetByID`, `getAccountAssetsByIDs`, `getAccountAssetsByScope`, and `getAccountAssets`, and routing Keyring and Send through them (still Snap-owned storage). ([#120](https://github.com/MetaMask/internal-snaps/pull/120)) + ## [6.0.0] ### Changed diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index 790f97ff1..0632d4d28 100644 --- a/packages/solana-wallet-snap/snap.manifest.json +++ b/packages/solana-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "Zj/AFb6WtXcDvQqAvfHjjEBTdWVdP0tVGUzLnPhAhQ4=", + "shasum": "YaPEFBNuMbbASorQCj4MFJKRSlqOjSOqJoOGUB9ET2I=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts index 5c27fe3eb..2a8284d9c 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.test.ts @@ -103,7 +103,8 @@ describe('SolanaKeyring', () => { mockAssetsService = { fetch: jest.fn().mockResolvedValue(MOCK_ASSET_ENTITIES), saveMany: jest.fn(), - findByAccount: jest.fn(), + getAccountAssets: jest.fn(), + getAccountAssetsByIDs: jest.fn(), getNativeAssetTypes: jest .fn() .mockReturnValue([KnownCaip19Id.SolMainnet]), @@ -143,13 +144,16 @@ describe('SolanaKeyring', () => { describe('getAccountAssets', () => { it('calls the assets service', async () => { jest - .spyOn(mockAssetsService, 'findByAccount') + .spyOn(mockAssetsService, 'getAccountAssets') .mockResolvedValue(MOCK_ASSET_ENTITIES); const result = await keyring.getAccountAssets( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, ); + expect(mockAssetsService.getAccountAssets).toHaveBeenCalledWith( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); expect(result).toStrictEqual([ MOCK_ASSET_ENTITY_0.assetType, MOCK_ASSET_ENTITY_1.assetType, @@ -158,7 +162,7 @@ describe('SolanaKeyring', () => { }); it('removes token assets with zero balance', async () => { - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ + jest.spyOn(mockAssetsService, 'getAccountAssets').mockResolvedValue([ MOCK_ASSET_ENTITY_1, // Token asset with non-zero balance { ...MOCK_ASSET_ENTITY_2, rawAmount: '0' }, // Token asset with zero balance ]); @@ -171,7 +175,7 @@ describe('SolanaKeyring', () => { }); it('keeps the native asset even if it has zero balance', async () => { - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ + jest.spyOn(mockAssetsService, 'getAccountAssets').mockResolvedValue([ { ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }, // Native asset with zero balance { ...MOCK_ASSET_ENTITY_1, rawAmount: '0' }, // Token asset with zero balance ]); @@ -343,9 +347,9 @@ describe('SolanaKeyring', () => { symbol: 4, } as unknown as AssetEntity; - jest - .spyOn(mockAssetsService, 'findByAccount') - .mockResolvedValue([invalidAsset]); + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [KnownCaip19Id.SolMainnet]: invalidAsset, + }); await expect( keyring.getAccountBalances(MOCK_SOLANA_KEYRING_ACCOUNT_1.id, [ @@ -355,10 +359,13 @@ describe('SolanaKeyring', () => { }); it('removes token assets with zero balance', async () => { - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - MOCK_ASSET_ENTITY_1, // Token asset with non-zero balance - { ...MOCK_ASSET_ENTITY_2, rawAmount: '0' }, // Token asset with zero balance - ]); + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [MOCK_ASSET_ENTITY_1.assetType]: MOCK_ASSET_ENTITY_1, + [MOCK_ASSET_ENTITY_2.assetType]: { + ...MOCK_ASSET_ENTITY_2, + rawAmount: '0', + }, + }); const result = await keyring.getAccountBalances( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, @@ -374,10 +381,16 @@ describe('SolanaKeyring', () => { }); it('keeps the native asset even if it has zero balance', async () => { - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { ...MOCK_ASSET_ENTITY_0, rawAmount: '0' }, // Native asset with zero balance - { ...MOCK_ASSET_ENTITY_1, rawAmount: '0' }, // Token asset with zero balance - ]); + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [MOCK_ASSET_ENTITY_0.assetType]: { + ...MOCK_ASSET_ENTITY_0, + rawAmount: '0', + }, + [MOCK_ASSET_ENTITY_1.assetType]: { + ...MOCK_ASSET_ENTITY_1, + rawAmount: '0', + }, + }); const result = await keyring.getAccountBalances( MOCK_SOLANA_KEYRING_ACCOUNT_0.id, diff --git a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts index 9dc6a9858..4feadc899 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts @@ -117,8 +117,6 @@ export class SolanaKeyring implements KeyringSnapRpc { readonly #keyringAccountMonitor: KeyringAccountMonitor; - readonly #traceName: string = 'Create Solana Account'; - readonly #traceNameBatch: string = 'Create Solana Account Batch'; constructor({ @@ -413,9 +411,10 @@ export class SolanaKeyring implements KeyringSnapRpc { try { validateRequest({ accountId }, ListAccountAssetsStruct); - const account = await this.getAccountOrThrow(accountId); + await this.getAccountOrThrow(accountId); - const assetEntities = await this.#assetsService.findByAccount(account); + const assetEntities = + await this.#assetsService.getAccountAssets(accountId); const result = assetEntities // Remove token assets with zero balance @@ -448,10 +447,15 @@ export class SolanaKeyring implements KeyringSnapRpc { try { validateRequest({ accountId, assets }, GetAccountBalancesStruct); - const account = await this.getAccountOrThrow(accountId); + await this.getAccountOrThrow(accountId); + + const assetsById = await this.#assetsService.getAccountAssetsByIDs( + accountId, + assets, + ); - const assetsToUse = (await this.#assetsService.findByAccount(account)) - .filter((asset) => assets.includes(asset.assetType)) + const assetsToUse = Object.values(assetsById) + .filter((asset): asset is NonNullable => asset !== null) // Remove token assets with zero balance .filter( (asset) => diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts index d7e7f6726..3bb5fca22 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.test.ts @@ -17,6 +17,7 @@ import { SOLANA_MOCK_TOKEN_METADATA, } from '../../test/mocks/asset-entities'; import { MOCK_SOLANA_KEYRING_ACCOUNT_0 } from '../../test/mocks/solana-keyring-accounts'; +import type { AccountsService } from '../accounts/AccountsService'; import type { ConfigProvider } from '../config'; import type { SolanaConnection } from '../connection'; import { mockLogger } from '../mocks/logger'; @@ -35,6 +36,7 @@ describe('AssetsService', () => { let mockConnection: SolanaConnection; let mockConfigProvider: ConfigProvider; let mockAssetsRepository: AssetsRepository; + let mockAccountsService: AccountsService; let mockTokenApiClient: TokenApiClient; let mockTokenPricesService: TokenPricesService; let mockNftApiClient: NftApiClient; @@ -81,11 +83,16 @@ describe('AssetsService', () => { saveMany: jest.fn(), } as unknown as AssetsRepository; + mockAccountsService = { + findById: jest.fn().mockResolvedValue(MOCK_SOLANA_KEYRING_ACCOUNT_0), + } as unknown as AccountsService; + assetsService = new AssetsService({ connection: mockConnection, logger: mockLogger, configProvider: mockConfigProvider, assetsRepository: mockAssetsRepository, + accountsService: mockAccountsService, tokenApiClient: mockTokenApiClient, tokenPricesService: mockTokenPricesService, cache: mockCache, @@ -604,4 +611,161 @@ describe('AssetsService', () => { expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); }); }); + + describe('getAccountAssetByID', () => { + it('returns the matching asset when present', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const asset = await assetsService.getAccountAssetByID( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_ASSET_ENTITY_1.assetType, + ); + + expect(asset).toStrictEqual(MOCK_ASSET_ENTITY_1); + }); + + it('returns null when the asset is missing', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce([]); + + const asset = await assetsService.getAccountAssetByID( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + MOCK_ASSET_ENTITY_1.assetType, + ); + + expect(asset).toBeNull(); + }); + + it('returns null when the account is missing', async () => { + jest.spyOn(mockAccountsService, 'findById').mockResolvedValueOnce(null); + + const asset = await assetsService.getAccountAssetByID( + 'missing-account', + MOCK_ASSET_ENTITY_1.assetType, + ); + + expect(asset).toBeNull(); + expect( + mockAssetsRepository.findByKeyringAccountId, + ).not.toHaveBeenCalled(); + }); + }); + + describe('getAccountAssetsByIDs', () => { + it('returns a record keyed by asset ID', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const assets = await assetsService.getAccountAssetsByIDs( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + [MOCK_ASSET_ENTITY_0.assetType, MOCK_ASSET_ENTITY_1.assetType], + ); + + expect(assets).toStrictEqual({ + [MOCK_ASSET_ENTITY_0.assetType]: MOCK_ASSET_ENTITY_0, + [MOCK_ASSET_ENTITY_1.assetType]: MOCK_ASSET_ENTITY_1, + }); + }); + + it('returns null entries for missing assets', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce([MOCK_ASSET_ENTITY_0]); + + const assets = await assetsService.getAccountAssetsByIDs( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + [MOCK_ASSET_ENTITY_0.assetType, MOCK_ASSET_ENTITY_1.assetType], + ); + + expect(assets).toStrictEqual({ + [MOCK_ASSET_ENTITY_0.assetType]: MOCK_ASSET_ENTITY_0, + [MOCK_ASSET_ENTITY_1.assetType]: null, + }); + }); + + it('returns an empty record for an empty asset ID list', async () => { + const assets = await assetsService.getAccountAssetsByIDs( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + [], + ); + + expect(assets).toStrictEqual({}); + expect(mockAccountsService.findById).not.toHaveBeenCalled(); + }); + + it('returns null entries when the account is missing', async () => { + jest.spyOn(mockAccountsService, 'findById').mockResolvedValueOnce(null); + + const assets = await assetsService.getAccountAssetsByIDs( + 'missing-account', + [MOCK_ASSET_ENTITY_0.assetType, MOCK_ASSET_ENTITY_1.assetType], + ); + + expect(assets).toStrictEqual({ + [MOCK_ASSET_ENTITY_0.assetType]: null, + [MOCK_ASSET_ENTITY_1.assetType]: null, + }); + expect( + mockAssetsRepository.findByKeyringAccountId, + ).not.toHaveBeenCalled(); + }); + }); + + describe('getAccountAssetsByScope', () => { + it('filters account assets to the requested scope', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const assets = await assetsService.getAccountAssetsByScope( + Network.Mainnet, + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); + }); + + it('returns an empty array when the account is missing', async () => { + jest.spyOn(mockAccountsService, 'findById').mockResolvedValueOnce(null); + + const assets = await assetsService.getAccountAssetsByScope( + Network.Mainnet, + 'missing-account', + ); + + expect(assets).toStrictEqual([]); + expect( + mockAssetsRepository.findByKeyringAccountId, + ).not.toHaveBeenCalled(); + }); + }); + + describe('getAccountAssets', () => { + it('returns assets across all active networks', async () => { + jest + .spyOn(mockAssetsRepository, 'findByKeyringAccountId') + .mockResolvedValueOnce(MOCK_ASSET_ENTITIES); + + const assets = await assetsService.getAccountAssets( + MOCK_SOLANA_KEYRING_ACCOUNT_0.id, + ); + + expect(assets).toStrictEqual(MOCK_ASSET_ENTITIES); + }); + + it('returns an empty array when the account is missing', async () => { + jest.spyOn(mockAccountsService, 'findById').mockResolvedValueOnce(null); + + const assets = await assetsService.getAccountAssets('missing-account'); + + expect(assets).toStrictEqual([]); + expect( + mockAssetsRepository.findByKeyringAccountId, + ).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts index a2778e7b1..7a58bfeb0 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsService.ts @@ -11,7 +11,7 @@ import type { FungibleAssetMarketData, FungibleAssetMetadata, } from '@metamask/snaps-sdk'; -import type { CaipAssetType } from '@metamask/utils'; +import type { CaipAssetType, CaipChainId } from '@metamask/utils'; import { Duration, parseCaipAssetType } from '@metamask/utils'; import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; import { TOKEN_2022_PROGRAM_ADDRESS } from '@solana-program/token-2022'; @@ -46,6 +46,7 @@ import { getNetworkFromToken } from '../../utils/getNetworkFromToken'; import { createPrefixedLogger } from '../../utils/logger'; import type { ILogger } from '../../utils/logger'; import { tokenAddressToCaip19 } from '../../utils/tokenAddressToCaip19'; +import type { AccountsService } from '../accounts/AccountsService'; import type { ConfigProvider } from '../config'; import type { SolanaConnection } from '../connection'; import type { TokenPricesService } from '../token-prices/TokenPrices'; @@ -71,6 +72,8 @@ export class AssetsService { readonly #assetsRepository: AssetsRepository; + readonly #accountsService: AccountsService; + readonly #tokenPricesService: TokenPricesService; readonly #tokenApiClient: TokenApiClient; @@ -88,6 +91,7 @@ export class AssetsService { logger, configProvider, assetsRepository, + accountsService, tokenApiClient, tokenPricesService, cache, @@ -97,6 +101,7 @@ export class AssetsService { logger: ILogger; configProvider: ConfigProvider; assetsRepository: AssetsRepository; + accountsService: AccountsService; tokenApiClient: TokenApiClient; tokenPricesService: TokenPricesService; cache: ICache; @@ -106,6 +111,7 @@ export class AssetsService { this.#connection = connection; this.#configProvider = configProvider; this.#assetsRepository = assetsRepository; + this.#accountsService = accountsService; this.#tokenApiClient = tokenApiClient; this.#tokenPricesService = tokenPricesService; this.#cache = cache; @@ -215,7 +221,7 @@ export class AssetsService { ): Promise> { this.#logger.log('Fetching metadata for assets', assetTypes); - const { nativeAssetTypes, tokenAssetTypes, nftAssetTypes } = + const { nativeAssetTypes, tokenAssetTypes } = this.#splitAssetsByType(assetTypes); const [ @@ -640,8 +646,93 @@ export class AssetsService { return this.#assetsRepository.getAll(); } + /** + * Resolves account assets via {@link findByAccount}, or `[]` if the account + * is missing. Centralizes the account lookup shared by the read API. + * + * @param accountId - Keyring account ID. + */ + async #getAccountAssetsOrEmpty(accountId: string): Promise { + const account = await this.#accountsService.findById(accountId); + + if (!account) { + return []; + } + + return this.findByAccount(account); + } + + /** + * Returns a single account asset by CAIP-19 ID, or `null` if missing. + * + * @param accountId - Keyring account ID. + * @param assetId - CAIP-19 asset ID. + */ + async getAccountAssetByID( + accountId: string, + assetId: CaipAssetType, + ): Promise { + const assets = await this.getAccountAssetsByIDs(accountId, [assetId]); + + return assets[assetId] ?? null; + } + + /** + * Returns account assets for the given CAIP-19 IDs, keyed by asset ID. + * Missing assets are `null`. + * + * @param accountId - Keyring account ID. + * @param assetIds - CAIP-19 asset IDs to resolve. + */ + async getAccountAssetsByIDs( + accountId: string, + assetIds: CaipAssetType[], + ): Promise> { + if (assetIds.length === 0) { + return {} as Record; + } + + const accountAssets = await this.#getAccountAssetsOrEmpty(accountId); + const assetsByType = new Map( + accountAssets.map((asset) => [asset.assetType, asset]), + ); + + return Object.fromEntries( + assetIds.map((assetId) => [assetId, assetsByType.get(assetId) ?? null]), + ) as Record; + } + + /** + * Returns controller-backed assets for an account on the given Solana scope. + * + * @param scope - CAIP-2 chain ID to filter results. + * @param accountId - Keyring account ID. + */ + async getAccountAssetsByScope( + scope: CaipChainId, + accountId: string, + ): Promise { + const accountAssets = await this.#getAccountAssetsOrEmpty(accountId); + + return accountAssets.filter((asset) => asset.assetType.startsWith(scope)); + } + + /** + * Returns assets for an account across all active Solana networks. + * + * @param accountId - Keyring account ID. + */ + async getAccountAssets(accountId: string): Promise { + const activeNetworks = await this.#configProvider.getActiveNetworks(); + const accountAssets = await this.#getAccountAssetsOrEmpty(accountId); + + return accountAssets.filter((asset) => + activeNetworks.some((scope) => asset.assetType.startsWith(scope)), + ); + } + async findByAccount(account: SolanaKeyringAccount): Promise { - const { id: keyringAccountId, address } = account; + const { id: keyringAccountId } = account; const savedAssets = await this.#assetsRepository.findByKeyringAccountId(keyringAccountId); @@ -656,15 +747,13 @@ export class AssetsService { ); if (!hasNativeAsset) { - // Create a placeholder native asset with zero balance - // This will be updated when assets are actually fetched const network = getNetworkFromToken(nativeAssetType); missingNativeAssets.push({ assetType: nativeAssetType, keyringAccountId: account.id, network, - address, + address: account.address, symbol: 'SOL', decimals: 9, rawAmount: '0', diff --git a/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts b/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts index bf47c8336..7ef7660cc 100644 --- a/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/send/SendService.test.ts @@ -108,7 +108,7 @@ describe('SendService', () => { } as unknown as SendSplTokenBuilder; mockAssetsService = { - findByAccount: jest.fn(), + getAccountAssetsByIDs: jest.fn(), } as unknown as AssetsService; (fromTransactionToBase64String as jest.Mock).mockReturnValue( @@ -291,8 +291,16 @@ describe('SendService', () => { beforeEach(() => { jest - .spyOn(mockAssetsService, 'findByAccount') - .mockResolvedValue(mockAssetBalances); + .spyOn(mockAssetsService, 'getAccountAssetsByIDs') + .mockImplementation(async (_accountId, assetIds) => + Object.fromEntries( + assetIds.map((assetId) => [ + assetId, + mockAssetBalances.find((asset) => asset.assetType === assetId) ?? + null, + ]), + ), + ); jest.spyOn(mockConnection, 'getRpc').mockReturnValue({ getMinimumBalanceForRentExemption: jest.fn().mockReturnValue({ @@ -325,7 +333,10 @@ describe('SendService', () => { }); it('rejects when asset balance not found', async () => { - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([]); + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [mockRequest.params.assetId]: null, + [Networks[Network.Mainnet].nativeToken.caip19Id]: null, + }); await expect(sendService.onAmountInput(mockRequest)).rejects.toThrow( `Balance not found for asset ${mockRequest.params.assetId} and account ${mockAccount.id}`, @@ -338,8 +349,8 @@ describe('SendService', () => { params: { ...mockRequest.params, value: '0.000001' }, }; - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [Networks[Network.Mainnet].nativeToken.caip19Id]: { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '0.00001', keyringAccountId: mockAccount.id, @@ -349,7 +360,17 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '999999999999999999', }, - ]); + [mockRequest.params.assetId]: { + assetType: Networks[Network.Mainnet].nativeToken.caip19Id, + uiAmount: '0.00001', + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + address: mockAccount.address, + symbol: Networks[Network.Mainnet].nativeToken.symbol, + decimals: Networks[Network.Mainnet].nativeToken.decimals, + rawAmount: '999999999999999999', + }, + }); const result = await sendService.onAmountInput(lowBalanceRequest); @@ -397,8 +418,8 @@ describe('SendService', () => { }, }; - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [Networks[Network.Mainnet].nativeToken.caip19Id]: { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '0.1', keyringAccountId: mockAccount.id, @@ -408,7 +429,7 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '10000000000', }, - { + [KnownCaip19Id.UsdcMainnet]: { assetType: KnownCaip19Id.UsdcMainnet, uiAmount: '0.001', keyringAccountId: mockAccount.id, @@ -419,7 +440,7 @@ describe('SendService', () => { decimals: 6, rawAmount: '1000000', }, - ]); + }); const result = await sendService.onAmountInput(zeroBalanceRequest); @@ -435,8 +456,18 @@ describe('SendService', () => { params: { ...mockRequest.params, value: '0.1' }, }; - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [Networks[Network.Mainnet].nativeToken.caip19Id]: { + assetType: Networks[Network.Mainnet].nativeToken.caip19Id, + uiAmount: '0', + keyringAccountId: mockAccount.id, + network: Network.Mainnet, + address: mockAccount.address, + symbol: Networks[Network.Mainnet].nativeToken.symbol, + decimals: Networks[Network.Mainnet].nativeToken.decimals, + rawAmount: '0', + }, + [mockRequest.params.assetId]: { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '0', keyringAccountId: mockAccount.id, @@ -446,7 +477,7 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '0', }, - ]); + }); const result = await sendService.onAmountInput(zeroSolRequest); @@ -465,8 +496,8 @@ describe('SendService', () => { }, }; - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [KnownCaip19Id.UsdcMainnet]: { assetType: KnownCaip19Id.UsdcMainnet, uiAmount: '100.0', keyringAccountId: mockAccount.id, @@ -477,7 +508,7 @@ describe('SendService', () => { decimals: 6, rawAmount: '100000000000', }, - { + [Networks[Network.Mainnet].nativeToken.caip19Id]: { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '1.0', keyringAccountId: mockAccount.id, @@ -487,7 +518,7 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '10000000000', }, - ]); + }); const result = await sendService.onAmountInput(tokenRequest); @@ -506,8 +537,8 @@ describe('SendService', () => { }, }; - jest.spyOn(mockAssetsService, 'findByAccount').mockResolvedValue([ - { + jest.spyOn(mockAssetsService, 'getAccountAssetsByIDs').mockResolvedValue({ + [KnownCaip19Id.UsdcMainnet]: { assetType: KnownCaip19Id.UsdcMainnet, uiAmount: '100.0', keyringAccountId: mockAccount.id, @@ -518,7 +549,7 @@ describe('SendService', () => { decimals: 6, rawAmount: '100000000000', }, - { + [Networks[Network.Mainnet].nativeToken.caip19Id]: { assetType: Networks[Network.Mainnet].nativeToken.caip19Id, uiAmount: '0.0001', keyringAccountId: mockAccount.id, @@ -528,7 +559,7 @@ describe('SendService', () => { decimals: Networks[Network.Mainnet].nativeToken.decimals, rawAmount: '10000000000', }, - ]); + }); const result = await sendService.onAmountInput(tokenRequest); @@ -549,7 +580,9 @@ describe('SendService', () => { it('handles errors if balances are not found', async () => { const error = new Error('Failed to fetch balances'); - jest.spyOn(mockAssetsService, 'findByAccount').mockRejectedValue(error); + jest + .spyOn(mockAssetsService, 'getAccountAssetsByIDs') + .mockRejectedValue(error); await expect(sendService.onAmountInput(mockRequest)).rejects.toThrow( 'Failed to fetch balances', diff --git a/packages/solana-wallet-snap/src/core/services/send/SendService.ts b/packages/solana-wallet-snap/src/core/services/send/SendService.ts index cbfea69f5..242ba86cc 100644 --- a/packages/solana-wallet-snap/src/core/services/send/SendService.ts +++ b/packages/solana-wallet-snap/src/core/services/send/SendService.ts @@ -189,7 +189,7 @@ export class SendService { valid: true, errors: [], }; - } catch (error) { + } catch { return { valid: false, errors: [{ code: SendErrorCodes.Invalid }], @@ -215,7 +215,7 @@ export class SendService { params: { value, accountId, assetId }, } = request; - const account = await this.#keyring.getAccountOrThrow(accountId); + await this.#keyring.getAccountOrThrow(accountId); const { chainId } = parseCaipAssetType(assetId); @@ -225,15 +225,13 @@ export class SendService { const isNativeToken = assetId === nativeAssetType; - const accountBalances = await this.#assetsService.findByAccount(account); - - const assetEntry = accountBalances.find( - (asset) => asset.assetType === assetId, + const assetsById = await this.#assetsService.getAccountAssetsByIDs( + accountId, + [assetId, nativeAssetType], ); - const nativeAsset = accountBalances.find( - (asset) => asset.assetType === nativeAssetType, - ); + const assetEntry = assetsById[assetId]; + const nativeAsset = assetsById[nativeAssetType]; if (!assetEntry) { throw new Error( diff --git a/packages/solana-wallet-snap/src/index.test.ts b/packages/solana-wallet-snap/src/index.test.ts index 11814354e..32eac1280 100644 --- a/packages/solana-wallet-snap/src/index.test.ts +++ b/packages/solana-wallet-snap/src/index.test.ts @@ -1,4 +1,3 @@ -import { expect } from '@jest/globals'; import { installSnap } from '@metamask/snaps-jest'; import { onCronjob } from '.'; diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 58e488561..32fc38f63 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -144,20 +144,22 @@ const tokenPricesService = new TokenPricesService({ const nameResolutionService = new NameResolutionService(connection, logger); const assetsRepository = new AssetsRepository(state); + +const accountsRepository = new AccountsRepository(state); +const accountsService = new AccountsService(accountsRepository); + const assetsService = new AssetsService({ connection, logger, configProvider, assetsRepository, + accountsService, tokenApiClient, cache: inMemoryCache, tokenPricesService, nftApiClient, }); -const accountsRepository = new AccountsRepository(state); -const accountsService = new AccountsService(accountsRepository); - const transactionsRepository = new TransactionsRepository(state); const transactionMapper = new TransactionMapper( tokenHelper, diff --git a/yarn.lock b/yarn.lock index 1a044bd76..0287253a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4563,21 +4563,6 @@ __metadata: languageName: node linkType: hard -"@solana/addresses@npm:^2.0.0": - version: 2.3.0 - resolution: "@solana/addresses@npm:2.3.0" - dependencies: - "@solana/assertions": "npm:2.3.0" - "@solana/codecs-core": "npm:2.3.0" - "@solana/codecs-strings": "npm:2.3.0" - "@solana/errors": "npm:2.3.0" - "@solana/nominal-types": "npm:2.3.0" - peerDependencies: - typescript: ">=5.3.3" - checksum: 10/11ee6774f938dd2fe5174375b02c4e52063173ce80a895dd35beb84635dcf3e2ba2e3fe7c74a614b2322da10906abceab758c91a13415a9cda53faf91a8ea722 - languageName: node - linkType: hard - "@solana/assertions@npm:2.1.0": version: 2.1.0 resolution: "@solana/assertions@npm:2.1.0" @@ -4589,17 +4574,6 @@ __metadata: languageName: node linkType: hard -"@solana/assertions@npm:2.3.0": - version: 2.3.0 - resolution: "@solana/assertions@npm:2.3.0" - dependencies: - "@solana/errors": "npm:2.3.0" - peerDependencies: - typescript: ">=5.3.3" - checksum: 10/3b430f01d5991569059fea3e6679a4548a624ff5da6db0c2eb2eba757a9ff774c9d90941a0a2335148599aaef8e8fc004a9c5996eb3627b25dca108b05591d02 - languageName: node - linkType: hard - "@solana/codecs-core@npm:2.1.0": version: 2.1.0 resolution: "@solana/codecs-core@npm:2.1.0" @@ -4611,17 +4585,6 @@ __metadata: languageName: node linkType: hard -"@solana/codecs-core@npm:2.3.0": - version: 2.3.0 - resolution: "@solana/codecs-core@npm:2.3.0" - dependencies: - "@solana/errors": "npm:2.3.0" - peerDependencies: - typescript: ">=5.3.3" - checksum: 10/d9bba1eaa3ee38fef04e1cbfa43defeea16729a1cf1628a71cb72340558a3f2296279899680e1dda4b1756ab2b280b5f1502330c21c35e167e554f3d0c9d193d - languageName: node - linkType: hard - "@solana/codecs-data-structures@npm:2.1.0": version: 2.1.0 resolution: "@solana/codecs-data-structures@npm:2.1.0" @@ -4647,18 +4610,6 @@ __metadata: languageName: node linkType: hard -"@solana/codecs-numbers@npm:2.3.0": - version: 2.3.0 - resolution: "@solana/codecs-numbers@npm:2.3.0" - dependencies: - "@solana/codecs-core": "npm:2.3.0" - "@solana/errors": "npm:2.3.0" - peerDependencies: - typescript: ">=5.3.3" - checksum: 10/e661338b5eb04268a104ff2189b5d001bd2f99e1a3726deaa7157d5acbc3b24740bc25ca03b2028c52ad21fd71d5d5aa64957411c895a9dc1c132aa3bc97b336 - languageName: node - linkType: hard - "@solana/codecs-strings@npm:2.1.0": version: 2.1.0 resolution: "@solana/codecs-strings@npm:2.1.0" @@ -4673,20 +4624,6 @@ __metadata: languageName: node linkType: hard -"@solana/codecs-strings@npm:2.3.0": - version: 2.3.0 - resolution: "@solana/codecs-strings@npm:2.3.0" - dependencies: - "@solana/codecs-core": "npm:2.3.0" - "@solana/codecs-numbers": "npm:2.3.0" - "@solana/errors": "npm:2.3.0" - peerDependencies: - fastestsmallesttextencoderdecoder: ^1.0.22 - typescript: ">=5.3.3" - checksum: 10/a553a891ce149a87f37ff338587980e81f9752e13039cb0d02f0fc6f21ce27af8548ad10f4c9e63f9ef74786ea0e498c91a2f5e3e996a930f581f2977bb1c215 - languageName: node - linkType: hard - "@solana/codecs@npm:2.1.0": version: 2.1.0 resolution: "@solana/codecs@npm:2.1.0" @@ -4716,20 +4653,6 @@ __metadata: languageName: node linkType: hard -"@solana/errors@npm:2.3.0": - version: 2.3.0 - resolution: "@solana/errors@npm:2.3.0" - dependencies: - chalk: "npm:^5.4.1" - commander: "npm:^14.0.0" - peerDependencies: - typescript: ">=5.3.3" - bin: - errors: bin/cli.mjs - checksum: 10/0e8a329790b7d38b4bfe1fa6ec2ac60be20562a610d992031395fe9886da28b578a9d0aebb318f5357ae0d4cbc8f3d323c12b9520da2cf6adc9038f96afc3fe1 - languageName: node - linkType: hard - "@solana/fast-stable-stringify@npm:2.1.0": version: 2.1.0 resolution: "@solana/fast-stable-stringify@npm:2.1.0" @@ -4802,15 +4725,6 @@ __metadata: languageName: node linkType: hard -"@solana/nominal-types@npm:2.3.0": - version: 2.3.0 - resolution: "@solana/nominal-types@npm:2.3.0" - peerDependencies: - typescript: ">=5.3.3" - checksum: 10/0594893661f4ff2f8587689cd4b61ee15c38c455fe5cbaa7ae7e416f3a483fac97cc3f5a5b3d0a7526bfb89d7da91bc2c72e7b1790bbe59b986579ef2f76689b - languageName: node - linkType: hard - "@solana/options@npm:2.1.0": version: 2.1.0 resolution: "@solana/options@npm:2.1.0" @@ -7479,7 +7393,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:5.6.2, chalk@npm:^5.3.0, chalk@npm:^5.4.1": +"chalk@npm:5.6.2, chalk@npm:^5.3.0": version: 5.6.2 resolution: "chalk@npm:5.6.2" checksum: 10/1b2f48f6fba1370670d5610f9cd54c391d6ede28f4b7062dd38244ea5768777af72e5be6b74fb6c6d54cb84c4a2dff3f3afa9b7cb5948f7f022cfd3d087989e0 @@ -7713,13 +7627,6 @@ __metadata: languageName: node linkType: hard -"commander@npm:^14.0.0": - version: 14.0.3 - resolution: "commander@npm:14.0.3" - checksum: 10/dfa9ebe2a433d277de5cb0252d23b10a543d245d892db858d23b516336a835c50fd4f52bee4cd13c705cc8acb6f03dc632c73dd806f7d06d3353eb09953dd17a - languageName: node - linkType: hard - "commander@npm:^2.20.0": version: 2.20.3 resolution: "commander@npm:2.20.3"