diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index 94b8b38f71..c533278abe 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": "7m/tln4qf/bu8u9PdJnluGBWg7949ema1QUhYrL6Kys=", + "shasum": "e3eXjWGO/nmmRxBt/caaktmqj/3chjABsSkFq6leppU=", "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 ca88f95503..0c470e9a01 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": "SeDH2s8fzM2/cxqbhyhF7G3TeztLsn01kRiWige7l2M=", + "shasum": "yvblLjVAXActFtH/3q3GxeTcbRopWjZCX2h4S21hscs=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snaps-controllers/coverage.json b/packages/snaps-controllers/coverage.json index a9a891e1f3..eb91c87495 100644 --- a/packages/snaps-controllers/coverage.json +++ b/packages/snaps-controllers/coverage.json @@ -1,5 +1,5 @@ { - "branches": 93.46, + "branches": 93.51, "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 a2a333359d..8265cb8e59 100644 --- a/packages/snaps-controllers/src/interface/utils.test.tsx +++ b/packages/snaps-controllers/src/interface/utils.test.tsx @@ -16,6 +16,7 @@ import { Card, SelectorOption, AssetSelector, + AddressInput, } from '@metamask/snaps-sdk/jsx'; import { @@ -296,6 +297,49 @@ describe('constructState', () => { }); }); + it('handles root level AddressInput with value', () => { + const element = ( + + + + ); + + const result = constructState({}, element, elementDataGetters); + expect(result).toStrictEqual({ + foo: 'eip155:1:0x123', + }); + }); + + it('handles root level AddressInput without value', () => { + const element = ( + + + + ); + + const result = constructState({}, element, elementDataGetters); + expect(result).toStrictEqual({ + foo: null, + }); + }); + + it('handles AddressInput in forms', () => { + const element = ( + +
+ + + +
+
+ ); + + const result = constructState({}, element, elementDataGetters); + expect(result).toStrictEqual({ + form: { foo: null }, + }); + }); + it('sets default value for root level dropdown', () => { const element = ( diff --git a/packages/snaps-controllers/src/interface/utils.ts b/packages/snaps-controllers/src/interface/utils.ts index 1d3bd77c52..2e5a321ccc 100644 --- a/packages/snaps-controllers/src/interface/utils.ts +++ b/packages/snaps-controllers/src/interface/utils.ts @@ -21,6 +21,7 @@ import type { SelectorElement, SelectorOptionElement, AssetSelectorElement, + AddressInputElement, } from '@metamask/snaps-sdk/jsx'; import { isJSXElementUnsafe } from '@metamask/snaps-sdk/jsx'; import type { InternalAccount } from '@metamask/snaps-utils'; @@ -35,6 +36,8 @@ import { type CaipAccountId, parseCaipAccountId, parseCaipAssetType, + toCaipAccountId, + parseCaipChainId, } from '@metamask/utils'; /** @@ -183,7 +186,8 @@ function constructComponentSpecificDefaultState( | RadioGroupElement | CheckboxElement | SelectorElement - | AssetSelectorElement, + | AssetSelectorElement + | AddressInputElement, elementDataGetters: ElementDataGetters, ) { switch (element.type) { @@ -264,7 +268,8 @@ function getComponentStateValue( | RadioGroupElement | CheckboxElement | SelectorElement - | AssetSelectorElement, + | AssetSelectorElement + | AddressInputElement, { getAssetsState }: ElementDataGetters, ) { switch (element.type) { @@ -274,6 +279,15 @@ function getComponentStateValue( case 'AssetSelector': return getAssetSelectorStateValue(element.props.value, getAssetsState); + case 'AddressInput': { + if (!element.props.value) { + return null; + } + + // Construct CAIP-10 Id + const { namespace, reference } = parseCaipChainId(element.props.chainId); + return toCaipAccountId(namespace, reference, element.props.value); + } default: return element.props.value; } @@ -297,7 +311,8 @@ function constructInputState( | FileInputElement | CheckboxElement | SelectorElement - | AssetSelectorElement, + | AssetSelectorElement + | AddressInputElement, elementDataGetters: ElementDataGetters, form?: string, ) { @@ -360,7 +375,8 @@ export function constructState( component.type === 'FileInput' || component.type === 'Checkbox' || component.type === 'Selector' || - component.type === 'AssetSelector') + component.type === 'AssetSelector' || + component.type === 'AddressInput') ) { const formState = newState[currentForm.name] as FormState; assertNameIsUnique(formState, component.props.name); @@ -382,7 +398,8 @@ export function constructState( component.type === 'FileInput' || component.type === 'Checkbox' || component.type === 'Selector' || - component.type === 'AssetSelector' + component.type === 'AssetSelector' || + component.type === 'AddressInput' ) { assertNameIsUnique(newState, component.props.name); newState[component.props.name] = constructInputState( diff --git a/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx b/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx index b39283fc89..7dc3472dc9 100644 --- a/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx +++ b/packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx @@ -141,7 +141,7 @@ describe('snap_createInterface', () => { error: { code: -32602, message: - 'Invalid params: At path: ui -- Expected type to be one of: "Address", "AssetSelector", "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", "AddressInput", "Bold", "Box", "Button", "Copyable", "Divider", "Dropdown", "RadioGroup", "Field", "FileInput", "Form", "Heading", "Input", "Image", "Italic", "Link", "Row", "Spinner", "Text", "Tooltip", "Checkbox", "Card", "Icon", "Selector", "Section", "Avatar", "Banner", "Skeleton", "Container", but received: undefined.', stack: expect.any(String), }, id: 1, @@ -191,7 +191,7 @@ describe('snap_createInterface', () => { error: { code: -32602, message: - 'Invalid params: At path: ui.props.children.props.children -- Expected type to be one of: "AssetSelector", "Input", "Dropdown", "RadioGroup", "FileInput", "Checkbox", "Selector", but received: "Copyable".', + 'Invalid params: At path: ui.props.children.props.children -- Expected type to be one of: "AssetSelector", "AddressInput", "Input", "Dropdown", "RadioGroup", "FileInput", "Checkbox", "Selector", but received: "Copyable".', stack: expect.any(String), }, id: 1, diff --git a/packages/snaps-sdk/src/internals/jsx.ts b/packages/snaps-sdk/src/internals/jsx.ts index 18d8a09fab..a7080cdcba 100644 --- a/packages/snaps-sdk/src/internals/jsx.ts +++ b/packages/snaps-sdk/src/internals/jsx.ts @@ -10,6 +10,7 @@ import type { Struct, UnionToIntersection, } from '@metamask/superstruct'; +import type { CaipChainId } from '@metamask/utils'; import { union } from './structs'; import type { EmptyObject } from '../types'; @@ -34,11 +35,13 @@ type StructSchema = : [Type] extends [string | undefined | null] ? [Type] extends [`0x${string}`] ? null - : [Type] extends [IsMatch] + : [Type] extends [CaipChainId] ? null - : [Type] extends [IsUnion] - ? EnumSchema - : Type + : [Type] extends [IsMatch] + ? null + : [Type] extends [IsUnion] + ? EnumSchema + : Type : [Type] extends [number | undefined | null] ? [Type] extends [IsMatch] ? null diff --git a/packages/snaps-sdk/src/jsx/components/form/AddressInput.test.tsx b/packages/snaps-sdk/src/jsx/components/form/AddressInput.test.tsx new file mode 100644 index 0000000000..2fcc03b39f --- /dev/null +++ b/packages/snaps-sdk/src/jsx/components/form/AddressInput.test.tsx @@ -0,0 +1,16 @@ +import { AddressInput } from './AddressInput'; + +describe('AddressInput', () => { + it('renders an address input', () => { + const result = ; + + expect(result).toStrictEqual({ + type: 'AddressInput', + props: { + name: 'address', + chainId: 'eip155:1', + }, + key: null, + }); + }); +}); diff --git a/packages/snaps-sdk/src/jsx/components/form/AddressInput.ts b/packages/snaps-sdk/src/jsx/components/form/AddressInput.ts new file mode 100644 index 0000000000..af86807b83 --- /dev/null +++ b/packages/snaps-sdk/src/jsx/components/form/AddressInput.ts @@ -0,0 +1,32 @@ +import type { CaipChainId } from '@metamask/utils'; + +import { createSnapComponent } from '../../component'; + +export type AddressInputProps = { + name: string; + value?: string | undefined; + chainId: CaipChainId; + placeholder?: string | undefined; + disabled?: boolean | undefined; +}; + +const TYPE = 'AddressInput'; + +/** + * An input component for entering an address. Resolves the address to a display name and avatar. + * + * @param props - The props of the component. + * @param props.name - The name of the input field. + * @param props.value - The value of the input field. + * @param props.chainId - The CAIP-2 chain ID of the address. + * @param props.placeholder - The placeholder text of the input field. + * @param props.disabled - Whether the input field is disabled. + * @returns An input element. + * @example + * + */ +export const AddressInput = createSnapComponent( + TYPE, +); + +export type AddressInputElement = ReturnType; diff --git a/packages/snaps-sdk/src/jsx/components/form/Field.test.tsx b/packages/snaps-sdk/src/jsx/components/form/Field.test.tsx index 1daf74e1f7..e0918de112 100644 --- a/packages/snaps-sdk/src/jsx/components/form/Field.test.tsx +++ b/packages/snaps-sdk/src/jsx/components/form/Field.test.tsx @@ -1,3 +1,4 @@ +import { AddressInput } from './AddressInput'; import { Button } from './Button'; import { Dropdown } from './Dropdown'; import { Field } from './Field'; @@ -323,6 +324,30 @@ describe('Field', () => { }); }); + it('renders a field element with an address input', () => { + const result = ( + + + + ); + + expect(result).toStrictEqual({ + type: 'Field', + key: null, + props: { + label: 'Label', + children: { + type: 'AddressInput', + key: null, + props: { + name: 'address', + chainId: 'eip155:1', + }, + }, + }, + }); + }); + it('renders a field with a conditional', () => { const result = ( diff --git a/packages/snaps-sdk/src/jsx/components/form/Field.ts b/packages/snaps-sdk/src/jsx/components/form/Field.ts index 27b6f8286d..fc3c1ddcca 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 { AddressInputElement } from './AddressInput'; import type { AssetSelectorElement } from './AssetSelector'; import type { CheckboxElement } from './Checkbox'; import type { DropdownElement } from './Dropdown'; @@ -5,8 +6,8 @@ import type { FileInputElement } from './FileInput'; import type { InputElement } from './Input'; import type { RadioGroupElement } from './RadioGroup'; import type { SelectorElement } from './Selector'; -import { createSnapComponent } from '../../component'; import type { GenericSnapChildren } from '../../component'; +import { createSnapComponent } from '../../component'; /** * The props of the {@link Field} component. @@ -28,7 +29,8 @@ export type FieldProps = { | InputElement | CheckboxElement | SelectorElement - | AssetSelectorElement; + | AssetSelectorElement + | AddressInputElement; }; const TYPE = 'Field'; diff --git a/packages/snaps-sdk/src/jsx/components/form/index.ts b/packages/snaps-sdk/src/jsx/components/form/index.ts index 7512d750f6..245fa13227 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 { AddressInputElement } from './AddressInput'; import type { AssetSelectorElement } from './AssetSelector'; import type { ButtonElement } from './Button'; import type { CheckboxElement } from './Checkbox'; @@ -25,9 +26,11 @@ export * from './Form'; export * from './Input'; export * from './Selector'; export * from './SelectorOption'; +export * from './AddressInput'; export type StandardFormElement = | AssetSelectorElement + | AddressInputElement | ButtonElement | CheckboxElement | FormElement diff --git a/packages/snaps-sdk/src/jsx/validation.test.tsx b/packages/snaps-sdk/src/jsx/validation.test.tsx index 132bdeb48e..9497371af0 100644 --- a/packages/snaps-sdk/src/jsx/validation.test.tsx +++ b/packages/snaps-sdk/src/jsx/validation.test.tsx @@ -36,6 +36,7 @@ import { Banner, Skeleton, AssetSelector, + AddressInput, } from './components'; import { AddressStruct, @@ -76,6 +77,7 @@ import { BannerStruct, SkeletonStruct, AssetSelectorStruct, + AddressInputStruct, } from './validation'; describe('KeyStruct', () => { @@ -209,6 +211,42 @@ describe('ButtonStruct', () => { }); }); +describe('AddressInputStruct', () => { + it.each([ + , + , + , + , + ])('validates an address input element', (value) => { + expect(is(value, AddressInputStruct)).toBe(true); + }); + + it.each([ + 'foo', + 42, + null, + undefined, + {}, + [], + // @ts-expect-error - Invalid props. + , + // @ts-expect-error - Invalid props. + , + // @ts-expect-error - Invalid props. + , + // @ts-expect-error - Invalid props. + , + // @ts-expect-error - Invalid props. + , + ])('does not validate "%p"', (value) => { + expect(is(value, AddressInputStruct)).toBe(false); + }); +}); + describe('InputStruct', () => { it.each([ , @@ -322,6 +360,9 @@ describe('FieldStruct', () => { ]} /> , + + + , ])('validates a field element', (value) => { expect(is(value, FieldStruct)).toBe(true); }); diff --git a/packages/snaps-sdk/src/jsx/validation.ts b/packages/snaps-sdk/src/jsx/validation.ts index ccb1108bb8..dfe8932b34 100644 --- a/packages/snaps-sdk/src/jsx/validation.ts +++ b/packages/snaps-sdk/src/jsx/validation.ts @@ -23,6 +23,7 @@ import { } from '@metamask/superstruct'; import { CaipAccountIdStruct, + CaipChainIdStruct, hasProperty, HexChecksumAddressStruct, isPlainObject, @@ -40,6 +41,7 @@ import type { StringElement, } from './component'; import type { + AddressInputElement, AssetSelectorElement, AvatarElement, SkeletonElement, @@ -345,6 +347,20 @@ export const InputStruct: Describe = elementWithSelectiveProps( }, ); +/** + * A struct for the {@link AddressInputElement} type. + */ +export const AddressInputStruct: Describe = element( + 'AddressInput', + { + name: string(), + chainId: CaipChainIdStruct, + value: optional(string()), + placeholder: optional(string()), + disabled: optional(boolean()), + }, +); + /** * A struct for the {@link OptionElement} type. */ @@ -499,6 +515,7 @@ const BOX_INPUT_BOTH = [ */ const FIELD_CHILDREN_ARRAY = [ AssetSelectorStruct, + AddressInputStruct, InputStruct, DropdownStruct, RadioGroupStruct, @@ -507,6 +524,7 @@ const FIELD_CHILDREN_ARRAY = [ SelectorStruct, ] as [ typeof AssetSelectorStruct, + typeof AddressInputStruct, typeof InputStruct, typeof DropdownStruct, typeof RadioGroupStruct, @@ -552,7 +570,8 @@ const FieldChildStruct = selectiveUnion((value) => { | InputElement | CheckboxElement | SelectorElement - | AssetSelectorElement, + | AssetSelectorElement + | AddressInputElement, null >; @@ -900,6 +919,7 @@ export const SpinnerStruct: Describe = element('Spinner'); export const BoxChildStruct = typedUnion([ AddressStruct, AssetSelectorStruct, + AddressInputStruct, BoldStruct, BoxStruct, ButtonStruct, @@ -964,6 +984,7 @@ export const RootJSXElementStruct = typedUnion([ */ export const JSXElementStruct: Describe = typedUnion([ AssetSelectorStruct, + AddressInputStruct, ButtonStruct, InputStruct, FileInputStruct, diff --git a/packages/snaps-sdk/src/types/interface.ts b/packages/snaps-sdk/src/types/interface.ts index d5086350a2..47af64c78b 100644 --- a/packages/snaps-sdk/src/types/interface.ts +++ b/packages/snaps-sdk/src/types/interface.ts @@ -6,7 +6,12 @@ import { string, union, } from '@metamask/superstruct'; -import { JsonStruct, hasProperty, isObject } from '@metamask/utils'; +import { + CaipAccountIdStruct, + JsonStruct, + hasProperty, + isObject, +} from '@metamask/utils'; import { AssetSelectorStateStruct, FileStruct } from './handlers'; import { selectiveUnion } from '../internals'; @@ -27,6 +32,7 @@ export const StateStruct = union([ FileStruct, string(), boolean(), + CaipAccountIdStruct, ]); export const FormStateStruct = record(string(), nullable(StateStruct));