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