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);
});
}