From 4fa700a5c64245800754b9467a2ce74f4d091a83 Mon Sep 17 00:00:00 2001
From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com>
Date: Wed, 17 Apr 2024 13:11:47 +0800
Subject: [PATCH 001/362] feat: init commit
---
.../bitcoin-wallet-snap/.eslintrc.js | 30 +++++
merged-packages/bitcoin-wallet-snap/README.md | 1 +
.../bitcoin-wallet-snap/babel.config.js | 6 +
.../bitcoin-wallet-snap/images/icon.svg | 17 +++
.../bitcoin-wallet-snap/jest.config.js | 6 +
.../bitcoin-wallet-snap/package.json | 77 +++++++++++
.../bitcoin-wallet-snap/snap.config.ts | 13 ++
.../bitcoin-wallet-snap/snap.manifest.json | 64 +++++++++
.../bitcoin-wallet-snap/src/index.ts | 44 ++++++
.../src/modules/bitcoin/account/account.ts | 88 ++++++++++++
.../src/modules/bitcoin/account/constants.ts | 5 +
.../src/modules/bitcoin/account/deriver.ts | 97 ++++++++++++++
.../src/modules/bitcoin/account/exceptions.ts | 7 +
.../src/modules/bitcoin/account/factory.ts | 27 ++++
.../src/modules/bitcoin/account/helpers.ts | 25 ++++
.../src/modules/bitcoin/account/index.ts | 8 ++
.../src/modules/bitcoin/account/manager.ts | 52 ++++++++
.../src/modules/bitcoin/account/signer.ts | 48 +++++++
.../src/modules/bitcoin/account/types.ts | 38 ++++++
.../src/modules/bitcoin/config/constants.ts | 9 ++
.../src/modules/bitcoin/config/index.ts | 2 +
.../src/modules/bitcoin/config/types.ts | 18 +++
.../data-client/clients/blockstream.ts | 86 ++++++++++++
.../modules/bitcoin/data-client/exceptions.ts | 3 +
.../modules/bitcoin/data-client/factory.ts | 22 +++
.../src/modules/bitcoin/data-client/index.ts | 3 +
.../src/modules/bitcoin/data-client/types.ts | 11 ++
.../src/modules/bitcoin/network/helpers.ts | 16 +++
.../src/modules/bitcoin/network/index.ts | 1 +
.../modules/bitcoin/transaction/exceptions.ts | 3 +
.../src/modules/bitcoin/transaction/index.ts | 3 +
.../modules/bitcoin/transaction/manager.ts | 24 ++++
.../src/modules/bitcoin/transaction/types.ts | 5 +
.../src/modules/config/index.ts | 56 ++++++++
.../src/modules/exceptions/exceptions.ts | 23 ++++
.../src/modules/exceptions/index.ts | 1 +
.../src/modules/factory.ts | 54 ++++++++
.../src/modules/keyring/exceptions.ts | 5 +
.../src/modules/keyring/index.ts | 4 +
.../src/modules/keyring/keyring.ts | 125 ++++++++++++++++++
.../src/modules/keyring/state.ts | 103 +++++++++++++++
.../src/modules/keyring/types.ts | 32 +++++
.../src/modules/logger/logger.ts | 88 ++++++++++++
.../src/modules/snap/exceptions.ts | 3 +
.../src/modules/snap/helpers.ts | 85 ++++++++++++
.../src/modules/snap/index.ts | 4 +
.../src/modules/snap/lock.ts | 12 ++
.../src/modules/snap/state.ts | 30 +++++
.../src/modules/transaction/exceptions.ts | 3 +
.../src/modules/transaction/index.ts | 4 +
.../src/modules/transaction/state.ts | 4 +
.../src/modules/transaction/transaction.ts | 26 ++++
.../src/modules/transaction/types.ts | 9 ++
.../bitcoin-wallet-snap/src/rpcs/base.ts | 67 ++++++++++
.../src/rpcs/exceptions.ts | 3 +
.../bitcoin-wallet-snap/src/rpcs/index.ts | 4 +
.../src/rpcs/methods/create-account.ts | 49 +++++++
.../src/rpcs/methods/get-balance.ts | 49 +++++++
.../bitcoin-wallet-snap/src/rpcs/types.ts | 43 ++++++
.../bitcoin-wallet-snap/src/types/state.ts | 6 +
.../bitcoin-wallet-snap/src/types/static.ts | 6 +
.../bitcoin-wallet-snap/tsconfig.json | 12 ++
62 files changed, 1769 insertions(+)
create mode 100644 merged-packages/bitcoin-wallet-snap/.eslintrc.js
create mode 100644 merged-packages/bitcoin-wallet-snap/README.md
create mode 100644 merged-packages/bitcoin-wallet-snap/babel.config.js
create mode 100644 merged-packages/bitcoin-wallet-snap/images/icon.svg
create mode 100644 merged-packages/bitcoin-wallet-snap/jest.config.js
create mode 100644 merged-packages/bitcoin-wallet-snap/package.json
create mode 100644 merged-packages/bitcoin-wallet-snap/snap.config.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/snap.manifest.json
create mode 100644 merged-packages/bitcoin-wallet-snap/src/index.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/constants.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/deriver.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/exceptions.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/factory.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/helpers.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/index.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/signer.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/types.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/constants.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/index.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/types.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/exceptions.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/factory.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/index.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/types.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/helpers.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/index.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/exceptions.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/index.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/manager.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/types.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/exceptions/exceptions.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/exceptions/index.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/factory.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/keyring/exceptions.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/keyring/index.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/logger/logger.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/snap/exceptions.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/snap/index.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/snap/lock.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/transaction/exceptions.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/transaction/index.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/transaction/state.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/transaction/types.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/exceptions.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balance.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/types/state.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/types/static.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/tsconfig.json
diff --git a/merged-packages/bitcoin-wallet-snap/.eslintrc.js b/merged-packages/bitcoin-wallet-snap/.eslintrc.js
new file mode 100644
index 00000000..8090a2b7
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/.eslintrc.js
@@ -0,0 +1,30 @@
+module.exports = {
+ extends: ['../../.eslintrc.js'],
+
+ parserOptions: {
+ tsconfigRootDir: __dirname,
+ },
+
+ overrides: [
+ {
+ files: ['snap.config.ts'],
+ extends: ['@metamask/eslint-config-nodejs'],
+ },
+
+ {
+ files: ['*.test.ts'],
+ rules: {
+ '@typescript-eslint/unbound-method': 'off',
+ },
+ },
+
+ {
+ files: ['*.ts'],
+ rules: {
+ 'import/no-nodejs-modules': 'off',
+ },
+ },
+ ],
+
+ ignorePatterns: ['!.eslintrc.js', 'dist/'],
+};
diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md
new file mode 100644
index 00000000..1a9ce2fe
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/README.md
@@ -0,0 +1 @@
+# BTC Snap
diff --git a/merged-packages/bitcoin-wallet-snap/babel.config.js b/merged-packages/bitcoin-wallet-snap/babel.config.js
new file mode 100644
index 00000000..8165fe45
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ presets: [
+ ['@babel/preset-env', { targets: { node: 'current' } }],
+ '@babel/preset-typescript',
+ ],
+};
diff --git a/merged-packages/bitcoin-wallet-snap/images/icon.svg b/merged-packages/bitcoin-wallet-snap/images/icon.svg
new file mode 100644
index 00000000..c6f77a0f
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/images/icon.svg
@@ -0,0 +1,17 @@
+
diff --git a/merged-packages/bitcoin-wallet-snap/jest.config.js b/merged-packages/bitcoin-wallet-snap/jest.config.js
new file mode 100644
index 00000000..f0a22c3e
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/jest.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ preset: '@metamask/snaps-jest',
+ transform: {
+ '^.+\\.(t|j)sx?$': 'ts-jest',
+ },
+};
diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json
new file mode 100644
index 00000000..123b07a0
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/package.json
@@ -0,0 +1,77 @@
+{
+ "name": "@metamask/bitcoin",
+ "version": "0.1.0",
+ "description": "A MetaMask snap to manage Bitcoin",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/MetaMask/bitcoin.git"
+ },
+ "license": "(MIT-0 OR Apache-2.0)",
+ "main": "./dist/bundle.js",
+ "files": [
+ "dist/",
+ "snap.manifest.json"
+ ],
+ "scripts": {
+ "allow-scripts": "yarn workspace root allow-scripts",
+ "build": "mm-snap build",
+ "build:clean": "yarn clean && yarn build",
+ "clean": "rimraf dist",
+ "lint": "yarn lint:eslint && yarn lint:misc --check",
+ "lint:eslint": "eslint . --cache --ext js,ts",
+ "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write",
+ "lint:misc": "prettier '**/*.ts' '**/*.json' '**/*.md' '!CHANGELOG.md' --ignore-path .gitignore",
+ "prepublishOnly": "mm-snap manifest",
+ "serve": "mm-snap serve",
+ "start": "mm-snap watch",
+ "test": "jest"
+ },
+ "dependencies": {
+ "@bitcoinerlab/secp256k1": "^1.1.1",
+ "@metamask/key-tree": "^9.0.0",
+ "@metamask/keyring-api": "^5.1.0",
+ "@metamask/snaps-sdk": "^4.0.0",
+ "async-mutex": "^0.3.2",
+ "bip32": "^4.0.0",
+ "bitcoinjs-lib": "^6.1.5",
+ "buffer": "^6.0.3",
+ "superstruct": "^1.0.3",
+ "uuid": "^9.0.1"
+ },
+ "devDependencies": {
+ "@babel/preset-typescript": "^7.23.3",
+ "@jest/globals": "^29.5.0",
+ "@metamask/auto-changelog": "^3.4.4",
+ "@metamask/eslint-config": "^12.2.0",
+ "@metamask/eslint-config-jest": "^12.1.0",
+ "@metamask/eslint-config-nodejs": "^12.1.0",
+ "@metamask/eslint-config-typescript": "^12.1.0",
+ "@metamask/snaps-cli": "^6.1.0",
+ "@metamask/snaps-jest": "^6.0.2",
+ "@typescript-eslint/eslint-plugin": "^5.42.1",
+ "@typescript-eslint/parser": "^5.42.1",
+ "bip174": "^2.1.1",
+ "eslint": "^8.45.0",
+ "eslint-config-prettier": "^8.5.0",
+ "eslint-plugin-import": "~2.26.0",
+ "eslint-plugin-jest": "^27.1.5",
+ "eslint-plugin-jsdoc": "^41.1.2",
+ "eslint-plugin-n": "^15.7.0",
+ "eslint-plugin-prettier": "^4.2.1",
+ "eslint-plugin-promise": "^6.1.1",
+ "jest": "^29.5.0",
+ "prettier": "^2.7.1",
+ "prettier-plugin-packagejson": "^2.2.11",
+ "rimraf": "^3.0.2",
+ "ts-jest": "^29.1.0",
+ "typescript": "^4.7.4"
+ },
+ "packageManager": "yarn@3.2.1",
+ "engines": {
+ "node": ">=18.6.0"
+ },
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts
new file mode 100644
index 00000000..622949da
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts
@@ -0,0 +1,13 @@
+import type { SnapConfig } from '@metamask/snaps-cli';
+import { resolve } from 'path';
+
+const config: SnapConfig = {
+ bundler: 'webpack',
+ input: resolve(__dirname, 'src/index.ts'),
+ server: {
+ port: 8080,
+ },
+ polyfills: true,
+};
+
+export default config;
diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json
new file mode 100644
index 00000000..d0f1cb5a
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json
@@ -0,0 +1,64 @@
+{
+ "version": "0.1.0",
+ "description": "A MetaMask snap to manage Bitcoin",
+ "proposedName": "Bitcoin Manager",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/MetaMask/bitcoin.git"
+ },
+ "source": {
+ "shasum": "QYsZ8HVb8OmMmPlePs8t9NbV0CIZkt7KucFBw/CkEuQ=",
+ "location": {
+ "npm": {
+ "filePath": "dist/bundle.js",
+ "iconPath": "images/icon.svg",
+ "packageName": "@metamask/bitcoin",
+ "registry": "https://registry.npmjs.org/"
+ }
+ }
+ },
+ "initialPermissions": {
+ "snap_dialog": {},
+ "endowment:rpc": {
+ "dapps": true,
+ "snaps": false
+ },
+ "snap_getBip32Entropy": [
+ {
+ "path": [
+ "m",
+ "49'",
+ "0'"
+ ],
+ "curve": "secp256k1"
+ },
+ {
+ "path": [
+ "m",
+ "49'",
+ "1'"
+ ],
+ "curve": "secp256k1"
+ },
+ {
+ "path": [
+ "m",
+ "84'",
+ "0'"
+ ],
+ "curve": "secp256k1"
+ },
+ {
+ "path": [
+ "m",
+ "84'",
+ "1'"
+ ],
+ "curve": "secp256k1"
+ }
+ ],
+ "endowment:network-access": {},
+ "snap_manageState": {}
+ },
+ "manifestVersion": "0.1"
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts
new file mode 100644
index 00000000..0e3626cf
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/index.ts
@@ -0,0 +1,44 @@
+import type { OnRpcRequestHandler } from '@metamask/snaps-sdk';
+import { SnapError } from '@metamask/snaps-sdk';
+
+import { LogLevel, logger } from './modules/logger/logger';
+import type {
+ SnapRpcRequestHandlerRequest,
+ IStaticSnapRpcRequestHandler,
+} from './rpcs';
+import { CreateAccountHandler } from './rpcs';
+import { GetBalanceHandler } from './rpcs/methods/get-balance';
+
+const validateOrigin = async (origin: string) => {
+ // TODO: validate origin
+ if (origin === '') {
+ throw new SnapError('Origin not found');
+ }
+};
+
+const getHandler = (method: string): IStaticSnapRpcRequestHandler => {
+ switch (method) {
+ case 'bitcoin_createAccount':
+ return CreateAccountHandler;
+ case 'bitcoin_getBalance':
+ return GetBalanceHandler;
+ default:
+ throw new SnapError(`Method not found`);
+ }
+};
+
+export const onRpcRequest: OnRpcRequestHandler = async (args) => {
+ const { request, origin } = args;
+
+ logger.logLevel = LogLevel.ALL;
+
+ await validateOrigin(origin);
+
+ const { method } = request;
+
+ const handler = getHandler(method);
+
+ return await handler
+ .getInstance()
+ .execute(request.params as SnapRpcRequestHandlerRequest);
+};
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.ts
new file mode 100644
index 00000000..69af3a12
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.ts
@@ -0,0 +1,88 @@
+import type { Network, Payment } from 'bitcoinjs-lib';
+import { Buffer } from 'buffer';
+
+import type { StaticImplements } from '../../../types/static';
+import { ScriptType } from './constants';
+import { AddressHelper } from './helpers';
+import type { IBtcAccount, IStaticBtcAccount, IAccountSigner } from './types';
+
+export class BtcAccount implements IBtcAccount {
+ #address: string;
+
+ #payment: Payment;
+
+ readonly mfp: string;
+
+ readonly index: number;
+
+ readonly hdPath: string;
+
+ readonly pubkey: string;
+
+ readonly network: Network;
+
+ readonly type: ScriptType;
+
+ readonly signer: IAccountSigner;
+
+ constructor(
+ mfp: string,
+ index: number,
+ hdPath: string,
+ pubkey: string,
+ network: Network,
+ type: ScriptType,
+ signer: IAccountSigner,
+ ) {
+ this.mfp = mfp;
+ this.index = index;
+ this.hdPath = hdPath;
+ this.pubkey = pubkey;
+ this.network = network;
+ this.type = type;
+ this.signer = signer;
+ }
+
+ get address() {
+ if (!this.#address) {
+ if (!this.payment.address) {
+ throw new Error('Payment address is missing');
+ }
+ this.#address = this.payment.address;
+ }
+ return this.#address;
+ }
+
+ get payment() {
+ if (!this.#payment) {
+ this.#payment = AddressHelper.getPayment(
+ this.type,
+ Buffer.from(this.pubkey, 'hex'),
+ this.network,
+ );
+ }
+ return this.#payment;
+ }
+
+ async sign(signMessage: Buffer): Promise {
+ return await this.signer.sign(signMessage);
+ }
+}
+
+export class P2WPKHAccount
+ extends BtcAccount
+ implements StaticImplements
+{
+ static readonly path = ['m', "84'", "0'"];
+
+ static readonly scriptType = ScriptType.P2wpkh;
+}
+
+export class P2SHP2WPKHAccount
+ extends BtcAccount
+ implements StaticImplements
+{
+ static readonly path = ['m', "49'", "0'"];
+
+ static readonly scriptType = ScriptType.P2shP2wkh;
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/constants.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/constants.ts
new file mode 100644
index 00000000..d173233a
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/constants.ts
@@ -0,0 +1,5 @@
+export enum ScriptType {
+ P2pkh = 'P2PKH',
+ P2shP2wkh = 'P2SH-P2WPKH',
+ P2wpkh = 'P2WPKH',
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/deriver.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/deriver.ts
new file mode 100644
index 00000000..394ede66
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/deriver.ts
@@ -0,0 +1,97 @@
+import ecc from '@bitcoinerlab/secp256k1';
+import type { BIP32Interface, BIP32API } from 'bip32';
+import { BIP32Factory } from 'bip32';
+import { type Network } from 'bitcoinjs-lib';
+import { Buffer } from 'buffer';
+
+import { SnapHelper } from '../../snap/helpers';
+import { DeriverError } from './exceptions';
+import { AddressHelper } from './helpers';
+import type { IBtcAccountDeriver } from './types';
+
+export abstract class BtcAccountDeriver implements IBtcAccountDeriver {
+ protected readonly network: Network;
+
+ protected readonly bip32Api: BIP32API;
+
+ constructor(network: Network) {
+ this.bip32Api = BIP32Factory(ecc);
+ this.network = network;
+ }
+
+ abstract getRoot(path: string[]): Promise;
+
+ fromSeed(seed: Buffer): BIP32Interface {
+ return this.bip32Api.fromSeed(seed, this.network);
+ }
+
+ fromPrivateKey(privateKey: Buffer, chainNode: Buffer): BIP32Interface {
+ return this.bip32Api.fromPrivateKey(privateKey, chainNode, this.network);
+ }
+
+ async getChild(root: BIP32Interface, idx: number): Promise {
+ return Promise.resolve(root.deriveHardened(0).derive(0).derive(idx));
+ }
+}
+
+export class BtcAccountBip44Deriver extends BtcAccountDeriver {
+ async getRoot(path: string[]) {
+ try {
+ const deriver = await SnapHelper.getBip44Deriver(0); // seed phase
+ const deriverNode = await deriver(0);
+ if (!deriverNode.privateKey) {
+ throw new DeriverError('Deriver private key is missing');
+ }
+ const privateKeyBuffer = Buffer.from(
+ AddressHelper.trimHexPrefix(deriverNode.privateKey),
+ 'hex',
+ );
+ const root = this.fromSeed(privateKeyBuffer);
+ return root
+ .deriveHardened(parseInt(path[1].slice(0, -1), 10))
+ .deriveHardened(0);
+ } catch (error) {
+ throw new DeriverError(error);
+ }
+ }
+}
+
+export class BtcAccountBip32Deriver extends BtcAccountDeriver {
+ readonly curve: 'secp256k1' | 'ed25519' = 'secp256k1';
+
+ async getRoot(path: string[]) {
+ try {
+ const deriver = await SnapHelper.getBip32Deriver(path, this.curve);
+
+ if (!deriver.privateKey) {
+ throw new DeriverError('Deriver private key is missing');
+ }
+
+ const privateKeyBuffer = Buffer.from(
+ AddressHelper.trimHexPrefix(deriver.privateKey),
+ 'hex',
+ );
+ const chainCodeBuffer = Buffer.from(
+ AddressHelper.trimHexPrefix(deriver.chainCode),
+ 'hex',
+ );
+
+ const root = this.fromPrivateKey(privateKeyBuffer, chainCodeBuffer);
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ // ignore checking since no function to set depth for node
+ root.DEPTH = deriver.depth;
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ // ignore checking since no function to set index for node
+ root.INDEX = deriver.index;
+
+ return root;
+ } catch (error) {
+ if (error instanceof DeriverError) {
+ throw error;
+ }
+ throw new DeriverError(error);
+ }
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/exceptions.ts
new file mode 100644
index 00000000..4b407262
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/exceptions.ts
@@ -0,0 +1,7 @@
+import { CustomError } from '../../exceptions';
+
+export class DeriverError extends CustomError {}
+
+export class AccountMgrFactoryError extends CustomError {}
+
+export class AccountMgrError extends CustomError {}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/factory.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/factory.ts
new file mode 100644
index 00000000..21d493da
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/factory.ts
@@ -0,0 +1,27 @@
+import { type Network } from 'bitcoinjs-lib';
+
+import { type IAccountMgr } from '../../keyring';
+import { type BtcAccountConfig } from '../config';
+import { P2WPKHAccount } from './account';
+import { ScriptType } from './constants';
+import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver';
+import { AccountMgrFactoryError } from './exceptions';
+import { BtcAccountMgr } from './manager';
+
+export class BtcAccountMgrFactory {
+ static create(config: BtcAccountConfig, network: Network): IAccountMgr {
+ const type = config.defaultAccountType as ScriptType;
+ switch (type) {
+ case ScriptType.P2wpkh:
+ return new BtcAccountMgr(
+ config.deriver === 'BIP44'
+ ? new BtcAccountBip44Deriver(network)
+ : new BtcAccountBip32Deriver(network),
+ P2WPKHAccount,
+ network,
+ );
+ default:
+ throw new AccountMgrFactoryError('Invalid script type');
+ }
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/helpers.ts
new file mode 100644
index 00000000..133f2a24
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/helpers.ts
@@ -0,0 +1,25 @@
+import { type Network, payments } from 'bitcoinjs-lib';
+import type { Buffer } from 'buffer';
+
+import { ScriptType } from './constants';
+
+export class AddressHelper {
+ static getPayment(type: ScriptType, pubkey: Buffer, network: Network) {
+ switch (type) {
+ case ScriptType.P2pkh:
+ return payments.p2pkh({ pubkey, network });
+ case ScriptType.P2shP2wkh:
+ return payments.p2sh({
+ redeem: payments.p2wpkh({ pubkey, network }),
+ network,
+ });
+ case ScriptType.P2wpkh:
+ return payments.p2wpkh({ pubkey, network });
+ default:
+ throw new Error('Invalid script type');
+ }
+ }
+
+ static trimHexPrefix = (key: string) =>
+ key.startsWith('0x') ? key.substring(2) : key;
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/index.ts
new file mode 100644
index 00000000..86be70b0
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/index.ts
@@ -0,0 +1,8 @@
+export * from './account';
+export * from './constants';
+export * from './factory';
+export * from './helpers';
+export * from './types';
+export * from './signer';
+export * from './deriver';
+export * from './manager';
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.ts
new file mode 100644
index 00000000..87995acc
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.ts
@@ -0,0 +1,52 @@
+import type { BIP32Interface } from 'bip32';
+import { type Network } from 'bitcoinjs-lib';
+
+import { type IAccount, type IAccountMgr } from '../../keyring';
+import { AccountMgrError } from './exceptions';
+import { AccountSigner } from './signer';
+import type { IStaticBtcAccount, IBtcAccountDeriver } from './types';
+
+export class BtcAccountMgr implements IAccountMgr {
+ protected readonly deriver: IBtcAccountDeriver;
+
+ protected readonly account: IStaticBtcAccount;
+
+ protected readonly network: Network;
+
+ constructor(
+ deriver: IBtcAccountDeriver,
+ account: IStaticBtcAccount,
+ network: Network,
+ ) {
+ this.deriver = deriver;
+ this.account = account;
+ this.network = network;
+ }
+
+ async unlock(index: number): Promise {
+ try {
+ //eslint -disable-next-line @typescript-eslint/naming-convention
+ const AccountContrustor = this.account;
+
+ const rootNode = await this.deriver.getRoot(AccountContrustor.path);
+ const childNode = await this.deriver.getChild(rootNode, index);
+ const hdPath = [`m`, `0'`, `0`, `${index}`].join('/');
+
+ return new AccountContrustor(
+ rootNode.fingerprint.toString('hex'),
+ index,
+ hdPath,
+ childNode.publicKey.toString('hex'),
+ this.network,
+ AccountContrustor.scriptType,
+ this.getHdSigner(rootNode),
+ );
+ } catch (error) {
+ throw new AccountMgrError(error);
+ }
+ }
+
+ protected getHdSigner(rootNode: BIP32Interface) {
+ return new AccountSigner(rootNode, rootNode.fingerprint);
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/signer.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/signer.ts
new file mode 100644
index 00000000..dcd8079d
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/signer.ts
@@ -0,0 +1,48 @@
+import type { BIP32Interface } from 'bip32';
+import type { HDSignerAsync } from 'bitcoinjs-lib';
+import type { Buffer } from 'buffer';
+
+import type { IAccountSigner } from './types';
+
+export class AccountSigner implements HDSignerAsync, IAccountSigner {
+ readonly publicKey: Buffer;
+
+ readonly fingerprint: Buffer;
+
+ protected readonly node: BIP32Interface;
+
+ constructor(accountNode: BIP32Interface, mfp?: Buffer) {
+ this.node = accountNode;
+ this.publicKey = this.node.publicKey;
+ this.fingerprint = mfp ?? this.node.fingerprint;
+ }
+
+ derivePath(path: string): IAccountSigner {
+ try {
+ let splitPath = path.split('/');
+ if (splitPath[0] === 'm') {
+ splitPath = splitPath.slice(1);
+ }
+ const childNode = splitPath.reduce((prevHd, indexStr) => {
+ let index;
+ if (indexStr.endsWith(`'`)) {
+ index = parseInt(indexStr.slice(0, -1), 10);
+ return prevHd.deriveHardened(index);
+ }
+ index = parseInt(indexStr, 10);
+ return prevHd.derive(index);
+ }, this.node);
+ return new AccountSigner(childNode, this.fingerprint);
+ } catch (error) {
+ throw new Error('invalid path');
+ }
+ }
+
+ async sign(hash: Buffer): Promise {
+ return this.node.sign(hash);
+ }
+
+ verify(hash: Buffer, signature: Buffer): boolean {
+ return this.node.verify(hash, signature);
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/types.ts
new file mode 100644
index 00000000..300e40ec
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/types.ts
@@ -0,0 +1,38 @@
+import type { BIP32Interface } from 'bip32';
+import type { Network, Payment } from 'bitcoinjs-lib';
+import type { Buffer } from 'buffer';
+
+import type { IAccount } from '../../keyring';
+import type { ScriptType } from './constants';
+
+export type IBtcAccountDeriver = {
+ getRoot(path: string[]): Promise;
+ getChild(root: BIP32Interface, idx: number): Promise;
+};
+
+export type IAccountSigner = {
+ sign(hash: Buffer): Promise;
+ derivePath(path: string): IAccountSigner;
+ verify(hash: Buffer, signature: Buffer): boolean;
+ publicKey: Buffer;
+ fingerprint: Buffer;
+};
+
+export type IBtcAccount = IAccount & {
+ payment: Payment;
+ signer: IAccountSigner;
+};
+
+export type IStaticBtcAccount = {
+ path: string[];
+ scriptType: ScriptType;
+ new (
+ mfp: string,
+ index: number,
+ hdPath: string,
+ pubkey: string,
+ network: Network,
+ type: ScriptType,
+ signer: IAccountSigner,
+ ): IBtcAccount;
+};
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/constants.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/constants.ts
new file mode 100644
index 00000000..4bea3450
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/constants.ts
@@ -0,0 +1,9 @@
+export enum Network {
+ Mainnet = '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f',
+ Testnet = '000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943',
+}
+
+export enum DataClient {
+ BlockStream = 'BlockStream',
+ BlockChair = 'BlockChair',
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/index.ts
new file mode 100644
index 00000000..33c8572a
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/index.ts
@@ -0,0 +1,2 @@
+export * from './types';
+export * from './constants';
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/types.ts
new file mode 100644
index 00000000..1285920c
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/types.ts
@@ -0,0 +1,18 @@
+import type { DataClient } from './constants';
+
+export type BtcTransactionConfig = {
+ dataClient: {
+ read: {
+ type: DataClient;
+ };
+ write: {
+ type: DataClient;
+ };
+ };
+};
+
+export type BtcAccountConfig = {
+ defaultAccountIndex: number;
+ defaultAccountType: string;
+ deriver: string;
+};
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.ts
new file mode 100644
index 00000000..27768bff
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.ts
@@ -0,0 +1,86 @@
+import { type Network, networks } from 'bitcoinjs-lib';
+
+import { logger } from '../../../logger/logger';
+import { type Balance } from '../../../transaction';
+import { DataClientError } from '../exceptions';
+import type { IReadDataClient } from '../types';
+
+export type BlockStreamClientOptions = {
+ network: Network;
+};
+
+/* eslint-disable */
+export type GetAddressStatsResponse = {
+ address: string;
+ chain_stats: {
+ funded_txo_count: number;
+ funded_txo_sum: number;
+ spent_txo_count: number;
+ spent_txo_sum: number;
+ tx_count: number;
+ };
+ mempool_stats: {
+ funded_txo_count: number;
+ funded_txo_sum: number;
+ spent_txo_count: number;
+ spent_txo_sum: number;
+ tx_count: number;
+ };
+};
+/* eslint-enable */
+
+export class BlockStreamClient implements IReadDataClient {
+ options: BlockStreamClientOptions;
+
+ constructor(options: BlockStreamClientOptions) {
+ this.options = options;
+ }
+
+ get baseUrl() {
+ if (this.options.network === networks.bitcoin) {
+ return 'https://blockstream.info/api';
+ }
+ return 'https://blockstream.info/testnet/api';
+ }
+
+ protected async get(endpoint: string): Promise {
+ const response = await fetch(`${this.baseUrl}${endpoint}`, {
+ method: 'GET',
+ });
+ if (!response.ok) {
+ throw new DataClientError(
+ `Failed to fetch data from blockstream: ${response.statusText}`,
+ );
+ }
+ return response.json() as Resp;
+ }
+
+ async getBalance(address: string): Promise {
+ try {
+ const response = await this.get(
+ `/address/${address}`,
+ );
+
+ logger.info(
+ `[BlockStreamClient.getBalance] response: ${JSON.stringify(response)}`,
+ );
+
+ const confirmed =
+ response.chain_stats.funded_txo_sum -
+ response.chain_stats.spent_txo_sum;
+ const unconfirmed =
+ response.mempool_stats.funded_txo_sum -
+ response.mempool_stats.spent_txo_sum;
+ return {
+ confirmed,
+ unconfirmed,
+ total: confirmed + unconfirmed,
+ };
+ } catch (error) {
+ if (error instanceof DataClientError) {
+ throw error;
+ }
+ throw new DataClientError(error);
+ }
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/exceptions.ts
new file mode 100644
index 00000000..c23c55be
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/exceptions.ts
@@ -0,0 +1,3 @@
+import { CustomError } from '../../exceptions';
+
+export class DataClientError extends CustomError {}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/factory.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/factory.ts
new file mode 100644
index 00000000..f37ce7fe
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/factory.ts
@@ -0,0 +1,22 @@
+import { type Network } from 'bitcoinjs-lib';
+
+import { DataClient, type BtcTransactionConfig } from '../config';
+import { BlockStreamClient } from './clients/blockstream';
+import { DataClientError } from './exceptions';
+import type { IReadDataClient } from './types';
+
+export class DataClientFactory {
+ static createReadClient(
+ config: BtcTransactionConfig,
+ network: Network,
+ ): IReadDataClient {
+ switch (config.dataClient.read.type) {
+ case DataClient.BlockStream:
+ return new BlockStreamClient({ network });
+ default:
+ throw new DataClientError(
+ `Unsupported client type: ${config.dataClient.read.type}`,
+ );
+ }
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/index.ts
new file mode 100644
index 00000000..8decdbf9
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/index.ts
@@ -0,0 +1,3 @@
+export * from './exceptions';
+export * from './types';
+export * from './clients/blockstream';
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/types.ts
new file mode 100644
index 00000000..a55756ad
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/types.ts
@@ -0,0 +1,11 @@
+import { type Balance } from '../../transaction';
+
+export type IReadDataClient = {
+ getBalance(address: string): Promise;
+};
+
+export type IWriteDataClient = {
+ sendTransaction(tx: string): Promise;
+};
+
+export type IDataClient = IReadDataClient & IWriteDataClient;
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/helpers.ts
new file mode 100644
index 00000000..3f264110
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/helpers.ts
@@ -0,0 +1,16 @@
+import { networks } from 'bitcoinjs-lib';
+
+import { Network } from '../config';
+
+export class NetworkHelper {
+ static getNetwork(network: Network) {
+ switch (network) {
+ case Network.Mainnet:
+ return networks.bitcoin;
+ case Network.Testnet:
+ return networks.testnet;
+ default:
+ throw new Error('Invalid network');
+ }
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/index.ts
new file mode 100644
index 00000000..c5f595cf
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/index.ts
@@ -0,0 +1 @@
+export * from './helpers';
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/exceptions.ts
new file mode 100644
index 00000000..79116410
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/exceptions.ts
@@ -0,0 +1,3 @@
+import { CustomError } from '../../exceptions';
+
+export class TransactionError extends CustomError {}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/index.ts
new file mode 100644
index 00000000..471b4a69
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/index.ts
@@ -0,0 +1,3 @@
+export * from './exceptions';
+export * from './manager';
+export * from './types';
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/manager.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/manager.ts
new file mode 100644
index 00000000..fdd628ae
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/manager.ts
@@ -0,0 +1,24 @@
+import type { ITransactionMgr, Balance } from '../../transaction/types';
+import { type IReadDataClient } from '../data-client';
+import { TransactionError } from './exceptions';
+import type { BtcTransactionMgrOptions } from './types';
+
+export class BtcTransactionMgr implements ITransactionMgr {
+ protected readonly readClient: IReadDataClient;
+
+ protected readonly options: BtcTransactionMgrOptions;
+
+ constructor(readClient: IReadDataClient, options: BtcTransactionMgrOptions) {
+ this.readClient = readClient;
+ this.options = options;
+ }
+
+ async getBalance(address: string): Promise {
+ try {
+ const response = await this.readClient.getBalance(address);
+ return response;
+ } catch (error) {
+ throw new TransactionError(error);
+ }
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/types.ts
new file mode 100644
index 00000000..01e37768
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/types.ts
@@ -0,0 +1,5 @@
+import type { Network } from 'bitcoinjs-lib';
+
+export type BtcTransactionMgrOptions = {
+ network: Network;
+};
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts
new file mode 100644
index 00000000..1db12070
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts
@@ -0,0 +1,56 @@
+import {
+ type BtcAccountConfig,
+ type BtcTransactionConfig,
+ Network as BtcNetwork,
+ DataClient,
+} from '../bitcoin/config';
+
+export enum Chain {
+ Bitcoin = 'Bitcoin',
+}
+
+export type NetworkConfig = {
+ [key in string]: string;
+};
+
+export type TransactionConfig = {
+ [Chain.Bitcoin]: BtcTransactionConfig;
+};
+
+export type AccountConfig = {
+ [Chain.Bitcoin]: BtcAccountConfig;
+};
+
+export type SnapConfig = {
+ transaction: TransactionConfig;
+ account: AccountConfig;
+ avaliableNetworks: {
+ [key in Chain]: string[];
+ };
+};
+
+export const Config: SnapConfig = {
+ transaction: {
+ [Chain.Bitcoin]: {
+ dataClient: {
+ read: {
+ type: DataClient.BlockStream,
+ },
+ write: {
+ type: DataClient.BlockStream,
+ },
+ },
+ },
+ },
+ account: {
+ [Chain.Bitcoin]: {
+ defaultAccountIndex: 0,
+ defaultAccountType: 'P2WPKH',
+ deriver: 'BIP32',
+ },
+ },
+ avaliableNetworks: {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ [Chain.Bitcoin]: Object.entries(BtcNetwork).map(([_, val]) => val),
+ },
+};
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/exceptions/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/exceptions/exceptions.ts
new file mode 100644
index 00000000..438d3c89
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/exceptions/exceptions.ts
@@ -0,0 +1,23 @@
+export class CustomError extends Error {
+ name!: string;
+
+ constructor(message: string) {
+ super(message);
+
+ // set error name as constructor name, make it not enumerable to keep native Error behavior
+ // see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/new.target#new.target_in_constructors
+ // see https://github.com/adriengibrat/ts-custom-error/issues/30
+ Object.defineProperty(this, 'name', {
+ value: new.target.name,
+ enumerable: false,
+ configurable: true,
+ });
+
+ // fix the extended error prototype chain
+ // because typescript __extends implementation can't
+ // see https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work
+ Object.setPrototypeOf(this, new.target.prototype);
+ // remove constructor from stack trace
+ Error.captureStackTrace(this, this.constructor);
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/exceptions/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/exceptions/index.ts
new file mode 100644
index 00000000..28386125
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/exceptions/index.ts
@@ -0,0 +1 @@
+export * from './exceptions';
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts
new file mode 100644
index 00000000..2ead89fa
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts
@@ -0,0 +1,54 @@
+import { type Keyring } from '@metamask/keyring-api';
+
+import { BtcAccountMgrFactory } from './bitcoin/account';
+import type { Network } from './bitcoin/config';
+import {
+ type BtcAccountConfig,
+ type BtcTransactionConfig,
+} from './bitcoin/config';
+import { DataClientFactory } from './bitcoin/data-client/factory';
+import { NetworkHelper } from './bitcoin/network';
+import { BtcTransactionMgr } from './bitcoin/transaction';
+import type { Chain } from './config';
+import { Config } from './config';
+import { BtcKeyring, KeyringStateManager, type IAccountMgr } from './keyring';
+import type { ITransactionMgr } from './transaction/types';
+
+export class Factory {
+ static createBtcTransactionMgr(
+ config: BtcTransactionConfig,
+ network: string,
+ ) {
+ const btcNetwork = NetworkHelper.getNetwork(network as Network);
+ const readClient = DataClientFactory.createReadClient(config, btcNetwork);
+
+ return new BtcTransactionMgr(readClient, {
+ network: btcNetwork,
+ });
+ }
+
+ static createBtcAccountMgr(config: BtcAccountConfig, network: string) {
+ const btcNetwork = NetworkHelper.getNetwork(network as Network);
+ return BtcAccountMgrFactory.create(config, btcNetwork);
+ }
+
+ static createBtcKeyring(config: BtcAccountConfig, scope: string): BtcKeyring {
+ const accClient = Factory.createBtcAccountMgr(config, scope);
+
+ return new BtcKeyring(accClient, new KeyringStateManager(), {
+ defaultIndex: config.defaultAccountIndex,
+ });
+ }
+
+ static createTransactionMgr(chain: Chain, scope: string): ITransactionMgr {
+ return Factory.createBtcTransactionMgr(Config.transaction[chain], scope);
+ }
+
+ static createAccountMgr(chain: Chain, scope: string): IAccountMgr {
+ return Factory.createBtcAccountMgr(Config.account[chain], scope);
+ }
+
+ static createKeyring(chain: Chain, scope: string): Keyring {
+ return Factory.createBtcKeyring(Config.account[chain], scope);
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/exceptions.ts
new file mode 100644
index 00000000..077e1dca
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/exceptions.ts
@@ -0,0 +1,5 @@
+import { CustomError } from '../exceptions';
+
+export class BtcKeyringError extends CustomError {}
+
+export class BtcKeyringStateError extends CustomError {}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/index.ts
new file mode 100644
index 00000000..53d3dc06
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/index.ts
@@ -0,0 +1,4 @@
+export * from './keyring';
+export * from './state';
+export * from './exceptions';
+export * from './types';
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts
new file mode 100644
index 00000000..7bd3e029
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts
@@ -0,0 +1,125 @@
+import type {
+ Keyring,
+ KeyringAccount,
+ KeyringRequest,
+ KeyringResponse,
+} from '@metamask/keyring-api';
+import { v4 as uuidv4 } from 'uuid';
+
+import { logger } from '../logger/logger';
+import { BtcKeyringError } from './exceptions';
+import type { KeyringStateManager } from './state';
+import type {
+ IAccount,
+ IAccountMgr,
+ CreateAccountOptions,
+ KeyringOptions,
+} from './types';
+
+export class BtcKeyring implements Keyring {
+ protected readonly accountMgr: IAccountMgr;
+
+ protected readonly stateMgr: KeyringStateManager;
+
+ protected readonly options: KeyringOptions;
+
+ constructor(
+ accountMgr: IAccountMgr,
+ stateMgr: KeyringStateManager,
+ options: KeyringOptions,
+ ) {
+ this.accountMgr = accountMgr;
+ this.stateMgr = stateMgr;
+ this.options = options;
+ }
+
+ async listAccounts(): Promise {
+ try {
+ const accounts = await this.stateMgr.listAccounts();
+ return accounts;
+ } catch (error) {
+ throw new BtcKeyringError(error);
+ }
+ }
+
+ async getAccount(id: string): Promise {
+ try {
+ const account = await this.stateMgr.getAccount(id);
+ return account ?? undefined;
+ } catch (error) {
+ throw new BtcKeyringError(error);
+ }
+ }
+
+ async createAccount(options?: CreateAccountOptions): Promise {
+ try {
+ // TODO: Create account with index 0 for now for phase 1 scope, update to use increment index later
+ const index = options?.index ?? this.options.defaultIndex;
+ const account = await this.accountMgr.unlock(index);
+ logger.info(
+ `[BtcKeyring.createAccount] Account unlocked: ${account.address}`,
+ );
+
+ let keyringAccount = await this.stateMgr.getAccountByAddress(
+ account.address,
+ );
+
+ if (keyringAccount) {
+ return keyringAccount;
+ }
+
+ keyringAccount = this.newKeyringAccount(account);
+ logger.info(
+ `[BtcKeyring.createAccount] Keyring account data: ${JSON.stringify(
+ keyringAccount,
+ )}`,
+ );
+
+ await this.stateMgr.saveAccount(keyringAccount);
+
+ return keyringAccount;
+ } catch (error) {
+ throw new BtcKeyringError(error);
+ }
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ async filterAccountChains(id: string, chains: string[]): Promise {
+ throw new Error('Method not implemented.');
+ }
+
+ async updateAccount(account: KeyringAccount): Promise {
+ try {
+ await this.stateMgr.saveAccount(account);
+ } catch (error) {
+ throw new BtcKeyringError(error);
+ }
+ }
+
+ async deleteAccount(id: string): Promise {
+ try {
+ await this.stateMgr.removeAccounts([id]);
+ } catch (error) {
+ throw new BtcKeyringError(error);
+ }
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ async submitRequest(request: KeyringRequest): Promise {
+ throw new BtcKeyringError('Method not implemented.');
+ }
+
+ protected newKeyringAccount(account: IAccount): KeyringAccount {
+ return {
+ type: 'bip32',
+ id: uuidv4(),
+ address: account.address,
+ options: {
+ hdPath: account.hdPath,
+ index: account.index,
+ type: account.type,
+ },
+ methods: [],
+ } as unknown as KeyringAccount;
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts
new file mode 100644
index 00000000..48c34987
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts
@@ -0,0 +1,103 @@
+import type { KeyringAccount } from '@metamask/keyring-api';
+
+import { type SnapState } from '../../types/state';
+import { SnapStateManager, StateError } from '../snap';
+
+export class KeyringStateManager extends SnapStateManager {
+ protected override async get(): Promise {
+ return super.get().then((state: SnapState) => {
+ if (!state) {
+ // eslint-disable-next-line no-param-reassign
+ state = {
+ accounts: [],
+ accountDetails: {},
+ };
+ }
+
+ if (!state.accounts) {
+ state.accounts = [];
+ }
+
+ if (!state.accountDetails) {
+ state.accountDetails = {};
+ }
+
+ return state;
+ });
+ }
+
+ async listAccounts() {
+ try {
+ const state = await this.get();
+ return state.accounts.map((id) => state.accountDetails[id]);
+ } catch (error) {
+ throw new StateError(error);
+ }
+ }
+
+ async saveAccount(account: KeyringAccount): Promise {
+ try {
+ await this.update(async (state: SnapState) => {
+ if (
+ !Object.prototype.hasOwnProperty.call(
+ state.accountDetails,
+ account.id,
+ )
+ ) {
+ state.accounts.push(account.id);
+ }
+
+ state.accountDetails[account.id] = account;
+ });
+ } catch (error) {
+ throw new StateError(error);
+ }
+ }
+
+ async removeAccounts(ids: string[]): Promise {
+ try {
+ await this.update(async (state: SnapState) => {
+ const removeIds = new Set();
+
+ for (const id of ids) {
+ if (!Object.prototype.hasOwnProperty.call(state.accountDetails, id)) {
+ throw new StateError(`Account with id ${id} does not exist`);
+ }
+ removeIds.add(id);
+ }
+
+ removeIds.forEach((id) => delete state.accountDetails[id]);
+ state.accounts = state.accounts.filter((id) => !removeIds.has(id));
+ });
+ } catch (error) {
+ throw new StateError(error);
+ }
+ }
+
+ async getAccount(id: string): Promise {
+ try {
+ const state = await this.get();
+
+ if (!Object.prototype.hasOwnProperty.call(state.accountDetails, id)) {
+ return null;
+ }
+
+ return state.accountDetails[id];
+ } catch (error) {
+ throw new StateError(error);
+ }
+ }
+
+ async getAccountByAddress(address: string): Promise {
+ try {
+ const state = await this.get();
+ return (
+ Object.values(state.accountDetails).find(
+ (account) => account.address.toLowerCase() === address.toLowerCase(),
+ ) ?? null
+ );
+ } catch (error) {
+ throw new StateError(error);
+ }
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts
new file mode 100644
index 00000000..d7d13e43
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts
@@ -0,0 +1,32 @@
+import { type Json } from '@metamask/snaps-sdk';
+import type { Buffer } from 'buffer';
+
+export type IAccountSigner = {
+ sign(hash: Buffer): Promise;
+ derivePath(path: string): IAccountSigner;
+ verify(hash: Buffer, signature: Buffer): boolean;
+ publicKey: Buffer;
+ fingerprint: Buffer;
+};
+
+export type IAccount = {
+ mfp: string;
+ index: number;
+ address: string;
+ hdPath: string;
+ pubkey: string;
+ type: string;
+ signer: IAccountSigner;
+};
+
+export type IAccountMgr = {
+ unlock(index: number): Promise;
+};
+
+export type CreateAccountOptions = Record & {
+ index: number;
+};
+
+export type KeyringOptions = Record & {
+ defaultIndex: number;
+};
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/logger/logger.ts b/merged-packages/bitcoin-wallet-snap/src/modules/logger/logger.ts
new file mode 100644
index 00000000..1a35a37f
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/logger/logger.ts
@@ -0,0 +1,88 @@
+// ERROR, WARN, INFO, DEBUG, TRACE, ALL, and OF
+export enum LogLevel {
+ ERROR = 1,
+ WARN = 2,
+ INFO = 3,
+ DEBUG = 4,
+ TRACE = 5,
+ ALL = 6,
+ OFF = 0,
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
+export type LoggingFn = (message?: any, ...optionalParams: any[]) => void;
+
+export type ILogger = {
+ log: LoggingFn;
+ warn: LoggingFn;
+ error: LoggingFn;
+ debug: LoggingFn;
+ info: LoggingFn;
+ trace: LoggingFn;
+ init: () => void;
+ logLevel: LogLevel;
+};
+
+export const emptyLog: LoggingFn = (
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
+ message?: any,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
+ ...optionalParams: any[]
+) =>
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ {};
+
+class Logger implements ILogger {
+ log: LoggingFn;
+
+ warn: LoggingFn;
+
+ error: LoggingFn;
+
+ debug: LoggingFn;
+
+ info: LoggingFn;
+
+ trace: LoggingFn;
+
+ #logLevel: LogLevel = LogLevel.OFF;
+
+ set logLevel(level: LogLevel) {
+ this.#logLevel = level;
+ this.init();
+ }
+
+ get logLevel(): LogLevel {
+ return this.#logLevel;
+ }
+
+ init(): void {
+ this.error = console.error.bind(console);
+ this.warn = console.warn.bind(console);
+ this.info = console.info.bind(console);
+ this.debug = console.debug.bind(console);
+ this.trace = console.trace.bind(console);
+ this.log = console.log.bind(console);
+
+ if (this._logLevel < LogLevel.ERROR) {
+ this.error = emptyLog;
+ }
+ if (this._logLevel < LogLevel.WARN) {
+ this.warn = emptyLog;
+ }
+ if (this._logLevel < LogLevel.INFO) {
+ this.info = emptyLog;
+ }
+ if (this._logLevel < LogLevel.DEBUG) {
+ this.debug = emptyLog;
+ }
+ if (this._logLevel < LogLevel.TRACE) {
+ this.trace = emptyLog;
+ }
+ if (this._logLevel < LogLevel.ALL) {
+ this.log = emptyLog;
+ }
+ }
+}
+
+export const logger = new Logger();
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/snap/exceptions.ts
new file mode 100644
index 00000000..276aed22
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/exceptions.ts
@@ -0,0 +1,3 @@
+import { CustomError } from '../exceptions';
+
+export class StateError extends CustomError {}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.ts
new file mode 100644
index 00000000..a999e71a
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.ts
@@ -0,0 +1,85 @@
+import type { BIP44AddressKeyDeriver } from '@metamask/key-tree';
+import {
+ getBIP44AddressKeyDeriver,
+ type SLIP10NodeInterface,
+} from '@metamask/key-tree';
+import type { DialogResult, Json } from '@metamask/snaps-sdk';
+import {
+ heading,
+ panel,
+ text,
+ divider,
+ type SnapsProvider,
+} from '@metamask/snaps-sdk';
+
+declare const snap: SnapsProvider;
+
+export class SnapHelper {
+ static wallet: SnapsProvider = snap;
+
+ static async getBip44Deriver(
+ coinType: number,
+ ): Promise {
+ const bip44Node = await SnapHelper.wallet.request({
+ method: 'snap_getBip44Entropy',
+ params: {
+ coinType,
+ },
+ });
+ return getBIP44AddressKeyDeriver(bip44Node);
+ }
+
+ static async getBip32Deriver(
+ path: string[],
+ curve: 'secp256k1' | 'ed25519',
+ ): Promise {
+ const node = await SnapHelper.wallet.request({
+ method: 'snap_getBip32Entropy',
+ params: {
+ path,
+ curve,
+ },
+ });
+ return node as SLIP10NodeInterface;
+ }
+
+ static async confirmDialog(
+ header: string,
+ subHeader: string,
+ body: Record,
+ ): Promise {
+ return SnapHelper.wallet.request({
+ method: 'snap_dialog',
+ params: {
+ type: 'confirmation',
+ content: panel([
+ heading(header),
+ text(subHeader),
+ divider(),
+ ...Object.entries(body).map(([key, value]) =>
+ text(`**${key}**:\n ${value}`),
+ ),
+ ]),
+ },
+ });
+ }
+
+ static async getStateData(): Promise {
+ return (await SnapHelper.wallet.request({
+ method: 'snap_manageState',
+ params: {
+ operation: 'get',
+ },
+ })) as unknown as State;
+ }
+
+ static async setStateData(data: State) {
+ await SnapHelper.wallet.request({
+ method: 'snap_manageState',
+ params: {
+ operation: 'update',
+ newState: data as Record,
+ },
+ });
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/snap/index.ts
new file mode 100644
index 00000000..6e76333f
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/index.ts
@@ -0,0 +1,4 @@
+export * from './helpers';
+export * from './state';
+export * from './lock';
+export * from './exceptions';
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/lock.ts b/merged-packages/bitcoin-wallet-snap/src/modules/snap/lock.ts
new file mode 100644
index 00000000..b4b0e4fb
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/lock.ts
@@ -0,0 +1,12 @@
+import { Mutex } from 'async-mutex';
+
+const saveMutex = new Mutex();
+
+export class MutexLock {
+ static acquire(create = false) {
+ if (create) {
+ return new Mutex();
+ }
+ return saveMutex;
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts b/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts
new file mode 100644
index 00000000..1b463247
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts
@@ -0,0 +1,30 @@
+import type { Mutex } from 'async-mutex';
+
+import { SnapHelper } from './helpers';
+import { MutexLock } from './lock';
+
+export abstract class SnapStateManager {
+ protected readonly mtx: Mutex;
+
+ constructor(createLock = false) {
+ this.mtx = MutexLock.acquire(createLock);
+ }
+
+ protected async get(): Promise {
+ return SnapHelper.getStateData();
+ }
+
+ protected async set(state: State): Promise {
+ return SnapHelper.setStateData(state);
+ }
+
+ protected async update(
+ update: (state: State) => Promise,
+ ): Promise {
+ return this.mtx.runExclusive(async () => {
+ const state = await this.get();
+ await update(state);
+ await this.set(state);
+ });
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/exceptions.ts
new file mode 100644
index 00000000..6b250ff1
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/exceptions.ts
@@ -0,0 +1,3 @@
+import { CustomError } from '../exceptions';
+
+export class TransactionServiceError extends CustomError {}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/index.ts
new file mode 100644
index 00000000..fb3a6883
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/index.ts
@@ -0,0 +1,4 @@
+export * from './transaction';
+export * from './state';
+export * from './types';
+export * from './exceptions';
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/state.ts b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/state.ts
new file mode 100644
index 00000000..05387d77
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/state.ts
@@ -0,0 +1,4 @@
+import type { SnapState } from '../../types/state';
+import { SnapStateManager } from '../snap';
+
+export class TransactionStateManager extends SnapStateManager {}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.ts b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.ts
new file mode 100644
index 00000000..8e9e7d58
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.ts
@@ -0,0 +1,26 @@
+import { TransactionServiceError } from './exceptions';
+import type { TransactionStateManager } from './state';
+import type { Balance, ITransactionMgr } from './types';
+
+export class TransactionService {
+ protected readonly transactionMgr: ITransactionMgr;
+
+ protected readonly transactionStateManager: TransactionStateManager;
+
+ constructor(
+ transactionMgr: ITransactionMgr,
+ transactionStateManager: TransactionStateManager,
+ ) {
+ this.transactionMgr = transactionMgr;
+ this.transactionStateManager = transactionStateManager;
+ }
+
+ async getBalance(address: string): Promise {
+ try {
+ const result = await this.transactionMgr.getBalance(address);
+ return result;
+ } catch (error) {
+ throw new TransactionServiceError(error);
+ }
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/types.ts
new file mode 100644
index 00000000..69caaf69
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/types.ts
@@ -0,0 +1,9 @@
+export type Balance = {
+ confirmed: number;
+ unconfirmed: number;
+ total: number;
+};
+
+export type ITransactionMgr = {
+ getBalance(address: string): Promise;
+};
diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts
new file mode 100644
index 00000000..06d63bb4
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts
@@ -0,0 +1,67 @@
+import { type Struct, assert } from 'superstruct';
+
+import { logger } from '../modules/logger/logger';
+import { SnapRpcRequestValidationError } from './exceptions';
+import type {
+ ISnapRpcValidator,
+ ISnapRpcExecutable,
+ SnapRpcRequestHandlerOptions,
+ ISnapRpcRequestHandler,
+ IStaticSnapRpcRequestHandler,
+ SnapRpcRequestHandlerResponse,
+ SnapRpcRequestHandlerRequest,
+} from './types';
+
+export abstract class BaseSnapRpcRequestHandler
+ implements ISnapRpcValidator, ISnapRpcExecutable
+{
+ static instance: ISnapRpcRequestHandler | null = null;
+
+ static validateStruct: Struct;
+
+ abstract handleRequest(
+ params: SnapRpcRequestHandlerRequest,
+ ): Promise;
+
+ abstract validateStruct: Struct;
+
+ async validate(params: SnapRpcRequestHandlerRequest): Promise {
+ assert(params, this.validateStruct);
+ }
+
+ async preExecute(params: SnapRpcRequestHandlerRequest): Promise {
+ logger.info(`Request: ${JSON.stringify(params)}`);
+ try {
+ await this.validate(params);
+ } catch (error) {
+ throw new SnapRpcRequestValidationError(error);
+ }
+ }
+
+ async postExecute(response: SnapRpcRequestHandlerResponse): Promise {
+ logger.info(`Response: ${JSON.stringify(response)}`);
+ }
+
+ async execute(
+ params: SnapRpcRequestHandlerRequest,
+ ): Promise {
+ try {
+ await this.preExecute(params);
+ const result = await this.handleRequest(params);
+ await this.postExecute(result);
+ return result;
+ } catch (error) {
+ throw new SnapRpcRequestValidationError(error);
+ }
+ }
+
+ static getInstance(
+ this: IStaticSnapRpcRequestHandler,
+ options?: SnapRpcRequestHandlerOptions,
+ ): ISnapRpcRequestHandler {
+ if (this.instance === null) {
+ this.instance = new this(options);
+ }
+ return this.instance;
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/exceptions.ts
new file mode 100644
index 00000000..e33ea153
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/exceptions.ts
@@ -0,0 +1,3 @@
+import { CustomError } from '../modules/exceptions';
+
+export class SnapRpcRequestValidationError extends CustomError {}
diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts
new file mode 100644
index 00000000..57dca8dd
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts
@@ -0,0 +1,4 @@
+export * from './methods/create-account';
+export * from './types';
+export * from './base';
+export * from './exceptions';
diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts
new file mode 100644
index 00000000..d69b77c7
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts
@@ -0,0 +1,49 @@
+import type { KeyringAccount } from '@metamask/keyring-api';
+import type { Infer } from 'superstruct';
+import { object, number, assign } from 'superstruct';
+
+import { Chain } from '../../modules/config';
+import { Factory } from '../../modules/factory';
+import type { StaticImplements } from '../../types/static';
+import { BaseSnapRpcRequestHandler } from '../base';
+import type {
+ IStaticSnapRpcRequestHandler,
+ SnapRpcRequestHandlerResponse,
+} from '../types';
+import { SnapRpcRequestHandlerRequestStruct } from '../types';
+
+export type CreateAccountParams = Infer<
+ typeof CreateAccountHandler.validateStruct
+>;
+
+export type CreateAccountResponse = SnapRpcRequestHandlerResponse &
+ KeyringAccount;
+
+export class CreateAccountHandler
+ extends BaseSnapRpcRequestHandler
+ implements
+ StaticImplements
+{
+ static get validateStruct() {
+ return assign(
+ object({
+ index: number(),
+ }),
+ SnapRpcRequestHandlerRequestStruct,
+ );
+ }
+
+ validateStruct = CreateAccountHandler.validateStruct;
+
+ async handleRequest(
+ params: CreateAccountParams,
+ ): Promise {
+ const keyring = Factory.createKeyring(Chain.Bitcoin, params.scope);
+
+ const account = await keyring.createAccount({
+ index: params.index,
+ });
+
+ return account;
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balance.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balance.ts
new file mode 100644
index 00000000..26c4397f
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balance.ts
@@ -0,0 +1,49 @@
+import type { Infer } from 'superstruct';
+import { object, string, assign } from 'superstruct';
+
+import { Chain } from '../../modules/config';
+import { Factory } from '../../modules/factory';
+import type { Balance } from '../../modules/transaction';
+import {
+ TransactionService,
+ TransactionStateManager,
+} from '../../modules/transaction';
+import type { StaticImplements } from '../../types/static';
+import { BaseSnapRpcRequestHandler } from '../base';
+import type {
+ IStaticSnapRpcRequestHandler,
+ SnapRpcRequestHandlerResponse,
+} from '../types';
+import { SnapRpcRequestHandlerRequestStruct } from '../types';
+
+export type GetBalanceParams = Infer;
+
+export type GetBalanceResponse = SnapRpcRequestHandlerResponse & Balance;
+
+export class GetBalanceHandler
+ extends BaseSnapRpcRequestHandler
+ implements
+ StaticImplements
+{
+ static get validateStruct() {
+ return assign(
+ object({
+ address: string(),
+ }),
+ SnapRpcRequestHandlerRequestStruct,
+ );
+ }
+
+ validateStruct = GetBalanceHandler.validateStruct;
+
+ async handleRequest(params: GetBalanceParams): Promise {
+ const { scope, address } = params;
+
+ const txService = new TransactionService(
+ Factory.createTransactionMgr(Chain.Bitcoin, scope),
+ new TransactionStateManager(),
+ );
+
+ return await txService.getBalance(address);
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts
new file mode 100644
index 00000000..89e589f0
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts
@@ -0,0 +1,43 @@
+import type { Json } from '@metamask/snaps-sdk';
+import type { Infer } from 'superstruct';
+import { enums, object, type Struct } from 'superstruct';
+
+import { Chain, Config } from '../modules/config';
+
+export const SnapRpcRequestHandlerRequestStruct = object({
+ scope: enums(Config.avaliableNetworks[Chain.Bitcoin]),
+});
+
+export type SnapRpcRequestHandlerRequest = Infer<
+ typeof SnapRpcRequestHandlerRequestStruct
+>;
+
+export type SnapRpcRequestHandlerResponse = Json;
+
+export type SnapRpcRequestHandlerOptions = Json | null;
+
+export type IStaticSnapRpcRequestHandler = {
+ validateStruct: Struct;
+ instance: ISnapRpcRequestHandler | null;
+ new (options?: SnapRpcRequestHandlerOptions): ISnapRpcRequestHandler;
+ getInstance(
+ this: IStaticSnapRpcRequestHandler,
+ options?: SnapRpcRequestHandlerOptions,
+ ): ISnapRpcRequestHandler;
+};
+
+export type ISnapRpcValidator = {
+ validate(params: SnapRpcRequestHandlerRequest): void;
+};
+
+export type ISnapRpcExecutable = {
+ execute(
+ params: SnapRpcRequestHandlerRequest,
+ ): Promise;
+};
+
+export type ISnapRpcRequestHandler = {
+ handleRequest(
+ params: SnapRpcRequestHandlerRequest,
+ ): Promise;
+} & ISnapRpcExecutable;
diff --git a/merged-packages/bitcoin-wallet-snap/src/types/state.ts b/merged-packages/bitcoin-wallet-snap/src/types/state.ts
new file mode 100644
index 00000000..8b10beb9
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/types/state.ts
@@ -0,0 +1,6 @@
+import type { KeyringAccount } from '@metamask/keyring-api';
+
+export type SnapState = {
+ accounts: string[];
+ accountDetails: Record;
+};
diff --git a/merged-packages/bitcoin-wallet-snap/src/types/static.ts b/merged-packages/bitcoin-wallet-snap/src/types/static.ts
new file mode 100644
index 00000000..48869c07
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/types/static.ts
@@ -0,0 +1,6 @@
+export type StaticImplements<
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ Inter extends new (...args: any[]) => any,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ Cls extends Inter,
+> = InstanceType;
diff --git a/merged-packages/bitcoin-wallet-snap/tsconfig.json b/merged-packages/bitcoin-wallet-snap/tsconfig.json
new file mode 100644
index 00000000..0fbd5d41
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
+ "module": "commonjs" /* Specify what module code is generated. */,
+ "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
+ "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
+ "skipLibCheck": true /* Skip type checking all .d.ts files. */,
+ "resolveJsonModule": true /* lets us import JSON modules from within TypeScript modules. */,
+ "strictNullChecks": true /* Enable strict null checks. */
+ },
+ "include": ["**/*.ts"]
+}
From e9ce99c4e5b100a30ed16de50b69af7b102f08c0 Mon Sep 17 00:00:00 2001
From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com>
Date: Thu, 18 Apr 2024 17:03:41 +0800
Subject: [PATCH 002/362] feat: add CI for lint and test (#2)
* feat: add CI for lint and test
* chore: add user story template
* chore: pass with no test
---
.../bitcoin-wallet-snap/package.json | 2 +-
.../bitcoin-wallet-snap/snap.manifest.json | 24 ++++---------------
2 files changed, 5 insertions(+), 21 deletions(-)
diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json
index 123b07a0..a0e24ddd 100644
--- a/merged-packages/bitcoin-wallet-snap/package.json
+++ b/merged-packages/bitcoin-wallet-snap/package.json
@@ -24,7 +24,7 @@
"prepublishOnly": "mm-snap manifest",
"serve": "mm-snap serve",
"start": "mm-snap watch",
- "test": "jest"
+ "test": "jest --passWithNoTests"
},
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.1.1",
diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json
index d0f1cb5a..964ddc0c 100644
--- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json
+++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json
@@ -25,35 +25,19 @@
},
"snap_getBip32Entropy": [
{
- "path": [
- "m",
- "49'",
- "0'"
- ],
+ "path": ["m", "49'", "0'"],
"curve": "secp256k1"
},
{
- "path": [
- "m",
- "49'",
- "1'"
- ],
+ "path": ["m", "49'", "1'"],
"curve": "secp256k1"
},
{
- "path": [
- "m",
- "84'",
- "0'"
- ],
+ "path": ["m", "84'", "0'"],
"curve": "secp256k1"
},
{
- "path": [
- "m",
- "84'",
- "1'"
- ],
+ "path": ["m", "84'", "1'"],
"curve": "secp256k1"
}
],
From 67df7ef388fc1f8a886e530214cccd55a18b722f Mon Sep 17 00:00:00 2001
From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com>
Date: Thu, 18 Apr 2024 17:05:51 +0800
Subject: [PATCH 003/362] feat: add snap unit test (#1)
* feat: add unit test for modules snap, logger, keyring
* chore: add unit for modules keyring and bitcoin
* chore: update jest config
---
.../bitcoin-wallet-snap/jest.config.js | 23 ++
.../bitcoin-wallet-snap/jest.setup.ts | 7 +
.../modules/bitcoin/account/account.test.ts | 91 +++++
.../modules/bitcoin/account/deriver.test.ts | 256 ++++++++++++++
.../modules/bitcoin/account/factory.test.ts | 86 +++++
.../modules/bitcoin/account/helpers.test.ts | 115 ++++++
.../modules/bitcoin/account/manager.test.ts | 48 +++
.../src/modules/bitcoin/account/manager.ts | 14 +-
.../modules/bitcoin/account/signer.test.ts | 69 ++++
.../src/modules/bitcoin/config/types.ts | 3 -
.../data-client/clients/blockstream.test.ts | 95 +++++
.../data-client/clients/blockstream.ts | 15 +-
.../bitcoin/data-client/factory.test.ts | 27 ++
.../modules/bitcoin/network/helpers.test.ts | 24 ++
.../modules/bitcoin/transaction/exceptions.ts | 2 +-
.../bitcoin/transaction/manager.test.ts | 50 +++
.../modules/bitcoin/transaction/manager.ts | 4 +-
.../src/modules/config/index.ts | 3 -
.../src/modules/factory.test.ts | 35 ++
.../src/modules/keyring/keyring.test.ts | 329 ++++++++++++++++++
.../src/modules/keyring/keyring.ts | 2 +-
.../src/modules/keyring/state.test.ts | 243 +++++++++++++
.../src/modules/logger/logger.test.ts | 78 +++++
.../src/modules/logger/logger.ts | 14 +-
.../src/modules/snap/helpers.test.ts | 132 +++++++
.../src/modules/snap/helpers.ts | 2 +-
.../src/modules/snap/lock.test.ts | 24 ++
.../src/modules/snap/state.test.ts | 101 ++++++
.../modules/transaction/transaction.test.ts | 49 +++
.../test/fixtures/blockstream.json | 19 +
.../bitcoin-wallet-snap/test/utils.ts | 146 ++++++++
.../test/wallet.mock.test.ts | 41 +++
32 files changed, 2117 insertions(+), 30 deletions(-)
create mode 100644 merged-packages/bitcoin-wallet-snap/jest.setup.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/deriver.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/factory.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/helpers.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/signer.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/factory.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/helpers.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/manager.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/logger/logger.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/snap/lock.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/snap/state.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.test.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json
create mode 100644 merged-packages/bitcoin-wallet-snap/test/utils.ts
create mode 100644 merged-packages/bitcoin-wallet-snap/test/wallet.mock.test.ts
diff --git a/merged-packages/bitcoin-wallet-snap/jest.config.js b/merged-packages/bitcoin-wallet-snap/jest.config.js
index f0a22c3e..b7fb6b70 100644
--- a/merged-packages/bitcoin-wallet-snap/jest.config.js
+++ b/merged-packages/bitcoin-wallet-snap/jest.config.js
@@ -3,4 +3,27 @@ module.exports = {
transform: {
'^.+\\.(t|j)sx?$': 'ts-jest',
},
+ setupFilesAfterEnv: ['/jest.setup.ts'],
+ restoreMocks: true,
+ resetMocks: true,
+ verbose: true,
+ testMatch: ['**/src/**/?(*.)+(spec|test).[tj]s?(x)'],
+ collectCoverage: true,
+ // An array of glob patterns indicating a set of files for which coverage information should be collected
+ collectCoverageFrom: [
+ './src/**/*.ts',
+ '!./src/**/*.d.ts',
+ '!./src/**/index.ts',
+ '!./src/**/type?(s).ts',
+ '!./src/**/exception?(s).ts',
+ '!./test/**',
+ './src/index.ts',
+ ],
+ // The directory where Jest should output its coverage files
+ coverageDirectory: 'coverage',
+ // Indicates which provider should be used to instrument code for coverage
+ coverageProvider: 'babel',
+
+ // A list of reporter names that Jest uses when writing coverage reports
+ coverageReporters: ['html', 'json-summary', 'text'],
};
diff --git a/merged-packages/bitcoin-wallet-snap/jest.setup.ts b/merged-packages/bitcoin-wallet-snap/jest.setup.ts
new file mode 100644
index 00000000..ec031f6e
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/jest.setup.ts
@@ -0,0 +1,7 @@
+import { WalletMock } from './test/wallet.mock.test';
+
+// eslint-disable-next-line no-restricted-globals
+const globalAny: any = global;
+
+globalAny.snap = new WalletMock();
+globalAny.fetch = jest.fn();
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.test.ts
new file mode 100644
index 00000000..0ffd160c
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.test.ts
@@ -0,0 +1,91 @@
+import type { Network, Payment } from 'bitcoinjs-lib';
+import { networks } from 'bitcoinjs-lib';
+import { Buffer } from 'buffer';
+
+import { P2WPKHAccount } from './account';
+import { ScriptType } from './constants';
+import { AddressHelper } from './helpers';
+import type { IAccountSigner } from './types';
+
+describe('BtcAccount', () => {
+ const createMockPaymentInstance = (address: string | undefined) => {
+ const getPaymentSpy = jest.spyOn(AddressHelper, 'getPayment');
+ getPaymentSpy.mockReturnValue({
+ address,
+ } as unknown as Payment);
+ return {
+ getPaymentSpy,
+ };
+ };
+
+ const createMockAccount = async (network: Network) => {
+ const signerSpy = jest.fn();
+ const index = 0;
+ const hdPath = [`m`, `0'`, `0`, `${index}`].join('/');
+
+ const instance = new P2WPKHAccount(
+ 'ddddddddddddd',
+ index,
+ hdPath,
+ 'ddddddddddddd',
+ network,
+ ScriptType.P2wpkh,
+ { sign: signerSpy } as unknown as IAccountSigner,
+ );
+
+ return {
+ instance,
+ signerSpy,
+ };
+ };
+
+ describe('address', () => {
+ it('returns an address', async () => {
+ const network = networks.testnet;
+ const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu';
+ const { getPaymentSpy } = createMockPaymentInstance(address);
+ const { instance } = await createMockAccount(network);
+
+ expect(instance.address).toStrictEqual(address);
+ expect(getPaymentSpy).toHaveBeenCalledWith(
+ ScriptType.P2wpkh,
+ Buffer.from(instance.pubkey, 'hex'),
+ network,
+ );
+ });
+
+ it('returns an address if it exists', async () => {
+ const network = networks.testnet;
+ const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu';
+ const { getPaymentSpy } = createMockPaymentInstance(address);
+ const { instance } = await createMockAccount(network);
+
+ let instanceAddress = instance.address;
+
+ expect(instanceAddress).toStrictEqual(address);
+
+ instanceAddress = instance.address;
+ expect(getPaymentSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('throws error if the payment address is undefined', async () => {
+ const network = networks.testnet;
+ createMockPaymentInstance(undefined);
+ const { instance } = await createMockAccount(network);
+
+ expect(() => instance.address).toThrow('Payment address is missing');
+ });
+ });
+
+ describe('sign', () => {
+ it('signs a message with signer', async () => {
+ const network = networks.testnet;
+ const { instance, signerSpy } = await createMockAccount(network);
+
+ const message = Buffer.from('test');
+ await instance.sign(message);
+
+ expect(signerSpy).toHaveBeenCalledWith(message);
+ });
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/deriver.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/deriver.test.ts
new file mode 100644
index 00000000..219a08a6
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/deriver.test.ts
@@ -0,0 +1,256 @@
+import {
+ type BIP44AddressKeyDeriver,
+ type SLIP10NodeInterface,
+} from '@metamask/key-tree';
+import * as bip32 from 'bip32';
+import { networks } from 'bitcoinjs-lib';
+import { Buffer } from 'buffer';
+
+import { createMockBip32Instance } from '../../../../test/utils';
+import { SnapHelper } from '../../snap';
+import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver';
+import { DeriverError } from './exceptions';
+
+jest.mock('bip32', () => {
+ return {
+ BIP32Factory: jest.fn(),
+ };
+});
+
+const createMockBip32Factory = () => {
+ const fromSeedSpy = jest.fn();
+ const fromBase58Spy = jest.fn();
+ const fromPrivateKeySpy = jest.fn();
+ const fromPublicKeySpy = jest.fn();
+
+ jest.spyOn(bip32, 'BIP32Factory').mockImplementation(() => {
+ return {
+ fromSeed: fromSeedSpy,
+ fromBase58: fromBase58Spy,
+ fromPrivateKey: fromPrivateKeySpy,
+ fromPublicKey: fromPublicKeySpy,
+ };
+ });
+ return {
+ fromSeedSpy,
+ fromBase58Spy,
+ fromPrivateKeySpy,
+ fromPublicKeySpy,
+ };
+};
+
+describe('BtcAccountBip32Deriver', () => {
+ const createMockBip32Entropy = () => {
+ const getBip32DeriverSpy = jest.spyOn(SnapHelper, 'getBip32Deriver');
+ const node = {
+ privateKey: 'dddddddd',
+ chainCode: 'dddddddd',
+ publicKey: 'dddddddd',
+ index: 0,
+ depth: 0,
+ parentFingerprint: 0,
+ curve: 'secp256k1',
+ chainCodeBytes: Buffer.from('dddddddd', 'hex'),
+ publicKeyBytes: Buffer.from('dddddddd', 'hex'),
+ toJSON: jest.fn(),
+ };
+ getBip32DeriverSpy.mockResolvedValue(
+ node as unknown as SLIP10NodeInterface,
+ );
+
+ return {
+ getBip32DeriverSpy,
+ node,
+ };
+ };
+
+ describe('fromSeed', () => {
+ it('returns an BIP32Interface', () => {
+ const network = networks.testnet;
+ const { fromSeedSpy } = createMockBip32Factory();
+ const seed = Buffer.from(
+ 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
+ 'hex',
+ );
+
+ const deriver = new BtcAccountBip32Deriver(network);
+ deriver.fromSeed(seed);
+
+ expect(fromSeedSpy).toHaveBeenCalledWith(seed, network);
+ });
+ });
+
+ describe('fromPrivateKey', () => {
+ it('returns an BIP32Interface', () => {
+ const network = networks.testnet;
+ const { fromPrivateKeySpy } = createMockBip32Factory();
+ const privateKey = Buffer.from(
+ 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
+ 'hex',
+ );
+ const chainCode = Buffer.from(
+ 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
+ 'hex',
+ );
+
+ const deriver = new BtcAccountBip32Deriver(network);
+ deriver.fromPrivateKey(privateKey, chainCode);
+
+ expect(fromPrivateKeySpy).toHaveBeenCalledWith(
+ privateKey,
+ chainCode,
+ network,
+ );
+ });
+ });
+
+ describe('getChild', () => {
+ it('returns an BIP32Interface', async () => {
+ const network = networks.testnet;
+ const {
+ instance: node,
+ deriveHardenedSpy,
+ deriveSpy,
+ } = createMockBip32Instance(network);
+ const idx = 0;
+
+ const deriver = new BtcAccountBip32Deriver(network);
+ await deriver.getChild(node, idx);
+
+ expect(deriveHardenedSpy).toHaveBeenCalledWith(0);
+ expect(deriveSpy).toHaveBeenNthCalledWith(1, 0);
+ expect(deriveSpy).toHaveBeenNthCalledWith(2, idx);
+ expect(deriveSpy).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('getRoot', () => {
+ it('returns an BIP32Interface', async () => {
+ const network = networks.testnet;
+ const { getBip32DeriverSpy, node } = createMockBip32Entropy();
+ const { fromPrivateKeySpy } = createMockBip32Factory();
+ fromPrivateKeySpy.mockReturnValue(node);
+ const path = ['m', "84'", "0'"];
+ const curve = 'secp256k1';
+
+ const deriver = new BtcAccountBip32Deriver(network);
+ const root = await deriver.getRoot(path);
+
+ expect(getBip32DeriverSpy).toHaveBeenCalledWith(path, curve);
+ expect(fromPrivateKeySpy).toHaveBeenCalledWith(
+ Buffer.from(node.privateKey, 'hex'),
+ Buffer.from(node.chainCode, 'hex'),
+ network,
+ );
+ expect(root).toStrictEqual(node);
+ });
+
+ it('throws DeriverError if private key is missing', async () => {
+ const network = networks.testnet;
+ const path = ['m', "84'", "0'"];
+ const { getBip32DeriverSpy } = createMockBip32Entropy();
+
+ getBip32DeriverSpy.mockResolvedValue({
+ privateKey: undefined,
+ chainCode: 'dddddddd',
+ publicKey: 'dddddddd',
+ index: 0,
+ depth: 0,
+ parentFingerprint: 0,
+ curve: 'secp256k1',
+ chainCodeBytes: Buffer.from('dddddddd', 'hex'),
+ publicKeyBytes: Buffer.from('dddddddd', 'hex'),
+ toJSON: jest.fn(),
+ });
+
+ const deriver = new BtcAccountBip32Deriver(network);
+
+ await expect(deriver.getRoot(path)).rejects.toThrow(
+ 'Deriver private key is missing',
+ );
+ });
+
+ it('throws DeriverError if an error catched', async () => {
+ const network = networks.testnet;
+ const path = ['m', "84'", "0'"];
+ const { getBip32DeriverSpy } = createMockBip32Entropy();
+ getBip32DeriverSpy.mockRejectedValue(new Error('error'));
+
+ const deriver = new BtcAccountBip32Deriver(network);
+
+ await expect(deriver.getRoot(path)).rejects.toThrow(DeriverError);
+ });
+ });
+});
+
+describe('BtcAccountBip44Deriver', () => {
+ const createMockBip44Entropy = () => {
+ const getBip44DeriverSpy = jest.spyOn(SnapHelper, 'getBip44Deriver');
+ const deriverSpy = jest.fn();
+
+ getBip44DeriverSpy.mockResolvedValue(
+ deriverSpy as unknown as BIP44AddressKeyDeriver,
+ );
+
+ return {
+ deriverSpy,
+ getBip44DeriverSpy,
+ };
+ };
+
+ describe('getRoot', () => {
+ it('returns an BIP32Interface', async () => {
+ const network = networks.testnet;
+ const path = ['m', "84'", "0'"];
+ const privateKey = 'dddddddd';
+ const { instance, deriveHardenedSpy } = createMockBip32Instance(network);
+ const { fromSeedSpy } = createMockBip32Factory();
+ const { getBip44DeriverSpy, deriverSpy } = createMockBip44Entropy();
+ fromSeedSpy.mockReturnValue(instance);
+ deriverSpy.mockResolvedValue({
+ privateKey,
+ });
+
+ const deriver = new BtcAccountBip44Deriver(network);
+ await deriver.getRoot(path);
+
+ expect(getBip44DeriverSpy).toHaveBeenCalledWith(0);
+ expect(fromSeedSpy).toHaveBeenCalledWith(
+ Buffer.from(privateKey, 'hex'),
+ network,
+ );
+ expect(deriveHardenedSpy).toHaveBeenNthCalledWith(
+ 1,
+ parseInt(path[1].slice(0, -1), 10),
+ );
+ expect(deriveHardenedSpy).toHaveBeenNthCalledWith(2, 0);
+ expect(deriveHardenedSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('throws DeriverError if the private key is missing', async () => {
+ const network = networks.testnet;
+ const path = ['m', "84'", "0'"];
+ const { deriverSpy } = createMockBip44Entropy();
+ deriverSpy.mockResolvedValue({
+ privateKey: undefined,
+ });
+
+ const deriver = new BtcAccountBip44Deriver(network);
+
+ await expect(deriver.getRoot(path)).rejects.toThrow(
+ 'Deriver private key is missing',
+ );
+ });
+
+ it('throws DeriverError if an error catched', async () => {
+ const network = networks.testnet;
+ const path = ['m', "84'", "0'"];
+ const { deriverSpy } = createMockBip44Entropy();
+ deriverSpy.mockRejectedValue(new Error('error'));
+
+ const deriver = new BtcAccountBip44Deriver(network);
+
+ await expect(deriver.getRoot(path)).rejects.toThrow(DeriverError);
+ });
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/factory.test.ts
new file mode 100644
index 00000000..bbefd1eb
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/factory.test.ts
@@ -0,0 +1,86 @@
+import type { Network } from 'bitcoinjs-lib';
+import { networks } from 'bitcoinjs-lib';
+
+import { ScriptType } from './constants';
+import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver';
+import { BtcAccountMgrFactory } from './factory';
+import * as manager from './manager';
+import type { IBtcAccountDeriver, IStaticBtcAccount } from './types';
+
+describe('BtcAccountMgrFactory', () => {
+ class MockBtcAccountMgr extends manager.BtcAccountMgr {
+ getDeriver() {
+ return this.deriver;
+ }
+
+ getAccountCtor() {
+ return this.accountCtor;
+ }
+ }
+
+ const createMockBtcAccountMgr = () => {
+ const spy = jest
+ .spyOn(manager, 'BtcAccountMgr')
+ .mockImplementation(
+ (
+ deriver: IBtcAccountDeriver,
+ account: IStaticBtcAccount,
+ network: Network,
+ ) => {
+ return new MockBtcAccountMgr(deriver, account, network);
+ },
+ );
+ return {
+ spy,
+ };
+ };
+
+ describe('create', () => {
+ it('creates BtcAccountMgr instance with `BtcAccountBip32Deriver` and `P2WPKHAccount`', () => {
+ const { spy } = createMockBtcAccountMgr();
+
+ const instance = BtcAccountMgrFactory.create(
+ {
+ defaultAccountIndex: 0,
+ defaultAccountType: 'P2WPKH',
+ deriver: 'BIP32',
+ },
+ networks.testnet,
+ ) as unknown as MockBtcAccountMgr;
+
+ expect(spy).toHaveBeenCalled();
+ expect(instance.getDeriver()).toBeInstanceOf(BtcAccountBip32Deriver);
+ expect(instance.getAccountCtor().name).toBe('P2WPKHAccount');
+ });
+
+ it('creates BtcAccountMgr instance with `BtcAccountBip44Deriver` and `P2WPKHAccount`', () => {
+ const { spy } = createMockBtcAccountMgr();
+
+ const instance = BtcAccountMgrFactory.create(
+ {
+ defaultAccountIndex: 0,
+ defaultAccountType: ScriptType.P2wpkh,
+ deriver: 'BIP44',
+ },
+ networks.testnet,
+ ) as unknown as MockBtcAccountMgr;
+
+ expect(spy).toHaveBeenCalled();
+ expect(instance.getDeriver()).toBeInstanceOf(BtcAccountBip44Deriver);
+ expect(instance.getAccountCtor().name).toBe('P2WPKHAccount');
+ });
+
+ it('throws `Invalid script type` if the given account type is not supported', () => {
+ expect(() =>
+ BtcAccountMgrFactory.create(
+ {
+ defaultAccountIndex: 0,
+ defaultAccountType: ScriptType.P2shP2wkh,
+ deriver: 'BIP32',
+ },
+ networks.testnet,
+ ),
+ ).toThrow('Invalid script type');
+ });
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/helpers.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/helpers.test.ts
new file mode 100644
index 00000000..ccd1febd
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/helpers.test.ts
@@ -0,0 +1,115 @@
+import * as biplib from 'bitcoinjs-lib';
+import { Buffer } from 'buffer';
+
+import { ScriptType } from './constants';
+import { AddressHelper } from './helpers';
+
+jest.mock('bitcoinjs-lib', () => {
+ const actual = jest.requireActual('bitcoinjs-lib');
+ return {
+ ...actual,
+ payments: {
+ p2pkh: jest.fn(),
+ p2sh: jest.fn(),
+ p2wpkh: jest.fn(),
+ },
+ };
+});
+
+describe('AddressHelper', () => {
+ class MockPayment implements biplib.Payment {}
+
+ const createMockPayment = (type) => {
+ const spy = jest.spyOn(biplib.payments, type);
+ spy.mockReturnValue(new MockPayment());
+ return {
+ spy,
+ };
+ };
+
+ describe('getPayment', () => {
+ it('returns P2pkh payment instance', () => {
+ const { spy } = createMockPayment('p2pkh');
+ const pubkey = Buffer.from('pubkey', 'hex');
+
+ const result = AddressHelper.getPayment(
+ ScriptType.P2pkh,
+ pubkey,
+ biplib.networks.testnet,
+ );
+
+ expect(spy).toHaveBeenCalledWith({
+ pubkey,
+ network: biplib.networks.testnet,
+ });
+ expect(result).toBeInstanceOf(MockPayment);
+ });
+
+ it('returns P2shP2wkh payment instance', () => {
+ const { spy: p2shSpy } = createMockPayment('p2sh');
+ const { spy: p2wpkhSpy } = createMockPayment('p2wpkh');
+ const pubkey = Buffer.from('pubkey', 'hex');
+
+ const result = AddressHelper.getPayment(
+ ScriptType.P2shP2wkh,
+ pubkey,
+ biplib.networks.testnet,
+ );
+
+ expect(p2wpkhSpy).toHaveBeenCalledWith({
+ pubkey,
+ network: biplib.networks.testnet,
+ });
+ expect(p2shSpy).toHaveBeenCalledWith({
+ redeem: expect.any(MockPayment),
+ network: biplib.networks.testnet,
+ });
+ expect(result).toBeInstanceOf(MockPayment);
+ });
+
+ it('returns P2wpkh payment instance', () => {
+ const { spy } = createMockPayment('p2wpkh');
+ const pubkey = Buffer.from('pubkey', 'hex');
+
+ const result = AddressHelper.getPayment(
+ ScriptType.P2wpkh,
+ pubkey,
+ biplib.networks.testnet,
+ );
+
+ expect(spy).toHaveBeenCalledWith({
+ pubkey,
+ network: biplib.networks.testnet,
+ });
+ expect(result).toBeInstanceOf(MockPayment);
+ });
+
+ it('throws `Invalid script type` if the given type is not supported', () => {
+ const pubkey = Buffer.from('pubkey', 'hex');
+
+ expect(() =>
+ AddressHelper.getPayment(
+ 'sometype' as unknown as ScriptType,
+ pubkey,
+ biplib.networks.testnet,
+ ),
+ ).toThrow('Invalid script type');
+ });
+ });
+
+ describe('trimHexPrefix', () => {
+ it('trims hex prefix', () => {
+ const key = '0x1234';
+ const result = AddressHelper.trimHexPrefix(key);
+
+ expect(result).toBe('1234');
+ });
+
+ it('returns key as is if it does not have hex prefix', () => {
+ const key = '1234';
+ const result = AddressHelper.trimHexPrefix(key);
+
+ expect(result).toBe('1234');
+ });
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.test.ts
new file mode 100644
index 00000000..cd64729e
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.test.ts
@@ -0,0 +1,48 @@
+import { networks } from 'bitcoinjs-lib';
+
+import { createMockBip32Instance } from '../../../../test/utils';
+import { P2WPKHAccount } from './account';
+import { BtcAccountBip32Deriver } from './deriver';
+import { BtcAccountMgr } from './manager';
+
+describe('BtcAccountMgr', () => {
+ describe('unlock', () => {
+ it('returns an `Account` object', async () => {
+ const network = networks.testnet;
+ const rootSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getRoot');
+ const childSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getChild');
+ const idx = 0;
+ const { instance: rootNode } = createMockBip32Instance(network, idx);
+ const { instance: childNode } = createMockBip32Instance(network, idx, 3);
+
+ rootSpy.mockResolvedValue(rootNode);
+ childSpy.mockResolvedValue(childNode);
+
+ const instance = new BtcAccountMgr(
+ new BtcAccountBip32Deriver(network),
+ P2WPKHAccount,
+ network,
+ );
+
+ const result = await instance.unlock(idx);
+
+ expect(result).toBeInstanceOf(P2WPKHAccount);
+ expect(rootSpy).toHaveBeenCalledWith(P2WPKHAccount.path);
+ expect(childSpy).toHaveBeenCalledWith(rootNode, idx);
+ });
+
+ it('throws error if the account cannot be unlocked', async () => {
+ const network = networks.testnet;
+ const rootSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getRoot');
+ rootSpy.mockRejectedValue(new Error('Error'));
+
+ const instance = new BtcAccountMgr(
+ new BtcAccountBip32Deriver(network),
+ P2WPKHAccount,
+ network,
+ );
+
+ await expect(instance.unlock(0)).rejects.toThrow('Error');
+ });
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.ts
index 87995acc..7811689a 100644
--- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.ts
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.ts
@@ -9,36 +9,36 @@ import type { IStaticBtcAccount, IBtcAccountDeriver } from './types';
export class BtcAccountMgr implements IAccountMgr {
protected readonly deriver: IBtcAccountDeriver;
- protected readonly account: IStaticBtcAccount;
+ protected readonly accountCtor: IStaticBtcAccount;
protected readonly network: Network;
constructor(
deriver: IBtcAccountDeriver,
- account: IStaticBtcAccount,
+ accountCtor: IStaticBtcAccount,
network: Network,
) {
this.deriver = deriver;
- this.account = account;
+ this.accountCtor = accountCtor;
this.network = network;
}
async unlock(index: number): Promise {
try {
//eslint -disable-next-line @typescript-eslint/naming-convention
- const AccountContrustor = this.account;
+ const AccountCtor = this.accountCtor;
- const rootNode = await this.deriver.getRoot(AccountContrustor.path);
+ const rootNode = await this.deriver.getRoot(AccountCtor.path);
const childNode = await this.deriver.getChild(rootNode, index);
const hdPath = [`m`, `0'`, `0`, `${index}`].join('/');
- return new AccountContrustor(
+ return new AccountCtor(
rootNode.fingerprint.toString('hex'),
index,
hdPath,
childNode.publicKey.toString('hex'),
this.network,
- AccountContrustor.scriptType,
+ AccountCtor.scriptType,
this.getHdSigner(rootNode),
);
} catch (error) {
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/signer.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/signer.test.ts
new file mode 100644
index 00000000..80983f04
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/signer.test.ts
@@ -0,0 +1,69 @@
+import { networks } from 'bitcoinjs-lib';
+import { Buffer } from 'buffer';
+
+import { createMockBip32Instance } from '../../../../test/utils';
+import { AccountSigner } from './signer';
+
+describe('AccountSigner', () => {
+ describe('derivePath', () => {
+ it('returns an `AccountSigner` object', async () => {
+ const network = networks.testnet;
+ const {
+ instance: node,
+ deriveHardenedSpy,
+ deriveSpy,
+ } = createMockBip32Instance(network);
+
+ const instance = new AccountSigner(node);
+
+ const signer = instance.derivePath("m/0'/0/1");
+
+ expect(deriveHardenedSpy).toHaveBeenCalledWith(0);
+ expect(deriveSpy).toHaveBeenNthCalledWith(1, 0);
+ expect(deriveSpy).toHaveBeenNthCalledWith(2, 1);
+ expect(deriveHardenedSpy).toHaveBeenCalledTimes(1);
+ expect(deriveSpy).toHaveBeenCalledTimes(2);
+ expect(signer).toBeInstanceOf(AccountSigner);
+ });
+
+ it('throws error if an error catched', async () => {
+ const network = networks.testnet;
+ const { instance: node, deriveHardenedSpy } =
+ createMockBip32Instance(network);
+ deriveHardenedSpy.mockReturnValue(new Error('invalid path'));
+
+ const instance = new AccountSigner(node);
+
+ expect(() => instance.derivePath("m/0'/0")).toThrow('invalid path');
+ });
+ });
+
+ describe('sign', () => {
+ it('signs a message with a BIP32 instance', async () => {
+ const network = networks.testnet;
+ const { instance: node, signSpy } = createMockBip32Instance(network);
+ const message = Buffer.from('test');
+
+ const instance = new AccountSigner(node);
+ const signer = instance.derivePath("m/0'/0/1");
+ await signer.sign(message);
+
+ expect(signSpy).toHaveBeenCalledWith(message);
+ });
+ });
+
+ describe('verify', () => {
+ it('verify a message with a BIP32 instance', () => {
+ const network = networks.testnet;
+ const { instance: node, verifySpy } = createMockBip32Instance(network);
+ const hash = Buffer.from('hash');
+ const signature = Buffer.from('signature');
+
+ const instance = new AccountSigner(node);
+ const signer = instance.derivePath("m/0'/0/1");
+ signer.verify(hash, signature);
+
+ expect(verifySpy).toHaveBeenCalledWith(hash, signature);
+ });
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/types.ts
index 1285920c..403950d3 100644
--- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/types.ts
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/types.ts
@@ -5,9 +5,6 @@ export type BtcTransactionConfig = {
read: {
type: DataClient;
};
- write: {
- type: DataClient;
- };
};
};
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.test.ts
new file mode 100644
index 00000000..b84acec6
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.test.ts
@@ -0,0 +1,95 @@
+import { networks } from 'bitcoinjs-lib';
+
+import blocksteamData from '../../../../../test/fixtures/blockstream.json';
+import { generateAccounts } from '../../../../../test/utils';
+import { DataClientError } from '../exceptions';
+import { BlockStreamClient } from './blockstream';
+
+jest.mock('../../../logger/logger', () => ({
+ logger: {
+ info: jest.fn(),
+ },
+}));
+
+describe('BlockStreamClient', () => {
+ const createMockFetch = () => {
+ const fetchSpy = fetch as jest.Mock;
+ return {
+ fetchSpy,
+ };
+ };
+
+ describe('baseUrl', () => {
+ it('returns testnet network url', () => {
+ const instance = new BlockStreamClient({ network: networks.testnet });
+ expect(instance.baseUrl).toBe('https://blockstream.info/testnet/api');
+ });
+
+ it('returns mainnet network url', () => {
+ const instance = new BlockStreamClient({ network: networks.bitcoin });
+ expect(instance.baseUrl).toBe('https://blockstream.info/api');
+ });
+
+ it('throws `Invalid network` error if the given network is not support', () => {
+ const instance = new BlockStreamClient({ network: networks.regtest });
+ expect(() => instance.baseUrl).toThrow('Invalid network');
+ });
+ });
+
+ describe('getBalance', () => {
+ it('returns balances', async () => {
+ const { fetchSpy } = createMockFetch();
+ fetchSpy.mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue(blocksteamData.accountInfo),
+ });
+ const account = generateAccounts(1)[0];
+
+ const instance = new BlockStreamClient({ network: networks.testnet });
+ const result = await instance.getBalance(account.address);
+
+ const confirmed =
+ blocksteamData.accountInfo.chain_stats.funded_txo_sum -
+ blocksteamData.accountInfo.chain_stats.spent_txo_sum;
+ const unconfirmed =
+ blocksteamData.accountInfo.mempool_stats.funded_txo_sum -
+ blocksteamData.accountInfo.mempool_stats.spent_txo_sum;
+
+ expect(result).toStrictEqual({
+ confirmed,
+ unconfirmed,
+ total: confirmed + unconfirmed,
+ });
+ });
+
+ it('throws `503` error if fetch status is 503', async () => {
+ const { fetchSpy } = createMockFetch();
+ fetchSpy.mockResolvedValue({
+ ok: false,
+ statusText: '503',
+ });
+ const account = generateAccounts(1)[0];
+
+ const instance = new BlockStreamClient({ network: networks.testnet });
+
+ await expect(instance.getBalance(account.address)).rejects.toThrow(
+ 'Failed to fetch data from blockstream: 503',
+ );
+ });
+
+ it('throws DataClientError error if an non DataClientError catched', async () => {
+ const { fetchSpy } = createMockFetch();
+ fetchSpy.mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockRejectedValue(new Error('error')),
+ });
+ const account = generateAccounts(1)[0];
+
+ const instance = new BlockStreamClient({ network: networks.testnet });
+
+ await expect(instance.getBalance(account.address)).rejects.toThrow(
+ DataClientError,
+ );
+ });
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.ts
index 27768bff..c535f99e 100644
--- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.ts
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.ts
@@ -36,23 +36,28 @@ export class BlockStreamClient implements IReadDataClient {
this.options = options;
}
- get baseUrl() {
- if (this.options.network === networks.bitcoin) {
- return 'https://blockstream.info/api';
+ get baseUrl(): string {
+ switch (this.options.network) {
+ case networks.bitcoin:
+ return 'https://blockstream.info/api';
+ case networks.testnet:
+ return 'https://blockstream.info/testnet/api';
+ default:
+ throw new DataClientError('Invalid network');
}
- return 'https://blockstream.info/testnet/api';
}
protected async get(endpoint: string): Promise {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: 'GET',
});
+
if (!response.ok) {
throw new DataClientError(
`Failed to fetch data from blockstream: ${response.statusText}`,
);
}
- return response.json() as Resp;
+ return response.json() as unknown as Resp;
}
async getBalance(address: string): Promise {
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/factory.test.ts
new file mode 100644
index 00000000..94a8e00d
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/factory.test.ts
@@ -0,0 +1,27 @@
+import { networks } from 'bitcoinjs-lib';
+
+import { DataClient } from '../config';
+import { BlockStreamClient } from './clients/blockstream';
+import { DataClientFactory } from './factory';
+
+describe('DataClientFactory', () => {
+ describe('createReadClient', () => {
+ it('creates BlockStreamClient', () => {
+ const instance = DataClientFactory.createReadClient(
+ { dataClient: { read: { type: DataClient.BlockStream } } },
+ networks.testnet,
+ );
+
+ expect(instance).toBeInstanceOf(BlockStreamClient);
+ });
+
+ it('throws `Unsupported client type` if the given client is not support', () => {
+ expect(() =>
+ DataClientFactory.createReadClient(
+ { dataClient: { read: { type: DataClient.BlockChair } } },
+ networks.testnet,
+ ),
+ ).toThrow('Unsupported client type: BlockChair');
+ });
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/helpers.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/helpers.test.ts
new file mode 100644
index 00000000..a56d8be4
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/helpers.test.ts
@@ -0,0 +1,24 @@
+import { networks } from 'bitcoinjs-lib';
+
+import { Network } from '../config';
+import { NetworkHelper } from './helpers';
+
+describe('NetworkHelper', () => {
+ describe('getNetwork', () => {
+ it('returns bitcoin testnet network', () => {
+ const result = NetworkHelper.getNetwork(Network.Testnet);
+ expect(result).toStrictEqual(networks.testnet);
+ });
+
+ it('returns bitcoin mainnet network', () => {
+ const result = NetworkHelper.getNetwork(Network.Mainnet);
+ expect(result).toStrictEqual(networks.bitcoin);
+ });
+
+ it('throws `Invalid network` error if the given network is not support', () => {
+ expect(() =>
+ NetworkHelper.getNetwork('someInvalidNetwork' as unknown as Network),
+ ).toThrow('Invalid network');
+ });
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/exceptions.ts
index 79116410..36ea1b04 100644
--- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/exceptions.ts
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/exceptions.ts
@@ -1,3 +1,3 @@
import { CustomError } from '../../exceptions';
-export class TransactionError extends CustomError {}
+export class TransactionMgrError extends CustomError {}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/manager.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/manager.test.ts
new file mode 100644
index 00000000..e083c6bc
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/manager.test.ts
@@ -0,0 +1,50 @@
+import { networks } from 'bitcoinjs-lib';
+
+import { generateAccounts } from '../../../../test/utils';
+import type { IReadDataClient } from '../data-client';
+import { BtcTransactionMgr } from './manager';
+
+describe('BtcTransactionMgr', () => {
+ const createMockReadDataClient = () => {
+ const getBalanceSpy = jest.fn();
+
+ class MockReadDataClient implements IReadDataClient {
+ getBalance = getBalanceSpy;
+ }
+ return {
+ instance: new MockReadDataClient(),
+ getBalanceSpy,
+ };
+ };
+
+ const createMockBtcTransactionMgr = (readDataClient: IReadDataClient) => {
+ const instance = new BtcTransactionMgr(readDataClient, {
+ network: networks.bitcoin,
+ });
+
+ return {
+ instance,
+ };
+ };
+
+ describe('getBalance', () => {
+ it('calls getBalance with readClient', async () => {
+ const { instance, getBalanceSpy } = createMockReadDataClient();
+ const { instance: txnMgr } = createMockBtcTransactionMgr(instance);
+ const account = generateAccounts(1)[0];
+
+ await txnMgr.getBalance(account.address);
+
+ expect(getBalanceSpy).toHaveBeenCalledWith(account.address);
+ });
+
+ it('throws TransactionMgrError if the getBalance failed', async () => {
+ const { instance, getBalanceSpy } = createMockReadDataClient();
+ const { instance: txnMgr } = createMockBtcTransactionMgr(instance);
+ getBalanceSpy.mockRejectedValue(new Error('error'));
+ const account = generateAccounts(1)[0];
+
+ await expect(txnMgr.getBalance(account.address)).rejects.toThrow('error');
+ });
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/manager.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/manager.ts
index fdd628ae..d08c2506 100644
--- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/manager.ts
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/manager.ts
@@ -1,6 +1,6 @@
import type { ITransactionMgr, Balance } from '../../transaction/types';
import { type IReadDataClient } from '../data-client';
-import { TransactionError } from './exceptions';
+import { TransactionMgrError } from './exceptions';
import type { BtcTransactionMgrOptions } from './types';
export class BtcTransactionMgr implements ITransactionMgr {
@@ -18,7 +18,7 @@ export class BtcTransactionMgr implements ITransactionMgr {
const response = await this.readClient.getBalance(address);
return response;
} catch (error) {
- throw new TransactionError(error);
+ throw new TransactionMgrError(error);
}
}
}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts
index 1db12070..db8c69d4 100644
--- a/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts
@@ -36,9 +36,6 @@ export const Config: SnapConfig = {
read: {
type: DataClient.BlockStream,
},
- write: {
- type: DataClient.BlockStream,
- },
},
},
},
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts
new file mode 100644
index 00000000..b307cae8
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts
@@ -0,0 +1,35 @@
+import { BtcAccountMgr } from './bitcoin/account';
+import { Network } from './bitcoin/config';
+import { BtcTransactionMgr } from './bitcoin/transaction';
+import { Chain } from './config';
+import { Factory } from './factory';
+import { BtcKeyring } from './keyring';
+
+describe('Factory', () => {
+ describe('createTransactionMgr', () => {
+ it('creates BtcTransactionMgr instance', () => {
+ const instance = Factory.createTransactionMgr(
+ Chain.Bitcoin,
+ Network.Testnet,
+ );
+
+ expect(instance).toBeInstanceOf(BtcTransactionMgr);
+ });
+ });
+
+ describe('createAccountMgr', () => {
+ it('creates BtcAccountMgr instance', () => {
+ const instance = Factory.createAccountMgr(Chain.Bitcoin, Network.Testnet);
+
+ expect(instance).toBeInstanceOf(BtcAccountMgr);
+ });
+ });
+
+ describe('createKeyring', () => {
+ it('creates BtcKeyring instance', () => {
+ const instance = Factory.createKeyring(Chain.Bitcoin, Network.Testnet);
+
+ expect(instance).toBeInstanceOf(BtcKeyring);
+ });
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts
new file mode 100644
index 00000000..02183616
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts
@@ -0,0 +1,329 @@
+import { generateAccounts } from '../../../test/utils';
+import { Network } from '../bitcoin/config';
+import { Chain, Config } from '../config';
+import { BtcKeyringError } from './exceptions';
+import { BtcKeyring } from './keyring';
+import { KeyringStateManager } from './state';
+import type { IAccountMgr } from './types';
+
+jest.mock('../logger/logger', () => ({
+ logger: {
+ info: jest.fn(),
+ },
+}));
+
+describe('BtcKeyring', () => {
+ const createMockAccountMgr = () => {
+ const unlockSpy = jest.fn();
+ class AccountMgr implements IAccountMgr {
+ unlock = unlockSpy;
+ }
+ return {
+ instance: new AccountMgr(),
+ unlockSpy,
+ };
+ };
+
+ const createMockStateMgr = () => {
+ const listAccountsSpy = jest.spyOn(
+ KeyringStateManager.prototype,
+ 'listAccounts',
+ );
+ const saveAccountSpy = jest.spyOn(
+ KeyringStateManager.prototype,
+ 'saveAccount',
+ );
+ const removeAccountsSpy = jest.spyOn(
+ KeyringStateManager.prototype,
+ 'removeAccounts',
+ );
+ const getAccountSpy = jest.spyOn(
+ KeyringStateManager.prototype,
+ 'getAccount',
+ );
+ const getAccountByAddressSpy = jest.spyOn(
+ KeyringStateManager.prototype,
+ 'getAccountByAddress',
+ );
+
+ return {
+ instance: new KeyringStateManager(),
+ listAccountsSpy,
+ saveAccountSpy,
+ removeAccountsSpy,
+ getAccountSpy,
+ getAccountByAddressSpy,
+ };
+ };
+
+ const createMockKeyring = (
+ accMgr: IAccountMgr,
+ stateMgr: KeyringStateManager,
+ ) => {
+ return {
+ instance: new BtcKeyring(accMgr, stateMgr, {
+ defaultIndex: 0,
+ }),
+ };
+ };
+
+ describe('createAccount', () => {
+ it('creates account', async () => {
+ const { instance: accMgr, unlockSpy } = createMockAccountMgr();
+ const {
+ instance: stateMgr,
+ saveAccountSpy,
+ getAccountByAddressSpy,
+ } = createMockStateMgr();
+ const { instance: keyring } = createMockKeyring(accMgr, stateMgr);
+ const account = generateAccounts(1)[0];
+
+ getAccountByAddressSpy.mockResolvedValue(null);
+
+ unlockSpy.mockResolvedValue({
+ address: account.address,
+ hdPath: account.options.hdPath,
+ index: account.options.index,
+ type: account.options.type,
+ });
+
+ await keyring.createAccount();
+
+ expect(unlockSpy).toHaveBeenCalledWith(
+ Config.account[Chain.Bitcoin].defaultAccountIndex,
+ );
+ expect(saveAccountSpy).toHaveBeenCalledWith({
+ type: account.type,
+ id: expect.any(String),
+ address: account.address,
+ options: {
+ hdPath: account.options.hdPath,
+ index: account.options.index,
+ type: account.options.type,
+ },
+ methods: [],
+ });
+ });
+
+ it('creates account with options', async () => {
+ const { instance: accMgr, unlockSpy } = createMockAccountMgr();
+ const {
+ instance: stateMgr,
+ saveAccountSpy,
+ getAccountByAddressSpy,
+ } = createMockStateMgr();
+ const { instance: keyring } = createMockKeyring(accMgr, stateMgr);
+ const account = generateAccounts(1)[0];
+
+ getAccountByAddressSpy.mockResolvedValue(null);
+
+ unlockSpy.mockResolvedValue({
+ address: account.address,
+ hdPath: account.options.hdPath,
+ index: account.options.index,
+ type: account.options.type,
+ });
+
+ await keyring.createAccount({ index: 1 });
+
+ expect(unlockSpy).toHaveBeenCalledWith(1);
+ expect(saveAccountSpy).toHaveBeenCalledWith({
+ type: account.type,
+ id: expect.any(String),
+ address: account.address,
+ options: {
+ hdPath: account.options.hdPath,
+ index: account.options.index,
+ type: account.options.type,
+ },
+ methods: [],
+ });
+ });
+
+ it('does not create account if the account is exist', async () => {
+ const { instance: accMgr, unlockSpy } = createMockAccountMgr();
+ const {
+ instance: stateMgr,
+ saveAccountSpy,
+ getAccountByAddressSpy,
+ } = createMockStateMgr();
+ const { instance: keyring } = createMockKeyring(accMgr, stateMgr);
+ const account = generateAccounts(1)[0];
+
+ getAccountByAddressSpy.mockResolvedValue(account);
+
+ unlockSpy.mockResolvedValue({
+ address: account.address,
+ hdPath: account.options.hdPath,
+ index: account.options.index,
+ type: account.options.type,
+ });
+
+ const acc = await keyring.createAccount();
+
+ expect(unlockSpy).toHaveBeenCalledWith(0);
+ expect(saveAccountSpy).toHaveBeenCalledTimes(0);
+ expect(acc).toStrictEqual(account);
+ });
+
+ it('throws BtcKeyringError if an error catched', async () => {
+ const { instance: accMgr, unlockSpy } = createMockAccountMgr();
+ const { instance: stateMgr, getAccountByAddressSpy } =
+ createMockStateMgr();
+ const { instance: keyring } = createMockKeyring(accMgr, stateMgr);
+ getAccountByAddressSpy.mockResolvedValue(null);
+ unlockSpy.mockRejectedValue(new Error('error'));
+
+ await expect(keyring.createAccount()).rejects.toThrow(BtcKeyringError);
+ });
+ });
+
+ describe('filterAccountChains', () => {
+ it('throws `Method not implemented` error', async () => {
+ const { instance: accMgr } = createMockAccountMgr();
+ const { instance: stateMgr } = createMockStateMgr();
+ const { instance: keyring } = createMockKeyring(accMgr, stateMgr);
+ const account = generateAccounts(1)[0];
+
+ await expect(
+ keyring.filterAccountChains(account.id, [Network.Testnet]),
+ ).rejects.toThrow('Method not implemented.');
+ });
+ });
+
+ describe('submitRequest', () => {
+ it('throws Method not implemented error', async () => {
+ const { instance: accMgr } = createMockAccountMgr();
+ const { instance: stateMgr } = createMockStateMgr();
+ const { instance: keyring } = createMockKeyring(accMgr, stateMgr);
+ const account = generateAccounts(1)[0];
+
+ await expect(
+ keyring.submitRequest({
+ id: account.id,
+ scope: Network.Testnet,
+ account: account.address,
+ request: {
+ method: 'signMessage',
+ },
+ }),
+ ).rejects.toThrow('Method not implemented.');
+ });
+ });
+
+ describe('listAccounts', () => {
+ it('returns result', async () => {
+ const { instance: accMgr } = createMockAccountMgr();
+ const { instance: stateMgr, listAccountsSpy } = createMockStateMgr();
+ const { instance: keyring } = createMockKeyring(accMgr, stateMgr);
+ const accounts = generateAccounts(10);
+ listAccountsSpy.mockResolvedValue(accounts);
+
+ const result = await keyring.listAccounts();
+
+ expect(result).toStrictEqual(accounts);
+ expect(listAccountsSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('throws BtcKeyringError if an error catched', async () => {
+ const { instance: accMgr } = createMockAccountMgr();
+ const { instance: stateMgr, listAccountsSpy } = createMockStateMgr();
+ const { instance: keyring } = createMockKeyring(accMgr, stateMgr);
+ listAccountsSpy.mockRejectedValue(new Error('error'));
+
+ await expect(keyring.listAccounts()).rejects.toThrow(BtcKeyringError);
+ });
+ });
+
+ describe('getAccount', () => {
+ it('returns result', async () => {
+ const { instance: accMgr } = createMockAccountMgr();
+ const { instance: stateMgr, getAccountSpy } = createMockStateMgr();
+ const { instance: keyring } = createMockKeyring(accMgr, stateMgr);
+ const account = generateAccounts(1)[0];
+ getAccountSpy.mockResolvedValue(account);
+
+ const result = await keyring.getAccount(account.id);
+
+ expect(result).toStrictEqual(account);
+ expect(getAccountSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns undefined if the account is not exist', async () => {
+ const { instance: accMgr } = createMockAccountMgr();
+ const { instance: stateMgr, getAccountSpy } = createMockStateMgr();
+ const { instance: keyring } = createMockKeyring(accMgr, stateMgr);
+ const account = generateAccounts(1)[0];
+ getAccountSpy.mockResolvedValue(null);
+
+ const result = await keyring.getAccount(account.id);
+
+ expect(result).toBeUndefined();
+ expect(getAccountSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('throws BtcKeyringError if an error catched', async () => {
+ const { instance: accMgr } = createMockAccountMgr();
+ const { instance: stateMgr, getAccountSpy } = createMockStateMgr();
+ const { instance: keyring } = createMockKeyring(accMgr, stateMgr);
+ getAccountSpy.mockRejectedValue(new Error('error'));
+ const accounts = generateAccounts(1);
+
+ await expect(keyring.getAccount(accounts[0].id)).rejects.toThrow(
+ BtcKeyringError,
+ );
+ });
+ });
+
+ describe('updateAccount', () => {
+ it('updates account', async () => {
+ const { instance: accMgr } = createMockAccountMgr();
+ const { instance: stateMgr, saveAccountSpy } = createMockStateMgr();
+ const { instance: keyring } = createMockKeyring(accMgr, stateMgr);
+ const account = generateAccounts(1)[0];
+ saveAccountSpy.mockReturnThis();
+
+ await keyring.updateAccount(account);
+
+ expect(saveAccountSpy).toHaveBeenCalledWith(account);
+ });
+
+ it('throws BtcKeyringError if an error catched', async () => {
+ const { instance: accMgr } = createMockAccountMgr();
+ const { instance: stateMgr, saveAccountSpy } = createMockStateMgr();
+ const { instance: keyring } = createMockKeyring(accMgr, stateMgr);
+ saveAccountSpy.mockRejectedValue(new Error('error'));
+ const account = generateAccounts(1)[0];
+
+ await expect(keyring.updateAccount(account)).rejects.toThrow(
+ BtcKeyringError,
+ );
+ });
+ });
+
+ describe('deleteAccount', () => {
+ it('remove account', async () => {
+ const { instance: accMgr } = createMockAccountMgr();
+ const { instance: stateMgr, removeAccountsSpy } = createMockStateMgr();
+ const { instance: keyring } = createMockKeyring(accMgr, stateMgr);
+ const account = generateAccounts(1)[0];
+ removeAccountsSpy.mockReturnThis();
+
+ await keyring.deleteAccount(account.id);
+
+ expect(removeAccountsSpy).toHaveBeenCalledWith([account.id]);
+ });
+
+ it('throws BtcKeyringError if an error catched', async () => {
+ const { instance: accMgr } = createMockAccountMgr();
+ const { instance: stateMgr, removeAccountsSpy } = createMockStateMgr();
+ const { instance: keyring } = createMockKeyring(accMgr, stateMgr);
+ removeAccountsSpy.mockRejectedValue(new Error('error'));
+ const account = generateAccounts(1)[0];
+
+ await expect(keyring.deleteAccount(account.id)).rejects.toThrow(
+ BtcKeyringError,
+ );
+ });
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts
index 7bd3e029..09cce4f9 100644
--- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts
@@ -85,7 +85,7 @@ export class BtcKeyring implements Keyring {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async filterAccountChains(id: string, chains: string[]): Promise {
- throw new Error('Method not implemented.');
+ throw new BtcKeyringError('Method not implemented.');
}
async updateAccount(account: KeyringAccount): Promise {
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.test.ts
new file mode 100644
index 00000000..8a02111f
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.test.ts
@@ -0,0 +1,243 @@
+import { generateAccounts } from '../../../test/utils';
+import { SnapHelper, StateError } from '../snap';
+import { KeyringStateManager } from './state';
+
+describe('BtcKeyring', () => {
+ const createMockStateManager = () => {
+ const getDataSpy = jest.spyOn(SnapHelper, 'getStateData');
+ const setDataSpy = jest.spyOn(SnapHelper, 'setStateData');
+ return {
+ instance: new KeyringStateManager(),
+ getDataSpy,
+ setDataSpy,
+ };
+ };
+
+ const createInitState = (cnt = 1) => {
+ const generatedAccounts = generateAccounts(cnt);
+ return {
+ accounts: generatedAccounts.map((accounts) => accounts.id),
+ accountDetails: generatedAccounts.reduce((acc, account) => {
+ acc[account.id] = account;
+ return acc;
+ }, {}),
+ };
+ };
+
+ describe('listAccounts', () => {
+ it('returns result', async () => {
+ const { instance, getDataSpy } = createMockStateManager();
+ const state = createInitState(20);
+ getDataSpy.mockResolvedValue(state);
+
+ const result = await instance.listAccounts();
+
+ expect(getDataSpy).toHaveBeenCalledTimes(1);
+ expect(result).toStrictEqual(
+ state.accounts.map((id) => state.accountDetails[id]),
+ );
+ });
+
+ it('inits keyring state if the state is null', async () => {
+ const { instance, getDataSpy } = createMockStateManager();
+ getDataSpy.mockResolvedValue(null);
+
+ const result = await instance.listAccounts();
+
+ expect(getDataSpy).toHaveBeenCalledTimes(1);
+ expect(result).toStrictEqual([]);
+ });
+
+ it('init keyring state `accounts` if `accounts` does not exist', async () => {
+ const { instance, getDataSpy } = createMockStateManager();
+ getDataSpy.mockResolvedValue({
+ accounts: [],
+ });
+
+ const result = await instance.listAccounts();
+
+ expect(getDataSpy).toHaveBeenCalledTimes(1);
+ expect(result).toStrictEqual([]);
+ });
+
+ it('init keyring state `accountDetails` if `accountDetails` does not exist', async () => {
+ const { instance, getDataSpy } = createMockStateManager();
+ getDataSpy.mockResolvedValue({
+ accountDetails: {},
+ });
+
+ const result = await instance.listAccounts();
+
+ expect(getDataSpy).toHaveBeenCalledTimes(1);
+ expect(result).toStrictEqual([]);
+ });
+
+ it('throw StateError if an error catched', async () => {
+ const { instance, getDataSpy } = createMockStateManager();
+ getDataSpy.mockRejectedValue(new Error('error'));
+
+ await expect(instance.listAccounts()).rejects.toThrow(StateError);
+ });
+ });
+
+ describe('saveAccount', () => {
+ it('updates account if the account exist', async () => {
+ const { instance, getDataSpy, setDataSpy } = createMockStateManager();
+ const state = createInitState(20);
+ getDataSpy.mockResolvedValue(state);
+ const accountToSave = { ...state.accountDetails[state.accounts[0]] };
+ accountToSave.options.hdPath = 'm/1/0/0';
+
+ await instance.saveAccount(accountToSave);
+
+ expect(getDataSpy).toHaveBeenCalledTimes(1);
+ expect(setDataSpy).toHaveBeenCalledTimes(1);
+ expect(state.accountDetails[state.accounts[0]]).toStrictEqual(
+ accountToSave,
+ );
+ });
+
+ it('adds account if the account not exist', async () => {
+ const { instance, getDataSpy, setDataSpy } = createMockStateManager();
+ const accountToSave = generateAccounts(1)[0];
+ const state = {
+ accounts: [],
+ accountDetails: {},
+ };
+ getDataSpy.mockResolvedValue(state);
+
+ await instance.saveAccount(accountToSave);
+
+ expect(getDataSpy).toHaveBeenCalledTimes(1);
+ expect(setDataSpy).toHaveBeenCalledTimes(1);
+ expect(state.accounts).toHaveLength(1);
+ expect(state.accountDetails).toStrictEqual({
+ [accountToSave.id]: accountToSave,
+ });
+ });
+
+ it('throw StateError if an error catched', async () => {
+ const { instance, getDataSpy } = createMockStateManager();
+ getDataSpy.mockRejectedValue(new Error('error'));
+ const state = createInitState(1);
+
+ await expect(
+ instance.saveAccount(state.accountDetails[state.accounts[0]]),
+ ).rejects.toThrow(StateError);
+ });
+ });
+
+ describe('removeAccounts', () => {
+ it('removes account if the account exist', async () => {
+ const { instance, getDataSpy, setDataSpy } = createMockStateManager();
+ const state = createInitState(20);
+ getDataSpy.mockResolvedValue(state);
+
+ const lengthB4Remove = state.accounts.length;
+ const testInput = [state.accounts[0], state.accounts[10]];
+
+ await instance.removeAccounts(testInput);
+
+ expect(getDataSpy).toHaveBeenCalledTimes(1);
+ expect(setDataSpy).toHaveBeenCalledTimes(1);
+ expect(state.accounts).toHaveLength(lengthB4Remove - testInput.length);
+ expect(state.accountDetails).not.toContain(testInput[0]);
+ expect(state.accountDetails).not.toContain(testInput[1]);
+ });
+
+ it('throw StateError if the account does not exist', async () => {
+ const { instance, getDataSpy } = createMockStateManager();
+ const state = createInitState(20);
+ const nonExistAcc = generateAccounts(1, 'notexist', 'notexist')[0];
+ getDataSpy.mockResolvedValue(state);
+
+ await expect(
+ instance.removeAccounts([nonExistAcc.id, state.accounts[0]]),
+ ).rejects.toThrow(
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+ `Account with id ${nonExistAcc.id} does not exist`,
+ );
+ });
+
+ it('throw StateError if an error catched', async () => {
+ const { instance, getDataSpy } = createMockStateManager();
+ getDataSpy.mockRejectedValue(new Error('error'));
+ const state = createInitState(1);
+
+ await expect(instance.removeAccounts(state.accounts)).rejects.toThrow(
+ StateError,
+ );
+ });
+ });
+
+ describe('getAccount', () => {
+ it('returns result', async () => {
+ const { instance, getDataSpy } = createMockStateManager();
+ const state = createInitState(20);
+ getDataSpy.mockResolvedValue(state);
+ const id = state.accounts[0];
+
+ const result = await instance.getAccount(id);
+
+ expect(getDataSpy).toHaveBeenCalledTimes(1);
+ expect(result).toStrictEqual(state.accountDetails[id]);
+ });
+
+ it('returns null if the account does not exist', async () => {
+ const { instance, getDataSpy } = createMockStateManager();
+ const state = createInitState(20);
+ getDataSpy.mockResolvedValue(state);
+ const { id } = generateAccounts(1, 'notexist', 'notexist')[0];
+
+ const result = await instance.getAccount(id);
+
+ expect(getDataSpy).toHaveBeenCalledTimes(1);
+ expect(result).toBeNull();
+ });
+
+ it('throw StateError if an error catched', async () => {
+ const { instance, getDataSpy } = createMockStateManager();
+ getDataSpy.mockRejectedValue(new Error('error'));
+ const { id } = generateAccounts(1)[0];
+
+ await expect(instance.getAccount(id)).rejects.toThrow(StateError);
+ });
+ });
+
+ describe('getAccountByAddress', () => {
+ it('returns result', async () => {
+ const { instance, getDataSpy } = createMockStateManager();
+ const state = createInitState(20);
+ getDataSpy.mockResolvedValue(state);
+ const id = state.accounts[0];
+ const { address } = state.accountDetails[id];
+
+ const result = await instance.getAccountByAddress(address);
+
+ expect(getDataSpy).toHaveBeenCalledTimes(1);
+ expect(result).toStrictEqual(state.accountDetails[id]);
+ });
+
+ it('returns null if the account does not exist', async () => {
+ const { instance, getDataSpy } = createMockStateManager();
+ const state = createInitState(20);
+ getDataSpy.mockResolvedValue(state);
+ const nonExistAcc = generateAccounts(1, 'notexist', 'notexist')[0];
+
+ const result = await instance.getAccountByAddress(nonExistAcc.address);
+
+ expect(getDataSpy).toHaveBeenCalledTimes(1);
+ expect(result).toBeNull();
+ });
+
+ it('throw StateError if an error catched', async () => {
+ const { instance, getDataSpy } = createMockStateManager();
+ getDataSpy.mockRejectedValue(new Error('error'));
+ const acc = generateAccounts(1)[0];
+
+ await expect(instance.getAccountByAddress(acc.id)).rejects.toThrow(
+ StateError,
+ );
+ });
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/logger/logger.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/logger/logger.test.ts
new file mode 100644
index 00000000..8d50c85e
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/logger/logger.test.ts
@@ -0,0 +1,78 @@
+import { logger, LogLevel } from './logger';
+
+describe('Logger', () => {
+ afterAll(() => {
+ logger.logLevel = LogLevel.OFF;
+ });
+ const createLogSpy = () => {
+ return {
+ log: jest.spyOn(console, 'log').mockReturnThis(),
+ error: jest.spyOn(console, 'error').mockReturnThis(),
+ warn: jest.spyOn(console, 'warn').mockReturnThis(),
+ info: jest.spyOn(console, 'info').mockReturnThis(),
+ trace: jest.spyOn(console, 'trace').mockReturnThis(),
+ debug: jest.spyOn(console, 'debug').mockReturnThis(),
+ };
+ };
+
+ const testLog = (message: string) => {
+ logger.log(message);
+ logger.error(message);
+ logger.warn(message);
+ logger.info(message);
+ logger.trace(message);
+ logger.debug(message);
+ };
+
+ it('logs when `logLevel` is `LogLevel.ALL`', () => {
+ const spys = createLogSpy();
+
+ logger.logLevel = LogLevel.ALL;
+
+ testLog('log');
+
+ expect(spys.info).toHaveBeenCalledWith('log');
+ expect(spys.warn).toHaveBeenCalledWith('log');
+ expect(spys.error).toHaveBeenCalledWith('log');
+ expect(spys.debug).toHaveBeenCalledWith('log');
+ expect(spys.log).toHaveBeenCalledWith('log');
+ expect(spys.trace).toHaveBeenCalledWith('log');
+ });
+
+ it('does not log when `logLevel` is `LogLevel.OFF`', () => {
+ const spys = createLogSpy();
+
+ logger.logLevel = LogLevel.OFF;
+
+ testLog('log');
+
+ expect(spys.info).toHaveBeenCalledTimes(0);
+ expect(spys.warn).toHaveBeenCalledTimes(0);
+ expect(spys.error).toHaveBeenCalledTimes(0);
+ expect(spys.debug).toHaveBeenCalledTimes(0);
+ expect(spys.log).toHaveBeenCalledTimes(0);
+ expect(spys.trace).toHaveBeenCalledTimes(0);
+ });
+
+ it('logs correctly when `logLevel` is `LogLevel.INFO`', () => {
+ const spys = createLogSpy();
+
+ logger.logLevel = LogLevel.INFO;
+
+ testLog('log');
+
+ expect(spys.info).toHaveBeenCalledWith('log');
+ expect(spys.warn).toHaveBeenCalledWith('log');
+ expect(spys.error).toHaveBeenCalledWith('log');
+
+ expect(spys.debug).toHaveBeenCalledTimes(0);
+ expect(spys.log).toHaveBeenCalledTimes(0);
+ expect(spys.trace).toHaveBeenCalledTimes(0);
+ });
+
+ it('return correct `LogLevel`', () => {
+ logger.logLevel = LogLevel.INFO;
+
+ expect(logger.logLevel).toStrictEqual(LogLevel.INFO);
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/logger/logger.ts b/merged-packages/bitcoin-wallet-snap/src/modules/logger/logger.ts
index 1a35a37f..538ba281 100644
--- a/merged-packages/bitcoin-wallet-snap/src/modules/logger/logger.ts
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/logger/logger.ts
@@ -32,7 +32,7 @@ export const emptyLog: LoggingFn = (
// eslint-disable-next-line @typescript-eslint/no-empty-function
{};
-class Logger implements ILogger {
+export class Logger implements ILogger {
log: LoggingFn;
warn: LoggingFn;
@@ -64,22 +64,22 @@ class Logger implements ILogger {
this.trace = console.trace.bind(console);
this.log = console.log.bind(console);
- if (this._logLevel < LogLevel.ERROR) {
+ if (this.#logLevel < LogLevel.ERROR) {
this.error = emptyLog;
}
- if (this._logLevel < LogLevel.WARN) {
+ if (this.#logLevel < LogLevel.WARN) {
this.warn = emptyLog;
}
- if (this._logLevel < LogLevel.INFO) {
+ if (this.#logLevel < LogLevel.INFO) {
this.info = emptyLog;
}
- if (this._logLevel < LogLevel.DEBUG) {
+ if (this.#logLevel < LogLevel.DEBUG) {
this.debug = emptyLog;
}
- if (this._logLevel < LogLevel.TRACE) {
+ if (this.#logLevel < LogLevel.TRACE) {
this.trace = emptyLog;
}
- if (this._logLevel < LogLevel.ALL) {
+ if (this.#logLevel < LogLevel.ALL) {
this.log = emptyLog;
}
}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.test.ts
new file mode 100644
index 00000000..5fb7bef7
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.test.ts
@@ -0,0 +1,132 @@
+import { expect } from '@jest/globals';
+import { heading, panel, text, divider } from '@metamask/snaps-sdk';
+
+import { SnapHelper } from './helpers';
+
+jest.mock('@metamask/key-tree', () => ({
+ getBIP44AddressKeyDeriver: jest.fn(),
+}));
+
+describe('SnapHelper', () => {
+ describe('getBip44Deriver', () => {
+ it('gets bip44 deriver', async () => {
+ const spy = jest.spyOn(SnapHelper.wallet, 'request');
+ const coinType = 1001;
+
+ await SnapHelper.getBip44Deriver(coinType);
+
+ expect(spy).toHaveBeenCalledWith({
+ method: 'snap_getBip44Entropy',
+ params: {
+ coinType,
+ },
+ });
+ });
+ });
+
+ describe('getBip32Deriver', () => {
+ it('gets bip32 deriver', async () => {
+ const spy = jest.spyOn(SnapHelper.wallet, 'request');
+ const path = ['m', "84'", "0'"];
+ const curve = 'secp256k1';
+
+ await SnapHelper.getBip32Deriver(path, curve);
+
+ expect(spy).toHaveBeenCalledWith({
+ method: 'snap_getBip32Entropy',
+ params: {
+ path,
+ curve,
+ },
+ });
+ });
+ });
+
+ describe('confirmDialog', () => {
+ it('calls snap_dialog', async () => {
+ const spy = jest.spyOn(SnapHelper.wallet, 'request');
+ const testcase = {
+ header: 'header',
+ subHeader: 'subHeader',
+ body: {
+ content: 'content',
+ },
+ };
+
+ await SnapHelper.confirmDialog(
+ testcase.header,
+ testcase.subHeader,
+ testcase.body,
+ );
+
+ expect(spy).toHaveBeenCalledWith({
+ method: 'snap_dialog',
+ params: {
+ type: 'confirmation',
+ content: panel([
+ heading(testcase.header),
+ text(testcase.subHeader),
+ divider(),
+ ...Object.entries(testcase.body).map(([key, value]) =>
+ text(`**${key}**:\n ${value}`),
+ ),
+ ]),
+ },
+ });
+ });
+ });
+
+ describe('getStateData', () => {
+ it('gets state data', async () => {
+ const spy = jest.spyOn(SnapHelper.wallet, 'request');
+ const testcase = {
+ state: {
+ transaction: [
+ {
+ txnHash: 'hash',
+ chainId: 'chainId',
+ },
+ ],
+ },
+ };
+
+ spy.mockResolvedValue(testcase.state);
+ const result = await SnapHelper.getStateData();
+
+ expect(spy).toHaveBeenCalledWith({
+ method: 'snap_manageState',
+ params: {
+ operation: 'get',
+ },
+ });
+
+ expect(result).toStrictEqual(testcase.state);
+ });
+ });
+
+ describe('setStateData', () => {
+ it('sets state data', async () => {
+ const spy = jest.spyOn(SnapHelper.wallet, 'request');
+ const testcase = {
+ state: {
+ transaction: [
+ {
+ txnHash: 'hash',
+ chainId: 'chainId',
+ },
+ ],
+ },
+ };
+
+ await SnapHelper.setStateData(testcase.state);
+
+ expect(spy).toHaveBeenCalledWith({
+ method: 'snap_manageState',
+ params: {
+ operation: 'update',
+ newState: testcase.state,
+ },
+ });
+ });
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.ts
index a999e71a..782def2a 100644
--- a/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.ts
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.ts
@@ -78,7 +78,7 @@ export class SnapHelper {
method: 'snap_manageState',
params: {
operation: 'update',
- newState: data as Record,
+ newState: data as unknown as Record,
},
});
}
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/lock.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/snap/lock.test.ts
new file mode 100644
index 00000000..66e667ed
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/lock.test.ts
@@ -0,0 +1,24 @@
+import { expect } from '@jest/globals';
+import { Mutex } from 'async-mutex';
+
+import { MutexLock } from './lock';
+
+jest.mock('async-mutex', () => {
+ return {
+ Mutex: jest.fn(),
+ };
+});
+
+describe('MutexLock', () => {
+ describe('acquire', () => {
+ it('acquires lock', () => {
+ MutexLock.acquire();
+ expect(Mutex).toHaveBeenCalledTimes(0);
+ });
+
+ it('acquires new lock if parameter `create` is true', () => {
+ MutexLock.acquire(true);
+ expect(Mutex).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.test.ts
new file mode 100644
index 00000000..e0a35dc2
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.test.ts
@@ -0,0 +1,101 @@
+import { expect } from '@jest/globals';
+
+import { SnapHelper } from './helpers';
+import { MutexLock } from './lock';
+import { SnapStateManager } from './state';
+
+describe('SnapStateManager', () => {
+ const createMockStateManager = (createLock?: boolean) => {
+ const updateDataSpy = jest.fn();
+ class MockSnapStateManager extends SnapStateManager {
+ constructor() {
+ super(createLock);
+ }
+
+ async getData() {
+ return this.get();
+ }
+
+ async updateData(data: any) {
+ await this.update(async (state) => updateDataSpy(state, data));
+ }
+ }
+
+ return {
+ instance: new MockSnapStateManager(),
+ updateDataSpy,
+ };
+ };
+
+ describe('constructor', () => {
+ it('sends `false` to Lock.Acquire if parameter `createLock` is `undefined`', async () => {
+ const spy = jest.spyOn(MutexLock, 'acquire');
+ createMockStateManager();
+
+ expect(spy).toHaveBeenCalledWith(false);
+ });
+
+ it('sends `true` to Lock.Acquire if parameter `createLock` is `true`', async () => {
+ const spy = jest.spyOn(MutexLock, 'acquire');
+ createMockStateManager(true);
+
+ expect(spy).toHaveBeenCalledWith(true);
+ });
+ });
+
+ describe('get', () => {
+ it('returns result', async () => {
+ const { instance } = createMockStateManager(false);
+ const state = {
+ transaction: [
+ {
+ txnHash: 'hash',
+ chainId: 'chainId',
+ },
+ ],
+ };
+ const readSpy = jest
+ .spyOn(SnapHelper, 'getStateData')
+ .mockResolvedValue(state);
+ const result = await instance.getData();
+
+ expect(readSpy).toHaveBeenCalledTimes(1);
+ expect(result).toStrictEqual(state);
+ });
+ });
+
+ describe('update', () => {
+ it('updates state', async () => {
+ const { instance, updateDataSpy } = createMockStateManager(false);
+ const testcase = {
+ state: {
+ transaction: [
+ {
+ txnHash: 'hash',
+ chainId: 'chainId',
+ },
+ ],
+ },
+ data: {
+ txnHash: 'hash2',
+ chainId: 'chainId2',
+ },
+ };
+ const readSpy = jest
+ .spyOn(SnapHelper, 'getStateData')
+ .mockResolvedValue(testcase.state);
+ const writeSpy = jest.spyOn(SnapHelper, 'setStateData');
+ updateDataSpy.mockImplementation((state, data) => {
+ state.transaction.push(data);
+ });
+
+ await instance.updateData(testcase.data);
+
+ expect(readSpy).toHaveBeenCalledTimes(1);
+ expect(writeSpy).toHaveBeenCalledTimes(1);
+ expect(writeSpy).toHaveBeenCalledWith(testcase.state);
+ expect(testcase.state.transaction).toHaveLength(2);
+ expect(updateDataSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.test.ts
new file mode 100644
index 00000000..d043e1fa
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.test.ts
@@ -0,0 +1,49 @@
+import { generateAccounts } from '../../../test/utils';
+import { TransactionServiceError } from './exceptions';
+import { TransactionStateManager } from './state';
+import { TransactionService } from './transaction';
+import type { ITransactionMgr } from './types';
+
+describe('TransactionService', () => {
+ const createMockTxnMgr = () => {
+ const getBalanceSpy = jest.fn();
+
+ class MockTxnMgr implements ITransactionMgr {
+ getBalance = getBalanceSpy;
+ }
+ return {
+ instance: new MockTxnMgr(),
+ getBalanceSpy,
+ };
+ };
+
+ describe('getBalance', () => {
+ it('calls getBalance with transactionMgr', async () => {
+ const { instance, getBalanceSpy } = createMockTxnMgr();
+ const accounts = generateAccounts(1);
+
+ const service = new TransactionService(
+ instance,
+ new TransactionStateManager(),
+ );
+ await service.getBalance(accounts[0].address);
+
+ expect(getBalanceSpy).toHaveBeenCalledWith(accounts[0].address);
+ });
+
+ it('throws TransactionServiceError if getBalance failed', async () => {
+ const { instance, getBalanceSpy } = createMockTxnMgr();
+ getBalanceSpy.mockRejectedValue(new Error('error'));
+ const accounts = generateAccounts(1);
+
+ const service = new TransactionService(
+ instance,
+ new TransactionStateManager(),
+ );
+
+ await expect(service.getBalance(accounts[0].address)).rejects.toThrow(
+ TransactionServiceError,
+ );
+ });
+ });
+});
diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json
new file mode 100644
index 00000000..7f4cfce2
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json
@@ -0,0 +1,19 @@
+{
+ "accountInfo": {
+ "address": "tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu",
+ "chain_stats": {
+ "funded_txo_count": 12,
+ "funded_txo_sum": 312762,
+ "spent_txo_count": 8,
+ "spent_txo_sum": 243323,
+ "tx_count": 12
+ },
+ "mempool_stats": {
+ "funded_txo_count": 0,
+ "funded_txo_sum": 0,
+ "spent_txo_count": 0,
+ "spent_txo_sum": 0,
+ "tx_count": 0
+ }
+ }
+}
diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts
new file mode 100644
index 00000000..4f9749ee
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts
@@ -0,0 +1,146 @@
+import type { BIP32Interface } from 'bip32';
+import type { Network } from 'bitcoinjs-lib';
+import { Buffer } from 'buffer';
+
+/**
+ * Method to generate testing account.
+ *
+ * @param cnt - Number of accounts to generate.
+ * @param addressPrefix - Prefix for the address.
+ * @param idPrefix - Prefix for the id.
+ * @returns Array of generated accounts.
+ */
+export function generateAccounts(cnt = 1, addressPrefix = '', idPrefix = '') {
+ const accounts: any[] = [];
+ let baseAddress = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu';
+ let baseUUID = '1b9d6bcd-bbfd-4b2d-9b5d-abadfbbdcbed';
+
+ baseAddress =
+ addressPrefix + baseAddress.slice(addressPrefix.length, baseAddress.length);
+ baseUUID = idPrefix + baseUUID.slice(idPrefix.length, baseUUID.length);
+
+ for (let i = 0; i < cnt; i++) {
+ const hdPath = [`m`, `0'`, `0`, `${i}`].join('/');
+ accounts.push({
+ type: 'bip32',
+ id:
+ baseUUID.slice(0, baseUUID.length - i.toString().length) + i.toString(),
+ address:
+ baseAddress.slice(0, baseAddress.length - i.toString().length) +
+ i.toString(),
+ options: {
+ hdPath,
+ index: i,
+ type: 'P2WPKH',
+ },
+ methods: [],
+ });
+ }
+
+ return accounts;
+}
+
+/**
+ * Method to generate mock bip32 instance.
+ *
+ * @param network - Bitcoin network.
+ * @param idx - Index of the bip32 instance.
+ * @param depth - Depth of the bip32 instance.
+ * @returns An BIP32Interface instance.
+ */
+export const createMockBip32Instance = (
+ network: Network,
+ idx = 0,
+ depth = 0,
+): {
+ instance: BIP32Interface;
+ isNeuteredSpy: jest.Mock;
+ neuteredSpy: jest.Mock;
+ toBase58Spy: jest.Mock;
+ toWIFSpy: jest.Mock;
+ deriveSpy: jest.Mock;
+ deriveHardenedSpy: jest.Mock;
+ derivePathSpy: jest.Mock;
+ tweakSpy: jest.Mock;
+ signSpy: jest.Mock;
+ verifySpy: jest.Mock;
+} => {
+ const deriveHardenedSpy = jest.fn();
+ const deriveSpy = jest.fn();
+ const derivePathSpy = jest.fn();
+ const tweakSpy = jest.fn();
+ const isNeuteredSpy = jest.fn();
+ const neuteredSpy = jest.fn();
+ const toBase58Spy = jest.fn();
+ const toWIFSpy = jest.fn();
+ const signSpy = jest.fn();
+ const verifySpy = jest.fn();
+
+ class MockBIP32Interface implements BIP32Interface {
+ fingerprint = Buffer.from('dddddddd', 'hex');
+
+ publicKey = Buffer.from('dddddddd', 'hex');
+
+ chainCodeBytes = Buffer.from('dddddddd', 'hex');
+
+ publicKeyBytes = Buffer.from('dddddddd', 'hex');
+
+ privateKey = Buffer.from('dddddddd', 'hex');
+
+ identifier = Buffer.from('dddddddd', 'hex');
+
+ chainCode = Buffer.from('dddddddd', 'hex');
+
+ network = network;
+
+ depth = depth;
+
+ index = idx;
+
+ parentFingerprint = 12345;
+
+ isNeutered = isNeuteredSpy.mockReturnValue(false);
+
+ neutered = neuteredSpy.mockImplementation(() => new MockBIP32Interface());
+
+ toBase58 = toBase58Spy.mockReturnValue('dddddddd');
+
+ toWIF = toWIFSpy.mockReturnValue('dddddddd');
+
+ derive = deriveSpy.mockImplementation(() => new MockBIP32Interface());
+
+ deriveHardened = deriveHardenedSpy.mockImplementation(
+ () => new MockBIP32Interface(),
+ );
+
+ derivePath = derivePathSpy.mockImplementation(
+ () => new MockBIP32Interface(),
+ );
+
+ tweak = tweakSpy;
+
+ lowR = false;
+
+ sign = signSpy;
+
+ verify = verifySpy;
+
+ signSchnorr = jest.fn();
+
+ verifySchnorr = jest.fn();
+ }
+
+ return {
+ instance: new MockBIP32Interface(),
+ isNeuteredSpy,
+ neuteredSpy,
+ toBase58Spy,
+ toWIFSpy,
+ deriveSpy,
+ deriveHardenedSpy,
+ derivePathSpy,
+ tweakSpy,
+ signSpy,
+ verifySpy,
+ };
+};
diff --git a/merged-packages/bitcoin-wallet-snap/test/wallet.mock.test.ts b/merged-packages/bitcoin-wallet-snap/test/wallet.mock.test.ts
new file mode 100644
index 00000000..0e1af9fc
--- /dev/null
+++ b/merged-packages/bitcoin-wallet-snap/test/wallet.mock.test.ts
@@ -0,0 +1,41 @@
+export type Wallet = {
+ registerRpcMessageHandler: (fn) => unknown;
+ request(options: {
+ method: string;
+ params?: { [key: string]: unknown } | unknown[];
+ }): unknown;
+};
+
+export class WalletMock implements Wallet {
+ public readonly registerRpcMessageHandler = jest.fn();
+
+ public readonly requestStub = jest.fn();
+ /* eslint-disable */
+ public readonly rpcStubs = {
+ snap_getBip32Entropy: jest.fn(),
+ snap_dialog: jest.fn(),
+ snap_manageState: jest.fn(),
+ };
+ /* eslint-disable */
+
+ /**
+ * Calls this.requestStub or this.rpcStubs[req.method], if the method has
+ * a dedicated stub.
+ * @param args
+ * @param args.method
+ * @param args.params
+ */
+ public request(args: {
+ method: string;
+ params: { [key: string]: unknown } | unknown[];
+ }): unknown {
+ const { method, params } = args;
+ if (Object.hasOwnProperty.call(this.rpcStubs, method)) {
+ if (Array.isArray(params)) {
+ return this.rpcStubs[method](...params);
+ }
+ return this.rpcStubs[method](params);
+ }
+ return this.requestStub(args);
+ }
+}
From 972f0a0fadd3e371e164d981300a4e9f396e43d6 Mon Sep 17 00:00:00 2001
From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com>
Date: Thu, 18 Apr 2024 17:48:36 +0800
Subject: [PATCH 004/362] fix: fix the ci pipeline for metamask task issue (#8)
* fix: fix the ci pipeline for metamask task issue
* chore: fix global fetch issue
---
merged-packages/bitcoin-wallet-snap/jest.setup.ts | 1 -
.../bitcoin/data-client/clients/blockstream.test.ts | 10 +++++++++-
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/merged-packages/bitcoin-wallet-snap/jest.setup.ts b/merged-packages/bitcoin-wallet-snap/jest.setup.ts
index ec031f6e..27a77684 100644
--- a/merged-packages/bitcoin-wallet-snap/jest.setup.ts
+++ b/merged-packages/bitcoin-wallet-snap/jest.setup.ts
@@ -4,4 +4,3 @@ import { WalletMock } from './test/wallet.mock.test';
const globalAny: any = global;
globalAny.snap = new WalletMock();
-globalAny.fetch = jest.fn();
diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.test.ts
index b84acec6..1e35545d 100644
--- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.test.ts
+++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.test.ts
@@ -13,7 +13,15 @@ jest.mock('../../../logger/logger', () => ({
describe('BlockStreamClient', () => {
const createMockFetch = () => {
- const fetchSpy = fetch as jest.Mock;
+ // eslint-disable-next-line no-restricted-globals
+ Object.defineProperty(global, 'fetch', {
+ writable: true,
+ });
+
+ const fetchSpy = jest.fn();
+ // eslint-disable-next-line no-restricted-globals
+ global.fetch = fetchSpy;
+
return {
fetchSpy,
};
From c17943b2313677571d9700fe0dd3c1e27e308b1d Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 18 Apr 2024 18:00:01 +0800
Subject: [PATCH 005/362] build(deps-dev): bump @metamask/snaps-jest from 6.0.2
to 7.0.2 (#7)
Bumps @metamask/snaps-jest from 6.0.2 to 7.0.2.
---
updated-dependencies:
- dependency-name: "@metamask/snaps-jest"
dependency-type: direct:development
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com>
---
merged-packages/bitcoin-wallet-snap/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json
index a0e24ddd..ee439043 100644
--- a/merged-packages/bitcoin-wallet-snap/package.json
+++ b/merged-packages/bitcoin-wallet-snap/package.json
@@ -47,7 +47,7 @@
"@metamask/eslint-config-nodejs": "^12.1.0",
"@metamask/eslint-config-typescript": "^12.1.0",
"@metamask/snaps-cli": "^6.1.0",
- "@metamask/snaps-jest": "^6.0.2",
+ "@metamask/snaps-jest": "^7.0.2",
"@typescript-eslint/eslint-plugin": "^5.42.1",
"@typescript-eslint/parser": "^5.42.1",
"bip174": "^2.1.1",
From efa3a4ccb70e0363541eca1032d2a2e2ae405244 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 18 Apr 2024 18:03:11 +0800
Subject: [PATCH 006/362] build(deps): bump @metamask/keyring-api from 5.1.0 to
6.0.0 (#6)
Bumps [@metamask/keyring-api](https://github.com/MetaMask/keyring-api) from 5.1.0 to 6.0.0.
- [Release notes](https://github.com/MetaMask/keyring-api/releases)
- [Changelog](https://github.com/MetaMask/keyring-api/blob/main/CHANGELOG.md)
- [Commits](https://github.com/MetaMask/keyring-api/compare/v5.1.0...v6.0.0)
---
updated-dependencies:
- dependency-name: "@metamask/keyring-api"
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com>
---
merged-packages/bitcoin-wallet-snap/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json
index ee439043..661936cc 100644
--- a/merged-packages/bitcoin-wallet-snap/package.json
+++ b/merged-packages/bitcoin-wallet-snap/package.json
@@ -29,7 +29,7 @@
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.1.1",
"@metamask/key-tree": "^9.0.0",
- "@metamask/keyring-api": "^5.1.0",
+ "@metamask/keyring-api": "^6.0.0",
"@metamask/snaps-sdk": "^4.0.0",
"async-mutex": "^0.3.2",
"bip32": "^4.0.0",
From 6a89ff8f97564dfd12b82ba9042040bf0429319e Mon Sep 17 00:00:00 2001
From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com>
Date: Thu, 18 Apr 2024 18:48:59 +0800
Subject: [PATCH 007/362] chore: update snap icon (#9)
---
.../bitcoin-wallet-snap/images/icon.svg | 19 ++++++-------------
1 file changed, 6 insertions(+), 13 deletions(-)
diff --git a/merged-packages/bitcoin-wallet-snap/images/icon.svg b/merged-packages/bitcoin-wallet-snap/images/icon.svg
index c6f77a0f..a64d9c44 100644
--- a/merged-packages/bitcoin-wallet-snap/images/icon.svg
+++ b/merged-packages/bitcoin-wallet-snap/images/icon.svg
@@ -1,17 +1,10 @@
-