Skip to content

Commit 584b6c6

Browse files
agangladadanroc
andauthored
feat: {Btc/Trx}AccountProvider account provider (#6662)
## Explanation * Adds `{Btc/Trx}AccountProvider` in order to be used for BIP44 <!-- Thanks for your contribution! Take a moment to answer these questions so that reviewers have the information they need to properly understand your changes: * What is the current state of things and why does it need to change? * What is the solution your changes offer and how does it work? * Are there any changes whose purpose might not obvious to those unfamiliar with the domain? * If your primary goal was to update one package but you found you had to update another one along the way, why did you do so? * If you had to upgrade a dependency, why did you do so? --> ## References <!-- Are there any issues that this pull request is tied to? Are there other links that reviewers should consult to understand these changes better? Are there client or consumer pull requests to adopt any breaking changes? For example: * Fixes #12345 * Related to #67890 --> ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: Daniel Rocha <[email protected]>
1 parent e3e786d commit 584b6c6

File tree

8 files changed

+1034
-2
lines changed

8 files changed

+1034
-2
lines changed

packages/multichain-account-service/CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add `{Btc/Trx}AccountProvider` account providers ([#6662](https://github.com/MetaMask/core/pull/6662))
13+
1014
### Changed
1115

1216
- Bump `@metamask/utils` from `^11.8.0` to `^11.8.1` ([#6708](https://github.com/MetaMask/core/pull/6708))

packages/multichain-account-service/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ export {
2222
EvmAccountProvider,
2323
SOL_ACCOUNT_PROVIDER_NAME,
2424
SolAccountProvider,
25+
BTC_ACCOUNT_PROVIDER_NAME,
26+
BtcAccountProvider,
27+
TRX_ACCOUNT_PROVIDER_NAME,
28+
TrxAccountProvider,
2529
} from './providers';
2630
export { MultichainAccountWallet } from './MultichainAccountWallet';
2731
export { MultichainAccountGroup } from './MultichainAccountGroup';
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
import { isBip44Account } from '@metamask/account-api';
2+
import type { Messenger } from '@metamask/base-controller';
3+
import type { SnapKeyring } from '@metamask/eth-snap-keyring';
4+
import { BtcAccountType } from '@metamask/keyring-api';
5+
import type { KeyringMetadata } from '@metamask/keyring-controller';
6+
import type {
7+
EthKeyring,
8+
InternalAccount,
9+
} from '@metamask/keyring-internal-api';
10+
11+
import { AccountProviderWrapper } from './AccountProviderWrapper';
12+
import { BtcAccountProvider } from './BtcAccountProvider';
13+
import {
14+
getMultichainAccountServiceMessenger,
15+
getRootMessenger,
16+
MOCK_BTC_P2TR_ACCOUNT_1,
17+
MOCK_BTC_P2WPKH_ACCOUNT_1,
18+
MOCK_BTC_P2TR_DISCOVERED_ACCOUNT_1,
19+
MOCK_HD_ACCOUNT_1,
20+
MOCK_HD_KEYRING_1,
21+
MockAccountBuilder,
22+
} from '../tests';
23+
import type {
24+
AllowedActions,
25+
AllowedEvents,
26+
MultichainAccountServiceActions,
27+
MultichainAccountServiceEvents,
28+
} from '../types';
29+
30+
class MockBtcKeyring {
31+
readonly type = 'MockBtcKeyring';
32+
33+
readonly metadata: KeyringMetadata = {
34+
id: 'mock-btc-keyring-id',
35+
name: '',
36+
};
37+
38+
readonly accounts: InternalAccount[];
39+
40+
constructor(accounts: InternalAccount[]) {
41+
this.accounts = accounts;
42+
}
43+
44+
#getIndexFromDerivationPath(derivationPath: string): number {
45+
// eslint-disable-next-line prefer-regex-literals
46+
const derivationPathIndexRegex = new RegExp(
47+
"^m/44'/0'/0'/(?<index>[0-9]+)'$",
48+
'u',
49+
);
50+
51+
const matched = derivationPath.match(derivationPathIndexRegex);
52+
if (matched?.groups?.index === undefined) {
53+
throw new Error('Unable to extract index');
54+
}
55+
56+
const { index } = matched.groups;
57+
return Number(index);
58+
}
59+
60+
createAccount: SnapKeyring['createAccount'] = jest
61+
.fn()
62+
.mockImplementation((_, { derivationPath, index, ...options }) => {
63+
// Determine the group index to use - either from derivationPath parsing, explicit index, or fallback
64+
let groupIndex: number;
65+
66+
if (derivationPath !== undefined) {
67+
groupIndex = this.#getIndexFromDerivationPath(derivationPath);
68+
} else if (index !== undefined) {
69+
groupIndex = index;
70+
} else {
71+
groupIndex = this.accounts.length;
72+
}
73+
74+
// Check if an account already exists for this group index AND account type (idempotent behavior)
75+
const found = this.accounts.find(
76+
(account) =>
77+
isBip44Account(account) &&
78+
account.options.entropy.groupIndex === groupIndex &&
79+
account.type === options.addressType,
80+
);
81+
82+
if (found) {
83+
return found; // Idempotent.
84+
}
85+
86+
// Create new account with the correct group index
87+
const baseAccount =
88+
options.addressType === BtcAccountType.P2wpkh
89+
? MOCK_BTC_P2WPKH_ACCOUNT_1
90+
: MOCK_BTC_P2TR_ACCOUNT_1;
91+
const account = MockAccountBuilder.from(baseAccount)
92+
.withUuid()
93+
.withAddressSuffix(`${this.accounts.length}`)
94+
.withGroupIndex(groupIndex)
95+
.get();
96+
this.accounts.push(account);
97+
98+
return account;
99+
});
100+
}
101+
102+
/**
103+
* Sets up a BtcAccountProvider for testing.
104+
*
105+
* @param options - Configuration options for setup.
106+
* @param options.messenger - An optional messenger instance to use. Defaults to a new Messenger.
107+
* @param options.accounts - List of accounts to use.
108+
* @returns An object containing the controller instance and the messenger.
109+
*/
110+
function setup({
111+
messenger = getRootMessenger(),
112+
accounts = [],
113+
}: {
114+
messenger?: Messenger<
115+
MultichainAccountServiceActions | AllowedActions,
116+
MultichainAccountServiceEvents | AllowedEvents
117+
>;
118+
accounts?: InternalAccount[];
119+
} = {}): {
120+
provider: AccountProviderWrapper;
121+
messenger: Messenger<
122+
MultichainAccountServiceActions | AllowedActions,
123+
MultichainAccountServiceEvents | AllowedEvents
124+
>;
125+
keyring: MockBtcKeyring;
126+
mocks: {
127+
handleRequest: jest.Mock;
128+
keyring: {
129+
createAccount: jest.Mock;
130+
};
131+
};
132+
} {
133+
const keyring = new MockBtcKeyring(accounts);
134+
135+
messenger.registerActionHandler(
136+
'AccountsController:listMultichainAccounts',
137+
() => accounts,
138+
);
139+
140+
const mockHandleRequest = jest
141+
.fn()
142+
.mockImplementation((address: string) =>
143+
keyring.accounts.find((account) => account.address === address),
144+
);
145+
messenger.registerActionHandler(
146+
'SnapController:handleRequest',
147+
mockHandleRequest,
148+
);
149+
150+
messenger.registerActionHandler(
151+
'KeyringController:withKeyring',
152+
async (_, operation) =>
153+
operation({
154+
// We type-cast here, since `withKeyring` defaults to `EthKeyring` and the
155+
// Snap keyring doesn't really implement this interface (this is expected).
156+
keyring: keyring as unknown as EthKeyring,
157+
metadata: keyring.metadata,
158+
}),
159+
);
160+
161+
const multichainMessenger = getMultichainAccountServiceMessenger(messenger);
162+
const provider = new AccountProviderWrapper(
163+
multichainMessenger,
164+
new BtcAccountProvider(multichainMessenger),
165+
);
166+
167+
return {
168+
provider,
169+
messenger,
170+
keyring,
171+
mocks: {
172+
handleRequest: mockHandleRequest,
173+
keyring: {
174+
createAccount: keyring.createAccount as jest.Mock,
175+
},
176+
},
177+
};
178+
}
179+
180+
describe('BtcAccountProvider', () => {
181+
it('getName returns Bitcoin', () => {
182+
const { provider } = setup({ accounts: [] });
183+
expect(provider.getName()).toBe('Bitcoin');
184+
});
185+
186+
it('gets accounts', () => {
187+
const accounts = [MOCK_BTC_P2TR_ACCOUNT_1];
188+
const { provider } = setup({
189+
accounts,
190+
});
191+
192+
expect(provider.getAccounts()).toStrictEqual(accounts);
193+
});
194+
195+
it('gets a specific account', () => {
196+
const account = MOCK_BTC_P2TR_ACCOUNT_1;
197+
const { provider } = setup({
198+
accounts: [account],
199+
});
200+
201+
expect(provider.getAccount(account.id)).toStrictEqual(account);
202+
});
203+
204+
it('throws if account does not exist', () => {
205+
const account = MOCK_BTC_P2TR_ACCOUNT_1;
206+
const { provider } = setup({
207+
accounts: [account],
208+
});
209+
210+
const unknownAccount = MOCK_HD_ACCOUNT_1;
211+
expect(() => provider.getAccount(unknownAccount.id)).toThrow(
212+
`Unable to find account: ${unknownAccount.id}`,
213+
);
214+
});
215+
216+
it('creates accounts', async () => {
217+
const accounts = [MOCK_BTC_P2TR_ACCOUNT_1, MOCK_BTC_P2WPKH_ACCOUNT_1];
218+
const { provider, keyring } = setup({
219+
accounts,
220+
});
221+
222+
const newGroupIndex = accounts.length; // Group-index are 0-based.
223+
const newAccounts = await provider.createAccounts({
224+
entropySource: MOCK_HD_KEYRING_1.metadata.id,
225+
groupIndex: newGroupIndex,
226+
});
227+
expect(newAccounts).toHaveLength(2);
228+
expect(keyring.createAccount).toHaveBeenCalled();
229+
});
230+
231+
it('does not re-create accounts (idempotent)', async () => {
232+
const accounts = [MOCK_BTC_P2TR_ACCOUNT_1];
233+
const { provider } = setup({
234+
accounts,
235+
});
236+
237+
const newAccounts = await provider.createAccounts({
238+
entropySource: MOCK_HD_KEYRING_1.metadata.id,
239+
groupIndex: 0,
240+
});
241+
expect(newAccounts).toHaveLength(2);
242+
expect(newAccounts[0]).toStrictEqual(MOCK_BTC_P2TR_ACCOUNT_1);
243+
});
244+
245+
it('throws if the account creation process takes too long', async () => {
246+
const { provider, mocks } = setup({
247+
accounts: [],
248+
});
249+
250+
mocks.keyring.createAccount.mockImplementation(() => {
251+
return new Promise((resolve) => {
252+
setTimeout(() => {
253+
resolve(MOCK_BTC_P2TR_ACCOUNT_1);
254+
}, 4000);
255+
});
256+
});
257+
258+
await expect(
259+
provider.createAccounts({
260+
entropySource: MOCK_HD_KEYRING_1.metadata.id,
261+
groupIndex: 0,
262+
}),
263+
).rejects.toThrow('Timed out');
264+
});
265+
266+
// Skip this test for now, since we manually inject those options upon
267+
// account creation, so it cannot fails (until the Bitcoin Snap starts
268+
// using the new typed options).
269+
// eslint-disable-next-line jest/no-disabled-tests
270+
it.skip('throws if the created account is not BIP-44 compatible', async () => {
271+
const accounts = [MOCK_BTC_P2TR_ACCOUNT_1];
272+
const { provider, mocks } = setup({
273+
accounts,
274+
});
275+
276+
mocks.keyring.createAccount.mockResolvedValue({
277+
...MOCK_BTC_P2TR_ACCOUNT_1,
278+
options: {}, // No options, so it cannot be BIP-44 compatible.
279+
});
280+
281+
await expect(
282+
provider.createAccounts({
283+
entropySource: MOCK_HD_KEYRING_1.metadata.id,
284+
groupIndex: 0,
285+
}),
286+
).rejects.toThrow('Created account is not BIP-44 compatible');
287+
});
288+
289+
it('discover accounts at a new group index creates an account', async () => {
290+
const { provider, mocks } = setup({
291+
accounts: [],
292+
});
293+
294+
// Simulate one discovered account at the requested index.
295+
mocks.handleRequest.mockReturnValue([MOCK_BTC_P2TR_DISCOVERED_ACCOUNT_1]);
296+
297+
const discovered = await provider.discoverAccounts({
298+
entropySource: MOCK_HD_KEYRING_1.metadata.id,
299+
groupIndex: 0,
300+
});
301+
302+
expect(discovered).toHaveLength(2);
303+
// Ensure we did go through creation path
304+
expect(mocks.keyring.createAccount).toHaveBeenCalled();
305+
// Provider should now expose one account (newly created)
306+
expect(provider.getAccounts()).toHaveLength(2);
307+
});
308+
309+
it('returns existing account if it already exists at index', async () => {
310+
const { provider, mocks } = setup({
311+
accounts: [MOCK_BTC_P2TR_ACCOUNT_1, MOCK_BTC_P2WPKH_ACCOUNT_1],
312+
});
313+
314+
// Simulate one discovered account — should resolve to the existing one
315+
mocks.handleRequest.mockReturnValue([MOCK_BTC_P2TR_DISCOVERED_ACCOUNT_1]);
316+
317+
const discovered = await provider.discoverAccounts({
318+
entropySource: MOCK_HD_KEYRING_1.metadata.id,
319+
groupIndex: 0,
320+
});
321+
322+
expect(discovered).toStrictEqual([
323+
MOCK_BTC_P2TR_ACCOUNT_1,
324+
MOCK_BTC_P2WPKH_ACCOUNT_1,
325+
]);
326+
});
327+
328+
it('does not return any accounts if no account is discovered', async () => {
329+
const { provider, mocks } = setup({
330+
accounts: [],
331+
});
332+
333+
mocks.handleRequest.mockReturnValue([]);
334+
335+
const discovered = await provider.discoverAccounts({
336+
entropySource: MOCK_HD_KEYRING_1.metadata.id,
337+
groupIndex: 0,
338+
});
339+
340+
expect(discovered).toStrictEqual([]);
341+
});
342+
});

0 commit comments

Comments
 (0)