diff --git a/eslint-suppressions.json b/eslint-suppressions.json index acd48b2b7..8026b9d22 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -443,30 +443,6 @@ "count": 1 } }, - "packages/solana-wallet-snap/src/core/services/state/IStateManager.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, - "packages/solana-wallet-snap/src/core/services/state/State.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, - "import-x/no-named-as-default": { - "count": 1 - }, - "n/no-sync": { - "count": 1 - } - }, - "packages/solana-wallet-snap/src/core/services/state/State.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 2 - }, - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "packages/solana-wallet-snap/src/core/services/subscriptions/KeyringAccountMonitor.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 1 diff --git a/packages/solana-wallet-snap/package.json b/packages/solana-wallet-snap/package.json index 236111688..abeff5d6e 100644 --- a/packages/solana-wallet-snap/package.json +++ b/packages/solana-wallet-snap/package.json @@ -73,7 +73,6 @@ "@types/lodash": "^4.17.15", "@types/react": "18.2.4", "@types/react-dom": "18.2.4", - "async-mutex": "^0.5.0", "bignumber.js": "^9.3.1", "bs58": "^6.0.0", "buffer": "^6.0.3", diff --git a/packages/solana-wallet-snap/snap.manifest.json b/packages/solana-wallet-snap/snap.manifest.json index b78f854a0..1200336a3 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": "Szf70/ghTVTeyeEcPUPnHGKxE+tWaZBEZJuR4Kh+gks=", + "shasum": "ZqSe1KRFMXRwvFxy8zShPbafw2c3AKz+HfBxJ2RwIxA=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshConfirmationEstimation.tsx b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshConfirmationEstimation.tsx index 474f941f8..7a1465e29 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshConfirmationEstimation.tsx +++ b/packages/solana-wallet-snap/src/core/handlers/onCronjob/backgroundEvents/refreshConfirmationEstimation.tsx @@ -9,7 +9,7 @@ import { transactionScanService, } from '../../../../snapContext'; import { METAMASK_ORIGIN } from '../../../constants/solana'; -import type { UnencryptedStateValue } from '../../../services/state/State'; +import type { UnencryptedStateValue } from '../../../services/state/stateTypes'; import { EXPIRED_TRANSACTION_SCAN } from '../../../services/transaction-scan/buildExpiredScanResult'; import { isTransactionBlockhashExpired } from '../../../services/transaction-scan/isTransactionBlockhashExpired'; import { trackError } from '../../../utils/errors'; 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 ef15003b0..d310ed679 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 @@ -2,7 +2,8 @@ import type { KeyringRequest } from '@metamask/keyring-api'; import { AccountCreationType, SolMethod } from '@metamask/keyring-api'; -import { Logger } from '@metamask/snap-networks-utils'; +import { InMemoryState, Logger } from '@metamask/snap-networks-utils'; +import type { IStateManager } from '@metamask/snap-networks-utils'; import { InvalidParamsError, SnapError } from '@metamask/snaps-sdk'; import type { CaipAssetType, JsonRpcRequest } from '@metamask/snaps-sdk'; import { signature } from '@solana/kit'; @@ -18,10 +19,8 @@ import type { TransactionsService, } from '../../services'; import type { ConfirmationHandler } from '../../services/confirmation/ConfirmationHandler'; -import { InMemoryState } from '../../services/state/InMemoryState'; -import type { IStateManager } from '../../services/state/IStateManager'; -import { DEFAULT_UNENCRYPTED_STATE } from '../../services/state/State'; -import type { UnencryptedStateValue } from '../../services/state/State'; +import { DEFAULT_UNENCRYPTED_STATE } from '../../services/state/stateTypes'; +import type { UnencryptedStateValue } from '../../services/state/stateTypes'; import { MOCK_SIGN_AND_SEND_TRANSACTION_REQUEST } from '../../services/wallet/mocks'; import type { WalletService } from '../../services/wallet/WalletService'; import { @@ -301,18 +300,26 @@ describe('SolanaKeyring', () => { describe('deleteAccount', () => { it('deletes an account', async () => { - const accountBeforeDeletion = await keyring.getAccount( - MOCK_SOLANA_KEYRING_ACCOUNT_1.id, - ); + const accountId = MOCK_SOLANA_KEYRING_ACCOUNT_1.id; + await mockState.setKey(`transactions.${accountId}`, []); + await mockState.setKey(`assetEntities.${accountId}`, [ + MOCK_ASSET_ENTITY_1, + ]); + + const accountBeforeDeletion = await keyring.getAccount(accountId); expect(accountBeforeDeletion).toBeDefined(); - await keyring.deleteAccount(MOCK_SOLANA_KEYRING_ACCOUNT_1.id); + await keyring.deleteAccount(accountId); - await expect( - keyring.getAccount(MOCK_SOLANA_KEYRING_ACCOUNT_1.id), - ).rejects.toThrow( - `Account "${MOCK_SOLANA_KEYRING_ACCOUNT_1.id}" not found`, + await expect(keyring.getAccount(accountId)).rejects.toThrow( + `Account "${accountId}" not found`, ); + expect( + await mockState.getKey(`transactions.${accountId}`), + ).toBeUndefined(); + expect( + await mockState.getKey(`assetEntities.${accountId}`), + ).toBeUndefined(); }); it('throws an error if account provided is not a uuid', async () => { 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 54255041a..8ad63f973 100644 --- a/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts +++ b/packages/solana-wallet-snap/src/core/handlers/onKeyringRequest/Keyring.ts @@ -24,8 +24,8 @@ import type { ExportedAccount, KeyringSnapRpc, } from '@metamask/keyring-api/v2'; -import type { Logger } from '@metamask/snap-networks-utils'; import { UuidStruct } from '@metamask/snap-networks-utils'; +import type { IStateManager, Logger } from '@metamask/snap-networks-utils'; import type { CaipAssetType, JsonRpcRequest } from '@metamask/snaps-sdk'; import { InvalidParamsError, @@ -51,8 +51,7 @@ import type { TransactionsService, } from '../../services'; import type { ConfirmationHandler } from '../../services/confirmation/ConfirmationHandler'; -import type { IStateManager } from '../../services/state/IStateManager'; -import type { UnencryptedStateValue } from '../../services/state/State'; +import type { UnencryptedStateValue } from '../../services/state/stateTypes'; import { SolanaWalletRequestStruct } from '../../services/wallet/structs'; import type { SolanaSignAndSendTransactionResponse, @@ -382,10 +381,10 @@ export class SolanaKeyring implements KeyringSnapRpc { } async #deleteAccountFromState(accountId: string): Promise { - await Promise.all([ - this.#state.deleteKey(`keyringAccounts.${accountId}`), - this.#state.deleteKey(`transactions.${accountId}`), - this.#state.deleteKey(`assets.${accountId}`), + await this.#state.deleteKeys([ + `keyringAccounts.${accountId}`, + `transactions.${accountId}`, + `assetEntities.${accountId}`, ]); } diff --git a/packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.ts b/packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.ts index c658e579e..c81d42630 100644 --- a/packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.ts +++ b/packages/solana-wallet-snap/src/core/services/accounts/AccountsRepository.ts @@ -1,6 +1,7 @@ +import type { IStateManager } from '@metamask/snap-networks-utils'; + import type { SolanaKeyringAccount } from '../../../entities'; -import type { IStateManager } from '../state/IStateManager'; -import type { UnencryptedStateValue } from '../state/State'; +import type { UnencryptedStateValue } from '../state/stateTypes'; export class AccountsRepository { readonly #state: IStateManager; diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts index 0c74ce76b..e4b3f5c63 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.test.ts @@ -1,3 +1,5 @@ +import { InMemoryState } from '@metamask/snap-networks-utils'; +import type { IStateManager } from '@metamask/snap-networks-utils'; import { cloneDeep } from 'lodash'; import { @@ -10,10 +12,8 @@ import { MOCK_SOLANA_KEYRING_ACCOUNT_0, MOCK_SOLANA_KEYRING_ACCOUNT_1, } from '../../test/mocks/solana-keyring-accounts'; -import { InMemoryState } from '../state/InMemoryState'; -import type { IStateManager } from '../state/IStateManager'; -import type { UnencryptedStateValue } from '../state/State'; -import { DEFAULT_UNENCRYPTED_STATE } from '../state/State'; +import { DEFAULT_UNENCRYPTED_STATE } from '../state/stateTypes'; +import type { UnencryptedStateValue } from '../state/stateTypes'; import { AssetsRepository } from './AssetsRepository'; describe('AssetsRepository', () => { diff --git a/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts b/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts index 3ed632cc5..8e30ede9c 100644 --- a/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts +++ b/packages/solana-wallet-snap/src/core/services/assets/AssetsRepository.ts @@ -1,8 +1,8 @@ +import type { IStateManager } from '@metamask/snap-networks-utils'; import { cloneDeep } from 'lodash'; import type { AssetEntity } from '../../../entities'; -import type { IStateManager } from '../state/IStateManager'; -import type { UnencryptedStateValue } from '../state/State'; +import type { UnencryptedStateValue } from '../state/stateTypes'; export class AssetsRepository { readonly #state: IStateManager; diff --git a/packages/solana-wallet-snap/src/core/services/state/IStateManager.ts b/packages/solana-wallet-snap/src/core/services/state/IStateManager.ts deleted file mode 100644 index 22367d8c8..000000000 --- a/packages/solana-wallet-snap/src/core/services/state/IStateManager.ts +++ /dev/null @@ -1,122 +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 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 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.set('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: any): Promise; - /** - * Atomically reads the current value at `key`, applies `updater`, and writes the result back. - * Both the read and the write are protected by the same exclusive lock so no concurrent - * operation can interleave between them. - * - * Prefer this over a manual `getKey` + `setKey` sequence whenever the new value depends on - * the current one (e.g. merging objects, appending to arrays). - * - * @example - * ```typescript - * // state is { scores: { alice: 10 } } - * - * await stateManager.setKeyWith('scores', (current) => ({ ...current, bob: 20 })); - * // state is now { scores: { alice: 10, bob: 20 } } - * ``` - * @param key - The json-path key to update. - * @param updater - Receives the current value (or `undefined` when the key is absent) and - * returns the new value to store. - */ - setKeyWith( - key: string, - updater: (currentValue: TValue | undefined) => TValue, - ): 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.set` or `state.delete` calls would require multiple round trips to the state storage system, causing potential overheads. - * - 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 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 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; - /** - * Deletes multiple keys in the state object in a single operation. - * The keys are 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.deleteKeys(['users.0.age', 'users.1.name']); - * // state is now { users: [ { name: 'Alice' }, { age: 25 } ] } - * ``` - */ - deleteKeys(keys: string[]): Promise; -}; diff --git a/packages/solana-wallet-snap/src/core/services/state/InMemoryState.ts b/packages/solana-wallet-snap/src/core/services/state/InMemoryState.ts deleted file mode 100644 index 597997809..000000000 --- a/packages/solana-wallet-snap/src/core/services/state/InMemoryState.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Serializable } from '@metamask/snap-networks-utils'; -import { get, set, unset } from 'lodash'; - -import type { IStateManager } from './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 setKeyWith( - key: string, - updater: (currentValue: TValue | undefined) => TValue, - ): Promise { - const oldValue = get(this.#state, key) as TValue | undefined; - const newValue = updater(oldValue); - - set(this.#state, key, newValue); - } - - 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); - } - - async deleteKeys(keys: string[]): Promise { - keys.forEach((key) => { - unset(this.#state, key); - }); - } -} diff --git a/packages/solana-wallet-snap/src/core/services/state/State.test.ts b/packages/solana-wallet-snap/src/core/services/state/State.test.ts deleted file mode 100644 index 5785ac4a0..000000000 --- a/packages/solana-wallet-snap/src/core/services/state/State.test.ts +++ /dev/null @@ -1,442 +0,0 @@ -/* eslint-disable jest/prefer-strict-equal */ - -import BigNumber from 'bignumber.js'; - -import { EventEmitter } from '../../../infrastructure/event-emitter/EventEmitter'; -import { mockLogger } from '../__mocks__/logger'; -import { State } from './State'; - -const snap = { - request: jest.fn(), -}; - -(globalThis as any).snap = 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; - let eventEmitter: EventEmitter; - - beforeEach(() => { - eventEmitter = new EventEmitter(mockLogger); - - state = new State(eventEmitter, { - encrypted: false, - defaultState: DEFAULT_STATE, - }); - - jest.clearAllMocks(); - }); - - afterEach(() => { - snap.request.mockReset(); - }); - - describe('constructor', () => { - it('runs migrateState on onStart/onUpdate/onInstall events', async () => { - const spy = jest.spyOn(state, 'update'); - - await eventEmitter.emitSync('onStart'); - - expect(spy).toHaveBeenCalled(); - }); - }); - - describe('get', () => { - it('gets the state', async () => { - const mockUnderlyingState = DEFAULT_STATE; - snap.request.mockResolvedValue(mockUnderlyingState); - - const stateValue = await state.get(); - - expect(snap.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 = {}; - snap.request.mockResolvedValue(mockUnderlyingState); - - const stateValue = await state.get(); - - expect(stateValue).toStrictEqual(DEFAULT_STATE); - }); - - it('preserves defaults when persisted state values are undefined', async () => { - snap.request.mockResolvedValue({ users: undefined }); - - expect(await state.get()).toStrictEqual(DEFAULT_STATE); - }); - - describe('when getting serialized non-JSON values', () => { - it('deserializes undefined values', async () => { - const mockUnderlyingState = { - users: [ - { - name: 'John', - age: { - __type: 'undefined', - }, - }, - ], - }; - snap.request.mockResolvedValue(mockUnderlyingState); - - const stateValue = await state.get(); - - expect(stateValue).toEqual({ - users: [ - { - name: 'John', - age: undefined, - }, - ], - }); - }); - - it('deserializes BigNumber values', async () => { - const mockUnderlyingState = { - users: [ - { - name: 'John', - age: { - __type: 'BigNumber', - value: '30', - }, - }, - ], - }; - snap.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', - }, - }, - ], - }; - snap.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; - snap.request.mockResolvedValue(mockUnderlyingState); - - await state.getKey('users.1.name'); - - expect(snap.request).toHaveBeenCalledWith({ - method: 'snap_getState', - params: { key: 'users.1.name', encrypted: false }, - }); - }); - - it('returns undefined if the key does not exist', async () => { - snap.request.mockResolvedValue(null); - - 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(snap.request).toHaveBeenCalledWith({ - method: 'snap_setState', - params: { - key: 'users.1.name', - value: 'Bob', - encrypted: false, - }, - }); - }); - }); - - describe('setKeyWith', () => { - it('reads the current value, applies the updater, and writes the result', async () => { - snap.request.mockResolvedValueOnce({ alice: 10 }); // getState (read) - - await state.setKeyWith>('scores', (current) => ({ - ...current, - bob: 20, - })); - - expect(snap.request).toHaveBeenNthCalledWith(1, { - method: 'snap_getState', - params: { key: 'scores', encrypted: false }, - }); - expect(snap.request).toHaveBeenNthCalledWith(2, { - method: 'snap_setState', - params: { - key: 'scores', - value: { alice: 10, bob: 20 }, - encrypted: false, - }, - }); - }); - - it('passes undefined to the updater when the key does not exist', async () => { - snap.request.mockResolvedValueOnce(null); // getState returns null → key absent - - const updater = jest.fn().mockReturnValue({ bob: 20 }); - - await state.setKeyWith('scores', updater); - - expect(updater).toHaveBeenCalledWith(undefined); - }); - }); - - describe('update', () => { - it('updates the state', async () => { - await state.update((currentState) => ({ - users: [ - ...currentState.users, - { - name: 'Bob', - age: 50, - }, - ], - })); - - expect(snap.request).toHaveBeenCalledWith({ - method: 'snap_getState', - params: { encrypted: false }, - }); - - expect(snap.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(snap.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(snap.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(snap.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(snap.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(snap.request).toHaveBeenCalledWith({ - method: 'snap_setState', - params: { - key: 'users', - value: { - __type: 'undefined', - }, - encrypted: false, - }, - }); - }); - - it('deletes a nested key', async () => { - await state.deleteKey('users[0].age'); - - expect(snap.request).toHaveBeenCalledWith({ - method: 'snap_setState', - params: { - key: 'users[0].age', - value: { - __type: 'undefined', - }, - encrypted: false, - }, - }); - }); - }); - - describe('deleteKeys', () => { - it('deletes multiple keys', async () => { - await state.deleteKeys(['users.0.age', 'users.1.name']); - - expect(snap.request).toHaveBeenCalledWith({ - method: 'snap_manageState', - params: { - operation: 'update', - newState: { users: [{ name: 'John' }, { age: 25 }] }, - encrypted: false, - }, - }); - }); - }); -}); diff --git a/packages/solana-wallet-snap/src/core/services/state/State.ts b/packages/solana-wallet-snap/src/core/services/state/State.ts deleted file mode 100644 index 18d6ab881..000000000 --- a/packages/solana-wallet-snap/src/core/services/state/State.ts +++ /dev/null @@ -1,273 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ - -import type { Transaction } from '@metamask/keyring-api'; -import { - deserialize, - safeMerge, - serialize, -} from '@metamask/snap-networks-utils'; -import type { Serializable } from '@metamask/snap-networks-utils'; -import type { Json } from '@metamask/snaps-sdk'; -import type { Address, Signature } from '@solana/kit'; -import type { MutexInterface } from 'async-mutex'; -import { Mutex } from 'async-mutex'; -import { omit, unset } from 'lodash'; - -import type { - AssetEntity, - SolanaKeyringAccount, - Subscription, -} from '../../../entities'; -import type { EventEmitter } from '../../../infrastructure'; -import type { IStateManager } from './IStateManager'; - -export type AccountId = string; - -export type UnencryptedStateValue = { - keyringAccounts: Record; - mapInterfaceNameToId: Record; - transactions: Record; - // we need to store the exhaustive list of signatures (including spam) - // to keep track of the transactions per account. The field transactions above only stores non-spam transactions, which break the refreshAccounts cronjob logic. - signatures: Record; - assetEntities: Record; - subscriptions: Record; - webSocketConnections: { - closeWebSocketConnectionsBackgroundEventId: string | null; - }; -}; - -export const DEFAULT_UNENCRYPTED_STATE: UnencryptedStateValue = { - keyringAccounts: {}, - mapInterfaceNameToId: {}, - transactions: {}, - signatures: {}, - assetEntities: {}, - subscriptions: {}, - webSocketConnections: { - closeWebSocketConnectionsBackgroundEventId: null, - }, -}; - -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 does not have this limitation and can be accessed safely as long as - * an ongoing manageState operation is not occurring. - */ -class StateLock { - readonly #blobModificationMutex = new Mutex(); - - readonly #regularStateUpdateMutex = new Mutex(); - - #pendingRegularStateUpdates = 0; - - #releaseRegularStateUpdateMutex: MutexInterface.Releaser | null = null; - - async #acquireRegularStateUpdateMutex() { - if (!this.#regularStateUpdateMutex.isLocked()) { - this.#releaseRegularStateUpdateMutex = - await this.#regularStateUpdateMutex.acquire(); - } - } - - 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 acquring 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(); - } - } - } - - async wrapManageStateOperation( - callback: MutexInterface.Worker, - ): Promise { - await this.#regularStateUpdateMutex.waitForUnlock(); - - return await this.#blobModificationMutex.runExclusive(callback); - } -} - -/** - * This class is a layer on top the the `snap_manageState` API that facilitates its usage: - * - * Basic usage: - * - Get and update the sate 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(eventEmitter: EventEmitter, config: StateConfig) { - this.#config = config; - - eventEmitter.on('onStart', this.#migrateState.bind(this)); - eventEmitter.on('onUpdate', this.#migrateState.bind(this)); - eventEmitter.on('onInstall', this.#migrateState.bind(this)); - } - - async #migrateState() { - await this.update((state) => { - return omit(state as any, ['assets']); - }); - } - - async #unsafeGet(): Promise { - const state = await snap.request({ - method: 'snap_getState', - params: { - encrypted: this.#config.encrypted, - }, - }); - - const stateDeserialized = deserialize(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 value = await snap.request({ - method: 'snap_getState', - params: { - key, - encrypted: this.#config.encrypted, - }, - }); - - if (value === null) { - return undefined; - } - - return deserialize(value) as TResponse; - }); - } - - async setKey(key: string, value: Serializable): Promise { - await this.#lock.wrapRegularStateOperation(async () => { - await snap.request({ - method: 'snap_setState', - params: { - key, - value: serialize(value), - encrypted: this.#config.encrypted, - }, - }); - }); - } - - async setKeyWith( - key: string, - updater: (currentValue: TValue | undefined) => TValue, - ): Promise { - await this.#lock.wrapManageStateOperation(async () => { - const rawValue = await snap.request({ - method: 'snap_getState', - params: { - key, - encrypted: this.#config.encrypted, - }, - }); - - const oldValue = - rawValue === null ? undefined : (deserialize(rawValue) as TValue); - const newValue = updater(oldValue); - - await snap.request({ - method: 'snap_setState', - params: { - key, - value: serialize(newValue), - 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 over this - // as snap_manageState is slower and error-prone due to requiring manual mutex management. - await snap.request({ - method: 'snap_manageState', - params: { - operation: 'update', - // State values are always objects, so the serialized result is too. - newState: serialize(newState) as Record, - encrypted: this.#config.encrypted, - }, - }); - - return newState; - }); - } - - async deleteKey(key: string): Promise { - return this.setKey(key, undefined); - } - - async deleteKeys(keys: string[]): Promise { - await this.update((state) => { - keys.forEach((key) => { - unset(state, key); - }); - return state; - }); - } -} diff --git a/packages/solana-wallet-snap/src/core/services/state/registerStateMigration.test.ts b/packages/solana-wallet-snap/src/core/services/state/registerStateMigration.test.ts new file mode 100644 index 000000000..f3c3b0a25 --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/state/registerStateMigration.test.ts @@ -0,0 +1,24 @@ +import type { IStateManager } from '@metamask/snap-networks-utils'; + +import { EventEmitter } from '../../../infrastructure/event-emitter/EventEmitter'; +import { mockLogger } from '../__mocks__/logger'; +import { registerStateMigration } from './registerStateMigration'; +import type { UnencryptedStateValue } from './stateTypes'; + +describe('registerStateMigration', () => { + it.each(['onStart', 'onUpdate', 'onInstall'])( + 'deletes the legacy assets state on %s', + async (event) => { + const eventEmitter = new EventEmitter(mockLogger); + const state = { + deleteKey: jest.fn().mockResolvedValue(undefined), + } as Pick, 'deleteKey'>; + + registerStateMigration(eventEmitter, state); + // eslint-disable-next-line n/no-sync + await eventEmitter.emitSync(event); + + expect(state.deleteKey).toHaveBeenCalledWith('assets'); + }, + ); +}); diff --git a/packages/solana-wallet-snap/src/core/services/state/registerStateMigration.ts b/packages/solana-wallet-snap/src/core/services/state/registerStateMigration.ts new file mode 100644 index 000000000..cd2869fab --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/state/registerStateMigration.ts @@ -0,0 +1,21 @@ +import type { IStateManager } from '@metamask/snap-networks-utils'; + +import type { EventEmitter } from '../../../infrastructure'; +import type { UnencryptedStateValue } from './stateTypes'; + +/** + * Registers the legacy state migration on Snap lifecycle events. + * + * @param eventEmitter - The Snap lifecycle event emitter. + * @param state - The Snap state manager. + */ +export const registerStateMigration = ( + eventEmitter: EventEmitter, + state: Pick, 'deleteKey'>, +): void => { + const migrateState = async (): Promise => state.deleteKey('assets'); + + eventEmitter.on('onStart', migrateState); + eventEmitter.on('onUpdate', migrateState); + eventEmitter.on('onInstall', migrateState); +}; diff --git a/packages/solana-wallet-snap/src/core/services/state/stateTypes.ts b/packages/solana-wallet-snap/src/core/services/state/stateTypes.ts new file mode 100644 index 000000000..3c202fc7b --- /dev/null +++ b/packages/solana-wallet-snap/src/core/services/state/stateTypes.ts @@ -0,0 +1,34 @@ +import type { Transaction } from '@metamask/keyring-api'; +import type { Address, Signature } from '@solana/kit'; + +import type { + AssetEntity, + SolanaKeyringAccount, + Subscription, +} from '../../../entities'; + +export type UnencryptedStateValue = { + keyringAccounts: Record; + mapInterfaceNameToId: Record; + transactions: Record; + // we need to store the exhaustive list of signatures (including spam) + // to keep track of the transactions per account. The field transactions above only stores non-spam transactions, which break the refreshAccounts cronjob logic. + signatures: Record; + assetEntities: Record; + subscriptions: Record; + webSocketConnections: { + closeWebSocketConnectionsBackgroundEventId: string | null; + }; +}; + +export const DEFAULT_UNENCRYPTED_STATE: UnencryptedStateValue = { + keyringAccounts: {}, + mapInterfaceNameToId: {}, + transactions: {}, + signatures: {}, + assetEntities: {}, + subscriptions: {}, + webSocketConnections: { + closeWebSocketConnectionsBackgroundEventId: null, + }, +}; diff --git a/packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionRepository.test.ts b/packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionRepository.test.ts index 536329738..9f222a14f 100644 --- a/packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionRepository.test.ts +++ b/packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionRepository.test.ts @@ -1,7 +1,8 @@ +import type { IStateManager } from '@metamask/snap-networks-utils'; + import type { Subscription } from '../../../entities'; import { Network } from '../../constants/solana'; -import type { IStateManager } from '../state/IStateManager'; -import type { UnencryptedStateValue } from '../state/State'; +import type { UnencryptedStateValue } from '../state/stateTypes'; import { SubscriptionRepository } from './SubscriptionRepository'; const createMockSubscription = (id: string): Subscription => ({ diff --git a/packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionRepository.ts b/packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionRepository.ts index d128faad4..66f3db9ee 100644 --- a/packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionRepository.ts +++ b/packages/solana-wallet-snap/src/core/services/subscriptions/SubscriptionRepository.ts @@ -1,10 +1,11 @@ +import type { IStateManager } from '@metamask/snap-networks-utils'; + import type { ConfirmedSubscription, PendingSubscription, Subscription, } from '../../../entities'; -import type { IStateManager } from '../state/IStateManager'; -import type { UnencryptedStateValue } from '../state/State'; +import type { UnencryptedStateValue } from '../state/stateTypes'; export class SubscriptionRepository { readonly #state: IStateManager; diff --git a/packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionService.test.ts b/packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionService.test.ts index af1846245..8303ed131 100644 --- a/packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionService.test.ts +++ b/packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionService.test.ts @@ -1,3 +1,6 @@ +import { InMemoryState } from '@metamask/snap-networks-utils'; +import type { IStateManager } from '@metamask/snap-networks-utils'; + import type { WebSocketConnection } from '../../../entities'; import { EventEmitter } from '../../../infrastructure'; import { Network } from '../../constants/solana'; @@ -6,10 +9,8 @@ import { mockLogger } from '../__mocks__/logger'; import type { AnalyticsService } from '../analytics/AnalyticsService'; import type { ConfigProvider } from '../config'; import type { NetworkConfig } from '../config/ConfigProvider'; -import { InMemoryState } from '../state/InMemoryState'; -import type { IStateManager } from '../state/IStateManager'; -import { DEFAULT_UNENCRYPTED_STATE } from '../state/State'; -import type { UnencryptedStateValue } from '../state/State'; +import { DEFAULT_UNENCRYPTED_STATE } from '../state/stateTypes'; +import type { UnencryptedStateValue } from '../state/stateTypes'; import type { WebSocketConnectionRepository } from './WebSocketConnectionRepository'; import { WebSocketConnectionService } from './WebSocketConnectionService'; diff --git a/packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionService.ts b/packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionService.ts index a71d1dea9..64ff74a83 100644 --- a/packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionService.ts +++ b/packages/solana-wallet-snap/src/core/services/subscriptions/WebSocketConnectionService.ts @@ -1,4 +1,4 @@ -import type { Logger } from '@metamask/snap-networks-utils'; +import type { IStateManager, Logger } from '@metamask/snap-networks-utils'; import type { WebSocketCloseEvent, WebSocketEvent, @@ -15,8 +15,7 @@ import { trackError } from '../../utils/errors'; import { getClientStatus } from '../../utils/interface'; import type { AnalyticsService } from '../analytics/AnalyticsService'; import type { ConfigProvider } from '../config'; -import type { IStateManager } from '../state/IStateManager'; -import type { UnencryptedStateValue } from '../state/State'; +import type { UnencryptedStateValue } from '../state/stateTypes'; import type { WebSocketConnectionRepository } from './WebSocketConnectionRepository'; /** diff --git a/packages/solana-wallet-snap/src/core/services/transactions/TransactionsRepository.test.ts b/packages/solana-wallet-snap/src/core/services/transactions/TransactionsRepository.test.ts index 272f1311d..04d361caf 100644 --- a/packages/solana-wallet-snap/src/core/services/transactions/TransactionsRepository.test.ts +++ b/packages/solana-wallet-snap/src/core/services/transactions/TransactionsRepository.test.ts @@ -1,15 +1,15 @@ import { TransactionStatus } from '@metamask/keyring-api'; import type { Transaction } from '@metamask/keyring-api'; +import { InMemoryState } from '@metamask/snap-networks-utils'; +import type { IStateManager } from '@metamask/snap-networks-utils'; import { cloneDeep } from 'lodash'; import { MOCK_SOLANA_KEYRING_ACCOUNT_0, MOCK_SOLANA_KEYRING_ACCOUNT_1, } from '../../test/mocks/solana-keyring-accounts'; -import { InMemoryState } from '../state/InMemoryState'; -import type { IStateManager } from '../state/IStateManager'; -import type { UnencryptedStateValue } from '../state/State'; -import { DEFAULT_UNENCRYPTED_STATE } from '../state/State'; +import { DEFAULT_UNENCRYPTED_STATE } from '../state/stateTypes'; +import type { UnencryptedStateValue } from '../state/stateTypes'; import { TransactionsRepository } from './TransactionsRepository'; describe('TransactionsRepository', () => { diff --git a/packages/solana-wallet-snap/src/core/services/transactions/TransactionsRepository.ts b/packages/solana-wallet-snap/src/core/services/transactions/TransactionsRepository.ts index e589fd48c..97b22ea5c 100644 --- a/packages/solana-wallet-snap/src/core/services/transactions/TransactionsRepository.ts +++ b/packages/solana-wallet-snap/src/core/services/transactions/TransactionsRepository.ts @@ -1,8 +1,8 @@ import type { Transaction } from '@metamask/keyring-api'; +import type { IStateManager } from '@metamask/snap-networks-utils'; import { chain } from 'lodash'; -import type { IStateManager } from '../state/IStateManager'; -import type { UnencryptedStateValue } from '../state/State'; +import type { UnencryptedStateValue } from '../state/stateTypes'; export class TransactionsRepository { readonly #state: IStateManager; diff --git a/packages/solana-wallet-snap/src/snapContext.ts b/packages/solana-wallet-snap/src/snapContext.ts index 0e8085914..f484e5012 100644 --- a/packages/solana-wallet-snap/src/snapContext.ts +++ b/packages/solana-wallet-snap/src/snapContext.ts @@ -1,3 +1,6 @@ +import { State } from '@metamask/snap-networks-utils'; +import type { IStateManager } from '@metamask/snap-networks-utils'; + import { InMemoryCache } from './core/caching/InMemoryCache'; import { NftApiClient } from './core/clients/nft-api/NftApiClient'; import { PriceApiClient } from './core/clients/price-api/PriceApiClient'; @@ -35,9 +38,9 @@ import { ConfigProvider } from './core/services/config'; import { ConfirmationHandler } from './core/services/confirmation/ConfirmationHandler'; import { SolanaConnection } from './core/services/connection/SolanaConnection'; import { NameResolutionService } from './core/services/name-resolution/NameResolutionService'; -import type { IStateManager } from './core/services/state/IStateManager'; -import type { UnencryptedStateValue } from './core/services/state/State'; -import { DEFAULT_UNENCRYPTED_STATE, State } from './core/services/state/State'; +import { registerStateMigration } from './core/services/state/registerStateMigration'; +import { DEFAULT_UNENCRYPTED_STATE } from './core/services/state/stateTypes'; +import type { UnencryptedStateValue } from './core/services/state/stateTypes'; import { TransactionScanService } from './core/services/transaction-scan/TransactionScan'; import { WalletService } from './core/services/wallet/WalletService'; import logger, { noOpLogger } from './core/utils/logger'; @@ -76,11 +79,13 @@ const configProvider = new ConfigProvider(); const eventEmitter = new EventEmitter(logger); -const state = new State(eventEmitter, { +const state = new State({ encrypted: false, defaultState: DEFAULT_UNENCRYPTED_STATE, }); +registerStateMigration(eventEmitter, state); + const inMemoryCache = new InMemoryCache(noOpLogger); const analyticsService = new AnalyticsService(logger); diff --git a/yarn.lock b/yarn.lock index 77cf460e0..2f5b98db4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3713,7 +3713,6 @@ __metadata: "@types/lodash": "npm:^4.17.15" "@types/react": "npm:18.2.4" "@types/react-dom": "npm:18.2.4" - async-mutex: "npm:^0.5.0" bignumber.js: "npm:^9.3.1" bs58: "npm:^6.0.0" buffer: "npm:^6.0.3"