|
| 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