diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index 1358b8d646..94b8b38f71 100644 --- a/packages/examples/packages/browserify-plugin/snap.manifest.json +++ b/packages/examples/packages/browserify-plugin/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "M83EcfIuVLDGdfzDz6ydToeMztnTSLhjTHpkiGwi3Mo=", + "shasum": "7m/tln4qf/bu8u9PdJnluGBWg7949ema1QUhYrL6Kys=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/browserify/snap.manifest.json b/packages/examples/packages/browserify/snap.manifest.json index ae89a5892e..ca88f95503 100644 --- a/packages/examples/packages/browserify/snap.manifest.json +++ b/packages/examples/packages/browserify/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "AEZW5peO3bzO6yrbWCplB1yo/tyNirb6bAUXwSuspBc=", + "shasum": "SeDH2s8fzM2/cxqbhyhF7G3TeztLsn01kRiWige7l2M=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/dialogs/snap.manifest.json b/packages/examples/packages/dialogs/snap.manifest.json index 215da19d93..58d8199e78 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": "CpcCabMasgyas/okZfYhJNCe1wK0SoFs0EC/3F8dsBU=", + "shasum": "fXWXTZIJmUniCOx+w5RAYCa5FcJrg26oNkvswihXeIQ=", "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 f42f77f2ce..6e430d2eff 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": "A9BecIJvmVZFc4oelZQaqvCCHTyr/VjZkSzOlGcO2Ps=", + "shasum": "Jc8veXAgN4hKHVcF3c4eXWdNNYXygFM6+Xg+fcoIgzU=", "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 98580b13a3..a7a0e01365 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": "zOHGdxFGbEikE/8emjcfVg7t3qkocvi77z/WxGhZbEA=", + "shasum": "dSiEAfhCU+9YTLqGfon3fyRuPDjBKSlGCY4PJkSBiRE=", "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 b91311841b..8912bc1d89 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": "MKYo8/2WYd69DzJ+eoVTvV3z0jgAsrNC2WMS4EjhKOA=", + "shasum": "xnUQpM9/BWwTzf+eIRmbQp/FJAdo4QcxdZskw4eLqGA=", "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 3764c061c7..d71fb46f3c 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": "n03p2erZgw7rH65NyuUglC4X3lFofgnrwASBBHE9ydw=", + "shasum": "S0DuiRsDfPw9rSkHFsMHB+BImA51WqU5autns2Atg4Y=", "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 4a2b2688be..a2bec870c3 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": "5gHpko9GiRp/MqDRMpJz1ahrFz3EXElk0ZIxe5Zs+8k=", + "shasum": "yWpds2oL+G/4fr18KIXr0LqcY8IWhw9mUPgs3FKyj3I=", "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 0230bb4f12..b8160937ee 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": "rn9CAjAXYPvPMWcU5DDEr1+x3ccMwv7+uvxC60VK6+E=", + "shasum": "TBt+UJnVWgfYB6xDuI94gT0Ns1WPw4LdJKFcxeUH8Rg=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index c73f8baa74..a9a891e1f3 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 93.31, - "functions": 97.05, - "lines": 98.25, - "statements": 97.98 + "branches": 93.46, + "functions": 97.36, + "lines": 98.33, + "statements": 98.06 } diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx b/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx index f8d6ffd9d0..de0a2623c0 100644 --- a/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx +++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx @@ -9,6 +9,7 @@ import { ContentType, } from '@metamask/snaps-sdk'; import { + AssetSelector, Box, Field, FileInput, @@ -159,6 +160,52 @@ describe('SnapInterfaceController', () => { expect(state).toStrictEqual({ foo: { bar: null } }); }); + it('calls the multichain asset controller to construct an asset selector state', async () => { + const rootMessenger = getRootSnapInterfaceControllerMessenger(); + const controllerMessenger = + getRestrictedSnapInterfaceControllerMessenger(rootMessenger); + + // eslint-disable-next-line no-new + new SnapInterfaceController({ + messenger: controllerMessenger, + }); + + const components = ( + + ); + + const interfaceId = await rootMessenger.call( + 'SnapInterfaceController:createInterface', + MOCK_SNAP_ID, + components, + ); + + expect(rootMessenger.call).toHaveBeenNthCalledWith( + 4, + 'MultichainAssetsController:getState', + ); + + const { state } = rootMessenger.call( + 'SnapInterfaceController:getInterface', + MOCK_SNAP_ID, + interfaceId, + ); + + expect(state).toStrictEqual({ + foo: { + asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105', + name: 'Solana', + symbol: 'SOL', + }, + }); + }); + it('can create a new interface from JSX', async () => { const rootMessenger = getRootSnapInterfaceControllerMessenger(); const controllerMessenger = @@ -412,6 +459,116 @@ describe('SnapInterfaceController', () => { ); }); + it('throws if a link tries to navigate to a snap that is not installed', async () => { + const rootMessenger = getRootSnapInterfaceControllerMessenger(); + const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger( + rootMessenger, + false, + ); + + rootMessenger.registerActionHandler( + 'PhishingController:maybeUpdateState', + jest.fn(), + ); + + rootMessenger.registerActionHandler( + 'SnapController:get', + () => undefined, + ); + + // eslint-disable-next-line no-new + new SnapInterfaceController({ + messenger: controllerMessenger, + }); + + const element = ( + + + Foo Bar + + + ); + + await expect( + rootMessenger.call( + 'SnapInterfaceController:createInterface', + MOCK_SNAP_ID, + element, + ), + ).rejects.toThrow( + 'Invalid URL: The Snap being navigated to is not installed.', + ); + + expect(rootMessenger.call).toHaveBeenNthCalledWith( + 3, + 'SnapController:get', + MOCK_SNAP_ID, + ); + }); + + it('throws if an address passed to an asset selector is not available in the client', async () => { + const rootMessenger = getRootSnapInterfaceControllerMessenger(); + const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger( + rootMessenger, + false, + ); + + rootMessenger.registerActionHandler( + 'PhishingController:maybeUpdateState', + jest.fn(), + ); + + rootMessenger.registerActionHandler( + 'PhishingController:testOrigin', + () => ({ result: true, type: 'all' }), + ); + + rootMessenger.registerActionHandler( + 'MultichainAssetsController:getState', + () => ({ + assetsMetadata: {}, + accountsAssets: {}, + }), + ); + + rootMessenger.registerActionHandler( + 'AccountsController:getAccountByAddress', + () => undefined, + ); + + // eslint-disable-next-line no-new + new SnapInterfaceController({ + messenger: controllerMessenger, + }); + + const element = ( + + + + ); + + await expect( + rootMessenger.call( + 'SnapInterfaceController:createInterface', + MOCK_SNAP_ID, + element, + ), + ).rejects.toThrow( + 'Could not find account for address: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ); + + expect(rootMessenger.call).toHaveBeenNthCalledWith( + 3, + 'AccountsController:getAccountByAddress', + '7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ); + }); + it('throws if UI content is too large', async () => { const rootMessenger = getRootSnapInterfaceControllerMessenger(); const controllerMessenger = getRestrictedSnapInterfaceControllerMessenger( diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts index cd25843886..487a11adf5 100644 --- a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts +++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts @@ -17,12 +17,14 @@ import type { SnapId, ComponentOrElement, InterfaceContext, + FungibleAssetMetadata, } from '@metamask/snaps-sdk'; import { ContentType } from '@metamask/snaps-sdk'; import type { JSXElement } from '@metamask/snaps-sdk/jsx'; -import { getJsonSizeUnsafe, validateJsxLinks } from '@metamask/snaps-utils'; -import type { Json } from '@metamask/utils'; -import { assert, hasProperty } from '@metamask/utils'; +import type { InternalAccount } from '@metamask/snaps-utils'; +import { getJsonSizeUnsafe, validateJsxElements } from '@metamask/snaps-utils'; +import type { CaipAccountId, CaipAssetType, Json } from '@metamask/utils'; +import { assert, hasProperty, parseCaipAccountId } from '@metamask/utils'; import { castDraft } from 'immer'; import { nanoid } from 'nanoid'; @@ -67,17 +69,34 @@ export type ResolveInterface = { handler: SnapInterfaceController['resolveInterface']; }; +type AccountsControllerGetAccountByAddressAction = { + type: `AccountsController:getAccountByAddress`; + handler: (address: string) => InternalAccount | undefined; +}; + export type SnapInterfaceControllerGetStateAction = ControllerGetStateAction< typeof controllerName, SnapInterfaceControllerState >; +type MultichainAssetsControllerGetStateAction = ControllerGetStateAction< + 'MultichainAssetsController', + { + assetsMetadata: { + [asset: CaipAssetType]: FungibleAssetMetadata; + }; + accountsAssets: { [account: string]: CaipAssetType[] }; + } +>; + export type SnapInterfaceControllerAllowedActions = | TestOrigin | MaybeUpdateState | HasApprovalRequest | AcceptRequest - | GetSnap; + | GetSnap + | MultichainAssetsControllerGetStateAction + | AccountsControllerGetAccountByAddressAction; export type SnapInterfaceControllerActions = | CreateInterface @@ -249,7 +268,10 @@ export class SnapInterfaceController extends BaseController< validateInterfaceContext(context); const id = nanoid(); - const componentState = constructState({}, element); + const componentState = constructState({}, element, { + getAssetsState: this.#getAssetsState.bind(this), + getAccountByAddress: this.#getAccountByAddress.bind(this), + }); this.update((draftState) => { // @ts-expect-error - TS2589: Type instantiation is excessively deep and @@ -299,7 +321,10 @@ export class SnapInterfaceController extends BaseController< validateInterfaceContext(context); const oldState = this.state.interfaces[id].state; - const newState = constructState(oldState, element); + const newState = constructState(oldState, element, { + getAssetsState: this.#getAssetsState.bind(this), + getAccountByAddress: this.#getAccountByAddress.bind(this), + }); this.update((draftState) => { draftState.interfaces[id].state = newState; @@ -426,6 +451,40 @@ export class SnapInterfaceController extends BaseController< ); } + /** + * Get an account by its address. + * + * @param address - The account address. + * @returns The account or undefined if not found. + */ + #getAccountByAddress(address: CaipAccountId) { + const { address: parsedAddress } = parseCaipAccountId(address); + + return this.messagingSystem.call( + 'AccountsController:getAccountByAddress', + parsedAddress, + ); + } + + /** + * Get the MultichainAssetsController state. + * + * @returns The MultichainAssetsController state. + */ + #getAssetsState() { + return this.messagingSystem.call('MultichainAssetsController:getState'); + } + + /** + * Get a snap by its id. + * + * @param id - The snap id. + * @returns The snap. + */ + #getSnap(id: string) { + return this.messagingSystem.call('SnapController:get', id); + } + /** * Utility function to validate the components of an interface. * Throws if something is invalid. @@ -442,11 +501,12 @@ export class SnapInterfaceController extends BaseController< ); await this.#triggerPhishingListUpdate(); - validateJsxLinks( - element, - this.#checkPhishingList.bind(this), - (id: string) => this.messagingSystem.call('SnapController:get', id), - ); + + validateJsxElements(element, { + isOnPhishingList: this.#checkPhishingList.bind(this), + getSnap: this.#getSnap.bind(this), + getAccountByAddress: this.#getAccountByAddress.bind(this), + }); } #onNotificationsListUpdated(notificationsList: Notification[]) { diff --git a/packages/snaps-controllers/src/interface/utils.test.tsx b/packages/snaps-controllers/src/interface/utils.test.tsx index c00f9de819..a2a333359d 100644 --- a/packages/snaps-controllers/src/interface/utils.test.tsx +++ b/packages/snaps-controllers/src/interface/utils.test.tsx @@ -15,9 +15,17 @@ import { Selector, Card, SelectorOption, + AssetSelector, } from '@metamask/snaps-sdk/jsx'; -import { assertNameIsUnique, constructState, getJsxInterface } from './utils'; +import { + assertNameIsUnique, + constructState, + getAssetSelectorStateValue, + getDefaultAsset, + getJsxInterface, +} from './utils'; +import { MOCK_ACCOUNT_ID } from '../test-utils'; describe('getJsxInterface', () => { it('returns the JSX interface for a JSX element', () => { @@ -64,6 +72,11 @@ describe('assertNameIsUnique', () => { }); describe('constructState', () => { + const elementDataGetters = { + getAssetsState: jest.fn(), + getAccountByAddress: jest.fn(), + }; + it('can construct a new component state', () => { const element = ( @@ -76,7 +89,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: { bar: null } }); }); @@ -94,7 +107,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: { bar: null } }); }); @@ -116,7 +129,7 @@ describe('constructState', () => { ); - const result = constructState(state, element); + const result = constructState(state, element, elementDataGetters); expect(result).toStrictEqual({ foo: { bar: 'test', baz: null } }); }); @@ -137,7 +150,7 @@ describe('constructState', () => { ); - const result = constructState(state, element); + const result = constructState(state, element, elementDataGetters); expect(result).toStrictEqual({ form: { bar: 'test', baz: null } }); }); @@ -169,7 +182,7 @@ describe('constructState', () => { ); - const result = constructState(state, element); + const result = constructState(state, element, elementDataGetters); expect(result).toStrictEqual({ form1: { bar: 'test', baz: null }, @@ -197,7 +210,7 @@ describe('constructState', () => { ); - const result = constructState(state, element); + const result = constructState(state, element, elementDataGetters); expect(result).toStrictEqual({ form1: { bar: 'test', baz: null }, }); @@ -235,7 +248,7 @@ describe('constructState', () => { ); - const result = constructState(state, element); + const result = constructState(state, element, elementDataGetters); expect(result).toStrictEqual({ form1: { bar: 'test', baz: null }, form2: { bar: 'def', baz: null }, @@ -251,7 +264,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'bar', }); @@ -264,7 +277,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'bar', }); @@ -277,7 +290,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: null, }); @@ -293,7 +306,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'option1', }); @@ -309,7 +322,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'option2', }); @@ -329,7 +342,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { foo: 'option1' }, }); @@ -349,7 +362,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { foo: 'option2' }, }); @@ -365,7 +378,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'option1', }); @@ -381,7 +394,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'option2', }); @@ -401,7 +414,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { foo: 'option1' }, }); @@ -421,7 +434,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { foo: 'option2' }, }); @@ -434,7 +447,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: true, }); @@ -451,7 +464,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { foo: false }, }); @@ -468,7 +481,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { foo: true }, }); @@ -488,7 +501,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'option1', }); @@ -508,7 +521,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'option2', }); @@ -532,7 +545,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { foo: 'option1' }, }); @@ -556,12 +569,253 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { foo: 'option2' }, }); }); + it('sets default value for root level AssetSelector', () => { + elementDataGetters.getAssetsState.mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105': { + name: 'Solana', + symbol: 'SOL', + }, + }, + accountsAssets: { + [MOCK_ACCOUNT_ID]: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105', + ], + }, + }); + + elementDataGetters.getAccountByAddress.mockReturnValue({ + id: MOCK_ACCOUNT_ID, + }); + + const element = ( + + + + ); + + const result = constructState({}, element, elementDataGetters); + + expect(result).toStrictEqual({ + foo: { + asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105', + name: 'Solana', + symbol: 'SOL', + }, + }); + }); + + it('supports root level AssetSelector', () => { + elementDataGetters.getAssetsState.mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105': { + name: 'Solana', + symbol: 'SOL', + }, + }, + accountsAssets: { + [MOCK_ACCOUNT_ID]: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105', + ], + }, + }); + + const element = ( + + + + ); + + const result = constructState({}, element, elementDataGetters); + + expect(result).toStrictEqual({ + foo: { + asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105', + name: 'Solana', + symbol: 'SOL', + }, + }); + }); + + it('sets default value for AssetSelector in form', () => { + elementDataGetters.getAssetsState.mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105': { + name: 'Solana', + symbol: 'SOL', + }, + }, + accountsAssets: { + [MOCK_ACCOUNT_ID]: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105', + ], + }, + }); + + elementDataGetters.getAccountByAddress.mockReturnValue({ + id: MOCK_ACCOUNT_ID, + }); + + const element = ( + +
+ + + +
+
+ ); + + const result = constructState({}, element, elementDataGetters); + + expect(result).toStrictEqual({ + form: { + foo: { + asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105', + name: 'Solana', + symbol: 'SOL', + }, + }, + }); + }); + + it('supports AssetSelector in form', () => { + elementDataGetters.getAssetsState.mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105': { + name: 'Solana', + symbol: 'SOL', + }, + }, + accountsAssets: { + [MOCK_ACCOUNT_ID]: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105', + ], + }, + }); + + const element = ( + +
+ + + +
+
+ ); + + const result = constructState({}, element, elementDataGetters); + + expect(result).toStrictEqual({ + form: { + foo: { + asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105', + name: 'Solana', + symbol: 'SOL', + }, + }, + }); + }); + + it('sets the value to the default asset if the asset metadata is not found', () => { + elementDataGetters.getAssetsState.mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105': { + name: 'Solana', + symbol: 'SOL', + }, + }, + accountsAssets: { + [MOCK_ACCOUNT_ID]: [], + }, + }); + + elementDataGetters.getAccountByAddress.mockReturnValue({ + id: MOCK_ACCOUNT_ID, + }); + + const element = ( + + + + ); + + const result = constructState({}, element, elementDataGetters); + + expect(result).toStrictEqual({ + foo: null, + }); + }); + + it('sets the value to null if the account has no assets', () => { + elementDataGetters.getAssetsState.mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105': { + name: 'Solana', + symbol: 'SOL', + }, + }, + accountsAssets: { + [MOCK_ACCOUNT_ID]: [], + }, + }); + + elementDataGetters.getAccountByAddress.mockReturnValue({ + id: MOCK_ACCOUNT_ID, + }); + + const element = ( + + + + ); + + const result = constructState({}, element, elementDataGetters); + + expect(result).toStrictEqual({ + foo: null, + }); + }); + it('supports nested fields', () => { const element = ( @@ -579,7 +833,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { bar: 'option2' }, }); @@ -610,7 +864,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { baz: 'option4' }, form2: { bar: 'option2' }, @@ -624,7 +878,11 @@ describe('constructState', () => { ); - const result = constructState({ foo: null, bar: null }, element); + const result = constructState( + { foo: null, bar: null }, + element, + elementDataGetters, + ); expect(result).toStrictEqual({ foo: null, }); @@ -641,7 +899,7 @@ describe('constructState', () => { ); - const result = constructState(state, element); + const result = constructState(state, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'bar', }); @@ -654,7 +912,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: null, }); @@ -672,7 +930,7 @@ describe('constructState', () => { ); - expect(() => constructState({}, element)).toThrow( + expect(() => constructState({}, element, elementDataGetters)).toThrow( `Duplicate component names are not allowed, found multiple instances of: "foo".`, ); }); @@ -685,7 +943,7 @@ describe('constructState', () => { ); - expect(() => constructState({}, element)).toThrow( + expect(() => constructState({}, element, elementDataGetters)).toThrow( `Duplicate component names are not allowed, found multiple instances of: "test".`, ); }); @@ -702,8 +960,221 @@ describe('constructState', () => { ); - expect(() => constructState({}, element)).toThrow( + expect(() => constructState({}, element, elementDataGetters)).toThrow( `Duplicate component names are not allowed, found multiple instances of: "test".`, ); }); }); + +describe('getAssetSelectorStateValue', () => { + const getAssetsState = jest.fn(); + + it('returns the asset selector state value', () => { + getAssetsState.mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + name: 'Solana', + symbol: 'SOL', + }, + }, + accountsAssets: { + [MOCK_ACCOUNT_ID]: [], + }, + }); + + expect( + getAssetSelectorStateValue( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + getAssetsState, + ), + ).toStrictEqual({ + asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + name: 'Solana', + symbol: 'SOL', + }); + }); + + it('returns null if the value is not set', () => { + expect(getAssetSelectorStateValue(undefined, getAssetsState)).toBeNull(); + }); + + it('returns null if the asset metadata is not found', () => { + getAssetsState.mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': + { + name: 'USDC', + symbol: 'USDC', + }, + }, + accountsAssets: { + [MOCK_ACCOUNT_ID]: [], + }, + }); + + expect( + getAssetSelectorStateValue( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + getAssetsState, + ), + ).toBeNull(); + }); +}); + +describe('getDefaultAsset', () => { + it('returns the native asset if available', () => { + const getAssetsState = jest.fn().mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + name: 'Solana', + symbol: 'SOL', + }, + }, + accountsAssets: { + [MOCK_ACCOUNT_ID]: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ], + }, + }); + + const getAccountByAddress = jest.fn().mockReturnValue({ + id: MOCK_ACCOUNT_ID, + }); + + expect( + getDefaultAsset( + [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + undefined, + { getAssetsState, getAccountByAddress }, + ), + ).toStrictEqual({ + asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + name: 'Solana', + symbol: 'SOL', + }); + }); + + it('returns the first asset if no native asset is available', () => { + const getAssetsState = jest.fn().mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': + { + name: 'USDC', + symbol: 'USDC', + }, + }, + accountsAssets: { + [MOCK_ACCOUNT_ID]: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + ], + }, + }); + + const getAccountByAddress = jest.fn().mockReturnValue({ + id: MOCK_ACCOUNT_ID, + }); + + expect( + getDefaultAsset( + [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + undefined, + { getAssetsState, getAccountByAddress }, + ), + ).toStrictEqual({ + asset: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + name: 'USDC', + symbol: 'USDC', + }); + }); + + it('returns undefined if no assets are available', () => { + const getAssetsState = jest.fn().mockReturnValue({ + assetsMetadata: {}, + accountsAssets: { + [MOCK_ACCOUNT_ID]: [], + }, + }); + + const getAccountByAddress = jest.fn().mockReturnValue({ + id: MOCK_ACCOUNT_ID, + }); + + expect( + getDefaultAsset( + [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + undefined, + { getAssetsState, getAccountByAddress }, + ), + ).toBeNull(); + }); + + it('selects the default asset from the requested network', () => { + const getAssetsState = jest.fn().mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + name: 'Solana', + symbol: 'SOL', + }, + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501': { + name: 'Solana Devnet', + symbol: 'SOL', + }, + }, + accountsAssets: { + [MOCK_ACCOUNT_ID]: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ], + }, + }); + + const getAccountByAddress = jest.fn().mockReturnValue({ + id: MOCK_ACCOUNT_ID, + }); + + expect( + getDefaultAsset( + [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + { getAssetsState, getAccountByAddress }, + ), + ).toStrictEqual({ + asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + name: 'Solana', + symbol: 'SOL', + }); + }); + + it('throws if the account is not found', () => { + const getAssetsState = jest.fn().mockReturnValue({ + assetsMetadata: {}, + accountsAssets: { + [MOCK_ACCOUNT_ID]: [], + }, + }); + + const getAccountByAddress = jest.fn().mockReturnValue(undefined); + + expect(() => + getDefaultAsset( + [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + undefined, + { getAssetsState, getAccountByAddress }, + ), + ).toThrow( + 'Account not found for address: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv.', + ); + }); +}); diff --git a/packages/snaps-controllers/src/interface/utils.ts b/packages/snaps-controllers/src/interface/utils.ts index 24923138a4..dd25b40a74 100644 --- a/packages/snaps-controllers/src/interface/utils.ts +++ b/packages/snaps-controllers/src/interface/utils.ts @@ -5,6 +5,9 @@ import type { ComponentOrElement, InterfaceContext, State, + FungibleAssetMetadata, + AssetSelectorState, + CaipChainId, } from '@metamask/snaps-sdk'; import type { DropdownElement, @@ -17,14 +20,56 @@ import type { RadioElement, SelectorElement, SelectorOptionElement, + AssetSelectorElement, } from '@metamask/snaps-sdk/jsx'; import { isJSXElementUnsafe } from '@metamask/snaps-sdk/jsx'; +import type { InternalAccount } from '@metamask/snaps-utils'; import { getJsonSizeUnsafe, getJsxChildren, getJsxElementFromComponent, walkJsx, } from '@metamask/snaps-utils'; +import { + type CaipAssetType, + type CaipAccountId, + parseCaipAccountId, + parseCaipAssetType, +} from '@metamask/utils'; + +/** + * A function to get the MultichainAssetController state. + * + * @returns The MultichainAssetController state. + */ +type GetAssetsState = () => { + assetsMetadata: { + [asset: CaipAssetType]: FungibleAssetMetadata; + }; + accountsAssets: { [account: string]: CaipAssetType[] }; +}; + +/** + * A function to get an account by its address. + * + * @param address - The account address. + * @returns The account or undefined if not found. + */ +type GetAccountByAddress = ( + address: CaipAccountId, +) => InternalAccount | undefined; + +/** + * 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. + */ +type ElementDataGetters = { + getAssetsState: GetAssetsState; + getAccountByAddress: GetAccountByAddress; +}; /** * Get a JSX element from a component or JSX element. If the component is a @@ -55,6 +100,71 @@ export function assertNameIsUnique(state: InterfaceState, name: string) { ); } +/** + * Get a default asset for a given address. + * + * @param addresses - The account addresses. + * @param chainIds - The chain IDs to filter the assets. + * @param elementDataGetters - Data getters for the element. + * @param elementDataGetters.getAccountByAddress - A function to get an account by its address. + * @param elementDataGetters.getAssetsState - A function to get the MultichainAssetController state. + * + * @returns The default asset for the account or undefined if not found. + */ +export function getDefaultAsset( + addresses: CaipAccountId[], + chainIds: CaipChainId[] | undefined, + { getAccountByAddress, getAssetsState }: ElementDataGetters, +) { + const { assetsMetadata, accountsAssets } = getAssetsState(); + + const parsedAccounts = addresses.map((address) => + parseCaipAccountId(address), + ); + + const accountChainIds = parsedAccounts.map(({ chainId }) => chainId); + + const filteredChainIds = + chainIds && chainIds.length > 0 + ? accountChainIds.filter((accountChainId) => + chainIds.includes(accountChainId), + ) + : accountChainIds; + + const accountId = getAccountByAddress(addresses[0])?.id; + + // We should never fail on this assertion as the address is already validated. + assert(accountId, `Account not found for address: ${addresses[0]}.`); + + const accountAssets = accountsAssets[accountId]; + + // The AssetSelector component in the UI will be disabled if there is no asset available for the account + // and networks provided. In this case, we return null to indicate that there is no default selected asset. + if (accountAssets.length === 0) { + return null; + } + + const nativeAsset = accountAssets.find((asset) => { + const { chainId, assetNamespace } = parseCaipAssetType(asset); + + return filteredChainIds.includes(chainId) && assetNamespace === 'slip44'; + }); + + if (nativeAsset) { + return { + asset: nativeAsset, + name: assetsMetadata[nativeAsset].name, + symbol: assetsMetadata[nativeAsset].symbol, + }; + } + + return { + asset: accountAssets[0], + name: assetsMetadata[accountAssets[0]].name, + symbol: assetsMetadata[accountAssets[0]].symbol, + }; +} + /** * Construct default state for a component. * @@ -62,6 +172,8 @@ export function assertNameIsUnique(state: InterfaceState, name: string) { * for component specific defaults and will not override the component value or existing form state. * * @param element - The input element. + * @param elementDataGetters - Data getters for the element. + * * @returns The default state for the specific component, if any. */ function constructComponentSpecificDefaultState( @@ -70,7 +182,9 @@ function constructComponentSpecificDefaultState( | DropdownElement | RadioGroupElement | CheckboxElement - | SelectorElement, + | SelectorElement + | AssetSelectorElement, + elementDataGetters: ElementDataGetters, ) { switch (element.type) { case 'Dropdown': { @@ -91,11 +205,47 @@ function constructComponentSpecificDefaultState( case 'Checkbox': return false; + case 'AssetSelector': + return getDefaultAsset( + element.props.addresses, + element.props.chainIds, + elementDataGetters, + ); + default: return null; } } +/** + * Get the state value for an asset selector. + * + * @param value - The asset selector value. + * @param getAssetState - A function to get the MultichainAssetController state. + * @returns The state value for the asset selector or null. + */ +export function getAssetSelectorStateValue( + value: CaipAssetType | undefined, + getAssetState: GetAssetsState, +): AssetSelectorState | null { + if (!value) { + return null; + } + + const { assetsMetadata } = getAssetState(); + const asset = assetsMetadata[value]; + + if (!asset) { + return null; + } + + return { + asset: value, + name: asset.name, + symbol: asset.symbol, + }; +} + /** * Get the state value for a stateful component. * @@ -103,6 +253,8 @@ function constructComponentSpecificDefaultState( * This function exists to account for components where that isn't the case. * * @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( @@ -111,12 +263,17 @@ function getComponentStateValue( | DropdownElement | RadioGroupElement | CheckboxElement - | SelectorElement, + | SelectorElement + | AssetSelectorElement, + { getAssetsState }: ElementDataGetters, ) { switch (element.type) { case 'Checkbox': return element.props.checked; + case 'AssetSelector': + return getAssetSelectorStateValue(element.props.value, getAssetsState); + default: return element.props.value; } @@ -127,6 +284,7 @@ function getComponentStateValue( * * @param oldState - The previous state. * @param element - The input element. + * @param elementDataGetters - Data getters for the element. * @param form - An optional form that the input is enclosed in. * @returns The input state. */ @@ -138,7 +296,9 @@ function constructInputState( | RadioGroupElement | FileInputElement | CheckboxElement - | SelectorElement, + | SelectorElement + | AssetSelectorElement, + elementDataGetters: ElementDataGetters, form?: string, ) { const oldStateUnwrapped = form ? (oldState[form] as FormState) : oldState; @@ -149,9 +309,9 @@ function constructInputState( } return ( - getComponentStateValue(element) ?? + getComponentStateValue(element, elementDataGetters) ?? oldInputState ?? - constructComponentSpecificDefaultState(element) ?? + constructComponentSpecificDefaultState(element, elementDataGetters) ?? null ); } @@ -161,11 +321,13 @@ function constructInputState( * * @param oldState - The previous state. * @param rootComponent - The UI component to construct state from. + * @param elementDataGetters - Data getters for the elements. * @returns The interface state of the passed component. */ export function constructState( oldState: InterfaceState, rootComponent: JSXElement, + elementDataGetters: ElementDataGetters, ): InterfaceState { const newState: InterfaceState = {}; @@ -189,6 +351,7 @@ export function constructState( } // Stateful components inside a form + // TODO: This is becoming a bit of a mess, we should consider refactoring this. if ( currentForm && (component.type === 'Input' || @@ -196,29 +359,37 @@ export function constructState( component.type === 'RadioGroup' || component.type === 'FileInput' || component.type === 'Checkbox' || - component.type === 'Selector') + component.type === 'Selector' || + component.type === 'AssetSelector') ) { const formState = newState[currentForm.name] as FormState; assertNameIsUnique(formState, component.props.name); formState[component.props.name] = constructInputState( oldState, component, + elementDataGetters, currentForm.name, ); return; } // Stateful components outside a form + // TODO: This is becoming a bit of a mess, we should consider refactoring this. if ( component.type === 'Input' || component.type === 'Dropdown' || component.type === 'RadioGroup' || component.type === 'FileInput' || component.type === 'Checkbox' || - component.type === 'Selector' + component.type === 'Selector' || + component.type === 'AssetSelector' ) { assertNameIsUnique(newState, component.props.name); - newState[component.props.name] = constructInputState(oldState, component); + newState[component.props.name] = constructInputState( + oldState, + component, + elementDataGetters, + ); } }); diff --git a/packages/snaps-controllers/src/multichain/MultichainRouter.ts b/packages/snaps-controllers/src/multichain/MultichainRouter.ts index 31e71f2d94..e2d48a2345 100644 --- a/packages/snaps-controllers/src/multichain/MultichainRouter.ts +++ b/packages/snaps-controllers/src/multichain/MultichainRouter.ts @@ -6,6 +6,7 @@ import { SnapEndowments, } from '@metamask/snaps-rpc-methods'; import type { Json, JsonRpcRequest, SnapId } from '@metamask/snaps-sdk'; +import type { InternalAccount } from '@metamask/snaps-utils'; import { HandlerType } from '@metamask/snaps-utils'; import type { CaipAccountId, @@ -43,19 +44,6 @@ export type MultichainRouterIsSupportedScopeAction = { handler: MultichainRouter['isSupportedScope']; }; -// Since the AccountsController depends on snaps-controllers we manually type this -type InternalAccount = { - id: string; - type: string; - address: string; - options: Record; - methods: string[]; - metadata: { - name: string; - snap?: { id: SnapId; enabled: boolean; name: string }; - }; -}; - type SnapKeyring = { submitRequest: (request: { account: string; diff --git a/packages/snaps-controllers/src/test-utils/controller.ts b/packages/snaps-controllers/src/test-utils/controller.ts index 386a4dfb24..7dc1efd3cc 100644 --- a/packages/snaps-controllers/src/test-utils/controller.ts +++ b/packages/snaps-controllers/src/test-utils/controller.ts @@ -22,7 +22,7 @@ import { SnapEndowments, WALLET_SNAP_PERMISSION_KEY, } from '@metamask/snaps-rpc-methods'; -import { text, type SnapId } from '@metamask/snaps-sdk'; +import type { SnapId, text } from '@metamask/snaps-sdk'; import { SnapCaveatType } from '@metamask/snaps-utils'; import { MockControllerMessenger, @@ -32,6 +32,7 @@ import { MOCK_ORIGIN, MOCK_SNAP_ID, TEST_SECRET_RECOVERY_PHRASE_SEED_BYTES, + getSnapObject, } from '@metamask/snaps-utils/test-utils'; import type { Json } from '@metamask/utils'; @@ -150,6 +151,8 @@ export const snapDialogPermissionKey = 'snap_dialog'; export const MOCK_INTERFACE_ID = 'QovlAsV2Z3xLP5hsrVMsz'; +export const MOCK_ACCOUNT_ID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; + export const MOCK_SNAP_SUBJECT_METADATA: SubjectMetadata = { origin: MOCK_SNAP_ID, subjectType: SubjectType.Snap, @@ -781,6 +784,9 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( 'PhishingController:maybeUpdateState', 'ApprovalController:hasRequest', 'ApprovalController:acceptRequest', + 'MultichainAssetsController:getState', + 'AccountsController:getAccountByAddress', + 'SnapController:get', ], allowedEvents: [ 'NotificationServicesController:notificationsListUpdated', @@ -798,6 +804,36 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( result: false, type: 'all', })); + + messenger.registerActionHandler( + 'MultichainAssetsController:getState', + () => ({ + assetsMetadata: { + // @ts-expect-error partial mock + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105': { + name: 'Solana', + symbol: 'SOL', + }, + }, + accountsAssets: { + [MOCK_ACCOUNT_ID]: [], + }, + }), + ); + + messenger.registerActionHandler( + 'AccountsController:getAccountByAddress', + // @ts-expect-error partial mock + (address: string) => ({ + address, + id: 'foo', + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + }), + ); + + messenger.registerActionHandler('SnapController:get', (snapId: string) => { + return getSnapObject({ id: snapId as SnapId }); + }); } return snapInterfaceControllerMessenger; diff --git a/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx b/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx index 1403fa95a3..b39283fc89 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", "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: "Address", "AssetSelector", "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: "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", "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/AssetSelector.test.tsx b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.test.tsx new file mode 100644 index 0000000000..bbcb557d21 --- /dev/null +++ b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.test.tsx @@ -0,0 +1,54 @@ +import { AssetSelector } from './AssetSelector'; + +describe('AssetSelector', () => { + it('renders an asset selector', () => { + const result = ( + + ); + + expect(result).toStrictEqual({ + type: 'AssetSelector', + props: { + name: 'foo', + addresses: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + }, + key: null, + }); + }); + + it('renders an asset selector with optional props', () => { + const result = ( + + ); + + expect(result).toStrictEqual({ + type: 'AssetSelector', + props: { + name: 'foo', + addresses: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + chainIds: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + value: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + disabled: true, + }, + key: null, + }); + }); +}); diff --git a/packages/snaps-sdk/src/jsx/components/form/AssetSelector.ts b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.ts new file mode 100644 index 0000000000..54a9a729d0 --- /dev/null +++ b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.ts @@ -0,0 +1,68 @@ +import type { + CaipChainId, + CaipAssetType, + CaipAccountId, +} from '@metamask/utils'; + +import { createSnapComponent } from '../../component'; + +/** + * The props of the {@link AssetSelector} component. + * + * @property name - The name of the asset selector. This is used to identify the + * state in the form data. + * @property addresses - The addresses of the account to pull the assets from as CAIP-10 Account IDs. + * Multiple CAIP-10 Account IDs can be provided, but the account address should be the same on all Account IDs. + * Only non-EIP-155 namespaces are supported for now. + * @property chainIds - The chain IDs to filter the assets. + * Only non-EIP-155 namespaces are supported for now. + * @property value - The selected value of the asset selector. + * Only non-EIP-155 namespaces are supported for now. + * @property disabled - Whether the asset selector is disabled. + */ +export type AssetSelectorProps = { + name: string; + addresses: CaipAccountId[]; + chainIds?: CaipChainId[] | undefined; + value?: CaipAssetType | undefined; + disabled?: boolean | undefined; +}; + +const TYPE = 'AssetSelector'; + +/** + * An asset selector component, which is used to create an asset selector. + * + * @param props - The props of the component. + * @property addresses - The addresses of the account to pull the assets from as CAIP-10 Account IDs. + * Multiple CAIP-10 Account IDs can be provided, but the account address should be the same on all Account IDs. + * Only non-EIP-155 namespaces are supported for now. + * @param props.chainIds - The chain IDs to filter the assets. + * Only non-EIP-155 namespaces are supported for now. + * @param props.value - The selected value of the asset selector. + * Only non-EIP-155 namespaces are supported for now. + * @param props.disabled - Whether the asset selector is disabled. + * @returns An asset selector element. + * @example + * + * @example + * + */ +export const AssetSelector = createSnapComponent< + AssetSelectorProps, + typeof TYPE +>(TYPE); + +export type AssetSelectorElement = ReturnType; diff --git a/packages/snaps-sdk/src/jsx/components/form/Field.ts b/packages/snaps-sdk/src/jsx/components/form/Field.ts index 9c4ccf9c7b..27b6f8286d 100644 --- a/packages/snaps-sdk/src/jsx/components/form/Field.ts +++ b/packages/snaps-sdk/src/jsx/components/form/Field.ts @@ -1,3 +1,4 @@ +import type { AssetSelectorElement } from './AssetSelector'; import type { CheckboxElement } from './Checkbox'; import type { DropdownElement } from './Dropdown'; import type { FileInputElement } from './FileInput'; @@ -26,7 +27,8 @@ export type FieldProps = { | FileInputElement | InputElement | CheckboxElement - | SelectorElement; + | SelectorElement + | AssetSelectorElement; }; const TYPE = 'Field'; diff --git a/packages/snaps-sdk/src/jsx/components/form/index.ts b/packages/snaps-sdk/src/jsx/components/form/index.ts index 3dc4221376..7512d750f6 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 { AssetSelectorElement } from './AssetSelector'; import type { ButtonElement } from './Button'; import type { CheckboxElement } from './Checkbox'; import type { DropdownElement } from './Dropdown'; @@ -11,6 +12,7 @@ import type { RadioGroupElement } from './RadioGroup'; import type { SelectorElement } from './Selector'; import type { SelectorOptionElement } from './SelectorOption'; +export * from './AssetSelector'; export * from './Button'; export * from './Checkbox'; export * from './Dropdown'; @@ -25,6 +27,7 @@ export * from './Selector'; export * from './SelectorOption'; export type StandardFormElement = + | 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 f6154bb1ce..132bdeb48e 100644 --- a/packages/snaps-sdk/src/jsx/validation.test.tsx +++ b/packages/snaps-sdk/src/jsx/validation.test.tsx @@ -35,6 +35,7 @@ import { Avatar, Banner, Skeleton, + AssetSelector, } from './components'; import { AddressStruct, @@ -74,6 +75,7 @@ import { AvatarStruct, BannerStruct, SkeletonStruct, + AssetSelectorStruct, } from './validation'; describe('KeyStruct', () => { @@ -312,6 +314,14 @@ describe('FieldStruct', () => { , + + + , ])('validates a field element', (value) => { expect(is(value, FieldStruct)).toBe(true); }); @@ -1687,3 +1697,71 @@ describe('SkeletonStruct', () => { expect(is(value, SkeletonStruct)).toBe(false); }); }); + +describe('AssetSelectorStruct', () => { + it.each([ + , + , + , + ])(`validates an AssetSelector element`, (value) => { + expect(is(value, AssetSelectorStruct)).toBe(true); + }); + it.each([ + 'foo', + 42, + null, + undefined, + {}, + [], + // @ts-expect-error - Invalid props. + , + // @ts-expect-error - Invalid props. + foo, + , + , + , + , + ])(`does not validate "%p"`, (value) => { + expect(is(value, AssetSelectorStruct)).toBe(false); + }); +}); diff --git a/packages/snaps-sdk/src/jsx/validation.ts b/packages/snaps-sdk/src/jsx/validation.ts index 2cbdaac2d3..ccb1108bb8 100644 --- a/packages/snaps-sdk/src/jsx/validation.ts +++ b/packages/snaps-sdk/src/jsx/validation.ts @@ -40,6 +40,7 @@ import type { StringElement, } from './component'; import type { + AssetSelectorElement, AvatarElement, SkeletonElement, AddressElement, @@ -86,7 +87,12 @@ import { svg, typedUnion, } from '../internals'; -import type { EmptyObject } from '../types'; +import { + NonEip155AssetTypeStruct, + NonEip155ChainIdStruct, + NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, + type EmptyObject, +} from '../types'; /** * A struct for the {@link Key} type. @@ -407,6 +413,23 @@ export const SelectorStruct: Describe = element('Selector', { disabled: optional(boolean()), }); +/** + * A struct for the {@link AssetSelectorElement} type. + */ +export const AssetSelectorStruct: Describe = element( + 'AssetSelector', + { + name: string(), + addresses: NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, + chainIds: optional(array(NonEip155ChainIdStruct)) as unknown as Struct< + Infer[] | undefined, + null + >, + value: optional(NonEip155AssetTypeStruct), + disabled: optional(boolean()), + }, +); + /** * A struct for the {@link RadioElement} type. */ @@ -475,6 +498,7 @@ const BOX_INPUT_BOTH = [ * A subset of JSX elements that are allowed as single children of the Field component. */ const FIELD_CHILDREN_ARRAY = [ + AssetSelectorStruct, InputStruct, DropdownStruct, RadioGroupStruct, @@ -482,6 +506,7 @@ const FIELD_CHILDREN_ARRAY = [ CheckboxStruct, SelectorStruct, ] as [ + typeof AssetSelectorStruct, typeof InputStruct, typeof DropdownStruct, typeof RadioGroupStruct, @@ -526,7 +551,8 @@ const FieldChildStruct = selectiveUnion((value) => { | FileInputElement | InputElement | CheckboxElement - | SelectorElement, + | SelectorElement + | AssetSelectorElement, null >; @@ -873,6 +899,7 @@ export const SpinnerStruct: Describe = element('Spinner'); */ export const BoxChildStruct = typedUnion([ AddressStruct, + AssetSelectorStruct, BoldStruct, BoxStruct, ButtonStruct, @@ -936,6 +963,7 @@ export const RootJSXElementStruct = typedUnion([ * A struct for the {@link JSXElement} type. */ export const JSXElementStruct: Describe = typedUnion([ + AssetSelectorStruct, ButtonStruct, InputStruct, FileInputStruct, diff --git a/packages/snaps-sdk/src/types/caip.test.ts b/packages/snaps-sdk/src/types/caip.test.ts new file mode 100644 index 0000000000..9858b93ff9 --- /dev/null +++ b/packages/snaps-sdk/src/types/caip.test.ts @@ -0,0 +1,84 @@ +import { is } from '@metamask/superstruct'; + +import { + NonEip155AssetTypeStruct, + NonEip155ChainIdStruct, + NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, +} from './caip'; + +describe('NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct', () => { + it('validates an array of matching non EIP-155 namespace addresses', () => { + expect( + is( + [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, + ), + ).toBe(true); + }); + + it("doesn't validate an array of matching addresses with an EIP-155 namespace", () => { + expect( + is( + [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + 'eip155:1:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, + ), + ).toBe(false); + }); + + it("doesn't validate an array of mismatching addresses", () => { + expect( + is( + [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:3PWWwUkCPALDXBcwQBRTYiob2C6xfCm35kzuoJr7ubuw', + ], + NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, + ), + ).toBe(false); + }); + + it("doesn't validate an array of mismatching chain namespaces", () => { + expect( + is( + [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + 'foo:3:0x1234567890123456789012345678901234567890', + ], + NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, + ), + ).toBe(false); + }); +}); + +describe('NonEip155ChainIdStruct', () => { + it('validates a non EIP-155 chain ID', () => { + expect( + is('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', NonEip155ChainIdStruct), + ).toBe(true); + }); + + it("doesn't validate an EIP-155 chain ID", () => { + expect(is('eip155:1', NonEip155ChainIdStruct)).toBe(false); + }); +}); + +describe('NonEip155AssetTypeStruct', () => { + it('validates a non EIP-155 asset type', () => { + expect( + is( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + NonEip155AssetTypeStruct, + ), + ).toBe(true); + }); + + it("doesn't validate an EIP-155 asset type", () => { + expect(is('eip155:1/slip44:60', NonEip155AssetTypeStruct)).toBe(false); + }); +}); diff --git a/packages/snaps-sdk/src/types/caip.ts b/packages/snaps-sdk/src/types/caip.ts index ee4b0e9df8..3717beeaaa 100644 --- a/packages/snaps-sdk/src/types/caip.ts +++ b/packages/snaps-sdk/src/types/caip.ts @@ -1,4 +1,15 @@ -import type { CaipAccountId, CaipChainId } from '@metamask/utils'; +import { array, refine } from '@metamask/superstruct'; +import { + CaipAccountIdStruct, + CaipAssetTypeStruct, + CaipChainIdStruct, + KnownCaipNamespace, + parseCaipAccountId, + parseCaipAssetType, + parseCaipChainId, + type CaipAccountId, + type CaipChainId, +} from '@metamask/utils'; export type { CaipAccountId, @@ -22,3 +33,72 @@ export type ChainId = CaipChainId; * @deprecated Use {@link CaipAccountId} instead. */ export type AccountId = CaipAccountId; + +/** + * A struct representing a list of non-EIP-155 CAIP-10 account IDs where the account addresses are the same. + */ +export const NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct = refine( + array(CaipAccountIdStruct), + 'Non-EIP-155 Matching Addresses Account ID List', + (value) => { + const parsedAccountIds = value.map((accountId) => + parseCaipAccountId(accountId), + ); + + if ( + !parsedAccountIds.every( + ({ address, chain: { namespace } }) => + address === parsedAccountIds[0].address && + namespace === parsedAccountIds[0].chain.namespace, + ) + ) { + return 'All account IDs must have the same address and namespace.'; + } + + const containsEip155 = parsedAccountIds.some(({ chain: { namespace } }) => { + return namespace === KnownCaipNamespace.Eip155; + }); + + if (containsEip155) { + return 'All account IDs must have non-EIP-155 namespaces.'; + } + + return true; + }, +); + +/** + * A struct representing a non-EIP-155 chain ID. + */ +export const NonEip155ChainIdStruct = refine( + CaipChainIdStruct, + 'Non-EIP-155 Chain ID', + (value) => { + const { namespace } = parseCaipChainId(value); + + if (namespace === KnownCaipNamespace.Eip155) { + return 'Chain ID must not be an EIP-155 chain ID.'; + } + + return true; + }, +); + +/** + * A struct representing a non-EIP-155 asset type. + */ +export const NonEip155AssetTypeStruct = refine( + CaipAssetTypeStruct, + 'Non-EIP-155 Asset Type', + (value) => { + const { + chain: { namespace }, + } = parseCaipAssetType(value); + + if (namespace === KnownCaipNamespace.Eip155) { + return 'Asset type must not be an EIP-155 asset type.'; + } + + return true; + }, +); diff --git a/packages/snaps-sdk/src/types/handlers/user-input.ts b/packages/snaps-sdk/src/types/handlers/user-input.ts index fb2bdb477f..fe8e65bee4 100644 --- a/packages/snaps-sdk/src/types/handlers/user-input.ts +++ b/packages/snaps-sdk/src/types/handlers/user-input.ts @@ -11,6 +11,7 @@ import { union, boolean, } from '@metamask/superstruct'; +import { CaipAssetTypeStruct } from '@metamask/utils'; import type { InterfaceContext } from '../interface'; @@ -71,11 +72,31 @@ export const FileStruct = object({ */ export type File = Infer; +export const AssetSelectorStateStruct = object({ + asset: CaipAssetTypeStruct, + name: string(), + symbol: string(), +}); + +/** + * The state of the asset selector component. + * + * @property asset - The CAIP-19 asset ID. + * @property name - The name of the asset. + * @property symbol - The symbol of the asset. + */ +export type AssetSelectorState = Infer; + export const FormSubmitEventStruct = assign( GenericEventStruct, object({ type: literal(UserInputEventType.FormSubmitEvent), - value: record(string(), nullable(union([string(), FileStruct, boolean()]))), + value: record( + string(), + nullable( + union([string(), FileStruct, boolean(), AssetSelectorStateStruct]), + ), + ), name: string(), }), ); @@ -100,7 +121,7 @@ export const InputChangeEventStruct = assign( object({ type: literal(UserInputEventType.InputChangeEvent), name: string(), - value: union([string(), boolean()]), + value: union([string(), boolean(), AssetSelectorStateStruct]), }), ); diff --git a/packages/snaps-sdk/src/types/index.ts b/packages/snaps-sdk/src/types/index.ts index bab24b6e09..2cd4ca99ea 100644 --- a/packages/snaps-sdk/src/types/index.ts +++ b/packages/snaps-sdk/src/types/index.ts @@ -4,7 +4,7 @@ import './global'; import './images'; /* eslint-enable import-x/no-unassigned-import */ -export type * from './caip'; +export * from './caip'; export * from './handlers'; export * from './methods'; export type * from './permissions'; diff --git a/packages/snaps-sdk/src/types/interface.ts b/packages/snaps-sdk/src/types/interface.ts index c11a36601a..d5086350a2 100644 --- a/packages/snaps-sdk/src/types/interface.ts +++ b/packages/snaps-sdk/src/types/interface.ts @@ -8,7 +8,7 @@ import { } from '@metamask/superstruct'; import { JsonStruct, hasProperty, isObject } from '@metamask/utils'; -import { FileStruct } from './handlers'; +import { AssetSelectorStateStruct, FileStruct } from './handlers'; import { selectiveUnion } from '../internals'; import type { JSXElement } from '../jsx'; import { RootJSXElementStruct } from '../jsx'; @@ -22,7 +22,12 @@ import { ComponentStruct } from '../ui'; * either the value of an input or a sub-state of a form. */ -export const StateStruct = union([FileStruct, string(), boolean()]); +export const StateStruct = union([ + AssetSelectorStateStruct, + FileStruct, + string(), + boolean(), +]); export const FormStateStruct = record(string(), nullable(StateStruct)); diff --git a/packages/snaps-utils/coverage.json b/packages/snaps-utils/coverage.json index b2aafe3a34..7a20a59bf4 100644 --- a/packages/snaps-utils/coverage.json +++ b/packages/snaps-utils/coverage.json @@ -2,5 +2,5 @@ "branches": 99.74, "functions": 98.93, "lines": 99.61, - "statements": 96.94 + "statements": 96.95 } diff --git a/packages/snaps-utils/src/account.ts b/packages/snaps-utils/src/account.ts new file mode 100644 index 0000000000..a1e4dbc972 --- /dev/null +++ b/packages/snaps-utils/src/account.ts @@ -0,0 +1,18 @@ +import type { SnapId } from '@metamask/snaps-sdk'; +import type { Json } from '@metamask/utils'; + +/** + * Copy of the original type from + * https://github.com/MetaMask/accounts/blob/main/packages/keyring-internal-api/src/types.ts + */ +export type InternalAccount = { + id: string; + type: string; + address: string; + options: Record; + methods: string[]; + metadata: { + name: string; + snap?: { id: SnapId; enabled: boolean; name: string }; + }; +}; diff --git a/packages/snaps-utils/src/index.ts b/packages/snaps-utils/src/index.ts index b7e9726780..92ae8f3843 100644 --- a/packages/snaps-utils/src/index.ts +++ b/packages/snaps-utils/src/index.ts @@ -1,3 +1,4 @@ +export type * from './account'; export * from './array'; export * from './auxiliary-files'; export * from './base64'; diff --git a/packages/snaps-utils/src/ui.test.tsx b/packages/snaps-utils/src/ui.test.tsx index b46e68c815..b13149e557 100644 --- a/packages/snaps-utils/src/ui.test.tsx +++ b/packages/snaps-utils/src/ui.test.tsx @@ -13,6 +13,7 @@ import { spinner, } from '@metamask/snaps-sdk'; import { + AssetSelector, Address, Bold, Box, @@ -37,12 +38,13 @@ import { hasChildren, getJsxElementFromComponent, getTextChildren, - validateJsxLinks, validateTextLinks, walkJsx, getJsxChildren, serialiseJsx, validateLink, + validateJsxElements, + validateAssetSelector, } from './ui'; describe('getTextChildren', () => { @@ -801,7 +803,35 @@ describe('validateTextLinks', () => { }); }); -describe('validateJsxLinks', () => { +describe('validateAssetSelector', () => { + it('passes if the address is available in the client', () => { + const getAccountByAddress = jest.fn().mockReturnValue({ + id: 'foo', + }); + + expect(() => + validateAssetSelector( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + getAccountByAddress, + ), + ).not.toThrow(); + }); + + it('throws if the address is not available in the client', () => { + const getAccountByAddress = jest.fn().mockReturnValue(undefined); + + expect(() => + validateAssetSelector( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + getAccountByAddress, + ), + ).toThrow( + `Could not find account for address: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv`, + ); + }); +}); + +describe('validateJsxElements', () => { it.each([ Foo, @@ -817,7 +847,11 @@ describe('validateJsxLinks', () => { const isOnPhishingList = () => false; expect(() => - validateJsxLinks(element, isOnPhishingList, jest.fn()), + validateJsxElements(element, { + isOnPhishingList, + getSnap: jest.fn(), + getAccountByAddress: jest.fn(), + }), ).not.toThrow(); }); @@ -825,13 +859,16 @@ describe('validateJsxLinks', () => { const isOnPhishingList = () => true; expect(() => - validateJsxLinks( + validateJsxElements( Foo https://foo.bar , - isOnPhishingList, - jest.fn(), + { + isOnPhishingList, + getSnap: jest.fn(), + getAccountByAddress: jest.fn(), + }, ), ).not.toThrow(); }); @@ -851,7 +888,11 @@ describe('validateJsxLinks', () => { const isOnPhishingList = () => true; expect(() => - validateJsxLinks(element, isOnPhishingList, jest.fn()), + validateJsxElements(element, { + isOnPhishingList, + getSnap: jest.fn(), + getAccountByAddress: jest.fn(), + }), ).toThrow('Invalid URL: The specified URL is not allowed.'); }); @@ -859,11 +900,11 @@ describe('validateJsxLinks', () => { const isOnPhishingList = () => false; expect(() => - validateJsxLinks( - Foo, + validateJsxElements(Foo, { isOnPhishingList, - jest.fn(), - ), + getSnap: jest.fn(), + getAccountByAddress: jest.fn(), + }), ).toThrow( 'Invalid URL: Protocol must be one of: https:, mailto:, metamask:.', ); @@ -873,13 +914,57 @@ describe('validateJsxLinks', () => { const isOnPhishingList = () => false; expect(() => - validateJsxLinks( - Foo, + validateJsxElements(Foo, { isOnPhishingList, - jest.fn(), - ), + getSnap: jest.fn(), + getAccountByAddress: jest.fn(), + }), ).toThrow('Invalid URL: Unable to parse URL.'); }); + + it('passes for a valid AssetSelector', () => { + const getAccountByAddress = jest.fn().mockReturnValue({ + id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + }); + + expect(() => + validateJsxElements( + , + { + getAccountByAddress, + isOnPhishingList: jest.fn(), + getSnap: jest.fn(), + }, + ), + ).not.toThrow(); + }); + + it('throws for an invalid AssetSelector', () => { + const getAccountByAddress = jest.fn().mockReturnValue(undefined); + + expect(() => + validateJsxElements( + , + { + getAccountByAddress, + isOnPhishingList: jest.fn(), + getSnap: jest.fn(), + }, + ), + ).toThrow( + 'Could not find account for address: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ); + }); }); describe('getTotalTextLength', () => { diff --git a/packages/snaps-utils/src/ui.tsx b/packages/snaps-utils/src/ui.tsx index 06dae6fa5f..845b06df43 100644 --- a/packages/snaps-utils/src/ui.tsx +++ b/packages/snaps-utils/src/ui.tsx @@ -1,4 +1,4 @@ -import type { Component } from '@metamask/snaps-sdk'; +import type { CaipAccountId, Component } from '@metamask/snaps-sdk'; import { NodeType } from '@metamask/snaps-sdk'; import type { BoldChildren, @@ -39,6 +39,7 @@ import { import { lexer, walkTokens } from 'marked'; import type { Token, Tokens } from 'marked'; +import type { InternalAccount } from './account'; import type { Snap } from './snaps'; import { parseMetaMaskUrl } from './url'; @@ -406,25 +407,63 @@ export function validateTextLinks( } /** - * Walk a JSX tree and validate each {@link LinkElement} node against the - * phishing list. + * Validate the asset selector component. + * + * @param address - The address of the account to pull the assets from. + * @param getAccountByAddress - A function to get an account by its address. + * + * @throws If the asset selector is invalid. + */ +export function validateAssetSelector( + address: CaipAccountId, + getAccountByAddress: (address: CaipAccountId) => InternalAccount | undefined, +) { + const account = getAccountByAddress(address); + + assert(account, `Could not find account for address: ${address}`); +} + +/** + * Walk a JSX tree and validate elements. + * This function validates Links and AssetSelectors. * * @param node - The JSX node to walk. - * @param isOnPhishingList - The function that checks the link against the + * @param hooks - The hooks to use for validation. + * @param hooks.isOnPhishingList - The function that checks the link against the * phishing list. - * @param getSnap - The function that returns a snap if installed, undefined otherwise. + * @param hooks.getSnap - The function that returns a snap if installed, undefined otherwise. + * @param hooks.getAccountByAddress - The function that returns an account by address. */ -export function validateJsxLinks( +export function validateJsxElements( node: JSXElement, - isOnPhishingList: (url: string) => boolean, - getSnap: (id: string) => Snap | undefined, + { + isOnPhishingList, + getSnap, + getAccountByAddress, + }: { + isOnPhishingList: (url: string) => boolean; + getSnap: (id: string) => Snap | undefined; + getAccountByAddress: ( + address: CaipAccountId, + ) => InternalAccount | undefined; + }, ) { walkJsx(node, (childNode) => { - if (childNode.type !== 'Link') { - return; + switch (childNode.type) { + case 'Link': + validateLink(childNode.props.href, isOnPhishingList, getSnap); + break; + case 'AssetSelector': + validateAssetSelector( + // We assume that the address part of the CAIP-10 account ID are the same, as + // that is already validated in the struct. + childNode.props.addresses[0], + getAccountByAddress, + ); + break; + default: + break; } - - validateLink(childNode.props.href, isOnPhishingList, getSnap); }); }