From 042caf7eba97e53bdbf0de72dc1f9770b3ba92ce Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 25 Apr 2024 19:13:07 +0200 Subject: [PATCH] feat: add bip122:p2wpkh account support (#294) * feat: add bip122:p2wpkh account support * refactor: add and use asInternalAccountStruct for internal accounts --- package.json | 1 + src/api.ts | 13 +++++++--- src/btc/index.ts | 1 + src/btc/types.test.ts | 41 ++++++++++++++++++++++++++++++ src/btc/types.ts | 51 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + src/internal/types.test.ts | 12 ++++++--- src/internal/types.ts | 44 ++++++++++++++++++++++++-------- yarn.lock | 8 ++++++ 9 files changed, 156 insertions(+), 16 deletions(-) create mode 100644 src/btc/index.ts create mode 100644 src/btc/types.test.ts create mode 100644 src/btc/types.ts diff --git a/package.json b/package.json index ca91b1228..27adfb30f 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@metamask/snaps-sdk": "^4.0.0", "@metamask/utils": "^8.3.0", "@types/uuid": "^9.0.1", + "bech32": "^2.0.0", "superstruct": "^1.0.3", "uuid": "^9.0.0" }, diff --git a/src/api.ts b/src/api.ts index bde33eb4f..455473505 100644 --- a/src/api.ts +++ b/src/api.ts @@ -14,6 +14,8 @@ import { } from 'superstruct'; import type { StaticAssertAbstractAccount } from './base-types'; +import type { BtcP2wpkhAccount } from './btc'; +import { BtcP2wpkhAccountStruct, BtcAccountType } from './btc'; import type { EthEoaAccount, EthErc4337Account } from './eth'; import { EthEoaAccountStruct, @@ -27,7 +29,7 @@ import { UuidStruct } from './utils'; * Type of supported accounts. */ export type KeyringAccounts = StaticAssertAbstractAccount< - EthEoaAccount | EthErc4337Account + EthEoaAccount | EthErc4337Account | BtcP2wpkhAccount >; /** @@ -35,10 +37,11 @@ export type KeyringAccounts = StaticAssertAbstractAccount< */ export const KeyringAccountStructs: Record< string, - Struct | Struct + Struct | Struct | Struct > = { [`${EthAccountType.Eoa}`]: EthEoaAccountStruct, [`${EthAccountType.Erc4337}`]: EthErc4337AccountStruct, + [`${BtcAccountType.P2wpkh}`]: BtcP2wpkhAccountStruct, }; /** @@ -48,7 +51,11 @@ export const BaseKeyringAccountStruct = object({ /** * Account type. */ - type: enums([`${EthAccountType.Eoa}`, `${EthAccountType.Erc4337}`]), + type: enums([ + `${EthAccountType.Eoa}`, + `${EthAccountType.Erc4337}`, + `${BtcAccountType.P2wpkh}`, + ]), }); /** diff --git a/src/btc/index.ts b/src/btc/index.ts new file mode 100644 index 000000000..fcb073fef --- /dev/null +++ b/src/btc/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/src/btc/types.test.ts b/src/btc/types.test.ts new file mode 100644 index 000000000..480a561a1 --- /dev/null +++ b/src/btc/types.test.ts @@ -0,0 +1,41 @@ +import { BtcP2wpkhAddressStruct } from './types'; + +describe('types', () => { + describe('BtcP2wpkhAddressStruct', () => { + const errorPrefix = 'Could not decode P2WPKH address'; + + it.each([ + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx', + ])('is valid address; %s', (address) => { + expect(() => BtcP2wpkhAddressStruct.assert(address)).not.toThrow(); + }); + + it.each([ + // Too short + '', + 'bc1q', + // Must have at least 6 characters after separator '1' + 'bc1q000', + ])('throws an error if address is too short: %s', (address) => { + expect(() => BtcP2wpkhAddressStruct.assert(address)).toThrow( + `${errorPrefix}: ${address} too short`, + ); + }); + + it('throws an error if address is too long', () => { + const address = + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4w508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4w508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; + expect(() => BtcP2wpkhAddressStruct.assert(address)).toThrow( + `${errorPrefix}: Exceeds length limit`, + ); + }); + + it('throws an error if there no seperator', () => { + const address = 'bc0qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; + expect(() => BtcP2wpkhAddressStruct.assert(address)).toThrow( + `${errorPrefix}: No separator character for ${address}`, + ); + }); + }); +}); diff --git a/src/btc/types.ts b/src/btc/types.ts new file mode 100644 index 000000000..0cf60983e --- /dev/null +++ b/src/btc/types.ts @@ -0,0 +1,51 @@ +import { bech32 } from 'bech32'; +import type { Infer } from 'superstruct'; +import { object, string, array, enums, literal, refine } from 'superstruct'; + +import { BaseAccount } from '../base-types'; + +export const BtcP2wpkhAddressStruct = refine( + string(), + 'BtcP2wpkhAddressStruct', + (address: string) => { + try { + bech32.decode(address); + } catch (error) { + return new Error( + `Could not decode P2WPKH address: ${(error as Error).message}`, + ); + } + return true; + }, +); + +/** + * Supported Bitcoin methods. + */ +export enum BtcMethod { + // General transaction methods + SendTransaction = 'btc_sendTransaction', +} + +/** + * Supported Bitcoin account types. + */ +export enum BtcAccountType { + P2wpkh = 'bip122:p2wpkh', +} + +export const BtcP2wpkhAccountStruct = object({ + ...BaseAccount, + + /** + * Account type. + */ + type: literal(`${BtcAccountType.P2wpkh}`), + + /** + * Account supported methods. + */ + methods: array(enums([`${BtcMethod.SendTransaction}`])), +}); + +export type BtcP2wpkhAccount = Infer; diff --git a/src/index.ts b/src/index.ts index cd44fe826..7acb3b951 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './api'; +export * from './btc'; export * from './contexts'; export * from './eth'; export * from './events'; diff --git a/src/internal/types.test.ts b/src/internal/types.test.ts index d61d9baae..5e396f28b 100644 --- a/src/internal/types.test.ts +++ b/src/internal/types.test.ts @@ -3,13 +3,19 @@ import { assert } from 'superstruct'; import { InternalAccountStruct } from '.'; describe('InternalAccount', () => { - it('should have the correct structure', () => { + it.each([ + { type: 'eip155:eoa', address: '0x000' }, + { + type: 'bip122:p2wpkh', + address: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + }, + ])('should have the correct structure: %s', ({ type, address }) => { const account = { id: '606a7759-b0fb-48e4-9874-bab62ff8e7eb', - address: '0x000', + address, options: {}, methods: [], - type: 'eip155:eoa', + type, metadata: { keyring: { type: 'Test Keyring', diff --git a/src/internal/types.ts b/src/internal/types.ts index 03bc7cb6d..a5c24bf10 100644 --- a/src/internal/types.ts +++ b/src/internal/types.ts @@ -2,6 +2,7 @@ import type { Infer, Struct } from 'superstruct'; import { boolean, string, number, define, mask, validate } from 'superstruct'; import { BaseKeyringAccountStruct } from '../api'; +import { BtcP2wpkhAccountStruct, BtcAccountType } from '../btc/types'; import { EthEoaAccountStruct, EthErc4337AccountStruct, @@ -27,28 +28,51 @@ export const InternalAccountMetadataStruct = object({ }), }); -export const InternalEthEoaAccountStruct = object({ - ...EthEoaAccountStruct.schema, - ...InternalAccountMetadataStruct.schema, -}); +/** + * Creates an `InternalAccount` from an existing account `superstruct` object. + * + * @param accountStruct - An account `superstruct` object. + * @returns The `InternalAccount` assocaited to `accountStruct`. + */ +function asInternalAccountStruct( + accountStruct: Struct, +) { + return object({ + ...accountStruct.schema, + ...InternalAccountMetadataStruct.schema, + }); +} -export type InternalEthEoaAccount = Infer; +export const InternalEthEoaAccountStruct = + asInternalAccountStruct(EthEoaAccountStruct); -export const InternalEthErc4337AccountStruct = object({ - ...EthErc4337AccountStruct.schema, - ...InternalAccountMetadataStruct.schema, -}); +export const InternalEthErc4337AccountStruct = asInternalAccountStruct( + EthErc4337AccountStruct, +); + +export const InternalBtcP2wpkhAccountStruct = asInternalAccountStruct( + BtcP2wpkhAccountStruct, +); + +export type InternalEthEoaAccount = Infer; export type InternalEthErc4337Account = Infer< typeof InternalEthErc4337AccountStruct >; +export type InternalBtcP2wpkhAccount = Infer< + typeof InternalBtcP2wpkhAccountStruct +>; + export const InternalAccountStructs: Record< string, - Struct | Struct + | Struct + | Struct + | Struct > = { [`${EthAccountType.Eoa}`]: InternalEthEoaAccountStruct, [`${EthAccountType.Erc4337}`]: InternalEthErc4337AccountStruct, + [`${BtcAccountType.P2wpkh}`]: InternalBtcP2wpkhAccountStruct, }; export const InternalAccountStruct = define( diff --git a/yarn.lock b/yarn.lock index fdfe8e9d1..01a37811c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1067,6 +1067,7 @@ __metadata: "@types/uuid": ^9.0.1 "@typescript-eslint/eslint-plugin": ^5.43.0 "@typescript-eslint/parser": ^5.43.0 + bech32: ^2.0.0 depcheck: ^1.4.3 eslint: ^8.27.0 eslint-config-prettier: ^8.5.0 @@ -2206,6 +2207,13 @@ __metadata: languageName: node linkType: hard +"bech32@npm:^2.0.0": + version: 2.0.0 + resolution: "bech32@npm:2.0.0" + checksum: fa15acb270b59aa496734a01f9155677b478987b773bf701f465858bf1606c6a970085babd43d71ce61895f1baa594cb41a2cd1394bd2c6698f03cc2d811300e + languageName: node + linkType: hard + "big-integer@npm:^1.6.44": version: 1.6.51 resolution: "big-integer@npm:1.6.51"