diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 68ff0ac490..3053fc9401 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -61,10 +61,12 @@ "@metamask/metamask-eth-abis": "^3.1.1", "@metamask/polling-controller": "^12.0.2", "@metamask/rpc-errors": "^7.0.2", + "@metamask/snaps-utils": "^8.3.0", "@metamask/utils": "^11.0.1", "@types/bn.js": "^5.1.5", "@types/uuid": "^8.3.0", "async-mutex": "^0.5.0", + "bitcoin-address-validation": "^2.2.3", "bn.js": "^5.2.1", "cockatiel": "^3.1.2", "immer": "^9.0.6", @@ -79,11 +81,15 @@ "@metamask/approval-controller": "^7.1.1", "@metamask/auto-changelog": "^3.4.4", "@metamask/ethjs-provider-http": "^0.3.0", + "@metamask/keyring-api": "^13.0.0", "@metamask/keyring-controller": "^19.0.2", "@metamask/keyring-internal-api": "^1.1.0", + "@metamask/keyring-snap-client": "^1.0.0", "@metamask/network-controller": "^22.1.1", "@metamask/preferences-controller": "^15.0.1", "@metamask/providers": "^18.1.1", + "@metamask/snaps-controllers": "^9.10.0", + "@metamask/snaps-sdk": "^6.7.0", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.191", "@types/node": "^16.18.54", diff --git a/packages/assets-controllers/src/MultichainBalancesController/BalancesTracker.test.ts b/packages/assets-controllers/src/MultichainBalancesController/BalancesTracker.test.ts new file mode 100644 index 0000000000..ed6409199f --- /dev/null +++ b/packages/assets-controllers/src/MultichainBalancesController/BalancesTracker.test.ts @@ -0,0 +1,143 @@ +import { BtcAccountType, BtcMethod } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { v4 as uuidv4 } from 'uuid'; + +import { BalancesTracker } from './BalancesTracker'; +import { Poller } from './Poller'; + +const MOCK_TIMESTAMP = 1709983353; + +const mockBtcAccount = { + address: '', + id: uuidv4(), + metadata: { + name: 'Bitcoin Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-btc-snap', + name: 'mock-btc-snap', + enabled: true, + }, + lastSelected: 0, + }, + options: {}, + methods: [BtcMethod.SendBitcoin], + type: BtcAccountType.P2wpkh, +}; + +/** + * Sets up a BalancesTracker instance for testing. + * @returns The BalancesTracker instance and a mock update balance function. + */ +function setupTracker() { + const mockUpdateBalance = jest.fn(); + const tracker = new BalancesTracker(mockUpdateBalance); + + return { + tracker, + mockUpdateBalance, + }; +} + +describe('BalancesTracker', () => { + it('starts polling when calling start', async () => { + const { tracker } = setupTracker(); + const spyPoller = jest.spyOn(Poller.prototype, 'start'); + + tracker.start(); + expect(spyPoller).toHaveBeenCalledTimes(1); + }); + + it('stops polling when calling stop', async () => { + const { tracker } = setupTracker(); + const spyPoller = jest.spyOn(Poller.prototype, 'stop'); + + tracker.start(); + tracker.stop(); + expect(spyPoller).toHaveBeenCalledTimes(1); + }); + + it('is not tracking if none accounts have been registered', async () => { + const { tracker, mockUpdateBalance } = setupTracker(); + + tracker.start(); + await tracker.updateBalances(); + + expect(mockUpdateBalance).not.toHaveBeenCalled(); + }); + + it('tracks account balances', async () => { + const { tracker, mockUpdateBalance } = setupTracker(); + + tracker.start(); + // We must track account IDs explicitly + tracker.track(mockBtcAccount.id, 0); + // Trigger balances refresh (not waiting for the Poller here) + await tracker.updateBalances(); + + expect(mockUpdateBalance).toHaveBeenCalledWith(mockBtcAccount.id); + }); + + it('untracks account balances', async () => { + const { tracker, mockUpdateBalance } = setupTracker(); + + tracker.start(); + tracker.track(mockBtcAccount.id, 0); + await tracker.updateBalances(); + expect(mockUpdateBalance).toHaveBeenCalledWith(mockBtcAccount.id); + + tracker.untrack(mockBtcAccount.id); + await tracker.updateBalances(); + expect(mockUpdateBalance).toHaveBeenCalledTimes(1); // No second call after untracking + }); + + it('tracks account after being registered', async () => { + const { tracker } = setupTracker(); + + tracker.start(); + tracker.track(mockBtcAccount.id, 0); + expect(tracker.isTracked(mockBtcAccount.id)).toBe(true); + }); + + it('does not track account if not registered', async () => { + const { tracker } = setupTracker(); + + tracker.start(); + expect(tracker.isTracked(mockBtcAccount.id)).toBe(false); + }); + + it('does not refresh balance if they are considered up-to-date', async () => { + const { tracker, mockUpdateBalance } = setupTracker(); + + const blockTime = 10 * 60 * 1000; // 10 minutes in milliseconds. + jest + .spyOn(global.Date, 'now') + .mockImplementation(() => new Date(MOCK_TIMESTAMP).getTime()); + + tracker.start(); + tracker.track(mockBtcAccount.id, blockTime); + await tracker.updateBalances(); + expect(mockUpdateBalance).toHaveBeenCalledTimes(1); + + await tracker.updateBalances(); + expect(mockUpdateBalance).toHaveBeenCalledTimes(1); // No second call since the balances is already still up-to-date + + jest + .spyOn(global.Date, 'now') + .mockImplementation(() => new Date(MOCK_TIMESTAMP + blockTime).getTime()); + + await tracker.updateBalances(); + expect(mockUpdateBalance).toHaveBeenCalledTimes(2); // Now the balance will update + }); + + it('throws an error if trying to update balance of an untracked account', async () => { + const { tracker } = setupTracker(); + + await expect(tracker.updateBalance(mockBtcAccount.id)).rejects.toThrow( + `Account is not being tracked: ${mockBtcAccount.id}`, + ); + }); +}); diff --git a/packages/assets-controllers/src/MultichainBalancesController/BalancesTracker.ts b/packages/assets-controllers/src/MultichainBalancesController/BalancesTracker.ts new file mode 100644 index 0000000000..661c229a82 --- /dev/null +++ b/packages/assets-controllers/src/MultichainBalancesController/BalancesTracker.ts @@ -0,0 +1,139 @@ +import { Poller } from './Poller'; + +type BalanceInfo = { + lastUpdated: number; + blockTime: number; +}; + +const BALANCES_TRACKING_INTERVAL = 5000; // Every 5s in milliseconds. + +export class BalancesTracker { + #poller: Poller; + + #updateBalance: (accountId: string) => Promise; + + #balances: Record = {}; + + constructor(updateBalanceCallback: (accountId: string) => Promise) { + this.#updateBalance = updateBalanceCallback; + + this.#poller = new Poller( + () => this.updateBalances(), + BALANCES_TRACKING_INTERVAL, + ); + } + + /** + * Starts the tracking process. + */ + start(): void { + this.#poller.start(); + } + + /** + * Stops the tracking process. + */ + stop(): void { + this.#poller.stop(); + } + + /** + * Checks if an account ID is being tracked. + * + * @param accountId - The account ID. + * @returns True if the account is being tracked, false otherwise. + */ + isTracked(accountId: string) { + return Object.prototype.hasOwnProperty.call(this.#balances, accountId); + } + + /** + * Asserts that an account ID is being tracked. + * + * @param accountId - The account ID. + * @throws If the account ID is not being tracked. + */ + assertBeingTracked(accountId: string) { + if (!this.isTracked(accountId)) { + throw new Error(`Account is not being tracked: ${accountId}`); + } + } + + /** + * Starts tracking a new account ID. This method has no effect on already tracked + * accounts. + * + * @param accountId - The account ID. + * @param blockTime - The block time (used when refreshing the account balances). + */ + track(accountId: string, blockTime: number) { + // Do not overwrite current info if already being tracked! + if (!this.isTracked(accountId)) { + this.#balances[accountId] = { + lastUpdated: 0, + blockTime, + }; + } + } + + /** + * Stops tracking a tracked account ID. + * + * @param accountId - The account ID. + * @throws If the account ID is not being tracked. + */ + untrack(accountId: string) { + this.assertBeingTracked(accountId); + delete this.#balances[accountId]; + } + + /** + * Update the balances for a tracked account ID. + * + * @param accountId - The account ID. + * @throws If the account ID is not being tracked. + */ + async updateBalance(accountId: string) { + this.assertBeingTracked(accountId); + + // We check if the balance is outdated (by comparing to the block time associated + // with this kind of account). + // + // This might not be super accurate, but we could probably compute this differently + // and try to sync with the "real block time"! + const info = this.#balances[accountId]; + if (this.#isBalanceOutdated(info)) { + await this.#updateBalance(accountId); + this.#balances[accountId].lastUpdated = Date.now(); + } + } + + /** + * Update the balances of all tracked accounts (only if the balances + * is considered outdated). + */ + async updateBalances() { + await Promise.allSettled( + Object.keys(this.#balances).map(async (accountId) => { + await this.updateBalance(accountId); + }), + ); + } + + /** + * Checks if the balance is outdated according to the provided data. + * + * @param param - The balance info. + * @param param.lastUpdated - The last updated timestamp. + * @param param.blockTime - The block time. + * @returns True if the balance is outdated, false otherwise. + */ + #isBalanceOutdated({ lastUpdated, blockTime }: BalanceInfo): boolean { + return ( + // Never been updated: + lastUpdated === 0 || + // Outdated: + Date.now() - lastUpdated >= blockTime + ); + } +} diff --git a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts new file mode 100644 index 0000000000..87f200ab55 --- /dev/null +++ b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts @@ -0,0 +1,261 @@ +import { ControllerMessenger } from '@metamask/base-controller'; +import type { Balance, CaipAssetType } from '@metamask/keyring-api'; +import { + BtcAccountType, + BtcMethod, + EthAccountType, + EthMethod, + BtcScopes, + EthScopes, + SolScopes, +} from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { v4 as uuidv4 } from 'uuid'; + +import type { + ExtractAvailableAction, + ExtractAvailableEvent, +} from '../../../base-controller/tests/helpers'; +import { BalancesTracker } from './BalancesTracker'; +import { + MultichainBalancesController, + getDefaultMultichainBalancesControllerState, +} from './MultichainBalancesController'; +import type { + MultichainBalancesControllerMessenger, + MultichainBalancesControllerState, +} from './MultichainBalancesController'; + +const mockBtcAccount = { + address: 'bc1qssdcp5kvwh6nghzg9tuk99xsflwkdv4hgvq58q', + id: uuidv4(), + metadata: { + name: 'Bitcoin Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-btc-snap', + name: 'mock-btc-snap', + enabled: true, + }, + lastSelected: 0, + }, + scopes: [BtcScopes.Namespace], + options: {}, + methods: [BtcMethod.SendBitcoin], + type: BtcAccountType.P2wpkh, +}; + +const mockEthAccount = { + address: '0x807dE1cf8f39E83258904b2f7b473E5C506E4aC1', + id: uuidv4(), + metadata: { + name: 'Ethereum Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-eth-snap', + name: 'mock-eth-snap', + enabled: true, + }, + lastSelected: 0, + }, + scopes: [EthScopes.Namespace], + options: {}, + methods: [EthMethod.SignTypedDataV4, EthMethod.SignTransaction], + type: EthAccountType.Eoa, +}; + +const mockBalanceResult = { + 'bip122:000000000933ea01ad0ee984209779ba/slip44:0': { + amount: '0.00000000', + unit: 'BTC', + }, +}; + +/** + * The union of actions that the root messenger allows. + */ +type RootAction = ExtractAvailableAction; + +/** + * The union of events that the root messenger allows. + */ +type RootEvent = ExtractAvailableEvent; + +/** + * Constructs the unrestricted messenger. This can be used to call actions and + * publish events within the tests for this controller. + * + * @returns The unrestricted messenger suited for PetNamesController. + */ +function getRootControllerMessenger(): ControllerMessenger< + RootAction, + RootEvent +> { + return new ControllerMessenger(); +} + +const setupController = ({ + state = getDefaultMultichainBalancesControllerState(), + mocks, +}: { + state?: MultichainBalancesControllerState; + mocks?: { + listMultichainAccounts?: InternalAccount[]; + handleRequestReturnValue?: Record; + }; +} = {}) => { + const controllerMessenger = getRootControllerMessenger(); + + const multichainBalancesControllerMessenger: MultichainBalancesControllerMessenger = + controllerMessenger.getRestricted({ + name: 'MultichainBalancesController', + allowedActions: [ + 'SnapController:handleRequest', + 'AccountsController:listMultichainAccounts', + ], + allowedEvents: [ + 'AccountsController:accountAdded', + 'AccountsController:accountRemoved', + ], + }); + + const mockSnapHandleRequest = jest.fn(); + controllerMessenger.registerActionHandler( + 'SnapController:handleRequest', + mockSnapHandleRequest.mockReturnValue( + mocks?.handleRequestReturnValue ?? mockBalanceResult, + ), + ); + + const mockListMultichainAccounts = jest.fn(); + controllerMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + mockListMultichainAccounts.mockReturnValue( + mocks?.listMultichainAccounts ?? [mockBtcAccount, mockEthAccount], + ), + ); + + const controller = new MultichainBalancesController({ + messenger: multichainBalancesControllerMessenger, + state, + }); + + return { + controller, + messenger: controllerMessenger, + mockSnapHandleRequest, + mockListMultichainAccounts, + }; +}; + +describe('BalancesController', () => { + it('initialize with default state', () => { + const { controller } = setupController({}); + expect(controller.state).toStrictEqual({ balances: {} }); + }); + + it('starts tracking when calling start', async () => { + const spyTracker = jest.spyOn(BalancesTracker.prototype, 'start'); + const { controller } = setupController(); + controller.start(); + expect(spyTracker).toHaveBeenCalledTimes(1); + }); + + it('stops tracking when calling stop', async () => { + const spyTracker = jest.spyOn(BalancesTracker.prototype, 'stop'); + const { controller } = setupController(); + controller.start(); + controller.stop(); + expect(spyTracker).toHaveBeenCalledTimes(1); + }); + + it('updates balances when calling updateBalances', async () => { + const { controller } = setupController(); + + await controller.updateBalances(); + + expect(controller.state).toStrictEqual({ + balances: { + [mockBtcAccount.id]: mockBalanceResult, + }, + }); + }); + + it('updates the balance for a specific account when calling updateBalance', async () => { + const { controller } = setupController(); + + await controller.updateBalance(mockBtcAccount.id); + + expect(controller.state).toStrictEqual({ + balances: { + [mockBtcAccount.id]: mockBalanceResult, + }, + }); + }); + + it('updates balances when "AccountsController:accountAdded" is fired', async () => { + const { controller, messenger, mockListMultichainAccounts } = + setupController({ + mocks: { + listMultichainAccounts: [], + }, + }); + + controller.start(); + mockListMultichainAccounts.mockReturnValue([mockBtcAccount]); + messenger.publish('AccountsController:accountAdded', mockBtcAccount); + await controller.updateBalances(); + + expect(controller.state).toStrictEqual({ + balances: { + [mockBtcAccount.id]: mockBalanceResult, + }, + }); + }); + + it('updates balances when "AccountsController:accountRemoved" is fired', async () => { + const { controller, messenger, mockListMultichainAccounts } = + setupController(); + + controller.start(); + await controller.updateBalances(); + expect(controller.state).toStrictEqual({ + balances: { + [mockBtcAccount.id]: mockBalanceResult, + }, + }); + + messenger.publish('AccountsController:accountRemoved', mockBtcAccount.id); + mockListMultichainAccounts.mockReturnValue([]); + await controller.updateBalances(); + + expect(controller.state).toStrictEqual({ + balances: {}, + }); + }); + + it('does not track balances for EVM accounts', async () => { + const { controller, messenger, mockListMultichainAccounts } = + setupController({ + mocks: { + listMultichainAccounts: [], + }, + }); + + controller.start(); + mockListMultichainAccounts.mockReturnValue([mockEthAccount]); + messenger.publish('AccountsController:accountAdded', mockEthAccount); + await controller.updateBalances(); + + expect(controller.state).toStrictEqual({ + balances: {}, + }); + }); +}); diff --git a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts new file mode 100644 index 0000000000..9442607e56 --- /dev/null +++ b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.ts @@ -0,0 +1,369 @@ +import type { + AccountsControllerAccountAddedEvent, + AccountsControllerAccountRemovedEvent, + AccountsControllerListMultichainAccountsAction, +} from '@metamask/accounts-controller'; +import { + BaseController, + type ControllerGetStateAction, + type ControllerStateChangeEvent, + type RestrictedControllerMessenger, +} from '@metamask/base-controller'; +import { isEvmAccountType } from '@metamask/keyring-api'; +import type { Balance, CaipAssetType } from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { KeyringClient } from '@metamask/keyring-snap-client'; +import type { HandleSnapRequest } from '@metamask/snaps-controllers'; +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; +import type { Draft } from 'immer'; + +import { BalancesTracker, NETWORK_ASSETS_MAP } from '.'; +import { getScopeForAccount, getBlockTimeForAccount } from './utils'; + +const controllerName = 'MultichainBalancesController'; + +/** + * State used by the {@link MultichainBalancesController} to cache account balances. + */ +export type MultichainBalancesControllerState = { + balances: { + [account: string]: { + [asset: string]: { + amount: string; + unit: string; + }; + }; + }; +}; + +/** + * Constructs the default {@link MultichainBalancesController} state. This allows + * consumers to provide a partial state object when initializing the controller + * and also helps in constructing complete state objects for this controller in + * tests. + * + * @returns The default {@link MultichainBalancesController} state. + */ +export function getDefaultMultichainBalancesControllerState(): MultichainBalancesControllerState { + return { balances: {} }; +} + +/** + * Returns the state of the {@link MultichainBalancesController}. + */ +export type MultichainBalancesControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + MultichainBalancesControllerState + >; + +/** + * Updates the balances of all supported accounts. + */ +export type MultichainBalancesControllerUpdateBalancesAction = { + type: `${typeof controllerName}:updateBalances`; + handler: MultichainBalancesController['updateBalances']; +}; + +/** + * Event emitted when the state of the {@link MultichainBalancesController} changes. + */ +export type MultichainBalancesControllerStateChange = + ControllerStateChangeEvent< + typeof controllerName, + MultichainBalancesControllerState + >; + +/** + * Actions exposed by the {@link MultichainBalancesController}. + */ +export type MultichainBalancesControllerActions = + | MultichainBalancesControllerGetStateAction + | MultichainBalancesControllerUpdateBalancesAction; + +/** + * Events emitted by {@link MultichainBalancesController}. + */ +export type MultichainBalancesControllerEvents = + MultichainBalancesControllerStateChange; + +/** + * Actions that this controller is allowed to call. + */ +type AllowedActions = + | HandleSnapRequest + | AccountsControllerListMultichainAccountsAction; + +/** + * Events that this controller is allowed to subscribe. + */ +type AllowedEvents = + | AccountsControllerAccountAddedEvent + | AccountsControllerAccountRemovedEvent; + +/** + * Messenger type for the MultichainBalancesController. + */ +export type MultichainBalancesControllerMessenger = + RestrictedControllerMessenger< + typeof controllerName, + MultichainBalancesControllerActions | AllowedActions, + MultichainBalancesControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] + >; + +/** + * {@link MultichainBalancesController}'s metadata. + * + * This allows us to choose if fields of the state should be persisted or not + * using the `persist` flag; and if they can be sent to Sentry or not, using + * the `anonymous` flag. + */ +const balancesControllerMetadata = { + balances: { + persist: true, + anonymous: false, + }, +}; + +/** + * The MultichainBalancesController is responsible for fetching and caching account + * balances. + */ +export class MultichainBalancesController extends BaseController< + typeof controllerName, + MultichainBalancesControllerState, + MultichainBalancesControllerMessenger +> { + #tracker: BalancesTracker; + + constructor({ + messenger, + state = {}, + }: { + messenger: MultichainBalancesControllerMessenger; + state?: Partial; + }) { + super({ + messenger, + name: controllerName, + metadata: balancesControllerMetadata, + state: { + ...getDefaultMultichainBalancesControllerState(), + ...state, + }, + }); + + this.#tracker = new BalancesTracker( + async (accountId: string) => await this.#updateBalance(accountId), + ); + + // Register all non-EVM accounts into the tracker + for (const account of this.#listAccounts()) { + if (this.#isNonEvmAccount(account)) { + this.#tracker.track(account.id, getBlockTimeForAccount(account.type)); + } + } + + this.messagingSystem.subscribe( + 'AccountsController:accountAdded', + (account) => this.#handleOnAccountAdded(account), + ); + this.messagingSystem.subscribe( + 'AccountsController:accountRemoved', + (account) => this.#handleOnAccountRemoved(account), + ); + } + + /** + * Starts the polling process. + */ + start(): void { + this.#tracker.start(); + } + + /** + * Stops the polling process. + */ + stop(): void { + this.#tracker.stop(); + } + + /** + * Updates the balances of one account. This method doesn't return + * anything, but it updates the state of the controller. + * + * @param accountId - The account ID. + */ + async updateBalance(accountId: string): Promise { + // NOTE: No need to track the account here, since we start tracking those when + // the "AccountsController:accountAdded" is fired. + await this.#tracker.updateBalance(accountId); + } + + /** + * Updates the balances of all supported accounts. This method doesn't return + * anything, but it updates the state of the controller. + */ + async updateBalances(): Promise { + await this.#tracker.updateBalances(); + } + + /** + * Lists the multichain accounts coming from the `AccountsController`. + * + * @returns A list of multichain accounts. + */ + #listMultichainAccounts(): InternalAccount[] { + return this.messagingSystem.call( + 'AccountsController:listMultichainAccounts', + ); + } + + /** + * Lists the accounts that we should get balances for. + * + * @returns A list of accounts that we should get balances for. + */ + #listAccounts(): InternalAccount[] { + const accounts = this.#listMultichainAccounts(); + + return accounts.filter((account) => this.#isNonEvmAccount(account)); + } + + /** + * Get a non-EVM account from its ID. + * + * @param accountId - The account ID. + * @returns The non-EVM account. + */ + #getAccount(accountId: string): InternalAccount { + const account: InternalAccount | undefined = this.#listAccounts().find( + (multichainAccount) => multichainAccount.id === accountId, + ); + + if (!account) { + throw new Error(`Unknown account: ${accountId}`); + } + + return account; + } + + /** + * Updates the balances of one account. This method doesn't return + * anything, but it updates the state of the controller. + * + * @param accountId - The account ID. + */ + + async #updateBalance(accountId: string) { + const account = this.#getAccount(accountId); + + if (account.metadata.snap) { + const scope = getScopeForAccount(account); + const assetTypes = NETWORK_ASSETS_MAP[scope]; + + const accountBalance = await this.#getBalances( + account.id, + account.metadata.snap.id, + assetTypes, + ); + + this.update((state: Draft) => { + state.balances[accountId] = accountBalance; + }); + } + } + + /** + * Checks for non-EVM accounts. + * + * @param account - The new account to be checked. + * @returns True if the account is a non-EVM account, false otherwise. + */ + #isNonEvmAccount(account: InternalAccount): boolean { + return ( + !isEvmAccountType(account.type) && + // Non-EVM accounts are backed by a Snap for now + account.metadata.snap !== undefined + ); + } + + /** + * Handles changes when a new account has been added. + * + * @param account - The new account being added. + */ + async #handleOnAccountAdded(account: InternalAccount): Promise { + if (!this.#isNonEvmAccount(account)) { + // Nothing to do here for EVM accounts + return; + } + + this.#tracker.track(account.id, getBlockTimeForAccount(account.type)); + // NOTE: Unfortunately, we cannot update the balance right away here, because + // messenger's events are running synchronously and fetching the balance is + // asynchronous. + // Updating the balance here would resume at some point but the event emitter + // will not `await` this (so we have no real control "when" the balance will + // really be updated), see: + // - https://github.com/MetaMask/core/blob/v213.0.0/packages/accounts-controller/src/AccountsController.ts#L1036-L1039 + } + + /** + * Handles changes when a new account has been removed. + * + * @param accountId - The account ID being removed. + */ + async #handleOnAccountRemoved(accountId: string): Promise { + if (this.#tracker.isTracked(accountId)) { + this.#tracker.untrack(accountId); + } + + if (accountId in this.state.balances) { + this.update((state: Draft) => { + delete state.balances[accountId]; + }); + } + } + + /** + * Get the balances for an account. + * + * @param accountId - ID of the account to get balances for. + * @param snapId - ID of the Snap which manages the account. + * @param assetTypes - Array of asset types to get balances for. + * @returns A map of asset types to balances. + */ + async #getBalances( + accountId: string, + snapId: string, + assetTypes: CaipAssetType[], + ): Promise> { + return await this.#getClient(snapId).getAccountBalances( + accountId, + assetTypes, + ); + } + + /** + * Gets a `KeyringClient` for a Snap. + * + * @param snapId - ID of the Snap to get the client for. + * @returns A `KeyringClient` for the Snap. + */ + #getClient(snapId: string): KeyringClient { + return new KeyringClient({ + send: async (request: JsonRpcRequest) => + (await this.messagingSystem.call('SnapController:handleRequest', { + snapId: snapId as SnapId, + origin: 'metamask', + handler: HandlerType.OnKeyringRequest, + request, + })) as Promise, + }); + } +} diff --git a/packages/assets-controllers/src/MultichainBalancesController/Poller.test.ts b/packages/assets-controllers/src/MultichainBalancesController/Poller.test.ts new file mode 100644 index 0000000000..aba0e4041b --- /dev/null +++ b/packages/assets-controllers/src/MultichainBalancesController/Poller.test.ts @@ -0,0 +1,118 @@ +import { PollerError } from './error'; +import { Poller } from './Poller'; + +jest.useFakeTimers(); + +const interval = 1000; +const intervalPlus100ms = interval + 100; + +describe('Poller', () => { + let callback: jest.Mock, []>; + + beforeEach(() => { + callback = jest.fn().mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('calls the callback function after the specified interval', async () => { + const poller = new Poller(callback, interval); + poller.start(); + jest.advanceTimersByTime(intervalPlus100ms); + poller.stop(); + + // Wait for all promises to resolve + await Promise.resolve(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('does not call the callback function if stopped before the interval', async () => { + const poller = new Poller(callback, interval); + poller.start(); + poller.stop(); + jest.advanceTimersByTime(intervalPlus100ms); + + // Wait for all promises to resolve + await Promise.resolve(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('calls the callback function multiple times if started and stopped multiple times', async () => { + const poller = new Poller(callback, interval); + poller.start(); + jest.advanceTimersByTime(intervalPlus100ms); + poller.stop(); + jest.advanceTimersByTime(intervalPlus100ms); + poller.start(); + jest.advanceTimersByTime(intervalPlus100ms); + poller.stop(); + + // Wait for all promises to resolve + await Promise.resolve(); + + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('does not call the callback if the poller is stopped before the interval has passed', async () => { + const poller = new Poller(callback, interval); + poller.start(); + // Wait for some time, but stop before reaching the `interval` timeout + jest.advanceTimersByTime(interval / 2); + poller.stop(); + + // Wait for all promises to resolve + await Promise.resolve(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('does not start a new interval if already running', async () => { + const poller = new Poller(callback, interval); + poller.start(); + poller.start(); // Attempt to start again + jest.advanceTimersByTime(intervalPlus100ms); + poller.stop(); + + // Wait for all promises to resolve + await Promise.resolve(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('can stop multiple times without issues', async () => { + const poller = new Poller(callback, interval); + poller.start(); + jest.advanceTimersByTime(interval / 2); + poller.stop(); + poller.stop(); // Attempt to stop again + jest.advanceTimersByTime(intervalPlus100ms); + + // Wait for all promises to resolve + await Promise.resolve(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('catches and logs a PollerError when callback throws an error', async () => { + const mockCallback = jest.fn().mockRejectedValue(new Error('Test error')); + const poller = new Poller(mockCallback, 1000); + const spyConsoleError = jest.spyOn(console, 'error'); + + poller.start(); + + // Fast-forward time to trigger the interval + jest.advanceTimersByTime(1000); + + // Wait for the promise to be handled + await Promise.resolve(); + + expect(mockCallback).toHaveBeenCalled(); + expect(spyConsoleError).toHaveBeenCalledWith(new PollerError('Test error')); + + poller.stop(); + }); +}); diff --git a/packages/assets-controllers/src/MultichainBalancesController/Poller.ts b/packages/assets-controllers/src/MultichainBalancesController/Poller.ts new file mode 100644 index 0000000000..c0167790c8 --- /dev/null +++ b/packages/assets-controllers/src/MultichainBalancesController/Poller.ts @@ -0,0 +1,34 @@ +import { PollerError } from './error'; + +export class Poller { + #interval: number; + + #callback: () => Promise; + + #handle: NodeJS.Timeout | undefined = undefined; + + constructor(callback: () => Promise, interval: number) { + this.#interval = interval; + this.#callback = callback; + } + + start() { + if (this.#handle) { + return; + } + + this.#handle = setInterval(() => { + this.#callback().catch((err) => { + console.error(new PollerError(err.message)); + }); + }, this.#interval); + } + + stop() { + if (!this.#handle) { + return; + } + clearInterval(this.#handle); + this.#handle = undefined; + } +} diff --git a/packages/assets-controllers/src/MultichainBalancesController/constants.ts b/packages/assets-controllers/src/MultichainBalancesController/constants.ts new file mode 100644 index 0000000000..81aebf8fbf --- /dev/null +++ b/packages/assets-controllers/src/MultichainBalancesController/constants.ts @@ -0,0 +1,44 @@ +import { BtcAccountType, SolAccountType } from '@metamask/keyring-api'; + +/** + * The network identifiers for supported networks in CAIP-2 format. + * Note: This is a temporary workaround until we have a more robust + * solution for network identifiers. + */ +export enum MultichainNetworks { + Bitcoin = 'bip122:000000000019d6689c085ae165831e93', + BitcoinTestnet = 'bip122:000000000933ea01ad0ee984209779ba', + Solana = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + SolanaDevnet = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + SolanaTestnet = 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z', +} + +export enum MultichainNativeAssets { + Bitcoin = `${MultichainNetworks.Bitcoin}/slip44:0`, + BitcoinTestnet = `${MultichainNetworks.BitcoinTestnet}/slip44:0`, + Solana = `${MultichainNetworks.Solana}/slip44:501`, + SolanaDevnet = `${MultichainNetworks.SolanaDevnet}/slip44:501`, + SolanaTestnet = `${MultichainNetworks.SolanaTestnet}/slip44:501`, +} + +const BITCOIN_AVG_BLOCK_TIME = 10 * 60 * 1000; // 10 minutes in milliseconds +const SOLANA_AVG_BLOCK_TIME = 400; // 400 milliseconds + +export const BALANCE_UPDATE_INTERVALS = { + // NOTE: We set an interval of half the average block time for bitcoin + // to mitigate when our interval is de-synchronized with the actual block time. + [BtcAccountType.P2wpkh]: BITCOIN_AVG_BLOCK_TIME / 2, + [SolAccountType.DataAccount]: SOLANA_AVG_BLOCK_TIME, +}; + +/** + * Maps network identifiers to their corresponding native asset types. + * Each network is mapped to an array containing its native asset for consistency. + */ +export const NETWORK_ASSETS_MAP: Record = { + [MultichainNetworks.Solana]: [MultichainNativeAssets.Solana], + [MultichainNetworks.SolanaTestnet]: [MultichainNativeAssets.SolanaTestnet], + [MultichainNetworks.SolanaDevnet]: [MultichainNativeAssets.SolanaDevnet], + [MultichainNetworks.Bitcoin]: [MultichainNativeAssets.Bitcoin], + [MultichainNetworks.BitcoinTestnet]: [MultichainNativeAssets.BitcoinTestnet], +}; diff --git a/packages/assets-controllers/src/MultichainBalancesController/error.test.ts b/packages/assets-controllers/src/MultichainBalancesController/error.test.ts new file mode 100644 index 0000000000..d94b5a3712 --- /dev/null +++ b/packages/assets-controllers/src/MultichainBalancesController/error.test.ts @@ -0,0 +1,23 @@ +import { BalancesTrackerError, PollerError } from './error'; + +describe('BalancesTrackerError', () => { + it('creates an instance of BalancesTrackerError with the correct message and name', () => { + const message = 'Test BalancesTrackerError message'; + const error = new BalancesTrackerError(message); + + expect(error).toBeInstanceOf(BalancesTrackerError); + expect(error.message).toBe(message); + expect(error.name).toBe('BalancesTrackerError'); + }); +}); + +describe('PollerError', () => { + it('creates an instance of PollerError with the correct message and name', () => { + const message = 'Test PollerError message'; + const error = new PollerError(message); + + expect(error).toBeInstanceOf(PollerError); + expect(error.message).toBe(message); + expect(error.name).toBe('PollerError'); + }); +}); diff --git a/packages/assets-controllers/src/MultichainBalancesController/error.ts b/packages/assets-controllers/src/MultichainBalancesController/error.ts new file mode 100644 index 0000000000..22229fb8e8 --- /dev/null +++ b/packages/assets-controllers/src/MultichainBalancesController/error.ts @@ -0,0 +1,13 @@ +export class BalancesTrackerError extends Error { + constructor(message: string) { + super(message); + this.name = 'BalancesTrackerError'; + } +} + +export class PollerError extends Error { + constructor(message: string) { + super(message); + this.name = 'PollerError'; + } +} diff --git a/packages/assets-controllers/src/MultichainBalancesController/index.ts b/packages/assets-controllers/src/MultichainBalancesController/index.ts new file mode 100644 index 0000000000..4b000464b1 --- /dev/null +++ b/packages/assets-controllers/src/MultichainBalancesController/index.ts @@ -0,0 +1,17 @@ +export { BalancesTracker } from './BalancesTracker'; +export { MultichainBalancesController } from './MultichainBalancesController'; +export { + BALANCE_UPDATE_INTERVALS, + NETWORK_ASSETS_MAP, + MultichainNetworks, + MultichainNativeAssets, +} from './constants'; +export type { + MultichainBalancesControllerState, + MultichainBalancesControllerGetStateAction, + MultichainBalancesControllerUpdateBalancesAction, + MultichainBalancesControllerStateChange, + MultichainBalancesControllerActions, + MultichainBalancesControllerEvents, + MultichainBalancesControllerMessenger, +} from './MultichainBalancesController'; diff --git a/packages/assets-controllers/src/MultichainBalancesController/utils.test.ts b/packages/assets-controllers/src/MultichainBalancesController/utils.test.ts new file mode 100644 index 0000000000..099ccf23c8 --- /dev/null +++ b/packages/assets-controllers/src/MultichainBalancesController/utils.test.ts @@ -0,0 +1,197 @@ +import { + BtcAccountType, + SolAccountType, + BtcMethod, + SolMethod, + BtcScopes, + SolScopes, +} from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { validate, Network } from 'bitcoin-address-validation'; +import { v4 as uuidv4 } from 'uuid'; + +import { MultichainNetworks, BALANCE_UPDATE_INTERVALS } from '.'; +import { + getScopeForBtcAddress, + getScopeForSolAddress, + getScopeForAccount, + getBlockTimeForAccount, +} from './utils'; + +const mockBtcAccount = { + address: 'bc1qssdcp5kvwh6nghzg9tuk99xsflwkdv4hgvq58q', + id: uuidv4(), + metadata: { + name: 'Bitcoin Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-btc-snap', + name: 'mock-btc-snap', + enabled: true, + }, + lastSelected: 0, + }, + scopes: [BtcScopes.Namespace], + options: {}, + methods: [BtcMethod.SendBitcoin], + type: BtcAccountType.P2wpkh, +}; + +const mockSolAccount = { + address: 'nicktrLHhYzLmoVbuZQzHUTicd2sfP571orwo9jfc8c', + id: uuidv4(), + metadata: { + name: 'Solana Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-sol-snap', + name: 'mock-sol-snap', + enabled: true, + }, + lastSelected: 0, + }, + options: { + scope: 'solana-scope', + }, + scopes: [SolScopes.Namespace], + methods: [SolMethod.SendAndConfirmTransaction], + type: SolAccountType.DataAccount, +}; + +jest.mock('bitcoin-address-validation', () => ({ + validate: jest.fn(), + Network: { + mainnet: 'mainnet', + testnet: 'testnet', + }, +})); + +describe('getScopeForBtcAddress', () => { + it('returns Bitcoin scope for a valid mainnet address', () => { + const account = { + ...mockBtcAccount, + address: 'valid-mainnet-address', + }; + (validate as jest.Mock).mockReturnValueOnce(true); + + const scope = getScopeForBtcAddress(account); + + expect(scope).toBe(MultichainNetworks.Bitcoin); + expect(validate).toHaveBeenCalledWith(account.address, Network.mainnet); + }); + + it('returns BitcoinTestnet scope for a valid testnet address', () => { + const account = { + ...mockBtcAccount, + address: 'valid-testnet-address', + }; + (validate as jest.Mock) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + + const scope = getScopeForBtcAddress(account); + + expect(scope).toBe(MultichainNetworks.BitcoinTestnet); + expect(validate).toHaveBeenCalledWith(account.address, Network.mainnet); + expect(validate).toHaveBeenCalledWith(account.address, Network.testnet); + }); + + it('throws an error for an invalid address', () => { + const account = { + ...mockBtcAccount, + address: 'invalid-address', + }; + (validate as jest.Mock) + .mockReturnValueOnce(false) + .mockReturnValueOnce(false); + + expect(() => getScopeForBtcAddress(account)).toThrow( + `Invalid Bitcoin address: ${account.address}`, + ); + expect(validate).toHaveBeenCalledWith(account.address, Network.mainnet); + expect(validate).toHaveBeenCalledWith(account.address, Network.testnet); + }); +}); + +describe('getScopeForSolAddress', () => { + it('returns the scope for a valid Solana account', () => { + const scope = getScopeForSolAddress(mockSolAccount); + + expect(scope).toBe('solana-scope'); + }); + + it('throws an error if the Solana account scope is undefined', () => { + const account = { + ...mockSolAccount, + options: {}, + }; + + expect(() => getScopeForSolAddress(account)).toThrow( + 'Solana account scope is undefined', + ); + }); +}); + +describe('getScopeForAddress', () => { + it('returns the scope for a Bitcoin account', () => { + const account = { + ...mockBtcAccount, + address: 'valid-mainnet-address', + }; + (validate as jest.Mock).mockReturnValueOnce(true); + + const scope = getScopeForAccount(account); + + expect(scope).toBe(MultichainNetworks.Bitcoin); + }); + + it('returns the scope for a Solana account', () => { + const account = { + ...mockSolAccount, + options: { scope: 'solana-scope' }, + }; + + const scope = getScopeForAccount(account); + + expect(scope).toBe('solana-scope'); + }); + + it('throws an error for an unsupported account type', () => { + const account = { + ...mockSolAccount, + type: 'unsupported-type', + }; + + // @ts-expect-error - We're testing an error case. + expect(() => getScopeForAccount(account)).toThrow( + `Unsupported non-EVM account type: ${account.type}`, + ); + }); +}); + +describe('getBlockTimeForAccount', () => { + it('returns the block time for a supported Bitcoin account', () => { + const blockTime = getBlockTimeForAccount(BtcAccountType.P2wpkh); + expect(blockTime).toBe(BALANCE_UPDATE_INTERVALS[BtcAccountType.P2wpkh]); + }); + + it('returns the block time for a supported Solana account', () => { + const blockTime = getBlockTimeForAccount(SolAccountType.DataAccount); + expect(blockTime).toBe( + BALANCE_UPDATE_INTERVALS[SolAccountType.DataAccount], + ); + }); + + it('throws an error for an unsupported account type', () => { + const unsupportedAccountType = 'unsupported-type'; + expect(() => getBlockTimeForAccount(unsupportedAccountType)).toThrow( + `Unsupported account type for balance tracking: ${unsupportedAccountType}`, + ); + }); +}); diff --git a/packages/assets-controllers/src/MultichainBalancesController/utils.ts b/packages/assets-controllers/src/MultichainBalancesController/utils.ts new file mode 100644 index 0000000000..205cca8fc3 --- /dev/null +++ b/packages/assets-controllers/src/MultichainBalancesController/utils.ts @@ -0,0 +1,77 @@ +import { BtcAccountType, SolAccountType } from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { validate, Network } from 'bitcoin-address-validation'; + +import { MultichainNetworks, BALANCE_UPDATE_INTERVALS } from './constants'; + +/** + * Gets the scope for a specific and supported Bitcoin account. + * Note: This is a temporary method and will be replaced by a more robust solution + * once the new `account.scopes` is available in the `@metamask/keyring-api` module. + * + * @param account - Bitcoin account + * @returns The scope for the given account. + */ +export const getScopeForBtcAddress = (account: InternalAccount): string => { + if (validate(account.address, Network.mainnet)) { + return MultichainNetworks.Bitcoin; + } + + if (validate(account.address, Network.testnet)) { + return MultichainNetworks.BitcoinTestnet; + } + + throw new Error(`Invalid Bitcoin address: ${account.address}`); +}; + +/** + * Gets the scope for a specific and supported Solana account. + * Note: This is a temporary method and will be replaced by a more robust solution + * once the new `account.scopes` is available in the `keyring-api`. + * + * @param account - Solana account + * @returns The scope for the given account. + */ +export const getScopeForSolAddress = (account: InternalAccount): string => { + // For Solana accounts, we know we have a `scope` on the account's `options` bag. + if (!account.options.scope) { + throw new Error('Solana account scope is undefined'); + } + return account.options.scope as string; +}; + +/** + * Get the scope for a given address. + * Note: This is a temporary method and will be replaced by a more robust solution + * once the new `account.scopes` is available in the `keyring-api`. + * + * @param account - The account to get the scope for. + * @returns The scope for the given account. + */ +export const getScopeForAccount = (account: InternalAccount): string => { + switch (account.type) { + case BtcAccountType.P2wpkh: + return getScopeForBtcAddress(account); + case SolAccountType.DataAccount: + return getScopeForSolAddress(account); + default: + throw new Error(`Unsupported non-EVM account type: ${account.type}`); + } +}; + +/** + * Gets the block time for a given account. + * + * @param accountType - The account type to get the block time for. + * @returns The block time for the account. + */ +export const getBlockTimeForAccount = (accountType: string): number => { + if (accountType in BALANCE_UPDATE_INTERVALS) { + return BALANCE_UPDATE_INTERVALS[ + accountType as keyof typeof BALANCE_UPDATE_INTERVALS + ]; + } + throw new Error( + `Unsupported account type for balance tracking: ${accountType}`, + ); +}; diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index d9a8857aab..410054b59e 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -148,3 +148,21 @@ export type { RatesControllerPollingStartedEvent, RatesControllerPollingStoppedEvent, } from './RatesController'; +export { + BalancesTracker, + MultichainBalancesController, + // constants + BALANCE_UPDATE_INTERVALS, + NETWORK_ASSETS_MAP, + MultichainNetworks, + MultichainNativeAssets, +} from './MultichainBalancesController'; +export type { + MultichainBalancesControllerState, + MultichainBalancesControllerGetStateAction, + MultichainBalancesControllerUpdateBalancesAction, + MultichainBalancesControllerStateChange, + MultichainBalancesControllerActions, + MultichainBalancesControllerEvents, + MultichainBalancesControllerMessenger, +} from './MultichainBalancesController'; diff --git a/yarn.lock b/yarn.lock index c92dafbd90..9ffa56c0f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2383,14 +2383,19 @@ __metadata: "@metamask/controller-utils": "npm:^11.4.4" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-provider-http": "npm:^0.3.0" + "@metamask/keyring-api": "npm:^13.0.0" "@metamask/keyring-controller": "npm:^19.0.2" "@metamask/keyring-internal-api": "npm:^1.1.0" + "@metamask/keyring-snap-client": "npm:^1.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^22.1.1" "@metamask/polling-controller": "npm:^12.0.2" "@metamask/preferences-controller": "npm:^15.0.1" "@metamask/providers": "npm:^18.1.1" "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/snaps-controllers": "npm:^9.10.0" + "@metamask/snaps-sdk": "npm:^6.7.0" + "@metamask/snaps-utils": "npm:^8.3.0" "@metamask/utils": "npm:^11.0.1" "@types/bn.js": "npm:^5.1.5" "@types/jest": "npm:^27.4.1" @@ -2398,6 +2403,7 @@ __metadata: "@types/node": "npm:^16.18.54" "@types/uuid": "npm:^8.3.0" async-mutex: "npm:^0.5.0" + bitcoin-address-validation: "npm:^2.2.3" bn.js: "npm:^5.2.1" cockatiel: "npm:^3.1.2" deepmerge: "npm:^4.2.2" @@ -3266,7 +3272,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-snap-client@npm:^1.1.0": +"@metamask/keyring-snap-client@npm:^1.0.0, @metamask/keyring-snap-client@npm:^1.1.0": version: 1.1.0 resolution: "@metamask/keyring-snap-client@npm:1.1.0" dependencies: @@ -3856,8 +3862,8 @@ __metadata: linkType: hard "@metamask/snaps-controllers@npm:^9.10.0": - version: 9.12.0 - resolution: "@metamask/snaps-controllers@npm:9.12.0" + version: 9.13.0 + resolution: "@metamask/snaps-controllers@npm:9.13.0" dependencies: "@metamask/approval-controller": "npm:^7.1.1" "@metamask/base-controller": "npm:^7.0.2" @@ -3870,8 +3876,8 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.1" "@metamask/snaps-registry": "npm:^3.2.2" "@metamask/snaps-rpc-methods": "npm:^11.5.1" - "@metamask/snaps-sdk": "npm:^6.10.0" - "@metamask/snaps-utils": "npm:^8.5.0" + "@metamask/snaps-sdk": "npm:^6.11.0" + "@metamask/snaps-utils": "npm:^8.6.0" "@metamask/utils": "npm:^10.0.0" "@xstate/fsm": "npm:^2.0.0" browserify-zlib: "npm:^0.2.0" @@ -3885,11 +3891,11 @@ __metadata: semver: "npm:^7.5.4" tar-stream: "npm:^3.1.7" peerDependencies: - "@metamask/snaps-execution-environments": ^6.9.2 + "@metamask/snaps-execution-environments": ^6.10.0 peerDependenciesMeta: "@metamask/snaps-execution-environments": optional: true - checksum: 10/8d411ff2cfd43e62fe780092e935a1d977379488407b56cca1390edfa9408871cbaf3599f6e6ee999340d46fd3650f225a3270ceec9492c6f2dc4d93538c25ae + checksum: 10/bcf60b61de067f89439cb15acbdf6f808b4bcda8e1cbc9debd693ca2c545c9d38c4e6f380191c4703bd9d28d7dd41e4ce5111664d7b474d5e86e460bcefc3637 languageName: node linkType: hard @@ -3921,22 +3927,22 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-sdk@npm:^6.10.0, @metamask/snaps-sdk@npm:^6.7.0": - version: 6.10.0 - resolution: "@metamask/snaps-sdk@npm:6.10.0" +"@metamask/snaps-sdk@npm:^6.10.0, @metamask/snaps-sdk@npm:^6.11.0, @metamask/snaps-sdk@npm:^6.7.0": + version: 6.11.0 + resolution: "@metamask/snaps-sdk@npm:6.11.0" dependencies: "@metamask/key-tree": "npm:^9.1.2" "@metamask/providers": "npm:^18.1.1" "@metamask/rpc-errors": "npm:^7.0.1" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^10.0.0" - checksum: 10/02f04536328a64ff1e9e48fb6b109698d6d83f42af5666a9758ccb1e7a1e67c0c2e296ef2fef419dd3d1c8f26bbf30b9f31911a1baa66f044f21cd0ecb7a11a7 + checksum: 10/0f9b507139d1544b1b3d85ff8de81b800d543012d3ee9414c607c23abe9562e0dca48de089ed94be69f5ad981730a0f443371edfe6bc2d5ffb140b28e437bfd2 languageName: node linkType: hard -"@metamask/snaps-utils@npm:^8.3.0, @metamask/snaps-utils@npm:^8.5.0": - version: 8.5.2 - resolution: "@metamask/snaps-utils@npm:8.5.2" +"@metamask/snaps-utils@npm:^8.3.0, @metamask/snaps-utils@npm:^8.5.0, @metamask/snaps-utils@npm:^8.6.0": + version: 8.6.0 + resolution: "@metamask/snaps-utils@npm:8.6.0" dependencies: "@babel/core": "npm:^7.23.2" "@babel/types": "npm:^7.23.0" @@ -3946,7 +3952,7 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.1" "@metamask/slip44": "npm:^4.0.0" "@metamask/snaps-registry": "npm:^3.2.2" - "@metamask/snaps-sdk": "npm:^6.10.0" + "@metamask/snaps-sdk": "npm:^6.11.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^10.0.0" "@noble/hashes": "npm:^1.3.1" @@ -3961,7 +3967,7 @@ __metadata: semver: "npm:^7.5.4" ses: "npm:^1.1.0" validate-npm-package-name: "npm:^5.0.0" - checksum: 10/e5d1344f948473e82d71007d2570272073cf070f40aa7746692a6d5e6f02cfce66a747cf50f439d32b29a3f6588486182453b26973f0d0c1d9f47914591d5790 + checksum: 10/c0f538f3f95e1875f6557b6ecc32f981bc4688d581af8cdc62c6c3ab8951c138286cd0b2d1cd82f769df24fcec10f71dcda67ae9a47edcff9ff73d52672df191 languageName: node linkType: hard @@ -6280,6 +6286,13 @@ __metadata: languageName: node linkType: hard +"base58-js@npm:^1.0.0": + version: 1.0.5 + resolution: "base58-js@npm:1.0.5" + checksum: 10/46c1b39d3a70bca0a47d56069c74a25d547680afd0f28609c90f280f5d614f5de36db5df993fa334db24008a68ab784a72fcdaa13eb40078e03c8999915a1100 + languageName: node + linkType: hard + "base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -6320,6 +6333,17 @@ __metadata: languageName: node linkType: hard +"bitcoin-address-validation@npm:^2.2.3": + version: 2.2.3 + resolution: "bitcoin-address-validation@npm:2.2.3" + dependencies: + base58-js: "npm:^1.0.0" + bech32: "npm:^2.0.0" + sha256-uint8array: "npm:^0.10.3" + checksum: 10/01603b5edf610ecf0843ae546534313f1cffabc8e7435a3678bc9788f18a54e51302218a539794aafd49beb5be70b5d1d507eb7442cb33970fcd665592a71305 + languageName: node + linkType: hard + "bl@npm:^4.0.3": version: 4.1.0 resolution: "bl@npm:4.1.0" @@ -12058,6 +12082,13 @@ __metadata: languageName: node linkType: hard +"sha256-uint8array@npm:^0.10.3": + version: 0.10.7 + resolution: "sha256-uint8array@npm:0.10.7" + checksum: 10/e427f9d2f9c521dea552f033d3f0c3bd641ab214d214dd41bde3c805edde393519cf982b3eee7d683b32e5f28fa23b2278d25935940e13fbe831b216a37832be + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0"