diff --git a/packages/keyring-api/src/api/account.ts b/packages/keyring-api/src/api/account.ts index 647909a2a..f0cc4661b 100644 --- a/packages/keyring-api/src/api/account.ts +++ b/packages/keyring-api/src/api/account.ts @@ -1,4 +1,4 @@ -import { object, UuidStruct } from '@metamask/keyring-utils'; +import { AccountIdStruct, object } from '@metamask/keyring-utils'; import type { Infer } from '@metamask/superstruct'; import { nonempty, @@ -56,7 +56,7 @@ export const KeyringAccountStruct = object({ /** * Account ID (UUIDv4). */ - id: UuidStruct, + id: AccountIdStruct, /** * Account type. diff --git a/packages/keyring-api/src/api/asset.ts b/packages/keyring-api/src/api/asset.ts index f46623a7e..154a33905 100644 --- a/packages/keyring-api/src/api/asset.ts +++ b/packages/keyring-api/src/api/asset.ts @@ -11,6 +11,21 @@ import { isPlainObject, } from '@metamask/utils'; +/** + * Fungible asset amount struct. + */ +export const FungibleAssetAmountStruct = object({ + /** + * Asset unit. + */ + unit: string(), + + /** + * Asset amount. + */ + amount: StringNumberStruct, +}); + /** * Fungible asset struct. */ @@ -25,15 +40,7 @@ export const FungibleAssetStruct = object({ */ type: CaipAssetTypeStruct, - /** - * Asset unit. - */ - unit: string(), - - /** - * Asset amount. - */ - amount: StringNumberStruct, + ...FungibleAssetAmountStruct.schema, }); /** diff --git a/packages/keyring-api/src/events.ts b/packages/keyring-api/src/events.ts index b5e433f6c..c3c95295e 100644 --- a/packages/keyring-api/src/events.ts +++ b/packages/keyring-api/src/events.ts @@ -1,8 +1,19 @@ -import { exactOptional, object, UuidStruct } from '@metamask/keyring-utils'; -import { boolean, literal, string } from '@metamask/superstruct'; -import { JsonStruct } from '@metamask/utils'; +import { + exactOptional, + object, + UuidStruct, + AccountIdStruct, +} from '@metamask/keyring-utils'; +import type { Infer } from '@metamask/superstruct'; +import { array, boolean, literal, record, string } from '@metamask/superstruct'; +import { CaipAssetTypeStruct, JsonStruct } from '@metamask/utils'; -import { KeyringAccountStruct } from './api'; +import { + CaipAssetTypeOrIdStruct, + FungibleAssetAmountStruct, + KeyringAccountStruct, + TransactionStruct, +} from './api'; /** * Supported keyring events. @@ -16,6 +27,11 @@ export enum KeyringEvent { // Request events RequestApproved = 'notify:requestApproved', RequestRejected = 'notify:requestRejected', + + // Assets related events + AccountBalancesUpdated = 'notify:accountBalancesUpdated', + AccountAssetListUpdated = 'notify:accountAssetListUpdated', + AccountTransactionsUpdated = 'notify:accountTransactionsUpdated', } export const AccountCreatedEventStruct = object({ @@ -87,3 +103,126 @@ export const RequestRejectedEventStruct = object({ id: UuidStruct, }), }); + +// Assets related events: +// ----------------------------------------------------------------------------------------------- + +export const AccountBalancesUpdatedEventStruct = object({ + method: literal(`${KeyringEvent.AccountBalancesUpdated}`), + params: object({ + /** + * Balances updates of accounts owned by the Snap. + */ + balances: record( + /** + * Account ID. + */ + AccountIdStruct, + + /** + * Mapping of each owned assets and their respective balances for that account. + */ + record( + /** + * Asset type (CAIP-19). + */ + CaipAssetTypeStruct, + + /** + * Balance information for a given asset. + */ + FungibleAssetAmountStruct, + ), + ), + }), +}); + +/** + * Event emitted when the balances of an account are updated. + * + * Only changes are reported. + * + * The Snap can choose to emit this event for multiple accounts at once. + */ +export type AccountBalancesUpdatedEvent = Infer< + typeof AccountBalancesUpdatedEventStruct +>; +export type AccountBalancesUpdatedEventPayload = + AccountBalancesUpdatedEvent['params']; + +export const AccountTransactionsUpdatedEventStruct = object({ + method: literal(`${KeyringEvent.AccountTransactionsUpdated}`), + params: object({ + /** + * Transactions updates of accounts owned by the Snap. + */ + transactions: record( + /** + * Account ID. + */ + AccountIdStruct, + + /** + * List of updated transactions for that account. + */ + array(TransactionStruct), + ), + }), +}); + +/** + * Event emitted when the transactions of an account are updated (added or + * changed). + * + * Only changes are reported. + * + * The Snap can choose to emit this event for multiple accounts at once. + */ +export type AccountTransactionsUpdatedEvent = Infer< + typeof AccountTransactionsUpdatedEventStruct +>; +export type AccountTransactionsUpdatedEventPayload = + AccountTransactionsUpdatedEvent['params']; + +export const AccountAssetListUpdatedEventStruct = object({ + method: literal(`${KeyringEvent.AccountAssetListUpdated}`), + params: object({ + /** + * Asset list update of accounts owned by the Snap. + */ + assets: record( + /** + * Account ID. + */ + AccountIdStruct, + + /** + * Asset list changes for that account. + */ + object({ + /** + * New assets detected. + */ + added: array(CaipAssetTypeOrIdStruct), + + /** + * Assets no longer available on that account. + */ + removed: array(CaipAssetTypeOrIdStruct), + }), + ), + }), +}); + +/** + * Event emitted when the assets of an account are updated. + * + * Only changes are reported. + * + * The Snap can choose to emit this event for multiple accounts at once. + */ +export type AccountAssetListUpdatedEvent = Infer< + typeof AccountAssetListUpdatedEventStruct +>; +export type AccountAssetListUpdatedEventPayload = + AccountAssetListUpdatedEvent['params']; diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.test.ts b/packages/keyring-snap-bridge/src/SnapKeyring.test.ts index f53e3499b..1c61145dc 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyring.test.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyring.test.ts @@ -7,6 +7,9 @@ import type { EthBaseUserOperation, EthUserOperation, EthUserOperationPatch, + AccountBalancesUpdatedEventPayload, + AccountTransactionsUpdatedEventPayload, + AccountAssetListUpdatedEventPayload, } from '@metamask/keyring-api'; import { EthScopes, @@ -29,7 +32,7 @@ import type { KeyringAccountV1 } from './account'; import { migrateAccountV1, getScopesForAccountV1 } from './migrations'; import type { SnapKeyringAllowedActions, - SnapKeyringAllowedEvents, + SnapKeyringEvents, SnapKeyringMessenger, } from './SnapKeyringMessenger'; @@ -63,50 +66,6 @@ function noScopes(account: KeyringAccount): KeyringAccountV1 { return accountV1; } -/* -class MockMessenger { - readonly #messenger: SnapKeyringMessenger; - - get: jest.Mock = jest.fn(); - - handleRequest: jest.Mock = jest.fn(); - - constructor( - messenger: ControllerMessenger< - SnapKeyringAllowedActions, - SnapKeyringAllowedEvents - >, - ) { - messenger.registerActionHandler('SnapController:get', this.get); - messenger.registerActionHandler( - 'SnapController:handleRequest', - this.handleRequest, - ); - - this.#messenger = messenger.getRestricted({ - name: 'MockSnapKeyringMessenger', - allowedEvents: [], - allowedActions: ['SnapController:get', 'SnapController:handleRequest'], - }); - } - - getMessenger(): SnapKeyringMessenger { - return this.#messenger; - } - - call(action: string, ...args: any): unknown { - switch (action) { - case 'SnapController:get': - return this.get(...args); - case 'SnapController:handleRequest': - return this.handleRequest(...args); - default: - throw new Error(`Unexpected action call: ${action}`); - } - } -} -*/ - describe('SnapKeyring', () => { let keyring: SnapKeyring; @@ -226,7 +185,7 @@ describe('SnapKeyring', () => { // Fake the ControllerMessenger and registers all mock actions here: const controllerMessenger: ControllerMessenger< SnapKeyringAllowedActions, - SnapKeyringAllowedEvents + SnapKeyringEvents > = new ControllerMessenger(); controllerMessenger.registerActionHandler( 'SnapController:get', @@ -240,7 +199,7 @@ describe('SnapKeyring', () => { // Now extracts a rectricted messenger for the Snap keyring only. const mockSnapKeyringMessenger: SnapKeyringMessenger = controllerMessenger.getRestricted({ - name: 'MockSnapKeyringMessenger', + name: 'SnapKeyring', allowedEvents: [], allowedActions: ['SnapController:get', 'SnapController:handleRequest'], }); @@ -491,6 +450,109 @@ describe('SnapKeyring', () => { 'Account scopes is required for non-EVM and ERC4337 accounts', ); }); + + it('receives an account balances update event and re-publish it to the messenger', async () => { + const mockPublishedEventCallback = jest.fn(); + mockSnapKeyringMessenger.subscribe( + 'SnapKeyring:accountBalancesUpdated', + mockPublishedEventCallback, + ); + + const account = ethEoaAccount1; + const event: AccountBalancesUpdatedEventPayload = { + balances: { + [account.id]: { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + amount: '0.1', + unit: 'BTC', + }, + }, + }, + }; + + await keyring.handleKeyringSnapMessage(snapId, { + method: KeyringEvent.AccountBalancesUpdated, + params: event, + }); + expect(mockPublishedEventCallback).toHaveBeenCalledWith(event); + }); + + it('receives an transactions update event and re-publish it to the messenger', async () => { + const mockPublishedEventCallback = jest.fn(); + mockSnapKeyringMessenger.subscribe( + 'SnapKeyring:accountTransactionsUpdated', + mockPublishedEventCallback, + ); + + const account = ethEoaAccount1; + const event: AccountTransactionsUpdatedEventPayload = { + transactions: { + [account.id]: [ + { + id: 'f5d8ee39a430901c91a5917b9f2dc19d6d1a0e9cea205b009ca73dd04470b9a6', + timestamp: null, + chain: 'eip155:1', + status: 'submitted', + type: 'receive', + account: '5cd17616-ea18-4d72-974f-6dbaa3c56d15', + from: [], + to: [], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: 'eip155:1/slip44:60', + unit: 'ETH', + amount: '0.0001', + }, + }, + { + type: 'priority', + asset: { + fungible: true, + type: 'eip155:1/slip44:60', + unit: 'ETH', + amount: '0.0001', + }, + }, + ], + events: [], + }, + ], + }, + }; + + await keyring.handleKeyringSnapMessage(snapId, { + method: KeyringEvent.AccountTransactionsUpdated, + params: event, + }); + expect(mockPublishedEventCallback).toHaveBeenCalledWith(event); + }); + + it('receives an asset list update event and re-publish it to the messenger', async () => { + const mockPublishedEventCallback = jest.fn(); + mockSnapKeyringMessenger.subscribe( + 'SnapKeyring:accountAssetListUpdated', + mockPublishedEventCallback, + ); + + const account = ethEoaAccount1; + const event: AccountAssetListUpdatedEventPayload = { + assets: { + [account.id]: { + added: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], + removed: ['bip122:000000000933ea01ad0ee984209779ba/slip44:0'], + }, + }, + }; + + await keyring.handleKeyringSnapMessage(snapId, { + method: KeyringEvent.AccountAssetListUpdated, + params: event, + }); + expect(mockPublishedEventCallback).toHaveBeenCalledWith(event); + }); }); describe('#handleAccountUpdated', () => { diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.ts b/packages/keyring-snap-bridge/src/SnapKeyring.ts index 43a2c404d..4783a5c51 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyring.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyring.ts @@ -4,6 +4,7 @@ import type { TypedTransaction } from '@ethereumjs/tx'; import { TransactionFactory } from '@ethereumjs/tx'; +import type { ExtractEventPayload } from '@metamask/base-controller'; import type { TypedDataV1, TypedMessage } from '@metamask/eth-sig-util'; import { SignTypedDataVersion } from '@metamask/eth-sig-util'; import { @@ -13,6 +14,9 @@ import { EthUserOperationPatchStruct, isEvmAccountType, KeyringEvent, + AccountAssetListUpdatedEventStruct, + AccountBalancesUpdatedEventStruct, + AccountTransactionsUpdatedEventStruct, } from '@metamask/keyring-api'; import type { KeyringAccount, @@ -54,7 +58,10 @@ import { transformAccountV1, } from './migrations'; import { SnapIdMap } from './SnapIdMap'; -import type { SnapKeyringMessenger } from './SnapKeyringMessenger'; +import type { + SnapKeyringEvents, + SnapKeyringMessenger, +} from './SnapKeyringMessenger'; import type { SnapMessage } from './types'; import { SnapMessageStruct } from './types'; import { @@ -349,6 +356,65 @@ export class SnapKeyring extends EventEmitter { return null; } + /** + * Re-publish an account event. + * + * @param event - The event type. This is a unique identifier for this event. + * @param payload - The event payload. The type of the parameters for each event handler must + * match the type of this payload. + * @template EventType - A Snap keyring event type. + * @returns `null`. + */ + async #rePublishAccountEvent( + event: EventType, + ...payload: ExtractEventPayload + ): Promise { + this.#messenger.publish(event, ...payload); + return null; + } + + /** + * Handle a balances updated event from a Snap. + * + * @param message - Event message. + * @returns `null`. + */ + async #handleAccountBalancesUpdated(message: SnapMessage): Promise { + assert(message, AccountBalancesUpdatedEventStruct); + return this.#rePublishAccountEvent( + 'SnapKeyring:accountBalancesUpdated', + message.params, + ); + } + + /** + * Handle a asset list updated event from a Snap. + * + * @param message - Event message. + * @returns `null`. + */ + async #handleAccountAssetListUpdated(message: SnapMessage): Promise { + assert(message, AccountAssetListUpdatedEventStruct); + return this.#rePublishAccountEvent( + 'SnapKeyring:accountAssetListUpdated', + message.params, + ); + } + + /** + * Handle a transactions updated event from a Snap. + * + * @param message - Event message. + * @returns `null`. + */ + async #handleAccountTransactionsUpdated(message: SnapMessage): Promise { + assert(message, AccountTransactionsUpdatedEventStruct); + return this.#rePublishAccountEvent( + 'SnapKeyring:accountTransactionsUpdated', + message.params, + ); + } + /** * Handle a message from a Snap. * @@ -382,6 +448,19 @@ export class SnapKeyring extends EventEmitter { return this.#handleRequestRejected(snapId, message); } + // Assets related events: + case `${KeyringEvent.AccountBalancesUpdated}`: { + return this.#handleAccountBalancesUpdated(message); + } + + case `${KeyringEvent.AccountAssetListUpdated}`: { + return this.#handleAccountAssetListUpdated(message); + } + + case `${KeyringEvent.AccountTransactionsUpdated}`: { + return this.#handleAccountTransactionsUpdated(message); + } + default: throw new Error(`Method not supported: ${message.method}`); } diff --git a/packages/keyring-snap-bridge/src/SnapKeyringMessenger.ts b/packages/keyring-snap-bridge/src/SnapKeyringMessenger.ts index 9932b3673..8f1e4226e 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyringMessenger.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyringMessenger.ts @@ -1,14 +1,42 @@ import type { RestrictedControllerMessenger } from '@metamask/base-controller'; +import type { + AccountAssetListUpdatedEventPayload, + AccountBalancesUpdatedEventPayload, + AccountTransactionsUpdatedEventPayload, +} from '@metamask/keyring-api'; import type { HandleSnapRequest, GetSnap } from '@metamask/snaps-controllers'; -export type SnapKeyringAllowedActions = HandleSnapRequest | GetSnap; +export type SnapKeyringGetAccountsAction = { + type: `SnapKeyring:getAccounts`; + handler: () => string[]; +}; + +export type SnapKeyringAccountBalancesUpdatedEvent = { + type: `SnapKeyring:accountBalancesUpdated`; + payload: [AccountBalancesUpdatedEventPayload]; +}; + +export type SnapKeyringAccountAssetListUpdatedEvent = { + type: `SnapKeyring:accountAssetListUpdated`; + payload: [AccountAssetListUpdatedEventPayload]; +}; -export type SnapKeyringAllowedEvents = never; +export type SnapKeyringAccountTransactionsUpdatedEvent = { + type: `SnapKeyring:accountTransactionsUpdated`; + payload: [AccountTransactionsUpdatedEventPayload]; +}; + +export type SnapKeyringEvents = + | SnapKeyringAccountAssetListUpdatedEvent + | SnapKeyringAccountBalancesUpdatedEvent + | SnapKeyringAccountTransactionsUpdatedEvent; + +export type SnapKeyringAllowedActions = HandleSnapRequest | GetSnap; export type SnapKeyringMessenger = RestrictedControllerMessenger< - 'SnapKeyringMessenger', + 'SnapKeyring', SnapKeyringAllowedActions, - SnapKeyringAllowedEvents, + SnapKeyringEvents, SnapKeyringAllowedActions['type'], - SnapKeyringAllowedEvents['type'] + never >; diff --git a/packages/keyring-snap-bridge/src/index.ts b/packages/keyring-snap-bridge/src/index.ts index d01d020e5..ceb4da7e1 100644 --- a/packages/keyring-snap-bridge/src/index.ts +++ b/packages/keyring-snap-bridge/src/index.ts @@ -1,2 +1,3 @@ export * from './types'; export * from './SnapKeyring'; +export type * from './SnapKeyringMessenger'; diff --git a/packages/keyring-utils/src/types.ts b/packages/keyring-utils/src/types.ts index f00db535c..89d2dde62 100644 --- a/packages/keyring-utils/src/types.ts +++ b/packages/keyring-utils/src/types.ts @@ -9,6 +9,10 @@ export const UuidStruct = definePattern( 'UuidV4', /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu, ); +/** + * Account ID (UUIDv4). + */ +export const AccountIdStruct = UuidStruct; // Alias for better naming purposes. /** * Validates if a given value is a valid URL.