Skip to content

Commit 26e98ee

Browse files
committed
refactor(multichain-account-service): move MultichainAccount{Wallet,Group} implementations
1 parent 299edd8 commit 26e98ee

7 files changed

Lines changed: 461 additions & 40 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@
4747
"resolutions": {
4848
"elliptic@6.5.4": "^6.5.7",
4949
"fast-xml-parser@^4.3.4": "^4.4.1",
50-
"ws@7.4.6": "^7.5.10"
50+
"ws@7.4.6": "^7.5.10",
51+
"@metamask/account-api@^0.5.0": "npm:@metamask-previews/account-api@0.5.0-0e28ac0"
5152
},
5253
"devDependencies": {
5354
"@babel/core": "^7.23.5",

packages/multichain-account-service/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"@metamask/keyring-api": "^19.0.0",
5252
"@metamask/keyring-internal-api": "^7.0.0",
5353
"@metamask/keyring-snap-client": "^6.0.0",
54+
"@metamask/keyring-utils": "^3.1.0",
5455
"@metamask/snaps-sdk": "^9.0.0",
5556
"@metamask/snaps-utils": "^11.0.0",
5657
"@metamask/superstruct": "^3.1.0"
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import type { Bip44Account } from '@metamask/account-api';
2+
import type { AccountProvider } from '@metamask/account-api';
3+
import type { AccountSelector } from '@metamask/account-api';
4+
import { AccountGroupType } from '@metamask/account-api';
5+
import {
6+
toMultichainAccountGroupId,
7+
type MultichainAccountGroupId,
8+
type MultichainAccountGroup as MultichainAccountGroupDefinition,
9+
} from '@metamask/account-api';
10+
import { type KeyringAccount } from '@metamask/keyring-api';
11+
import { isScopeEqualToAny } from '@metamask/keyring-utils';
12+
13+
import type { MultichainAccountWallet } from './MultichainAccountWallet';
14+
15+
/**
16+
* A multichain account group that holds multiple accounts.
17+
*/
18+
export class MultichainAccountGroup<
19+
Account extends Bip44Account<KeyringAccount>,
20+
> implements MultichainAccountGroupDefinition<Account>
21+
{
22+
readonly #id: MultichainAccountGroupId;
23+
24+
readonly #wallet: MultichainAccountWallet<Account>;
25+
26+
readonly #index: number;
27+
28+
readonly #providers: AccountProvider<Account>[];
29+
30+
readonly #providerToAccounts: Map<AccountProvider<Account>, Account['id'][]>;
31+
32+
readonly #accountToProvider: Map<Account['id'], AccountProvider<Account>>;
33+
34+
constructor({
35+
groupIndex,
36+
wallet,
37+
providers,
38+
}: {
39+
groupIndex: number;
40+
wallet: MultichainAccountWallet<Account>;
41+
providers: AccountProvider<Account>[];
42+
}) {
43+
this.#id = toMultichainAccountGroupId(wallet.id, groupIndex);
44+
this.#index = groupIndex;
45+
this.#wallet = wallet;
46+
this.#providers = providers;
47+
this.#providerToAccounts = new Map();
48+
this.#accountToProvider = new Map();
49+
50+
this.sync();
51+
}
52+
53+
/**
54+
* Force multichain account synchronization.
55+
*
56+
* This can be used if account providers got new accounts that the multichain
57+
* account doesn't know about.
58+
*/
59+
sync(): void {
60+
// Clear reverse mapping and re-construct it entirely based on the refreshed
61+
// list of accounts from each providers.
62+
this.#accountToProvider.clear();
63+
64+
for (const provider of this.#providers) {
65+
// Filter account only for that index.
66+
const accounts = [];
67+
for (const account of provider.getAccounts()) {
68+
if (
69+
account.options.entropy.id === this.wallet.entropySource &&
70+
account.options.entropy.groupIndex === this.index
71+
) {
72+
// We only use IDs to always fetch the latest version of accounts.
73+
accounts.push(account.id);
74+
}
75+
}
76+
this.#providerToAccounts.set(provider, accounts);
77+
78+
// Reverse-mapping for fast indexing.
79+
for (const id of accounts) {
80+
this.#accountToProvider.set(id, provider);
81+
}
82+
}
83+
}
84+
85+
/**
86+
* Gets the multichain account ID.
87+
*
88+
* @returns The multichain account ID.
89+
*/
90+
get id(): MultichainAccountGroupId {
91+
return this.#id;
92+
}
93+
94+
/**
95+
* Gets the multichain account type.
96+
*
97+
* @returns The multichain account type.
98+
*/
99+
get type(): AccountGroupType.MultichainAccount {
100+
return AccountGroupType.MultichainAccount;
101+
}
102+
103+
/**
104+
* Gets the multichain account's wallet reference (parent).
105+
*
106+
* @returns The multichain account's wallet.
107+
*/
108+
get wallet(): MultichainAccountWallet<Account> {
109+
return this.#wallet;
110+
}
111+
112+
/**
113+
* Gets the multichain account group index.
114+
*
115+
* @returns The multichain account group index.
116+
*/
117+
get index(): number {
118+
return this.#index;
119+
}
120+
121+
/**
122+
* Checks if there's any underlying accounts for this multichain accounts.
123+
*
124+
* @returns True if there's any underlying accounts, false otherwise.
125+
*/
126+
hasAccounts(): boolean {
127+
// If there's anything in the reverse-map, it means we have some accounts.
128+
return this.#accountToProvider.size > 0;
129+
}
130+
131+
/**
132+
* Gets the accounts for this multichain account.
133+
*
134+
* @returns The accounts.
135+
*/
136+
getAccounts(): Account[] {
137+
const allAccounts: Account[] = [];
138+
139+
for (const [provider, accounts] of this.#providerToAccounts.entries()) {
140+
for (const id of accounts) {
141+
const account = provider.getAccount(id);
142+
143+
if (account) {
144+
// If for some reason we cannot get this account from the provider, it
145+
// might means it has been deleted or something, so we just filter it
146+
// out.
147+
allAccounts.push(account);
148+
}
149+
}
150+
}
151+
152+
return allAccounts;
153+
}
154+
155+
/**
156+
* Gets the account for a given account ID.
157+
*
158+
* @param id - Account ID.
159+
* @returns The account or undefined if not found.
160+
*/
161+
getAccount(id: Account['id']): Account | undefined {
162+
const provider = this.#accountToProvider.get(id);
163+
164+
// If there's nothing in the map, it means we tried to get an account
165+
// that does not belong to this multichain account.
166+
if (!provider) {
167+
return undefined;
168+
}
169+
return provider.getAccount(id);
170+
}
171+
172+
/**
173+
* Query an account matching the selector.
174+
*
175+
* @param selector - Query selector.
176+
* @returns The account matching the selector or undefined if not matching.
177+
* @throws If multiple accounts match the selector.
178+
*/
179+
get(selector: AccountSelector<Account>): Account | undefined {
180+
const accounts = this.select(selector);
181+
182+
if (accounts.length > 1) {
183+
throw new Error(
184+
`Too many account candidates, expected 1, got: ${accounts.length}`,
185+
);
186+
}
187+
188+
if (accounts.length === 0) {
189+
return undefined;
190+
}
191+
192+
return accounts[0]; // This is safe, see checks above.
193+
}
194+
195+
/**
196+
* Query accounts matching the selector.
197+
*
198+
* @param selector - Query selector.
199+
* @returns The accounts matching the selector.
200+
*/
201+
select(selector: AccountSelector<Account>): Account[] {
202+
return this.getAccounts().filter((account) => {
203+
let selected = true;
204+
205+
if (selector.id) {
206+
selected &&= account.id === selector.id;
207+
}
208+
if (selector.address) {
209+
selected &&= account.address === selector.address;
210+
}
211+
if (selector.type) {
212+
selected &&= account.type === selector.type;
213+
}
214+
if (selector.methods !== undefined) {
215+
selected &&= selector.methods.some((method) =>
216+
account.methods.includes(method),
217+
);
218+
}
219+
if (selector.scopes !== undefined) {
220+
selected &&= selector.scopes.some((scope) => {
221+
return (
222+
// This will cover specific EVM EOA scopes as well.
223+
isScopeEqualToAny(scope, account.scopes)
224+
);
225+
});
226+
}
227+
228+
return selected;
229+
});
230+
}
231+
}

packages/multichain-account-service/src/MultichainAccountService.test.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -358,8 +358,8 @@ describe('MultichainAccountService', () => {
358358
});
359359

360360
const [multichainAccount1, multichainAccount2] =
361-
wallet1.getMultichainAccounts();
362-
const [multichainAccount3] = wallet2.getMultichainAccounts();
361+
wallet1.getMultichainAccountGroups();
362+
const [multichainAccount3] = wallet2.getMultichainAccountGroups();
363363

364364
const walletAndMultichainAccount1 = service.getMultichainAccountAndWallet(
365365
account1.id,
@@ -396,15 +396,15 @@ describe('MultichainAccountService', () => {
396396
const wallet1 = service.getMultichainAccountWallet({
397397
entropySource: entropy1,
398398
});
399-
expect(wallet1.getMultichainAccounts()).toHaveLength(1);
399+
expect(wallet1.getMultichainAccountGroups()).toHaveLength(1);
400400

401401
// Now we're adding `account2`.
402402
mocks.EvmAccountProvider.accounts = [account1, account2];
403403
messenger.publish('AccountsController:accountAdded', account2);
404-
expect(wallet1.getMultichainAccounts()).toHaveLength(2);
404+
expect(wallet1.getMultichainAccountGroups()).toHaveLength(2);
405405

406406
const [multichainAccount1, multichainAccount2] =
407-
wallet1.getMultichainAccounts();
407+
wallet1.getMultichainAccountGroups();
408408

409409
const walletAndMultichainAccount1 = service.getMultichainAccountAndWallet(
410410
account1.id,
@@ -437,16 +437,16 @@ describe('MultichainAccountService', () => {
437437
const wallet1 = service.getMultichainAccountWallet({
438438
entropySource: entropy1,
439439
});
440-
expect(wallet1.getMultichainAccounts()).toHaveLength(1);
440+
expect(wallet1.getMultichainAccountGroups()).toHaveLength(1);
441441

442442
// Now we're adding `account2`.
443443
mocks.EvmAccountProvider.accounts = [account1, otherAccount1];
444444
messenger.publish('AccountsController:accountAdded', otherAccount1);
445445
// Still 1, that's the same multichain account, but a new "blockchain
446446
// account" got added.
447-
expect(wallet1.getMultichainAccounts()).toHaveLength(1);
447+
expect(wallet1.getMultichainAccountGroups()).toHaveLength(1);
448448

449-
const [multichainAccount1] = wallet1.getMultichainAccounts();
449+
const [multichainAccount1] = wallet1.getMultichainAccountGroups();
450450

451451
const walletAndMultichainAccount1 = service.getMultichainAccountAndWallet(
452452
account1.id,
@@ -477,7 +477,7 @@ describe('MultichainAccountService', () => {
477477
const wallet1 = service.getMultichainAccountWallet({
478478
entropySource: entropy1,
479479
});
480-
expect(wallet1.getMultichainAccounts()).toHaveLength(2);
480+
expect(wallet1.getMultichainAccountGroups()).toHaveLength(2);
481481

482482
// No wallet 2 yet.
483483
expect(() =>
@@ -492,9 +492,9 @@ describe('MultichainAccountService', () => {
492492
entropySource: entropy2,
493493
});
494494
expect(wallet2).toBeDefined();
495-
expect(wallet2.getMultichainAccounts()).toHaveLength(1);
495+
expect(wallet2.getMultichainAccountGroups()).toHaveLength(1);
496496

497-
const [multichainAccount3] = wallet2.getMultichainAccounts();
497+
const [multichainAccount3] = wallet2.getMultichainAccountGroups();
498498

499499
const walletAndMultichainAccount3 = service.getMultichainAccountAndWallet(
500500
account3.id,
@@ -515,14 +515,14 @@ describe('MultichainAccountService', () => {
515515
const wallet1 = service.getMultichainAccountWallet({
516516
entropySource: entropy1,
517517
});
518-
const oldMultichainAccounts = wallet1.getMultichainAccounts();
518+
const oldMultichainAccounts = wallet1.getMultichainAccountGroups();
519519
expect(oldMultichainAccounts).toHaveLength(1);
520520
expect(oldMultichainAccounts[0].getAccounts()).toHaveLength(1);
521521

522522
// Now we're publishing a new account that is not BIP-44 compatible.
523523
messenger.publish('AccountsController:accountAdded', MOCK_SNAP_ACCOUNT_2);
524524

525-
const newMultichainAccounts = wallet1.getMultichainAccounts();
525+
const newMultichainAccounts = wallet1.getMultichainAccountGroups();
526526
expect(newMultichainAccounts).toHaveLength(1);
527527
expect(newMultichainAccounts[0].getAccounts()).toHaveLength(1);
528528
});
@@ -534,12 +534,12 @@ describe('MultichainAccountService', () => {
534534
const wallet1 = service.getMultichainAccountWallet({
535535
entropySource: entropy1,
536536
});
537-
expect(wallet1.getMultichainAccounts()).toHaveLength(2);
537+
expect(wallet1.getMultichainAccountGroups()).toHaveLength(2);
538538

539539
// Now we're removing `account2`.
540540
mocks.EvmAccountProvider.accounts = [account1];
541541
messenger.publish('AccountsController:accountRemoved', account2.id);
542-
expect(wallet1.getMultichainAccounts()).toHaveLength(1);
542+
expect(wallet1.getMultichainAccountGroups()).toHaveLength(1);
543543

544544
const walletAndMultichainAccount2 = service.getMultichainAccountAndWallet(
545545
account2.id,

0 commit comments

Comments
 (0)