-
-
Notifications
You must be signed in to change notification settings - Fork 250
feat: {Btc/Trx}AccountProvider
account provider
#6662
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
a1465fa
feat: bitcoin account provider
aganglada 30b899d
chore: fix test
aganglada afdbfb4
feat: trx account provider
aganglada a8ec2f5
chore: fix test
aganglada a210cee
chore: update changelog
aganglada a50bd10
chore: wrap with withTimeout
aganglada c73203d
Update packages/multichain-account-service/CHANGELOG.md
aganglada 11411b2
chore: add test to cover creation process takes too long in btc provider
aganglada cada598
Merge branch 'main' into feat/btc-account-provider
zone-live c2ec4c7
chore: updated changelog
aganglada File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
342 changes: 342 additions & 0 deletions
342
packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,342 @@ | ||
import { isBip44Account } from '@metamask/account-api'; | ||
import type { Messenger } from '@metamask/base-controller'; | ||
import type { SnapKeyring } from '@metamask/eth-snap-keyring'; | ||
import { BtcAccountType } from '@metamask/keyring-api'; | ||
import type { KeyringMetadata } from '@metamask/keyring-controller'; | ||
import type { | ||
EthKeyring, | ||
InternalAccount, | ||
} from '@metamask/keyring-internal-api'; | ||
|
||
import { AccountProviderWrapper } from './AccountProviderWrapper'; | ||
import { BtcAccountProvider } from './BtcAccountProvider'; | ||
import { | ||
getMultichainAccountServiceMessenger, | ||
getRootMessenger, | ||
MOCK_BTC_P2TR_ACCOUNT_1, | ||
MOCK_BTC_P2WPKH_ACCOUNT_1, | ||
MOCK_BTC_P2TR_DISCOVERED_ACCOUNT_1, | ||
MOCK_HD_ACCOUNT_1, | ||
MOCK_HD_KEYRING_1, | ||
MockAccountBuilder, | ||
} from '../tests'; | ||
import type { | ||
AllowedActions, | ||
AllowedEvents, | ||
MultichainAccountServiceActions, | ||
MultichainAccountServiceEvents, | ||
} from '../types'; | ||
|
||
class MockBtcKeyring { | ||
readonly type = 'MockBtcKeyring'; | ||
|
||
readonly metadata: KeyringMetadata = { | ||
id: 'mock-btc-keyring-id', | ||
name: '', | ||
}; | ||
|
||
readonly accounts: InternalAccount[]; | ||
|
||
constructor(accounts: InternalAccount[]) { | ||
this.accounts = accounts; | ||
} | ||
|
||
#getIndexFromDerivationPath(derivationPath: string): number { | ||
// eslint-disable-next-line prefer-regex-literals | ||
const derivationPathIndexRegex = new RegExp( | ||
"^m/44'/0'/0'/(?<index>[0-9]+)'$", | ||
'u', | ||
); | ||
|
||
const matched = derivationPath.match(derivationPathIndexRegex); | ||
if (matched?.groups?.index === undefined) { | ||
throw new Error('Unable to extract index'); | ||
} | ||
|
||
const { index } = matched.groups; | ||
return Number(index); | ||
} | ||
|
||
createAccount: SnapKeyring['createAccount'] = jest | ||
.fn() | ||
.mockImplementation((_, { derivationPath, index, ...options }) => { | ||
// Determine the group index to use - either from derivationPath parsing, explicit index, or fallback | ||
let groupIndex: number; | ||
|
||
if (derivationPath !== undefined) { | ||
groupIndex = this.#getIndexFromDerivationPath(derivationPath); | ||
} else if (index !== undefined) { | ||
groupIndex = index; | ||
} else { | ||
groupIndex = this.accounts.length; | ||
} | ||
|
||
// Check if an account already exists for this group index AND account type (idempotent behavior) | ||
const found = this.accounts.find( | ||
(account) => | ||
isBip44Account(account) && | ||
account.options.entropy.groupIndex === groupIndex && | ||
account.type === options.addressType, | ||
); | ||
|
||
if (found) { | ||
return found; // Idempotent. | ||
} | ||
|
||
// Create new account with the correct group index | ||
const baseAccount = | ||
options.addressType === BtcAccountType.P2wpkh | ||
? MOCK_BTC_P2WPKH_ACCOUNT_1 | ||
: MOCK_BTC_P2TR_ACCOUNT_1; | ||
const account = MockAccountBuilder.from(baseAccount) | ||
.withUuid() | ||
.withAddressSuffix(`${this.accounts.length}`) | ||
.withGroupIndex(groupIndex) | ||
.get(); | ||
this.accounts.push(account); | ||
|
||
return account; | ||
}); | ||
} | ||
|
||
/** | ||
* Sets up a BtcAccountProvider for testing. | ||
* | ||
* @param options - Configuration options for setup. | ||
* @param options.messenger - An optional messenger instance to use. Defaults to a new Messenger. | ||
* @param options.accounts - List of accounts to use. | ||
* @returns An object containing the controller instance and the messenger. | ||
*/ | ||
function setup({ | ||
messenger = getRootMessenger(), | ||
accounts = [], | ||
}: { | ||
messenger?: Messenger< | ||
MultichainAccountServiceActions | AllowedActions, | ||
MultichainAccountServiceEvents | AllowedEvents | ||
>; | ||
accounts?: InternalAccount[]; | ||
} = {}): { | ||
provider: AccountProviderWrapper; | ||
messenger: Messenger< | ||
MultichainAccountServiceActions | AllowedActions, | ||
MultichainAccountServiceEvents | AllowedEvents | ||
>; | ||
keyring: MockBtcKeyring; | ||
mocks: { | ||
handleRequest: jest.Mock; | ||
keyring: { | ||
createAccount: jest.Mock; | ||
}; | ||
}; | ||
} { | ||
const keyring = new MockBtcKeyring(accounts); | ||
|
||
messenger.registerActionHandler( | ||
'AccountsController:listMultichainAccounts', | ||
() => accounts, | ||
); | ||
|
||
const mockHandleRequest = jest | ||
.fn() | ||
.mockImplementation((address: string) => | ||
keyring.accounts.find((account) => account.address === address), | ||
); | ||
messenger.registerActionHandler( | ||
'SnapController:handleRequest', | ||
mockHandleRequest, | ||
); | ||
|
||
messenger.registerActionHandler( | ||
'KeyringController:withKeyring', | ||
async (_, operation) => | ||
operation({ | ||
// We type-cast here, since `withKeyring` defaults to `EthKeyring` and the | ||
// Snap keyring doesn't really implement this interface (this is expected). | ||
keyring: keyring as unknown as EthKeyring, | ||
metadata: keyring.metadata, | ||
}), | ||
); | ||
|
||
const multichainMessenger = getMultichainAccountServiceMessenger(messenger); | ||
const provider = new AccountProviderWrapper( | ||
multichainMessenger, | ||
new BtcAccountProvider(multichainMessenger), | ||
); | ||
|
||
return { | ||
provider, | ||
messenger, | ||
keyring, | ||
mocks: { | ||
handleRequest: mockHandleRequest, | ||
keyring: { | ||
createAccount: keyring.createAccount as jest.Mock, | ||
}, | ||
}, | ||
}; | ||
} | ||
|
||
describe('BtcAccountProvider', () => { | ||
it('getName returns Bitcoin', () => { | ||
const { provider } = setup({ accounts: [] }); | ||
expect(provider.getName()).toBe('Bitcoin'); | ||
}); | ||
|
||
it('gets accounts', () => { | ||
const accounts = [MOCK_BTC_P2TR_ACCOUNT_1]; | ||
const { provider } = setup({ | ||
accounts, | ||
}); | ||
|
||
expect(provider.getAccounts()).toStrictEqual(accounts); | ||
}); | ||
|
||
it('gets a specific account', () => { | ||
const account = MOCK_BTC_P2TR_ACCOUNT_1; | ||
const { provider } = setup({ | ||
accounts: [account], | ||
}); | ||
|
||
expect(provider.getAccount(account.id)).toStrictEqual(account); | ||
}); | ||
|
||
it('throws if account does not exist', () => { | ||
const account = MOCK_BTC_P2TR_ACCOUNT_1; | ||
const { provider } = setup({ | ||
accounts: [account], | ||
}); | ||
|
||
const unknownAccount = MOCK_HD_ACCOUNT_1; | ||
expect(() => provider.getAccount(unknownAccount.id)).toThrow( | ||
`Unable to find account: ${unknownAccount.id}`, | ||
); | ||
}); | ||
|
||
it('creates accounts', async () => { | ||
const accounts = [MOCK_BTC_P2TR_ACCOUNT_1, MOCK_BTC_P2WPKH_ACCOUNT_1]; | ||
const { provider, keyring } = setup({ | ||
accounts, | ||
}); | ||
|
||
const newGroupIndex = accounts.length; // Group-index are 0-based. | ||
const newAccounts = await provider.createAccounts({ | ||
entropySource: MOCK_HD_KEYRING_1.metadata.id, | ||
groupIndex: newGroupIndex, | ||
}); | ||
expect(newAccounts).toHaveLength(2); | ||
expect(keyring.createAccount).toHaveBeenCalled(); | ||
}); | ||
|
||
it('does not re-create accounts (idempotent)', async () => { | ||
const accounts = [MOCK_BTC_P2TR_ACCOUNT_1]; | ||
const { provider } = setup({ | ||
accounts, | ||
}); | ||
|
||
const newAccounts = await provider.createAccounts({ | ||
entropySource: MOCK_HD_KEYRING_1.metadata.id, | ||
groupIndex: 0, | ||
}); | ||
expect(newAccounts).toHaveLength(2); | ||
expect(newAccounts[0]).toStrictEqual(MOCK_BTC_P2TR_ACCOUNT_1); | ||
}); | ||
|
||
it('throws if the account creation process takes too long', async () => { | ||
const { provider, mocks } = setup({ | ||
accounts: [], | ||
}); | ||
|
||
mocks.keyring.createAccount.mockImplementation(() => { | ||
return new Promise((resolve) => { | ||
setTimeout(() => { | ||
resolve(MOCK_BTC_P2TR_ACCOUNT_1); | ||
}, 4000); | ||
}); | ||
}); | ||
|
||
await expect( | ||
provider.createAccounts({ | ||
entropySource: MOCK_HD_KEYRING_1.metadata.id, | ||
groupIndex: 0, | ||
}), | ||
).rejects.toThrow('Timed out'); | ||
}); | ||
|
||
// Skip this test for now, since we manually inject those options upon | ||
// account creation, so it cannot fails (until the Bitcoin Snap starts | ||
// using the new typed options). | ||
// eslint-disable-next-line jest/no-disabled-tests | ||
it.skip('throws if the created account is not BIP-44 compatible', async () => { | ||
const accounts = [MOCK_BTC_P2TR_ACCOUNT_1]; | ||
const { provider, mocks } = setup({ | ||
accounts, | ||
}); | ||
|
||
mocks.keyring.createAccount.mockResolvedValue({ | ||
...MOCK_BTC_P2TR_ACCOUNT_1, | ||
options: {}, // No options, so it cannot be BIP-44 compatible. | ||
}); | ||
|
||
await expect( | ||
provider.createAccounts({ | ||
entropySource: MOCK_HD_KEYRING_1.metadata.id, | ||
groupIndex: 0, | ||
}), | ||
).rejects.toThrow('Created account is not BIP-44 compatible'); | ||
}); | ||
|
||
it('discover accounts at a new group index creates an account', async () => { | ||
const { provider, mocks } = setup({ | ||
accounts: [], | ||
}); | ||
|
||
// Simulate one discovered account at the requested index. | ||
mocks.handleRequest.mockReturnValue([MOCK_BTC_P2TR_DISCOVERED_ACCOUNT_1]); | ||
|
||
const discovered = await provider.discoverAccounts({ | ||
entropySource: MOCK_HD_KEYRING_1.metadata.id, | ||
groupIndex: 0, | ||
}); | ||
|
||
expect(discovered).toHaveLength(2); | ||
// Ensure we did go through creation path | ||
expect(mocks.keyring.createAccount).toHaveBeenCalled(); | ||
// Provider should now expose one account (newly created) | ||
expect(provider.getAccounts()).toHaveLength(2); | ||
}); | ||
|
||
it('returns existing account if it already exists at index', async () => { | ||
const { provider, mocks } = setup({ | ||
accounts: [MOCK_BTC_P2TR_ACCOUNT_1, MOCK_BTC_P2WPKH_ACCOUNT_1], | ||
}); | ||
|
||
// Simulate one discovered account — should resolve to the existing one | ||
mocks.handleRequest.mockReturnValue([MOCK_BTC_P2TR_DISCOVERED_ACCOUNT_1]); | ||
|
||
const discovered = await provider.discoverAccounts({ | ||
entropySource: MOCK_HD_KEYRING_1.metadata.id, | ||
groupIndex: 0, | ||
}); | ||
|
||
expect(discovered).toStrictEqual([ | ||
MOCK_BTC_P2TR_ACCOUNT_1, | ||
MOCK_BTC_P2WPKH_ACCOUNT_1, | ||
]); | ||
}); | ||
|
||
it('does not return any accounts if no account is discovered', async () => { | ||
const { provider, mocks } = setup({ | ||
accounts: [], | ||
}); | ||
|
||
mocks.handleRequest.mockReturnValue([]); | ||
|
||
const discovered = await provider.discoverAccounts({ | ||
entropySource: MOCK_HD_KEYRING_1.metadata.id, | ||
groupIndex: 0, | ||
}); | ||
|
||
expect(discovered).toStrictEqual([]); | ||
}); | ||
}); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.