Skip to content

Commit 45ce853

Browse files
committed
[WIP] AccountSelector
1 parent 21e8168 commit 45ce853

File tree

12 files changed

+489
-14
lines changed

12 files changed

+489
-14
lines changed

packages/examples/packages/dialogs/src/components/custom.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
AccountSelector,
23
Box,
34
Button,
45
Container,
@@ -22,7 +23,7 @@ export const CustomDialog: SnapComponent = () => (
2223
This is a custom dialog. It has a custom Footer and can be resolved to
2324
any value.
2425
</Text>
25-
<Input name="custom-input" placeholder="Enter something..." />
26+
<AccountSelector name="account" />
2627
</Box>
2728
<Footer>
2829
<Button name="cancel">Cancel</Button>

packages/snaps-controllers/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
"@metamask/eslint-config-jest": "^12.1.0",
119119
"@metamask/eslint-config-nodejs": "^12.1.0",
120120
"@metamask/eslint-config-typescript": "^12.1.0",
121+
"@metamask/keyring-internal-api": "^4.0.1",
121122
"@metamask/template-snap": "^0.7.0",
122123
"@swc/core": "1.3.78",
123124
"@swc/jest": "^0.2.26",

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

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
ControllerStateChangeEvent,
99
} from '@metamask/base-controller';
1010
import { BaseController } from '@metamask/base-controller';
11+
import type { InternalAccount } from '@metamask/keyring-internal-api';
1112
import type {
1213
MaybeUpdateState,
1314
TestOrigin,
@@ -67,6 +68,16 @@ export type ResolveInterface = {
6768
handler: SnapInterfaceController['resolveInterface'];
6869
};
6970

71+
type AccountsControllerGetSelectedMultichainAccountAction = {
72+
type: `AccountsController:getSelectedMultichainAccount`;
73+
handler: () => InternalAccount;
74+
};
75+
76+
type AccountsControllerGetAccountByAddressAction = {
77+
type: `AccountsController:getAccountByAddress`;
78+
handler: (address: string) => InternalAccount;
79+
};
80+
7081
export type SnapInterfaceControllerGetStateAction = ControllerGetStateAction<
7182
typeof controllerName,
7283
SnapInterfaceControllerState
@@ -77,7 +88,9 @@ export type SnapInterfaceControllerAllowedActions =
7788
| MaybeUpdateState
7889
| HasApprovalRequest
7990
| AcceptRequest
80-
| GetSnap;
91+
| GetSnap
92+
| AccountsControllerGetSelectedMultichainAccountAction
93+
| AccountsControllerGetAccountByAddressAction;
8194

8295
export type SnapInterfaceControllerActions =
8396
| CreateInterface
@@ -249,7 +262,12 @@ export class SnapInterfaceController extends BaseController<
249262
validateInterfaceContext(context);
250263

251264
const id = nanoid();
252-
const componentState = constructState({}, element);
265+
const componentState = constructState(
266+
{},
267+
element,
268+
this.#getSelectedAccount.bind(this),
269+
this.#getAccountByAddress.bind(this),
270+
);
253271

254272
this.update((draftState) => {
255273
// @ts-expect-error - TS2589: Type instantiation is excessively deep and
@@ -299,7 +317,12 @@ export class SnapInterfaceController extends BaseController<
299317
validateInterfaceContext(context);
300318

301319
const oldState = this.state.interfaces[id].state;
302-
const newState = constructState(oldState, element);
320+
const newState = constructState(
321+
oldState,
322+
element,
323+
this.#getSelectedAccount.bind(this),
324+
this.#getAccountByAddress.bind(this),
325+
);
303326

304327
this.update((draftState) => {
305328
draftState.interfaces[id].state = newState;
@@ -426,6 +449,19 @@ export class SnapInterfaceController extends BaseController<
426449
);
427450
}
428451

452+
#getAccountByAddress(address: string) {
453+
return this.messagingSystem.call(
454+
'AccountsController:getAccountByAddress',
455+
address,
456+
);
457+
}
458+
459+
#getSelectedAccount() {
460+
return this.messagingSystem.call(
461+
'AccountsController:getSelectedMultichainAccount',
462+
);
463+
}
464+
429465
/**
430466
* Utility function to validate the components of an interface.
431467
* Throws if something is invalid.

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

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { InternalAccount } from '@metamask/keyring-internal-api';
12
import { assert } from '@metamask/snaps-sdk';
23
import type {
34
FormState,
@@ -17,6 +18,7 @@ import type {
1718
RadioElement,
1819
SelectorElement,
1920
SelectorOptionElement,
21+
AccountSelectorElement,
2022
} from '@metamask/snaps-sdk/jsx';
2123
import { isJSXElementUnsafe } from '@metamask/snaps-sdk/jsx';
2224
import {
@@ -25,6 +27,17 @@ import {
2527
getJsxElementFromComponent,
2628
walkJsx,
2729
} from '@metamask/snaps-utils';
30+
import type { CaipAccountId, CaipChainId } from '@metamask/utils';
31+
import {
32+
parseCaipChainId,
33+
toCaipAccountId,
34+
type CaipAccountAddress,
35+
} from '@metamask/utils';
36+
37+
type GetSelectedAccount = () => InternalAccount | undefined;
38+
type GetAccountByAddress = (
39+
address: CaipAccountAddress,
40+
) => InternalAccount | undefined;
2841

2942
/**
3043
* Get a JSX element from a component or JSX element. If the component is a
@@ -55,13 +68,31 @@ export function assertNameIsUnique(state: InterfaceState, name: string) {
5568
);
5669
}
5770

71+
/**
72+
* Create a list of CAIP account IDs from an address and a list of scopes.
73+
*
74+
* @param address - The address to create the account IDs from.
75+
* @param scopes - The scopes to create the account IDs from.
76+
* @returns The list of CAIP account IDs.
77+
*/
78+
export function createAddressList(
79+
address: string,
80+
scopes: CaipChainId[],
81+
): CaipAccountId[] {
82+
return scopes.map((scope) => {
83+
const { namespace, reference } = parseCaipChainId(scope);
84+
return toCaipAccountId(namespace, reference, address);
85+
});
86+
}
87+
5888
/**
5989
* Construct default state for a component.
6090
*
6191
* This function is meant to be used inside constructInputState to account
6292
* for component specific defaults and will not override the component value or existing form state.
6393
*
6494
* @param element - The input element.
95+
* @param getSelectedAccount - A function to get the selected account in the client.
6596
* @returns The default state for the specific component, if any.
6697
*/
6798
function constructComponentSpecificDefaultState(
@@ -70,7 +101,9 @@ function constructComponentSpecificDefaultState(
70101
| DropdownElement
71102
| RadioGroupElement
72103
| CheckboxElement
73-
| SelectorElement,
104+
| SelectorElement
105+
| AccountSelectorElement,
106+
getSelectedAccount: GetSelectedAccount,
74107
) {
75108
switch (element.type) {
76109
case 'Dropdown': {
@@ -88,6 +121,20 @@ function constructComponentSpecificDefaultState(
88121
return children[0]?.props.value;
89122
}
90123

124+
case 'AccountSelector': {
125+
const account = getSelectedAccount();
126+
127+
if (!account) {
128+
return null;
129+
}
130+
131+
const { id, address, scopes } = account;
132+
133+
const addresses = createAddressList(address, scopes);
134+
135+
return { accountId: id, addresses };
136+
}
137+
91138
case 'Checkbox':
92139
return false;
93140

@@ -103,20 +150,41 @@ function constructComponentSpecificDefaultState(
103150
* This function exists to account for components where that isn't the case.
104151
*
105152
* @param element - The input element.
153+
* @param getAccountByAddress - A function to get an account by address.
106154
* @returns The state value for a given component.
107155
*/
108156
function getComponentStateValue(
109157
element:
158+
| AccountSelectorElement
110159
| InputElement
111160
| DropdownElement
112161
| RadioGroupElement
113162
| CheckboxElement
114163
| SelectorElement,
164+
getAccountByAddress: GetAccountByAddress,
115165
) {
116166
switch (element.type) {
117167
case 'Checkbox':
118168
return element.props.checked;
119169

170+
case 'AccountSelector': {
171+
if (!element.props.selectedAddress) {
172+
return undefined;
173+
}
174+
175+
const account = getAccountByAddress(element.props.selectedAddress);
176+
177+
if (!account) {
178+
return undefined;
179+
}
180+
181+
const { id, address, scopes } = account;
182+
183+
const addresses = createAddressList(address, scopes);
184+
185+
return { accountId: id, addresses };
186+
}
187+
120188
default:
121189
return element.props.value;
122190
}
@@ -127,18 +195,23 @@ function getComponentStateValue(
127195
*
128196
* @param oldState - The previous state.
129197
* @param element - The input element.
198+
* @param getSelectedAccount - A function to get the selected account in the client.
199+
* @param getAccountByAddress - A function to get an account by address.
130200
* @param form - An optional form that the input is enclosed in.
131201
* @returns The input state.
132202
*/
133203
function constructInputState(
134204
oldState: InterfaceState,
135205
element:
206+
| AccountSelectorElement
136207
| InputElement
137208
| DropdownElement
138209
| RadioGroupElement
139210
| FileInputElement
140211
| CheckboxElement
141212
| SelectorElement,
213+
getSelectedAccount: GetSelectedAccount,
214+
getAccountByAddress: GetAccountByAddress,
142215
form?: string,
143216
) {
144217
const oldStateUnwrapped = form ? (oldState[form] as FormState) : oldState;
@@ -149,9 +222,9 @@ function constructInputState(
149222
}
150223

151224
return (
152-
getComponentStateValue(element) ??
225+
getComponentStateValue(element, getAccountByAddress) ??
153226
oldInputState ??
154-
constructComponentSpecificDefaultState(element) ??
227+
constructComponentSpecificDefaultState(element, getSelectedAccount) ??
155228
null
156229
);
157230
}
@@ -161,11 +234,15 @@ function constructInputState(
161234
*
162235
* @param oldState - The previous state.
163236
* @param rootComponent - The UI component to construct state from.
237+
* @param getSelectedAccount - A function to get the selected account in the client.
238+
* @param getAccountByAddress - A function to get an account by address.
164239
* @returns The interface state of the passed component.
165240
*/
166241
export function constructState(
167242
oldState: InterfaceState,
168243
rootComponent: JSXElement,
244+
getSelectedAccount: GetSelectedAccount,
245+
getAccountByAddress: GetAccountByAddress,
169246
): InterfaceState {
170247
const newState: InterfaceState = {};
171248

@@ -196,13 +273,16 @@ export function constructState(
196273
component.type === 'RadioGroup' ||
197274
component.type === 'FileInput' ||
198275
component.type === 'Checkbox' ||
199-
component.type === 'Selector')
276+
component.type === 'Selector' ||
277+
component.type === 'AccountSelector')
200278
) {
201279
const formState = newState[currentForm.name] as FormState;
202280
assertNameIsUnique(formState, component.props.name);
203281
formState[component.props.name] = constructInputState(
204282
oldState,
205283
component,
284+
getSelectedAccount,
285+
getAccountByAddress,
206286
currentForm.name,
207287
);
208288
return;
@@ -215,10 +295,16 @@ export function constructState(
215295
component.type === 'RadioGroup' ||
216296
component.type === 'FileInput' ||
217297
component.type === 'Checkbox' ||
218-
component.type === 'Selector'
298+
component.type === 'Selector' ||
299+
component.type === 'AccountSelector'
219300
) {
220301
assertNameIsUnique(newState, component.props.name);
221-
newState[component.props.name] = constructInputState(oldState, component);
302+
newState[component.props.name] = constructInputState(
303+
oldState,
304+
component,
305+
getSelectedAccount,
306+
getAccountByAddress,
307+
);
222308
}
223309
});
224310

0 commit comments

Comments
 (0)