From 768fd87b8da843b1dab64d1b712cd63f7943924a Mon Sep 17 00:00:00 2001 From: Julien Fontanel Date: Wed, 9 Sep 2026 16:54:34 +0200 Subject: [PATCH 1/2] feat(stellar-wallet-snap): use shareable state management lib --- .../stellar-wallet-snap/snap.manifest.json | 2 +- packages/stellar-wallet-snap/src/context.ts | 11 +- .../services/account/AccountsRepository.ts | 2 +- .../account/__mocks__/account.fixtures.ts | 12 +- .../AssetMetadataRepository.test.ts | 6 +- .../asset-metadata/AssetMetadataRepository.ts | 2 +- .../__mocks__/assets.fixtures.ts | 8 +- .../src/services/cache/InMemoryState.ts | 46 -- .../src/services/cache/StateCache.test.ts | 5 +- .../src/services/cache/StateCache.ts | 7 +- .../OnChainAccountRepository.ts | 2 +- .../__mocks__/onChainAccount.fixtures.ts | 11 +- .../src/services/state/IStateManager.ts | 85 ---- .../src/services/state/State.test.ts | 408 ------------------ .../src/services/state/State.ts | 193 --------- .../src/services/state/index.ts | 2 - .../src/services/state/stateTypes.ts | 17 + .../transaction/TransactionRepository.test.ts | 37 +- .../transaction/TransactionRepository.ts | 6 +- .../TransactionSynchronizeService.test.ts | 11 +- .../__mocks__/transaction.fixtures.ts | 11 +- .../src/utils/__mocks__/snap.ts | 3 - .../stellar-wallet-snap/src/utils/snap.ts | 85 ---- 23 files changed, 62 insertions(+), 910 deletions(-) delete mode 100644 packages/stellar-wallet-snap/src/services/cache/InMemoryState.ts delete mode 100644 packages/stellar-wallet-snap/src/services/state/IStateManager.ts delete mode 100644 packages/stellar-wallet-snap/src/services/state/State.test.ts delete mode 100644 packages/stellar-wallet-snap/src/services/state/State.ts delete mode 100644 packages/stellar-wallet-snap/src/services/state/index.ts create mode 100644 packages/stellar-wallet-snap/src/services/state/stateTypes.ts diff --git a/packages/stellar-wallet-snap/snap.manifest.json b/packages/stellar-wallet-snap/snap.manifest.json index 81e9fa822..91f8e01e2 100644 --- a/packages/stellar-wallet-snap/snap.manifest.json +++ b/packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "df2jwrVqYLs741TWeyLYKABatgBkFCEDWJdwpnNwiXw=", + "shasum": "76h1Htpk8MNnwqlMdjcCrmimVkqw4hMSRxksHa5BGC0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/stellar-wallet-snap/src/context.ts b/packages/stellar-wallet-snap/src/context.ts index d29ec967b..6aa91f924 100644 --- a/packages/stellar-wallet-snap/src/context.ts +++ b/packages/stellar-wallet-snap/src/context.ts @@ -1,3 +1,4 @@ +import { State } from '@metamask/snap-networks-utils'; import { assert, object } from '@metamask/superstruct'; import { AppConfig } from './config'; @@ -45,7 +46,7 @@ import { OnChainAccountService, } from './services/on-chain-account'; import { PriceService } from './services/price'; -import { State } from './services/state'; +import { DEFAULT_UNENCRYPTED_STATE } from './services/state/stateTypes'; import { SynchronizeService } from './services/sync/SynchronizeService'; import { TransactionBuilder, @@ -64,13 +65,7 @@ assert(AppConfig, object()); const state = new State({ encrypted: false, - defaultState: { - keyringAccounts: {}, - assets: {}, - transactions: {}, - lastScanTokens: {}, - onChainAccounts: {}, - }, + defaultState: DEFAULT_UNENCRYPTED_STATE, }); const accountsRepository = new AccountsRepository(state); diff --git a/packages/stellar-wallet-snap/src/services/account/AccountsRepository.ts b/packages/stellar-wallet-snap/src/services/account/AccountsRepository.ts index b559b69f0..c532bbb07 100644 --- a/packages/stellar-wallet-snap/src/services/account/AccountsRepository.ts +++ b/packages/stellar-wallet-snap/src/services/account/AccountsRepository.ts @@ -1,8 +1,8 @@ +import type { IStateManager } from '@metamask/snap-networks-utils'; import { cloneDeep } from 'lodash'; import type { KnownCaip2ChainId } from '../../api'; import { isSameStr } from '../../utils/assert'; -import type { IStateManager } from '../state/IStateManager'; import type { KeyringAccountState, StellarKeyringAccount } from './api'; /** diff --git a/packages/stellar-wallet-snap/src/services/account/__mocks__/account.fixtures.ts b/packages/stellar-wallet-snap/src/services/account/__mocks__/account.fixtures.ts index 2edc659c7..d901043b1 100644 --- a/packages/stellar-wallet-snap/src/services/account/__mocks__/account.fixtures.ts +++ b/packages/stellar-wallet-snap/src/services/account/__mocks__/account.fixtures.ts @@ -1,8 +1,9 @@ +import { InMemoryState } from '@metamask/snap-networks-utils'; + import { KnownCaip2ChainId } from '../../../api'; import { KEYRING_ACCOUNT_TYPE } from '../../../constants'; import { MultichainMethod } from '../../../handlers/keyring/api'; import { logger } from '../../../utils/logger'; -import { State } from '../../state/State'; import { WalletService, getDerivationPath } from '../../wallet'; import { generateStellarAddress } from '../../wallet/__mocks__/wallet.fixtures'; import { AccountService } from '../AccountService'; @@ -54,12 +55,9 @@ export const generateMockStellarKeyringAccounts = ( */ export const mockAccountService = () => { const walletService = new WalletService(); - const state = new State({ - encrypted: false, - defaultState: { - keyringAccounts: {}, - onChainAccounts: {}, - }, + const state = new InMemoryState({ + keyringAccounts: {}, + onChainAccounts: {}, }); const accountService = new AccountService({ logger, diff --git a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts index cddca0fce..b865e2553 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.test.ts @@ -1,8 +1,8 @@ +import type { IStateManager } from '@metamask/snap-networks-utils'; import { cloneDeep } from 'lodash'; -import { AssetType, KnownCaip2ChainId } from '../../api'; import type { KnownCaip19AssetId } from '../../api'; -import type { IStateManager } from '../state/IStateManager'; +import { AssetType, KnownCaip2ChainId } from '../../api'; import type { AssetMetadataState, StellarAssetMetadata } from './api'; import { AssetMetadataRepository } from './AssetMetadataRepository'; @@ -46,11 +46,13 @@ function createMockStateManager( return undefined; }, setKey: jest.fn(async () => Promise.resolve()), + setKeyWith: jest.fn(async () => Promise.resolve()), update: async (updater) => { state = updater(cloneDeep(state)); return cloneDeep(state); }, deleteKey: jest.fn(async () => Promise.resolve()), + deleteKeys: jest.fn(async () => Promise.resolve()), }; } diff --git a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts index 023d48ae8..0c0b9ec31 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/AssetMetadataRepository.ts @@ -1,3 +1,4 @@ +import type { IStateManager } from '@metamask/snap-networks-utils'; import { cloneDeep } from 'lodash'; import type { @@ -5,7 +6,6 @@ import type { KnownCaip19AssetId, KnownCaip2ChainId, } from '../../api'; -import type { IStateManager } from '../state/IStateManager'; import type { AssetMetadataByAssetId, AssetMetadataState, diff --git a/packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts b/packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts index 9fcef6a6e..55a1ed079 100644 --- a/packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts +++ b/packages/stellar-wallet-snap/src/services/asset-metadata/__mocks__/assets.fixtures.ts @@ -1,3 +1,5 @@ +import { InMemoryState } from '@metamask/snap-networks-utils'; + import type { KnownCaip19AssetIdOrSlip44Id } from '../../../api'; import { AssetType, KnownCaip2ChainId } from '../../../api'; import { NATIVE_ASSET_NAME, NATIVE_ASSET_SYMBOL } from '../../../constants'; @@ -5,7 +7,6 @@ import { getSlip44AssetId } from '../../../utils/caip'; import { logger, noOpLogger } from '../../../utils/logger'; import { InMemoryCache } from '../../cache'; import { NetworkService } from '../../network'; -import { State } from '../../state'; import type { AssetMetadataByAssetId, StellarAssetMetadata } from '../api'; import { AssetMetadataRepository } from '../AssetMetadataRepository'; import { AssetMetadataService } from '../AssetMetadataService'; @@ -79,9 +80,8 @@ export const createMockAssetMetadataService = () => { cache: new InMemoryCache(noOpLogger), }), assetMetadataRepository: new AssetMetadataRepository( - new State({ - encrypted: false, - defaultState: { assets: generateMockStellarAssetMetadata() }, + new InMemoryState({ + assets: generateMockStellarAssetMetadata(), }), ), logger, diff --git a/packages/stellar-wallet-snap/src/services/cache/InMemoryState.ts b/packages/stellar-wallet-snap/src/services/cache/InMemoryState.ts deleted file mode 100644 index 02513d0f4..000000000 --- a/packages/stellar-wallet-snap/src/services/cache/InMemoryState.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; -import { get, set, unset } from 'lodash'; - -import type { IStateManager } from '../state/IStateManager'; - -/** - * A simple implementation of the `IStateManager` interface that relies on an in memory state that can be used for testing purposes. - */ -export class InMemoryState< - TStateValue extends Record, -> implements IStateManager { - #state: TStateValue; - - constructor(initialState: TStateValue) { - this.#state = initialState; - } - - async get(): Promise { - return this.#state; - } - - async getKey( - key: string, - ): Promise { - const value = get(this.#state, key); - - return value as TResponse | undefined; - } - - async setKey(key: string, value: Serializable): Promise { - set(this.#state, key, value); // Use lodash to set the value using a json path - } - - async update( - callback: (state: TStateValue) => TStateValue, - ): Promise { - this.#state = callback(this.#state); - - return this.#state; - } - - async deleteKey(key: string): Promise { - // Using lodash's unset to leverage the json path capabilities - unset(this.#state, key); - } -} diff --git a/packages/stellar-wallet-snap/src/services/cache/StateCache.test.ts b/packages/stellar-wallet-snap/src/services/cache/StateCache.test.ts index 5743cd7fd..eb55e810b 100644 --- a/packages/stellar-wallet-snap/src/services/cache/StateCache.test.ts +++ b/packages/stellar-wallet-snap/src/services/cache/StateCache.test.ts @@ -1,8 +1,9 @@ /* eslint-disable jest/prefer-strict-equal */ +import { InMemoryState } from '@metamask/snap-networks-utils'; +import type { IStateManager } from '@metamask/snap-networks-utils'; + import { logger } from '../../utils/logger'; -import type { IStateManager } from '../state'; -import { InMemoryState } from './InMemoryState'; import type { StateValue } from './StateCache'; import { StateCache } from './StateCache'; diff --git a/packages/stellar-wallet-snap/src/services/cache/StateCache.ts b/packages/stellar-wallet-snap/src/services/cache/StateCache.ts index 7858ac5f1..c9c9bcc4e 100644 --- a/packages/stellar-wallet-snap/src/services/cache/StateCache.ts +++ b/packages/stellar-wallet-snap/src/services/cache/StateCache.ts @@ -1,7 +1,10 @@ -import type { Logger, Serializable } from '@metamask/snap-networks-utils'; +import type { + IStateManager, + Logger, + Serializable, +} from '@metamask/snap-networks-utils'; import { assert } from '@metamask/utils'; -import type { IStateManager } from '../state/IStateManager'; import type { ICache, CacheEntry } from './api'; /** diff --git a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts index d5e76b7d4..3ff8108ae 100644 --- a/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts +++ b/packages/stellar-wallet-snap/src/services/on-chain-account/OnChainAccountRepository.ts @@ -1,7 +1,7 @@ +import type { IStateManager } from '@metamask/snap-networks-utils'; import { cloneDeep } from 'lodash'; import type { KnownCaip2ChainId } from '../../api'; -import type { IStateManager } from '../state/IStateManager'; import type { OnChainAccountSnapshotsByKeyringId, OnChainAccountState, 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 585b7c36d..cee06ed3b 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 @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import { InMemoryState } from '@metamask/snap-networks-utils'; import type { Horizon } from '@stellar/stellar-sdk'; import { Account } from '@stellar/stellar-sdk'; @@ -8,7 +9,6 @@ import { AccountService } from '../../account/AccountService'; import { AccountsRepository } from '../../account/AccountsRepository'; import { InMemoryCache } from '../../cache'; import { NetworkService } from '../../network'; -import { State } from '../../state/State'; import { WalletService } from '../../wallet'; import { OnChainAccount } from '../OnChainAccount'; import { OnChainAccountRepository } from '../OnChainAccountRepository'; @@ -158,12 +158,9 @@ export const createMockAccountWithBalances = ( */ export function mockOnChainAccountService() { const walletService = new WalletService(); - const state = new State({ - encrypted: false, - defaultState: { - keyringAccounts: {}, - onChainAccounts: {}, - }, + const state = new InMemoryState({ + keyringAccounts: {}, + onChainAccounts: {}, }); const accountService = new AccountService({ logger, diff --git a/packages/stellar-wallet-snap/src/services/state/IStateManager.ts b/packages/stellar-wallet-snap/src/services/state/IStateManager.ts deleted file mode 100644 index aff8a1b42..000000000 --- a/packages/stellar-wallet-snap/src/services/state/IStateManager.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; - -export type IStateManager> = { - /** - * Gets the whole state object. - * - * ⚠️ WARNING: Use with caution because it transfers the whole state, which might contain a lot of data. - * If you need to retrieve only a specific part of the state, use IStateManager.getKey instead. - * - * @example - * ```typescript - * // state is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ], countries: ['Spain', 'France'] } - * - * const value = await stateManager.get(); - * // value is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ], countries: ['Spain', 'France'] } - * ``` - */ - get(): Promise; - /** - * Gets the value of the passed key in the state object. - * The key is the JSON path to the value to get. - * - * @example - * ```typescript - * // state is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ], countries: ['Spain', 'France'] } - * - * const value = await stateManager.getKey('users.1.name'); - * // value is 'Bob' - * - * @returns The value of the key, or undefined if the key does not exist. - */ - getKey( - key: string, - ): Promise; - /** - * Sets the value of the passed key in the state object. - * The key is a JSON path to the value to set. - * - * @example - * ```typescript - * const state = await stateManager.get(); - * // state is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ] } - * - * await stateManager.setKey('users.1.name', 'John'); - * // state is now { users: [ { name: 'Alice', age: 20 }, { name: 'John', age: 25 } ] } - * ``` - * @param key - The key to set, which is a JSON path to the location. - * @param value - The value to set. - */ - setKey(key: string, value: Serializable): Promise; - /** - * Updates the whole state object. - * - * Typically used for bulk `set`s or `delete`s, because: - * - Atomicity: Using a single `state.update` ensures that all changes are applied atomically. If any part of the operation fails, none of the changes will be applied. This prevents partial updates that could leave the underlying data store in an inconsistent state. - * - Performance: Making multiple individual `state.setKey` or `state.deleteKey` calls would require multiple round trips to the state storage system, causing potential overhead. - * - State Consistency: Maintains better state consistency by reading the state once, making all modifications in memory and writing the complete updated state back. - * - * ⚠️ WARNING: Use with caution because: - * - it will override the whole state. - * - it transfers the whole state back and forth to the data store, which might consume a lot of bandwidth. - * - * For single updates, use instead `setKey` or `deleteKey`. - * - * @param updaterFunction - The function that updates the state. - * @returns The updated state. - */ - update( - updaterFunction: (state: TStateValue) => TStateValue, - ): Promise; - /** - * Deletes the value of the passed key in the state object. - * The key is a JSON path to the value to delete. - * - * @example - * ```typescript - * const state = await stateManager.get(); - * // state is { users: [ { name: 'Alice', age: 20 }, { name: 'Bob', age: 25 } ] } - * - * await stateManager.deleteKey('users.1'); - * // state is now { users: [ { name: 'Alice', age: 20 } ] } - * ``` - */ - deleteKey(key: string): Promise; -}; diff --git a/packages/stellar-wallet-snap/src/services/state/State.test.ts b/packages/stellar-wallet-snap/src/services/state/State.test.ts deleted file mode 100644 index e67eebe66..000000000 --- a/packages/stellar-wallet-snap/src/services/state/State.test.ts +++ /dev/null @@ -1,408 +0,0 @@ -/* eslint-disable jest/prefer-strict-equal */ - -import { BigNumber } from 'bignumber.js'; -import { cloneDeep } from 'lodash'; - -import { getSnapProvider } from '../../utils/snap'; -import { State } from './State'; - -jest.mock('../../utils/snap'); - -type User = { - name: string; - age: BigNumber | bigint | number | undefined | null; -}; - -type MockStateValue = { - users: User[]; -}; - -const DEFAULT_STATE: MockStateValue = { - users: [ - { - name: 'John', - age: 30, - }, - { - name: 'Jane', - age: 25, - }, - ], -}; - -describe('State', () => { - let state: State; - - const snapProvider = getSnapProvider() as { request: jest.Mock }; - - beforeEach(() => { - jest.clearAllMocks(); - state = new State({ - encrypted: false, - // Clone the default state to avoid mutating the original object - defaultState: cloneDeep(DEFAULT_STATE), - }); - }); - - afterEach(() => { - snapProvider.request.mockReset(); - }); - - describe('get', () => { - it('gets the state', async () => { - const mockUnderlyingState = DEFAULT_STATE; - snapProvider.request.mockResolvedValue(mockUnderlyingState); - - const stateValue = await state.get(); - - expect(snapProvider.request).toHaveBeenCalledWith({ - method: 'snap_getState', - params: { encrypted: false }, - }); - expect(stateValue).toStrictEqual(mockUnderlyingState); - }); - - it('gets the default state if the Snap state is empty', async () => { - const mockUnderlyingState = {}; - snapProvider.request.mockResolvedValue(mockUnderlyingState); - - const stateValue = await state.get(); - - expect(stateValue).toStrictEqual(DEFAULT_STATE); - }); - - describe('when getting serialized non-JSON values', () => { - it('deserializes undefined values', async () => { - const mockUnderlyingState = { - users: [ - { - name: 'JohnStanley', - age: { - __type: 'undefined', - }, - }, - ], - }; - snapProvider.request.mockResolvedValue(mockUnderlyingState); - - const stateValue = await state.get(); - - expect(stateValue).toEqual({ - users: [ - { - name: 'JohnStanley', - age: undefined, - }, - ], - }); - }); - - it('deserializes BigNumber values', async () => { - const mockUnderlyingState = { - users: [ - { - name: 'John', - age: { - __type: 'BigNumber', - value: '30', - }, - }, - ], - }; - snapProvider.request.mockResolvedValue(mockUnderlyingState); - - const stateValue = await state.get(); - - expect(stateValue).toStrictEqual({ - users: [ - { - name: 'John', - age: new BigNumber(30), - }, - ], - }); - }); - - it('deserializes bigint values', async () => { - const mockUnderlyingState = { - users: [ - { - name: 'John', - age: { - __type: 'bigint', - value: '30', - }, - }, - ], - }; - snapProvider.request.mockResolvedValue(mockUnderlyingState); - - const stateValue = await state.get(); - - expect(stateValue).toStrictEqual({ - users: [ - { - name: 'John', - age: BigInt(30), - }, - ], - }); - }); - }); - }); - - describe('getKey', () => { - it('calls the snap_getState method with the correct parameters', async () => { - const mockUnderlyingState = DEFAULT_STATE; - snapProvider.request.mockResolvedValue(mockUnderlyingState); - - await state.getKey('users.1.name'); - - expect(snapProvider.request).toHaveBeenCalledWith({ - method: 'snap_getState', - params: { key: 'users.1.name', encrypted: false }, - }); - }); - - it('returns undefined if the key does not exist', async () => { - snapProvider.request.mockResolvedValue(undefined); - - const value = await state.getKey('users.1.name'); - - expect(value).toBeUndefined(); - }); - }); - - describe('setKey', () => { - it('sets the value of a key', async () => { - await state.setKey('users.1.name', 'Bob'); - - expect(snapProvider.request).toHaveBeenCalledWith({ - method: 'snap_setState', - params: { - key: 'users.1.name', - value: 'Bob', - encrypted: false, - }, - }); - }); - }); - - describe('update', () => { - it('updates the state', async () => { - await state.update((currentState) => ({ - users: [ - ...currentState.users, - { - name: 'Bob', - age: 50, - }, - ], - })); - - expect(snapProvider.request).toHaveBeenCalledWith({ - method: 'snap_getState', - params: { encrypted: false }, - }); - - expect(snapProvider.request).toHaveBeenCalledWith({ - method: 'snap_manageState', - params: { - operation: 'update', - encrypted: false, - newState: { - users: [ - ...DEFAULT_STATE.users, - { - name: 'Bob', - age: 50, - }, - ], - }, - }, - }); - }); - - describe('when updating serialized non-JSON values', () => { - it('serializes undefined values', async () => { - await state.update((currentState) => ({ - users: [ - ...currentState.users, - { - name: 'Bob', - age: undefined, - }, - ], - })); - - expect(snapProvider.request).toHaveBeenNthCalledWith(2, { - method: 'snap_manageState', - params: { - operation: 'update', - encrypted: false, - newState: { - users: [ - ...DEFAULT_STATE.users, - { - name: 'Bob', - age: { - __type: 'undefined', - }, - }, - ], - }, - }, - }); - }); - - it('serializes BigNumber values', async () => { - await state.update((currentState) => ({ - users: [ - ...currentState.users, - { - name: 'Bob', - age: new BigNumber(50), - }, - ], - })); - - expect(snapProvider.request).toHaveBeenNthCalledWith(2, { - method: 'snap_manageState', - params: { - operation: 'update', - encrypted: false, - newState: { - users: [ - ...DEFAULT_STATE.users, - { - name: 'Bob', - age: { - __type: 'BigNumber', - value: '50', - }, - }, - ], - }, - }, - }); - }); - - it('serializes bigint values', async () => { - await state.update((currentState) => ({ - users: [ - ...currentState.users, - { - name: 'Bob', - age: BigInt(50), - }, - ], - })); - - expect(snapProvider.request).toHaveBeenNthCalledWith(2, { - method: 'snap_manageState', - params: { - operation: 'update', - encrypted: false, - newState: { - users: [ - ...DEFAULT_STATE.users, - { - name: 'Bob', - age: { - __type: 'bigint', - value: '50', - }, - }, - ], - }, - }, - }); - }); - - it('serializes null values', async () => { - await state.update((currentState) => ({ - users: [...currentState.users, { name: 'Bob', age: null }], - })); - - expect(snapProvider.request).toHaveBeenNthCalledWith(2, { - method: 'snap_manageState', - params: { - operation: 'update', - encrypted: false, - newState: { - users: [...DEFAULT_STATE.users, { name: 'Bob', age: null }], - }, - }, - }); - }); - }); - }); - - describe('deleteKey', () => { - it('deletes a key', async () => { - await state.deleteKey('users'); - - expect(snapProvider.request).toHaveBeenCalledWith({ - method: 'snap_manageState', - params: { - operation: 'update', - newState: {}, - encrypted: false, - }, - }); - }); - - it('deletes a nested key', async () => { - await state.deleteKey('users[0].age'); - - expect(snapProvider.request).toHaveBeenCalledWith({ - method: 'snap_manageState', - params: { - operation: 'update', - newState: { - users: [ - { - name: 'John', - }, - { - name: 'Jane', - age: 25, - }, - ], - }, - encrypted: false, - }, - }); - }); - }); - - describe('deleteKeys', () => { - it('deletes multiple keys', async () => { - // remove the name key from the first and second user - await state.deleteKeys(['users.0.name', 'users.1.name']); - - expect(snapProvider.request).toHaveBeenNthCalledWith(1, { - method: 'snap_getState', - params: { encrypted: false }, - }); - - expect(snapProvider.request).toHaveBeenNthCalledWith(2, { - method: 'snap_manageState', - params: { - operation: 'update', - encrypted: false, - newState: { - users: [ - { - age: 30, - }, - { - age: 25, - }, - ], - }, - }, - }); - }); - }); -}); diff --git a/packages/stellar-wallet-snap/src/services/state/State.ts b/packages/stellar-wallet-snap/src/services/state/State.ts deleted file mode 100644 index a0e9ef1ac..000000000 --- a/packages/stellar-wallet-snap/src/services/state/State.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { safeMerge } from '@metamask/snap-networks-utils'; -import type { Serializable } from '@metamask/snap-networks-utils'; -import type { MutexInterface } from 'async-mutex'; -import { Mutex } from 'async-mutex'; -import { unset } from 'lodash'; - -import { getState, setState, updateState } from '../../utils'; -import type { IStateManager } from './IStateManager'; - -export type StateConfig> = { - encrypted: boolean; - defaultState: TValue; -}; - -/** - * Because we use both snap_manageState and snap_setState, we must protect against them being used at the same time. - * We must also protect against multiple parallel requests to snap_manageState. - * snap_setState, snap_getState, etc. - */ -class StateLock { - readonly #blobModificationMutex = new Mutex(); - - readonly #regularStateUpdateMutex = new Mutex(); - - #pendingRegularStateUpdates = 0; - - #releaseRegularStateUpdateMutex: MutexInterface.Releaser | null = null; - - async #acquireRegularStateUpdateMutex(): Promise { - if (!this.#regularStateUpdateMutex.isLocked()) { - this.#releaseRegularStateUpdateMutex = - await this.#regularStateUpdateMutex.acquire(); - } - } - - /** - * Wraps a regular state operation in a mutex to protect against concurrent access. - * This is used for operations that read or modify parts of the state (e.g. snap_getState, snap_setState). - * - * @param callback - The callback to wrap. - * @returns The result of the callback. - */ - async wrapRegularStateOperation( - callback: MutexInterface.Worker, - ): Promise { - // If we are currently doing a full blob update, wait it out. - // Signal that regular state operations are ongoing by acquiring the mutex. - // Other regular state operations can skip this, as they are safe to do in parallel. - await Promise.all([ - this.#blobModificationMutex.waitForUnlock(), - this.#acquireRegularStateUpdateMutex(), - ]); - - try { - this.#pendingRegularStateUpdates += 1; - return await callback(); - } finally { - this.#pendingRegularStateUpdates -= 1; - - if ( - this.#pendingRegularStateUpdates === 0 && - this.#releaseRegularStateUpdateMutex - ) { - this.#releaseRegularStateUpdateMutex(); - } - } - } - - /** - * Wraps a manage-state (full blob) operation in a mutex to protect against concurrent access. - * This is used for operations that modify the entire state blob, such as snap_manageState. - * - * @param callback - The callback to wrap. - * @returns The result of the callback. - */ - async wrapManageStateOperation( - callback: MutexInterface.Worker, - ): Promise { - return await this.#blobModificationMutex.runExclusive(async () => { - await this.#regularStateUpdateMutex.waitForUnlock(); - return await callback(); - }); - } -} - -/** - * This class is a layer on top of the `snap_manageState` API that facilitates its usage: - * - * Basic usage: - * - Get and update the state of the snap - * - * Serialization: - * - It serializes the data before storing it in the Snap state because only JSON-assignable data can be stored. - * - It deserializes the data after retrieving it from the Snap state. - * - So you don't need to worry about the data format when storing or retrieving data. - * - * Default values: - * - It merges the default state with the underlying Snap state to ensure that we always have default values, - * letting us avoid a ton of null checks everywhere. - */ -export class State< - TStateValue extends Record, -> implements IStateManager { - readonly #lock = new StateLock(); - - readonly #config: StateConfig; - - constructor(config: StateConfig) { - this.#config = config; - } - - async #unsafeGet(): Promise { - const state = await getState({ - encrypted: this.#config.encrypted, - }); - - const stateDeserialized = (state as TStateValue) ?? {}; - - // Merge the default state with the underlying Snap state - // to ensure that we always have default values. It lets us avoid a ton of null checks everywhere. - const stateWithDefaults = safeMerge( - this.#config.defaultState, - stateDeserialized, - ); - - return stateWithDefaults; - } - - async get(): Promise { - return this.#lock.wrapRegularStateOperation(async () => this.#unsafeGet()); - } - - async getKey( - key: string, - ): Promise { - return this.#lock.wrapRegularStateOperation(async () => { - const state = await getState({ - key, - encrypted: this.#config.encrypted, - }); - - return state as TResponse; - }); - } - - async setKey(key: string, value: Serializable): Promise { - await this.#lock.wrapRegularStateOperation(async () => { - await setState({ - key, - newState: value, - encrypted: this.#config.encrypted, - }); - }); - } - - async update( - updaterFunction: (state: TStateValue) => TStateValue, - ): Promise { - // Because this function modifies the entire state blob, - // we must protect against parallel requests. - return await this.#lock.wrapManageStateOperation(async () => { - const currentState = await this.#unsafeGet(); - - const newState = updaterFunction(currentState); - - // Generally we should try to use snap_getState and snap_setState instead of this, - // as snap_manageState is slower and error-prone due to requiring manual mutex management. - await updateState({ - newState, - encrypted: this.#config.encrypted, - }); - - return newState; - }); - } - - async deleteKey(key: string): Promise { - await this.update((state) => { - // Using lodash's unset to leverage the JSON path capabilities - unset(state, key); - return state; - }); - } - - async deleteKeys(keys: string[]): Promise { - await this.update((state) => { - keys.forEach((key) => { - unset(state, key); - }); - return state; - }); - } -} diff --git a/packages/stellar-wallet-snap/src/services/state/index.ts b/packages/stellar-wallet-snap/src/services/state/index.ts deleted file mode 100644 index 1ac5867ea..000000000 --- a/packages/stellar-wallet-snap/src/services/state/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './State'; -export type * from './IStateManager'; diff --git a/packages/stellar-wallet-snap/src/services/state/stateTypes.ts b/packages/stellar-wallet-snap/src/services/state/stateTypes.ts new file mode 100644 index 000000000..c2a5d2bce --- /dev/null +++ b/packages/stellar-wallet-snap/src/services/state/stateTypes.ts @@ -0,0 +1,17 @@ +import type { KeyringAccountState } from '../account/api'; +import type { AssetMetadataState } from '../asset-metadata/api'; +import type { OnChainAccountState } from '../on-chain-account/api'; +import type { TransactionStateValue } from '../transaction/TransactionRepository'; + +export type UnencryptedStateValue = KeyringAccountState & + AssetMetadataState & + TransactionStateValue & + OnChainAccountState; + +export const DEFAULT_UNENCRYPTED_STATE: UnencryptedStateValue = { + keyringAccounts: {}, + assets: {}, + transactions: {}, + lastScanTokens: {}, + onChainAccounts: {}, +}; diff --git a/packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.test.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.test.ts index 01827ae3e..42b22b2fb 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.test.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.test.ts @@ -1,16 +1,13 @@ import { TransactionStatus } from '@metamask/keyring-api'; +import { InMemoryState } from '@metamask/snap-networks-utils'; import { KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; -import { getSnapProvider } from '../../utils/snap'; -import { State } from '../state/State'; import { generateMockTransactions } from './__mocks__/transaction.fixtures'; import type { StellarKeyringTransaction } from './api'; import type { TransactionStateValue } from './TransactionRepository'; import { TransactionRepository } from './TransactionRepository'; -jest.mock('../../utils/snap'); - describe('TransactionRepository', () => { const scope = KnownCaip2ChainId.Mainnet; const accountId = 'account-1'; @@ -28,38 +25,8 @@ describe('TransactionRepository', () => { 1000, ); - let mockState: TransactionStateValue; - const createRepository = () => - new TransactionRepository( - new State({ - encrypted: false, - defaultState, - }), - ); - - beforeEach(() => { - mockState = structuredClone(defaultState); - const snapProvider = getSnapProvider() as { request: jest.Mock }; - snapProvider.request.mockImplementation(async ({ method, params }) => { - if (method === 'snap_getState') { - if (params.key) { - return mockState[params.key as keyof TransactionStateValue]; - } - return mockState; - } - - if (method === 'snap_manageState' && params.operation === 'update') { - mockState = params.newState as TransactionStateValue; - } - - return null; - }); - }); - - afterEach(() => { - (getSnapProvider() as { request: jest.Mock }).request.mockReset(); - }); + new TransactionRepository(new InMemoryState(structuredClone(defaultState))); it('removes confirmed incoming transactions from snap state', async () => { const repository = createRepository(); diff --git a/packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts index c18b0dd53..7af6869ad 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.ts @@ -1,10 +1,10 @@ import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; +import type { IStateManager } from '@metamask/snap-networks-utils'; import { groupBy } from 'lodash'; import sortBy from 'lodash/sortBy'; import uniqBy from 'lodash/uniqBy'; import type { KnownCaip2ChainId } from '../../api'; -import type { State } from '../state/State'; import type { StellarKeyringTransaction } from './api'; import { isPendingTransactionStatus, @@ -18,13 +18,13 @@ export type TransactionStateValue = { }; export class TransactionRepository { - readonly #state: State; + readonly #state: IStateManager; readonly #stateKey = 'transactions'; readonly #lastScanTokensKey = 'lastScanTokens'; - constructor(state: State) { + constructor(state: IStateManager) { this.#state = state; } diff --git a/packages/stellar-wallet-snap/src/services/transaction/TransactionSynchronizeService.test.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionSynchronizeService.test.ts index fa2f32a4f..290487c9b 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/TransactionSynchronizeService.test.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/TransactionSynchronizeService.test.ts @@ -5,6 +5,7 @@ import { TransactionType, } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import { InMemoryState } from '@metamask/snap-networks-utils'; import type { Horizon } from '@stellar/stellar-sdk'; import { Keypair, Networks } from '@stellar/stellar-sdk'; @@ -24,7 +25,6 @@ import { horizonSource, } from '../on-chain-account/__mocks__/onChainAccount.fixtures'; import { OnChainAccount } from '../on-chain-account/OnChainAccount'; -import { State } from '../state/State'; import type { ActivatedAccountPair } from '../sync/api'; import { sep41SendTransactionResponse } from './__mocks__/horizon-transaction-responses.fixtures'; import { @@ -109,12 +109,9 @@ describe('TransactionSynchronizeService', () => { const { cache } = createMemoryCache(); const networkService = new NetworkService({ logger, cache }); const transactionRepository = new TransactionRepository( - new State({ - encrypted: false, - defaultState: { - transactions: {}, - lastScanTokens: {}, - }, + new InMemoryState({ + transactions: {}, + lastScanTokens: {}, }), ); const transactionMapper = new TransactionMapper({ diff --git a/packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts b/packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts index c24348a7c..c65f1cfcf 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/__mocks__/transaction.fixtures.ts @@ -1,5 +1,6 @@ import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; import { TransactionStatus, TransactionType } from '@metamask/keyring-api'; +import { InMemoryState } from '@metamask/snap-networks-utils'; import type { AuthFlag, Horizon } from '@stellar/stellar-sdk'; import { Account, @@ -18,7 +19,6 @@ import { getSlip44AssetId, logger } from '../../../utils'; import { mockAccountService } from '../../account/__mocks__/account.fixtures'; import { createMemoryCache } from '../../cache/__mocks__/cache.fixtures'; import { NetworkService } from '../../network'; -import { State } from '../../state/State'; import { generateStellarAddress } from '../../wallet/__mocks__/wallet.fixtures'; import { Transaction } from '../Transaction'; import { TransactionBuilder } from '../TransactionBuilder'; @@ -33,12 +33,9 @@ export const createMockTransactionService = () => { const transactionService = new TransactionService({ logger, transactionRepository: new TransactionRepository( - new State({ - encrypted: false, - defaultState: { - transactions: {}, - lastScanTokens: {}, - }, + new InMemoryState({ + transactions: {}, + lastScanTokens: {}, }), ), networkService, diff --git a/packages/stellar-wallet-snap/src/utils/__mocks__/snap.ts b/packages/stellar-wallet-snap/src/utils/__mocks__/snap.ts index c4a9be6c2..af3f68e48 100644 --- a/packages/stellar-wallet-snap/src/utils/__mocks__/snap.ts +++ b/packages/stellar-wallet-snap/src/utils/__mocks__/snap.ts @@ -17,9 +17,6 @@ export const getDefaultEntropySource = jest.fn(); export const trackEvent = jest.fn(); export const { - getState, - setState, - updateState, createInterface, showDialog, getPreferences, diff --git a/packages/stellar-wallet-snap/src/utils/snap.ts b/packages/stellar-wallet-snap/src/utils/snap.ts index 8e1e3b012..d434c8614 100644 --- a/packages/stellar-wallet-snap/src/utils/snap.ts +++ b/packages/stellar-wallet-snap/src/utils/snap.ts @@ -1,7 +1,5 @@ import type { JsonSLIP10Node } from '@metamask/key-tree'; import type { EntropySourceId } from '@metamask/keyring-api'; -import type { Serializable } from '@metamask/snap-networks-utils'; -import { deserialize, serialize } from '@metamask/snap-networks-utils'; import type { ComponentOrElement, DialogResult, @@ -133,89 +131,6 @@ export async function getDefaultEntropySource(): Promise { return defaultEntropySource.id; } -/** - * Updates the state. - * - * @param params - The parameters for the state update. - * @param params.newState - The new state to set. - * @param params.encrypted - Whether the state is encrypted. - * @returns A Promise that resolves when the state is updated. - */ -export async function updateState({ - newState, - encrypted, -}: { - newState: Record; - encrypted: boolean; -}): Promise { - await getSnapProvider().request({ - method: 'snap_manageState', - params: { - operation: 'update', - newState: serialize(newState) as Record, - encrypted, - }, - }); -} - -/** - * Sets the state for the given key. - * - * @param params - The parameters for the state update. - * @param params.key - The key (path) to set. - * @param params.newState - The new state to set. - * @param params.encrypted - Whether the state is encrypted. - * @returns A Promise that resolves when the state is updated. - */ -export async function setState({ - key, - newState, - encrypted, -}: { - key: string; - newState: Serializable; - encrypted: boolean; -}): Promise { - await getSnapProvider().request({ - method: 'snap_setState', - params: { - key, - value: serialize(newState), - encrypted, - }, - }); -} - -/** - * Retrieves the state for the given key. - * - * @param params - The parameters for the state retrieval. - * @param params.key - (optional) The key to get the state for. If not provided, the whole state is returned. - * @param params.encrypted - Whether the state is encrypted. - * @returns The state for the given key. - */ -export async function getState({ - key, - encrypted, -}: { - key?: string; - encrypted: boolean; -}): Promise { - const state = await getSnapProvider().request({ - method: 'snap_getState', - params: { - ...(key ? { key } : {}), - encrypted, - }, - }); - - if (state === null || state === undefined) { - return undefined; - } - - return deserialize(state); -} - /** * Retrieves the client status (locked/unlocked) in this case from MM. * From 849f77c0dff81955e9f46b468140520104be2f82 Mon Sep 17 00:00:00 2001 From: Julien Fontanel Date: Wed, 9 Sep 2026 17:09:11 +0200 Subject: [PATCH 2/2] chore: add test for DEFAULT_UNENCRYPTED_STATE --- .../transaction/TransactionRepository.test.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.test.ts b/packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.test.ts index 42b22b2fb..ec4ebef5c 100644 --- a/packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.test.ts +++ b/packages/stellar-wallet-snap/src/services/transaction/TransactionRepository.test.ts @@ -3,20 +3,15 @@ import { InMemoryState } from '@metamask/snap-networks-utils'; import { KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; +import { DEFAULT_UNENCRYPTED_STATE } from '../state/stateTypes'; import { generateMockTransactions } from './__mocks__/transaction.fixtures'; import type { StellarKeyringTransaction } from './api'; -import type { TransactionStateValue } from './TransactionRepository'; import { TransactionRepository } from './TransactionRepository'; describe('TransactionRepository', () => { const scope = KnownCaip2ChainId.Mainnet; const accountId = 'account-1'; - const defaultState: TransactionStateValue = { - transactions: {}, - lastScanTokens: {}, - }; - const recentTimestampSeconds = () => Math.floor(Date.now() / 1000); const expiredTimestampSeconds = () => @@ -26,7 +21,9 @@ describe('TransactionRepository', () => { ); const createRepository = () => - new TransactionRepository(new InMemoryState(structuredClone(defaultState))); + new TransactionRepository( + new InMemoryState(structuredClone(DEFAULT_UNENCRYPTED_STATE)), + ); it('removes confirmed incoming transactions from snap state', async () => { const repository = createRepository();