Skip to content

Commit b923057

Browse files
Add AddressInput component (#3129)
Closes #2812 This adds an `AddressInput` component that takes a CAIP-2 `chainId` to identify the chain the address needs resolution on and resolves to a display name and avatar (if it exists). The resolution is to be made against the user's own saved accounts. --------- Co-authored-by: Guillaume Roux <[email protected]>
1 parent 099030b commit b923057

File tree

15 files changed

+228
-18
lines changed

15 files changed

+228
-18
lines changed

packages/examples/packages/browserify-plugin/snap.manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"url": "https://github.com/MetaMask/snaps.git"
88
},
99
"source": {
10-
"shasum": "7m/tln4qf/bu8u9PdJnluGBWg7949ema1QUhYrL6Kys=",
10+
"shasum": "e3eXjWGO/nmmRxBt/caaktmqj/3chjABsSkFq6leppU=",
1111
"location": {
1212
"npm": {
1313
"filePath": "dist/bundle.js",

packages/examples/packages/browserify/snap.manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"url": "https://github.com/MetaMask/snaps.git"
88
},
99
"source": {
10-
"shasum": "SeDH2s8fzM2/cxqbhyhF7G3TeztLsn01kRiWige7l2M=",
10+
"shasum": "yvblLjVAXActFtH/3q3GxeTcbRopWjZCX2h4S21hscs=",
1111
"location": {
1212
"npm": {
1313
"filePath": "dist/bundle.js",

packages/snaps-controllers/coverage.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"branches": 93.46,
2+
"branches": 93.51,
33
"functions": 97.36,
44
"lines": 98.33,
55
"statements": 98.06

packages/snaps-controllers/src/interface/utils.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
Card,
1717
SelectorOption,
1818
AssetSelector,
19+
AddressInput,
1920
} from '@metamask/snaps-sdk/jsx';
2021

2122
import {
@@ -296,6 +297,49 @@ describe('constructState', () => {
296297
});
297298
});
298299

300+
it('handles root level AddressInput with value', () => {
301+
const element = (
302+
<Box>
303+
<AddressInput name="foo" chainId="eip155:1" value="0x123" />
304+
</Box>
305+
);
306+
307+
const result = constructState({}, element, elementDataGetters);
308+
expect(result).toStrictEqual({
309+
foo: 'eip155:1:0x123',
310+
});
311+
});
312+
313+
it('handles root level AddressInput without value', () => {
314+
const element = (
315+
<Box>
316+
<AddressInput name="foo" chainId="eip155:1" />
317+
</Box>
318+
);
319+
320+
const result = constructState({}, element, elementDataGetters);
321+
expect(result).toStrictEqual({
322+
foo: null,
323+
});
324+
});
325+
326+
it('handles AddressInput in forms', () => {
327+
const element = (
328+
<Box>
329+
<Form name="form">
330+
<Field label="foo">
331+
<AddressInput name="foo" chainId="eip155:1" />
332+
</Field>
333+
</Form>
334+
</Box>
335+
);
336+
337+
const result = constructState({}, element, elementDataGetters);
338+
expect(result).toStrictEqual({
339+
form: { foo: null },
340+
});
341+
});
342+
299343
it('sets default value for root level dropdown', () => {
300344
const element = (
301345
<Box>

packages/snaps-controllers/src/interface/utils.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
SelectorElement,
2222
SelectorOptionElement,
2323
AssetSelectorElement,
24+
AddressInputElement,
2425
} from '@metamask/snaps-sdk/jsx';
2526
import { isJSXElementUnsafe } from '@metamask/snaps-sdk/jsx';
2627
import type { InternalAccount } from '@metamask/snaps-utils';
@@ -35,6 +36,8 @@ import {
3536
type CaipAccountId,
3637
parseCaipAccountId,
3738
parseCaipAssetType,
39+
toCaipAccountId,
40+
parseCaipChainId,
3841
} from '@metamask/utils';
3942

4043
/**
@@ -183,7 +186,8 @@ function constructComponentSpecificDefaultState(
183186
| RadioGroupElement
184187
| CheckboxElement
185188
| SelectorElement
186-
| AssetSelectorElement,
189+
| AssetSelectorElement
190+
| AddressInputElement,
187191
elementDataGetters: ElementDataGetters,
188192
) {
189193
switch (element.type) {
@@ -264,7 +268,8 @@ function getComponentStateValue(
264268
| RadioGroupElement
265269
| CheckboxElement
266270
| SelectorElement
267-
| AssetSelectorElement,
271+
| AssetSelectorElement
272+
| AddressInputElement,
268273
{ getAssetsState }: ElementDataGetters,
269274
) {
270275
switch (element.type) {
@@ -274,6 +279,15 @@ function getComponentStateValue(
274279
case 'AssetSelector':
275280
return getAssetSelectorStateValue(element.props.value, getAssetsState);
276281

282+
case 'AddressInput': {
283+
if (!element.props.value) {
284+
return null;
285+
}
286+
287+
// Construct CAIP-10 Id
288+
const { namespace, reference } = parseCaipChainId(element.props.chainId);
289+
return toCaipAccountId(namespace, reference, element.props.value);
290+
}
277291
default:
278292
return element.props.value;
279293
}
@@ -297,7 +311,8 @@ function constructInputState(
297311
| FileInputElement
298312
| CheckboxElement
299313
| SelectorElement
300-
| AssetSelectorElement,
314+
| AssetSelectorElement
315+
| AddressInputElement,
301316
elementDataGetters: ElementDataGetters,
302317
form?: string,
303318
) {
@@ -360,7 +375,8 @@ export function constructState(
360375
component.type === 'FileInput' ||
361376
component.type === 'Checkbox' ||
362377
component.type === 'Selector' ||
363-
component.type === 'AssetSelector')
378+
component.type === 'AssetSelector' ||
379+
component.type === 'AddressInput')
364380
) {
365381
const formState = newState[currentForm.name] as FormState;
366382
assertNameIsUnique(formState, component.props.name);
@@ -382,7 +398,8 @@ export function constructState(
382398
component.type === 'FileInput' ||
383399
component.type === 'Checkbox' ||
384400
component.type === 'Selector' ||
385-
component.type === 'AssetSelector'
401+
component.type === 'AssetSelector' ||
402+
component.type === 'AddressInput'
386403
) {
387404
assertNameIsUnique(newState, component.props.name);
388405
newState[component.props.name] = constructInputState(

packages/snaps-rpc-methods/src/permitted/createInterface.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ describe('snap_createInterface', () => {
141141
error: {
142142
code: -32602,
143143
message:
144-
'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.',
144+
'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.',
145145
stack: expect.any(String),
146146
},
147147
id: 1,
@@ -191,7 +191,7 @@ describe('snap_createInterface', () => {
191191
error: {
192192
code: -32602,
193193
message:
194-
'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".',
194+
'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".',
195195
stack: expect.any(String),
196196
},
197197
id: 1,

packages/snaps-sdk/src/internals/jsx.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
Struct,
1111
UnionToIntersection,
1212
} from '@metamask/superstruct';
13+
import type { CaipChainId } from '@metamask/utils';
1314

1415
import { union } from './structs';
1516
import type { EmptyObject } from '../types';
@@ -34,11 +35,13 @@ type StructSchema<Type> =
3435
: [Type] extends [string | undefined | null]
3536
? [Type] extends [`0x${string}`]
3637
? null
37-
: [Type] extends [IsMatch<Type, string | undefined | null>]
38+
: [Type] extends [CaipChainId]
3839
? null
39-
: [Type] extends [IsUnion<Type>]
40-
? EnumSchema<Type>
41-
: Type
40+
: [Type] extends [IsMatch<Type, string | undefined | null>]
41+
? null
42+
: [Type] extends [IsUnion<Type>]
43+
? EnumSchema<Type>
44+
: Type
4245
: [Type] extends [number | undefined | null]
4346
? [Type] extends [IsMatch<Type, number | undefined | null>]
4447
? null
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { AddressInput } from './AddressInput';
2+
3+
describe('AddressInput', () => {
4+
it('renders an address input', () => {
5+
const result = <AddressInput name="address" chainId="eip155:1" />;
6+
7+
expect(result).toStrictEqual({
8+
type: 'AddressInput',
9+
props: {
10+
name: 'address',
11+
chainId: 'eip155:1',
12+
},
13+
key: null,
14+
});
15+
});
16+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { CaipChainId } from '@metamask/utils';
2+
3+
import { createSnapComponent } from '../../component';
4+
5+
export type AddressInputProps = {
6+
name: string;
7+
value?: string | undefined;
8+
chainId: CaipChainId;
9+
placeholder?: string | undefined;
10+
disabled?: boolean | undefined;
11+
};
12+
13+
const TYPE = 'AddressInput';
14+
15+
/**
16+
* An input component for entering an address. Resolves the address to a display name and avatar.
17+
*
18+
* @param props - The props of the component.
19+
* @param props.name - The name of the input field.
20+
* @param props.value - The value of the input field.
21+
* @param props.chainId - The CAIP-2 chain ID of the address.
22+
* @param props.placeholder - The placeholder text of the input field.
23+
* @param props.disabled - Whether the input field is disabled.
24+
* @returns An input element.
25+
* @example
26+
* <AddressInput name="address" value="0x1234567890123456789012345678901234567890" chainId="eip155:1" />
27+
*/
28+
export const AddressInput = createSnapComponent<AddressInputProps, typeof TYPE>(
29+
TYPE,
30+
);
31+
32+
export type AddressInputElement = ReturnType<typeof AddressInput>;

packages/snaps-sdk/src/jsx/components/form/Field.test.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AddressInput } from './AddressInput';
12
import { Button } from './Button';
23
import { Dropdown } from './Dropdown';
34
import { Field } from './Field';
@@ -323,6 +324,30 @@ describe('Field', () => {
323324
});
324325
});
325326

327+
it('renders a field element with an address input', () => {
328+
const result = (
329+
<Field label="Label">
330+
<AddressInput name="address" chainId="eip155:1" />
331+
</Field>
332+
);
333+
334+
expect(result).toStrictEqual({
335+
type: 'Field',
336+
key: null,
337+
props: {
338+
label: 'Label',
339+
children: {
340+
type: 'AddressInput',
341+
key: null,
342+
props: {
343+
name: 'address',
344+
chainId: 'eip155:1',
345+
},
346+
},
347+
},
348+
});
349+
});
350+
326351
it('renders a field with a conditional', () => {
327352
const result = (
328353
<Field>

0 commit comments

Comments
 (0)