From c2e22af4f4d93ec26677564d36a87570d51fee9b Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Wed, 26 Feb 2025 13:16:49 +0100 Subject: [PATCH 01/21] Add `AssetSelector` to `snaps-sdk` --- .../components/form/AssetSelector.test.tsx | 41 +++++++++++++++ .../src/jsx/components/form/AssetSelector.tsx | 52 +++++++++++++++++++ .../src/jsx/components/form/index.ts | 3 ++ packages/snaps-sdk/src/jsx/validation.ts | 28 +++++++++- packages/snaps-sdk/src/types/caip.test.ts | 44 ++++++++++++++++ packages/snaps-sdk/src/types/caip.ts | 41 ++++++++++++++- packages/snaps-sdk/src/types/index.ts | 2 +- 7 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 packages/snaps-sdk/src/jsx/components/form/AssetSelector.test.tsx create mode 100644 packages/snaps-sdk/src/jsx/components/form/AssetSelector.tsx create mode 100644 packages/snaps-sdk/src/types/caip.test.ts 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..be72c7a7e9 --- /dev/null +++ b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.test.tsx @@ -0,0 +1,41 @@ +import { AssetSelector } from './AssetSelector'; + +describe('AssetSelector', () => { + it('renders an asset selector', () => { + const result = ( + + ); + + expect(result).toStrictEqual({ + type: 'AssetSelector', + props: { + addresses: ['eip155:0:0x1234567890123456789012345678901234567890'], + }, + key: null, + }); + }); + + it('renders an asset selector with optional props', () => { + const result = ( + + ); + + expect(result).toStrictEqual({ + type: 'AssetSelector', + props: { + addresses: ['eip155:0:0x1234567890123456789012345678901234567890'], + chainIds: ['eip155:1'], + value: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + disabled: true, + }, + key: null, + }); + }); +}); diff --git a/packages/snaps-sdk/src/jsx/components/form/AssetSelector.tsx b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.tsx new file mode 100644 index 0000000000..fcd60f681c --- /dev/null +++ b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.tsx @@ -0,0 +1,52 @@ +import type { CaipAssetTypeOrId, CaipChainId } from '@metamask/utils'; +import type { MatchingAddressesCaipAccountIdList } from 'src/types'; + +import { createSnapComponent } from '../../component'; + +/** + * The props of the {@link AssetSelector} component. + * + * @property addresses - The addresses of the account to pull the assets from. + * Only one address is supported, but different chains can be used. + * @property chainIds - The chain IDs to filter the assets. + * @property value - The selected value of the asset selector. + * @property disabled - Whether the asset selector is disabled. + */ +export type AssetSelectorProps = { + addresses: MatchingAddressesCaipAccountIdList; + chainIds?: CaipChainId[] | undefined; + value?: CaipAssetTypeOrId | 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. + * @param props.addresses - The addresses of the account to pull the assets from. + * @param props.chainIds - The chain IDs to filter the assets. + * @param props.value - The selected value of the asset selector. + * @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/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.ts b/packages/snaps-sdk/src/jsx/validation.ts index 2cbdaac2d3..41f7406add 100644 --- a/packages/snaps-sdk/src/jsx/validation.ts +++ b/packages/snaps-sdk/src/jsx/validation.ts @@ -23,6 +23,8 @@ import { } from '@metamask/superstruct'; import { CaipAccountIdStruct, + CaipAssetTypeOrIdStruct, + CaipChainIdStruct, hasProperty, HexChecksumAddressStruct, isPlainObject, @@ -40,6 +42,7 @@ import type { StringElement, } from './component'; import type { + AssetSelectorElement, AvatarElement, SkeletonElement, AddressElement, @@ -86,7 +89,10 @@ import { svg, typedUnion, } from '../internals'; -import type { EmptyObject } from '../types'; +import { + MatchingAddressesCaipAccountIdListStruct, + type EmptyObject, +} from '../types'; /** * A struct for the {@link Key} type. @@ -407,6 +413,22 @@ export const SelectorStruct: Describe = element('Selector', { disabled: optional(boolean()), }); +/** + * A struct for the {@link AssetSelectorElement} type. + */ +export const AssetSelectorStruct: Describe = element( + 'AssetSelector', + { + addresses: MatchingAddressesCaipAccountIdListStruct, + chainIds: optional(array(CaipChainIdStruct)) as unknown as Struct< + Infer[] | undefined, + null + >, + value: optional(CaipAssetTypeOrIdStruct), + disabled: optional(boolean()), + }, +); + /** * A struct for the {@link RadioElement} type. */ @@ -475,6 +497,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 +505,7 @@ const FIELD_CHILDREN_ARRAY = [ CheckboxStruct, SelectorStruct, ] as [ + typeof AssetSelectorStruct, typeof InputStruct, typeof DropdownStruct, typeof RadioGroupStruct, @@ -873,6 +897,7 @@ export const SpinnerStruct: Describe = element('Spinner'); */ export const BoxChildStruct = typedUnion([ AddressStruct, + AssetSelectorStruct, BoldStruct, BoxStruct, ButtonStruct, @@ -936,6 +961,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..19af5c7815 --- /dev/null +++ b/packages/snaps-sdk/src/types/caip.test.ts @@ -0,0 +1,44 @@ +import { is } from '@metamask/superstruct'; + +import { MatchingAddressesCaipAccountIdListStruct } from './caip'; + +describe('MatchingAddressesCaipAccountIdListStruct', () => { + it('validates an array of matchin addresses', () => { + expect( + is( + [ + 'eip155:1:0x1234567890123456789012345678901234567890', + 'eip155:2:0x1234567890123456789012345678901234567890', + 'eip155:3:0x1234567890123456789012345678901234567890', + ], + MatchingAddressesCaipAccountIdListStruct, + ), + ).toBe(true); + }); + + it("doesn't validate an array of mismatching addresses", () => { + expect( + is( + [ + 'eip155:1:0x1234567890123456789012345678901234567890', + 'eip155:2:0x1234567890123456789012225678901234567890', + 'eip155:3:0x1234567890123456789012345678901234567890', + ], + MatchingAddressesCaipAccountIdListStruct, + ), + ).toBe(false); + }); + + it("doesn't validate an array of mismatching chain namespaces", () => { + expect( + is( + [ + 'eip155:1:0x1234567890123456789012345678901234567890', + 'eip155:2:0x1234567890123456789012345678901234567890', + 'foo:3:0x1234567890123456789012345678901234567890', + ], + MatchingAddressesCaipAccountIdListStruct, + ), + ).toBe(false); + }); +}); diff --git a/packages/snaps-sdk/src/types/caip.ts b/packages/snaps-sdk/src/types/caip.ts index ee4b0e9df8..f6f993e61e 100644 --- a/packages/snaps-sdk/src/types/caip.ts +++ b/packages/snaps-sdk/src/types/caip.ts @@ -1,4 +1,11 @@ -import type { CaipAccountId, CaipChainId } from '@metamask/utils'; +import type { Infer } from '@metamask/superstruct'; +import { array, refine } from '@metamask/superstruct'; +import { + CaipAccountIdStruct, + parseCaipAccountId, + type CaipAccountId, + type CaipChainId, +} from '@metamask/utils'; export type { CaipAccountId, @@ -22,3 +29,35 @@ export type ChainId = CaipChainId; * @deprecated Use {@link CaipAccountId} instead. */ export type AccountId = CaipAccountId; + +/** + * A stuct representing a list of CAIP-10 account IDs where the account addresses and namespaces are the same. + */ +export const MatchingAddressesCaipAccountIdListStruct = refine( + array(CaipAccountIdStruct), + '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 chain namespace.'; + } + + return true; + }, +); + +/** + * A list of CAIP-10 account IDs where the account addresses are the same. + */ +export type MatchingAddressesCaipAccountIdList = Infer< + typeof MatchingAddressesCaipAccountIdListStruct +>; 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'; From 14de4ec3cbd97c69b26469d89493e5502e1bc777 Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Wed, 26 Feb 2025 17:37:20 +0100 Subject: [PATCH 02/21] Wire up to `SnapInterfaceController` --- packages/snaps-controllers/coverage.json | 8 +- .../SnapInterfaceController.test.tsx | 31 +++ .../src/interface/SnapInterfaceController.ts | 33 ++- .../src/interface/utils.test.tsx | 246 +++++++++++++++--- .../snaps-controllers/src/interface/utils.ts | 91 ++++++- .../src/test-utils/controller.ts | 9 + .../components/form/AssetSelector.test.tsx | 4 + .../src/jsx/components/form/AssetSelector.tsx | 7 +- packages/snaps-sdk/src/jsx/validation.ts | 5 +- .../src/types/handlers/user-input.ts | 25 +- packages/snaps-sdk/src/types/interface.ts | 9 +- 11 files changed, 411 insertions(+), 57 deletions(-) diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index c73f8baa74..a0c65627a9 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.36, + "functions": 97.06, + "lines": 98.26, + "statements": 97.99 } diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx b/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx index f8d6ffd9d0..0bc941fa00 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,36 @@ 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 = ( + + ); + + await rootMessenger.call( + 'SnapInterfaceController:createInterface', + MOCK_SNAP_ID, + components, + ); + + expect(rootMessenger.call).toHaveBeenNthCalledWith( + 3, + 'MultichainAssetsController:getState', + ); + }); + it('can create a new interface from JSX', async () => { const rootMessenger = getRootSnapInterfaceControllerMessenger(); const controllerMessenger = diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts index cd25843886..47360445b5 100644 --- a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts +++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts @@ -17,11 +17,12 @@ 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 type { CaipAssetType, Json } from '@metamask/utils'; import { assert, hasProperty } from '@metamask/utils'; import { castDraft } from 'immer'; import { nanoid } from 'nanoid'; @@ -72,12 +73,23 @@ export type SnapInterfaceControllerGetStateAction = ControllerGetStateAction< SnapInterfaceControllerState >; +type MultichainAssetsControllerGetStateAction = ControllerGetStateAction< + 'MultichainAssetsController', + { + assetsMetadata: { + [asset: CaipAssetType]: FungibleAssetMetadata; + }; + accountsAssets: { [account: string]: CaipAssetType[] }; + } +>; + export type SnapInterfaceControllerAllowedActions = | TestOrigin | MaybeUpdateState | HasApprovalRequest | AcceptRequest - | GetSnap; + | GetSnap + | MultichainAssetsControllerGetStateAction; export type SnapInterfaceControllerActions = | CreateInterface @@ -249,7 +261,9 @@ export class SnapInterfaceController extends BaseController< validateInterfaceContext(context); const id = nanoid(); - const componentState = constructState({}, element); + const componentState = constructState({}, element, { + getAssetMetadata: this.#getAssetMetadata.bind(this), + }); this.update((draftState) => { // @ts-expect-error - TS2589: Type instantiation is excessively deep and @@ -299,7 +313,9 @@ export class SnapInterfaceController extends BaseController< validateInterfaceContext(context); const oldState = this.state.interfaces[id].state; - const newState = constructState(oldState, element); + const newState = constructState(oldState, element, { + getAssetMetadata: this.#getAssetMetadata.bind(this), + }); this.update((draftState) => { draftState.interfaces[id].state = newState; @@ -426,6 +442,15 @@ export class SnapInterfaceController extends BaseController< ); } + #getAssetMetadata(assetId: CaipAssetType) { + // TODO: Introduce an action in MultichainAssetsController to get a specific asset metadata. + const assets = this.messagingSystem.call( + 'MultichainAssetsController:getState', + ); + + return assets.assetsMetadata[assetId]; + } + /** * Utility function to validate the components of an interface. * Throws if something is invalid. diff --git a/packages/snaps-controllers/src/interface/utils.test.tsx b/packages/snaps-controllers/src/interface/utils.test.tsx index c00f9de819..27e4e5e768 100644 --- a/packages/snaps-controllers/src/interface/utils.test.tsx +++ b/packages/snaps-controllers/src/interface/utils.test.tsx @@ -15,9 +15,15 @@ import { Selector, Card, SelectorOption, + AssetSelector, } from '@metamask/snaps-sdk/jsx'; -import { assertNameIsUnique, constructState, getJsxInterface } from './utils'; +import { + assertNameIsUnique, + constructState, + getAssetSelectorStateValue, + getJsxInterface, +} from './utils'; describe('getJsxInterface', () => { it('returns the JSX interface for a JSX element', () => { @@ -64,6 +70,10 @@ describe('assertNameIsUnique', () => { }); describe('constructState', () => { + const elementDataGetters = { + getAssetMetadata: jest.fn(), + }; + it('can construct a new component state', () => { const element = ( @@ -76,7 +86,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: { bar: null } }); }); @@ -94,7 +104,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: { bar: null } }); }); @@ -116,7 +126,7 @@ describe('constructState', () => { ); - const result = constructState(state, element); + const result = constructState(state, element, elementDataGetters); expect(result).toStrictEqual({ foo: { bar: 'test', baz: null } }); }); @@ -137,7 +147,7 @@ describe('constructState', () => { ); - const result = constructState(state, element); + const result = constructState(state, element, elementDataGetters); expect(result).toStrictEqual({ form: { bar: 'test', baz: null } }); }); @@ -169,7 +179,7 @@ describe('constructState', () => { ); - const result = constructState(state, element); + const result = constructState(state, element, elementDataGetters); expect(result).toStrictEqual({ form1: { bar: 'test', baz: null }, @@ -197,7 +207,7 @@ describe('constructState', () => { ); - const result = constructState(state, element); + const result = constructState(state, element, elementDataGetters); expect(result).toStrictEqual({ form1: { bar: 'test', baz: null }, }); @@ -235,7 +245,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 +261,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'bar', }); @@ -264,7 +274,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'bar', }); @@ -277,7 +287,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: null, }); @@ -293,7 +303,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'option1', }); @@ -309,7 +319,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'option2', }); @@ -329,7 +339,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { foo: 'option1' }, }); @@ -349,7 +359,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { foo: 'option2' }, }); @@ -365,7 +375,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'option1', }); @@ -381,7 +391,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'option2', }); @@ -401,7 +411,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { foo: 'option1' }, }); @@ -421,7 +431,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { foo: 'option2' }, }); @@ -434,7 +444,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: true, }); @@ -451,7 +461,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { foo: false }, }); @@ -468,7 +478,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { foo: true }, }); @@ -488,7 +498,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'option1', }); @@ -508,7 +518,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'option2', }); @@ -532,7 +542,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { foo: 'option1' }, }); @@ -556,12 +566,139 @@ 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.getAssetMetadata.mockReturnValue({ + name: 'foobar', + symbol: 'FOO', + }); + + const element = ( + + + + ); + + const result = constructState({}, element, elementDataGetters); + + expect(result).toStrictEqual({ + foo: null, + }); + }); + + it('supports root level AssetSelector', () => { + elementDataGetters.getAssetMetadata.mockReturnValue({ + name: 'foobar', + symbol: 'FOO', + }); + + const element = ( + + + + ); + + const result = constructState({}, element, elementDataGetters); + + expect(result).toStrictEqual({ + foo: { + asset: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + name: 'foobar', + symbol: 'FOO', + }, + }); + }); + + it('sets default value for AssetSelector in form', () => { + const element = ( + +
+ + + +
+
+ ); + + const result = constructState({}, element, elementDataGetters); + + expect(result).toStrictEqual({ + form: { foo: null }, + }); + }); + + it('supports AssetSelector in form', () => { + elementDataGetters.getAssetMetadata.mockReturnValue({ + name: 'foobar', + symbol: 'FOO', + }); + + const element = ( + +
+ + + +
+
+ ); + + const result = constructState({}, element, elementDataGetters); + + expect(result).toStrictEqual({ + form: { + foo: { + asset: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + name: 'foobar', + symbol: 'FOO', + }, + }, + }); + }); + + it('sets the value to null if the asset metadata is not found', () => { + elementDataGetters.getAssetMetadata.mockReturnValue(undefined); + + const element = ( + + + + ); + + const result = constructState({}, element, elementDataGetters); + + expect(result).toStrictEqual({ + foo: null, + }); + }); + it('supports nested fields', () => { const element = ( @@ -579,7 +716,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { bar: 'option2' }, }); @@ -610,7 +747,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ form: { baz: 'option4' }, form2: { bar: 'option2' }, @@ -624,7 +761,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 +782,7 @@ describe('constructState', () => { ); - const result = constructState(state, element); + const result = constructState(state, element, elementDataGetters); expect(result).toStrictEqual({ foo: 'bar', }); @@ -654,7 +795,7 @@ describe('constructState', () => { ); - const result = constructState({}, element); + const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ foo: null, }); @@ -672,7 +813,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 +826,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 +843,45 @@ 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 getAssetMetadata = jest.fn(); + + it('returns the asset selector state value', () => { + getAssetMetadata.mockReturnValue({ + name: 'foobar', + symbol: 'FOO', + }); + + expect( + getAssetSelectorStateValue( + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + getAssetMetadata, + ), + ).toStrictEqual({ + asset: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + name: 'foobar', + symbol: 'FOO', + }); + }); + + it('returns null if the value is not set', () => { + expect(getAssetSelectorStateValue(undefined, getAssetMetadata)).toBeNull(); + }); + + it('returns null if the asset metadata is not found', () => { + getAssetMetadata.mockReturnValue(undefined); + + expect( + getAssetSelectorStateValue( + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + getAssetMetadata, + ), + ).toBeNull(); + }); +}); diff --git a/packages/snaps-controllers/src/interface/utils.ts b/packages/snaps-controllers/src/interface/utils.ts index 24923138a4..89748e823d 100644 --- a/packages/snaps-controllers/src/interface/utils.ts +++ b/packages/snaps-controllers/src/interface/utils.ts @@ -5,6 +5,8 @@ import type { ComponentOrElement, InterfaceContext, State, + FungibleAssetMetadata, + AssetSelectorState, } from '@metamask/snaps-sdk'; import type { DropdownElement, @@ -17,6 +19,7 @@ import type { RadioElement, SelectorElement, SelectorOptionElement, + AssetSelectorElement, } from '@metamask/snaps-sdk/jsx'; import { isJSXElementUnsafe } from '@metamask/snaps-sdk/jsx'; import { @@ -25,6 +28,28 @@ import { getJsxElementFromComponent, walkJsx, } from '@metamask/snaps-utils'; +import type { CaipAssetType } from '@metamask/utils'; + +/** + * A function to get asset metadata. + * This is used to get the metadata of an asset by its ID. + * + * @param assetId - The asset ID. + * @returns The asset metadata or undefined if not found. + */ +type GetAssetMetadata = ( + assetId: CaipAssetType, +) => FungibleAssetMetadata | undefined; + +/** + * Data getters for elements. + * This is used to get data from elements that is not directly accessible from the element itself. + * + * @param getAssetMetadata - A function to get asset metadata. + */ +type ElementDataGetters = { + getAssetMetadata: GetAssetMetadata; +}; /** * Get a JSX element from a component or JSX element. If the component is a @@ -70,7 +95,8 @@ function constructComponentSpecificDefaultState( | DropdownElement | RadioGroupElement | CheckboxElement - | SelectorElement, + | SelectorElement + | AssetSelectorElement, ) { switch (element.type) { case 'Dropdown': { @@ -96,6 +122,34 @@ function constructComponentSpecificDefaultState( } } +/** + * Get the state value for an asset selector. + * + * @param value - The asset selector value. + * @param getAssetMetadata - A function to get asset metadata. + * @returns The state value for the asset selector or null. + */ +export function getAssetSelectorStateValue( + value: CaipAssetType | undefined, + getAssetMetadata: GetAssetMetadata, +): AssetSelectorState | null { + if (!value) { + return null; + } + + const asset = getAssetMetadata(value); + + if (!asset) { + return null; + } + + return { + asset: value, + name: asset.name, + symbol: asset.symbol, + }; +} + /** * Get the state value for a stateful component. * @@ -103,6 +157,7 @@ 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. * @returns The state value for a given component. */ function getComponentStateValue( @@ -111,12 +166,20 @@ function getComponentStateValue( | DropdownElement | RadioGroupElement | CheckboxElement - | SelectorElement, + | SelectorElement + | AssetSelectorElement, + elementDataGetters: ElementDataGetters, ) { switch (element.type) { case 'Checkbox': return element.props.checked; + case 'AssetSelector': + return getAssetSelectorStateValue( + element.props.value, + elementDataGetters.getAssetMetadata, + ); + default: return element.props.value; } @@ -127,6 +190,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 +202,9 @@ function constructInputState( | RadioGroupElement | FileInputElement | CheckboxElement - | SelectorElement, + | SelectorElement + | AssetSelectorElement, + elementDataGetters: ElementDataGetters, form?: string, ) { const oldStateUnwrapped = form ? (oldState[form] as FormState) : oldState; @@ -149,7 +215,7 @@ function constructInputState( } return ( - getComponentStateValue(element) ?? + getComponentStateValue(element, elementDataGetters) ?? oldInputState ?? constructComponentSpecificDefaultState(element) ?? null @@ -161,11 +227,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 +257,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 +265,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/test-utils/controller.ts b/packages/snaps-controllers/src/test-utils/controller.ts index 386a4dfb24..334678feed 100644 --- a/packages/snaps-controllers/src/test-utils/controller.ts +++ b/packages/snaps-controllers/src/test-utils/controller.ts @@ -781,6 +781,7 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( 'PhishingController:maybeUpdateState', 'ApprovalController:hasRequest', 'ApprovalController:acceptRequest', + 'MultichainAssetsController:getState', ], allowedEvents: [ 'NotificationServicesController:notificationsListUpdated', @@ -798,6 +799,14 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( result: false, type: 'all', })); + + messenger.registerActionHandler( + 'MultichainAssetsController:getState', + () => ({ + assetsMetadata: {}, + accountsAssets: {}, + }), + ); } return snapInterfaceControllerMessenger; diff --git a/packages/snaps-sdk/src/jsx/components/form/AssetSelector.test.tsx b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.test.tsx index be72c7a7e9..102611c0fd 100644 --- a/packages/snaps-sdk/src/jsx/components/form/AssetSelector.test.tsx +++ b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.test.tsx @@ -4,6 +4,7 @@ describe('AssetSelector', () => { it('renders an asset selector', () => { const result = ( ); @@ -11,6 +12,7 @@ describe('AssetSelector', () => { expect(result).toStrictEqual({ type: 'AssetSelector', props: { + name: 'foo', addresses: ['eip155:0:0x1234567890123456789012345678901234567890'], }, key: null, @@ -20,6 +22,7 @@ describe('AssetSelector', () => { it('renders an asset selector with optional props', () => { const result = ( { expect(result).toStrictEqual({ type: 'AssetSelector', props: { + name: 'foo', addresses: ['eip155:0:0x1234567890123456789012345678901234567890'], chainIds: ['eip155:1'], value: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', diff --git a/packages/snaps-sdk/src/jsx/components/form/AssetSelector.tsx b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.tsx index fcd60f681c..bba3a049ff 100644 --- a/packages/snaps-sdk/src/jsx/components/form/AssetSelector.tsx +++ b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.tsx @@ -1,4 +1,4 @@ -import type { CaipAssetTypeOrId, CaipChainId } from '@metamask/utils'; +import type { CaipAssetType, CaipChainId } from '@metamask/utils'; import type { MatchingAddressesCaipAccountIdList } from 'src/types'; import { createSnapComponent } from '../../component'; @@ -6,6 +6,8 @@ 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. * Only one address is supported, but different chains can be used. * @property chainIds - The chain IDs to filter the assets. @@ -13,9 +15,10 @@ import { createSnapComponent } from '../../component'; * @property disabled - Whether the asset selector is disabled. */ export type AssetSelectorProps = { + name: string; addresses: MatchingAddressesCaipAccountIdList; chainIds?: CaipChainId[] | undefined; - value?: CaipAssetTypeOrId | undefined; + value?: CaipAssetType | undefined; disabled?: boolean | undefined; }; diff --git a/packages/snaps-sdk/src/jsx/validation.ts b/packages/snaps-sdk/src/jsx/validation.ts index 41f7406add..85eafd0c4d 100644 --- a/packages/snaps-sdk/src/jsx/validation.ts +++ b/packages/snaps-sdk/src/jsx/validation.ts @@ -23,7 +23,7 @@ import { } from '@metamask/superstruct'; import { CaipAccountIdStruct, - CaipAssetTypeOrIdStruct, + CaipAssetTypeStruct, CaipChainIdStruct, hasProperty, HexChecksumAddressStruct, @@ -419,12 +419,13 @@ export const SelectorStruct: Describe = element('Selector', { export const AssetSelectorStruct: Describe = element( 'AssetSelector', { + name: string(), addresses: MatchingAddressesCaipAccountIdListStruct, chainIds: optional(array(CaipChainIdStruct)) as unknown as Struct< Infer[] | undefined, null >, - value: optional(CaipAssetTypeOrIdStruct), + value: optional(CaipAssetTypeStruct), disabled: optional(boolean()), }, ); diff --git a/packages/snaps-sdk/src/types/handlers/user-input.ts b/packages/snaps-sdk/src/types/handlers/user-input.ts index fb2bdb477f..b37d382de7 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 ticker - The ticker 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/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)); From aab5155164d0d068c73b025fd8484c1ba3968a0e Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Wed, 26 Feb 2025 17:46:05 +0100 Subject: [PATCH 03/21] update manifests --- .../jsx/components/form/{AssetSelector.tsx => AssetSelector.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename packages/snaps-sdk/src/jsx/components/form/{AssetSelector.tsx => AssetSelector.ts} (96%) diff --git a/packages/snaps-sdk/src/jsx/components/form/AssetSelector.tsx b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.ts similarity index 96% rename from packages/snaps-sdk/src/jsx/components/form/AssetSelector.tsx rename to packages/snaps-sdk/src/jsx/components/form/AssetSelector.ts index bba3a049ff..c16f6d8ea1 100644 --- a/packages/snaps-sdk/src/jsx/components/form/AssetSelector.tsx +++ b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.ts @@ -1,6 +1,6 @@ import type { CaipAssetType, CaipChainId } from '@metamask/utils'; -import type { MatchingAddressesCaipAccountIdList } from 'src/types'; +import type { MatchingAddressesCaipAccountIdList } from '../../../types'; import { createSnapComponent } from '../../component'; /** From 0ff420d81ccfe4d22e38312cb5217c57aed025a9 Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Wed, 26 Feb 2025 18:00:27 +0100 Subject: [PATCH 04/21] fix `createInterface` tests --- .../examples/packages/send-flow/src/components/SendForm.tsx | 4 ++++ .../snaps-rpc-methods/src/permitted/createInterface.test.tsx | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/examples/packages/send-flow/src/components/SendForm.tsx b/packages/examples/packages/send-flow/src/components/SendForm.tsx index 8a7dcf03e0..80b810ee4b 100644 --- a/packages/examples/packages/send-flow/src/components/SendForm.tsx +++ b/packages/examples/packages/send-flow/src/components/SendForm.tsx @@ -1,4 +1,5 @@ import { + AssetSelector, Box, Button, Field, @@ -60,6 +61,9 @@ export const SendForm: SnapComponent = ({ + + + {selectedCurrency} 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, From c57c39c78ab3aac7fc86746c049580efcefc5fcd Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Fri, 28 Feb 2025 14:39:11 +0100 Subject: [PATCH 05/21] Fix issues --- .../send-flow/src/components/SendForm.tsx | 37 ++++++++++++------- .../src/jsx/components/form/Field.ts | 4 +- packages/snaps-sdk/src/jsx/validation.ts | 3 +- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/examples/packages/send-flow/src/components/SendForm.tsx b/packages/examples/packages/send-flow/src/components/SendForm.tsx index 80b810ee4b..2660660d7d 100644 --- a/packages/examples/packages/send-flow/src/components/SendForm.tsx +++ b/packages/examples/packages/send-flow/src/components/SendForm.tsx @@ -12,7 +12,6 @@ import { } from '@metamask/snaps-sdk/jsx'; import { AccountSelector } from './AccountSelector'; -import btcIcon from '../images/btc.svg'; import jazzicon3 from '../images/jazzicon3.svg'; import type { Account, SendFormErrors } from '../types'; @@ -57,21 +56,31 @@ export const SendForm: SnapComponent = ({ }) => (
- - - - + + + - + - - - {selectedCurrency} - - - + + + + {selectedCurrency} + + + + + 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/validation.ts b/packages/snaps-sdk/src/jsx/validation.ts index 85eafd0c4d..927e838c90 100644 --- a/packages/snaps-sdk/src/jsx/validation.ts +++ b/packages/snaps-sdk/src/jsx/validation.ts @@ -551,7 +551,8 @@ const FieldChildStruct = selectiveUnion((value) => { | FileInputElement | InputElement | CheckboxElement - | SelectorElement, + | SelectorElement + | AssetSelectorElement, null >; From c94e7d0b7e45b0f2c8cd89ea3b75def62e3a7a08 Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Tue, 4 Mar 2025 14:59:49 +0100 Subject: [PATCH 06/21] add account handling --- packages/snaps-controllers/coverage.json | 6 +- packages/snaps-controllers/package.json | 5 + .../SnapInterfaceController.test.tsx | 62 ++++++++- .../src/interface/SnapInterfaceController.ts | 37 ++++- .../src/interface/utils.test.tsx | 35 +++++ .../snaps-controllers/src/interface/utils.ts | 41 +++++- .../src/test-utils/controller.ts | 11 ++ yarn.lock | 131 ++++++++++++++++++ 8 files changed, 319 insertions(+), 9 deletions(-) diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index a0c65627a9..26a4917c09 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 93.36, - "functions": 97.06, + "branches": 93.37, + "functions": 97.08, "lines": 98.26, - "statements": 97.99 + "statements": 98 } diff --git a/packages/snaps-controllers/package.json b/packages/snaps-controllers/package.json index 6f3d3b9fd6..7b96bc8157 100644 --- a/packages/snaps-controllers/package.json +++ b/packages/snaps-controllers/package.json @@ -83,7 +83,12 @@ "@metamask/base-controller": "^8.0.0", "@metamask/json-rpc-engine": "^10.0.2", "@metamask/json-rpc-middleware-stream": "^8.0.7", +<<<<<<< HEAD "@metamask/key-tree": "^10.1.0", +======= + "@metamask/key-tree": "^10.0.2", + "@metamask/keyring-internal-api": "^6.0.0", +>>>>>>> 1e98fa8c (add account handling) "@metamask/object-multiplex": "^2.1.0", "@metamask/permission-controller": "^11.0.6", "@metamask/phishing-controller": "^12.4.0", diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx b/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx index 0bc941fa00..cdebc6d0af 100644 --- a/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx +++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx @@ -185,7 +185,7 @@ describe('SnapInterfaceController', () => { ); expect(rootMessenger.call).toHaveBeenNthCalledWith( - 3, + 4, 'MultichainAssetsController:getState', ); }); @@ -443,6 +443,66 @@ describe('SnapInterfaceController', () => { ); }); + 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: eip155:1:0x1234567890123456789012345678901234567890', + ); + + expect(rootMessenger.call).toHaveBeenNthCalledWith( + 3, + 'AccountsController:getAccountByAddress', + '0x1234567890123456789012345678901234567890', + ); + }); 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 47360445b5..96e61fc9a8 100644 --- a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts +++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts @@ -8,6 +8,7 @@ import type { ControllerStateChangeEvent, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { MaybeUpdateState, TestOrigin, @@ -22,14 +23,15 @@ import type { import { ContentType } from '@metamask/snaps-sdk'; import type { JSXElement } from '@metamask/snaps-sdk/jsx'; import { getJsonSizeUnsafe, validateJsxLinks } from '@metamask/snaps-utils'; -import type { CaipAssetType, Json } from '@metamask/utils'; -import { assert, hasProperty } from '@metamask/utils'; +import type { CaipAccountId, CaipAssetType, Json } from '@metamask/utils'; +import { assert, hasProperty, parseCaipAccountId } from '@metamask/utils'; import { castDraft } from 'immer'; import { nanoid } from 'nanoid'; import { constructState, getJsxInterface, + validateAssetSelector, validateInterfaceContext, } from './utils'; import type { GetSnap } from '../snaps'; @@ -68,6 +70,11 @@ export type ResolveInterface = { handler: SnapInterfaceController['resolveInterface']; }; +type AccountsControllerGetAccountByAddressAction = { + type: `AccountsController:getAccountByAddress`; + handler: (address: string) => InternalAccount | undefined; +}; + export type SnapInterfaceControllerGetStateAction = ControllerGetStateAction< typeof controllerName, SnapInterfaceControllerState @@ -89,7 +96,8 @@ export type SnapInterfaceControllerAllowedActions = | HasApprovalRequest | AcceptRequest | GetSnap - | MultichainAssetsControllerGetStateAction; + | MultichainAssetsControllerGetStateAction + | AccountsControllerGetAccountByAddressAction; export type SnapInterfaceControllerActions = | CreateInterface @@ -442,6 +450,27 @@ 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 asset metadata for a given asset ID. + * + * @param assetId - The asset ID. + * @returns The asset metadata or undefined if not found. + */ #getAssetMetadata(assetId: CaipAssetType) { // TODO: Introduce an action in MultichainAssetsController to get a specific asset metadata. const assets = this.messagingSystem.call( @@ -472,6 +501,8 @@ export class SnapInterfaceController extends BaseController< this.#checkPhishingList.bind(this), (id: string) => this.messagingSystem.call('SnapController:get', id), ); + + validateAssetSelector(element, 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 27e4e5e768..7d7d4fd60f 100644 --- a/packages/snaps-controllers/src/interface/utils.test.tsx +++ b/packages/snaps-controllers/src/interface/utils.test.tsx @@ -23,6 +23,7 @@ import { constructState, getAssetSelectorStateValue, getJsxInterface, + validateAssetSelector, } from './utils'; describe('getJsxInterface', () => { @@ -885,3 +886,37 @@ describe('getAssetSelectorStateValue', () => { ).toBeNull(); }); }); + +describe('validateAssetSelector', () => { + it('passes if the address is available in the client', () => { + const getAccountByAddress = jest.fn().mockReturnValue({ + id: 'foo', + }); + + expect( + validateAssetSelector( + , + getAccountByAddress, + ), + ).toBeUndefined(); + }); + + it('throws if the address is not available in the client', () => { + const getAccountByAddress = jest.fn().mockReturnValue(undefined); + + expect(() => + validateAssetSelector( + , + getAccountByAddress, + ), + ).toThrow( + `Could not find account for address: eip155:0:0x1234567890123456789012345678901234567890`, + ); + }); +}); diff --git a/packages/snaps-controllers/src/interface/utils.ts b/packages/snaps-controllers/src/interface/utils.ts index 89748e823d..6a392c92b5 100644 --- a/packages/snaps-controllers/src/interface/utils.ts +++ b/packages/snaps-controllers/src/interface/utils.ts @@ -1,3 +1,4 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; import { assert } from '@metamask/snaps-sdk'; import type { FormState, @@ -28,11 +29,10 @@ import { getJsxElementFromComponent, walkJsx, } from '@metamask/snaps-utils'; -import type { CaipAssetType } from '@metamask/utils'; +import { type CaipAssetType, type CaipAccountId } from '@metamask/utils'; /** * A function to get asset metadata. - * This is used to get the metadata of an asset by its ID. * * @param assetId - The asset ID. * @returns The asset metadata or undefined if not found. @@ -41,6 +41,16 @@ type GetAssetMetadata = ( assetId: CaipAssetType, ) => FungibleAssetMetadata | undefined; +/** + * 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. @@ -80,6 +90,33 @@ export function assertNameIsUnique(state: InterfaceState, name: string) { ); } +/** + * Validate the asset selector component. + * + * @param node - The JSX node to validate. + * @param getAccountByAddress - A function to get an account by its address. + * + * @throws If the asset selector is invalid. + */ +export function validateAssetSelector( + node: JSXElement, + getAccountByAddress: GetAccountByAddress, +) { + walkJsx(node, (childNode) => { + if (childNode.type !== 'AssetSelector') { + return; + } + + // We can assume that the addresses are the same for all CAIP account IDs + const account = getAccountByAddress(childNode.props.addresses[0]); + + assert( + account, + `Could not find account for address: ${childNode.props.addresses[0]}`, + ); + }); +} + /** * Construct default state for a component. * diff --git a/packages/snaps-controllers/src/test-utils/controller.ts b/packages/snaps-controllers/src/test-utils/controller.ts index 334678feed..30d196fa20 100644 --- a/packages/snaps-controllers/src/test-utils/controller.ts +++ b/packages/snaps-controllers/src/test-utils/controller.ts @@ -782,6 +782,7 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( 'ApprovalController:hasRequest', 'ApprovalController:acceptRequest', 'MultichainAssetsController:getState', + 'AccountsController:getAccountByAddress', ], allowedEvents: [ 'NotificationServicesController:notificationsListUpdated', @@ -807,6 +808,16 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( accountsAssets: {}, }), ); + + messenger.registerActionHandler( + 'AccountsController:getAccountByAddress', + // @ts-expect-error partial mock + (address: string) => ({ + address, + id: 'foo', + scopes: ['eip155:0'], + }), + ); } return snapInterfaceControllerMessenger; diff --git a/yarn.lock b/yarn.lock index 0f5e2fbbcc..a470f8bf8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3292,6 +3292,15 @@ __metadata: languageName: node linkType: hard +"@ethereumjs/common@npm:^4.4.0": + version: 4.4.0 + resolution: "@ethereumjs/common@npm:4.4.0" + dependencies: + "@ethereumjs/util": "npm:^9.1.0" + checksum: 10/dd5cc78575a762b367601f94d6af7e36cb3a5ecab45eec0c1259c433e755a16c867753aa88f331e3963791a18424ad0549682a3a6a0a160640fe846db6ce8014 + languageName: node + linkType: hard + "@ethereumjs/rlp@npm:^4.0.1": version: 4.0.1 resolution: "@ethereumjs/rlp@npm:4.0.1" @@ -3322,6 +3331,18 @@ __metadata: languageName: node linkType: hard +"@ethereumjs/tx@npm:^5.4.0": + version: 5.4.0 + resolution: "@ethereumjs/tx@npm:5.4.0" + dependencies: + "@ethereumjs/common": "npm:^4.4.0" + "@ethereumjs/rlp": "npm:^5.0.2" + "@ethereumjs/util": "npm:^9.1.0" + ethereum-cryptography: "npm:^2.2.1" + checksum: 10/8d2c0a69ab37015f945f9de065cfb9f05e8e79179efeed725ea0a14760c3eb8ff900bcf915bb71ec29fe2f753db35d1b78a15ac4ddec489e87c995dec1ba6e85 + languageName: node + linkType: hard + "@ethereumjs/util@npm:^8.1.0": version: 8.1.0 resolution: "@ethereumjs/util@npm:8.1.0" @@ -4850,6 +4871,53 @@ __metadata: languageName: node linkType: hard +"@metamask/keyring-api@npm:^17.2.1": + version: 17.2.1 + resolution: "@metamask/keyring-api@npm:17.2.1" + dependencies: + "@metamask/keyring-utils": "npm:^2.3.1" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.1.0" + bech32: "npm:^2.0.0" + checksum: 10/666b8506724c0f759e755ddc888fc0ecb44ef98bcf2f9d15ce009d00b93c126415c0af9f5037157a63f7fc7524358601650589819e487f7acb3e4748467b0a7b + languageName: node + linkType: hard + +"@metamask/keyring-internal-api@npm:^6.0.0": + version: 6.0.0 + resolution: "@metamask/keyring-internal-api@npm:6.0.0" + dependencies: + "@metamask/keyring-api": "npm:^17.2.1" + "@metamask/keyring-utils": "npm:^3.0.0" + "@metamask/superstruct": "npm:^3.1.0" + checksum: 10/069945b3423e7b6bd0b8735d65e17c968e494bc3f8c06e585d6e27f09ced0027541440c9e90ffbcd59b1daf91d7848c09be010a8ceb547ed3c4f6465e810b7a8 + languageName: node + linkType: hard + +"@metamask/keyring-utils@npm:^2.3.1": + version: 2.3.1 + resolution: "@metamask/keyring-utils@npm:2.3.1" + dependencies: + "@ethereumjs/tx": "npm:^4.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.1.0" + bitcoin-address-validation: "npm:^2.2.3" + checksum: 10/4a11b780621d82ab2d3fe39fbaed0ea87c01139c925c4c26cb25e2361bd855eae1c7c8cf01a84d2030de3bbef65590caecfe538f37490f75cad8a0a65b318c95 + languageName: node + linkType: hard + +"@metamask/keyring-utils@npm:^3.0.0": + version: 3.0.0 + resolution: "@metamask/keyring-utils@npm:3.0.0" + dependencies: + "@ethereumjs/tx": "npm:^5.4.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.1.0" + bitcoin-address-validation: "npm:^2.2.3" + checksum: 10/eff3c0b9a86d6a25c5dd443946ba3ff56cb94fcb915a4eb061089819805e1e78eba2ea5cfb12a47ec4606542870c417de422f755947389ab9f3a4f08e96742db + languageName: node + linkType: hard + "@metamask/lifecycle-hooks-example-snap@workspace:^, @metamask/lifecycle-hooks-example-snap@workspace:packages/examples/packages/lifecycle-hooks": version: 0.0.0-use.local resolution: "@metamask/lifecycle-hooks-example-snap@workspace:packages/examples/packages/lifecycle-hooks" @@ -5399,6 +5467,7 @@ __metadata: "@metamask/json-rpc-engine": "npm:^10.0.2" "@metamask/json-rpc-middleware-stream": "npm:^8.0.7" "@metamask/key-tree": "npm:^10.1.0" + "@metamask/keyring-internal-api": "npm:^6.0.0" "@metamask/object-multiplex": "npm:^2.1.0" "@metamask/permission-controller": "npm:^11.0.6" "@metamask/phishing-controller": "npm:^12.4.0" @@ -6185,7 +6254,15 @@ __metadata: languageName: node linkType: hard +<<<<<<< HEAD +"@noble/curves@npm:1.4.2, @noble/curves@npm:~1.4.0": +======= +<<<<<<< HEAD +"@noble/curves@npm:1.4.2, @noble/curves@npm:^1.1.0, @noble/curves@npm:^1.2.0, @noble/curves@npm:~1.4.0": +======= "@noble/curves@npm:1.4.2, @noble/curves@npm:~1.4.0": +>>>>>>> 97d29693 (add account handling) +>>>>>>> 1e98fa8c (add account handling) version: 1.4.2 resolution: "@noble/curves@npm:1.4.2" dependencies: @@ -6768,6 +6845,16 @@ __metadata: languageName: node linkType: hard +"@scure/bip39@npm:1.3.0": + version: 1.3.0 + resolution: "@scure/bip39@npm:1.3.0" + dependencies: + "@noble/hashes": "npm:~1.4.0" + "@scure/base": "npm:~1.1.6" + checksum: 10/7d71fd58153de22fe8cd65b525f6958a80487bc9d0fbc32c71c328aeafe41fa259f989d2f1e0fa4fdfeaf83b8fcf9310d52ed9862987e46c2f2bfb9dd8cf9fc1 + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.27.8": version: 0.27.8 resolution: "@sinclair/typebox@npm:0.27.8" @@ -9273,6 +9360,13 @@ __metadata: languageName: node linkType: hard +"base58-js@npm:^1.0.0": + version: 1.0.5 + resolution: "base58-js@npm:1.0.5" + checksum: 10/46c1b39d3a70bca0a47d56069c74a25d547680afd0f28609c90f280f5d614f5de36db5df993fa334db24008a68ab784a72fcdaa13eb40078e03c8999915a1100 + languageName: node + linkType: hard + "base64-js@npm:^1.0.2, base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -9303,6 +9397,13 @@ __metadata: languageName: node linkType: hard +"bech32@npm:^2.0.0": + version: 2.0.0 + resolution: "bech32@npm:2.0.0" + checksum: 10/fa15acb270b59aa496734a01f9155677b478987b773bf701f465858bf1606c6a970085babd43d71ce61895f1baa594cb41a2cd1394bd2c6698f03cc2d811300e + languageName: node + linkType: hard + "big-integer@npm:^1.6.17": version: 1.6.51 resolution: "big-integer@npm:1.6.51" @@ -9372,6 +9473,17 @@ __metadata: languageName: node linkType: hard +"bitcoin-address-validation@npm:^2.2.3": + version: 2.2.3 + resolution: "bitcoin-address-validation@npm:2.2.3" + dependencies: + base58-js: "npm:^1.0.0" + bech32: "npm:^2.0.0" + sha256-uint8array: "npm:^0.10.3" + checksum: 10/01603b5edf610ecf0843ae546534313f1cffabc8e7435a3678bc9788f18a54e51302218a539794aafd49beb5be70b5d1d507eb7442cb33970fcd665592a71305 + languageName: node + linkType: hard + "bl@npm:^4.0.3, bl@npm:^4.1.0": version: 4.1.0 resolution: "bl@npm:4.1.0" @@ -12729,6 +12841,18 @@ __metadata: languageName: node linkType: hard +"ethereum-cryptography@npm:^2.2.1": + version: 2.2.1 + resolution: "ethereum-cryptography@npm:2.2.1" + dependencies: + "@noble/curves": "npm:1.4.2" + "@noble/hashes": "npm:1.4.0" + "@scure/bip32": "npm:1.4.0" + "@scure/bip39": "npm:1.3.0" + checksum: 10/ab123bbfe843500ac2d645ce9edc4bc814962ffb598db6bf8bf01fbecac656e6c81ff4cf2472f1734844bbcbad2bf658d8b699cb7248d768e0f06ae13ecf43b8 + languageName: node + linkType: hard + "ethers@npm:^6.3.0": version: 6.13.4 resolution: "ethers@npm:6.13.4" @@ -20335,6 +20459,13 @@ __metadata: languageName: node linkType: hard +"sha256-uint8array@npm:^0.10.3": + version: 0.10.7 + resolution: "sha256-uint8array@npm:0.10.7" + checksum: 10/e427f9d2f9c521dea552f033d3f0c3bd641ab214d214dd41bde3c805edde393519cf982b3eee7d683b32e5f28fa23b2278d25935940e13fbe831b216a37832be + languageName: node + linkType: hard + "shallow-clone@npm:^0.1.2": version: 0.1.2 resolution: "shallow-clone@npm:0.1.2" From dd256eb11cd2d30ac7af273bfb5cea210962b82c Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Wed, 12 Mar 2025 11:06:03 +0100 Subject: [PATCH 07/21] revert send flow snap --- .../send-flow/src/components/SendForm.tsx | 39 +++++++------------ 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/packages/examples/packages/send-flow/src/components/SendForm.tsx b/packages/examples/packages/send-flow/src/components/SendForm.tsx index 2660660d7d..8a7dcf03e0 100644 --- a/packages/examples/packages/send-flow/src/components/SendForm.tsx +++ b/packages/examples/packages/send-flow/src/components/SendForm.tsx @@ -1,5 +1,4 @@ import { - AssetSelector, Box, Button, Field, @@ -12,6 +11,7 @@ import { } from '@metamask/snaps-sdk/jsx'; import { AccountSelector } from './AccountSelector'; +import btcIcon from '../images/btc.svg'; import jazzicon3 from '../images/jazzicon3.svg'; import type { Account, SendFormErrors } from '../types'; @@ -56,31 +56,18 @@ export const SendForm: SnapComponent = ({ }) => ( - - - - - - - - - - {selectedCurrency} - - - - - + + + + + + + {selectedCurrency} + + + From 4a0c7fe4961c890ef9aee6f8e32651822a9d416c Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Wed, 12 Mar 2025 16:11:22 +0100 Subject: [PATCH 08/21] refactor to only support non-EIP155 values --- .../packages/dialogs/snap.manifest.json | 2 +- .../packages/file-upload/snap.manifest.json | 2 +- .../packages/home-page/snap.manifest.json | 2 +- .../interactive-ui/snap.manifest.json | 2 +- .../examples/packages/jsx/snap.manifest.json | 2 +- .../packages/preinstalled/snap.manifest.json | 2 +- .../packages/send-flow/snap.manifest.json | 2 +- packages/snaps-controllers/coverage.json | 8 +- .../SnapInterfaceController.test.tsx | 15 +- .../src/interface/SnapInterfaceController.ts | 20 +- .../src/interface/utils.test.tsx | 401 ++++++++++++++++-- .../snaps-controllers/src/interface/utils.ts | 129 +++++- .../src/test-utils/controller.ts | 6 +- .../components/form/AssetSelector.test.tsx | 25 +- .../src/jsx/components/form/AssetSelector.ts | 30 +- .../snaps-sdk/src/jsx/validation.test.tsx | 63 +++ packages/snaps-sdk/src/jsx/validation.ts | 14 +- packages/snaps-sdk/src/types/caip.test.ts | 60 ++- packages/snaps-sdk/src/types/caip.ts | 80 ++++ yarn.lock | 26 +- 20 files changed, 780 insertions(+), 111 deletions(-) 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 26a4917c09..ddf594074d 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { - "branches": 93.37, - "functions": 97.08, - "lines": 98.26, - "statements": 98 + "branches": 93.5, + "functions": 97.12, + "lines": 98.29, + "statements": 98.02 } diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx b/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx index cdebc6d0af..767cc36836 100644 --- a/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx +++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx @@ -173,8 +173,10 @@ describe('SnapInterfaceController', () => { const components = ( ); @@ -482,7 +484,9 @@ describe('SnapInterfaceController', () => { ); @@ -494,15 +498,16 @@ describe('SnapInterfaceController', () => { element, ), ).rejects.toThrow( - 'Could not find account for address: eip155:1:0x1234567890123456789012345678901234567890', + 'Could not find account for address: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ); expect(rootMessenger.call).toHaveBeenNthCalledWith( 3, 'AccountsController:getAccountByAddress', - '0x1234567890123456789012345678901234567890', + '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 96e61fc9a8..eb30a9ce12 100644 --- a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts +++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts @@ -271,6 +271,8 @@ export class SnapInterfaceController extends BaseController< const id = nanoid(); const componentState = constructState({}, element, { getAssetMetadata: this.#getAssetMetadata.bind(this), + getAssetsState: this.#getAssetsState.bind(this), + getAccountByAddress: this.#getAccountByAddress.bind(this), }); this.update((draftState) => { @@ -323,6 +325,8 @@ export class SnapInterfaceController extends BaseController< const oldState = this.state.interfaces[id].state; const newState = constructState(oldState, element, { getAssetMetadata: this.#getAssetMetadata.bind(this), + getAssetsState: this.#getAssetsState.bind(this), + getAccountByAddress: this.#getAccountByAddress.bind(this), }); this.update((draftState) => { @@ -465,6 +469,15 @@ export class SnapInterfaceController extends BaseController< ); } + /** + * Get the MultichainAssetsController state. + * + * @returns The MultichainAssetsController state. + */ + #getAssetsState() { + return this.messagingSystem.call('MultichainAssetsController:getState'); + } + /** * Get the asset metadata for a given asset ID. * @@ -472,12 +485,9 @@ export class SnapInterfaceController extends BaseController< * @returns The asset metadata or undefined if not found. */ #getAssetMetadata(assetId: CaipAssetType) { - // TODO: Introduce an action in MultichainAssetsController to get a specific asset metadata. - const assets = this.messagingSystem.call( - 'MultichainAssetsController:getState', - ); + const { assetsMetadata } = this.#getAssetsState(); - return assets.assetsMetadata[assetId]; + return assetsMetadata[assetId]; } /** diff --git a/packages/snaps-controllers/src/interface/utils.test.tsx b/packages/snaps-controllers/src/interface/utils.test.tsx index 7d7d4fd60f..197ed3074a 100644 --- a/packages/snaps-controllers/src/interface/utils.test.tsx +++ b/packages/snaps-controllers/src/interface/utils.test.tsx @@ -21,7 +21,9 @@ import { import { assertNameIsUnique, constructState, + getAssetSelectorDefaultState, getAssetSelectorStateValue, + getDefaultAsset, getJsxInterface, validateAssetSelector, } from './utils'; @@ -73,6 +75,8 @@ describe('assertNameIsUnique', () => { describe('constructState', () => { const elementDataGetters = { getAssetMetadata: jest.fn(), + getAssetsState: jest.fn(), + getAccountByAddress: jest.fn(), }; it('can construct a new component state', () => { @@ -574,16 +578,29 @@ describe('constructState', () => { }); it('sets default value for root level AssetSelector', () => { - elementDataGetters.getAssetMetadata.mockReturnValue({ - name: 'foobar', - symbol: 'FOO', + elementDataGetters.getAssetsState.mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105': { + name: 'Solana', + symbol: 'SOL', + }, + }, + accountsAssets: { + foo: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105'], + }, + }); + + elementDataGetters.getAccountByAddress.mockReturnValue({ + id: 'foo', }); const element = ( ); @@ -591,22 +608,28 @@ describe('constructState', () => { const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ - foo: null, + foo: { + asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105', + name: 'Solana', + symbol: 'SOL', + }, }); }); it('supports root level AssetSelector', () => { elementDataGetters.getAssetMetadata.mockReturnValue({ - name: 'foobar', - symbol: 'FOO', + name: 'Solana', + symbol: 'SOL', }); const element = ( ); @@ -615,14 +638,30 @@ describe('constructState', () => { expect(result).toStrictEqual({ foo: { - asset: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', - name: 'foobar', - symbol: '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: { + foo: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105'], + }, + }); + + elementDataGetters.getAccountByAddress.mockReturnValue({ + id: 'foo', + }); + const element = ( @@ -630,7 +669,7 @@ describe('constructState', () => { @@ -641,14 +680,20 @@ describe('constructState', () => { const result = constructState({}, element, elementDataGetters); expect(result).toStrictEqual({ - form: { foo: null }, + form: { + foo: { + asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105', + name: 'Solana', + symbol: 'SOL', + }, + }, }); }); it('supports AssetSelector in form', () => { elementDataGetters.getAssetMetadata.mockReturnValue({ - name: 'foobar', - symbol: 'FOO', + name: 'Solana', + symbol: 'SOL', }); const element = ( @@ -658,9 +703,9 @@ describe('constructState', () => { @@ -672,9 +717,9 @@ describe('constructState', () => { expect(result).toStrictEqual({ form: { foo: { - asset: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', - name: 'foobar', - symbol: 'FOO', + asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105', + name: 'Solana', + symbol: 'SOL', }, }, }); @@ -682,13 +727,65 @@ describe('constructState', () => { it('sets the value to null if the asset metadata is not found', () => { elementDataGetters.getAssetMetadata.mockReturnValue(undefined); + elementDataGetters.getAssetsState.mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105': { + name: 'Solana', + symbol: 'SOL', + }, + }, + accountsAssets: { + foo: [], + }, + }); + + elementDataGetters.getAccountByAddress.mockReturnValue({ + id: 'foo', + }); + + 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: { + foo: [], + }, + }); + + elementDataGetters.getAccountByAddress.mockReturnValue({ + id: 'foo', + }); const element = ( ); @@ -855,19 +952,19 @@ describe('getAssetSelectorStateValue', () => { it('returns the asset selector state value', () => { getAssetMetadata.mockReturnValue({ - name: 'foobar', - symbol: 'FOO', + name: 'Solana', + symbol: 'SOL', }); expect( getAssetSelectorStateValue( - 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', getAssetMetadata, ), ).toStrictEqual({ - asset: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', - name: 'foobar', - symbol: 'FOO', + asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + name: 'Solana', + symbol: 'SOL', }); }); @@ -880,13 +977,83 @@ describe('getAssetSelectorStateValue', () => { expect( getAssetSelectorStateValue( - 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', getAssetMetadata, ), ).toBeNull(); }); }); +describe('getAssetSelectorDefaultState', () => { + it('returns the default asset for an asset selector', () => { + const getAccountByAddress = jest.fn().mockReturnValue({ + id: 'foo', + }); + + const getAssetsState = jest.fn().mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + name: 'Solana', + symbol: 'SOL', + }, + }, + accountsAssets: { + foo: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], + }, + }); + + const getAssetMetadata = jest.fn(); + + expect( + getAssetSelectorDefaultState( + , + { getAccountByAddress, getAssetsState, getAssetMetadata }, + ), + ).toStrictEqual({ + asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + name: 'Solana', + symbol: 'SOL', + }); + }); + + it('returns null if the default asset is not found', () => { + const getAccountByAddress = jest.fn().mockReturnValue({ + id: 'foo', + }); + + const getAssetsState = jest.fn().mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + name: 'Solana', + symbol: 'SOL', + }, + }, + accountsAssets: { + foo: [], + }, + }); + + const getAssetMetadata = jest.fn(); + + expect( + getAssetSelectorDefaultState( + , + { getAccountByAddress, getAssetsState, getAssetMetadata }, + ), + ).toBeNull(); + }); +}); + describe('validateAssetSelector', () => { it('passes if the address is available in the client', () => { const getAccountByAddress = jest.fn().mockReturnValue({ @@ -897,7 +1064,9 @@ describe('validateAssetSelector', () => { validateAssetSelector( , getAccountByAddress, ), @@ -911,12 +1080,178 @@ describe('validateAssetSelector', () => { validateAssetSelector( , getAccountByAddress, ), ).toThrow( - `Could not find account for address: eip155:0:0x1234567890123456789012345678901234567890`, + `Could not find account for address: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv`, ); }); }); + +describe('getDefaultAsset', () => { + it('returns the native asset if available', () => { + const getAssetsState = jest.fn().mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + name: 'Solana', + symbol: 'SOL', + }, + }, + accountsAssets: { + foo: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], + }, + }); + + const getAccountByAddress = jest.fn().mockReturnValue({ + id: 'foo', + }); + + const getAssetMetadata = jest.fn(); + + expect( + getDefaultAsset( + [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + undefined, + { getAssetsState, getAccountByAddress, getAssetMetadata }, + ), + ).toStrictEqual({ + address: '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: { + foo: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + ], + }, + }); + + const getAccountByAddress = jest.fn().mockReturnValue({ + id: 'foo', + }); + + const getAssetMetadata = jest.fn(); + + expect( + getDefaultAsset( + [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + undefined, + { getAssetsState, getAccountByAddress, getAssetMetadata }, + ), + ).toStrictEqual({ + address: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + name: 'USDC', + symbol: 'USDC', + }); + }); + + it('returns undefined if no assets are available', () => { + const getAssetsState = jest.fn().mockReturnValue({ + assetsMetadata: {}, + accountsAssets: { + foo: [], + }, + }); + + const getAccountByAddress = jest.fn().mockReturnValue({ + id: 'foo', + }); + + const getAssetMetadata = jest.fn(); + + expect( + getDefaultAsset( + [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + undefined, + { getAssetsState, getAccountByAddress, getAssetMetadata }, + ), + ).toBeUndefined(); + }); + + 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: { + foo: [ + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ], + }, + }); + + const getAccountByAddress = jest.fn().mockReturnValue({ + id: 'foo', + }); + + const getAssetMetadata = jest.fn(); + + expect( + getDefaultAsset( + [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + { getAssetsState, getAccountByAddress, getAssetMetadata }, + ), + ).toStrictEqual({ + address: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + name: 'Solana', + symbol: 'SOL', + }); + }); + + it('returns undefined if the account is not found', () => { + const getAssetsState = jest.fn().mockReturnValue({ + assetsMetadata: {}, + accountsAssets: { + foo: [], + }, + }); + + const getAccountByAddress = jest.fn().mockReturnValue(undefined); + + const getAssetMetadata = jest.fn(); + + expect( + getDefaultAsset( + [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + undefined, + { getAssetsState, getAccountByAddress, getAssetMetadata }, + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/snaps-controllers/src/interface/utils.ts b/packages/snaps-controllers/src/interface/utils.ts index 6a392c92b5..77e312ca15 100644 --- a/packages/snaps-controllers/src/interface/utils.ts +++ b/packages/snaps-controllers/src/interface/utils.ts @@ -8,6 +8,7 @@ import type { State, FungibleAssetMetadata, AssetSelectorState, + CaipChainId, } from '@metamask/snaps-sdk'; import type { DropdownElement, @@ -29,7 +30,24 @@ import { getJsxElementFromComponent, walkJsx, } from '@metamask/snaps-utils'; -import { type CaipAssetType, type CaipAccountId } from '@metamask/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 asset metadata. @@ -40,7 +58,6 @@ import { type CaipAssetType, type CaipAccountId } from '@metamask/utils'; type GetAssetMetadata = ( assetId: CaipAssetType, ) => FungibleAssetMetadata | undefined; - /** * A function to get an account by its address. * @@ -56,9 +73,13 @@ type GetAccountByAddress = ( * This is used to get data from elements that is not directly accessible from the element itself. * * @param getAssetMetadata - A function to get asset metadata. + * @param getDefaultAsset - A function to get the default asset for an address. + * @param getAssetState - A function to get the MultichainAssetController state. */ type ElementDataGetters = { getAssetMetadata: GetAssetMetadata; + getAssetsState: GetAssetsState; + getAccountByAddress: GetAccountByAddress; }; /** @@ -90,6 +111,68 @@ 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; + + if (!accountId) { + return undefined; + } + + const accountAssets = accountsAssets[accountId]; + + if (accountAssets.length === 0) { + return undefined; + } + + const nativeAsset = accountAssets.find((asset) => { + const { chainId, assetNamespace } = parseCaipAssetType(asset); + + return filteredChainIds.includes(chainId) && assetNamespace === 'slip44'; + }); + + if (!nativeAsset) { + return { + address: accountAssets[0], + ...assetsMetadata[accountAssets[0]], + }; + } + + return { + address: nativeAsset, + ...assetsMetadata[nativeAsset], + }; +} + /** * Validate the asset selector component. * @@ -124,6 +207,8 @@ export function validateAssetSelector( * 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( @@ -134,6 +219,7 @@ function constructComponentSpecificDefaultState( | CheckboxElement | SelectorElement | AssetSelectorElement, + elementDataGetters: ElementDataGetters, ) { switch (element.type) { case 'Dropdown': { @@ -154,11 +240,40 @@ function constructComponentSpecificDefaultState( case 'Checkbox': return false; + case 'AssetSelector': + return getAssetSelectorDefaultState(element, elementDataGetters); + default: return null; } } +/** + * Get the default state for an asset selector. + * + * @param element - The asset selector element. + * @param elementDataGetters - Data getters for the element. + * @returns The default state for the asset selector or null. + */ +export function getAssetSelectorDefaultState( + element: AssetSelectorElement, + elementDataGetters: ElementDataGetters, +) { + const { chainIds, addresses } = element.props; + + const asset = getDefaultAsset(addresses, chainIds, elementDataGetters); + + if (!asset) { + return null; + } + + return { + asset: asset.address, + name: asset.name, + symbol: asset.symbol, + }; +} + /** * Get the state value for an asset selector. * @@ -195,6 +310,7 @@ export function getAssetSelectorStateValue( * * @param element - The input element. * @param elementDataGetters - Data getters for the element. + * @param elementDataGetters.getAssetMetadata - A function to get asset metadata. * @returns The state value for a given component. */ function getComponentStateValue( @@ -205,17 +321,14 @@ function getComponentStateValue( | CheckboxElement | SelectorElement | AssetSelectorElement, - elementDataGetters: ElementDataGetters, + { getAssetMetadata }: ElementDataGetters, ) { switch (element.type) { case 'Checkbox': return element.props.checked; case 'AssetSelector': - return getAssetSelectorStateValue( - element.props.value, - elementDataGetters.getAssetMetadata, - ); + return getAssetSelectorStateValue(element.props.value, getAssetMetadata); default: return element.props.value; @@ -254,7 +367,7 @@ function constructInputState( return ( getComponentStateValue(element, elementDataGetters) ?? oldInputState ?? - constructComponentSpecificDefaultState(element) ?? + constructComponentSpecificDefaultState(element, elementDataGetters) ?? null ); } diff --git a/packages/snaps-controllers/src/test-utils/controller.ts b/packages/snaps-controllers/src/test-utils/controller.ts index 30d196fa20..4f0676841f 100644 --- a/packages/snaps-controllers/src/test-utils/controller.ts +++ b/packages/snaps-controllers/src/test-utils/controller.ts @@ -805,7 +805,9 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( 'MultichainAssetsController:getState', () => ({ assetsMetadata: {}, - accountsAssets: {}, + accountsAssets: { + foo: [], + }, }), ); @@ -815,7 +817,7 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( (address: string) => ({ address, id: 'foo', - scopes: ['eip155:0'], + scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], }), ); } diff --git a/packages/snaps-sdk/src/jsx/components/form/AssetSelector.test.tsx b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.test.tsx index 102611c0fd..bbcb557d21 100644 --- a/packages/snaps-sdk/src/jsx/components/form/AssetSelector.test.tsx +++ b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.test.tsx @@ -5,7 +5,9 @@ describe('AssetSelector', () => { const result = ( ); @@ -13,7 +15,9 @@ describe('AssetSelector', () => { type: 'AssetSelector', props: { name: 'foo', - addresses: ['eip155:0:0x1234567890123456789012345678901234567890'], + addresses: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], }, key: null, }); @@ -23,9 +27,11 @@ describe('AssetSelector', () => { const result = ( ); @@ -34,9 +40,12 @@ describe('AssetSelector', () => { type: 'AssetSelector', props: { name: 'foo', - addresses: ['eip155:0:0x1234567890123456789012345678901234567890'], - chainIds: ['eip155:1'], - value: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + 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 index c16f6d8ea1..7f95535923 100644 --- a/packages/snaps-sdk/src/jsx/components/form/AssetSelector.ts +++ b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.ts @@ -1,6 +1,8 @@ -import type { CaipAssetType, CaipChainId } from '@metamask/utils'; - -import type { MatchingAddressesCaipAccountIdList } from '../../../types'; +import type { + NonEip155AssetType, + NonEip155ChainId, + NonEip155MatchingAddressesCaipAccountIdList, +} from '../../../types'; import { createSnapComponent } from '../../component'; /** @@ -10,15 +12,18 @@ import { createSnapComponent } from '../../component'; * state in the form data. * @property addresses - The addresses of the account to pull the assets from. * Only one address is supported, but different chains can be used. + * 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: MatchingAddressesCaipAccountIdList; - chainIds?: CaipChainId[] | undefined; - value?: CaipAssetType | undefined; + addresses: NonEip155MatchingAddressesCaipAccountIdList; + chainIds?: NonEip155ChainId[] | undefined; + value?: NonEip155AssetType | undefined; disabled?: boolean | undefined; }; @@ -29,22 +34,29 @@ const TYPE = 'AssetSelector'; * * @param props - The props of the component. * @param props.addresses - The addresses of the account to pull the assets from. + * Only one address is supported, but different chains can be used. + * 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< diff --git a/packages/snaps-sdk/src/jsx/validation.test.tsx b/packages/snaps-sdk/src/jsx/validation.test.tsx index f6154bb1ce..94fc6bf676 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', () => { @@ -1687,3 +1689,64 @@ 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 927e838c90..fa4d0b4e54 100644 --- a/packages/snaps-sdk/src/jsx/validation.ts +++ b/packages/snaps-sdk/src/jsx/validation.ts @@ -23,8 +23,6 @@ import { } from '@metamask/superstruct'; import { CaipAccountIdStruct, - CaipAssetTypeStruct, - CaipChainIdStruct, hasProperty, HexChecksumAddressStruct, isPlainObject, @@ -90,7 +88,9 @@ import { typedUnion, } from '../internals'; import { - MatchingAddressesCaipAccountIdListStruct, + NonEip155AssetTypeStruct, + NonEip155ChainIdStruct, + NonEip155MatchingAddressesCaipAccountIdListStruct, type EmptyObject, } from '../types'; @@ -420,12 +420,12 @@ export const AssetSelectorStruct: Describe = element( 'AssetSelector', { name: string(), - addresses: MatchingAddressesCaipAccountIdListStruct, - chainIds: optional(array(CaipChainIdStruct)) as unknown as Struct< - Infer[] | undefined, + addresses: NonEip155MatchingAddressesCaipAccountIdListStruct, + chainIds: optional(array(NonEip155ChainIdStruct)) as unknown as Struct< + Infer[] | undefined, null >, - value: optional(CaipAssetTypeStruct), + value: optional(NonEip155AssetTypeStruct), disabled: optional(boolean()), }, ); diff --git a/packages/snaps-sdk/src/types/caip.test.ts b/packages/snaps-sdk/src/types/caip.test.ts index 19af5c7815..de6412f2ad 100644 --- a/packages/snaps-sdk/src/types/caip.test.ts +++ b/packages/snaps-sdk/src/types/caip.test.ts @@ -1,6 +1,11 @@ import { is } from '@metamask/superstruct'; -import { MatchingAddressesCaipAccountIdListStruct } from './caip'; +import { + MatchingAddressesCaipAccountIdListStruct, + NonEip155AssetTypeStruct, + NonEip155ChainIdStruct, + NonEip155MatchingAddressesCaipAccountIdListStruct, +} from './caip'; describe('MatchingAddressesCaipAccountIdListStruct', () => { it('validates an array of matchin addresses', () => { @@ -42,3 +47,56 @@ describe('MatchingAddressesCaipAccountIdListStruct', () => { ).toBe(false); }); }); + +describe('NonEip155MatchingAddressesCaipAccountIdListStruct', () => { + it('validates an array of matching non EIP-155 namespace addresses', () => { + expect( + is( + [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + NonEip155MatchingAddressesCaipAccountIdListStruct, + ), + ).toBe(true); + }); + + it("doesn't validate an array of matching addresses with an EIP-155 namespace", () => { + expect( + is( + [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + 'eip155:1:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + ], + NonEip155MatchingAddressesCaipAccountIdListStruct, + ), + ).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 f6f993e61e..36060e397e 100644 --- a/packages/snaps-sdk/src/types/caip.ts +++ b/packages/snaps-sdk/src/types/caip.ts @@ -2,7 +2,12 @@ import type { Infer } from '@metamask/superstruct'; import { array, refine } from '@metamask/superstruct'; import { CaipAccountIdStruct, + CaipAssetTypeStruct, + CaipChainIdStruct, + KnownCaipNamespace, parseCaipAccountId, + parseCaipAssetType, + parseCaipChainId, type CaipAccountId, type CaipChainId, } from '@metamask/utils'; @@ -61,3 +66,78 @@ export const MatchingAddressesCaipAccountIdListStruct = refine( export type MatchingAddressesCaipAccountIdList = Infer< typeof MatchingAddressesCaipAccountIdListStruct >; + +/** + * A struct representing a list of non-EIP-155 CAIP-10 account IDs where the account addresses are the same. + */ +export const NonEip155MatchingAddressesCaipAccountIdListStruct = refine( + MatchingAddressesCaipAccountIdListStruct, + 'Non-EIP-155 Matching Addresses Account ID List', + (value) => { + const containsEip155 = value.some((accountId) => { + const { + chain: { namespace }, + } = parseCaipAccountId(accountId); + + return namespace === KnownCaipNamespace.Eip155; + }); + + if (containsEip155) { + return 'All account IDs must have non-EIP-155 chain namespaces.'; + } + return true; + }, +); + +/** + * A list of non-EIP-155 CAIP-10 account IDs where the account addresses are the same. + */ +export type NonEip155MatchingAddressesCaipAccountIdList = Infer< + typeof NonEip155MatchingAddressesCaipAccountIdListStruct +>; + +/** + * 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 non-EIP-155 chain ID. + */ +export type NonEip155ChainId = Infer; + +/** + * 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; + }, +); + +/** + * A non-EIP-155 asset type. + */ +export type NonEip155AssetType = Infer; diff --git a/yarn.lock b/yarn.lock index a470f8bf8d..88cf1ba433 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6254,6 +6254,7 @@ __metadata: languageName: node linkType: hard +<<<<<<< HEAD <<<<<<< HEAD "@noble/curves@npm:1.4.2, @noble/curves@npm:~1.4.0": ======= @@ -6263,6 +6264,9 @@ __metadata: "@noble/curves@npm:1.4.2, @noble/curves@npm:~1.4.0": >>>>>>> 97d29693 (add account handling) >>>>>>> 1e98fa8c (add account handling) +======= +"@noble/curves@npm:1.4.2, @noble/curves@npm:^1.1.0, @noble/curves@npm:^1.2.0, @noble/curves@npm:~1.4.0": +>>>>>>> c47feb14 (refactor to only support non-EIP155 values) version: 1.4.2 resolution: "@noble/curves@npm:1.4.2" dependencies: @@ -6845,16 +6849,6 @@ __metadata: languageName: node linkType: hard -"@scure/bip39@npm:1.3.0": - version: 1.3.0 - resolution: "@scure/bip39@npm:1.3.0" - dependencies: - "@noble/hashes": "npm:~1.4.0" - "@scure/base": "npm:~1.1.6" - checksum: 10/7d71fd58153de22fe8cd65b525f6958a80487bc9d0fbc32c71c328aeafe41fa259f989d2f1e0fa4fdfeaf83b8fcf9310d52ed9862987e46c2f2bfb9dd8cf9fc1 - languageName: node - linkType: hard - "@sinclair/typebox@npm:^0.27.8": version: 0.27.8 resolution: "@sinclair/typebox@npm:0.27.8" @@ -12841,18 +12835,6 @@ __metadata: languageName: node linkType: hard -"ethereum-cryptography@npm:^2.2.1": - version: 2.2.1 - resolution: "ethereum-cryptography@npm:2.2.1" - dependencies: - "@noble/curves": "npm:1.4.2" - "@noble/hashes": "npm:1.4.0" - "@scure/bip32": "npm:1.4.0" - "@scure/bip39": "npm:1.3.0" - checksum: 10/ab123bbfe843500ac2d645ce9edc4bc814962ffb598db6bf8bf01fbecac656e6c81ff4cf2472f1734844bbcbad2bf658d8b699cb7248d768e0f06ae13ecf43b8 - languageName: node - linkType: hard - "ethers@npm:^6.3.0": version: 6.13.4 resolution: "ethers@npm:6.13.4" From 55c2f0652b661061517af0f038546a328e5dea87 Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Thu, 13 Mar 2025 11:09:06 +0100 Subject: [PATCH 09/21] address requested changes --- packages/snaps-sdk/src/jsx/validation.ts | 4 ++-- packages/snaps-sdk/src/types/caip.test.ts | 18 +++++++++--------- packages/snaps-sdk/src/types/caip.ts | 14 +++++++------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/snaps-sdk/src/jsx/validation.ts b/packages/snaps-sdk/src/jsx/validation.ts index fa4d0b4e54..ccb1108bb8 100644 --- a/packages/snaps-sdk/src/jsx/validation.ts +++ b/packages/snaps-sdk/src/jsx/validation.ts @@ -90,7 +90,7 @@ import { import { NonEip155AssetTypeStruct, NonEip155ChainIdStruct, - NonEip155MatchingAddressesCaipAccountIdListStruct, + NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, type EmptyObject, } from '../types'; @@ -420,7 +420,7 @@ export const AssetSelectorStruct: Describe = element( 'AssetSelector', { name: string(), - addresses: NonEip155MatchingAddressesCaipAccountIdListStruct, + addresses: NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, chainIds: optional(array(NonEip155ChainIdStruct)) as unknown as Struct< Infer[] | undefined, null diff --git a/packages/snaps-sdk/src/types/caip.test.ts b/packages/snaps-sdk/src/types/caip.test.ts index de6412f2ad..a27b41983d 100644 --- a/packages/snaps-sdk/src/types/caip.test.ts +++ b/packages/snaps-sdk/src/types/caip.test.ts @@ -1,13 +1,13 @@ import { is } from '@metamask/superstruct'; import { - MatchingAddressesCaipAccountIdListStruct, + CaipAccountIdsMatchedByAddressAndNamespaceStruct, NonEip155AssetTypeStruct, NonEip155ChainIdStruct, - NonEip155MatchingAddressesCaipAccountIdListStruct, + NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, } from './caip'; -describe('MatchingAddressesCaipAccountIdListStruct', () => { +describe('CaipAccountIdsMatchedByAddressAndNamespaceStruct', () => { it('validates an array of matchin addresses', () => { expect( is( @@ -16,7 +16,7 @@ describe('MatchingAddressesCaipAccountIdListStruct', () => { 'eip155:2:0x1234567890123456789012345678901234567890', 'eip155:3:0x1234567890123456789012345678901234567890', ], - MatchingAddressesCaipAccountIdListStruct, + CaipAccountIdsMatchedByAddressAndNamespaceStruct, ), ).toBe(true); }); @@ -29,7 +29,7 @@ describe('MatchingAddressesCaipAccountIdListStruct', () => { 'eip155:2:0x1234567890123456789012225678901234567890', 'eip155:3:0x1234567890123456789012345678901234567890', ], - MatchingAddressesCaipAccountIdListStruct, + CaipAccountIdsMatchedByAddressAndNamespaceStruct, ), ).toBe(false); }); @@ -42,13 +42,13 @@ describe('MatchingAddressesCaipAccountIdListStruct', () => { 'eip155:2:0x1234567890123456789012345678901234567890', 'foo:3:0x1234567890123456789012345678901234567890', ], - MatchingAddressesCaipAccountIdListStruct, + CaipAccountIdsMatchedByAddressAndNamespaceStruct, ), ).toBe(false); }); }); -describe('NonEip155MatchingAddressesCaipAccountIdListStruct', () => { +describe('NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct', () => { it('validates an array of matching non EIP-155 namespace addresses', () => { expect( is( @@ -56,7 +56,7 @@ describe('NonEip155MatchingAddressesCaipAccountIdListStruct', () => { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ], - NonEip155MatchingAddressesCaipAccountIdListStruct, + NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, ), ).toBe(true); }); @@ -68,7 +68,7 @@ describe('NonEip155MatchingAddressesCaipAccountIdListStruct', () => { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', 'eip155:1:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ], - NonEip155MatchingAddressesCaipAccountIdListStruct, + NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, ), ).toBe(false); }); diff --git a/packages/snaps-sdk/src/types/caip.ts b/packages/snaps-sdk/src/types/caip.ts index 36060e397e..073bf8d685 100644 --- a/packages/snaps-sdk/src/types/caip.ts +++ b/packages/snaps-sdk/src/types/caip.ts @@ -38,7 +38,7 @@ export type AccountId = CaipAccountId; /** * A stuct representing a list of CAIP-10 account IDs where the account addresses and namespaces are the same. */ -export const MatchingAddressesCaipAccountIdListStruct = refine( +export const CaipAccountIdsMatchedByAddressAndNamespaceStruct = refine( array(CaipAccountIdStruct), 'Matching Addresses Account ID List', (value) => { @@ -53,7 +53,7 @@ export const MatchingAddressesCaipAccountIdListStruct = refine( namespace === parsedAccountIds[0].chain.namespace, ) ) { - return 'All account IDs must have the same address and chain namespace.'; + return 'All account IDs must have the same address and namespace.'; } return true; @@ -64,14 +64,14 @@ export const MatchingAddressesCaipAccountIdListStruct = refine( * A list of CAIP-10 account IDs where the account addresses are the same. */ export type MatchingAddressesCaipAccountIdList = Infer< - typeof MatchingAddressesCaipAccountIdListStruct + typeof CaipAccountIdsMatchedByAddressAndNamespaceStruct >; /** * A struct representing a list of non-EIP-155 CAIP-10 account IDs where the account addresses are the same. */ -export const NonEip155MatchingAddressesCaipAccountIdListStruct = refine( - MatchingAddressesCaipAccountIdListStruct, +export const NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct = refine( + CaipAccountIdsMatchedByAddressAndNamespaceStruct, 'Non-EIP-155 Matching Addresses Account ID List', (value) => { const containsEip155 = value.some((accountId) => { @@ -83,7 +83,7 @@ export const NonEip155MatchingAddressesCaipAccountIdListStruct = refine( }); if (containsEip155) { - return 'All account IDs must have non-EIP-155 chain namespaces.'; + return 'All account IDs must have non-EIP-155 namespaces.'; } return true; }, @@ -93,7 +93,7 @@ export const NonEip155MatchingAddressesCaipAccountIdListStruct = refine( * A list of non-EIP-155 CAIP-10 account IDs where the account addresses are the same. */ export type NonEip155MatchingAddressesCaipAccountIdList = Infer< - typeof NonEip155MatchingAddressesCaipAccountIdListStruct + typeof NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct >; /** From bd0292b3f2001fd1f802c06417f72e27d2308a49 Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Thu, 13 Mar 2025 16:35:15 +0100 Subject: [PATCH 10/21] update after rebase --- .../packages/browserify-plugin/snap.manifest.json | 2 +- .../examples/packages/browserify/snap.manifest.json | 2 +- packages/snaps-controllers/package.json | 4 ---- yarn.lock | 12 ------------ 4 files changed, 2 insertions(+), 18 deletions(-) diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index 1358b8d646..cf603eb4d8 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": "rwdRZcZOgYfoAvunjlh3qgfhusfBqSv3g23SNhrfSQc=", "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..be7ed41d57 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": "12YFI4tP/C551Biu4npoh6PtlIPLKDcZa6Mrx2ZItlc=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snaps-controllers/package.json b/packages/snaps-controllers/package.json index 7b96bc8157..039558ff9e 100644 --- a/packages/snaps-controllers/package.json +++ b/packages/snaps-controllers/package.json @@ -83,12 +83,8 @@ "@metamask/base-controller": "^8.0.0", "@metamask/json-rpc-engine": "^10.0.2", "@metamask/json-rpc-middleware-stream": "^8.0.7", -<<<<<<< HEAD "@metamask/key-tree": "^10.1.0", -======= - "@metamask/key-tree": "^10.0.2", "@metamask/keyring-internal-api": "^6.0.0", ->>>>>>> 1e98fa8c (add account handling) "@metamask/object-multiplex": "^2.1.0", "@metamask/permission-controller": "^11.0.6", "@metamask/phishing-controller": "^12.4.0", diff --git a/yarn.lock b/yarn.lock index 88cf1ba433..8a07bb4a0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6254,19 +6254,7 @@ __metadata: languageName: node linkType: hard -<<<<<<< HEAD -<<<<<<< HEAD "@noble/curves@npm:1.4.2, @noble/curves@npm:~1.4.0": -======= -<<<<<<< HEAD -"@noble/curves@npm:1.4.2, @noble/curves@npm:^1.1.0, @noble/curves@npm:^1.2.0, @noble/curves@npm:~1.4.0": -======= -"@noble/curves@npm:1.4.2, @noble/curves@npm:~1.4.0": ->>>>>>> 97d29693 (add account handling) ->>>>>>> 1e98fa8c (add account handling) -======= -"@noble/curves@npm:1.4.2, @noble/curves@npm:^1.1.0, @noble/curves@npm:^1.2.0, @noble/curves@npm:~1.4.0": ->>>>>>> c47feb14 (refactor to only support non-EIP155 values) version: 1.4.2 resolution: "@noble/curves@npm:1.4.2" dependencies: From 182ea6917dbc6047b7d430cf1c9b94ca5a3b7b0c Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Fri, 14 Mar 2025 14:35:44 +0100 Subject: [PATCH 11/21] address requested changes --- packages/snaps-controllers/coverage.json | 2 +- packages/snaps-controllers/package.json | 1 - .../SnapInterfaceController.test.tsx | 63 +++++++- .../src/interface/SnapInterfaceController.ts | 30 ++-- .../src/interface/utils.test.tsx | 141 ++++++++---------- .../snaps-controllers/src/interface/utils.ts | 60 ++------ .../src/multichain/MultichainRouter.ts | 14 +- .../src/test-utils/controller.ts | 16 +- .../src/jsx/components/form/AssetSelector.ts | 23 +-- .../snaps-sdk/src/jsx/validation.test.tsx | 8 + packages/snaps-sdk/src/types/caip.ts | 26 ---- .../src/types/handlers/user-input.ts | 2 +- packages/snaps-utils/coverage.json | 2 +- packages/snaps-utils/src/account.ts | 18 +++ packages/snaps-utils/src/index.ts | 1 + packages/snaps-utils/src/ui.test.tsx | 115 ++++++++++++-- packages/snaps-utils/src/ui.tsx | 63 ++++++-- yarn.lock | 101 ------------- 18 files changed, 352 insertions(+), 334 deletions(-) create mode 100644 packages/snaps-utils/src/account.ts diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index ddf594074d..ce91437cd5 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,5 +1,5 @@ { - "branches": 93.5, + "branches": 93.47, "functions": 97.12, "lines": 98.29, "statements": 98.02 diff --git a/packages/snaps-controllers/package.json b/packages/snaps-controllers/package.json index 039558ff9e..6f3d3b9fd6 100644 --- a/packages/snaps-controllers/package.json +++ b/packages/snaps-controllers/package.json @@ -84,7 +84,6 @@ "@metamask/json-rpc-engine": "^10.0.2", "@metamask/json-rpc-middleware-stream": "^8.0.7", "@metamask/key-tree": "^10.1.0", - "@metamask/keyring-internal-api": "^6.0.0", "@metamask/object-multiplex": "^2.1.0", "@metamask/permission-controller": "^11.0.6", "@metamask/phishing-controller": "^12.4.0", diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx b/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx index 767cc36836..de0a2623c0 100644 --- a/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx +++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.test.tsx @@ -180,7 +180,7 @@ describe('SnapInterfaceController', () => { /> ); - await rootMessenger.call( + const interfaceId = await rootMessenger.call( 'SnapInterfaceController:createInterface', MOCK_SNAP_ID, components, @@ -190,6 +190,20 @@ describe('SnapInterfaceController', () => { 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 () => { @@ -445,6 +459,53 @@ 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( diff --git a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts index eb30a9ce12..487a11adf5 100644 --- a/packages/snaps-controllers/src/interface/SnapInterfaceController.ts +++ b/packages/snaps-controllers/src/interface/SnapInterfaceController.ts @@ -8,7 +8,6 @@ import type { ControllerStateChangeEvent, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { MaybeUpdateState, TestOrigin, @@ -22,7 +21,8 @@ import type { } 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 { 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'; @@ -31,7 +31,6 @@ import { nanoid } from 'nanoid'; import { constructState, getJsxInterface, - validateAssetSelector, validateInterfaceContext, } from './utils'; import type { GetSnap } from '../snaps'; @@ -270,7 +269,6 @@ export class SnapInterfaceController extends BaseController< const id = nanoid(); const componentState = constructState({}, element, { - getAssetMetadata: this.#getAssetMetadata.bind(this), getAssetsState: this.#getAssetsState.bind(this), getAccountByAddress: this.#getAccountByAddress.bind(this), }); @@ -324,7 +322,6 @@ export class SnapInterfaceController extends BaseController< const oldState = this.state.interfaces[id].state; const newState = constructState(oldState, element, { - getAssetMetadata: this.#getAssetMetadata.bind(this), getAssetsState: this.#getAssetsState.bind(this), getAccountByAddress: this.#getAccountByAddress.bind(this), }); @@ -479,15 +476,13 @@ export class SnapInterfaceController extends BaseController< } /** - * Get the asset metadata for a given asset ID. + * Get a snap by its id. * - * @param assetId - The asset ID. - * @returns The asset metadata or undefined if not found. + * @param id - The snap id. + * @returns The snap. */ - #getAssetMetadata(assetId: CaipAssetType) { - const { assetsMetadata } = this.#getAssetsState(); - - return assetsMetadata[assetId]; + #getSnap(id: string) { + return this.messagingSystem.call('SnapController:get', id); } /** @@ -506,13 +501,12 @@ export class SnapInterfaceController extends BaseController< ); await this.#triggerPhishingListUpdate(); - validateJsxLinks( - element, - this.#checkPhishingList.bind(this), - (id: string) => this.messagingSystem.call('SnapController:get', id), - ); - validateAssetSelector(element, this.#getAccountByAddress.bind(this)); + 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 197ed3074a..cd337b7700 100644 --- a/packages/snaps-controllers/src/interface/utils.test.tsx +++ b/packages/snaps-controllers/src/interface/utils.test.tsx @@ -25,7 +25,6 @@ import { getAssetSelectorStateValue, getDefaultAsset, getJsxInterface, - validateAssetSelector, } from './utils'; describe('getJsxInterface', () => { @@ -74,7 +73,6 @@ describe('assertNameIsUnique', () => { describe('constructState', () => { const elementDataGetters = { - getAssetMetadata: jest.fn(), getAssetsState: jest.fn(), getAccountByAddress: jest.fn(), }; @@ -617,9 +615,16 @@ describe('constructState', () => { }); it('supports root level AssetSelector', () => { - elementDataGetters.getAssetMetadata.mockReturnValue({ - name: 'Solana', - symbol: 'SOL', + elementDataGetters.getAssetsState.mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105': { + name: 'Solana', + symbol: 'SOL', + }, + }, + accountsAssets: { + foo: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105'], + }, }); const element = ( @@ -691,9 +696,16 @@ describe('constructState', () => { }); it('supports AssetSelector in form', () => { - elementDataGetters.getAssetMetadata.mockReturnValue({ - name: 'Solana', - symbol: 'SOL', + elementDataGetters.getAssetsState.mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105': { + name: 'Solana', + symbol: 'SOL', + }, + }, + accountsAssets: { + foo: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105'], + }, }); const element = ( @@ -725,8 +737,7 @@ describe('constructState', () => { }); }); - it('sets the value to null if the asset metadata is not found', () => { - elementDataGetters.getAssetMetadata.mockReturnValue(undefined); + it('sets the value to the default asset if the asset metadata is not found', () => { elementDataGetters.getAssetsState.mockReturnValue({ assetsMetadata: { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105': { @@ -750,7 +761,7 @@ describe('constructState', () => { addresses={[ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ]} - value="solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105" + value="solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" />
); @@ -948,18 +959,25 @@ describe('constructState', () => { }); describe('getAssetSelectorStateValue', () => { - const getAssetMetadata = jest.fn(); + const getAssetsState = jest.fn(); it('returns the asset selector state value', () => { - getAssetMetadata.mockReturnValue({ - name: 'Solana', - symbol: 'SOL', + getAssetsState.mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { + name: 'Solana', + symbol: 'SOL', + }, + }, + accountsAssets: { + foo: [], + }, }); expect( getAssetSelectorStateValue( 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', - getAssetMetadata, + getAssetsState, ), ).toStrictEqual({ asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', @@ -969,16 +987,27 @@ describe('getAssetSelectorStateValue', () => { }); it('returns null if the value is not set', () => { - expect(getAssetSelectorStateValue(undefined, getAssetMetadata)).toBeNull(); + expect(getAssetSelectorStateValue(undefined, getAssetsState)).toBeNull(); }); it('returns null if the asset metadata is not found', () => { - getAssetMetadata.mockReturnValue(undefined); + getAssetsState.mockReturnValue({ + assetsMetadata: { + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v': + { + name: 'USDC', + symbol: 'USDC', + }, + }, + accountsAssets: { + foo: [], + }, + }); expect( getAssetSelectorStateValue( 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', - getAssetMetadata, + getAssetsState, ), ).toBeNull(); }); @@ -1002,8 +1031,6 @@ describe('getAssetSelectorDefaultState', () => { }, }); - const getAssetMetadata = jest.fn(); - expect( getAssetSelectorDefaultState( { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ]} />, - { getAccountByAddress, getAssetsState, getAssetMetadata }, + { getAccountByAddress, getAssetsState }, ), ).toStrictEqual({ asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', @@ -1038,8 +1065,6 @@ describe('getAssetSelectorDefaultState', () => { }, }); - const getAssetMetadata = jest.fn(); - expect( getAssetSelectorDefaultState( { 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ]} />, - { getAccountByAddress, getAssetsState, getAssetMetadata }, + { getAccountByAddress, getAssetsState }, ), ).toBeNull(); }); }); -describe('validateAssetSelector', () => { - it('passes if the address is available in the client', () => { - const getAccountByAddress = jest.fn().mockReturnValue({ - id: 'foo', - }); - - expect( - validateAssetSelector( - , - getAccountByAddress, - ), - ).toBeUndefined(); - }); - - it('throws if the address is not available in the client', () => { - const getAccountByAddress = jest.fn().mockReturnValue(undefined); - - expect(() => - validateAssetSelector( - , - getAccountByAddress, - ), - ).toThrow( - `Could not find account for address: solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv`, - ); - }); -}); - describe('getDefaultAsset', () => { it('returns the native asset if available', () => { const getAssetsState = jest.fn().mockReturnValue({ @@ -1110,15 +1097,13 @@ describe('getDefaultAsset', () => { id: 'foo', }); - const getAssetMetadata = jest.fn(); - expect( getDefaultAsset( [ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ], undefined, - { getAssetsState, getAccountByAddress, getAssetMetadata }, + { getAssetsState, getAccountByAddress }, ), ).toStrictEqual({ address: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', @@ -1147,15 +1132,13 @@ describe('getDefaultAsset', () => { id: 'foo', }); - const getAssetMetadata = jest.fn(); - expect( getDefaultAsset( [ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ], undefined, - { getAssetsState, getAccountByAddress, getAssetMetadata }, + { getAssetsState, getAccountByAddress }, ), ).toStrictEqual({ address: @@ -1177,15 +1160,13 @@ describe('getDefaultAsset', () => { id: 'foo', }); - const getAssetMetadata = jest.fn(); - expect( getDefaultAsset( [ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ], undefined, - { getAssetsState, getAccountByAddress, getAssetMetadata }, + { getAssetsState, getAccountByAddress }, ), ).toBeUndefined(); }); @@ -1214,8 +1195,6 @@ describe('getDefaultAsset', () => { id: 'foo', }); - const getAssetMetadata = jest.fn(); - expect( getDefaultAsset( [ @@ -1223,7 +1202,7 @@ describe('getDefaultAsset', () => { 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ], ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], - { getAssetsState, getAccountByAddress, getAssetMetadata }, + { getAssetsState, getAccountByAddress }, ), ).toStrictEqual({ address: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', @@ -1232,7 +1211,7 @@ describe('getDefaultAsset', () => { }); }); - it('returns undefined if the account is not found', () => { + it('throws if the account is not found', () => { const getAssetsState = jest.fn().mockReturnValue({ assetsMetadata: {}, accountsAssets: { @@ -1242,16 +1221,16 @@ describe('getDefaultAsset', () => { const getAccountByAddress = jest.fn().mockReturnValue(undefined); - const getAssetMetadata = jest.fn(); - - expect( + expect(() => getDefaultAsset( [ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ], undefined, - { getAssetsState, getAccountByAddress, getAssetMetadata }, + { getAssetsState, getAccountByAddress }, ), - ).toBeUndefined(); + ).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 77e312ca15..f689a0a670 100644 --- a/packages/snaps-controllers/src/interface/utils.ts +++ b/packages/snaps-controllers/src/interface/utils.ts @@ -1,4 +1,3 @@ -import type { InternalAccount } from '@metamask/keyring-internal-api'; import { assert } from '@metamask/snaps-sdk'; import type { FormState, @@ -24,6 +23,7 @@ import type { AssetSelectorElement, } from '@metamask/snaps-sdk/jsx'; import { isJSXElementUnsafe } from '@metamask/snaps-sdk/jsx'; +import type { InternalAccount } from '@metamask/snaps-utils'; import { getJsonSizeUnsafe, getJsxChildren, @@ -49,15 +49,6 @@ type GetAssetsState = () => { accountsAssets: { [account: string]: CaipAssetType[] }; }; -/** - * A function to get asset metadata. - * - * @param assetId - The asset ID. - * @returns The asset metadata or undefined if not found. - */ -type GetAssetMetadata = ( - assetId: CaipAssetType, -) => FungibleAssetMetadata | undefined; /** * A function to get an account by its address. * @@ -72,12 +63,10 @@ type GetAccountByAddress = ( * Data getters for elements. * This is used to get data from elements that is not directly accessible from the element itself. * - * @param getAssetMetadata - A function to get asset metadata. - * @param getDefaultAsset - A function to get the default asset for an address. * @param getAssetState - A function to get the MultichainAssetController state. + * @param getAccountByAddress - A function to get an account by its address. */ type ElementDataGetters = { - getAssetMetadata: GetAssetMetadata; getAssetsState: GetAssetsState; getAccountByAddress: GetAccountByAddress; }; @@ -144,9 +133,8 @@ export function getDefaultAsset( const accountId = getAccountByAddress(addresses[0])?.id; - if (!accountId) { - return undefined; - } + // 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]; @@ -173,33 +161,6 @@ export function getDefaultAsset( }; } -/** - * Validate the asset selector component. - * - * @param node - The JSX node to validate. - * @param getAccountByAddress - A function to get an account by its address. - * - * @throws If the asset selector is invalid. - */ -export function validateAssetSelector( - node: JSXElement, - getAccountByAddress: GetAccountByAddress, -) { - walkJsx(node, (childNode) => { - if (childNode.type !== 'AssetSelector') { - return; - } - - // We can assume that the addresses are the same for all CAIP account IDs - const account = getAccountByAddress(childNode.props.addresses[0]); - - assert( - account, - `Could not find account for address: ${childNode.props.addresses[0]}`, - ); - }); -} - /** * Construct default state for a component. * @@ -278,18 +239,19 @@ export function getAssetSelectorDefaultState( * Get the state value for an asset selector. * * @param value - The asset selector value. - * @param getAssetMetadata - A function to get asset metadata. + * @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, - getAssetMetadata: GetAssetMetadata, + getAssetState: GetAssetsState, ): AssetSelectorState | null { if (!value) { return null; } - const asset = getAssetMetadata(value); + const { assetsMetadata } = getAssetState(); + const asset = assetsMetadata[value]; if (!asset) { return null; @@ -310,7 +272,7 @@ export function getAssetSelectorStateValue( * * @param element - The input element. * @param elementDataGetters - Data getters for the element. - * @param elementDataGetters.getAssetMetadata - A function to get asset metadata. + * @param elementDataGetters.getAssetsState - A function to get the MultichainAssetController state. * @returns The state value for a given component. */ function getComponentStateValue( @@ -321,14 +283,14 @@ function getComponentStateValue( | CheckboxElement | SelectorElement | AssetSelectorElement, - { getAssetMetadata }: ElementDataGetters, + { getAssetsState }: ElementDataGetters, ) { switch (element.type) { case 'Checkbox': return element.props.checked; case 'AssetSelector': - return getAssetSelectorStateValue(element.props.value, getAssetMetadata); + return getAssetSelectorStateValue(element.props.value, getAssetsState); default: return element.props.value; 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 4f0676841f..e3f5da7d2c 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'; @@ -783,6 +784,7 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( 'ApprovalController:acceptRequest', 'MultichainAssetsController:getState', 'AccountsController:getAccountByAddress', + 'SnapController:get', ], allowedEvents: [ 'NotificationServicesController:notificationsListUpdated', @@ -804,7 +806,13 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( messenger.registerActionHandler( 'MultichainAssetsController:getState', () => ({ - assetsMetadata: {}, + assetsMetadata: { + // @ts-expect-error partial mock + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105': { + name: 'Solana', + symbol: 'SOL', + }, + }, accountsAssets: { foo: [], }, @@ -820,6 +828,10 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( scopes: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], }), ); + + messenger.registerActionHandler('SnapController:get', (snapId: string) => { + return getSnapObject({ id: snapId as SnapId }); + }); } return snapInterfaceControllerMessenger; diff --git a/packages/snaps-sdk/src/jsx/components/form/AssetSelector.ts b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.ts index 7f95535923..54a9a729d0 100644 --- a/packages/snaps-sdk/src/jsx/components/form/AssetSelector.ts +++ b/packages/snaps-sdk/src/jsx/components/form/AssetSelector.ts @@ -1,8 +1,9 @@ import type { - NonEip155AssetType, - NonEip155ChainId, - NonEip155MatchingAddressesCaipAccountIdList, -} from '../../../types'; + CaipChainId, + CaipAssetType, + CaipAccountId, +} from '@metamask/utils'; + import { createSnapComponent } from '../../component'; /** @@ -10,8 +11,8 @@ import { createSnapComponent } from '../../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. - * Only one address is supported, but different chains can be used. + * @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. @@ -21,9 +22,9 @@ import { createSnapComponent } from '../../component'; */ export type AssetSelectorProps = { name: string; - addresses: NonEip155MatchingAddressesCaipAccountIdList; - chainIds?: NonEip155ChainId[] | undefined; - value?: NonEip155AssetType | undefined; + addresses: CaipAccountId[]; + chainIds?: CaipChainId[] | undefined; + value?: CaipAssetType | undefined; disabled?: boolean | undefined; }; @@ -33,8 +34,8 @@ const TYPE = 'AssetSelector'; * An asset selector component, which is used to create an asset selector. * * @param props - The props of the component. - * @param props.addresses - The addresses of the account to pull the assets from. - * Only one address is supported, but different chains can be used. + * @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. diff --git a/packages/snaps-sdk/src/jsx/validation.test.tsx b/packages/snaps-sdk/src/jsx/validation.test.tsx index 94fc6bf676..ccbb700337 100644 --- a/packages/snaps-sdk/src/jsx/validation.test.tsx +++ b/packages/snaps-sdk/src/jsx/validation.test.tsx @@ -314,6 +314,14 @@ describe('FieldStruct', () => { , + + + , ])('validates a field element', (value) => { expect(is(value, FieldStruct)).toBe(true); }); diff --git a/packages/snaps-sdk/src/types/caip.ts b/packages/snaps-sdk/src/types/caip.ts index 073bf8d685..d48851160f 100644 --- a/packages/snaps-sdk/src/types/caip.ts +++ b/packages/snaps-sdk/src/types/caip.ts @@ -1,4 +1,3 @@ -import type { Infer } from '@metamask/superstruct'; import { array, refine } from '@metamask/superstruct'; import { CaipAccountIdStruct, @@ -60,13 +59,6 @@ export const CaipAccountIdsMatchedByAddressAndNamespaceStruct = refine( }, ); -/** - * A list of CAIP-10 account IDs where the account addresses are the same. - */ -export type MatchingAddressesCaipAccountIdList = Infer< - typeof CaipAccountIdsMatchedByAddressAndNamespaceStruct ->; - /** * A struct representing a list of non-EIP-155 CAIP-10 account IDs where the account addresses are the same. */ @@ -88,14 +80,6 @@ export const NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct = refine( return true; }, ); - -/** - * A list of non-EIP-155 CAIP-10 account IDs where the account addresses are the same. - */ -export type NonEip155MatchingAddressesCaipAccountIdList = Infer< - typeof NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct ->; - /** * A struct representing a non-EIP-155 chain ID. */ @@ -113,11 +97,6 @@ export const NonEip155ChainIdStruct = refine( }, ); -/** - * A non-EIP-155 chain ID. - */ -export type NonEip155ChainId = Infer; - /** * A struct representing a non-EIP-155 asset type. */ @@ -136,8 +115,3 @@ export const NonEip155AssetTypeStruct = refine( return true; }, ); - -/** - * A non-EIP-155 asset type. - */ -export type NonEip155AssetType = Infer; diff --git a/packages/snaps-sdk/src/types/handlers/user-input.ts b/packages/snaps-sdk/src/types/handlers/user-input.ts index b37d382de7..fe8e65bee4 100644 --- a/packages/snaps-sdk/src/types/handlers/user-input.ts +++ b/packages/snaps-sdk/src/types/handlers/user-input.ts @@ -83,7 +83,7 @@ export const AssetSelectorStateStruct = object({ * * @property asset - The CAIP-19 asset ID. * @property name - The name of the asset. - * @property ticker - The ticker of the asset. + * @property symbol - The symbol of the asset. */ export type AssetSelectorState = Infer; 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..246234622d 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: 'foo', + }); + + 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..cc1a492f5f 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,61 @@ 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 - * phishing list. - * @param getSnap - The function that returns a snap if installed, undefined otherwise. + * @param hooks - The hooks to use for validation. + * @param hooks.isOnPhishingList - The function that checks the link against the + * @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. + childNode.props.addresses[0], + getAccountByAddress, + ); + break; + default: + break; } - - validateLink(childNode.props.href, isOnPhishingList, getSnap); }); } diff --git a/yarn.lock b/yarn.lock index 8a07bb4a0d..0f5e2fbbcc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3292,15 +3292,6 @@ __metadata: languageName: node linkType: hard -"@ethereumjs/common@npm:^4.4.0": - version: 4.4.0 - resolution: "@ethereumjs/common@npm:4.4.0" - dependencies: - "@ethereumjs/util": "npm:^9.1.0" - checksum: 10/dd5cc78575a762b367601f94d6af7e36cb3a5ecab45eec0c1259c433e755a16c867753aa88f331e3963791a18424ad0549682a3a6a0a160640fe846db6ce8014 - languageName: node - linkType: hard - "@ethereumjs/rlp@npm:^4.0.1": version: 4.0.1 resolution: "@ethereumjs/rlp@npm:4.0.1" @@ -3331,18 +3322,6 @@ __metadata: languageName: node linkType: hard -"@ethereumjs/tx@npm:^5.4.0": - version: 5.4.0 - resolution: "@ethereumjs/tx@npm:5.4.0" - dependencies: - "@ethereumjs/common": "npm:^4.4.0" - "@ethereumjs/rlp": "npm:^5.0.2" - "@ethereumjs/util": "npm:^9.1.0" - ethereum-cryptography: "npm:^2.2.1" - checksum: 10/8d2c0a69ab37015f945f9de065cfb9f05e8e79179efeed725ea0a14760c3eb8ff900bcf915bb71ec29fe2f753db35d1b78a15ac4ddec489e87c995dec1ba6e85 - languageName: node - linkType: hard - "@ethereumjs/util@npm:^8.1.0": version: 8.1.0 resolution: "@ethereumjs/util@npm:8.1.0" @@ -4871,53 +4850,6 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-api@npm:^17.2.1": - version: 17.2.1 - resolution: "@metamask/keyring-api@npm:17.2.1" - dependencies: - "@metamask/keyring-utils": "npm:^2.3.1" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.1.0" - bech32: "npm:^2.0.0" - checksum: 10/666b8506724c0f759e755ddc888fc0ecb44ef98bcf2f9d15ce009d00b93c126415c0af9f5037157a63f7fc7524358601650589819e487f7acb3e4748467b0a7b - languageName: node - linkType: hard - -"@metamask/keyring-internal-api@npm:^6.0.0": - version: 6.0.0 - resolution: "@metamask/keyring-internal-api@npm:6.0.0" - dependencies: - "@metamask/keyring-api": "npm:^17.2.1" - "@metamask/keyring-utils": "npm:^3.0.0" - "@metamask/superstruct": "npm:^3.1.0" - checksum: 10/069945b3423e7b6bd0b8735d65e17c968e494bc3f8c06e585d6e27f09ced0027541440c9e90ffbcd59b1daf91d7848c09be010a8ceb547ed3c4f6465e810b7a8 - languageName: node - linkType: hard - -"@metamask/keyring-utils@npm:^2.3.1": - version: 2.3.1 - resolution: "@metamask/keyring-utils@npm:2.3.1" - dependencies: - "@ethereumjs/tx": "npm:^4.2.0" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.1.0" - bitcoin-address-validation: "npm:^2.2.3" - checksum: 10/4a11b780621d82ab2d3fe39fbaed0ea87c01139c925c4c26cb25e2361bd855eae1c7c8cf01a84d2030de3bbef65590caecfe538f37490f75cad8a0a65b318c95 - languageName: node - linkType: hard - -"@metamask/keyring-utils@npm:^3.0.0": - version: 3.0.0 - resolution: "@metamask/keyring-utils@npm:3.0.0" - dependencies: - "@ethereumjs/tx": "npm:^5.4.0" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.1.0" - bitcoin-address-validation: "npm:^2.2.3" - checksum: 10/eff3c0b9a86d6a25c5dd443946ba3ff56cb94fcb915a4eb061089819805e1e78eba2ea5cfb12a47ec4606542870c417de422f755947389ab9f3a4f08e96742db - languageName: node - linkType: hard - "@metamask/lifecycle-hooks-example-snap@workspace:^, @metamask/lifecycle-hooks-example-snap@workspace:packages/examples/packages/lifecycle-hooks": version: 0.0.0-use.local resolution: "@metamask/lifecycle-hooks-example-snap@workspace:packages/examples/packages/lifecycle-hooks" @@ -5467,7 +5399,6 @@ __metadata: "@metamask/json-rpc-engine": "npm:^10.0.2" "@metamask/json-rpc-middleware-stream": "npm:^8.0.7" "@metamask/key-tree": "npm:^10.1.0" - "@metamask/keyring-internal-api": "npm:^6.0.0" "@metamask/object-multiplex": "npm:^2.1.0" "@metamask/permission-controller": "npm:^11.0.6" "@metamask/phishing-controller": "npm:^12.4.0" @@ -9342,13 +9273,6 @@ __metadata: languageName: node linkType: hard -"base58-js@npm:^1.0.0": - version: 1.0.5 - resolution: "base58-js@npm:1.0.5" - checksum: 10/46c1b39d3a70bca0a47d56069c74a25d547680afd0f28609c90f280f5d614f5de36db5df993fa334db24008a68ab784a72fcdaa13eb40078e03c8999915a1100 - languageName: node - linkType: hard - "base64-js@npm:^1.0.2, base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -9379,13 +9303,6 @@ __metadata: languageName: node linkType: hard -"bech32@npm:^2.0.0": - version: 2.0.0 - resolution: "bech32@npm:2.0.0" - checksum: 10/fa15acb270b59aa496734a01f9155677b478987b773bf701f465858bf1606c6a970085babd43d71ce61895f1baa594cb41a2cd1394bd2c6698f03cc2d811300e - languageName: node - linkType: hard - "big-integer@npm:^1.6.17": version: 1.6.51 resolution: "big-integer@npm:1.6.51" @@ -9455,17 +9372,6 @@ __metadata: languageName: node linkType: hard -"bitcoin-address-validation@npm:^2.2.3": - version: 2.2.3 - resolution: "bitcoin-address-validation@npm:2.2.3" - dependencies: - base58-js: "npm:^1.0.0" - bech32: "npm:^2.0.0" - sha256-uint8array: "npm:^0.10.3" - checksum: 10/01603b5edf610ecf0843ae546534313f1cffabc8e7435a3678bc9788f18a54e51302218a539794aafd49beb5be70b5d1d507eb7442cb33970fcd665592a71305 - languageName: node - linkType: hard - "bl@npm:^4.0.3, bl@npm:^4.1.0": version: 4.1.0 resolution: "bl@npm:4.1.0" @@ -20429,13 +20335,6 @@ __metadata: languageName: node linkType: hard -"sha256-uint8array@npm:^0.10.3": - version: 0.10.7 - resolution: "sha256-uint8array@npm:0.10.7" - checksum: 10/e427f9d2f9c521dea552f033d3f0c3bd641ab214d214dd41bde3c805edde393519cf982b3eee7d683b32e5f28fa23b2278d25935940e13fbe831b216a37832be - languageName: node - linkType: hard - "shallow-clone@npm:^0.1.2": version: 0.1.2 resolution: "shallow-clone@npm:0.1.2" From 77cff655e055140b2efa1265e635635d5d54749f Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Fri, 14 Mar 2025 14:49:59 +0100 Subject: [PATCH 12/21] update coverage --- packages/snaps-controllers/coverage.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index ce91437cd5..297ad82c55 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,6 +1,6 @@ { "branches": 93.47, - "functions": 97.12, - "lines": 98.29, - "statements": 98.02 + "functions": 97.36, + "lines": 98.33, + "statements": 98.06 } From b94d2650b962fe5c2cd2cf09d95f2614ccc84671 Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Tue, 18 Mar 2025 10:59:08 +0100 Subject: [PATCH 13/21] update comment --- packages/snaps-utils/src/ui.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/snaps-utils/src/ui.tsx b/packages/snaps-utils/src/ui.tsx index cc1a492f5f..476e5ecb74 100644 --- a/packages/snaps-utils/src/ui.tsx +++ b/packages/snaps-utils/src/ui.tsx @@ -454,7 +454,8 @@ export function validateJsxElements( break; case 'AssetSelector': validateAssetSelector( - // We assume that the address part of the CAIP-10 account ID are the same. + // 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, ); From 89f02b67a4ae412f04846bcc9f4063c4d24f9fab Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Tue, 18 Mar 2025 11:14:27 +0100 Subject: [PATCH 14/21] use an UUID to mock account ID --- .../src/interface/utils.test.tsx | 63 +++++++++++-------- .../src/test-utils/controller.ts | 4 +- packages/snaps-utils/src/ui.test.tsx | 2 +- 3 files changed, 42 insertions(+), 27 deletions(-) diff --git a/packages/snaps-controllers/src/interface/utils.test.tsx b/packages/snaps-controllers/src/interface/utils.test.tsx index cd337b7700..d70bae6c42 100644 --- a/packages/snaps-controllers/src/interface/utils.test.tsx +++ b/packages/snaps-controllers/src/interface/utils.test.tsx @@ -17,6 +17,7 @@ import { SelectorOption, AssetSelector, } from '@metamask/snaps-sdk/jsx'; +import { MOCK_ACCOUNT_ID } from 'src/test-utils'; import { assertNameIsUnique, @@ -584,12 +585,14 @@ describe('constructState', () => { }, }, accountsAssets: { - foo: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105'], + [MOCK_ACCOUNT_ID]: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105', + ], }, }); elementDataGetters.getAccountByAddress.mockReturnValue({ - id: 'foo', + id: MOCK_ACCOUNT_ID, }); const element = ( @@ -623,7 +626,9 @@ describe('constructState', () => { }, }, accountsAssets: { - foo: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105'], + [MOCK_ACCOUNT_ID]: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105', + ], }, }); @@ -659,12 +664,14 @@ describe('constructState', () => { }, }, accountsAssets: { - foo: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105'], + [MOCK_ACCOUNT_ID]: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105', + ], }, }); elementDataGetters.getAccountByAddress.mockReturnValue({ - id: 'foo', + id: MOCK_ACCOUNT_ID, }); const element = ( @@ -704,7 +711,9 @@ describe('constructState', () => { }, }, accountsAssets: { - foo: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105'], + [MOCK_ACCOUNT_ID]: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:105', + ], }, }); @@ -746,12 +755,12 @@ describe('constructState', () => { }, }, accountsAssets: { - foo: [], + [MOCK_ACCOUNT_ID]: [], }, }); elementDataGetters.getAccountByAddress.mockReturnValue({ - id: 'foo', + id: MOCK_ACCOUNT_ID, }); const element = ( @@ -782,12 +791,12 @@ describe('constructState', () => { }, }, accountsAssets: { - foo: [], + [MOCK_ACCOUNT_ID]: [], }, }); elementDataGetters.getAccountByAddress.mockReturnValue({ - id: 'foo', + id: MOCK_ACCOUNT_ID, }); const element = ( @@ -970,7 +979,7 @@ describe('getAssetSelectorStateValue', () => { }, }, accountsAssets: { - foo: [], + [MOCK_ACCOUNT_ID]: [], }, }); @@ -1000,7 +1009,7 @@ describe('getAssetSelectorStateValue', () => { }, }, accountsAssets: { - foo: [], + [MOCK_ACCOUNT_ID]: [], }, }); @@ -1016,7 +1025,7 @@ describe('getAssetSelectorStateValue', () => { describe('getAssetSelectorDefaultState', () => { it('returns the default asset for an asset selector', () => { const getAccountByAddress = jest.fn().mockReturnValue({ - id: 'foo', + id: MOCK_ACCOUNT_ID, }); const getAssetsState = jest.fn().mockReturnValue({ @@ -1027,7 +1036,9 @@ describe('getAssetSelectorDefaultState', () => { }, }, accountsAssets: { - foo: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], + [MOCK_ACCOUNT_ID]: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ], }, }); @@ -1050,7 +1061,7 @@ describe('getAssetSelectorDefaultState', () => { it('returns null if the default asset is not found', () => { const getAccountByAddress = jest.fn().mockReturnValue({ - id: 'foo', + id: MOCK_ACCOUNT_ID, }); const getAssetsState = jest.fn().mockReturnValue({ @@ -1061,7 +1072,7 @@ describe('getAssetSelectorDefaultState', () => { }, }, accountsAssets: { - foo: [], + [MOCK_ACCOUNT_ID]: [], }, }); @@ -1089,12 +1100,14 @@ describe('getDefaultAsset', () => { }, }, accountsAssets: { - foo: ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501'], + [MOCK_ACCOUNT_ID]: [ + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + ], }, }); const getAccountByAddress = jest.fn().mockReturnValue({ - id: 'foo', + id: MOCK_ACCOUNT_ID, }); expect( @@ -1122,14 +1135,14 @@ describe('getDefaultAsset', () => { }, }, accountsAssets: { - foo: [ + [MOCK_ACCOUNT_ID]: [ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', ], }, }); const getAccountByAddress = jest.fn().mockReturnValue({ - id: 'foo', + id: MOCK_ACCOUNT_ID, }); expect( @@ -1152,12 +1165,12 @@ describe('getDefaultAsset', () => { const getAssetsState = jest.fn().mockReturnValue({ assetsMetadata: {}, accountsAssets: { - foo: [], + [MOCK_ACCOUNT_ID]: [], }, }); const getAccountByAddress = jest.fn().mockReturnValue({ - id: 'foo', + id: MOCK_ACCOUNT_ID, }); expect( @@ -1184,7 +1197,7 @@ describe('getDefaultAsset', () => { }, }, accountsAssets: { - foo: [ + [MOCK_ACCOUNT_ID]: [ 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', ], @@ -1192,7 +1205,7 @@ describe('getDefaultAsset', () => { }); const getAccountByAddress = jest.fn().mockReturnValue({ - id: 'foo', + id: MOCK_ACCOUNT_ID, }); expect( @@ -1215,7 +1228,7 @@ describe('getDefaultAsset', () => { const getAssetsState = jest.fn().mockReturnValue({ assetsMetadata: {}, accountsAssets: { - foo: [], + [MOCK_ACCOUNT_ID]: [], }, }); diff --git a/packages/snaps-controllers/src/test-utils/controller.ts b/packages/snaps-controllers/src/test-utils/controller.ts index e3f5da7d2c..7dc1efd3cc 100644 --- a/packages/snaps-controllers/src/test-utils/controller.ts +++ b/packages/snaps-controllers/src/test-utils/controller.ts @@ -151,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, @@ -814,7 +816,7 @@ export const getRestrictedSnapInterfaceControllerMessenger = ( }, }, accountsAssets: { - foo: [], + [MOCK_ACCOUNT_ID]: [], }, }), ); diff --git a/packages/snaps-utils/src/ui.test.tsx b/packages/snaps-utils/src/ui.test.tsx index 246234622d..b13149e557 100644 --- a/packages/snaps-utils/src/ui.test.tsx +++ b/packages/snaps-utils/src/ui.test.tsx @@ -924,7 +924,7 @@ describe('validateJsxElements', () => { it('passes for a valid AssetSelector', () => { const getAccountByAddress = jest.fn().mockReturnValue({ - id: 'foo', + id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', }); expect(() => From ae62d8b20dd839eebc9d5613968bc7acb91d4051 Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Tue, 18 Mar 2025 11:19:16 +0100 Subject: [PATCH 15/21] validate that `AssetSelector` throws if different addresses are passed --- packages/snaps-sdk/src/jsx/validation.test.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/snaps-sdk/src/jsx/validation.test.tsx b/packages/snaps-sdk/src/jsx/validation.test.tsx index ccbb700337..132bdeb48e 100644 --- a/packages/snaps-sdk/src/jsx/validation.test.tsx +++ b/packages/snaps-sdk/src/jsx/validation.test.tsx @@ -1754,6 +1754,13 @@ describe('AssetSelectorStruct', () => { addresses={['eip155:0:0x1234567890123456789012345678901234567890']} value="eip155:1/slip44:60" />, + , ])(`does not validate "%p"`, (value) => { expect(is(value, AssetSelectorStruct)).toBe(false); }); From 4e25d4e3a7800618f424bb3c5de5313208fcf0a0 Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Tue, 18 Mar 2025 11:20:26 +0100 Subject: [PATCH 16/21] fix comment --- packages/snaps-sdk/src/types/caip.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/snaps-sdk/src/types/caip.ts b/packages/snaps-sdk/src/types/caip.ts index d48851160f..c648fc3469 100644 --- a/packages/snaps-sdk/src/types/caip.ts +++ b/packages/snaps-sdk/src/types/caip.ts @@ -35,7 +35,7 @@ export type ChainId = CaipChainId; export type AccountId = CaipAccountId; /** - * A stuct representing a list of CAIP-10 account IDs where the account addresses and namespaces are the same. + * A struct representing a list of CAIP-10 account IDs where the account addresses and namespaces are the same. */ export const CaipAccountIdsMatchedByAddressAndNamespaceStruct = refine( array(CaipAccountIdStruct), From 0271d8072707c8c8b3b4ba17c58001b0432222fc Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Tue, 18 Mar 2025 11:35:37 +0100 Subject: [PATCH 17/21] fix import --- packages/snaps-controllers/src/interface/utils.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/snaps-controllers/src/interface/utils.test.tsx b/packages/snaps-controllers/src/interface/utils.test.tsx index d70bae6c42..8b791a5ee4 100644 --- a/packages/snaps-controllers/src/interface/utils.test.tsx +++ b/packages/snaps-controllers/src/interface/utils.test.tsx @@ -17,7 +17,6 @@ import { SelectorOption, AssetSelector, } from '@metamask/snaps-sdk/jsx'; -import { MOCK_ACCOUNT_ID } from 'src/test-utils'; import { assertNameIsUnique, @@ -27,6 +26,7 @@ import { getDefaultAsset, getJsxInterface, } from './utils'; +import { MOCK_ACCOUNT_ID } from '../test-utils'; describe('getJsxInterface', () => { it('returns the JSX interface for a JSX element', () => { From d37ac4fe758b75041579ec74cf38646963bf0697 Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Tue, 18 Mar 2025 12:48:55 +0100 Subject: [PATCH 18/21] Update JSDoc Co-authored-by: Frederik Bolding --- packages/snaps-utils/src/ui.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/snaps-utils/src/ui.tsx b/packages/snaps-utils/src/ui.tsx index 476e5ecb74..845b06df43 100644 --- a/packages/snaps-utils/src/ui.tsx +++ b/packages/snaps-utils/src/ui.tsx @@ -430,6 +430,7 @@ export function validateAssetSelector( * @param node - The JSX node to walk. * @param hooks - The hooks to use for validation. * @param hooks.isOnPhishingList - The function that checks the link against the + * phishing list. * @param hooks.getSnap - The function that returns a snap if installed, undefined otherwise. * @param hooks.getAccountByAddress - The function that returns an account by address. */ From 42e7ae10501e07f0310ae0a8020eda378c6e9432 Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Tue, 18 Mar 2025 14:48:42 +0100 Subject: [PATCH 19/21] Merge `NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct` and `CaipAccountIdsMatchedByAddressAndNamespaceStruct` --- .../browserify-plugin/snap.manifest.json | 2 +- .../packages/browserify/snap.manifest.json | 2 +- packages/snaps-sdk/src/types/caip.test.ts | 46 ++++++------------- packages/snaps-sdk/src/types/caip.ts | 25 +++------- 4 files changed, 22 insertions(+), 53 deletions(-) diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index cf603eb4d8..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": "rwdRZcZOgYfoAvunjlh3qgfhusfBqSv3g23SNhrfSQc=", + "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 be7ed41d57..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": "12YFI4tP/C551Biu4npoh6PtlIPLKDcZa6Mrx2ZItlc=", + "shasum": "SeDH2s8fzM2/cxqbhyhF7G3TeztLsn01kRiWige7l2M=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snaps-sdk/src/types/caip.test.ts b/packages/snaps-sdk/src/types/caip.test.ts index a27b41983d..9858b93ff9 100644 --- a/packages/snaps-sdk/src/types/caip.test.ts +++ b/packages/snaps-sdk/src/types/caip.test.ts @@ -1,72 +1,54 @@ import { is } from '@metamask/superstruct'; import { - CaipAccountIdsMatchedByAddressAndNamespaceStruct, NonEip155AssetTypeStruct, NonEip155ChainIdStruct, NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, } from './caip'; -describe('CaipAccountIdsMatchedByAddressAndNamespaceStruct', () => { - it('validates an array of matchin addresses', () => { +describe('NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct', () => { + it('validates an array of matching non EIP-155 namespace addresses', () => { expect( is( [ - 'eip155:1:0x1234567890123456789012345678901234567890', - 'eip155:2:0x1234567890123456789012345678901234567890', - 'eip155:3:0x1234567890123456789012345678901234567890', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ], - CaipAccountIdsMatchedByAddressAndNamespaceStruct, + NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, ), ).toBe(true); }); - it("doesn't validate an array of mismatching addresses", () => { - expect( - is( - [ - 'eip155:1:0x1234567890123456789012345678901234567890', - 'eip155:2:0x1234567890123456789012225678901234567890', - 'eip155:3:0x1234567890123456789012345678901234567890', - ], - CaipAccountIdsMatchedByAddressAndNamespaceStruct, - ), - ).toBe(false); - }); - - it("doesn't validate an array of mismatching chain namespaces", () => { + it("doesn't validate an array of matching addresses with an EIP-155 namespace", () => { expect( is( [ - 'eip155:1:0x1234567890123456789012345678901234567890', - 'eip155:2:0x1234567890123456789012345678901234567890', - 'foo:3:0x1234567890123456789012345678901234567890', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + 'eip155:1:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', ], - CaipAccountIdsMatchedByAddressAndNamespaceStruct, + NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, ), ).toBe(false); }); -}); -describe('NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct', () => { - it('validates an array of matching non EIP-155 namespace addresses', () => { + it("doesn't validate an array of mismatching addresses", () => { expect( is( [ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', - 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:3PWWwUkCPALDXBcwQBRTYiob2C6xfCm35kzuoJr7ubuw', ], NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, ), - ).toBe(true); + ).toBe(false); }); - it("doesn't validate an array of matching addresses with an EIP-155 namespace", () => { + it("doesn't validate an array of mismatching chain namespaces", () => { expect( is( [ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', - 'eip155:1:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + 'foo:3:0x1234567890123456789012345678901234567890', ], NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct, ), diff --git a/packages/snaps-sdk/src/types/caip.ts b/packages/snaps-sdk/src/types/caip.ts index c648fc3469..3717beeaaa 100644 --- a/packages/snaps-sdk/src/types/caip.ts +++ b/packages/snaps-sdk/src/types/caip.ts @@ -35,11 +35,11 @@ export type ChainId = CaipChainId; export type AccountId = CaipAccountId; /** - * A struct representing a list of CAIP-10 account IDs where the account addresses and namespaces are the same. + * A struct representing a list of non-EIP-155 CAIP-10 account IDs where the account addresses are the same. */ -export const CaipAccountIdsMatchedByAddressAndNamespaceStruct = refine( +export const NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct = refine( array(CaipAccountIdStruct), - 'Matching Addresses Account ID List', + 'Non-EIP-155 Matching Addresses Account ID List', (value) => { const parsedAccountIds = value.map((accountId) => parseCaipAccountId(accountId), @@ -55,31 +55,18 @@ export const CaipAccountIdsMatchedByAddressAndNamespaceStruct = refine( return 'All account IDs must have the same address and namespace.'; } - return true; - }, -); - -/** - * A struct representing a list of non-EIP-155 CAIP-10 account IDs where the account addresses are the same. - */ -export const NonEip155CaipAccountIdsMatchedByAddressAndNamespaceStruct = refine( - CaipAccountIdsMatchedByAddressAndNamespaceStruct, - 'Non-EIP-155 Matching Addresses Account ID List', - (value) => { - const containsEip155 = value.some((accountId) => { - const { - chain: { namespace }, - } = parseCaipAccountId(accountId); - + 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. */ From 886264c1f2363a0162b1d09faf8372e6ed75e2ea Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Tue, 18 Mar 2025 15:00:15 +0100 Subject: [PATCH 20/21] refactor utils for `AssetSelector` --- packages/snaps-controllers/coverage.json | 2 +- .../src/interface/utils.test.tsx | 77 +------------------ .../snaps-controllers/src/interface/utils.ts | 48 ++++-------- 3 files changed, 20 insertions(+), 107 deletions(-) diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index 297ad82c55..a9a891e1f3 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,5 +1,5 @@ { - "branches": 93.47, + "branches": 93.46, "functions": 97.36, "lines": 98.33, "statements": 98.06 diff --git a/packages/snaps-controllers/src/interface/utils.test.tsx b/packages/snaps-controllers/src/interface/utils.test.tsx index 8b791a5ee4..a2a333359d 100644 --- a/packages/snaps-controllers/src/interface/utils.test.tsx +++ b/packages/snaps-controllers/src/interface/utils.test.tsx @@ -21,7 +21,6 @@ import { import { assertNameIsUnique, constructState, - getAssetSelectorDefaultState, getAssetSelectorStateValue, getDefaultAsset, getJsxInterface, @@ -1022,74 +1021,6 @@ describe('getAssetSelectorStateValue', () => { }); }); -describe('getAssetSelectorDefaultState', () => { - it('returns the default asset for an asset selector', () => { - const getAccountByAddress = jest.fn().mockReturnValue({ - id: MOCK_ACCOUNT_ID, - }); - - const getAssetsState = jest.fn().mockReturnValue({ - assetsMetadata: { - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { - name: 'Solana', - symbol: 'SOL', - }, - }, - accountsAssets: { - [MOCK_ACCOUNT_ID]: [ - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', - ], - }, - }); - - expect( - getAssetSelectorDefaultState( - , - { getAccountByAddress, getAssetsState }, - ), - ).toStrictEqual({ - asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', - name: 'Solana', - symbol: 'SOL', - }); - }); - - it('returns null if the default asset is not found', () => { - const getAccountByAddress = jest.fn().mockReturnValue({ - id: MOCK_ACCOUNT_ID, - }); - - const getAssetsState = jest.fn().mockReturnValue({ - assetsMetadata: { - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501': { - name: 'Solana', - symbol: 'SOL', - }, - }, - accountsAssets: { - [MOCK_ACCOUNT_ID]: [], - }, - }); - - expect( - getAssetSelectorDefaultState( - , - { getAccountByAddress, getAssetsState }, - ), - ).toBeNull(); - }); -}); - describe('getDefaultAsset', () => { it('returns the native asset if available', () => { const getAssetsState = jest.fn().mockReturnValue({ @@ -1119,7 +1050,7 @@ describe('getDefaultAsset', () => { { getAssetsState, getAccountByAddress }, ), ).toStrictEqual({ - address: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', name: 'Solana', symbol: 'SOL', }); @@ -1154,7 +1085,7 @@ describe('getDefaultAsset', () => { { getAssetsState, getAccountByAddress }, ), ).toStrictEqual({ - address: + asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', name: 'USDC', symbol: 'USDC', @@ -1181,7 +1112,7 @@ describe('getDefaultAsset', () => { undefined, { getAssetsState, getAccountByAddress }, ), - ).toBeUndefined(); + ).toBeNull(); }); it('selects the default asset from the requested network', () => { @@ -1218,7 +1149,7 @@ describe('getDefaultAsset', () => { { getAssetsState, getAccountByAddress }, ), ).toStrictEqual({ - address: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + asset: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', name: 'Solana', symbol: 'SOL', }); diff --git a/packages/snaps-controllers/src/interface/utils.ts b/packages/snaps-controllers/src/interface/utils.ts index f689a0a670..7a36e57b2c 100644 --- a/packages/snaps-controllers/src/interface/utils.ts +++ b/packages/snaps-controllers/src/interface/utils.ts @@ -138,8 +138,10 @@ export function getDefaultAsset( const accountAssets = accountsAssets[accountId]; + // The Asset 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 asset. if (accountAssets.length === 0) { - return undefined; + return null; } const nativeAsset = accountAssets.find((asset) => { @@ -148,16 +150,18 @@ export function getDefaultAsset( return filteredChainIds.includes(chainId) && assetNamespace === 'slip44'; }); - if (!nativeAsset) { + if (nativeAsset) { return { - address: accountAssets[0], - ...assetsMetadata[accountAssets[0]], + asset: nativeAsset, + name: assetsMetadata[nativeAsset].name, + symbol: assetsMetadata[nativeAsset].symbol, }; } return { - address: nativeAsset, - ...assetsMetadata[nativeAsset], + asset: accountAssets[0], + name: assetsMetadata[accountAssets[0]].name, + symbol: assetsMetadata[accountAssets[0]].symbol, }; } @@ -202,39 +206,17 @@ function constructComponentSpecificDefaultState( return false; case 'AssetSelector': - return getAssetSelectorDefaultState(element, elementDataGetters); + return getDefaultAsset( + element.props.addresses, + element.props.chainIds, + elementDataGetters, + ); default: return null; } } -/** - * Get the default state for an asset selector. - * - * @param element - The asset selector element. - * @param elementDataGetters - Data getters for the element. - * @returns The default state for the asset selector or null. - */ -export function getAssetSelectorDefaultState( - element: AssetSelectorElement, - elementDataGetters: ElementDataGetters, -) { - const { chainIds, addresses } = element.props; - - const asset = getDefaultAsset(addresses, chainIds, elementDataGetters); - - if (!asset) { - return null; - } - - return { - asset: asset.address, - name: asset.name, - symbol: asset.symbol, - }; -} - /** * Get the state value for an asset selector. * From 481b734c4f1b2e58fa9b7df3cd2b28e1658eab32 Mon Sep 17 00:00:00 2001 From: Guillaume Roux Date: Tue, 18 Mar 2025 15:02:29 +0100 Subject: [PATCH 21/21] update comment --- packages/snaps-controllers/src/interface/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/snaps-controllers/src/interface/utils.ts b/packages/snaps-controllers/src/interface/utils.ts index 7a36e57b2c..dd25b40a74 100644 --- a/packages/snaps-controllers/src/interface/utils.ts +++ b/packages/snaps-controllers/src/interface/utils.ts @@ -138,8 +138,8 @@ export function getDefaultAsset( const accountAssets = accountsAssets[accountId]; - // The Asset 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 asset. + // 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; }