diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index bd8befde71c..354a6940c41 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `{Btc/Trx}AccountProvider` account providers ([#6662](https://github.com/MetaMask/core/pull/6662)) + ### Changed - Bump `@metamask/utils` from `^11.8.0` to `^11.8.1` ([#6708](https://github.com/MetaMask/core/pull/6708)) diff --git a/packages/multichain-account-service/src/index.ts b/packages/multichain-account-service/src/index.ts index 06315dc5c3f..8a322a0894f 100644 --- a/packages/multichain-account-service/src/index.ts +++ b/packages/multichain-account-service/src/index.ts @@ -22,6 +22,10 @@ export { EvmAccountProvider, SOL_ACCOUNT_PROVIDER_NAME, SolAccountProvider, + BTC_ACCOUNT_PROVIDER_NAME, + BtcAccountProvider, + TRX_ACCOUNT_PROVIDER_NAME, + TrxAccountProvider, } from './providers'; export { MultichainAccountWallet } from './MultichainAccountWallet'; export { MultichainAccountGroup } from './MultichainAccountGroup'; diff --git a/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts b/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts new file mode 100644 index 00000000000..b50618154e7 --- /dev/null +++ b/packages/multichain-account-service/src/providers/BtcAccountProvider.test.ts @@ -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'/(?[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([]); + }); +}); diff --git a/packages/multichain-account-service/src/providers/BtcAccountProvider.ts b/packages/multichain-account-service/src/providers/BtcAccountProvider.ts new file mode 100644 index 00000000000..a76c69d1307 --- /dev/null +++ b/packages/multichain-account-service/src/providers/BtcAccountProvider.ts @@ -0,0 +1,150 @@ +import { assertIsBip44Account, type Bip44Account } from '@metamask/account-api'; +import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; +import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { KeyringClient } from '@metamask/keyring-snap-client'; +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import { SnapAccountProvider } from './SnapAccountProvider'; +import { withRetry, withTimeout } from './utils'; +import type { MultichainAccountServiceMessenger } from '../types'; + +export type BtcAccountProviderConfig = { + discovery: { + maxAttempts: number; + timeoutMs: number; + backOffMs: number; + }; + createAccounts: { + timeoutMs: number; + }; +}; + +export const BTC_ACCOUNT_PROVIDER_NAME = 'Bitcoin' as const; + +export class BtcAccountProvider extends SnapAccountProvider { + static NAME = BTC_ACCOUNT_PROVIDER_NAME; + + static BTC_SNAP_ID = 'npm:@metamask/bitcoin-wallet-snap' as SnapId; + + readonly #client: KeyringClient; + + readonly #config: BtcAccountProviderConfig; + + constructor( + messenger: MultichainAccountServiceMessenger, + config: BtcAccountProviderConfig = { + createAccounts: { + timeoutMs: 3000, + }, + discovery: { + timeoutMs: 2000, + maxAttempts: 3, + backOffMs: 1000, + }, + }, + ) { + super(BtcAccountProvider.BTC_SNAP_ID, messenger); + this.#client = this.#getKeyringClientFromSnapId( + BtcAccountProvider.BTC_SNAP_ID, + ); + this.#config = config; + } + + getName(): string { + return BtcAccountProvider.NAME; + } + + #getKeyringClientFromSnapId(snapId: string): KeyringClient { + return new KeyringClient({ + send: async (request: JsonRpcRequest) => { + const response = await this.messenger.call( + 'SnapController:handleRequest', + { + snapId: snapId as SnapId, + origin: 'metamask', + handler: HandlerType.OnKeyringRequest, + request, + }, + ); + return response as Json; + }, + }); + } + + isAccountCompatible(account: Bip44Account): boolean { + return ( + account.metadata.keyring.type === KeyringTypes.snap && + Object.values(BtcAccountType).includes(account.type) + ); + } + + async createAccounts({ + entropySource, + groupIndex: index, + }: { + entropySource: EntropySourceId; + groupIndex: number; + }): Promise[]> { + const createAccount = await this.getRestrictedSnapAccountCreator(); + + const createBitcoinAccount = async (addressType: BtcAccountType) => + await withTimeout( + createAccount({ + entropySource, + index, + addressType, + scope: BtcScope.Mainnet, + }), + this.#config.createAccounts.timeoutMs, + ); + + const [p2wpkh, p2tr] = await Promise.all([ + createBitcoinAccount(BtcAccountType.P2wpkh), + createBitcoinAccount(BtcAccountType.P2tr), + ]); + + assertIsBip44Account(p2wpkh); + assertIsBip44Account(p2tr); + + return [p2tr, p2wpkh]; + } + + async discoverAccounts({ + entropySource, + groupIndex, + }: { + entropySource: EntropySourceId; + groupIndex: number; + }) { + const discoveredAccounts = await withRetry( + () => + withTimeout( + this.#client.discoverAccounts( + [BtcScope.Mainnet], + entropySource, + groupIndex, + ), + this.#config.discovery.timeoutMs, + ), + { + maxAttempts: this.#config.discovery.maxAttempts, + backOffMs: this.#config.discovery.backOffMs, + }, + ); + + if (!Array.isArray(discoveredAccounts) || discoveredAccounts.length === 0) { + return []; + } + + const createdAccounts = await this.createAccounts({ + entropySource, + groupIndex, + }); + + return createdAccounts; + } +} diff --git a/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts b/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts new file mode 100644 index 00000000000..d52aaa25f95 --- /dev/null +++ b/packages/multichain-account-service/src/providers/TrxAccountProvider.test.ts @@ -0,0 +1,322 @@ +import { isBip44Account } from '@metamask/account-api'; +import type { Messenger } from '@metamask/base-controller'; +import type { SnapKeyring } from '@metamask/eth-snap-keyring'; +import type { KeyringMetadata } from '@metamask/keyring-controller'; +import type { + EthKeyring, + InternalAccount, +} from '@metamask/keyring-internal-api'; + +import { AccountProviderWrapper } from './AccountProviderWrapper'; +import { TrxAccountProvider } from './TrxAccountProvider'; +import { + getMultichainAccountServiceMessenger, + getRootMessenger, + MOCK_HD_ACCOUNT_1, + MOCK_HD_KEYRING_1, + MOCK_TRX_ACCOUNT_1, + MOCK_TRX_DISCOVERED_ACCOUNT_1, + MockAccountBuilder, +} from '../tests'; +import type { + AllowedActions, + AllowedEvents, + MultichainAccountServiceActions, + MultichainAccountServiceEvents, +} from '../types'; + +class MockTronKeyring { + readonly type = 'MockTronKeyring'; + + readonly metadata: KeyringMetadata = { + id: 'mock-tron-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'/195'/0'/(?[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 }) => { + if (derivationPath !== undefined) { + const index = this.#getIndexFromDerivationPath(derivationPath); + const found = this.accounts.find( + (account) => + isBip44Account(account) && + account.options.entropy.groupIndex === index, + ); + + if (found) { + return found; // Idempotent. + } + } + + const account = MockAccountBuilder.from(MOCK_TRX_ACCOUNT_1) + .withUuid() + .withAddressSuffix(`${this.accounts.length}`) + .withGroupIndex(this.accounts.length) + .get(); + this.accounts.push(account); + + return account; + }); +} + +/** + * Sets up a SolAccountProvider 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: MockTronKeyring; + mocks: { + handleRequest: jest.Mock; + keyring: { + createAccount: jest.Mock; + }; + }; +} { + const keyring = new MockTronKeyring(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 TrxAccountProvider(multichainMessenger), + ); + + return { + provider, + messenger, + keyring, + mocks: { + handleRequest: mockHandleRequest, + keyring: { + createAccount: keyring.createAccount as jest.Mock, + }, + }, + }; +} + +describe('TrxAccountProvider', () => { + it('getName returns Tron', () => { + const { provider } = setup({ accounts: [] }); + expect(provider.getName()).toBe('Tron'); + }); + + it('gets accounts', () => { + const accounts = [MOCK_TRX_ACCOUNT_1]; + const { provider } = setup({ + accounts, + }); + + expect(provider.getAccounts()).toStrictEqual(accounts); + }); + + it('gets a specific account', () => { + const account = MOCK_TRX_ACCOUNT_1; + const { provider } = setup({ + accounts: [account], + }); + + expect(provider.getAccount(account.id)).toStrictEqual(account); + }); + + it('throws if account does not exist', () => { + const account = MOCK_TRX_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_TRX_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(1); + expect(keyring.createAccount).toHaveBeenCalled(); + }); + + it('does not re-create accounts (idempotent)', async () => { + const accounts = [MOCK_TRX_ACCOUNT_1]; + const { provider } = setup({ + accounts, + }); + + const newAccounts = await provider.createAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + expect(newAccounts).toHaveLength(1); + expect(newAccounts[0]).toStrictEqual(MOCK_TRX_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_TRX_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 Solana 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_TRX_ACCOUNT_1]; + const { provider, mocks } = setup({ + accounts, + }); + + mocks.keyring.createAccount.mockResolvedValue({ + ...MOCK_TRX_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_TRX_DISCOVERED_ACCOUNT_1]); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toHaveLength(1); + // Ensure we did go through creation path + expect(mocks.keyring.createAccount).toHaveBeenCalled(); + // Provider should now expose one account (newly created) + expect(provider.getAccounts()).toHaveLength(1); + }); + + it('returns existing account if it already exists at index', async () => { + const { provider, mocks } = setup({ + accounts: [MOCK_TRX_ACCOUNT_1], + }); + + // Simulate one discovered account — should resolve to the existing one + mocks.handleRequest.mockReturnValue([MOCK_TRX_DISCOVERED_ACCOUNT_1]); + + const discovered = await provider.discoverAccounts({ + entropySource: MOCK_HD_KEYRING_1.metadata.id, + groupIndex: 0, + }); + + expect(discovered).toStrictEqual([MOCK_TRX_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([]); + }); +}); diff --git a/packages/multichain-account-service/src/providers/TrxAccountProvider.ts b/packages/multichain-account-service/src/providers/TrxAccountProvider.ts new file mode 100644 index 00000000000..0ff4aea105c --- /dev/null +++ b/packages/multichain-account-service/src/providers/TrxAccountProvider.ts @@ -0,0 +1,169 @@ +import { assertIsBip44Account, type Bip44Account } from '@metamask/account-api'; +import type { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; +import { TrxAccountType, TrxScope } from '@metamask/keyring-api'; +import { KeyringAccountEntropyTypeOption } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { KeyringClient } from '@metamask/keyring-snap-client'; +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; +import type { MultichainAccountServiceMessenger } from 'src/types'; + +import { SnapAccountProvider } from './SnapAccountProvider'; +import { withRetry, withTimeout } from './utils'; + +export type TrxAccountProviderConfig = { + discovery: { + maxAttempts: number; + timeoutMs: number; + backOffMs: number; + }; + createAccounts: { + timeoutMs: number; + }; +}; + +export const TRX_ACCOUNT_PROVIDER_NAME = 'Tron' as const; + +export class TrxAccountProvider extends SnapAccountProvider { + static NAME = TRX_ACCOUNT_PROVIDER_NAME; + + static TRX_SNAP_ID = 'npm:@metamask/tron-wallet-snap' as SnapId; + + readonly #client: KeyringClient; + + readonly #config: TrxAccountProviderConfig; + + constructor( + messenger: MultichainAccountServiceMessenger, + config: TrxAccountProviderConfig = { + discovery: { + timeoutMs: 2000, + maxAttempts: 3, + backOffMs: 1000, + }, + createAccounts: { + timeoutMs: 3000, + }, + }, + ) { + super(TrxAccountProvider.TRX_SNAP_ID, messenger); + this.#client = this.#getKeyringClientFromSnapId( + TrxAccountProvider.TRX_SNAP_ID, + ); + this.#config = config; + } + + getName(): string { + return TrxAccountProvider.NAME; + } + + #getKeyringClientFromSnapId(snapId: string): KeyringClient { + return new KeyringClient({ + send: async (request: JsonRpcRequest) => { + const response = await this.messenger.call( + 'SnapController:handleRequest', + { + snapId: snapId as SnapId, + origin: 'metamask', + handler: HandlerType.OnKeyringRequest, + request, + }, + ); + return response as Json; + }, + }); + } + + isAccountCompatible(account: Bip44Account): boolean { + return ( + account.type === TrxAccountType.Eoa && + account.metadata.keyring.type === (KeyringTypes.snap as string) + ); + } + + async #createAccount({ + entropySource, + groupIndex, + derivationPath, + }: { + entropySource: EntropySourceId; + groupIndex: number; + derivationPath: string; + }): Promise> { + const createAccount = await this.getRestrictedSnapAccountCreator(); + const account = await withTimeout( + createAccount({ entropySource, derivationPath }), + this.#config.createAccounts.timeoutMs, + ); + + // Ensure entropy is present before type assertion validation + account.options.entropy = { + type: KeyringAccountEntropyTypeOption.Mnemonic, + id: entropySource, + groupIndex, + derivationPath, + }; + + assertIsBip44Account(account); + return account; + } + + async createAccounts({ + entropySource, + groupIndex, + }: { + entropySource: EntropySourceId; + groupIndex: number; + }): Promise[]> { + const derivationPath = `m/44'/195'/0'/${groupIndex}'`; + const account = await this.#createAccount({ + entropySource, + groupIndex, + derivationPath, + }); + + return [account]; + } + + async discoverAccounts({ + entropySource, + groupIndex, + }: { + entropySource: EntropySourceId; + groupIndex: number; + }): Promise[]> { + const discoveredAccounts = await withRetry( + () => + withTimeout( + this.#client.discoverAccounts( + [TrxScope.Mainnet], + entropySource, + groupIndex, + ), + this.#config.discovery.timeoutMs, + ), + { + maxAttempts: this.#config.discovery.maxAttempts, + backOffMs: this.#config.discovery.backOffMs, + }, + ); + + if (!discoveredAccounts.length) { + return []; + } + + const createdAccounts = await Promise.all( + discoveredAccounts.map((d) => + this.#createAccount({ + entropySource, + groupIndex, + derivationPath: d.derivationPath, + }), + ), + ); + + return createdAccounts; + } +} diff --git a/packages/multichain-account-service/src/providers/index.ts b/packages/multichain-account-service/src/providers/index.ts index 8bf5a8e2dcc..f482c41d922 100644 --- a/packages/multichain-account-service/src/providers/index.ts +++ b/packages/multichain-account-service/src/providers/index.ts @@ -8,3 +8,5 @@ export { TimeoutError } from './utils'; // Concrete providers: export * from './SolAccountProvider'; export * from './EvmAccountProvider'; +export * from './BtcAccountProvider'; +export * from './TrxAccountProvider'; diff --git a/packages/multichain-account-service/src/tests/accounts.ts b/packages/multichain-account-service/src/tests/accounts.ts index f227e7ae8d1..28e47d7e7c1 100644 --- a/packages/multichain-account-service/src/tests/accounts.ts +++ b/packages/multichain-account-service/src/tests/accounts.ts @@ -17,6 +17,9 @@ import { SolAccountType, SolMethod, SolScope, + TrxAccountType, + TrxMethod, + TrxScope, } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; @@ -139,12 +142,48 @@ export const MOCK_SOL_ACCOUNT_1: Bip44Account = { }, }; +export const MOCK_TRX_ACCOUNT_1: Bip44Account = { + id: 'mock-snap-id-1', + address: 'aabbccdd', + options: { + entropy: { + type: KeyringAccountEntropyTypeOption.Mnemonic, + // NOTE: shares entropy with MOCK_HD_ACCOUNT_2 + id: MOCK_HD_KEYRING_2.metadata.id, + groupIndex: 0, + derivationPath: '', + }, + }, + methods: [TrxMethod.SignMessageV2, TrxMethod.VerifyMessageV2], + type: TrxAccountType.Eoa, + scopes: [TrxScope.Mainnet], + metadata: { + name: 'Tron Account 1', + keyring: { type: KeyringTypes.snap }, + snap: MOCK_SNAP_1, + importTime: 0, + lastSelected: 0, + }, +}; + export const MOCK_SOL_DISCOVERED_ACCOUNT_1: DiscoveredAccount = { type: 'bip44', scopes: [SolScope.Mainnet], derivationPath: `m/44'/501'/0'/0'`, }; +export const MOCK_TRX_DISCOVERED_ACCOUNT_1: DiscoveredAccount = { + type: 'bip44', + scopes: [TrxScope.Mainnet], + derivationPath: `m/44'/195'/0'/0'`, +}; + +export const MOCK_BTC_P2TR_DISCOVERED_ACCOUNT_1: DiscoveredAccount = { + type: 'bip44', + scopes: [BtcScope.Mainnet], + derivationPath: `m/44'/0'/0'/0'`, +}; + export const MOCK_BTC_P2WPKH_ACCOUNT_1: Bip44Account = { id: 'b0f030d8-e101-4b5a-a3dd-13f8ca8ec1db', type: BtcAccountType.P2wpkh, @@ -164,7 +203,7 @@ export const MOCK_BTC_P2WPKH_ACCOUNT_1: Bip44Account = { name: 'Bitcoin Native Segwit Account 1', importTime: 0, keyring: { - type: 'Snap keyring', + type: KeyringTypes.snap, }, snap: { id: 'mock-btc-snap-id', @@ -193,7 +232,7 @@ export const MOCK_BTC_P2TR_ACCOUNT_1: Bip44Account = { name: 'Bitcoin Taproot Account 1', importTime: 0, keyring: { - type: 'Snap keyring', + type: KeyringTypes.snap, }, snap: { id: 'mock-btc-snap-id',