Skip to content

Commit 6bf852e

Browse files
ccharlydavid0xd
andauthored
feat(account-api): add selectOne and select selector utils (#342)
Those selectors implementations used to leave on the `MultichainAccountService`, but having them as free-functions would allow us to use them in standard react selectors too. Also, it's important we implement them right given that `scopes` requires some specific logic (and will require even more once CAIP introduce the ["wildcard" pattern for CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-363.md)). --------- Co-authored-by: David Drazic <david@timechaser.org>
1 parent 459da42 commit 6bf852e

10 files changed

Lines changed: 378 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ linkStyle default opacity:0.5
4848
keyring_snap_sdk(["@metamask/keyring-snap-sdk"]);
4949
keyring_utils(["@metamask/keyring-utils"]);
5050
account_api --> keyring_api;
51+
account_api --> keyring_utils;
5152
keyring_api --> keyring_utils;
5253
eth_hd_keyring --> keyring_utils;
5354
eth_ledger_bridge_keyring --> keyring_utils;

packages/account-api/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Add `selectOne` and `select` selectors functions ([#342](https://github.com/MetaMask/accounts/pull/342))
13+
- Those functions can be used to filter a list of accounts using an `AccountSelector` object.
1214
- Add `assertIsBip44Account` ([#339](https://github.com/MetaMask/accounts/pull/339))
1315

1416
### Changed

packages/account-api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
},
5858
"dependencies": {
5959
"@metamask/keyring-api": "workspace:^",
60+
"@metamask/keyring-utils": "workspace:^",
6061
"@metamask/superstruct": "^3.1.0",
6162
"uuid": "^9.0.1"
6263
},

packages/account-api/src/api/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,7 @@ export * from './bip44';
22
export * from './group';
33
export * from './wallet';
44
export type * from './provider';
5-
export type * from './selector';
5+
export * from './selector';
66
export * from './multichain';
7+
8+
// NOTE: Do not export the `internal` sub-path.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// This folder is meant to be used internally and should not be exported.
2+
3+
export * from './utils';
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { areBothEmpty } from './utils';
2+
3+
describe('internal utils', () => {
4+
describe('areBothEmpty', () => {
5+
it('returns true if both arrays are empty', () => {
6+
expect(areBothEmpty([], [])).toBe(true);
7+
});
8+
9+
it('returns false if one of the arrays is empty', () => {
10+
expect(areBothEmpty([0], [])).toBe(false);
11+
expect(areBothEmpty([], [1])).toBe(false);
12+
});
13+
14+
it('returns false if the two arrays are not empty', () => {
15+
expect(areBothEmpty([0], [1])).toBe(false);
16+
});
17+
});
18+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Checks that both arrays are empty, and thus, identical.
3+
*
4+
* @param a - First array.
5+
* @param b - Second array.
6+
* @returns True if both arrays are empty, false otherwise.
7+
*/
8+
export function areBothEmpty<Value>(a: Value[], b: Value[]): boolean {
9+
return a.length === 0 && b.length === 0;
10+
}
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import type { KeyringAccount } from '@metamask/keyring-api';
2+
import {
3+
BtcAccountType,
4+
BtcMethod,
5+
BtcScope,
6+
EthAccountType,
7+
EthMethod,
8+
EthScope,
9+
SolScope,
10+
} from '@metamask/keyring-api';
11+
12+
import type { Bip44Account } from './bip44';
13+
import type { AccountSelector } from './selector';
14+
import { select, selectOne } from './selector';
15+
import {
16+
MOCK_WALLET_1_BTC_P2TR_ACCOUNT,
17+
MOCK_WALLET_1_BTC_P2WPKH_ACCOUNT,
18+
MOCK_WALLET_1_EVM_ACCOUNT,
19+
MOCK_WALLET_1_SOL_ACCOUNT,
20+
} from '../mocks';
21+
22+
describe('selector', () => {
23+
const accounts = [
24+
MOCK_WALLET_1_EVM_ACCOUNT,
25+
MOCK_WALLET_1_SOL_ACCOUNT,
26+
MOCK_WALLET_1_BTC_P2WPKH_ACCOUNT,
27+
MOCK_WALLET_1_BTC_P2TR_ACCOUNT,
28+
];
29+
30+
describe('selectOne', () => {
31+
it.each([
32+
{
33+
tc: 'using id',
34+
selector: { id: MOCK_WALLET_1_EVM_ACCOUNT.id },
35+
expected: MOCK_WALLET_1_EVM_ACCOUNT,
36+
},
37+
{
38+
tc: 'using address',
39+
selector: { address: MOCK_WALLET_1_SOL_ACCOUNT.address },
40+
expected: MOCK_WALLET_1_SOL_ACCOUNT,
41+
},
42+
{
43+
tc: 'using type',
44+
selector: { type: MOCK_WALLET_1_EVM_ACCOUNT.type },
45+
expected: MOCK_WALLET_1_EVM_ACCOUNT,
46+
},
47+
{
48+
tc: 'using scope',
49+
selector: { scopes: [SolScope.Mainnet] },
50+
expected: MOCK_WALLET_1_SOL_ACCOUNT,
51+
},
52+
{
53+
tc: 'using another scope (but still included in the list of account.scopes)',
54+
selector: { scopes: [SolScope.Testnet] },
55+
expected: MOCK_WALLET_1_SOL_ACCOUNT,
56+
},
57+
{
58+
tc: 'using specific EVM chain still matches with EVM EOA scopes',
59+
selector: { scopes: [EthScope.Testnet] },
60+
expected: MOCK_WALLET_1_EVM_ACCOUNT,
61+
},
62+
{
63+
tc: 'using multiple scopes',
64+
selector: { scopes: [SolScope.Mainnet, SolScope.Testnet] },
65+
expected: MOCK_WALLET_1_SOL_ACCOUNT,
66+
},
67+
{
68+
tc: 'using method',
69+
selector: { methods: [EthMethod.SignTransaction] },
70+
expected: MOCK_WALLET_1_EVM_ACCOUNT,
71+
},
72+
{
73+
tc: 'using another method',
74+
selector: { methods: [EthMethod.PersonalSign] },
75+
expected: MOCK_WALLET_1_EVM_ACCOUNT,
76+
},
77+
{
78+
tc: 'using multiple methods',
79+
selector: {
80+
methods: [EthMethod.SignTransaction, EthMethod.PersonalSign],
81+
},
82+
expected: MOCK_WALLET_1_EVM_ACCOUNT,
83+
},
84+
])(
85+
'gets internal account from selector: $tc',
86+
async ({ selector, expected }) => {
87+
expect(selectOne(accounts, selector)).toStrictEqual(expected);
88+
},
89+
);
90+
91+
it.each([
92+
{
93+
tc: 'using non-matching id',
94+
selector: { id: '66da96d7-8f24-4895-82d6-183d740c2da1' },
95+
},
96+
{
97+
tc: 'using non-matching address',
98+
selector: { address: 'unknown-address' },
99+
},
100+
{
101+
tc: 'using non-matching type',
102+
selector: { type: 'unknown-type' },
103+
},
104+
{
105+
tc: 'using non-matching scope',
106+
selector: {
107+
scopes: ['bip122:12a765e31ffd4059bada1e25190f6e98' /* Litecoin */],
108+
},
109+
},
110+
{
111+
tc: 'using non-matching method',
112+
selector: { methods: ['eth_unknownMethod'] },
113+
},
114+
] as {
115+
tc: string;
116+
selector: AccountSelector<Bip44Account<KeyringAccount>>;
117+
}[])(
118+
'gets undefined if not matching selector: $tc',
119+
async ({ selector }) => {
120+
expect(selectOne(accounts, selector)).toBeUndefined();
121+
},
122+
);
123+
124+
it('matches account when using empty scopes', () => {
125+
const mockAccountWithNoScopes = {
126+
...MOCK_WALLET_1_EVM_ACCOUNT,
127+
scopes: [],
128+
};
129+
130+
expect(
131+
selectOne([mockAccountWithNoScopes], { scopes: [] }),
132+
).toStrictEqual(mockAccountWithNoScopes);
133+
});
134+
135+
it('matches account when using empty methods', () => {
136+
const mockAccountWithNoMethods = {
137+
...MOCK_WALLET_1_EVM_ACCOUNT,
138+
methods: [],
139+
};
140+
141+
expect(
142+
selectOne([mockAccountWithNoMethods], { methods: [] }),
143+
).toStrictEqual(mockAccountWithNoMethods);
144+
});
145+
146+
it('throws if multiple candidates are found', async () => {
147+
const selector = {
148+
scopes: [EthScope.Mainnet, SolScope.Mainnet],
149+
};
150+
151+
expect(() => selectOne(accounts, selector)).toThrow(
152+
'Too many account candidates, expected 1, got: 2',
153+
);
154+
});
155+
});
156+
157+
describe('select', () => {
158+
it.each([
159+
{
160+
tc: 'using id',
161+
selector: { id: MOCK_WALLET_1_EVM_ACCOUNT.id },
162+
expected: [MOCK_WALLET_1_EVM_ACCOUNT],
163+
},
164+
{
165+
tc: 'using non-matching id',
166+
selector: { id: '66da96d7-8f24-4895-82d6-183d740c2da1' },
167+
expected: [],
168+
},
169+
{
170+
tc: 'using address',
171+
selector: { address: MOCK_WALLET_1_SOL_ACCOUNT.address },
172+
expected: [MOCK_WALLET_1_SOL_ACCOUNT],
173+
},
174+
{
175+
tc: 'using non-matching address',
176+
selector: { address: 'unknown-address' },
177+
expected: [],
178+
},
179+
{
180+
tc: 'using type',
181+
selector: { type: MOCK_WALLET_1_EVM_ACCOUNT.type },
182+
expected: [MOCK_WALLET_1_EVM_ACCOUNT],
183+
},
184+
{
185+
tc: 'using non-matching type',
186+
selector: { type: 'unknown-type' },
187+
expected: [],
188+
},
189+
{
190+
tc: 'using scope',
191+
selector: { scopes: [SolScope.Mainnet] },
192+
expected: [MOCK_WALLET_1_SOL_ACCOUNT],
193+
},
194+
{
195+
tc: 'using another scope (but still included in the list of account.scopes)',
196+
selector: { scopes: [SolScope.Testnet] },
197+
expected: [MOCK_WALLET_1_SOL_ACCOUNT],
198+
},
199+
{
200+
tc: 'using specific EVM chain still matches with EVM EOA scopes',
201+
selector: { scopes: [EthScope.Testnet] },
202+
expected: [MOCK_WALLET_1_EVM_ACCOUNT],
203+
},
204+
{
205+
tc: 'using multiple scopes',
206+
selector: { scopes: [BtcScope.Mainnet, BtcScope.Testnet] },
207+
expected: [
208+
MOCK_WALLET_1_BTC_P2WPKH_ACCOUNT,
209+
MOCK_WALLET_1_BTC_P2TR_ACCOUNT,
210+
],
211+
},
212+
{
213+
tc: 'using non-matching scopes',
214+
selector: {
215+
scopes: ['bip122:12a765e31ffd4059bada1e25190f6e98' /* Litecoin */],
216+
},
217+
expected: [],
218+
},
219+
{
220+
tc: 'using method',
221+
selector: { methods: [BtcMethod.SendBitcoin] },
222+
expected: [
223+
MOCK_WALLET_1_BTC_P2WPKH_ACCOUNT,
224+
MOCK_WALLET_1_BTC_P2TR_ACCOUNT,
225+
],
226+
},
227+
{
228+
tc: 'using multiple methods',
229+
selector: {
230+
methods: [EthMethod.SignTransaction, EthMethod.PersonalSign],
231+
},
232+
expected: [MOCK_WALLET_1_EVM_ACCOUNT],
233+
},
234+
{
235+
tc: 'using non-matching method',
236+
selector: { methods: ['eth_unknownMethod'] },
237+
expected: [],
238+
},
239+
{
240+
tc: 'using multiple selectors',
241+
selector: {
242+
type: EthAccountType.Eoa,
243+
methods: [EthMethod.SignTransaction, EthMethod.PersonalSign],
244+
},
245+
expected: [MOCK_WALLET_1_EVM_ACCOUNT],
246+
},
247+
{
248+
tc: 'using non-matching selectors',
249+
selector: {
250+
type: BtcAccountType.P2wpkh,
251+
methods: [EthMethod.SignTransaction, EthMethod.PersonalSign],
252+
},
253+
expected: [],
254+
},
255+
] as {
256+
tc: string;
257+
selector: AccountSelector<Bip44Account<KeyringAccount>>;
258+
expected: Bip44Account<KeyringAccount>[];
259+
}[])(
260+
'selects internal accounts from selector: $tc',
261+
async ({ selector, expected }) => {
262+
expect(select(accounts, selector)).toStrictEqual(expected);
263+
},
264+
);
265+
});
266+
});

0 commit comments

Comments
 (0)