diff --git a/packages/examples/packages/dialogs/snap.manifest.json b/packages/examples/packages/dialogs/snap.manifest.json index 7cc8b37f2a..9162194b5b 100644 --- a/packages/examples/packages/dialogs/snap.manifest.json +++ b/packages/examples/packages/dialogs/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "uCUq9PpdqnbX61m+h7IfPWoZafKw3gsb6dvSLJse63k=", + "shasum": "vvzCJ8B00GXoZDq0/ZeMzguNiPRmim1qcbD27rbYH8o=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/file-upload/snap.manifest.json b/packages/examples/packages/file-upload/snap.manifest.json index 6ca1cd840d..395b416144 100644 --- a/packages/examples/packages/file-upload/snap.manifest.json +++ b/packages/examples/packages/file-upload/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "4k6xK8xISA7UOVam45VhyZbIsnivQetJf+zYSliy0uE=", + "shasum": "YOWrI9oMzaIWvmV9LgcN26us5sYglQDbBX07W6XljnY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/home-page/snap.manifest.json b/packages/examples/packages/home-page/snap.manifest.json index 2a6a3607ee..475d1de2fa 100644 --- a/packages/examples/packages/home-page/snap.manifest.json +++ b/packages/examples/packages/home-page/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "XMyDMaJ0vTcAHOZLQ47QaJ5vvpJZ7gdJ1prS96WuF5A=", + "shasum": "z5wfUL3L05s9oQcF9DqPC51v52oJAMm6xKSg1G+Oybw=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/interactive-ui/snap.manifest.json b/packages/examples/packages/interactive-ui/snap.manifest.json index 290bda0ed3..4039718d60 100644 --- a/packages/examples/packages/interactive-ui/snap.manifest.json +++ b/packages/examples/packages/interactive-ui/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "drB7P0k6b6CJojJ2jGPD7WIaDQBSIXIn0+0DeK+8GMQ=", + "shasum": "f5KnTUI4ZIwhE+lJrFhfiOQfx1ZmtoFnkmzKKcPXEdo=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/jsx/snap.manifest.json b/packages/examples/packages/jsx/snap.manifest.json index b94417e1f6..648ab6712d 100644 --- a/packages/examples/packages/jsx/snap.manifest.json +++ b/packages/examples/packages/jsx/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "19gK/VOwtR4It+Okzr3egNGj0+rg3LdI1Up9iXxFAKE=", + "shasum": "9qEMnlcHK3ETO9tVCsjN7c6NGvAPJJHZmPC/Mcp4TIs=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/preinstalled/snap.manifest.json b/packages/examples/packages/preinstalled/snap.manifest.json index 6d6dea7858..49110169b7 100644 --- a/packages/examples/packages/preinstalled/snap.manifest.json +++ b/packages/examples/packages/preinstalled/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "I6UW3+TF1AWBF62h/1NiJNuKG1IA2R/R3o3xHzJCdrA=", + "shasum": "tUcUWcyZ9IOY/BYxXSrTfMFcn96KBE2UUNTBVahNT/Y=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/send-flow/snap.manifest.json b/packages/examples/packages/send-flow/snap.manifest.json index 33562448f5..3aaa56dc5b 100644 --- a/packages/examples/packages/send-flow/snap.manifest.json +++ b/packages/examples/packages/send-flow/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "6lChQQfDqYhY5N/FRsogbHI8+a4eWJuojcRS2yRi66Q=", + "shasum": "CJT5E1MntaPZkK6LBvAayvsc0q1AVGVVMRGh0gkxRHY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index a9b6365a07..dffd65d6ce 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 94.97, - "functions": 98.38, - "lines": 98.76, - "statements": 98.59 + "branches": 95.14, + "functions": 98.43, + "lines": 98.79, + "statements": 98.62 } diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx b/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx index e703024069..f257a77320 100644 --- a/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx +++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx @@ -10,6 +10,7 @@ import { } from '@metamask/snaps-sdk'; import { AssetSelector, + AccountSelector, Box, Field, FileInput, @@ -27,6 +28,7 @@ import { MOCK_SNAP_ID } from '@metamask/snaps-utils/test-utils'; import { SnapInterfaceController } from './SnapInterfaceController'; import { + MOCK_ACCOUNT_ID, MockApprovalController, getRestrictedSnapInterfaceControllerMessenger, getRootSnapInterfaceControllerMessenger, @@ -243,7 +245,213 @@ describe('SnapInterfaceController', () => { ); expect(content).toStrictEqual(element); - expect(state).toStrictEqual({ foo: { bar: null } }); + expect(state).toStrictEqual({ + foo: { + bar: null, + }, + }); + }); + + it('can retrieve the selected account from the client', async () => { + const rootMessenger = getRootSnapInterfaceControllerMessenger(); + const controllerMessenger = + getRestrictedSnapInterfaceControllerMessenger(rootMessenger); + + // eslint-disable-next-line no-new + new SnapInterfaceController({ + messenger: controllerMessenger, + }); + + const element = ( + + + + ); + + const id = await rootMessenger.call( + 'SnapInterfaceController:createInterface', + MOCK_SNAP_ID, + element, + ); + + const { content, state } = rootMessenger.call( + 'SnapInterfaceController:getInterface', + MOCK_SNAP_ID, + id, + ); + + expect(rootMessenger.call).toHaveBeenNthCalledWith( + 2, + 'AccountsController:getSelectedMultichainAccount', + ); + + expect(content).toStrictEqual(element); + expect(state).toStrictEqual({ + foo: { + accountId: MOCK_ACCOUNT_ID, + addresses: ['eip155:0:0x1234567890123456789012345678901234567890'], + }, + }); + }); + + it('can select an account owned by the snap', async () => { + const rootMessenger = getRootSnapInterfaceControllerMessenger(); + const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger( + rootMessenger, + false, + ); + + rootMessenger.registerActionHandler( + 'AccountsController:getSelectedMultichainAccount', + () => ({ + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:0'], + metadata: { + // @ts-expect-error partial mock + snap: { + id: 'npm:foo@1.0.0' as SnapId, + }, + }, + }), + ); + + rootMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => [ + { + id: MOCK_ACCOUNT_ID, + address: '7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + metadata: { + // @ts-expect-error partial mock + snap: { + id: MOCK_SNAP_ID, + }, + }, + }, + ], + ); + + // eslint-disable-next-line no-new + new SnapInterfaceController({ + messenger: controllerMessenger, + }); + + const element = ( + + + + ); + + const id = await rootMessenger.call( + 'SnapInterfaceController:createInterface', + MOCK_SNAP_ID, + element, + ); + + const { content, state } = rootMessenger.call( + 'SnapInterfaceController:getInterface', + MOCK_SNAP_ID, + id, + ); + + expect(rootMessenger.call).toHaveBeenNthCalledWith( + 2, + 'AccountsController:getSelectedMultichainAccount', + ); + + expect(rootMessenger.call).toHaveBeenNthCalledWith( + 3, + 'AccountsController:listMultichainAccounts', + ); + + expect(content).toStrictEqual(element); + expect(state).toStrictEqual({ + foo: { + accountId: MOCK_ACCOUNT_ID, + addresses: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + }, + }); + }); + + it('can get accounts of a specific chain ID from the client', async () => { + const rootMessenger = getRootSnapInterfaceControllerMessenger(); + const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger( + rootMessenger, + false, + ); + + rootMessenger.registerActionHandler( + 'AccountsController:getSelectedMultichainAccount', + // @ts-expect-error partial mock + () => ({ + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:0'], + }), + ); + + rootMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => [ + // @ts-expect-error partial mock + { + id: MOCK_ACCOUNT_ID, + address: '7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + }, + ], + ); + + // eslint-disable-next-line no-new + new SnapInterfaceController({ + messenger: controllerMessenger, + }); + + const element = ( + + + + ); + + const id = await rootMessenger.call( + 'SnapInterfaceController:createInterface', + MOCK_SNAP_ID, + element, + ); + + const { content, state } = rootMessenger.call( + 'SnapInterfaceController:getInterface', + MOCK_SNAP_ID, + id, + ); + + expect(rootMessenger.call).toHaveBeenNthCalledWith( + 2, + 'AccountsController:getSelectedMultichainAccount', + ); + + expect(rootMessenger.call).toHaveBeenNthCalledWith( + 3, + 'AccountsController:listMultichainAccounts', + ); + + expect(content).toStrictEqual(element); + expect(state).toStrictEqual({ + foo: { + accountId: MOCK_ACCOUNT_ID, + addresses: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + }, + }); }); it('supports providing interface context', async () => { @@ -1164,6 +1372,94 @@ describe('SnapInterfaceController', () => { ), ).rejects.toThrow('Interface not created by foo.'); }); + + it('can select an account owned by the snap', async () => { + const rootMessenger = getRootSnapInterfaceControllerMessenger(); + const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger( + rootMessenger, + false, + ); + + rootMessenger.registerActionHandler( + 'AccountsController:getSelectedMultichainAccount', + () => ({ + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:0'], + metadata: { + // @ts-expect-error partial mock + snap: { + id: 'npm:foo@1.0.0' as SnapId, + }, + }, + }), + ); + + rootMessenger.registerActionHandler( + 'AccountsController:getAccountByAddress', + () => ({ + id: MOCK_ACCOUNT_ID, + address: '7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + metadata: { + // @ts-expect-error partial mock + snap: { + id: MOCK_SNAP_ID, + }, + }, + }), + ); + + // eslint-disable-next-line no-new + new SnapInterfaceController({ + messenger: controllerMessenger, + }); + + const element = ( + + + + ); + + const newElement = ( + + + + ); + + const id = await rootMessenger.call( + 'SnapInterfaceController:createInterface', + MOCK_SNAP_ID, + element, + ); + + await rootMessenger.call( + 'SnapInterfaceController:updateInterface', + MOCK_SNAP_ID, + id, + newElement, + ); + + const { content, state } = rootMessenger.call( + 'SnapInterfaceController:getInterface', + MOCK_SNAP_ID, + id, + ); + + expect(content).toStrictEqual(newElement); + expect(state).toStrictEqual({ + foo: { + accountId: MOCK_ACCOUNT_ID, + addresses: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + }, + }); + }); }); describe('updateInterfaceState', () => { diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts index f8143fd2c5..30f5ae61c0 100644 --- a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts +++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts @@ -19,8 +19,17 @@ import type { import { ContentType } from '@metamask/snaps-sdk'; import type { JSXElement } from '@metamask/snaps-sdk/jsx'; import type { InternalAccount } from '@metamask/snaps-utils'; -import { getJsonSizeUnsafe, validateJsxElements } from '@metamask/snaps-utils'; -import type { CaipAccountId, CaipAssetType, Json } from '@metamask/utils'; +import { + getJsonSizeUnsafe, + snapOwnsAccount, + validateJsxElements, +} from '@metamask/snaps-utils'; +import type { + CaipAccountId, + CaipAssetType, + CaipChainId, + Json, +} from '@metamask/utils'; import { assert, hasProperty, parseCaipAccountId } from '@metamask/utils'; import { castDraft } from 'immer'; import { nanoid } from 'nanoid'; @@ -28,6 +37,7 @@ import { nanoid } from 'nanoid'; import { constructState, getJsxInterface, + isMatchingChainId, validateInterfaceContext, } from './utils'; import type { GetSnap } from '../snaps'; @@ -71,6 +81,16 @@ type AccountsControllerGetAccountByAddressAction = { handler: (address: string) => InternalAccount | undefined; }; +type AccountsControllerGetSelectedMultichainAccountAction = { + type: `AccountsController:getSelectedMultichainAccount`; + handler: () => InternalAccount; +}; + +type AccountsControllerListMultichainAccountsAction = { + type: `AccountsController:listMultichainAccounts`; + handler: (chainId?: CaipChainId) => InternalAccount[]; +}; + export type SnapInterfaceControllerGetStateAction = ControllerGetStateAction< typeof controllerName, SnapInterfaceControllerState @@ -92,7 +112,9 @@ export type SnapInterfaceControllerAllowedActions = | AcceptRequest | GetSnap | MultichainAssetsControllerGetStateAction - | AccountsControllerGetAccountByAddressAction; + | AccountsControllerGetSelectedMultichainAccountAction + | AccountsControllerGetAccountByAddressAction + | AccountsControllerListMultichainAccountsAction; export type SnapInterfaceControllerActions = | CreateInterface @@ -267,6 +289,10 @@ export class SnapInterfaceController extends BaseController< const componentState = constructState({}, element, { getAssetsState: this.#getAssetsState.bind(this), getAccountByAddress: this.#getAccountByAddress.bind(this), + getSelectedAccount: this.#getSelectedAccount.bind(this), + listAccounts: this.#listAccounts.bind(this), + snapOwnsAccount: (account: InternalAccount) => + snapOwnsAccount(snapId, account), }); this.update((draftState) => { @@ -320,6 +346,10 @@ export class SnapInterfaceController extends BaseController< const newState = constructState(oldState, element, { getAssetsState: this.#getAssetsState.bind(this), getAccountByAddress: this.#getAccountByAddress.bind(this), + getSelectedAccount: this.#getSelectedAccount.bind(this), + listAccounts: this.#listAccounts.bind(this), + snapOwnsAccount: (account: InternalAccount) => + snapOwnsAccount(snapId, account), }); this.update((draftState) => { @@ -440,6 +470,37 @@ export class SnapInterfaceController extends BaseController< ); } + /** + * Get the selected account in the client. + * + * @returns The selected account. + */ + #getSelectedAccount() { + return this.messagingSystem.call( + 'AccountsController:getSelectedMultichainAccount', + ); + } + + /** + * Get a list of accounts for the given chain IDs. + * + * @param chainIds - The chain IDs to get the accounts for. + * @returns The list of accounts. + */ + #listAccounts(chainIds?: CaipChainId[]) { + const accounts = this.messagingSystem.call( + 'AccountsController:listMultichainAccounts', + ); + + if (!chainIds || chainIds.length === 0) { + return accounts; + } + + return accounts.filter((account) => + account.scopes.some((scope) => isMatchingChainId(scope, chainIds)), + ); + } + /** * Get an account by its address. * diff --git a/packages/snaps-controllers/src/interface/utils.test.tsx b/packages/snaps-controllers/src/interface/utils.test.tsx index 4a98be1ef5..159c4bbfb5 100644 --- a/packages/snaps-controllers/src/interface/utils.test.tsx +++ b/packages/snaps-controllers/src/interface/utils.test.tsx @@ -17,15 +17,22 @@ import { SelectorOption, AssetSelector, AddressInput, + AccountSelector, } from '@metamask/snaps-sdk/jsx'; +import type { CaipAccountId } from '@metamask/utils'; +import { parseCaipAccountId } from '@metamask/utils'; import { assertNameIsUnique, constructState, + formatAccountSelectorStateValue, + getAccountSelectorDefaultStateValue, + getAccountSelectorStateValue, getAssetSelectorStateValue, getDefaultAsset, getJsxInterface, isStatefulComponent, + isMatchingChainId, } from './utils'; import { MOCK_ACCOUNT_ID } from '../test-utils'; @@ -77,6 +84,10 @@ describe('constructState', () => { const elementDataGetters = { getAssetsState: jest.fn(), getAccountByAddress: jest.fn(), + getSelectedAccount: jest.fn(), + + listAccounts: jest.fn(), + snapOwnsAccount: jest.fn(), }; it('can construct a new component state', () => { @@ -620,6 +631,184 @@ describe('constructState', () => { }); }); + it('sets default value for root level AccountSelector', () => { + elementDataGetters.getSelectedAccount.mockReturnValue({ + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:0'], + }); + + elementDataGetters.getAccountByAddress.mockImplementation( + (caipAccountId: CaipAccountId) => { + const { address } = parseCaipAccountId(caipAccountId); + return { + id: MOCK_ACCOUNT_ID, + address, + scopes: ['eip155:0'], + }; + }, + ); + + const element = ( + + + + ); + + const result = constructState({}, element, elementDataGetters); + expect(result).toStrictEqual({ + foo: { + accountId: MOCK_ACCOUNT_ID, + addresses: ['eip155:0:0x1234567890123456789012345678901234567890'], + }, + }); + }); + + it('supports root level AccountSelector', () => { + elementDataGetters.getSelectedAccount.mockReturnValue({ + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:0'], + }); + + elementDataGetters.getAccountByAddress.mockImplementation( + (caipAccountId: CaipAccountId) => { + const { address } = parseCaipAccountId(caipAccountId); + return { + id: MOCK_ACCOUNT_ID, + address, + scopes: ['eip155:0'], + }; + }, + ); + + const element = ( + + + + ); + + const result = constructState({}, element, elementDataGetters); + expect(result).toStrictEqual({ + foo: { + accountId: MOCK_ACCOUNT_ID, + addresses: ['eip155:0:0x1234567890123456789012345678901234567890'], + }, + }); + }); + + it('sets default value for AccountSelector in form', () => { + elementDataGetters.getSelectedAccount.mockReturnValue({ + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:0'], + }); + + elementDataGetters.getAccountByAddress.mockImplementation( + (caipAccountId: CaipAccountId) => { + const { address } = parseCaipAccountId(caipAccountId); + return { + id: MOCK_ACCOUNT_ID, + address, + scopes: ['eip155:0'], + }; + }, + ); + + const element = ( + +
+ + + +
+
+ ); + + const result = constructState({}, element, elementDataGetters); + expect(result).toStrictEqual({ + form: { + foo: { + accountId: MOCK_ACCOUNT_ID, + addresses: ['eip155:0:0x1234567890123456789012345678901234567890'], + }, + }, + }); + }); + + it('supports AccountSelector in form', () => { + elementDataGetters.getSelectedAccount.mockReturnValue({ + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:0'], + }); + + elementDataGetters.getAccountByAddress.mockImplementation( + (caipAccountId: CaipAccountId) => { + const { address } = parseCaipAccountId(caipAccountId); + return { + id: MOCK_ACCOUNT_ID, + address, + scopes: ['eip155:0'], + }; + }, + ); + + const element = ( + +
+ + + +
+
+ ); + + const result = constructState({}, element, elementDataGetters); + expect(result).toStrictEqual({ + form: { + foo: { + accountId: MOCK_ACCOUNT_ID, + addresses: ['eip155:0:0x1234567890123456789012345678901234567890'], + }, + }, + }); + }); + + it('sets the selected account to the currently selected account if the account does not exist for AccountSelector', () => { + elementDataGetters.getSelectedAccount.mockReturnValue({ + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:0'], + }); + + elementDataGetters.getAccountByAddress.mockReturnValue(undefined); + + const element = ( + + + + ); + + const result = constructState({}, element, elementDataGetters); + + expect(result).toStrictEqual({ + foo: { + accountId: MOCK_ACCOUNT_ID, + addresses: ['eip155:0:0x1234567890123456789012345678901234567890'], + }, + }); + }); + it('sets default value for root level AssetSelector', () => { elementDataGetters.getAssetsState.mockReturnValue({ assetsMetadata: { @@ -1164,7 +1353,13 @@ describe('getDefaultAsset', () => { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ], undefined, - { getAssetsState, getAccountByAddress }, + { + getAssetsState, + getAccountByAddress, + getSelectedAccount: jest.fn(), + listAccounts: jest.fn(), + snapOwnsAccount: jest.fn(), + }, ), ).toStrictEqual({ asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', @@ -1199,7 +1394,13 @@ describe('getDefaultAsset', () => { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ], undefined, - { getAssetsState, getAccountByAddress }, + { + getAssetsState, + getAccountByAddress, + getSelectedAccount: jest.fn(), + listAccounts: jest.fn(), + snapOwnsAccount: jest.fn(), + }, ), ).toStrictEqual({ asset: @@ -1227,7 +1428,13 @@ describe('getDefaultAsset', () => { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ], undefined, - { getAssetsState, getAccountByAddress }, + { + getAssetsState, + getAccountByAddress, + getSelectedAccount: jest.fn(), + listAccounts: jest.fn(), + snapOwnsAccount: jest.fn(), + }, ), ).toBeNull(); }); @@ -1263,7 +1470,13 @@ describe('getDefaultAsset', () => { 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ], ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], - { getAssetsState, getAccountByAddress }, + { + getAssetsState, + getAccountByAddress, + getSelectedAccount: jest.fn(), + listAccounts: jest.fn(), + snapOwnsAccount: jest.fn(), + }, ), ).toStrictEqual({ asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', @@ -1288,7 +1501,13 @@ describe('getDefaultAsset', () => { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ], undefined, - { getAssetsState, getAccountByAddress }, + { + getAssetsState, + getAccountByAddress, + getSelectedAccount: jest.fn(), + listAccounts: jest.fn(), + snapOwnsAccount: jest.fn(), + }, ), ).toThrow( 'Account not found for address: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv.', @@ -1337,3 +1556,345 @@ describe('isStatefulComponent', () => { ).toBe(false); }); }); + +describe('isMatchingChainId', () => { + it('returns true if one of the chain IDs match the scope', () => { + expect( + isMatchingChainId('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ]), + ).toBe(true); + }); + + it('returns false if none of the chain IDs match the scope', () => { + expect( + isMatchingChainId('bip122:000000000019d6689c085ae165831e93', [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ]), + ).toBe(false); + }); + + it('returns true if one of the chain ID has the `eip-155` namespace and the scope is `eip-155:0`', () => { + expect(isMatchingChainId('eip155:0', ['eip155:1'])).toBe(true); + }); +}); + +describe('formatAccountSelectorStateValue', () => { + it('formats the account selector state value', () => { + expect( + // @ts-expect-error partial mock + formatAccountSelectorStateValue({ + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:1', 'eip155:2', 'eip155:3'], + }), + ).toStrictEqual({ + accountId: MOCK_ACCOUNT_ID, + addresses: [ + 'eip155:1:0x1234567890123456789012345678901234567890', + 'eip155:2:0x1234567890123456789012345678901234567890', + 'eip155:3:0x1234567890123456789012345678901234567890', + ], + }); + }); +}); + +describe('getAccountSelectorDefaultStateValue', () => { + it('returns the currently selected account in the client if no chain IDs are defined', () => { + const getSelectedAccount = jest.fn().mockReturnValue({ + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:0'], + }); + expect( + getAccountSelectorDefaultStateValue(, { + getSelectedAccount, + getAccountByAddress: jest.fn(), + getAssetsState: jest.fn(), + listAccounts: jest.fn(), + snapOwnsAccount: jest.fn(), + }), + ).toStrictEqual({ + accountId: MOCK_ACCOUNT_ID, + addresses: ['eip155:0:0x1234567890123456789012345678901234567890'], + }); + }); + + it('returns the currently selected account in the client if chain IDs match', () => { + const getSelectedAccount = jest.fn().mockReturnValue({ + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:0'], + }); + expect( + getAccountSelectorDefaultStateValue( + , + { + getSelectedAccount, + getAccountByAddress: jest.fn(), + getAssetsState: jest.fn(), + listAccounts: jest.fn(), + snapOwnsAccount: jest.fn(), + }, + ), + ).toStrictEqual({ + accountId: MOCK_ACCOUNT_ID, + addresses: ['eip155:1:0x1234567890123456789012345678901234567890'], + }); + }); + + it('returns the first account of the accounts that matches the chain IDs if the selected account is not on the same chain ID', () => { + const getSelectedAccount = jest.fn().mockReturnValue({ + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:1', 'eip155:2', 'eip155:3'], + }); + + const listAccounts = jest.fn().mockReturnValue([ + { + id: MOCK_ACCOUNT_ID, + address: '7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + }, + { + id: MOCK_ACCOUNT_ID, + address: 'DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKUmG5CNSKK', + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + }, + ]); + + expect( + getAccountSelectorDefaultStateValue( + , + { + getSelectedAccount, + getAccountByAddress: jest.fn(), + getAssetsState: jest.fn(), + listAccounts, + snapOwnsAccount: jest.fn(), + }, + ), + ).toStrictEqual({ + accountId: MOCK_ACCOUNT_ID, + addresses: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + }); + }); + + it('returns the currently selected account in the client if the snap owns the account', () => { + const getSelectedAccount = jest.fn().mockReturnValue({ + id: MOCK_ACCOUNT_ID, + address: '7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + }); + + const snapOwnsAccount = jest.fn().mockReturnValue(true); + expect( + getAccountSelectorDefaultStateValue( + , + { + getSelectedAccount, + getAccountByAddress: jest.fn(), + getAssetsState: jest.fn(), + listAccounts: jest.fn(), + snapOwnsAccount, + }, + ), + ).toStrictEqual({ + accountId: MOCK_ACCOUNT_ID, + addresses: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + }); + }); + + it('returns the first account of the accounts that matches the chain IDs and that is owned by the snap if the selected account is not on the same chain ID', () => { + const getSelectedAccount = jest.fn().mockReturnValue({ + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:1', 'eip155:2', 'eip155:3'], + }); + + const listAccounts = jest.fn().mockReturnValue([ + { + id: MOCK_ACCOUNT_ID, + address: '7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + }, + { + id: MOCK_ACCOUNT_ID, + address: 'DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKUmG5CNSKK', + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + }, + ]); + + const snapOwnsAccount = jest.fn().mockReturnValue(true); + + expect( + getAccountSelectorDefaultStateValue( + , + { + getSelectedAccount, + getAccountByAddress: jest.fn(), + getAssetsState: jest.fn(), + listAccounts, + snapOwnsAccount, + }, + ), + ).toStrictEqual({ + accountId: MOCK_ACCOUNT_ID, + addresses: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + }); + }); + + it('returns null if no account is found for the given chain IDs', () => { + const getSelectedAccount = jest.fn().mockReturnValue({ + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:1', 'eip155:2', 'eip155:3'], + }); + + const listAccounts = jest.fn().mockReturnValue([]); + + expect( + getAccountSelectorDefaultStateValue( + , + { + getSelectedAccount, + getAccountByAddress: jest.fn(), + getAssetsState: jest.fn(), + listAccounts, + snapOwnsAccount: jest.fn(), + }, + ), + ).toBeNull(); + }); + + it('returns null if no account that the snap owns is found for the given chain IDs', () => { + const getSelectedAccount = jest.fn().mockReturnValue({ + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:1', 'eip155:2', 'eip155:3'], + }); + + const listAccounts = jest.fn().mockReturnValue([]); + + const snapOwnsAccount = jest.fn().mockReturnValue(false); + + expect( + getAccountSelectorDefaultStateValue( + , + { + getSelectedAccount, + getAccountByAddress: jest.fn(), + getAssetsState: jest.fn(), + listAccounts, + snapOwnsAccount, + }, + ), + ).toBeNull(); + }); +}); + +describe('getAccountSelectorStateValue', () => { + it('returns the account selector state value', () => { + const getAccountByAddress = jest.fn().mockReturnValue({ + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:1', 'eip155:2', 'eip155:3'], + }); + + expect( + getAccountSelectorStateValue( + , + { + getAccountByAddress, + getSelectedAccount: jest.fn(), + getAssetsState: jest.fn(), + listAccounts: jest.fn(), + snapOwnsAccount: jest.fn(), + }, + ), + ).toStrictEqual({ + accountId: MOCK_ACCOUNT_ID, + addresses: [ + 'eip155:1:0x1234567890123456789012345678901234567890', + 'eip155:2:0x1234567890123456789012345678901234567890', + 'eip155:3:0x1234567890123456789012345678901234567890', + ], + }); + }); + + it('returns null if the account is not owned by the snap', () => { + const getAccountByAddress = jest.fn().mockReturnValue({ + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:1', 'eip155:2', 'eip155:3'], + }); + + const snapOwnsAccount = jest.fn().mockReturnValue(false); + + expect( + getAccountSelectorStateValue( + , + { + getAccountByAddress, + getSelectedAccount: jest.fn(), + getAssetsState: jest.fn(), + listAccounts: jest.fn(), + snapOwnsAccount, + }, + ), + ).toBeNull(); + }); + + it('returns null if the account is not found', () => { + const getAccountByAddress = jest.fn().mockReturnValue(undefined); + + expect( + getAccountSelectorStateValue( + , + { + getAccountByAddress, + getSelectedAccount: jest.fn(), + getAssetsState: jest.fn(), + listAccounts: jest.fn(), + snapOwnsAccount: jest.fn(), + }, + ), + ).toBeNull(); + }); +}); diff --git a/packages/snaps-controllers/src/interface/utils.ts b/packages/snaps-controllers/src/interface/utils.ts index 171d2a4573..73d555fc45 100644 --- a/packages/snaps-controllers/src/interface/utils.ts +++ b/packages/snaps-controllers/src/interface/utils.ts @@ -22,10 +22,13 @@ import type { SelectorOptionElement, AssetSelectorElement, AddressInputElement, + AccountSelectorElement, } from '@metamask/snaps-sdk/jsx'; import { isJSXElementUnsafe } from '@metamask/snaps-sdk/jsx'; import type { InternalAccount } from '@metamask/snaps-utils'; import { + createAccountList, + createChainIdList, getJsonSizeUnsafe, getJsxChildren, getJsxElementFromComponent, @@ -38,6 +41,7 @@ import { parseCaipAssetType, toCaipAccountId, parseCaipChainId, + KnownCaipNamespace, } from '@metamask/utils'; /** @@ -52,6 +56,7 @@ const STATEFUL_COMPONENT_TYPES = [ 'Selector', 'AssetSelector', 'AddressInput', + 'AccountSelector', ] as const; /** @@ -97,16 +102,39 @@ type GetAccountByAddress = ( address: CaipAccountId, ) => InternalAccount | undefined; +/** + * A function to get the selected account in the client. + * + * @returns The selected account. + */ +type GetSelectedAccount = () => InternalAccount; + +/** + * A function to get accounts for the provided chain IDs. + */ +type ListAccounts = (chainIds?: CaipChainId[]) => InternalAccount[]; + +/** + * A function to check if the snap owns the account. + */ +type SnapOwnsAccount = (account: InternalAccount) => boolean; + /** * Data getters for elements. * This is used to get data from elements that is not directly accessible from the element itself. * * @param getAssetState - A function to get the MultichainAssetController state. * @param getAccountByAddress - A function to get an account by its address. + * @param getSelectedAccount - A function to get the selected account in the client. + * @param listAccounts - A function to list accounts for the provided chain IDs. + * @param snapOwnsAccount - A function to check if the snap owns the account. */ type ElementDataGetters = { getAssetsState: GetAssetsState; getAccountByAddress: GetAccountByAddress; + getSelectedAccount: GetSelectedAccount; + listAccounts: ListAccounts; + snapOwnsAccount: SnapOwnsAccount; }; /** @@ -138,6 +166,48 @@ export function assertNameIsUnique(state: InterfaceState, name: string) { ); } +/** + * Check if the chain ID matches the scope. + * This function handles the case where a scope represents all EVM compatible chains. + * In this case, it returns true if the chain ID is an EIP-155 chain ID. + * + * @param scope - The scope to check. + * @param chainIds - The chain IDs to check against. + * @returns Whether one of the chain ID matches the scope. + */ +export function isMatchingChainId(scope: CaipChainId, chainIds: CaipChainId[]) { + // if the scope represents all EVM compatible chains, return true if the namespace is EIP-155. + if (scope === 'eip155:0') { + return chainIds.some((chainId) => { + const { namespace } = parseCaipChainId(chainId); + return namespace === KnownCaipNamespace.Eip155; + }); + } + + // Otherwise, check if the scope is in the chain IDs. + return chainIds.includes(scope); +} + +/** + * Format the state value for an account selector. + * + * @param account - The account to format. + * @param requestedChainIds - The requested chain IDs. + * + * @returns The state value for the account selector. + */ +export function formatAccountSelectorStateValue( + account: InternalAccount, + requestedChainIds?: CaipChainId[], +) { + const { id, address, scopes } = account; + + const chainIds = createChainIdList(scopes, requestedChainIds); + const addresses = createAccountList(address, chainIds); + + return { accountId: id, addresses }; +} + /** * Get a default asset for a given address. * @@ -203,6 +273,55 @@ export function getDefaultAsset( }; } +/** + * Get the default state value for an account selector. + * + * @param element - The account selector element. + * @param elementDataGetters - Data getters for the element. + * @param elementDataGetters.getSelectedAccount - A function to get the selected account in the client. + * @param elementDataGetters.listAccounts - A function to list accounts for the provided chain IDs. + * @param elementDataGetters.snapOwnsAccount - A function to check if the snap owns the account. + * @returns The default state for the account selector. + */ +export function getAccountSelectorDefaultStateValue( + element: AccountSelectorElement, + { getSelectedAccount, listAccounts, snapOwnsAccount }: ElementDataGetters, +) { + const { chainIds, hideExternalAccounts } = element.props; + + const selectedAccount = getSelectedAccount(); + + // Use the selected account if it matches. + // The following conditions are checked: + // - If the selected account has any of the requested chain IDs in its scopes. + // - If the selected account is owned by the snap and hideExternalAccounts is true. + if ( + (!chainIds || + chainIds.length === 0 || + selectedAccount.scopes.some((scope) => + isMatchingChainId(scope, chainIds), + )) && + (!hideExternalAccounts || + (hideExternalAccounts && snapOwnsAccount(selectedAccount))) + ) { + return formatAccountSelectorStateValue(selectedAccount, chainIds); + } + + const accounts = listAccounts(chainIds); + + const filteredAccounts = hideExternalAccounts + ? accounts.filter((account) => snapOwnsAccount(account)) + : accounts; + + // The AccountSelector component in the UI will be disabled if there is no account available for the networks provided. + // In this case, we return null to indicate that there is no default selected account. + if (filteredAccounts.length === 0) { + return null; + } + + return formatAccountSelectorStateValue(filteredAccounts[0], chainIds); +} + /** * Construct default state for a component. * @@ -222,7 +341,8 @@ function constructComponentSpecificDefaultState( | CheckboxElement | SelectorElement | AssetSelectorElement - | AddressInputElement, + | AddressInputElement + | AccountSelectorElement, elementDataGetters: ElementDataGetters, ) { switch (element.type) { @@ -241,6 +361,9 @@ function constructComponentSpecificDefaultState( return children[0]?.props.value; } + case 'AccountSelector': + return getAccountSelectorDefaultStateValue(element, elementDataGetters); + case 'Checkbox': return false; @@ -285,6 +408,38 @@ export function getAssetSelectorStateValue( }; } +/** + * Get the state value for an account selector. + * + * @param element - The account selector element. + * @param elementDataGetters - Data getters for the element. + * @param elementDataGetters.getAccountByAddress - A function to get an account by address. + * @param elementDataGetters.snapOwnsAccount - A function to check if the snap owns the account. + * @returns The state value for the account selector. + */ +export function getAccountSelectorStateValue( + element: AccountSelectorElement, + { getAccountByAddress, snapOwnsAccount }: ElementDataGetters, +) { + const { value, hideExternalAccounts } = element.props; + + if (!value) { + return null; + } + + const account = getAccountByAddress(value); + + if (!account) { + return null; + } + + if (hideExternalAccounts && !snapOwnsAccount(account)) { + return null; + } + + return formatAccountSelectorStateValue(account, element.props.chainIds); +} + /** * Get the state value for a stateful component. * @@ -293,7 +448,6 @@ export function getAssetSelectorStateValue( * * @param element - The input element. * @param elementDataGetters - Data getters for the element. - * @param elementDataGetters.getAssetsState - A function to get the MultichainAssetController state. * @returns The state value for a given component. */ function getComponentStateValue( @@ -304,15 +458,19 @@ function getComponentStateValue( | CheckboxElement | SelectorElement | AssetSelectorElement - | AddressInputElement, - { getAssetsState }: ElementDataGetters, + | AddressInputElement + | AccountSelectorElement, + elementDataGetters: ElementDataGetters, ) { switch (element.type) { case 'Checkbox': return element.props.checked; case 'AssetSelector': - return getAssetSelectorStateValue(element.props.value, getAssetsState); + return getAssetSelectorStateValue( + element.props.value, + elementDataGetters.getAssetsState, + ); case 'AddressInput': { if (!element.props.value) { @@ -323,6 +481,10 @@ function getComponentStateValue( const { namespace, reference } = parseCaipChainId(element.props.chainId); return toCaipAccountId(namespace, reference, element.props.value); } + + case 'AccountSelector': + return getAccountSelectorStateValue(element, elementDataGetters); + default: return element.props.value; } @@ -347,7 +509,8 @@ function constructInputState( | CheckboxElement | SelectorElement | AssetSelectorElement - | AddressInputElement, + | AddressInputElement + | AccountSelectorElement, elementDataGetters: ElementDataGetters, form?: string, ) { diff --git a/packages/snaps-controllers/src/test-utils/controller.ts b/packages/snaps-controllers/src/test-utils/controller.ts index 3d7588156a..eba12a0328 100644 --- a/packages/snaps-controllers/src/test-utils/controller.ts +++ b/packages/snaps-controllers/src/test-utils/controller.ts @@ -795,6 +795,8 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( 'MultichainAssetsController:getState', 'AccountsController:getAccountByAddress', 'SnapController:get', + 'AccountsController:getSelectedMultichainAccount', + 'AccountsController:listMultichainAccounts', ], allowedEvents: [ 'NotificationServicesController:notificationsListUpdated', @@ -829,11 +831,33 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( // @ts-expect-error partial mock (address: string) => ({ address, - id: 'foo', + id: MOCK_ACCOUNT_ID, scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], }), ); + messenger.registerActionHandler( + 'AccountsController:getSelectedMultichainAccount', + // @ts-expect-error partial mock + () => ({ + address: '0x1234567890123456789012345678901234567890', + id: MOCK_ACCOUNT_ID, + scopes: ['eip155:0'], + }), + ); + + messenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + () => [ + // @ts-expect-error partial mock + { + id: MOCK_ACCOUNT_ID, + address: '0x1234567890123456789012345678901234567890', + scopes: ['eip155:0'], + }, + ], + ); + messenger.registerActionHandler('SnapController:get', (snapId: string) => { return getSnapObject({ id: snapId as SnapId }); }); diff --git a/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx b/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx index 7dc3472dc9..7a36ba062a 100644 --- a/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx +++ b/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx @@ -141,7 +141,7 @@ describe('snap_createInterface', () => { error: { code: -32602, message: - 'Invalid params: At path: ui -- Expected type to be one of: "Address", "AssetSelector", "AddressInput", "Bold", "Box", "Button", "Copyable", "Divider", "Dropdown", "RadioGroup", "Field", "FileInput", "Form", "Heading", "Input", "Image", "Italic", "Link", "Row", "Spinner", "Text", "Tooltip", "Checkbox", "Card", "Icon", "Selector", "Section", "Avatar", "Banner", "Skeleton", "Container", but received: undefined.', + 'Invalid params: At path: ui -- Expected type to be one of: "AccountSelector", "Address", "AssetSelector", "AddressInput", "Bold", "Box", "Button", "Copyable", "Divider", "Dropdown", "RadioGroup", "Field", "FileInput", "Form", "Heading", "Input", "Image", "Italic", "Link", "Row", "Spinner", "Text", "Tooltip", "Checkbox", "Card", "Icon", "Selector", "Section", "Avatar", "Banner", "Skeleton", "Container", but received: undefined.', stack: expect.any(String), }, id: 1, @@ -191,7 +191,7 @@ describe('snap_createInterface', () => { error: { code: -32602, message: - 'Invalid params: At path: ui.props.children.props.children -- Expected type to be one of: "AssetSelector", "AddressInput", "Input", "Dropdown", "RadioGroup", "FileInput", "Checkbox", "Selector", but received: "Copyable".', + 'Invalid params: At path: ui.props.children.props.children -- Expected type to be one of: "AssetSelector", "AddressInput", "AccountSelector", "Input", "Dropdown", "RadioGroup", "FileInput", "Checkbox", "Selector", but received: "Copyable".', stack: expect.any(String), }, id: 1, diff --git a/packages/snaps-sdk/src/jsx/components/form/AccountSelector.test.tsx b/packages/snaps-sdk/src/jsx/components/form/AccountSelector.test.tsx new file mode 100644 index 0000000000..37d6ad4006 --- /dev/null +++ b/packages/snaps-sdk/src/jsx/components/form/AccountSelector.test.tsx @@ -0,0 +1,106 @@ +import { AccountSelector } from './AccountSelector'; + +describe('AccountSelector', () => { + it('returns an account selector element without filter props', () => { + const result = ; + + expect(result).toStrictEqual({ + type: 'AccountSelector', + props: { + name: 'account', + }, + key: null, + }); + }); + + it('returns an account selector element with the chainIds filter prop', () => { + const result = ( + + ); + + expect(result).toStrictEqual({ + type: 'AccountSelector', + props: { + name: 'account', + chainIds: ['bip122:000000000019d6689c085ae165831e93'], + }, + key: null, + }); + }); + + it('returns an account selector element with the hideExternalAccounts filter prop', () => { + const result = ( + + ); + + expect(result).toStrictEqual({ + type: 'AccountSelector', + props: { + name: 'account', + hideExternalAccounts: true, + }, + key: null, + }); + }); + + it('returns an account selector element with the switchGlobalAccount filter prop', () => { + const result = ( + + ); + + expect(result).toStrictEqual({ + type: 'AccountSelector', + props: { + name: 'account', + switchGlobalAccount: true, + }, + key: null, + }); + }); + + it('returns an account selector element with a value prop', () => { + const result = ( + + ); + + expect(result).toStrictEqual({ + type: 'AccountSelector', + props: { + name: 'account', + value: 'eip155:1:0x1234567890abcdef1234567890abcdef12345678', + }, + key: null, + }); + }); + + it('returns an account selector element with all props', () => { + const result = ( + + ); + + expect(result).toStrictEqual({ + type: 'AccountSelector', + props: { + name: 'account', + chainIds: ['bip122:000000000019d6689c085ae165831e93'], + hideExternalAccounts: true, + switchGlobalAccount: true, + value: + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + }, + key: null, + }); + }); +}); diff --git a/packages/snaps-sdk/src/jsx/components/form/AccountSelector.ts b/packages/snaps-sdk/src/jsx/components/form/AccountSelector.ts new file mode 100644 index 0000000000..c32dbcadc3 --- /dev/null +++ b/packages/snaps-sdk/src/jsx/components/form/AccountSelector.ts @@ -0,0 +1,59 @@ +import type { CaipAccountId, CaipChainId } from '@metamask/utils'; + +import { createSnapComponent } from '../../component'; + +/** + * The props of the {@link AccountSelector} component. + * + * @property name - The name of the account selector. This is used to identify the + * state in the form data. + * @property hideExternalAccounts - Whether to hide accounts that don't belong to the snap. + * @property chainIds - The chain IDs to filter the accounts to show. + * @property switchGlobalAccount - Whether to switch the selected account in the client. + * @property value - The selected address. + */ +export type AccountSelectorProps = { + name: string; + hideExternalAccounts?: boolean | undefined; + chainIds?: CaipChainId[] | undefined; + switchGlobalAccount?: boolean | undefined; + value?: CaipAccountId | undefined; +}; + +const TYPE = 'AccountSelector'; + +/** + * An account selector component, which is used to create an account selector. + * + * @param props - The props of the component. + * @param props.name - The name of the account selector. This is used to identify the + * state in the form data. + * @param props.hideExternalAccounts - Whether to hide accounts that doesn't belong to the snap. + * @param props.chainIds - The chain IDs to filter the accounts to show. + * @param props.switchGlobalAccount - Whether to switch the selected account in the client. + * @param props.value - The selected address. + * @returns An account selector element. + * @example + * + * @example + * + * @example + * + * @example + * + * @example + * + * @example + * + */ +export const AccountSelector = createSnapComponent< + AccountSelectorProps, + typeof TYPE +>(TYPE); + +/** + * An account selector element. + * + * @see AccountSelector + */ +export type AccountSelectorElement = ReturnType; diff --git a/packages/snaps-sdk/src/jsx/components/form/index.ts b/packages/snaps-sdk/src/jsx/components/form/index.ts index 245fa13227..6fe932c3b4 100644 --- a/packages/snaps-sdk/src/jsx/components/form/index.ts +++ b/packages/snaps-sdk/src/jsx/components/form/index.ts @@ -1,3 +1,4 @@ +import type { AccountSelectorElement } from './AccountSelector'; import type { AddressInputElement } from './AddressInput'; import type { AssetSelectorElement } from './AssetSelector'; import type { ButtonElement } from './Button'; @@ -13,6 +14,8 @@ import type { RadioGroupElement } from './RadioGroup'; import type { SelectorElement } from './Selector'; import type { SelectorOptionElement } from './SelectorOption'; +export * from './AccountSelector'; +export * from './AddressInput'; export * from './AssetSelector'; export * from './Button'; export * from './Checkbox'; @@ -26,11 +29,11 @@ export * from './Form'; export * from './Input'; export * from './Selector'; export * from './SelectorOption'; -export * from './AddressInput'; export type StandardFormElement = - | AssetSelectorElement + | AccountSelectorElement | AddressInputElement + | AssetSelectorElement | ButtonElement | CheckboxElement | FormElement diff --git a/packages/snaps-sdk/src/jsx/validation.test.tsx b/packages/snaps-sdk/src/jsx/validation.test.tsx index 0e12283d37..a05d96a051 100644 --- a/packages/snaps-sdk/src/jsx/validation.test.tsx +++ b/packages/snaps-sdk/src/jsx/validation.test.tsx @@ -37,6 +37,7 @@ import { Skeleton, AssetSelector, AddressInput, + AccountSelector, } from './components'; import { AddressStruct, @@ -78,6 +79,7 @@ import { SkeletonStruct, AssetSelectorStruct, AddressInputStruct, + AccountSelectorStruct, } from './validation'; describe('KeyStruct', () => { @@ -376,6 +378,9 @@ describe('FieldStruct', () => { , + + + , ])('validates a field element', (value) => { expect(is(value, FieldStruct)).toBe(true); }); @@ -633,6 +638,68 @@ describe('AddressStruct', () => { }); }); +describe('AccountSelectorStruct', () => { + it.each([ + , + , + , + , + , + , + ])('validates an account picker element', (value) => { + expect(is(value, AccountSelectorStruct)).toBe(true); + }); + + it.each([ + 'foo', + 42, + null, + undefined, + {}, + [], + // @ts-expect-error - Invalid props. + , + // @ts-expect-error - Invalid props. + + foo + , + // @ts-expect-error - Invalid props. + , + // @ts-expect-error - Invalid props. + , + // @ts-expect-error - Invalid props. + , + , + foo, + + foo + , + + alt + , + ])('does not validate "%p"', (value) => { + expect(is(value, AccountSelectorStruct)).toBe(false); + }); +}); + describe('BoxStruct', () => { it.each([ diff --git a/packages/snaps-sdk/src/jsx/validation.ts b/packages/snaps-sdk/src/jsx/validation.ts index bfe72209fa..0809e6c94f 100644 --- a/packages/snaps-sdk/src/jsx/validation.ts +++ b/packages/snaps-sdk/src/jsx/validation.ts @@ -43,6 +43,7 @@ import type { import type { AssetSelectorElement, AvatarElement, + AccountSelectorElement, SkeletonElement, AddressElement, AddressInputElement, @@ -394,6 +395,23 @@ export const AddressStruct: Describe = element('Address', { avatar: optional(boolean()), }); +/** + * A struct for the {@link AccountSelectorElement} type. + */ +export const AccountSelectorStruct: Describe = element( + 'AccountSelector', + { + name: string(), + hideExternalAccounts: optional(boolean()), + chainIds: optional(array(CaipChainIdStruct)) as unknown as Struct< + Infer[] | undefined, + null + >, + switchGlobalAccount: optional(boolean()), + value: optional(CaipAccountIdStruct), + }, +); + /** * A struct for the {@link CardElement} type. */ @@ -520,6 +538,7 @@ const BOX_INPUT_BOTH = [ const FIELD_CHILDREN_ARRAY = [ AssetSelectorStruct, AddressInputStruct, + AccountSelectorStruct, InputStruct, DropdownStruct, RadioGroupStruct, @@ -529,6 +548,7 @@ const FIELD_CHILDREN_ARRAY = [ ] as [ typeof AssetSelectorStruct, typeof AddressInputStruct, + typeof AccountSelectorStruct, typeof InputStruct, typeof DropdownStruct, typeof RadioGroupStruct, @@ -921,6 +941,7 @@ export const SpinnerStruct: Describe = element('Spinner'); * another component (e.g., Field must be contained in a Form). */ export const BoxChildStruct = typedUnion([ + AccountSelectorStruct, AddressStruct, AssetSelectorStruct, AddressInputStruct, @@ -989,6 +1010,7 @@ export const RootJSXElementStruct = typedUnion([ export const JSXElementStruct: Describe = typedUnion([ AssetSelectorStruct, AddressInputStruct, + AccountSelectorStruct, ButtonStruct, InputStruct, FileInputStruct, diff --git a/packages/snaps-sdk/src/types/handlers/user-input.ts b/packages/snaps-sdk/src/types/handlers/user-input.ts index 063a67c049..66db395d68 100644 --- a/packages/snaps-sdk/src/types/handlers/user-input.ts +++ b/packages/snaps-sdk/src/types/handlers/user-input.ts @@ -9,8 +9,9 @@ import { string, union, boolean, + array, } from '@metamask/superstruct'; -import { CaipAssetTypeStruct } from '@metamask/utils'; +import { CaipAssetTypeStruct, CaipAccountIdStruct } from '@metamask/utils'; import { typedUnion, literal } from '../../internals'; import type { InterfaceContext } from '../interface'; @@ -54,6 +55,19 @@ export const ButtonClickEventStruct = assign( */ export type ButtonClickEvent = Infer; +export const AccountSelectorStateStruct = object({ + accountId: string(), + addresses: array(CaipAccountIdStruct), +}); + +/** + * The state of an `AccountSelector` component. + * + * @property accountId - The account ID of the account. + * @property addresses - The addresses of the account as CAIP-10 account ID. + */ +export type AccountSelectorState = Infer; + export const FileStruct = object({ name: string(), size: number(), @@ -94,7 +108,13 @@ export const FormSubmitEventStruct = assign( value: record( string(), nullable( - union([string(), FileStruct, boolean(), AssetSelectorStateStruct]), + union([ + string(), + FileStruct, + boolean(), + AccountSelectorStateStruct, + AssetSelectorStateStruct, + ]), ), ), name: string(), @@ -121,7 +141,12 @@ export const InputChangeEventStruct = assign( object({ type: literal(UserInputEventType.InputChangeEvent), name: string(), - value: union([string(), boolean(), AssetSelectorStateStruct]), + value: union([ + string(), + boolean(), + AccountSelectorStateStruct, + AssetSelectorStateStruct, + ]), }), ); diff --git a/packages/snaps-sdk/src/types/interface.ts b/packages/snaps-sdk/src/types/interface.ts index 47af64c78b..c211d5384c 100644 --- a/packages/snaps-sdk/src/types/interface.ts +++ b/packages/snaps-sdk/src/types/interface.ts @@ -13,7 +13,11 @@ import { isObject, } from '@metamask/utils'; -import { AssetSelectorStateStruct, FileStruct } from './handlers'; +import { + AssetSelectorStateStruct, + FileStruct, + AccountSelectorStateStruct, +} from './handlers'; import { selectiveUnion } from '../internals'; import type { JSXElement } from '../jsx'; import { RootJSXElementStruct } from '../jsx'; @@ -28,6 +32,7 @@ import { ComponentStruct } from '../ui'; */ export const StateStruct = union([ + AccountSelectorStateStruct, AssetSelectorStateStruct, FileStruct, string(), diff --git a/packages/snaps-utils/coverage.json b/packages/snaps-utils/coverage.json index 659b3a5c59..d33640423b 100644 --- a/packages/snaps-utils/coverage.json +++ b/packages/snaps-utils/coverage.json @@ -1,6 +1,6 @@ { - "branches": 99.75, - "functions": 98.99, - "lines": 98.61, - "statements": 97.15 + "branches": 99.76, + "functions": 99.01, + "lines": 98.62, + "statements": 97.18 } diff --git a/packages/snaps-utils/src/account.test.ts b/packages/snaps-utils/src/account.test.ts new file mode 100644 index 0000000000..73a57c7ba7 --- /dev/null +++ b/packages/snaps-utils/src/account.test.ts @@ -0,0 +1,83 @@ +import type { SnapId } from '@metamask/snaps-sdk'; + +import { + createAccountList, + createChainIdList, + snapOwnsAccount, +} from './account'; +import { MOCK_SNAP_ID } from './test-utils'; + +describe('createAccountList', () => { + it('creates an account list from an address and a list of chain IDs', () => { + const result = createAccountList( + '0x1234567890123456789012345678901234567890', + ['eip155:1', 'eip155:2'], + ); + + expect(result).toStrictEqual([ + 'eip155:1:0x1234567890123456789012345678901234567890', + 'eip155:2:0x1234567890123456789012345678901234567890', + ]); + }); +}); + +describe('createChainIdList', () => { + it('creates a chain ID list from account scopes and requested chain IDs', () => { + const result = createChainIdList( + ['eip155:1', 'eip155:2'], + ['eip155:1', 'eip155:3'], + ); + + expect(result).toStrictEqual(['eip155:1']); + }); + + it('returns all account scopes if no requested chain IDs are provided', () => { + const result = createChainIdList(['eip155:1', 'eip155:2']); + + expect(result).toStrictEqual(['eip155:1', 'eip155:2']); + }); + + it('returns all requested chain IDs if the scope represents all EVM compatible chains', () => { + const result = createChainIdList(['eip155:0'], ['eip155:1', 'eip155:2']); + + expect(result).toStrictEqual(['eip155:1', 'eip155:2']); + }); + + it('returns "eip155:0" if the scope represents all EVM compatible chains', () => { + const result = createChainIdList(['eip155:0']); + + expect(result).toStrictEqual(['eip155:0']); + }); +}); + +describe('snapOwnsAccount', () => { + it('returns true if the snap owns the account', () => { + const result = snapOwnsAccount(MOCK_SNAP_ID, { + id: 'eip155:1:0x1234567890123456789012345678901234567890', + scopes: ['eip155:1'], + metadata: { + // @ts-expect-error partial mock + snap: { + id: MOCK_SNAP_ID, + }, + }, + }); + + expect(result).toBe(true); + }); + + it('returns false if the snap does not own the account', () => { + const result = snapOwnsAccount(MOCK_SNAP_ID, { + id: 'eip155:2:0x1234567890123456789012345678901234567890', + scopes: ['eip155:1'], + metadata: { + // @ts-expect-error partial mock + snap: { + id: 'npm:other-snap' as SnapId, + }, + }, + }); + + expect(result).toBe(false); + }); +}); diff --git a/packages/snaps-utils/src/account.ts b/packages/snaps-utils/src/account.ts index a1e4dbc972..ea55a18a22 100644 --- a/packages/snaps-utils/src/account.ts +++ b/packages/snaps-utils/src/account.ts @@ -1,5 +1,6 @@ import type { SnapId } from '@metamask/snaps-sdk'; -import type { Json } from '@metamask/utils'; +import type { Json, CaipAccountId, CaipChainId } from '@metamask/utils'; +import { KnownCaipNamespace, parseCaipChainId } from '@metamask/utils'; /** * Copy of the original type from @@ -11,8 +12,71 @@ export type InternalAccount = { address: string; options: Record; methods: string[]; + scopes: CaipChainId[]; metadata: { name: string; snap?: { id: SnapId; enabled: boolean; name: string }; }; }; + +/** + * Create a list of CAIP account IDs from an address and a list of scopes. + * + * @param address - The address to create the account IDs from. + * @param scopes - The scopes to create the account IDs from. + * @returns The list of CAIP account IDs. + */ +export function createAccountList( + address: string, + scopes: CaipChainId[], +): CaipAccountId[] { + return scopes.map((scope) => `${scope}:${address}`) as CaipAccountId[]; +} + +/** + * Create a list of CAIP chain IDs from a list of account scopes and a list of requested chain IDs. + * + * @param accountScopes - The account scopes to create the chain ID list from. + * @param requestedChainIds - The requested chain IDs to filter the account scopes by. + * @returns The list of CAIP chain IDs. + */ +export function createChainIdList( + accountScopes: CaipChainId[], + requestedChainIds?: CaipChainId[], +) { + // If there are no requested chain IDs, return all account scopes. + if (!requestedChainIds || requestedChainIds.length === 0) { + return accountScopes; + } + + return accountScopes.reduce((acc, scope) => { + // If the scope represents all EVM compatible chains, return all requested chain IDs. + if (scope === 'eip155:0') { + const evmChainIds = requestedChainIds.filter((chainId) => { + const { namespace } = parseCaipChainId(chainId); + + return namespace === KnownCaipNamespace.Eip155; + }); + + acc.push(...evmChainIds); + } + + // If the scope is not in the requested chain IDs, skip it. + else if (requestedChainIds.includes(scope)) { + acc.push(scope); + } + + return acc; + }, []); +} + +/** + * Whether if the snap owns the account. + * + * @param snapId - The snap id. + * @param account - The account. + * @returns True if the snap owns the account, otherwise false. + */ +export function snapOwnsAccount(snapId: SnapId, account: InternalAccount) { + return account.metadata.snap?.id === snapId; +} diff --git a/packages/snaps-utils/src/index.ts b/packages/snaps-utils/src/index.ts index b51634f29b..00fef34a89 100644 --- a/packages/snaps-utils/src/index.ts +++ b/packages/snaps-utils/src/index.ts @@ -1,4 +1,4 @@ -export type * from './account'; +export * from './account'; export * from './array'; export * from './auxiliary-files'; export * from './base64';