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 @@ - - - - - - + + + - - - - - - - + + + From 070cc75f23ab89c57bca44e63a5d24d669114814 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 18 Apr 2024 19:16:13 +0800 Subject: [PATCH 008/362] chore: update keyring type and methods permission (#10) * chore: update keyring type and methods permission * chore: update account test * fix: incorrect account type in keyring --- .../src/modules/bitcoin/account/account.test.ts | 3 ++- .../src/modules/bitcoin/account/account.ts | 12 ++++++++---- .../src/modules/bitcoin/account/constants.ts | 6 +++--- .../src/modules/bitcoin/account/factory.test.ts | 2 +- .../src/modules/bitcoin/account/manager.ts | 1 + .../src/modules/bitcoin/account/types.ts | 3 ++- .../bitcoin-wallet-snap/src/modules/config/index.ts | 2 +- .../src/modules/keyring/keyring.test.ts | 12 +++++------- .../src/modules/keyring/keyring.ts | 5 ++--- merged-packages/bitcoin-wallet-snap/test/utils.ts | 5 ++--- 10 files changed, 27 insertions(+), 24 deletions(-) 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 index 0ffd160c..bd4cdb02 100644 --- 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 @@ -29,7 +29,8 @@ describe('BtcAccount', () => { hdPath, 'ddddddddddddd', network, - ScriptType.P2wpkh, + P2WPKHAccount.scriptType, + `bip122:${P2WPKHAccount.scriptType.toLowerCase()}`, { sign: signerSpy } as unknown as IAccountSigner, ); 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 index 69af3a12..9d1410be 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.ts @@ -21,7 +21,9 @@ export class BtcAccount implements IBtcAccount { readonly network: Network; - readonly type: ScriptType; + readonly scriptType: ScriptType; + + readonly type: string; readonly signer: IAccountSigner; @@ -31,7 +33,8 @@ export class BtcAccount implements IBtcAccount { hdPath: string, pubkey: string, network: Network, - type: ScriptType, + scriptType: ScriptType, + type: string, signer: IAccountSigner, ) { this.mfp = mfp; @@ -39,8 +42,9 @@ export class BtcAccount implements IBtcAccount { this.hdPath = hdPath; this.pubkey = pubkey; this.network = network; - this.type = type; + this.scriptType = scriptType; this.signer = signer; + this.type = type; } get address() { @@ -56,7 +60,7 @@ export class BtcAccount implements IBtcAccount { get payment() { if (!this.#payment) { this.#payment = AddressHelper.getPayment( - this.type, + this.scriptType, Buffer.from(this.pubkey, 'hex'), this.network, ); 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 index d173233a..3512876c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/constants.ts @@ -1,5 +1,5 @@ export enum ScriptType { - P2pkh = 'P2PKH', - P2shP2wkh = 'P2SH-P2WPKH', - P2wpkh = 'P2WPKH', + P2pkh = 'p2pkh', + P2shP2wkh = 'p2sh-p2wpkh', + P2wpkh = 'p2wpkh', } 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 index bbefd1eb..5dedd9c0 100644 --- 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 @@ -42,7 +42,7 @@ describe('BtcAccountMgrFactory', () => { const instance = BtcAccountMgrFactory.create( { defaultAccountIndex: 0, - defaultAccountType: 'P2WPKH', + defaultAccountType: ScriptType.P2wpkh, deriver: 'BIP32', }, networks.testnet, 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 7811689a..92237923 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 @@ -39,6 +39,7 @@ export class BtcAccountMgr implements IAccountMgr { childNode.publicKey.toString('hex'), this.network, AccountCtor.scriptType, + `bip122:${AccountCtor.scriptType.toLowerCase()}`, this.getHdSigner(rootNode), ); } catch (error) { 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 index 300e40ec..89ed4e5c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/types.ts @@ -32,7 +32,8 @@ export type IStaticBtcAccount = { hdPath: string, pubkey: string, network: Network, - type: ScriptType, + scriptType: ScriptType, + type: string, signer: IAccountSigner, ): IBtcAccount; }; 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 db8c69d4..41a11289 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts @@ -42,7 +42,7 @@ export const Config: SnapConfig = { account: { [Chain.Bitcoin]: { defaultAccountIndex: 0, - defaultAccountType: 'P2WPKH', + defaultAccountType: 'p2wpkh', deriver: 'BIP32', }, }, 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 index 02183616..db2783ba 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts @@ -84,7 +84,7 @@ describe('BtcKeyring', () => { address: account.address, hdPath: account.options.hdPath, index: account.options.index, - type: account.options.type, + type: account.type, }); await keyring.createAccount(); @@ -99,9 +99,8 @@ describe('BtcKeyring', () => { options: { hdPath: account.options.hdPath, index: account.options.index, - type: account.options.type, }, - methods: [], + methods: account.methods, }); }); @@ -121,7 +120,7 @@ describe('BtcKeyring', () => { address: account.address, hdPath: account.options.hdPath, index: account.options.index, - type: account.options.type, + type: account.type, }); await keyring.createAccount({ index: 1 }); @@ -134,9 +133,8 @@ describe('BtcKeyring', () => { options: { hdPath: account.options.hdPath, index: account.options.index, - type: account.options.type, }, - methods: [], + methods: account.methods, }); }); @@ -156,7 +154,7 @@ describe('BtcKeyring', () => { address: account.address, hdPath: account.options.hdPath, index: account.options.index, - type: account.options.type, + type: account.type, }); const acc = await keyring.createAccount(); 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 09cce4f9..b650126e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts @@ -111,15 +111,14 @@ export class BtcKeyring implements Keyring { protected newKeyringAccount(account: IAccount): KeyringAccount { return { - type: 'bip32', + type: account.type, id: uuidv4(), address: account.address, options: { hdPath: account.hdPath, index: account.index, - type: account.type, }, - methods: [], + methods: ['chain_getBalances'], } as unknown as KeyringAccount; } } diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index 4f9749ee..f4e8477b 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -22,7 +22,7 @@ export function generateAccounts(cnt = 1, addressPrefix = '', idPrefix = '') { for (let i = 0; i < cnt; i++) { const hdPath = [`m`, `0'`, `0`, `${i}`].join('/'); accounts.push({ - type: 'bip32', + type: 'bip122:p2wpkh', id: baseUUID.slice(0, baseUUID.length - i.toString().length) + i.toString(), address: @@ -31,9 +31,8 @@ export function generateAccounts(cnt = 1, addressPrefix = '', idPrefix = '') { options: { hdPath, index: i, - type: 'P2WPKH', }, - methods: [], + methods: ['chain_getBalances'], }); } From 44f4a6e128f37f7f22753a1e826a5d6036d67315 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 19 Apr 2024 13:03:37 +0800 Subject: [PATCH 009/362] feat: add blockchair and update getBalances to accept multiple address (#11) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/index.ts | 7 +- .../src/modules/async/helpers.test.ts | 15 ++ .../src/modules/async/helpers.ts | 19 +++ .../src/modules/async/index.ts | 1 + .../src/modules/bitcoin/config/constants.ts | 9 +- .../data-client/clients/blockchair.test.ts | 131 ++++++++++++++++++ .../bitcoin/data-client/clients/blockchair.ts | 98 +++++++++++++ .../data-client/clients/blockstream.test.ts | 76 +++++----- .../data-client/clients/blockstream.ts | 46 +++--- .../bitcoin/data-client/factory.test.ts | 18 ++- .../modules/bitcoin/data-client/factory.ts | 4 + .../src/modules/bitcoin/data-client/types.ts | 4 +- .../bitcoin/transaction/manager.test.ts | 63 +++++++-- .../modules/bitcoin/transaction/manager.ts | 50 ++++++- .../src/modules/config/index.ts | 2 +- .../modules/transaction/transaction.test.ts | 29 ++-- .../src/modules/transaction/transaction.ts | 9 +- .../src/modules/transaction/types.ts | 16 ++- .../bitcoin-wallet-snap/src/rpcs/index.ts | 1 + .../{get-balance.ts => get-balances.ts} | 23 +-- .../test/fixtures/blockchair.json | 31 +++++ .../test/fixtures/blockstream.json | 2 +- .../bitcoin-wallet-snap/test/utils.ts | 61 +++++++- 24 files changed, 601 insertions(+), 116 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/async/helpers.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/async/helpers.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/async/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts rename merged-packages/bitcoin-wallet-snap/src/rpcs/methods/{get-balance.ts => get-balances.ts} (58%) create mode 100644 merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 964ddc0c..6693f25c 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "QYsZ8HVb8OmMmPlePs8t9NbV0CIZkt7KucFBw/CkEuQ=", + "shasum": "SeWMmXKfuQ7q4TZScQ1ANgp9ssppSHYNzR2csvh9SpU=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 0e3626cf..d45ce261 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -6,8 +6,7 @@ import type { SnapRpcRequestHandlerRequest, IStaticSnapRpcRequestHandler, } from './rpcs'; -import { CreateAccountHandler } from './rpcs'; -import { GetBalanceHandler } from './rpcs/methods/get-balance'; +import { CreateAccountHandler, GetBalancesHandler } from './rpcs'; const validateOrigin = async (origin: string) => { // TODO: validate origin @@ -20,8 +19,8 @@ const getHandler = (method: string): IStaticSnapRpcRequestHandler => { switch (method) { case 'bitcoin_createAccount': return CreateAccountHandler; - case 'bitcoin_getBalance': - return GetBalanceHandler; + case 'bitcoin_getBalances': + return GetBalancesHandler; default: throw new SnapError(`Method not found`); } diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/async/helpers.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/async/helpers.test.ts new file mode 100644 index 00000000..8e442a65 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/async/helpers.test.ts @@ -0,0 +1,15 @@ +import { expect, jest } from '@jest/globals'; + +import { AsyncHelper } from './helpers'; + +describe('AsyncHelper', function () { + describe('processBatch', function () { + it('processes the array in batch', async function () { + const fn = jest.fn() as any; + const mockArr = new Array(99).fill(0); + + await AsyncHelper.processBatch(mockArr, fn); + expect(fn).toHaveBeenCalledTimes(mockArr.length); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/async/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/modules/async/helpers.ts new file mode 100644 index 00000000..fe385984 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/async/helpers.ts @@ -0,0 +1,19 @@ +export class AsyncHelper { + static async processBatch( + arr: Data[], + callback: (item: Data) => Promise, + batchSize = 50, + ): Promise { + let from = 0; + let to = batchSize; + while (from < arr.length) { + const batch: Promise[] = []; + for (let i = from; i < Math.min(to, arr.length); i++) { + batch.push(callback(arr[i])); + } + await Promise.all(batch); + from += batchSize; + to += batchSize; + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/async/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/async/index.ts new file mode 100644 index 00000000..c5f595cf --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/async/index.ts @@ -0,0 +1 @@ +export * from './helpers'; 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 index 4bea3450..ee2386d9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/constants.ts @@ -1,9 +1,14 @@ export enum Network { - Mainnet = '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f', - Testnet = '000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943', + Mainnet = 'bip122:000000000019d6689c085ae165831e93', + Testnet = 'bip122:000000000933ea01ad0ee984209779ba', } export enum DataClient { BlockStream = 'BlockStream', BlockChair = 'BlockChair', } + +export enum BtcAsset { + Btc = 'bip122:000000000019d6689c085ae165831e93:slip44:0', + TBtc = 'bip122:000000000933ea01ad0ee984209779ba:slip44:0', +} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts new file mode 100644 index 00000000..f93a1c2a --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts @@ -0,0 +1,131 @@ +import { networks } from 'bitcoinjs-lib'; + +import { + generateAccounts, + generateBlockChairGetBalanceResp, +} from '../../../../../test/utils'; +import { DataClientError } from '../exceptions'; +import { BlockChairClient } from './blockchair'; + +jest.mock('../../../logger/logger', () => ({ + logger: { + info: jest.fn(), + }, +})); + +describe('BlockChairClient', () => { + const createMockFetch = () => { + // 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, + }; + }; + + describe('baseUrl', () => { + it('returns testnet network url', () => { + const instance = new BlockChairClient({ network: networks.testnet }); + expect(instance.baseUrl).toBe( + 'https://api.blockchair.com/bitcoin/testnet', + ); + }); + + it('returns mainnet network url', () => { + const instance = new BlockChairClient({ network: networks.bitcoin }); + expect(instance.baseUrl).toBe('https://api.blockchair.com/bitcoin'); + }); + + it('throws `Invalid network` error if the given network is not support', () => { + const instance = new BlockChairClient({ network: networks.regtest }); + expect(() => instance.baseUrl).toThrow('Invalid network'); + }); + }); + + describe('getBalances', () => { + it('returns balances', async () => { + const { fetchSpy } = createMockFetch(); + const accounts = generateAccounts(10); + const addresses = accounts.map((account) => account.address); + const mockResponse = generateBlockChairGetBalanceResp(addresses); + const expectedResult = mockResponse.data; + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const instance = new BlockChairClient({ network: networks.testnet }); + const result = await instance.getBalances(addresses); + + expect(result).toStrictEqual(expectedResult); + }); + + it('assigns balance to 0 if account is not exist', async () => { + const { fetchSpy } = createMockFetch(); + const accounts = generateAccounts(2); + const accountWithNoBalance = generateAccounts(1, 'notexist')[0]; + const addresses = accounts.map((account) => account.address); + const mockResponse = generateBlockChairGetBalanceResp(addresses); + + const expectedResult = { + ...mockResponse.data, + [accountWithNoBalance.address]: 0, + }; + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const instance = new BlockChairClient({ network: networks.testnet }); + const result = await instance.getBalances( + addresses.concat(accountWithNoBalance.address), + ); + + expect(result).toStrictEqual(expectedResult); + }); + + it('throws DataClientError error if an non DataClientError catched', async () => { + const { fetchSpy } = createMockFetch(); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockRejectedValue(new Error('error')), + }); + + const accounts = generateAccounts(1); + const addresses = accounts.map((account) => account.address); + + const instance = new BlockChairClient({ network: networks.testnet }); + + await expect(instance.getBalances(addresses)).rejects.toThrow( + DataClientError, + ); + }); + + it('throws DataClientError error if an DataClientError catched', async () => { + const { fetchSpy } = createMockFetch(); + + fetchSpy.mockResolvedValueOnce({ + ok: false, + json: jest.fn().mockResolvedValue(null), + }); + + const accounts = generateAccounts(1); + const addresses = accounts.map((account) => account.address); + + const instance = new BlockChairClient({ network: networks.testnet }); + + await expect(instance.getBalances(addresses)).rejects.toThrow( + DataClientError, + ); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts new file mode 100644 index 00000000..b49c9569 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts @@ -0,0 +1,98 @@ +import { type Network, networks } from 'bitcoinjs-lib'; + +import { logger } from '../../../logger/logger'; +import { type Balances } from '../../../transaction'; +import { DataClientError } from '../exceptions'; +import type { IReadDataClient } from '../types'; + +export type BlockChairClientOptions = { + network: Network; +}; + +/* eslint-disable */ +export type GetBalanceResponse = { + data: { + [address: string]: number; + }; + context: { + code: number; + source: string; + results: number; + state: number; + market_price_usd: number; + cache: { + live: boolean; + duration: number; + since: string; + until: string; + time: null; + }; + api: { + version: string; + last_major_update: string; + next_major_update: string; + documentation: string; + notice: string; + }; + servers: string; + time: number; + render_time: number; + full_time: number; + request_cost: number; + }; +}; +/* eslint-disable */ + +export class BlockChairClient implements IReadDataClient { + options: BlockChairClientOptions; + + constructor(options: BlockChairClientOptions) { + this.options = options; + } + + get baseUrl(): string { + switch (this.options.network) { + case networks.bitcoin: + return 'https://api.blockchair.com/bitcoin'; + case networks.testnet: + return 'https://api.blockchair.com/bitcoin/testnet'; + default: + throw new DataClientError('Invalid network'); + } + } + + 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 blockchair: ${response.statusText}`, + ); + } + return response.json() as unknown as Resp; + } + + async getBalances(addresses: string[]): Promise { + try { + const response = await this.get( + `/addresses/balances?addresses=${addresses.join(',')}`, + ); + + logger.info( + `[BlockChairClient.getBalance] response: ${JSON.stringify(response)}`, + ); + + return addresses.reduce((data: Balances, address: string) => { + data[address] = response.data[address] ?? 0; + return data; + }, {}); + } 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/clients/blockstream.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.test.ts index 1e35545d..b7110e5d 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 @@ -1,7 +1,10 @@ import { networks } from 'bitcoinjs-lib'; -import blocksteamData from '../../../../../test/fixtures/blockstream.json'; -import { generateAccounts } from '../../../../../test/utils'; +import { + generateAccounts, + generateBlockStreamAccountStats, +} from '../../../../../test/utils'; +import { AsyncHelper } from '../../../async'; import { DataClientError } from '../exceptions'; import { BlockStreamClient } from './blockstream'; @@ -44,58 +47,65 @@ describe('BlockStreamClient', () => { }); }); - describe('getBalance', () => { + describe('getBalances', () => { it('returns balances', async () => { const { fetchSpy } = createMockFetch(); - fetchSpy.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(blocksteamData.accountInfo), + const accounts = generateAccounts(60); + const addresses = accounts.map((account) => account.address); + const mockResponse = generateBlockStreamAccountStats(addresses); + const expectedResult = {}; + mockResponse.forEach((data) => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(data), + }); + expectedResult[data.address] = + data.chain_stats.funded_txo_sum - data.chain_stats.spent_txo_sum; }); - 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, - }); + const result = await instance.getBalances(addresses); + + expect(result).toStrictEqual(expectedResult); }); - it('throws `503` error if fetch status is 503', async () => { + it('assigns balance to 0 if it failed to fetch', async () => { const { fetchSpy } = createMockFetch(); fetchSpy.mockResolvedValue({ ok: false, statusText: '503', }); - const account = generateAccounts(1)[0]; + const accounts = generateAccounts(1); + const addresses = accounts.map((account) => account.address); const instance = new BlockStreamClient({ network: networks.testnet }); + const result = await instance.getBalances(addresses); - await expect(instance.getBalance(account.address)).rejects.toThrow( - 'Failed to fetch data from blockstream: 503', - ); + expect(result).toStrictEqual({ [addresses[0]]: 0 }); }); 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 asyncHelperSpy = jest.spyOn(AsyncHelper, 'processBatch'); + asyncHelperSpy.mockRejectedValue(new Error('error')); + const accounts = generateAccounts(1); + const addresses = accounts.map((account) => account.address); + + const instance = new BlockStreamClient({ network: networks.testnet }); + + await expect(instance.getBalances(addresses)).rejects.toThrow( + DataClientError, + ); + }); + + it('throws DataClientError error if an DataClientError catched', async () => { + const asyncHelperSpy = jest.spyOn(AsyncHelper, 'processBatch'); + asyncHelperSpy.mockRejectedValue(new DataClientError('error')); + const accounts = generateAccounts(1); + const addresses = accounts.map((account) => account.address); const instance = new BlockStreamClient({ network: networks.testnet }); - await expect(instance.getBalance(account.address)).rejects.toThrow( + await expect(instance.getBalances(addresses)).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 c535f99e..0593441d 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 @@ -1,7 +1,8 @@ import { type Network, networks } from 'bitcoinjs-lib'; +import { AsyncHelper } from '../../../async'; import { logger } from '../../../logger/logger'; -import { type Balance } from '../../../transaction'; +import { type Balances } from '../../../transaction'; import { DataClientError } from '../exceptions'; import type { IReadDataClient } from '../types'; @@ -60,27 +61,34 @@ export class BlockStreamClient implements IReadDataClient { return response.json() as unknown as Resp; } - async getBalance(address: string): Promise { + async getBalances(addresses: string[]): Promise { try { - const response = await this.get( - `/address/${address}`, - ); + const responses: Balances = {}; - logger.info( - `[BlockStreamClient.getBalance] response: ${JSON.stringify(response)}`, - ); + await AsyncHelper.processBatch(addresses, async (address: string) => { + logger.info(`[BlockStreamClient.getBalance] address: ${address}`); + let balance = 0; + try { + const response = await this.get( + `/address/${address}`, + ); + logger.info( + `[BlockStreamClient.getBalance] response: ${JSON.stringify( + response, + )}`, + ); + balance = + response.chain_stats.funded_txo_sum - + response.chain_stats.spent_txo_sum; + } catch (error) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.info(`[BlockStreamClient.getBalance] error: ${error.message}`); + } finally { + responses[address] = balance; + } + }); - 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, - }; + return responses; } catch (error) { if (error instanceof DataClientError) { throw error; 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 index 94a8e00d..d11985bb 100644 --- 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 @@ -1,6 +1,7 @@ import { networks } from 'bitcoinjs-lib'; import { DataClient } from '../config'; +import { BlockChairClient } from './clients/blockchair'; import { BlockStreamClient } from './clients/blockstream'; import { DataClientFactory } from './factory'; @@ -15,13 +16,26 @@ describe('DataClientFactory', () => { expect(instance).toBeInstanceOf(BlockStreamClient); }); + it('creates BlockChairClient', () => { + const instance = DataClientFactory.createReadClient( + { dataClient: { read: { type: DataClient.BlockChair } } }, + networks.testnet, + ); + + expect(instance).toBeInstanceOf(BlockChairClient); + }); + it('throws `Unsupported client type` if the given client is not support', () => { expect(() => DataClientFactory.createReadClient( - { dataClient: { read: { type: DataClient.BlockChair } } }, + { + dataClient: { + read: { type: 'SomeClient' as unknown as DataClient }, + }, + }, networks.testnet, ), - ).toThrow('Unsupported client type: BlockChair'); + ).toThrow('Unsupported client type: SomeClient'); }); }); }); 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 index f37ce7fe..18a28fb6 100644 --- 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 @@ -1,6 +1,7 @@ import { type Network } from 'bitcoinjs-lib'; import { DataClient, type BtcTransactionConfig } from '../config'; +import { BlockChairClient } from './clients/blockchair'; import { BlockStreamClient } from './clients/blockstream'; import { DataClientError } from './exceptions'; import type { IReadDataClient } from './types'; @@ -13,8 +14,11 @@ export class DataClientFactory { switch (config.dataClient.read.type) { case DataClient.BlockStream: return new BlockStreamClient({ network }); + case DataClient.BlockChair: + return new BlockChairClient({ network }); default: throw new DataClientError( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Unsupported client type: ${config.dataClient.read.type}`, ); } 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 index a55756ad..1c64b971 100644 --- 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 @@ -1,7 +1,7 @@ -import { type Balance } from '../../transaction'; +import { type Balances } from '../../transaction'; export type IReadDataClient = { - getBalance(address: string): Promise; + getBalances(address: string[]): Promise; }; export type IWriteDataClient = { 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 index e083c6bc..30f6573b 100644 --- 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 @@ -1,6 +1,8 @@ +import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; import { generateAccounts } from '../../../../test/utils'; +import { BtcAsset } from '../config'; import type { IReadDataClient } from '../data-client'; import { BtcTransactionMgr } from './manager'; @@ -9,7 +11,7 @@ describe('BtcTransactionMgr', () => { const getBalanceSpy = jest.fn(); class MockReadDataClient implements IReadDataClient { - getBalance = getBalanceSpy; + getBalances = getBalanceSpy; } return { instance: new MockReadDataClient(), @@ -17,9 +19,12 @@ describe('BtcTransactionMgr', () => { }; }; - const createMockBtcTransactionMgr = (readDataClient: IReadDataClient) => { + const createMockBtcTransactionMgr = ( + readDataClient: IReadDataClient, + network: Network = networks.testnet, + ) => { const instance = new BtcTransactionMgr(readDataClient, { - network: networks.bitcoin, + network, }); return { @@ -28,23 +33,57 @@ describe('BtcTransactionMgr', () => { }; describe('getBalance', () => { - it('calls getBalance with readClient', async () => { + it('calls getBalances with readClient', async () => { const { instance, getBalanceSpy } = createMockReadDataClient(); const { instance: txnMgr } = createMockBtcTransactionMgr(instance); - const account = generateAccounts(1)[0]; + const accounts = generateAccounts(2); + const addresses = accounts.map((account) => account.address); + getBalanceSpy.mockResolvedValue( + addresses.reduce((acc, address) => { + acc[address] = 100; + return acc; + }, {}), + ); - await txnMgr.getBalance(account.address); + await txnMgr.getBalances(addresses, [BtcAsset.TBtc]); - expect(getBalanceSpy).toHaveBeenCalledWith(account.address); + expect(getBalanceSpy).toHaveBeenCalledWith(addresses); }); - it('throws TransactionMgrError if the getBalance failed', async () => { - const { instance, getBalanceSpy } = createMockReadDataClient(); + it('throws `Only one asset is supported` error if the given assert more than 1', async () => { + const { instance } = createMockReadDataClient(); + const { instance: txnMgr } = createMockBtcTransactionMgr(instance); + const accounts = generateAccounts(2); + const addresses = accounts.map((account) => account.address); + + await expect( + txnMgr.getBalances(addresses, [BtcAsset.TBtc, BtcAsset.Btc]), + ).rejects.toThrow('Only one asset is supported'); + }); + + it('throws `Invalid asset` error if the BTC assert is given and current network is testnet network', async () => { + const { instance } = createMockReadDataClient(); const { instance: txnMgr } = createMockBtcTransactionMgr(instance); - getBalanceSpy.mockRejectedValue(new Error('error')); - const account = generateAccounts(1)[0]; + const accounts = generateAccounts(2); + const addresses = accounts.map((account) => account.address); + + await expect( + txnMgr.getBalances(addresses, [BtcAsset.Btc]), + ).rejects.toThrow('Invalid asset'); + }); + + it('throws `Invalid asset` error if the TBTC assert is given and current network is bitcoin network', async () => { + const { instance } = createMockReadDataClient(); + const { instance: txnMgr } = createMockBtcTransactionMgr( + instance, + networks.bitcoin, + ); + const accounts = generateAccounts(2); + const addresses = accounts.map((account) => account.address); - await expect(txnMgr.getBalance(account.address)).rejects.toThrow('error'); + await expect( + txnMgr.getBalances(addresses, [BtcAsset.TBtc]), + ).rejects.toThrow('Invalid asset'); }); }); }); 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 d08c2506..9b864838 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,4 +1,12 @@ -import type { ITransactionMgr, Balance } from '../../transaction/types'; +import type { Network } from 'bitcoinjs-lib'; +import { networks } from 'bitcoinjs-lib'; + +import type { + ITransactionMgr, + Balances, + AssetBalances, +} from '../../transaction/types'; +import { BtcAsset } from '../config'; import { type IReadDataClient } from '../data-client'; import { TransactionMgrError } from './exceptions'; import type { BtcTransactionMgrOptions } from './types'; @@ -13,10 +21,44 @@ export class BtcTransactionMgr implements ITransactionMgr { this.options = options; } - async getBalance(address: string): Promise { + get network(): Network { + return this.options.network; + } + + async getBalances( + addresses: string[], + assets: string[], + ): Promise { try { - const response = await this.readClient.getBalance(address); - return response; + if (assets.length > 1) { + throw new TransactionMgrError('Only one asset is supported'); + } + + const allowedAssets = new Set( + Object.entries(BtcAsset).map(([_, value]) => value.toString()), + ); + + if ( + !allowedAssets.has(assets[0]) || + (this.network === networks.testnet && assets[0] !== BtcAsset.TBtc) || + (this.network === networks.bitcoin && assets[0] !== BtcAsset.Btc) + ) { + throw new TransactionMgrError('Invalid asset'); + } + + const balance: Balances = await this.readClient.getBalances(addresses); + + return addresses.reduce( + (acc: AssetBalances, address: string) => { + acc.balances[address] = { + [assets[0]]: { + amount: balance[address], + }, + }; + return acc; + }, + { balances: {} }, + ); } catch (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 41a11289..e1e63fad 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts @@ -34,7 +34,7 @@ export const Config: SnapConfig = { [Chain.Bitcoin]: { dataClient: { read: { - type: DataClient.BlockStream, + type: DataClient.BlockChair, }, }, }, 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 index d043e1fa..d7947465 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.test.ts @@ -1,4 +1,5 @@ import { generateAccounts } from '../../../test/utils'; +import { BtcAsset } from '../bitcoin/config'; import { TransactionServiceError } from './exceptions'; import { TransactionStateManager } from './state'; import { TransactionService } from './transaction'; @@ -6,44 +7,46 @@ import type { ITransactionMgr } from './types'; describe('TransactionService', () => { const createMockTxnMgr = () => { - const getBalanceSpy = jest.fn(); + const getBalancesSpy = jest.fn(); class MockTxnMgr implements ITransactionMgr { - getBalance = getBalanceSpy; + getBalances = getBalancesSpy; } return { instance: new MockTxnMgr(), - getBalanceSpy, + getBalancesSpy, }; }; describe('getBalance', () => { - it('calls getBalance with transactionMgr', async () => { - const { instance, getBalanceSpy } = createMockTxnMgr(); + it('calls getBalances with transactionMgr', async () => { + const { instance, getBalancesSpy } = createMockTxnMgr(); const accounts = generateAccounts(1); + const addresses = accounts.map((account) => account.address); const service = new TransactionService( instance, new TransactionStateManager(), ); - await service.getBalance(accounts[0].address); + await service.getBalances(addresses, [BtcAsset.TBtc]); - expect(getBalanceSpy).toHaveBeenCalledWith(accounts[0].address); + expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [BtcAsset.TBtc]); }); - it('throws TransactionServiceError if getBalance failed', async () => { - const { instance, getBalanceSpy } = createMockTxnMgr(); - getBalanceSpy.mockRejectedValue(new Error('error')); + it('throws TransactionServiceError if getBalances failed', async () => { + const { instance, getBalancesSpy } = createMockTxnMgr(); + getBalancesSpy.mockRejectedValue(new Error('error')); const accounts = generateAccounts(1); + const addresses = accounts.map((account) => account.address); const service = new TransactionService( instance, new TransactionStateManager(), ); - await expect(service.getBalance(accounts[0].address)).rejects.toThrow( - TransactionServiceError, - ); + await expect( + service.getBalances(addresses, [BtcAsset.TBtc]), + ).rejects.toThrow(TransactionServiceError); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.ts b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.ts index 8e9e7d58..49ed8b92 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.ts @@ -1,6 +1,6 @@ import { TransactionServiceError } from './exceptions'; import type { TransactionStateManager } from './state'; -import type { Balance, ITransactionMgr } from './types'; +import type { AssetBalances, ITransactionMgr } from './types'; export class TransactionService { protected readonly transactionMgr: ITransactionMgr; @@ -15,9 +15,12 @@ export class TransactionService { this.transactionStateManager = transactionStateManager; } - async getBalance(address: string): Promise { + async getBalances( + addresses: string[], + assets: string[], + ): Promise { try { - const result = await this.transactionMgr.getBalance(address); + const result = await this.transactionMgr.getBalances(addresses, assets); 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 index 69caaf69..753d7e90 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/types.ts @@ -1,9 +1,17 @@ +export type Balances = Record; + export type Balance = { - confirmed: number; - unconfirmed: number; - total: number; + amount: number; +}; + +export type AssetBalances = { + balances: { + [address: string]: { + [asset: string]: Balance; + }; + }; }; export type ITransactionMgr = { - getBalance(address: string): Promise; + getBalances(addresses: string[], assets: string[]): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts index 57dca8dd..c6b254f2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts @@ -1,4 +1,5 @@ export * from './methods/create-account'; +export * from './methods/get-balances'; export * from './types'; export * from './base'; export * from './exceptions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balance.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts similarity index 58% rename from merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balance.ts rename to merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts index 26c4397f..15425884 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balance.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts @@ -1,9 +1,9 @@ import type { Infer } from 'superstruct'; -import { object, string, assign } from 'superstruct'; +import { object, string, assign, array } from 'superstruct'; import { Chain } from '../../modules/config'; import { Factory } from '../../modules/factory'; -import type { Balance } from '../../modules/transaction'; +import type { AssetBalances } from '../../modules/transaction'; import { TransactionService, TransactionStateManager, @@ -16,34 +16,35 @@ import type { } from '../types'; import { SnapRpcRequestHandlerRequestStruct } from '../types'; -export type GetBalanceParams = Infer; +export type GetBalancesParams = Infer; -export type GetBalanceResponse = SnapRpcRequestHandlerResponse & Balance; +export type GetBalancesResponse = SnapRpcRequestHandlerResponse & AssetBalances; -export class GetBalanceHandler +export class GetBalancesHandler extends BaseSnapRpcRequestHandler implements - StaticImplements + StaticImplements { static get validateStruct() { return assign( object({ - address: string(), + accounts: array(string()), + assets: array(string()), }), SnapRpcRequestHandlerRequestStruct, ); } - validateStruct = GetBalanceHandler.validateStruct; + validateStruct = GetBalancesHandler.validateStruct; - async handleRequest(params: GetBalanceParams): Promise { - const { scope, address } = params; + async handleRequest(params: GetBalancesParams): Promise { + const { scope, accounts, assets } = params; const txService = new TransactionService( Factory.createTransactionMgr(Chain.Bitcoin, scope), new TransactionStateManager(), ); - return await txService.getBalance(address); + return await txService.getBalances(accounts, assets); } } diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json new file mode 100644 index 00000000..d9cee728 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json @@ -0,0 +1,31 @@ +{ + "getBalanceResp": { + "data": {}, + "context": { + "code": 200, + "source": "A", + "results": 2, + "state": 2587556, + "market_price_usd": 62471, + "cache": { + "live": true, + "duration": 20, + "since": "2024-04-19 01:15:47", + "until": "2024-04-19 01:16:07", + "time": null + }, + "api": { + "version": "2.0.95-ie", + "last_major_update": "2022-11-07 02:00:00", + "next_major_update": "2023-11-12 02:00:00", + "documentation": "https://blockchair.com/api/docs", + "notice": "Plaese note that on November 12th, 2023 public support for the following blockchains will be dropped: Mixin, Ethereum Testnet (Goerli)" + }, + "servers": "API4,TBTC3", + "time": 0.4156050682067871, + "render_time": 0.0018379688262939453, + "full_time": 0.41744303703308105, + "request_cost": 1.003 + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json index 7f4cfce2..ff3211e7 100644 --- a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json +++ b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json @@ -1,5 +1,5 @@ { - "accountInfo": { + "getAccountStatsResp": { "address": "tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu", "chain_stats": { "funded_txo_count": 12, diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index f4e8477b..05de5df7 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -2,6 +2,9 @@ import type { BIP32Interface } from 'bip32'; import type { Network } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; +import blockChairData from './fixtures/blockchair.json'; +import blockStreamData from './fixtures/blockstream.json'; + /** * Method to generate testing account. * @@ -45,9 +48,9 @@ export function generateAccounts(cnt = 1, addressPrefix = '', idPrefix = '') { * @param network - Bitcoin network. * @param idx - Index of the bip32 instance. * @param depth - Depth of the bip32 instance. - * @returns An BIP32Interface instance. + * @returns An BIP32Interface instance and spys. */ -export const createMockBip32Instance = ( +export function createMockBip32Instance( network: Network, idx = 0, depth = 0, @@ -63,7 +66,7 @@ export const createMockBip32Instance = ( tweakSpy: jest.Mock; signSpy: jest.Mock; verifySpy: jest.Mock; -} => { +} { const deriveHardenedSpy = jest.fn(); const deriveSpy = jest.fn(); const derivePathSpy = jest.fn(); @@ -142,4 +145,54 @@ export const createMockBip32Instance = ( signSpy, verifySpy, }; -}; +} + +const randomNum = (max) => Math.floor(Math.random() * max); +/** + * Method to generate blockstream account stats resp by addresses. + * + * @param addresses - Array of address in string. + * @returns An array of blocksteam stats response. + */ +export function generateBlockStreamAccountStats(addresses: string[]) { + const template = blockStreamData.getAccountStatsResp; + const resp: (typeof template)[] = []; + for (const address of addresses) { + /* eslint-disable */ + resp.push({ + ...template, + address, + chain_stats: { + funded_txo_count: randomNum(100), + funded_txo_sum: Math.max(randomNum(1000000), 10000), + spent_txo_count: randomNum(100), + spent_txo_sum: randomNum(10000), + tx_count: randomNum(100), + }, + mempool_stats: { + funded_txo_count: randomNum(100), + funded_txo_sum: Math.max(randomNum(1000000), 10000), + spent_txo_count: randomNum(100), + spent_txo_sum: randomNum(10000), + tx_count: randomNum(100), + }, + }); + /* eslint-disable */ + } + return resp; +} + +/** + * Method to generate blockchair getBalance resp by addresses. + * + * @param addresses - Array of address in string. + * @returns An array of blockchair getBalance response. + */ +export function generateBlockChairGetBalanceResp(addresses: string[]) { + const template = blockChairData.getBalanceResp; + const resp: typeof template = { ...template, data: {} }; + for (const address of addresses) { + resp.data[address] = randomNum(1000000); + } + return resp; +} From 80c848c53ea236fb13428a4bc2f0ce26476ee6f4 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 22 Apr 2024 14:11:07 +0800 Subject: [PATCH 010/362] feat: implement keyring api (#12) * feat: implement keyring api * chore: update keyring api implementation * feat: add TODO comment --- .../bitcoin-wallet-snap/snap.manifest.json | 14 +- .../bitcoin-wallet-snap/src/index.ts | 20 +- .../modules/bitcoin/account/factory.test.ts | 3 + .../src/modules/bitcoin/config/types.ts | 1 + .../src/modules/config/index.ts | 3 + .../src/modules/factory.test.ts | 2 +- .../src/modules/factory.ts | 20 +- .../src/modules/keyring/keyring.test.ts | 191 ++++++----------- .../src/modules/keyring/keyring.ts | 115 ++++++---- .../src/modules/keyring/state.test.ts | 196 ++++++++++++++---- .../src/modules/keyring/state.ts | 93 ++++++--- .../src/modules/keyring/types.ts | 5 +- .../src/rpcs/methods/create-account.ts | 4 +- .../bitcoin-wallet-snap/src/types/state.ts | 11 +- .../bitcoin-wallet-snap/test/utils.ts | 4 +- 15 files changed, 419 insertions(+), 263 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 6693f25c..5dc62f3f 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "SeWMmXKfuQ7q4TZScQ1ANgp9ssppSHYNzR2csvh9SpU=", + "shasum": "2aagAzXrGS6z6HJjwYFNS1lHWzs099t+ZyMD/LX52+g=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -18,11 +18,17 @@ } }, "initialPermissions": { - "snap_dialog": {}, "endowment:rpc": { "dapps": true, "snaps": false }, + "endowment:keyring": { + "allowedOrigins": [ + "https://metamask.github.io", + "https://portfolio.metamask.io", + "http://localhost:8000" + ] + }, "snap_getBip32Entropy": [ { "path": ["m", "49'", "0'"], @@ -42,7 +48,9 @@ } ], "endowment:network-access": {}, - "snap_manageState": {} + "snap_manageAccounts": {}, + "snap_manageState": {}, + "snap_dialog": {} }, "manifestVersion": "0.1" } diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index d45ce261..2eb76ea0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -1,6 +1,13 @@ -import type { OnRpcRequestHandler } from '@metamask/snaps-sdk'; -import { SnapError } from '@metamask/snaps-sdk'; - +import { handleKeyringRequest } from '@metamask/keyring-api'; +import { + type OnRpcRequestHandler, + type OnKeyringRequestHandler, + type Json, + SnapError, +} from '@metamask/snaps-sdk'; + +import { Chain } from './modules/config'; +import { Factory } from './modules/factory'; import { LogLevel, logger } from './modules/logger/logger'; import type { SnapRpcRequestHandlerRequest, @@ -41,3 +48,10 @@ export const onRpcRequest: OnRpcRequestHandler = async (args) => { .getInstance() .execute(request.params as SnapRpcRequestHandlerRequest); }; + +export const onKeyringRequest: OnKeyringRequestHandler = async ({ + request, +}): Promise => { + const keyring = Factory.createKeyring(Chain.Bitcoin); + return handleKeyringRequest(keyring, request) as unknown as Promise; +}; 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 index 5dedd9c0..d2d1d1ed 100644 --- 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 @@ -44,6 +44,7 @@ describe('BtcAccountMgrFactory', () => { defaultAccountIndex: 0, defaultAccountType: ScriptType.P2wpkh, deriver: 'BIP32', + enableMultiAccounts: false, }, networks.testnet, ) as unknown as MockBtcAccountMgr; @@ -61,6 +62,7 @@ describe('BtcAccountMgrFactory', () => { defaultAccountIndex: 0, defaultAccountType: ScriptType.P2wpkh, deriver: 'BIP44', + enableMultiAccounts: false, }, networks.testnet, ) as unknown as MockBtcAccountMgr; @@ -77,6 +79,7 @@ describe('BtcAccountMgrFactory', () => { defaultAccountIndex: 0, defaultAccountType: ScriptType.P2shP2wkh, deriver: 'BIP32', + enableMultiAccounts: false, }, networks.testnet, ), 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 403950d3..54bb1b42 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 @@ -9,6 +9,7 @@ export type BtcTransactionConfig = { }; export type BtcAccountConfig = { + enableMultiAccounts: boolean; defaultAccountIndex: number; defaultAccountType: string; deriver: string; 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 e1e63fad..3b451fec 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts @@ -27,6 +27,7 @@ export type SnapConfig = { avaliableNetworks: { [key in Chain]: string[]; }; + chain: Chain; }; export const Config: SnapConfig = { @@ -41,6 +42,7 @@ export const Config: SnapConfig = { }, account: { [Chain.Bitcoin]: { + enableMultiAccounts: false, defaultAccountIndex: 0, defaultAccountType: 'p2wpkh', deriver: 'BIP32', @@ -50,4 +52,5 @@ export const Config: SnapConfig = { // eslint-disable-next-line @typescript-eslint/no-unused-vars [Chain.Bitcoin]: Object.entries(BtcNetwork).map(([_, val]) => val), }, + chain: Chain.Bitcoin, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts index b307cae8..68f1fe89 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts @@ -27,7 +27,7 @@ describe('Factory', () => { describe('createKeyring', () => { it('creates BtcKeyring instance', () => { - const instance = Factory.createKeyring(Chain.Bitcoin, Network.Testnet); + const instance = Factory.createKeyring(Chain.Bitcoin); expect(instance).toBeInstanceOf(BtcKeyring); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts index 2ead89fa..6334e892 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts @@ -9,8 +9,8 @@ import { 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 type { Chain } from './config'; import { BtcKeyring, KeyringStateManager, type IAccountMgr } from './keyring'; import type { ITransactionMgr } from './transaction/types'; @@ -32,12 +32,14 @@ export class Factory { 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 createBtcKeyring(config: BtcAccountConfig): BtcKeyring { + return new BtcKeyring( + { + defaultIndex: config.defaultAccountIndex, + multiAccount: config.enableMultiAccounts, + }, + new KeyringStateManager(), + ); } static createTransactionMgr(chain: Chain, scope: string): ITransactionMgr { @@ -48,7 +50,7 @@ export class Factory { return Factory.createBtcAccountMgr(Config.account[chain], scope); } - static createKeyring(chain: Chain, scope: string): Keyring { - return Factory.createBtcKeyring(Config.account[chain], scope); + static createKeyring(chain: Chain): Keyring { + return Factory.createBtcKeyring(Config.account[chain]); } } 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 index db2783ba..542bff06 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts @@ -1,6 +1,7 @@ import { generateAccounts } from '../../../test/utils'; import { Network } from '../bitcoin/config'; import { Chain, Config } from '../config'; +import { Factory } from '../factory'; import { BtcKeyringError } from './exceptions'; import { BtcKeyring } from './keyring'; import { KeyringStateManager } from './state'; @@ -12,14 +13,22 @@ jest.mock('../logger/logger', () => ({ }, })); +jest.mock('@metamask/keyring-api', () => ({ + ...jest.requireActual('@metamask/keyring-api'), + emitSnapKeyringEvent: jest.fn(), +})); + describe('BtcKeyring', () => { const createMockAccountMgr = () => { const unlockSpy = jest.fn(); class AccountMgr implements IAccountMgr { unlock = unlockSpy; } + jest + .spyOn(Factory, 'createAccountMgr') + .mockImplementation() + .mockReturnValue(new AccountMgr()); return { - instance: new AccountMgr(), unlockSpy, }; }; @@ -29,9 +38,10 @@ describe('BtcKeyring', () => { KeyringStateManager.prototype, 'listAccounts', ); - const saveAccountSpy = jest.spyOn( + const addWalletSpy = jest.spyOn(KeyringStateManager.prototype, 'addWallet'); + const updateAccountSpy = jest.spyOn( KeyringStateManager.prototype, - 'saveAccount', + 'updateAccount', ); const removeAccountsSpy = jest.spyOn( KeyringStateManager.prototype, @@ -41,45 +51,37 @@ describe('BtcKeyring', () => { KeyringStateManager.prototype, 'getAccount', ); - const getAccountByAddressSpy = jest.spyOn( - KeyringStateManager.prototype, - 'getAccountByAddress', - ); return { instance: new KeyringStateManager(), listAccountsSpy, - saveAccountSpy, + addWalletSpy, removeAccountsSpy, getAccountSpy, - getAccountByAddressSpy, + updateAccountSpy, }; }; - const createMockKeyring = ( - accMgr: IAccountMgr, - stateMgr: KeyringStateManager, - ) => { + const createMockKeyring = (stateMgr: KeyringStateManager) => { return { - instance: new BtcKeyring(accMgr, stateMgr, { - defaultIndex: 0, - }), + instance: new BtcKeyring( + { + defaultIndex: 0, + multiAccount: false, + }, + stateMgr, + ), }; }; describe('createAccount', () => { it('creates account', async () => { - const { instance: accMgr, unlockSpy } = createMockAccountMgr(); - const { - instance: stateMgr, - saveAccountSpy, - getAccountByAddressSpy, - } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(accMgr, stateMgr); + const { unlockSpy } = createMockAccountMgr(); + const { instance: stateMgr, addWalletSpy } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + const scope = Network.Testnet; const account = generateAccounts(1)[0]; - getAccountByAddressSpy.mockResolvedValue(null); - unlockSpy.mockResolvedValue({ address: account.address, hdPath: account.options.hdPath, @@ -87,100 +89,49 @@ describe('BtcKeyring', () => { type: account.type, }); - await keyring.createAccount(); + await keyring.createAccount({ + scope, + }); 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, + expect(addWalletSpy).toHaveBeenCalledWith({ + account: { + type: account.type, + id: expect.any(String), + address: account.address, + options: { + scope, + index: account.options.index, + }, + methods: account.methods, }, - methods: account.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.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, - }, - methods: account.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.type, + scope, }); - - 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); + const { unlockSpy } = createMockAccountMgr(); + const { instance: stateMgr } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); unlockSpy.mockRejectedValue(new Error('error')); + const scope = Network.Testnet; - await expect(keyring.createAccount()).rejects.toThrow(BtcKeyringError); + await expect( + keyring.createAccount({ + scope, + }), + ).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 { instance: keyring } = createMockKeyring(stateMgr); const account = generateAccounts(1)[0]; await expect( @@ -191,9 +142,8 @@ describe('BtcKeyring', () => { describe('submitRequest', () => { it('throws Method not implemented error', async () => { - const { instance: accMgr } = createMockAccountMgr(); const { instance: stateMgr } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(accMgr, stateMgr); + const { instance: keyring } = createMockKeyring(stateMgr); const account = generateAccounts(1)[0]; await expect( @@ -211,9 +161,8 @@ describe('BtcKeyring', () => { describe('listAccounts', () => { it('returns result', async () => { - const { instance: accMgr } = createMockAccountMgr(); const { instance: stateMgr, listAccountsSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(accMgr, stateMgr); + const { instance: keyring } = createMockKeyring(stateMgr); const accounts = generateAccounts(10); listAccountsSpy.mockResolvedValue(accounts); @@ -224,9 +173,8 @@ describe('BtcKeyring', () => { }); it('throws BtcKeyringError if an error catched', async () => { - const { instance: accMgr } = createMockAccountMgr(); const { instance: stateMgr, listAccountsSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(accMgr, stateMgr); + const { instance: keyring } = createMockKeyring(stateMgr); listAccountsSpy.mockRejectedValue(new Error('error')); await expect(keyring.listAccounts()).rejects.toThrow(BtcKeyringError); @@ -235,9 +183,8 @@ describe('BtcKeyring', () => { describe('getAccount', () => { it('returns result', async () => { - const { instance: accMgr } = createMockAccountMgr(); const { instance: stateMgr, getAccountSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(accMgr, stateMgr); + const { instance: keyring } = createMockKeyring(stateMgr); const account = generateAccounts(1)[0]; getAccountSpy.mockResolvedValue(account); @@ -248,9 +195,8 @@ describe('BtcKeyring', () => { }); 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 { instance: keyring } = createMockKeyring(stateMgr); const account = generateAccounts(1)[0]; getAccountSpy.mockResolvedValue(null); @@ -261,9 +207,8 @@ describe('BtcKeyring', () => { }); it('throws BtcKeyringError if an error catched', async () => { - const { instance: accMgr } = createMockAccountMgr(); const { instance: stateMgr, getAccountSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(accMgr, stateMgr); + const { instance: keyring } = createMockKeyring(stateMgr); getAccountSpy.mockRejectedValue(new Error('error')); const accounts = generateAccounts(1); @@ -275,22 +220,20 @@ describe('BtcKeyring', () => { describe('updateAccount', () => { it('updates account', async () => { - const { instance: accMgr } = createMockAccountMgr(); - const { instance: stateMgr, saveAccountSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(accMgr, stateMgr); + const { instance: stateMgr, updateAccountSpy } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); const account = generateAccounts(1)[0]; - saveAccountSpy.mockReturnThis(); + updateAccountSpy.mockReturnThis(); await keyring.updateAccount(account); - expect(saveAccountSpy).toHaveBeenCalledWith(account); + expect(updateAccountSpy).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 { instance: stateMgr, updateAccountSpy } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + updateAccountSpy.mockRejectedValue(new Error('error')); const account = generateAccounts(1)[0]; await expect(keyring.updateAccount(account)).rejects.toThrow( @@ -301,9 +244,8 @@ describe('BtcKeyring', () => { describe('deleteAccount', () => { it('remove account', async () => { - const { instance: accMgr } = createMockAccountMgr(); const { instance: stateMgr, removeAccountsSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(accMgr, stateMgr); + const { instance: keyring } = createMockKeyring(stateMgr); const account = generateAccounts(1)[0]; removeAccountsSpy.mockReturnThis(); @@ -313,9 +255,8 @@ describe('BtcKeyring', () => { }); it('throws BtcKeyringError if an error catched', async () => { - const { instance: accMgr } = createMockAccountMgr(); const { instance: stateMgr, removeAccountsSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(accMgr, stateMgr); + const { instance: keyring } = createMockKeyring(stateMgr); removeAccountsSpy.mockRejectedValue(new Error('error')); const account = generateAccounts(1)[0]; 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 b650126e..ab5c264c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts @@ -1,42 +1,46 @@ -import type { - Keyring, - KeyringAccount, - KeyringRequest, - KeyringResponse, +import { + KeyringEvent, + emitSnapKeyringEvent, + type Keyring, + type KeyringAccount, + type KeyringRequest, + type KeyringResponse, } from '@metamask/keyring-api'; +import { type Json } from '@metamask/snaps-sdk'; +import type { Infer } from 'superstruct'; +import { assert, object, enums } from 'superstruct'; import { v4 as uuidv4 } from 'uuid'; +import { Config } from '../config'; +import { Factory } from '../factory'; import { logger } from '../logger/logger'; +import { SnapHelper } from '../snap'; import { BtcKeyringError } from './exceptions'; import type { KeyringStateManager } from './state'; -import type { - IAccount, - IAccountMgr, - CreateAccountOptions, - KeyringOptions, -} from './types'; +import type { IAccount, IAccountMgr, KeyringOptions } from './types'; -export class BtcKeyring implements Keyring { - protected readonly accountMgr: IAccountMgr; +export const CreateAccountOptionsStruct = object({ + scope: enums(Config.avaliableNetworks[Config.chain]), +}); + +export type CreateAccountOptions = Record & + Infer; +export class BtcKeyring implements Keyring { protected readonly stateMgr: KeyringStateManager; protected readonly options: KeyringOptions; - constructor( - accountMgr: IAccountMgr, - stateMgr: KeyringStateManager, - options: KeyringOptions, - ) { - this.accountMgr = accountMgr; + protected readonly keyringMethods = ['chain_getBalances']; + + constructor(options: KeyringOptions, stateMgr: KeyringStateManager) { this.stateMgr = stateMgr; this.options = options; } async listAccounts(): Promise { try { - const accounts = await this.stateMgr.listAccounts(); - return accounts; + return await this.stateMgr.listAccounts(); } catch (error) { throw new BtcKeyringError(error); } @@ -44,8 +48,7 @@ export class BtcKeyring implements Keyring { async getAccount(id: string): Promise { try { - const account = await this.stateMgr.getAccount(id); - return account ?? undefined; + return (await this.stateMgr.getAccount(id)) ?? undefined; } catch (error) { throw new BtcKeyringError(error); } @@ -53,29 +56,43 @@ export class BtcKeyring implements Keyring { async createAccount(options?: CreateAccountOptions): Promise { try { + assert(options, CreateAccountOptionsStruct); + + const accountMgr: IAccountMgr = Factory.createAccountMgr( + Config.chain, + options.scope, + ); + // 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); + const index = this.options.defaultIndex; + + const account = await accountMgr.unlock(index); logger.info( `[BtcKeyring.createAccount] Account unlocked: ${account.address}`, ); - let keyringAccount = await this.stateMgr.getAccountByAddress( - account.address, - ); + const keyringAccount = this.newKeyringAccount(account, { + ...options, + index, + }); - if (keyringAccount) { - return keyringAccount; - } - - keyringAccount = this.newKeyringAccount(account); logger.info( `[BtcKeyring.createAccount] Keyring account data: ${JSON.stringify( keyringAccount, )}`, ); - await this.stateMgr.saveAccount(keyringAccount); + // TODO: Add 2 phase commit + await this.stateMgr.addWallet({ + account: keyringAccount, + type: account.type, + index, + scope: options?.scope, + }); + + await this.#emitEvent(KeyringEvent.AccountCreated, { + account: keyringAccount, + }); return keyringAccount; } catch (error) { @@ -90,7 +107,9 @@ export class BtcKeyring implements Keyring { async updateAccount(account: KeyringAccount): Promise { try { - await this.stateMgr.saveAccount(account); + // TODO: Add 2 phase commit + await this.stateMgr.updateAccount(account); + await this.#emitEvent(KeyringEvent.AccountUpdated, { account }); } catch (error) { throw new BtcKeyringError(error); } @@ -98,7 +117,9 @@ export class BtcKeyring implements Keyring { async deleteAccount(id: string): Promise { try { + // TODO: Add 2 phase commit await this.stateMgr.removeAccounts([id]); + await this.#emitEvent(KeyringEvent.AccountDeleted, { id }); } catch (error) { throw new BtcKeyringError(error); } @@ -109,16 +130,32 @@ export class BtcKeyring implements Keyring { throw new BtcKeyringError('Method not implemented.'); } - protected newKeyringAccount(account: IAccount): KeyringAccount { + async #emitEvent( + event: KeyringEvent, + data: Record, + ): Promise { + try { + await emitSnapKeyringEvent(SnapHelper.wallet, event, data); + } catch (error) { + logger.error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `[BtcKeyring.emitEvent] Error emitting event ${event}: ${error}`, + ); + } + } + + protected newKeyringAccount( + account: IAccount, + options?: CreateAccountOptions, + ): KeyringAccount { return { type: account.type, id: uuidv4(), address: account.address, options: { - hdPath: account.hdPath, - index: account.index, + ...options, }, - methods: ['chain_getBalances'], + methods: this.keyringMethods, } as unknown as KeyringAccount; } } 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 index 8a02111f..4446a8ae 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.test.ts @@ -1,4 +1,5 @@ import { generateAccounts } from '../../../test/utils'; +import { Network } from '../bitcoin/config'; import { SnapHelper, StateError } from '../snap'; import { KeyringStateManager } from './state'; @@ -13,12 +14,17 @@ describe('BtcKeyring', () => { }; }; - const createInitState = (cnt = 1) => { + const createInitState = (cnt = 1, scope = Network.Testnet) => { const generatedAccounts = generateAccounts(cnt); return { accounts: generatedAccounts.map((accounts) => accounts.id), - accountDetails: generatedAccounts.reduce((acc, account) => { - acc[account.id] = account; + wallets: generatedAccounts.reduce((acc, account) => { + acc[account.id] = { + account, + type: account.type, + index: account.options.index, + scope, + }; return acc; }, {}), }; @@ -34,7 +40,7 @@ describe('BtcKeyring', () => { expect(getDataSpy).toHaveBeenCalledTimes(1); expect(result).toStrictEqual( - state.accounts.map((id) => state.accountDetails[id]), + state.accounts.map((id) => state.wallets[id].account), ); }); @@ -51,7 +57,7 @@ describe('BtcKeyring', () => { it('init keyring state `accounts` if `accounts` does not exist', async () => { const { instance, getDataSpy } = createMockStateManager(); getDataSpy.mockResolvedValue({ - accounts: [], + wallets: [], }); const result = await instance.listAccounts(); @@ -60,10 +66,10 @@ describe('BtcKeyring', () => { expect(result).toStrictEqual([]); }); - it('init keyring state `accountDetails` if `accountDetails` does not exist', async () => { + it('init keyring state `wallets` if `wallets` does not exist', async () => { const { instance, getDataSpy } = createMockStateManager(); getDataSpy.mockResolvedValue({ - accountDetails: {}, + account: {}, }); const result = await instance.listAccounts(); @@ -80,49 +86,103 @@ describe('BtcKeyring', () => { }); }); - describe('saveAccount', () => { - it('updates account if the account exist', async () => { + describe('addWallet', () => { + it('adds an new wallet when state is empty', async () => { const { instance, getDataSpy, setDataSpy } = createMockStateManager(); - const state = createInitState(20); + const accountToSave = generateAccounts(1)[0]; + const state = { + accounts: [], + wallets: {}, + }; getDataSpy.mockResolvedValue(state); - const accountToSave = { ...state.accountDetails[state.accounts[0]] }; - accountToSave.options.hdPath = 'm/1/0/0'; - await instance.saveAccount(accountToSave); + await instance.addWallet({ + account: accountToSave, + type: accountToSave.type, + index: accountToSave.index, + scope: accountToSave.scope, + }); expect(getDataSpy).toHaveBeenCalledTimes(1); expect(setDataSpy).toHaveBeenCalledTimes(1); - expect(state.accountDetails[state.accounts[0]]).toStrictEqual( - accountToSave, - ); + expect(state.wallets[accountToSave.id]).toStrictEqual({ + account: accountToSave, + type: accountToSave.type, + index: accountToSave.index, + scope: accountToSave.scope, + }); }); - it('adds account if the account not exist', async () => { + it('adds an new wallet when state is not empty', async () => { const { instance, getDataSpy, setDataSpy } = createMockStateManager(); - const accountToSave = generateAccounts(1)[0]; - const state = { - accounts: [], - accountDetails: {}, - }; + const state = createInitState(5); + const accountToSave = generateAccounts(1, 'new', 'new')[0]; getDataSpy.mockResolvedValue(state); - await instance.saveAccount(accountToSave); + await instance.addWallet({ + account: accountToSave, + type: accountToSave.type, + index: accountToSave.index, + scope: accountToSave.scope, + }); expect(getDataSpy).toHaveBeenCalledTimes(1); expect(setDataSpy).toHaveBeenCalledTimes(1); - expect(state.accounts).toHaveLength(1); - expect(state.accountDetails).toStrictEqual({ - [accountToSave.id]: accountToSave, + expect(state.wallets[accountToSave.id]).toStrictEqual({ + account: accountToSave, + type: accountToSave.type, + index: accountToSave.index, + scope: accountToSave.scope, }); }); + it('throw StateError if the given account id exist', async () => { + const { instance, getDataSpy } = createMockStateManager(); + const state = createInitState(20); + getDataSpy.mockResolvedValue(state); + const accountToSave = state.wallets[state.accounts[0]].account; + + await expect( + instance.addWallet({ + account: accountToSave, + type: accountToSave.type, + index: accountToSave.index, + scope: accountToSave.scope, + }), + ).rejects.toThrow(StateError); + }); + + it('throw StateError if the given account address exist', async () => { + const { instance, getDataSpy } = createMockStateManager(); + const state = createInitState(20); + getDataSpy.mockResolvedValue(state); + const accountToSave = generateAccounts(1, 'new')[0]; + const { address } = state.wallets[state.accounts[0]].account; + accountToSave.address = address; + + await expect( + instance.addWallet({ + account: accountToSave, + type: accountToSave.type, + index: accountToSave.index, + scope: accountToSave.scope, + }), + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + ).rejects.toThrow(`Account address ${address} already exists`); + }); + it('throw StateError if an error catched', async () => { const { instance, getDataSpy } = createMockStateManager(); + const accountToSave = generateAccounts(1)[0]; getDataSpy.mockRejectedValue(new Error('error')); - const state = createInitState(1); await expect( - instance.saveAccount(state.accountDetails[state.accounts[0]]), + instance.addWallet({ + account: accountToSave, + type: accountToSave.type, + index: accountToSave.index, + scope: accountToSave.scope, + }), ).rejects.toThrow(StateError); }); }); @@ -141,8 +201,8 @@ describe('BtcKeyring', () => { 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]); + expect(state.wallets).not.toContain(testInput[0]); + expect(state.wallets).not.toContain(testInput[1]); }); it('throw StateError if the account does not exist', async () => { @@ -155,7 +215,7 @@ describe('BtcKeyring', () => { 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`, + `Account id ${nonExistAcc.id} does not exist`, ); }); @@ -180,7 +240,7 @@ describe('BtcKeyring', () => { const result = await instance.getAccount(id); expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(result).toStrictEqual(state.accountDetails[id]); + expect(result).toStrictEqual(state.wallets[id].account); }); it('returns null if the account does not exist', async () => { @@ -204,38 +264,82 @@ describe('BtcKeyring', () => { }); }); - describe('getAccountByAddress', () => { - it('returns result', async () => { - const { instance, getDataSpy } = createMockStateManager(); + describe('updateAccount', () => { + it('update account if the account exist', async () => { + const { instance, getDataSpy, setDataSpy } = createMockStateManager(); const state = createInitState(20); getDataSpy.mockResolvedValue(state); - const id = state.accounts[0]; - const { address } = state.accountDetails[id]; - const result = await instance.getAccountByAddress(address); + const accToUpdate = { + ...state.wallets[state.accounts[0]].account, + methods: ['btc_sendTransactions'], + }; + + await instance.updateAccount(accToUpdate); expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(result).toStrictEqual(state.accountDetails[id]); + expect(setDataSpy).toHaveBeenCalledWith( + expect.objectContaining({ + wallets: expect.objectContaining({ + [accToUpdate.id]: expect.objectContaining({ + account: accToUpdate, + }), + }), + }), + ); }); - it('returns null if the account does not exist', async () => { + it('throw StateError if the account does not exist', async () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); + const accToUpdate = generateAccounts(1, 'notexist', 'notexist')[0]; getDataSpy.mockResolvedValue(state); - const nonExistAcc = generateAccounts(1, 'notexist', 'notexist')[0]; - const result = await instance.getAccountByAddress(nonExistAcc.address); + await expect(instance.updateAccount(accToUpdate)).rejects.toThrow( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Account id ${accToUpdate.id} does not exist`, + ); + }); - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(result).toBeNull(); + it('throw `immutable` error if the update value is address', async () => { + const { instance, getDataSpy } = createMockStateManager(); + const state = createInitState(20); + const accToUpdate = { + ...state.wallets[state.accounts[0]].account, + address: state.wallets[state.accounts[1]].account.address, + }; + getDataSpy.mockResolvedValue(state); + + await expect(instance.updateAccount(accToUpdate)).rejects.toThrow( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Account address or type is immutable`, + ); + }); + + it('throw `immutable` error if the update value is type', async () => { + const { instance, getDataSpy } = createMockStateManager(); + const state = createInitState(20); + const accToUpdate = { + ...state.wallets[state.accounts[0]].account, + type: 'someothertype', + }; + getDataSpy.mockResolvedValue(state); + + await expect(instance.updateAccount(accToUpdate)).rejects.toThrow( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Account address or type is immutable`, + ); }); it('throw StateError if an error catched', async () => { const { instance, getDataSpy } = createMockStateManager(); getDataSpy.mockRejectedValue(new Error('error')); - const acc = generateAccounts(1)[0]; + const state = createInitState(1); + const accToUpdate = { + ...state.wallets[state.accounts[0]].account, + }; - await expect(instance.getAccountByAddress(acc.id)).rejects.toThrow( + await expect(instance.updateAccount(accToUpdate)).rejects.toThrow( StateError, ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts index 48c34987..2e0166e1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts @@ -1,5 +1,6 @@ import type { KeyringAccount } from '@metamask/keyring-api'; +import type { Wallet } from '../../types/state'; import { type SnapState } from '../../types/state'; import { SnapStateManager, StateError } from '../snap'; @@ -10,7 +11,7 @@ export class KeyringStateManager extends SnapStateManager { // eslint-disable-next-line no-param-reassign state = { accounts: [], - accountDetails: {}, + wallets: {}, }; } @@ -18,38 +19,69 @@ export class KeyringStateManager extends SnapStateManager { state.accounts = []; } - if (!state.accountDetails) { - state.accountDetails = {}; + if (!state.wallets) { + state.wallets = {}; } return state; }); } - async listAccounts() { + async listAccounts(): Promise { try { const state = await this.get(); - return state.accounts.map((id) => state.accountDetails[id]); + return state.accounts.map((id) => state.wallets[id].account); } catch (error) { throw new StateError(error); } } - async saveAccount(account: KeyringAccount): Promise { + async addWallet(wallet: Wallet): Promise { try { await this.update(async (state: SnapState) => { + const { id, address } = wallet.account; if ( - !Object.prototype.hasOwnProperty.call( - state.accountDetails, - account.id, - ) + this.isAccountExist(state, id) || + this.getAccountByAddress(state, address) ) { - state.accounts.push(account.id); + throw new StateError(`Account address ${address} already exists`); } - state.accountDetails[account.id] = account; + state.wallets[id] = wallet; + state.accounts.push(id); }); } catch (error) { + if (error instanceof StateError) { + throw error; + } + throw new StateError(error); + } + } + + async updateAccount(account: KeyringAccount): Promise { + try { + await this.update(async (state: SnapState) => { + if (!this.isAccountExist(state, account.id)) { + throw new StateError(`Account id ${account.id} does not exist`); + } + + const wallet = state.wallets[account.id]; + const accountInState = wallet.account; + + if ( + accountInState.address.toLowerCase() !== + account.address.toLowerCase() || + accountInState.type !== account.type + ) { + throw new StateError(`Account address or type is immutable`); + } + + state.wallets[account.id].account = account; + }); + } catch (error) { + if (error instanceof StateError) { + throw error; + } throw new StateError(error); } } @@ -60,16 +92,19 @@ export class KeyringStateManager extends SnapStateManager { 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`); + if (!this.isAccountExist(state, id)) { + throw new StateError(`Account id ${id} does not exist`); } removeIds.add(id); } - removeIds.forEach((id) => delete state.accountDetails[id]); + removeIds.forEach((id) => delete state.wallets[id]); state.accounts = state.accounts.filter((id) => !removeIds.has(id)); }); } catch (error) { + if (error instanceof StateError) { + throw error; + } throw new StateError(error); } } @@ -78,26 +113,28 @@ export class KeyringStateManager extends SnapStateManager { try { const state = await this.get(); - if (!Object.prototype.hasOwnProperty.call(state.accountDetails, id)) { + if (!this.isAccountExist(state, id)) { return null; } - return state.accountDetails[id]; + return state.wallets[id].account as unknown as KeyringAccount; } 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); - } + protected getAccountByAddress( + state: SnapState, + address: string, + ): KeyringAccount | null { + return ( + Object.values(state.wallets).find( + (wallet) => wallet.account.address.toString() === address.toLowerCase(), + )?.account ?? null + ); + } + + protected isAccountExist(state: SnapState, id: string): boolean { + return Object.prototype.hasOwnProperty.call(state.wallets, id); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts index d7d13e43..76691212 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts @@ -23,10 +23,7 @@ export type IAccountMgr = { unlock(index: number): Promise; }; -export type CreateAccountOptions = Record & { - index: number; -}; - export type KeyringOptions = Record & { defaultIndex: number; + multiAccount: boolean; }; 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 index d69b77c7..1bbd35be 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts @@ -38,10 +38,10 @@ export class CreateAccountHandler async handleRequest( params: CreateAccountParams, ): Promise { - const keyring = Factory.createKeyring(Chain.Bitcoin, params.scope); + const keyring = Factory.createKeyring(Chain.Bitcoin); const account = await keyring.createAccount({ - index: params.index, + scope: params.scope, }); return account; diff --git a/merged-packages/bitcoin-wallet-snap/src/types/state.ts b/merged-packages/bitcoin-wallet-snap/src/types/state.ts index 8b10beb9..116f62f7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/types/state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/types/state.ts @@ -1,6 +1,15 @@ import type { KeyringAccount } from '@metamask/keyring-api'; +export type Wallet = { + account: KeyringAccount; + type: string; + index: number; + scope: string; +}; + +export type Wallets = Record; + export type SnapState = { accounts: string[]; - accountDetails: Record; + wallets: Wallets; }; diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index 05de5df7..99e1f356 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -2,6 +2,7 @@ import type { BIP32Interface } from 'bip32'; import type { Network } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; +import { Network as NetworkEnum } from '../src/modules/bitcoin/config'; import blockChairData from './fixtures/blockchair.json'; import blockStreamData from './fixtures/blockstream.json'; @@ -23,7 +24,6 @@ export function generateAccounts(cnt = 1, addressPrefix = '', idPrefix = '') { 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: 'bip122:p2wpkh', id: @@ -32,7 +32,7 @@ export function generateAccounts(cnt = 1, addressPrefix = '', idPrefix = '') { baseAddress.slice(0, baseAddress.length - i.toString().length) + i.toString(), options: { - hdPath, + scope: NetworkEnum.Testnet, index: i, }, methods: ['chain_getBalances'], From 46640b2b405fe2a5f7c391c6f847cce99005069c Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 22 Apr 2024 16:30:23 +0800 Subject: [PATCH 011/362] feat: adding event emit before state store (#13) --- .../bitcoin-wallet-snap/package.json | 1 + .../bitcoin-wallet-snap/snap.config.ts | 7 ++ .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/index.ts | 9 ++- .../modules/bitcoin/account/deriver.test.ts | 75 +++++++++++++++++++ .../src/modules/bitcoin/account/deriver.ts | 47 ++++++++---- .../src/modules/config/index.ts | 3 + .../src/modules/factory.ts | 25 ++++--- .../src/modules/keyring/keyring.test.ts | 11 +-- .../src/modules/keyring/keyring.ts | 34 ++++++--- .../src/modules/keyring/state.test.ts | 32 ++++---- .../src/modules/keyring/state.ts | 19 ++--- .../src/modules/keyring/types.ts | 3 +- .../src/rpcs/methods/create-account.ts | 10 ++- .../bitcoin-wallet-snap/src/types/state.ts | 2 +- 15 files changed, 201 insertions(+), 79 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 661936cc..1aee7198 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -35,6 +35,7 @@ "bip32": "^4.0.0", "bitcoinjs-lib": "^6.1.5", "buffer": "^6.0.3", + "dotenv": "^16.4.5", "superstruct": "^1.0.3", "uuid": "^9.0.1" }, diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts index 622949da..2640f138 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.config.ts +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -1,12 +1,19 @@ import type { SnapConfig } from '@metamask/snaps-cli'; import { resolve } from 'path'; +// eslint-disable-next-line @typescript-eslint/no-require-imports,@typescript-eslint/no-var-requires +require('dotenv').config(); + const config: SnapConfig = { bundler: 'webpack', input: resolve(__dirname, 'src/index.ts'), server: { port: 8080, }, + environment: { + // eslint-disable-next-line n/no-process-env + LOG_LEVEL: process.env.LOG_LEVEL, + }, polyfills: true, }; diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 5dc62f3f..b240a942 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "2aagAzXrGS6z6HJjwYFNS1lHWzs099t+ZyMD/LX52+g=", + "shasum": "nQf2nhkUXq9AUDXdOH0gzOBBiNT32CZxxc3lMpu3JMA=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 2eb76ea0..a1269372 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -6,9 +6,9 @@ import { SnapError, } from '@metamask/snaps-sdk'; -import { Chain } from './modules/config'; +import { Chain, Config } from './modules/config'; import { Factory } from './modules/factory'; -import { LogLevel, logger } from './modules/logger/logger'; +import { logger } from './modules/logger/logger'; import type { SnapRpcRequestHandlerRequest, IStaticSnapRpcRequestHandler, @@ -36,7 +36,7 @@ const getHandler = (method: string): IStaticSnapRpcRequestHandler => { export const onRpcRequest: OnRpcRequestHandler = async (args) => { const { request, origin } = args; - logger.logLevel = LogLevel.ALL; + logger.logLevel = parseInt(Config.logLevel, 10); await validateOrigin(origin); @@ -52,6 +52,9 @@ export const onRpcRequest: OnRpcRequestHandler = async (args) => { export const onKeyringRequest: OnKeyringRequestHandler = async ({ request, }): Promise => { + logger.logLevel = parseInt(Config.logLevel, 10); + const keyring = Factory.createKeyring(Chain.Bitcoin); + return handleKeyringRequest(keyring, request) as unknown as Promise; }; 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 index 219a08a6..7d4ddfc0 100644 --- 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 @@ -10,6 +10,7 @@ import { createMockBip32Instance } from '../../../../test/utils'; import { SnapHelper } from '../../snap'; import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; import { DeriverError } from './exceptions'; +import { AddressHelper } from './helpers'; jest.mock('bip32', () => { return { @@ -78,6 +79,25 @@ describe('BtcAccountBip32Deriver', () => { expect(fromSeedSpy).toHaveBeenCalledWith(seed, network); }); + + it('throws `Unable to construct BIP32 node from seed` if an error catched', () => { + const network = networks.testnet; + const { fromSeedSpy } = createMockBip32Factory(); + const seed = Buffer.from( + 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + 'hex', + ); + + fromSeedSpy.mockImplementation(() => { + throw new Error('error'); + }); + + const deriver = new BtcAccountBip32Deriver(network); + + expect(() => deriver.fromSeed(seed)).toThrow( + 'Unable to construct BIP32 node from seed', + ); + }); }); describe('fromPrivateKey', () => { @@ -102,6 +122,29 @@ describe('BtcAccountBip32Deriver', () => { network, ); }); + + it('throws `Unable to construct BIP32 node from private key` if an error catched', () => { + const network = networks.testnet; + const { fromPrivateKeySpy } = createMockBip32Factory(); + const privateKey = Buffer.from( + 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + 'hex', + ); + const chainCode = Buffer.from( + 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + 'hex', + ); + + fromPrivateKeySpy.mockImplementation(() => { + throw new Error('error'); + }); + + const deriver = new BtcAccountBip32Deriver(network); + + expect(() => deriver.fromPrivateKey(privateKey, chainCode)).toThrow( + 'Unable to construct BIP32 node from private key', + ); + }); }); describe('getChild', () => { @@ -145,6 +188,38 @@ describe('BtcAccountBip32Deriver', () => { expect(root).toStrictEqual(node); }); + it('throws `Private key is invalid` if private key is invalid', async () => { + const network = networks.testnet; + const path = ['m', "84'", "0'"]; + createMockBip32Entropy(); + const addressHelperSpy = jest.spyOn(AddressHelper, 'trimHexPrefix'); + addressHelperSpy.mockImplementation(() => { + throw new Error('error'); + }); + const deriver = new BtcAccountBip32Deriver(network); + + await expect(deriver.getRoot(path)).rejects.toThrow( + 'Private key is invalid', + ); + }); + + it('throws `Chain code is invalid` if chain code is invalid', async () => { + const network = networks.testnet; + const path = ['m', "84'", "0'"]; + createMockBip32Entropy(); + const addressHelperSpy = jest.spyOn(AddressHelper, 'trimHexPrefix'); + addressHelperSpy + .mockImplementationOnce((val: string) => val) + .mockImplementationOnce(() => { + throw new Error('error'); + }); + const deriver = new BtcAccountBip32Deriver(network); + + await expect(deriver.getRoot(path)).rejects.toThrow( + 'Chain code is invalid', + ); + }); + it('throws DeriverError if private key is missing', async () => { const network = networks.testnet; const path = ['m', "84'", "0'"]; 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 index 394ede66..f59464c0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/deriver.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/deriver.ts @@ -22,16 +22,44 @@ export abstract class BtcAccountDeriver implements IBtcAccountDeriver { abstract getRoot(path: string[]): Promise; fromSeed(seed: Buffer): BIP32Interface { - return this.bip32Api.fromSeed(seed, this.network); + try { + return this.bip32Api.fromSeed(seed, this.network); + } catch (error) { + throw new DeriverError('Unable to construct BIP32 node from seed'); + } } fromPrivateKey(privateKey: Buffer, chainNode: Buffer): BIP32Interface { - return this.bip32Api.fromPrivateKey(privateKey, chainNode, this.network); + try { + return this.bip32Api.fromPrivateKey(privateKey, chainNode, this.network); + } catch (error) { + throw new DeriverError('Unable to construct BIP32 node from private key'); + } } async getChild(root: BIP32Interface, idx: number): Promise { return Promise.resolve(root.deriveHardened(0).derive(0).derive(idx)); } + + protected pkToBuf(pk: string): Buffer { + try { + return this.#toBuffer(pk); + } catch (error) { + throw new DeriverError('Private key is invalid'); + } + } + + protected chainCodeToBuf(chainCode: string): Buffer { + try { + return this.#toBuffer(chainCode); + } catch (error) { + throw new DeriverError('Chain code is invalid'); + } + } + + #toBuffer(val: string): Buffer { + return Buffer.from(AddressHelper.trimHexPrefix(val), 'hex'); + } } export class BtcAccountBip44Deriver extends BtcAccountDeriver { @@ -42,10 +70,7 @@ export class BtcAccountBip44Deriver extends BtcAccountDeriver { if (!deriverNode.privateKey) { throw new DeriverError('Deriver private key is missing'); } - const privateKeyBuffer = Buffer.from( - AddressHelper.trimHexPrefix(deriverNode.privateKey), - 'hex', - ); + const privateKeyBuffer = this.pkToBuf(deriverNode.privateKey); const root = this.fromSeed(privateKeyBuffer); return root .deriveHardened(parseInt(path[1].slice(0, -1), 10)) @@ -67,14 +92,8 @@ export class BtcAccountBip32Deriver extends BtcAccountDeriver { 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 privateKeyBuffer = this.pkToBuf(deriver.privateKey); + const chainCodeBuffer = this.chainCodeToBuf(deriver.chainCode); const root = this.fromPrivateKey(privateKeyBuffer, chainCodeBuffer); // eslint-disable-next-line @typescript-eslint/ban-ts-comment 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 3b451fec..051e01dc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts @@ -28,6 +28,7 @@ export type SnapConfig = { [key in Chain]: string[]; }; chain: Chain; + logLevel: string; }; export const Config: SnapConfig = { @@ -53,4 +54,6 @@ export const Config: SnapConfig = { [Chain.Bitcoin]: Object.entries(BtcNetwork).map(([_, val]) => val), }, chain: Chain.Bitcoin, + // eslint-disable-next-line no-restricted-globals + logLevel: process.env.LOG_LEVEL ?? '6', }; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts index 6334e892..c63958da 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts @@ -14,6 +14,10 @@ import type { Chain } from './config'; import { BtcKeyring, KeyringStateManager, type IAccountMgr } from './keyring'; import type { ITransactionMgr } from './transaction/types'; +export type CreateBtcKeyringOptions = { + emitEvents: boolean; +}; + export class Factory { static createBtcTransactionMgr( config: BtcTransactionConfig, @@ -32,14 +36,15 @@ export class Factory { return BtcAccountMgrFactory.create(config, btcNetwork); } - static createBtcKeyring(config: BtcAccountConfig): BtcKeyring { - return new BtcKeyring( - { - defaultIndex: config.defaultAccountIndex, - multiAccount: config.enableMultiAccounts, - }, - new KeyringStateManager(), - ); + static createBtcKeyring( + config: BtcAccountConfig, + options: CreateBtcKeyringOptions, + ): BtcKeyring { + return new BtcKeyring(new KeyringStateManager(), { + defaultIndex: config.defaultAccountIndex, + multiAccount: config.enableMultiAccounts, + emitEvents: options.emitEvents, + }); } static createTransactionMgr(chain: Chain, scope: string): ITransactionMgr { @@ -51,6 +56,8 @@ export class Factory { } static createKeyring(chain: Chain): Keyring { - return Factory.createBtcKeyring(Config.account[chain]); + return Factory.createBtcKeyring(Config.account[chain], { + emitEvents: true, + }); } } 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 index 542bff06..3eab1ae1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts @@ -64,13 +64,10 @@ describe('BtcKeyring', () => { const createMockKeyring = (stateMgr: KeyringStateManager) => { return { - instance: new BtcKeyring( - { - defaultIndex: 0, - multiAccount: false, - }, - stateMgr, - ), + instance: new BtcKeyring(stateMgr, { + defaultIndex: 0, + multiAccount: false, + }), }; }; 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 ab5c264c..9421b5cb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts @@ -33,7 +33,7 @@ export class BtcKeyring implements Keyring { protected readonly keyringMethods = ['chain_getBalances']; - constructor(options: KeyringOptions, stateMgr: KeyringStateManager) { + constructor(stateMgr: KeyringStateManager, options: KeyringOptions) { this.stateMgr = stateMgr; this.options = options; } @@ -82,7 +82,7 @@ export class BtcKeyring implements Keyring { )}`, ); - // TODO: Add 2 phase commit + // TODO: Add 2 phases commit await this.stateMgr.addWallet({ account: keyringAccount, type: account.type, @@ -107,7 +107,7 @@ export class BtcKeyring implements Keyring { async updateAccount(account: KeyringAccount): Promise { try { - // TODO: Add 2 phase commit + // TODO: Add 2 phases commit await this.stateMgr.updateAccount(account); await this.#emitEvent(KeyringEvent.AccountUpdated, { account }); } catch (error) { @@ -117,7 +117,7 @@ export class BtcKeyring implements Keyring { async deleteAccount(id: string): Promise { try { - // TODO: Add 2 phase commit + // TODO: Add 2 phases commit await this.stateMgr.removeAccounts([id]); await this.#emitEvent(KeyringEvent.AccountDeleted, { id }); } catch (error) { @@ -125,22 +125,32 @@ export class BtcKeyring implements Keyring { } } - // eslint-disable-next-line @typescript-eslint/no-unused-vars async submitRequest(request: KeyringRequest): Promise { - throw new BtcKeyringError('Method not implemented.'); + return this.syncSubmitRequest(request); + } + + protected async syncSubmitRequest( + request: KeyringRequest, + ): Promise { + return { + pending: false, + result: await this.handleSubmitRequest(request), + }; + } + + protected async handleSubmitRequest( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + request: KeyringRequest, + ): Promise { + throw new BtcKeyringError(`Method not implemented.`); } async #emitEvent( event: KeyringEvent, data: Record, ): Promise { - try { + if (this.options.emitEvents) { await emitSnapKeyringEvent(SnapHelper.wallet, event, data); - } catch (error) { - logger.error( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `[BtcKeyring.emitEvent] Error emitting event ${event}: ${error}`, - ); } } 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 index 4446a8ae..bd178c5f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.test.ts @@ -17,7 +17,7 @@ describe('BtcKeyring', () => { const createInitState = (cnt = 1, scope = Network.Testnet) => { const generatedAccounts = generateAccounts(cnt); return { - accounts: generatedAccounts.map((accounts) => accounts.id), + walletIds: generatedAccounts.map((accounts) => accounts.id), wallets: generatedAccounts.reduce((acc, account) => { acc[account.id] = { account, @@ -40,7 +40,7 @@ describe('BtcKeyring', () => { expect(getDataSpy).toHaveBeenCalledTimes(1); expect(result).toStrictEqual( - state.accounts.map((id) => state.wallets[id].account), + state.walletIds.map((id) => state.wallets[id].account), ); }); @@ -54,7 +54,7 @@ describe('BtcKeyring', () => { expect(result).toStrictEqual([]); }); - it('init keyring state `accounts` if `accounts` does not exist', async () => { + it('init keyring state `walletIds` if `walletIds` does not exist', async () => { const { instance, getDataSpy } = createMockStateManager(); getDataSpy.mockResolvedValue({ wallets: [], @@ -140,7 +140,7 @@ describe('BtcKeyring', () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); getDataSpy.mockResolvedValue(state); - const accountToSave = state.wallets[state.accounts[0]].account; + const accountToSave = state.wallets[state.walletIds[0]].account; await expect( instance.addWallet({ @@ -157,7 +157,7 @@ describe('BtcKeyring', () => { const state = createInitState(20); getDataSpy.mockResolvedValue(state); const accountToSave = generateAccounts(1, 'new')[0]; - const { address } = state.wallets[state.accounts[0]].account; + const { address } = state.wallets[state.walletIds[0]].account; accountToSave.address = address; await expect( @@ -193,14 +193,14 @@ describe('BtcKeyring', () => { const state = createInitState(20); getDataSpy.mockResolvedValue(state); - const lengthB4Remove = state.accounts.length; - const testInput = [state.accounts[0], state.accounts[10]]; + const lengthB4Remove = state.walletIds.length; + const testInput = [state.walletIds[0], state.walletIds[10]]; await instance.removeAccounts(testInput); expect(getDataSpy).toHaveBeenCalledTimes(1); expect(setDataSpy).toHaveBeenCalledTimes(1); - expect(state.accounts).toHaveLength(lengthB4Remove - testInput.length); + expect(state.walletIds).toHaveLength(lengthB4Remove - testInput.length); expect(state.wallets).not.toContain(testInput[0]); expect(state.wallets).not.toContain(testInput[1]); }); @@ -212,7 +212,7 @@ describe('BtcKeyring', () => { getDataSpy.mockResolvedValue(state); await expect( - instance.removeAccounts([nonExistAcc.id, state.accounts[0]]), + instance.removeAccounts([nonExistAcc.id, state.walletIds[0]]), ).rejects.toThrow( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `Account id ${nonExistAcc.id} does not exist`, @@ -224,7 +224,7 @@ describe('BtcKeyring', () => { getDataSpy.mockRejectedValue(new Error('error')); const state = createInitState(1); - await expect(instance.removeAccounts(state.accounts)).rejects.toThrow( + await expect(instance.removeAccounts(state.walletIds)).rejects.toThrow( StateError, ); }); @@ -235,7 +235,7 @@ describe('BtcKeyring', () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); getDataSpy.mockResolvedValue(state); - const id = state.accounts[0]; + const id = state.walletIds[0]; const result = await instance.getAccount(id); @@ -271,7 +271,7 @@ describe('BtcKeyring', () => { getDataSpy.mockResolvedValue(state); const accToUpdate = { - ...state.wallets[state.accounts[0]].account, + ...state.wallets[state.walletIds[0]].account, methods: ['btc_sendTransactions'], }; @@ -305,8 +305,8 @@ describe('BtcKeyring', () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); const accToUpdate = { - ...state.wallets[state.accounts[0]].account, - address: state.wallets[state.accounts[1]].account.address, + ...state.wallets[state.walletIds[0]].account, + address: state.wallets[state.walletIds[1]].account.address, }; getDataSpy.mockResolvedValue(state); @@ -320,7 +320,7 @@ describe('BtcKeyring', () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); const accToUpdate = { - ...state.wallets[state.accounts[0]].account, + ...state.wallets[state.walletIds[0]].account, type: 'someothertype', }; getDataSpy.mockResolvedValue(state); @@ -336,7 +336,7 @@ describe('BtcKeyring', () => { getDataSpy.mockRejectedValue(new Error('error')); const state = createInitState(1); const accToUpdate = { - ...state.wallets[state.accounts[0]].account, + ...state.wallets[state.walletIds[0]].account, }; await expect(instance.updateAccount(accToUpdate)).rejects.toThrow( diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts index 2e0166e1..23e283e1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts @@ -10,13 +10,13 @@ export class KeyringStateManager extends SnapStateManager { if (!state) { // eslint-disable-next-line no-param-reassign state = { - accounts: [], + walletIds: [], wallets: {}, }; } - if (!state.accounts) { - state.accounts = []; + if (!state.walletIds) { + state.walletIds = []; } if (!state.wallets) { @@ -30,7 +30,7 @@ export class KeyringStateManager extends SnapStateManager { async listAccounts(): Promise { try { const state = await this.get(); - return state.accounts.map((id) => state.wallets[id].account); + return state.walletIds.map((id) => state.wallets[id].account); } catch (error) { throw new StateError(error); } @@ -48,7 +48,7 @@ export class KeyringStateManager extends SnapStateManager { } state.wallets[id] = wallet; - state.accounts.push(id); + state.walletIds.push(id); }); } catch (error) { if (error instanceof StateError) { @@ -99,7 +99,7 @@ export class KeyringStateManager extends SnapStateManager { } removeIds.forEach((id) => delete state.wallets[id]); - state.accounts = state.accounts.filter((id) => !removeIds.has(id)); + state.walletIds = state.walletIds.filter((id) => !removeIds.has(id)); }); } catch (error) { if (error instanceof StateError) { @@ -112,12 +112,7 @@ export class KeyringStateManager extends SnapStateManager { async getAccount(id: string): Promise { try { const state = await this.get(); - - if (!this.isAccountExist(state, id)) { - return null; - } - - return state.wallets[id].account as unknown as KeyringAccount; + return state.wallets[id]?.account ?? 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 index 76691212..d67f44b8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts @@ -25,5 +25,6 @@ export type IAccountMgr = { export type KeyringOptions = Record & { defaultIndex: number; - multiAccount: boolean; + multiAccount?: boolean; + emitEvents?: boolean; }; 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 index 1bbd35be..5a653166 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts @@ -2,8 +2,8 @@ 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 { Config } from '../../modules/config'; +import { BtcKeyring, KeyringStateManager } from '../../modules/keyring'; import type { StaticImplements } from '../../types/static'; import { BaseSnapRpcRequestHandler } from '../base'; import type { @@ -38,7 +38,11 @@ export class CreateAccountHandler async handleRequest( params: CreateAccountParams, ): Promise { - const keyring = Factory.createKeyring(Chain.Bitcoin); + const keyring = new BtcKeyring(new KeyringStateManager(), { + defaultIndex: Config.account[Config.chain].defaultAccountIndex, + multiAccount: Config.account[Config.chain].enableMultiAccounts, + emitEvents: false, + }); const account = await keyring.createAccount({ scope: params.scope, diff --git a/merged-packages/bitcoin-wallet-snap/src/types/state.ts b/merged-packages/bitcoin-wallet-snap/src/types/state.ts index 116f62f7..b313e646 100644 --- a/merged-packages/bitcoin-wallet-snap/src/types/state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/types/state.ts @@ -10,6 +10,6 @@ export type Wallet = { export type Wallets = Record; export type SnapState = { - accounts: string[]; + walletIds: string[]; wallets: Wallets; }; From 0e9b6b01a9438a72501f7434af64bc65c37527a1 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 23 Apr 2024 11:37:39 +0800 Subject: [PATCH 012/362] feat: implement chain api skeleton (#14) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/index.ts | 51 ++++++------ .../src/modules/factory.ts | 23 ++++-- .../src/modules/keyring/keyring.test.ts | 77 ++++++++++++++++++- .../src/modules/keyring/keyring.ts | 55 +++++++++---- .../src/modules/keyring/types.ts | 14 ++++ .../bitcoin-wallet-snap/src/rpcs/base.ts | 69 +++++++++-------- .../src/rpcs/exceptions.ts | 3 +- .../src/rpcs/methods/create-account.ts | 39 +++++----- .../src/rpcs/methods/get-balances.ts | 29 ++++--- .../bitcoin-wallet-snap/src/rpcs/types.ts | 41 +++++----- 11 files changed, 266 insertions(+), 137 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index b240a942..c2c2a586 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "nQf2nhkUXq9AUDXdOH0gzOBBiNT32CZxxc3lMpu3JMA=", + "shasum": "o88PXyjmak4cz88JmxwWzhjLXqk+MIm3v6ycTj0lyuQ=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index a1269372..20f8905f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -9,10 +9,7 @@ import { import { Chain, Config } from './modules/config'; import { Factory } from './modules/factory'; import { logger } from './modules/logger/logger'; -import type { - SnapRpcRequestHandlerRequest, - IStaticSnapRpcRequestHandler, -} from './rpcs'; +import type { SnapRpcHandlerRequest, IStaticSnapRpcHandler } from './rpcs'; import { CreateAccountHandler, GetBalancesHandler } from './rpcs'; const validateOrigin = async (origin: string) => { @@ -22,7 +19,7 @@ const validateOrigin = async (origin: string) => { } }; -const getHandler = (method: string): IStaticSnapRpcRequestHandler => { +const getHandler = (method: string): IStaticSnapRpcHandler => { switch (method) { case 'bitcoin_createAccount': return CreateAccountHandler; @@ -34,27 +31,37 @@ const getHandler = (method: string): IStaticSnapRpcRequestHandler => { }; export const onRpcRequest: OnRpcRequestHandler = async (args) => { - const { request, origin } = args; + try { + logger.logLevel = parseInt(Config.logLevel, 10); + const { request, origin } = args; + const { method } = request; + await validateOrigin(origin); - logger.logLevel = parseInt(Config.logLevel, 10); - - await validateOrigin(origin); - - const { method } = request; - - const handler = getHandler(method); - - return await handler - .getInstance() - .execute(request.params as SnapRpcRequestHandlerRequest); + return await getHandler(method) + .getInstance() + .execute(request.params as SnapRpcHandlerRequest); + } catch (error) { + if (error instanceof SnapError) { + throw error; + } + throw new SnapError(error.message); + } }; export const onKeyringRequest: OnKeyringRequestHandler = async ({ request, }): Promise => { - logger.logLevel = parseInt(Config.logLevel, 10); - - const keyring = Factory.createKeyring(Chain.Bitcoin); - - return handleKeyringRequest(keyring, request) as unknown as Promise; + try { + logger.logLevel = parseInt(Config.logLevel, 10); + const keyring = Factory.createKeyring(Chain.Bitcoin); + return (await handleKeyringRequest( + keyring, + request, + )) as unknown as Promise; + } catch (error) { + if (error instanceof SnapError) { + throw error; + } + throw new SnapError(error.message); + } }; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts index c63958da..93fb44fb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts @@ -1,5 +1,7 @@ import { type Keyring } from '@metamask/keyring-api'; +import type { IStaticSnapRpcHandler } from '../rpcs'; +import { GetBalancesHandler } from '../rpcs'; import { BtcAccountMgrFactory } from './bitcoin/account'; import type { Network } from './bitcoin/config'; import { @@ -36,15 +38,26 @@ export class Factory { return BtcAccountMgrFactory.create(config, btcNetwork); } + static createBtcChainRpcMapping(): Record { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_getBalances: GetBalancesHandler, + }; + } + static createBtcKeyring( config: BtcAccountConfig, options: CreateBtcKeyringOptions, ): BtcKeyring { - return new BtcKeyring(new KeyringStateManager(), { - defaultIndex: config.defaultAccountIndex, - multiAccount: config.enableMultiAccounts, - emitEvents: options.emitEvents, - }); + return new BtcKeyring( + new KeyringStateManager(), + Factory.createBtcChainRpcMapping(), + { + defaultIndex: config.defaultAccountIndex, + multiAccount: config.enableMultiAccounts, + emitEvents: options.emitEvents, + }, + ); } static createTransactionMgr(chain: Chain, scope: string): ITransactionMgr { 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 index 3eab1ae1..dfe7e1df 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts @@ -1,4 +1,9 @@ +import { unknown } from 'superstruct'; + import { generateAccounts } from '../../../test/utils'; +import type { IStaticSnapRpcHandler } from '../../rpcs'; +import { BaseSnapRpcHandler } from '../../rpcs'; +import type { StaticImplements } from '../../types/static'; import { Network } from '../bitcoin/config'; import { Chain, Config } from '../config'; import { Factory } from '../factory'; @@ -62,12 +67,41 @@ describe('BtcKeyring', () => { }; }; + const createMockChainRPCHandler = () => { + const handleRequestSpy = jest.fn(); + class MockChainRpcHandler + extends BaseSnapRpcHandler + implements + StaticImplements + { + static override get requestStruct() { + return unknown(); + } + + requestStruct = MockChainRpcHandler.requestStruct; + + handleRequest = handleRequestSpy; + } + return { + instance: MockChainRpcHandler, + handleRequestSpy, + }; + }; + const createMockKeyring = (stateMgr: KeyringStateManager) => { + const { instance: RpcHandler, handleRequestSpy } = + createMockChainRPCHandler(); + const chainRPCHanlers = { + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_getBalances: RpcHandler, + }; + return { - instance: new BtcKeyring(stateMgr, { + instance: new BtcKeyring(stateMgr, chainRPCHanlers, { defaultIndex: 0, multiAccount: false, }), + handleRequestSpy, }; }; @@ -123,6 +157,17 @@ describe('BtcKeyring', () => { }), ).rejects.toThrow(BtcKeyringError); }); + + it('throws `Invalid params to create account` if the create options is invalid', async () => { + const { instance: stateMgr } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + + await expect( + keyring.createAccount({ + scope: 'invalid', + }), + ).rejects.toThrow(`Invalid params to create account`); + }); }); describe('filterAccountChains', () => { @@ -138,7 +183,33 @@ describe('BtcKeyring', () => { }); describe('submitRequest', () => { - it('throws Method not implemented error', async () => { + it('calls SnapRpcHandler if the method support', async () => { + const { instance: stateMgr } = createMockStateMgr(); + const { instance: keyring, handleRequestSpy } = + createMockKeyring(stateMgr); + const account = generateAccounts(1)[0]; + const params = { + scope: 'bip122:000000000019d6689c085ae165831e93', + accounts: [ + 'bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah', + 'bc1qf5n2h6mgelkls4497pkpemew55xpew90td2qae', + ], + assets: ['bip122:000000000019d6689c085ae165831e93/asset:0'], + }; + await keyring.submitRequest({ + id: account.id, + scope: Network.Testnet, + account: account.address, + request: { + method: 'chain_getBalances', + params, + }, + }); + + expect(handleRequestSpy).toHaveBeenCalledWith(params); + }); + + it('throws `Method not found` if the method not support', async () => { const { instance: stateMgr } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); const account = generateAccounts(1)[0]; @@ -152,7 +223,7 @@ describe('BtcKeyring', () => { method: 'signMessage', }, }), - ).rejects.toThrow('Method not implemented.'); + ).rejects.toThrow('Method not found: signMessage'); }); }); 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 9421b5cb..ac93c84b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts @@ -7,35 +7,43 @@ import { type KeyringResponse, } from '@metamask/keyring-api'; import { type Json } from '@metamask/snaps-sdk'; -import type { Infer } from 'superstruct'; -import { assert, object, enums } from 'superstruct'; +import { assert, StructError } from 'superstruct'; import { v4 as uuidv4 } from 'uuid'; +import type { SnapRpcHandlerRequest } from '../../rpcs'; import { Config } from '../config'; import { Factory } from '../factory'; import { logger } from '../logger/logger'; import { SnapHelper } from '../snap'; import { BtcKeyringError } from './exceptions'; import type { KeyringStateManager } from './state'; -import type { IAccount, IAccountMgr, KeyringOptions } from './types'; - -export const CreateAccountOptionsStruct = object({ - scope: enums(Config.avaliableNetworks[Config.chain]), -}); - -export type CreateAccountOptions = Record & - Infer; +import { + CreateAccountOptionsStruct, + type ChainRPCHandlers, + type CreateAccountOptions, + type IAccount, + type IAccountMgr, + type KeyringOptions, +} from './types'; export class BtcKeyring implements Keyring { protected readonly stateMgr: KeyringStateManager; protected readonly options: KeyringOptions; - protected readonly keyringMethods = ['chain_getBalances']; + protected readonly keyringMethods: string[]; + + protected readonly handlers: ChainRPCHandlers; - constructor(stateMgr: KeyringStateManager, options: KeyringOptions) { + constructor( + stateMgr: KeyringStateManager, + chainRPCHanlers: ChainRPCHandlers, + options: KeyringOptions, + ) { this.stateMgr = stateMgr; this.options = options; + this.keyringMethods = Object.keys(chainRPCHanlers); + this.handlers = chainRPCHanlers; } async listAccounts(): Promise { @@ -96,6 +104,11 @@ export class BtcKeyring implements Keyring { return keyringAccount; } catch (error) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.info(`[BtcKeyring.createAccount] Error: ${error.message}`); + if (error instanceof StructError) { + throw new BtcKeyringError('Invalid params to create account'); + } throw new BtcKeyringError(error); } } @@ -111,6 +124,8 @@ export class BtcKeyring implements Keyring { await this.stateMgr.updateAccount(account); await this.#emitEvent(KeyringEvent.AccountUpdated, { account }); } catch (error) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.info(`[BtcKeyring.updateAccount] Error: ${error.message}`); throw new BtcKeyringError(error); } } @@ -121,6 +136,8 @@ export class BtcKeyring implements Keyring { await this.stateMgr.removeAccounts([id]); await this.#emitEvent(KeyringEvent.AccountDeleted, { id }); } catch (error) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.info(`[BtcKeyring.deleteAccount] Error: ${error.message}`); throw new BtcKeyringError(error); } } @@ -138,11 +155,15 @@ export class BtcKeyring implements Keyring { }; } - protected async handleSubmitRequest( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - request: KeyringRequest, - ): Promise { - throw new BtcKeyringError(`Method not implemented.`); + protected async handleSubmitRequest(request: KeyringRequest): Promise { + const { method, params } = request.request; + if (!Object.prototype.hasOwnProperty.call(this.handlers, method)) { + throw new BtcKeyringError(`Method not found: ${method}`); + } + + return this.handlers[method] + .getInstance() + .execute(params as unknown as SnapRpcHandlerRequest); } async #emitEvent( diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts index d67f44b8..e656325a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts @@ -1,5 +1,10 @@ import { type Json } from '@metamask/snaps-sdk'; import type { Buffer } from 'buffer'; +import type { Infer } from 'superstruct'; +import { object, enums } from 'superstruct'; + +import type { IStaticSnapRpcHandler } from '../../rpcs'; +import { Config } from '../config'; export type IAccountSigner = { sign(hash: Buffer): Promise; @@ -28,3 +33,12 @@ export type KeyringOptions = Record & { multiAccount?: boolean; emitEvents?: boolean; }; + +export type ChainRPCHandlers = Record; + +export const CreateAccountOptionsStruct = object({ + scope: enums(Config.avaliableNetworks[Config.chain]), +}); + +export type CreateAccountOptions = Record & + Infer; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts index 06d63bb4..0f845d6b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts @@ -1,64 +1,73 @@ 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, +import { SnapRpcError, SnapRpcValidationError } from './exceptions'; +import { + type ISnapRpcExecutable, + type SnapRpcHandlerOptions, + type ISnapRpcHandler, + type IStaticSnapRpcHandler, + type SnapRpcHandlerResponse, + type SnapRpcHandlerRequest, + SnapRpcHandlerRequestStruct, } from './types'; -export abstract class BaseSnapRpcRequestHandler - implements ISnapRpcValidator, ISnapRpcExecutable -{ - static instance: ISnapRpcRequestHandler | null = null; +export abstract class BaseSnapRpcHandler implements ISnapRpcExecutable { + static instance: ISnapRpcHandler | null = null; - static validateStruct: Struct; + static requestStruct: Struct = SnapRpcHandlerRequestStruct; abstract handleRequest( - params: SnapRpcRequestHandlerRequest, - ): Promise; + params: SnapRpcHandlerRequest, + ): Promise; - abstract validateStruct: Struct; + abstract requestStruct: Struct; - async validate(params: SnapRpcRequestHandlerRequest): Promise { - assert(params, this.validateStruct); + protected async validate(params: SnapRpcHandlerRequest): Promise { + assert(params, this.requestStruct); } - async preExecute(params: SnapRpcRequestHandlerRequest): Promise { - logger.info(`Request: ${JSON.stringify(params)}`); + protected async preExecute(params: SnapRpcHandlerRequest): Promise { + logger.info( + `[SnapRpcHandler.preExecute] Request: ${JSON.stringify(params)}`, + ); try { await this.validate(params); } catch (error) { - throw new SnapRpcRequestValidationError(error); + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.info(`[SnapRpcHandler.preExecute] Error: ${error.message}`); + throw new SnapRpcValidationError('Request params is invalid'); } } - async postExecute(response: SnapRpcRequestHandlerResponse): Promise { - logger.info(`Response: ${JSON.stringify(response)}`); + protected async postExecute(response: SnapRpcHandlerResponse): Promise { + logger.info( + `[SnapRpcHandler.postExecute] Response: ${JSON.stringify(response)}`, + ); } async execute( - params: SnapRpcRequestHandlerRequest, - ): Promise { + params: SnapRpcHandlerRequest, + ): Promise { try { await this.preExecute(params); const result = await this.handleRequest(params); await this.postExecute(result); return result; } catch (error) { - throw new SnapRpcRequestValidationError(error); + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.info(`[SnapRpcHandler.execute] Error: ${error.message}`); + if (error instanceof SnapRpcValidationError) { + throw error; + } + throw new SnapRpcError(error.message); } } static getInstance( - this: IStaticSnapRpcRequestHandler, - options?: SnapRpcRequestHandlerOptions, - ): ISnapRpcRequestHandler { + this: IStaticSnapRpcHandler, + options?: SnapRpcHandlerOptions, + ): ISnapRpcHandler { if (this.instance === null) { this.instance = new this(options); } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/exceptions.ts index e33ea153..9f25ea65 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/exceptions.ts @@ -1,3 +1,4 @@ import { CustomError } from '../modules/exceptions'; -export class SnapRpcRequestValidationError extends CustomError {} +export class SnapRpcError extends CustomError {} +export class SnapRpcValidationError extends CustomError {} 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 index 5a653166..24e2f3da 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts @@ -3,46 +3,47 @@ import type { Infer } from 'superstruct'; import { object, number, assign } from 'superstruct'; import { Config } from '../../modules/config'; +import { Factory } from '../../modules/factory'; import { BtcKeyring, KeyringStateManager } from '../../modules/keyring'; import type { StaticImplements } from '../../types/static'; -import { BaseSnapRpcRequestHandler } from '../base'; -import type { - IStaticSnapRpcRequestHandler, - SnapRpcRequestHandlerResponse, -} from '../types'; -import { SnapRpcRequestHandlerRequestStruct } from '../types'; +import { BaseSnapRpcHandler } from '../base'; +import type { IStaticSnapRpcHandler, SnapRpcHandlerResponse } from '../types'; +import { SnapRpcHandlerRequestStruct } from '../types'; export type CreateAccountParams = Infer< - typeof CreateAccountHandler.validateStruct + typeof CreateAccountHandler.requestStruct >; -export type CreateAccountResponse = SnapRpcRequestHandlerResponse & - KeyringAccount; +export type CreateAccountResponse = SnapRpcHandlerResponse & KeyringAccount; export class CreateAccountHandler - extends BaseSnapRpcRequestHandler + extends BaseSnapRpcHandler implements - StaticImplements + StaticImplements { - static get validateStruct() { + static override get requestStruct() { return assign( object({ index: number(), }), - SnapRpcRequestHandlerRequestStruct, + SnapRpcHandlerRequestStruct, ); } - validateStruct = CreateAccountHandler.validateStruct; + requestStruct = CreateAccountHandler.requestStruct; async handleRequest( params: CreateAccountParams, ): Promise { - const keyring = new BtcKeyring(new KeyringStateManager(), { - defaultIndex: Config.account[Config.chain].defaultAccountIndex, - multiAccount: Config.account[Config.chain].enableMultiAccounts, - emitEvents: false, - }); + const keyring = new BtcKeyring( + new KeyringStateManager(), + Factory.createBtcChainRpcMapping(), + { + defaultIndex: Config.account[Config.chain].defaultAccountIndex, + multiAccount: Config.account[Config.chain].enableMultiAccounts, + emitEvents: false, + }, + ); const account = await keyring.createAccount({ scope: params.scope, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts index 15425884..fdca2cfc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts @@ -1,6 +1,7 @@ import type { Infer } from 'superstruct'; -import { object, string, assign, array } from 'superstruct'; +import { object, string, assign, array, enums } from 'superstruct'; +import { BtcAsset } from '../../modules/bitcoin/config'; import { Chain } from '../../modules/config'; import { Factory } from '../../modules/factory'; import type { AssetBalances } from '../../modules/transaction'; @@ -9,33 +10,29 @@ import { TransactionStateManager, } from '../../modules/transaction'; import type { StaticImplements } from '../../types/static'; -import { BaseSnapRpcRequestHandler } from '../base'; -import type { - IStaticSnapRpcRequestHandler, - SnapRpcRequestHandlerResponse, -} from '../types'; -import { SnapRpcRequestHandlerRequestStruct } from '../types'; +import { BaseSnapRpcHandler } from '../base'; +import type { IStaticSnapRpcHandler, SnapRpcHandlerResponse } from '../types'; +import { SnapRpcHandlerRequestStruct } from '../types'; -export type GetBalancesParams = Infer; +export type GetBalancesParams = Infer; -export type GetBalancesResponse = SnapRpcRequestHandlerResponse & AssetBalances; +export type GetBalancesResponse = SnapRpcHandlerResponse & AssetBalances; export class GetBalancesHandler - extends BaseSnapRpcRequestHandler - implements - StaticImplements + extends BaseSnapRpcHandler + implements StaticImplements { - static get validateStruct() { + static override get requestStruct() { return assign( object({ accounts: array(string()), - assets: array(string()), + assets: array(enums(Object.values(BtcAsset))), }), - SnapRpcRequestHandlerRequestStruct, + SnapRpcHandlerRequestStruct, ); } - validateStruct = GetBalancesHandler.validateStruct; + requestStruct = GetBalancesHandler.requestStruct; async handleRequest(params: GetBalancesParams): Promise { const { scope, accounts, assets } = params; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts index 89e589f0..83352b50 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts @@ -2,42 +2,37 @@ 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'; +import { Config } from '../modules/config'; -export const SnapRpcRequestHandlerRequestStruct = object({ - scope: enums(Config.avaliableNetworks[Chain.Bitcoin]), +export const SnapRpcHandlerRequestStruct = object({ + scope: enums(Config.avaliableNetworks[Config.chain]), }); -export type SnapRpcRequestHandlerRequest = Infer< - typeof SnapRpcRequestHandlerRequestStruct ->; +export type SnapRpcHandlerRequest = Json & + Infer; -export type SnapRpcRequestHandlerResponse = Json; +export type SnapRpcHandlerResponse = Json; -export type SnapRpcRequestHandlerOptions = Json | null; +export type SnapRpcHandlerOptions = Record | null; -export type IStaticSnapRpcRequestHandler = { - validateStruct: Struct; - instance: ISnapRpcRequestHandler | null; - new (options?: SnapRpcRequestHandlerOptions): ISnapRpcRequestHandler; +export type IStaticSnapRpcHandler = { + requestStruct: Struct; + instance: ISnapRpcHandler | null; + new (options?: SnapRpcHandlerOptions): ISnapRpcHandler; getInstance( - this: IStaticSnapRpcRequestHandler, - options?: SnapRpcRequestHandlerOptions, - ): ISnapRpcRequestHandler; + this: IStaticSnapRpcHandler, + options?: SnapRpcHandlerOptions, + ): ISnapRpcHandler; }; export type ISnapRpcValidator = { - validate(params: SnapRpcRequestHandlerRequest): void; + validate(params: SnapRpcHandlerRequest): void; }; export type ISnapRpcExecutable = { - execute( - params: SnapRpcRequestHandlerRequest, - ): Promise; + execute(params: SnapRpcHandlerRequest): Promise; }; -export type ISnapRpcRequestHandler = { - handleRequest( - params: SnapRpcRequestHandlerRequest, - ): Promise; +export type ISnapRpcHandler = { + handleRequest(params: SnapRpcHandlerRequest): Promise; } & ISnapRpcExecutable; From 20fdc94a398d8faef884a186da44f9ff32864c04 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 23 Apr 2024 12:29:34 +0800 Subject: [PATCH 013/362] feat: setup init cd to publish snap to npm public registry (#15) * feat: setup init cd by using MetaMask/action-npm-publish * feat: add init change log * chore: add eol to main.yml * feat: update change log format --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 merged-packages/bitcoin-wallet-snap/CHANGELOG.md diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md new file mode 100644 index 00000000..4656a044 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: git+https://github.com/MetaMask/bitcoin/ From 21dc863d63b605396a947900838f4edc4b4f95ae Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 23 Apr 2024 17:38:13 +0800 Subject: [PATCH 014/362] feat: implement chain api - get balance (#16) --- .../bitcoin-wallet-snap/.env.example | 6 +++ .../bitcoin-wallet-snap/snap.config.ts | 2 + .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/index.ts | 4 +- .../src/modules/config/index.ts | 5 ++- .../src/modules/factory.ts | 16 ++++--- .../src/modules/keyring/keyring.test.ts | 10 ++--- .../bitcoin-wallet-snap/src/rpcs/base.ts | 41 +++++++++++++++--- .../bitcoin-wallet-snap/src/rpcs/index.ts | 1 + .../src/rpcs/methods/create-account.ts | 4 +- .../src/rpcs/methods/get-balances.ts | 42 ++++++++++++++++--- .../src/rpcs/methods/send-transaction.ts | 30 +++++++++++++ .../bitcoin-wallet-snap/src/rpcs/types.ts | 1 + .../bitcoin-wallet-snap/test/utils.ts | 2 +- 14 files changed, 133 insertions(+), 33 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/.env.example create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts diff --git a/merged-packages/bitcoin-wallet-snap/.env.example b/merged-packages/bitcoin-wallet-snap/.env.example new file mode 100644 index 00000000..4e21456f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/.env.example @@ -0,0 +1,6 @@ +# Description: Environment variables for read data client +# Possible Options: BlockStream | BlockChair +DATA_CLIENT_READ_TYPE=BlockStream +# Description: Environment variables for Log Level, 0 does not log anything, 6 logs everything +# Possible Options: 0-6 +LOG_LEVEL=6 diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts index 2640f138..992c8186 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.config.ts +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -13,6 +13,8 @@ const config: SnapConfig = { environment: { // eslint-disable-next-line n/no-process-env LOG_LEVEL: process.env.LOG_LEVEL, + // eslint-disable-next-line n/no-process-env + DATA_CLIENT_READ_TYPE: process.env.DATA_CLIENT_READ_TYPE, }, polyfills: true, }; diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index c2c2a586..22891243 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "o88PXyjmak4cz88JmxwWzhjLXqk+MIm3v6ycTj0lyuQ=", + "shasum": "kCyawnub+hxegviL49Qojh2EWrjCFQBufNI1iGF4q18=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 20f8905f..0833fd8e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -21,9 +21,9 @@ const validateOrigin = async (origin: string) => { const getHandler = (method: string): IStaticSnapRpcHandler => { switch (method) { - case 'bitcoin_createAccount': + case 'chain_createAccount': return CreateAccountHandler; - case 'bitcoin_getBalances': + case 'chain_getBalances': return GetBalancesHandler; default: throw new SnapError(`Method not found`); 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 051e01dc..ee42025b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts @@ -36,7 +36,10 @@ export const Config: SnapConfig = { [Chain.Bitcoin]: { dataClient: { read: { - type: DataClient.BlockChair, + type: + // eslint-disable-next-line no-restricted-globals + (process.env.DATA_CLIENT_READ_TYPE as DataClient) ?? + DataClient.BlockChair, }, }, }, diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts index 93fb44fb..128ec801 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts @@ -1,20 +1,18 @@ import { type Keyring } from '@metamask/keyring-api'; -import type { IStaticSnapRpcHandler } from '../rpcs'; -import { GetBalancesHandler } from '../rpcs'; +import { type IStaticSnapRpcHandler, SendTransactionHandler } from '../rpcs'; import { BtcAccountMgrFactory } from './bitcoin/account'; -import type { Network } from './bitcoin/config'; import { + type Network, 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 { Config } from './config'; -import type { Chain } from './config'; +import { type Chain, Config } from './config'; import { BtcKeyring, KeyringStateManager, type IAccountMgr } from './keyring'; -import type { ITransactionMgr } from './transaction/types'; +import type { ITransactionMgr } from './transaction'; export type CreateBtcKeyringOptions = { emitEvents: boolean; @@ -38,10 +36,10 @@ export class Factory { return BtcAccountMgrFactory.create(config, btcNetwork); } - static createBtcChainRpcMapping(): Record { + static createBtcKeyringRpcMapping(): Record { return { // eslint-disable-next-line @typescript-eslint/naming-convention - chain_getBalances: GetBalancesHandler, + btc_sendTransaction: SendTransactionHandler, }; } @@ -51,7 +49,7 @@ export class Factory { ): BtcKeyring { return new BtcKeyring( new KeyringStateManager(), - Factory.createBtcChainRpcMapping(), + Factory.createBtcKeyringRpcMapping(), { defaultIndex: config.defaultAccountIndex, multiAccount: config.enableMultiAccounts, 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 index dfe7e1df..91eaa9be 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts @@ -78,8 +78,6 @@ describe('BtcKeyring', () => { return unknown(); } - requestStruct = MockChainRpcHandler.requestStruct; - handleRequest = handleRequestSpy; } return { @@ -93,7 +91,7 @@ describe('BtcKeyring', () => { createMockChainRPCHandler(); const chainRPCHanlers = { // eslint-disable-next-line @typescript-eslint/naming-convention - chain_getBalances: RpcHandler, + btc_sendTransaction: RpcHandler, }; return { @@ -201,7 +199,7 @@ describe('BtcKeyring', () => { scope: Network.Testnet, account: account.address, request: { - method: 'chain_getBalances', + method: 'btc_sendTransaction', params, }, }); @@ -220,10 +218,10 @@ describe('BtcKeyring', () => { scope: Network.Testnet, account: account.address, request: { - method: 'signMessage', + method: 'btc_doesNotExist', }, }), - ).rejects.toThrow('Method not found: signMessage'); + ).rejects.toThrow('Method not found: btc_doesNotExist'); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts index 0f845d6b..892c4e20 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts @@ -12,19 +12,40 @@ import { SnapRpcHandlerRequestStruct, } from './types'; +abstract class Parent { + static readonly staticMemeber: string; + + protected myProtectedMethod() { + console.log((this.constructor as typeof Parent).staticMemeber); + } +} + +class Child extends Parent { + static readonly staticMemeber = 'Hello, world!'; + + public doSomething() { + this.myProtectedMethod(); + } +} +new Child().doSomething(); + export abstract class BaseSnapRpcHandler implements ISnapRpcExecutable { static instance: ISnapRpcHandler | null = null; - static requestStruct: Struct = SnapRpcHandlerRequestStruct; + static readonly requestStruct: Struct = SnapRpcHandlerRequestStruct; + + static readonly responseStruct?: Struct; abstract handleRequest( params: SnapRpcHandlerRequest, ): Promise; - abstract requestStruct: Struct; + protected get requestStruct(): Struct { + return (this.constructor as typeof BaseSnapRpcHandler).requestStruct; + } - protected async validate(params: SnapRpcHandlerRequest): Promise { - assert(params, this.requestStruct); + protected get responseStruct(): Struct | undefined { + return (this.constructor as typeof BaseSnapRpcHandler).responseStruct; } protected async preExecute(params: SnapRpcHandlerRequest): Promise { @@ -32,7 +53,7 @@ export abstract class BaseSnapRpcHandler implements ISnapRpcExecutable { `[SnapRpcHandler.preExecute] Request: ${JSON.stringify(params)}`, ); try { - await this.validate(params); + assert(params, this.requestStruct); } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[SnapRpcHandler.preExecute] Error: ${error.message}`); @@ -41,6 +62,16 @@ export abstract class BaseSnapRpcHandler implements ISnapRpcExecutable { } protected async postExecute(response: SnapRpcHandlerResponse): Promise { + try { + if (this.responseStruct) { + assert(response, this.responseStruct); + } + } catch (error) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.info(`[SnapRpcHandler.postExecute] Error: ${error.message}`); + throw new SnapRpcValidationError('Response is invalid'); + } + logger.info( `[SnapRpcHandler.postExecute] Response: ${JSON.stringify(response)}`, ); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts index c6b254f2..a19d2263 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts @@ -1,5 +1,6 @@ export * from './methods/create-account'; export * from './methods/get-balances'; +export * from './methods/send-transaction'; 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 index 24e2f3da..64ae5ed4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts @@ -30,14 +30,12 @@ export class CreateAccountHandler ); } - requestStruct = CreateAccountHandler.requestStruct; - async handleRequest( params: CreateAccountParams, ): Promise { const keyring = new BtcKeyring( new KeyringStateManager(), - Factory.createBtcChainRpcMapping(), + Factory.createBtcKeyringRpcMapping(), { defaultIndex: Config.account[Config.chain].defaultAccountIndex, multiAccount: Config.account[Config.chain].enableMultiAccounts, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts index fdca2cfc..408ed41d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts @@ -1,10 +1,9 @@ import type { Infer } from 'superstruct'; -import { object, string, assign, array, enums } from 'superstruct'; +import { object, string, assign, array, enums, record } from 'superstruct'; import { BtcAsset } from '../../modules/bitcoin/config'; import { Chain } from '../../modules/config'; import { Factory } from '../../modules/factory'; -import type { AssetBalances } from '../../modules/transaction'; import { TransactionService, TransactionStateManager, @@ -16,7 +15,8 @@ import { SnapRpcHandlerRequestStruct } from '../types'; export type GetBalancesParams = Infer; -export type GetBalancesResponse = SnapRpcHandlerResponse & AssetBalances; +export type GetBalancesResponse = SnapRpcHandlerResponse & + Infer; export class GetBalancesHandler extends BaseSnapRpcHandler @@ -32,7 +32,19 @@ export class GetBalancesHandler ); } - requestStruct = GetBalancesHandler.requestStruct; + static override get responseStruct() { + return object({ + balances: record( + string(), + record( + string(), + object({ + amount: string(), + }), + ), + ), + }); + } async handleRequest(params: GetBalancesParams): Promise { const { scope, accounts, assets } = params; @@ -42,6 +54,26 @@ export class GetBalancesHandler new TransactionStateManager(), ); - return await txService.getBalances(accounts, assets); + const balances = await txService.getBalances(accounts, assets); + + const response = { + balances: Object.entries(balances.balances).reduce( + (balancesObj, [address, assetBalances]) => { + balancesObj[address] = Object.entries(assetBalances).reduce( + (assetBalanceObj, [asset, balance]) => { + assetBalanceObj[asset] = { + amount: balance.amount.toString(), + }; + return assetBalanceObj; + }, + {}, + ); + return balancesObj; + }, + {}, + ), + }; + + return response; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts new file mode 100644 index 00000000..a0a7995a --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts @@ -0,0 +1,30 @@ +import type { Infer } from 'superstruct'; +import { unknown } from 'superstruct'; + +import type { AssetBalances } from '../../modules/transaction'; +import type { StaticImplements } from '../../types/static'; +import { BaseSnapRpcHandler } from '../base'; +import type { IStaticSnapRpcHandler, SnapRpcHandlerResponse } from '../types'; + +export type SendTransactionParams = Infer< + typeof SendTransactionHandler.requestStruct +>; + +export type SendTransactionResponse = SnapRpcHandlerResponse & AssetBalances; + +export class SendTransactionHandler + extends BaseSnapRpcHandler + implements + StaticImplements +{ + static override get requestStruct() { + return unknown(); + } + + async handleRequest( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + params: SendTransactionParams, + ): Promise { + throw new Error('Method not supported'); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts index 83352b50..c967123f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts @@ -17,6 +17,7 @@ export type SnapRpcHandlerOptions = Record | null; export type IStaticSnapRpcHandler = { requestStruct: Struct; + reponseStruct?: Struct; instance: ISnapRpcHandler | null; new (options?: SnapRpcHandlerOptions): ISnapRpcHandler; getInstance( diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index 99e1f356..4af15f05 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -35,7 +35,7 @@ export function generateAccounts(cnt = 1, addressPrefix = '', idPrefix = '') { scope: NetworkEnum.Testnet, index: i, }, - methods: ['chain_getBalances'], + methods: ['btc_sendTransaction'], }); } From 4c9bd9aef4d792ddde01c6a9613cb82df7f66799 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 24 Apr 2024 09:40:48 +0800 Subject: [PATCH 015/362] feat: add response struct (#17) --- .../src/modules/config/index.ts | 10 ++- .../src/modules/keyring/types.ts | 6 +- .../src/rpcs/methods/get-balances.ts | 10 +-- .../bitcoin-wallet-snap/src/rpcs/types.ts | 6 +- .../src/types/superstruct.test.ts | 72 +++++++++++++++++++ .../src/types/superstruct.ts | 9 +++ 6 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/types/superstruct.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/types/superstruct.ts 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 ee42025b..541cf769 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts @@ -3,6 +3,7 @@ import { type BtcTransactionConfig, Network as BtcNetwork, DataClient, + BtcAsset, } from '../bitcoin/config'; export enum Chain { @@ -27,6 +28,9 @@ export type SnapConfig = { avaliableNetworks: { [key in Chain]: string[]; }; + avaliableAssets: { + [key in Chain]: string[]; + }; chain: Chain; logLevel: string; }; @@ -53,8 +57,10 @@ export const Config: SnapConfig = { }, }, avaliableNetworks: { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - [Chain.Bitcoin]: Object.entries(BtcNetwork).map(([_, val]) => val), + [Chain.Bitcoin]: Object.values(BtcNetwork), + }, + avaliableAssets: { + [Chain.Bitcoin]: Object.values(BtcAsset), }, chain: Chain.Bitcoin, // eslint-disable-next-line no-restricted-globals diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts index e656325a..5750244c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts @@ -1,10 +1,10 @@ import { type Json } from '@metamask/snaps-sdk'; import type { Buffer } from 'buffer'; import type { Infer } from 'superstruct'; -import { object, enums } from 'superstruct'; +import { object } from 'superstruct'; import type { IStaticSnapRpcHandler } from '../../rpcs'; -import { Config } from '../config'; +import { scopeStruct } from '../../types/superstruct'; export type IAccountSigner = { sign(hash: Buffer): Promise; @@ -37,7 +37,7 @@ export type KeyringOptions = Record & { export type ChainRPCHandlers = Record; export const CreateAccountOptionsStruct = object({ - scope: enums(Config.avaliableNetworks[Config.chain]), + scope: scopeStruct, }); export type CreateAccountOptions = Record & diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts index 408ed41d..4764a805 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts @@ -1,7 +1,6 @@ import type { Infer } from 'superstruct'; -import { object, string, assign, array, enums, record } from 'superstruct'; +import { object, string, assign, array, record } from 'superstruct'; -import { BtcAsset } from '../../modules/bitcoin/config'; import { Chain } from '../../modules/config'; import { Factory } from '../../modules/factory'; import { @@ -9,6 +8,7 @@ import { TransactionStateManager, } from '../../modules/transaction'; import type { StaticImplements } from '../../types/static'; +import { assetsStruct, numberStringStruct } from '../../types/superstruct'; import { BaseSnapRpcHandler } from '../base'; import type { IStaticSnapRpcHandler, SnapRpcHandlerResponse } from '../types'; import { SnapRpcHandlerRequestStruct } from '../types'; @@ -26,7 +26,7 @@ export class GetBalancesHandler return assign( object({ accounts: array(string()), - assets: array(enums(Object.values(BtcAsset))), + assets: array(assetsStruct), }), SnapRpcHandlerRequestStruct, ); @@ -37,9 +37,9 @@ export class GetBalancesHandler balances: record( string(), record( - string(), + assetsStruct, object({ - amount: string(), + amount: numberStringStruct, }), ), ), diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts index c967123f..b7ba9f71 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts @@ -1,11 +1,11 @@ import type { Json } from '@metamask/snaps-sdk'; import type { Infer } from 'superstruct'; -import { enums, object, type Struct } from 'superstruct'; +import { object, type Struct } from 'superstruct'; -import { Config } from '../modules/config'; +import { scopeStruct } from '../types/superstruct'; export const SnapRpcHandlerRequestStruct = object({ - scope: enums(Config.avaliableNetworks[Config.chain]), + scope: scopeStruct, }); export type SnapRpcHandlerRequest = Json & diff --git a/merged-packages/bitcoin-wallet-snap/src/types/superstruct.test.ts b/merged-packages/bitcoin-wallet-snap/src/types/superstruct.test.ts new file mode 100644 index 00000000..cb4db46c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/types/superstruct.test.ts @@ -0,0 +1,72 @@ +import { assert } from 'superstruct'; + +import { Config } from '../modules/config'; +import * as superstruct from './superstruct'; + +describe('superstruct', () => { + describe('numberStringStruct', () => { + it('validates correctly', () => { + expect(() => assert('1', superstruct.numberStringStruct)).not.toThrow( + Error, + ); + expect(() => assert('1.2', superstruct.numberStringStruct)).not.toThrow( + Error, + ); + expect(() => assert('0', superstruct.numberStringStruct)).not.toThrow( + Error, + ); + expect(() => + assert('0.0023', superstruct.numberStringStruct), + ).not.toThrow(); + expect(() => assert('0101', superstruct.numberStringStruct)).toThrow( + Error, + ); + expect(() => assert('0101.1', superstruct.numberStringStruct)).toThrow( + Error, + ); + expect(() => assert('-0101', superstruct.numberStringStruct)).toThrow( + Error, + ); + expect(() => assert('-1.3', superstruct.numberStringStruct)).toThrow( + Error, + ); + expect(() => assert(' 1.3', superstruct.numberStringStruct)).toThrow( + Error, + ); + expect(() => assert('+1-3', superstruct.numberStringStruct)).toThrow( + Error, + ); + expect(() => assert('abc', superstruct.numberStringStruct)).toThrow( + Error, + ); + }); + }); + + describe('scopeStruct', () => { + it('validates correctly', () => { + expect(() => + assert( + Config.avaliableNetworks[Config.chain][0], + superstruct.scopeStruct, + ), + ).not.toThrow(); + expect(() => assert('custom scope', superstruct.scopeStruct)).toThrow( + Error, + ); + }); + }); + + describe('assetsStruct', () => { + it('validates correctly', () => { + expect(() => + assert( + Config.avaliableAssets[Config.chain][0], + superstruct.assetsStruct, + ), + ).not.toThrow(); + expect(() => assert('custom scope', superstruct.assetsStruct)).toThrow( + Error, + ); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/types/superstruct.ts b/merged-packages/bitcoin-wallet-snap/src/types/superstruct.ts new file mode 100644 index 00000000..4d6af29a --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/types/superstruct.ts @@ -0,0 +1,9 @@ +import { enums, string, pattern } from 'superstruct'; + +import { Config } from '../modules/config'; + +export const assetsStruct = enums(Config.avaliableAssets[Config.chain]); + +export const scopeStruct = enums(Config.avaliableNetworks[Config.chain]); + +export const numberStringStruct = pattern(string(), /^(?!0\d)(\d+(\.\d+)?)$/u); From 0b529d1f60a9d13c5172e78719aec7ba95b8746c Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 24 Apr 2024 10:02:32 +0800 Subject: [PATCH 016/362] fix: remove un use code in BaseSnapRpcHandler (#19) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/rpcs/base.ts | 25 +++---------------- 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 22891243..e584d2d0 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "kCyawnub+hxegviL49Qojh2EWrjCFQBufNI1iGF4q18=", + "shasum": "rWA8p3lwhc7PyoKokMPwssnTRiWd4ZeKbMes2iiDhos=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts index 892c4e20..18d43845 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts @@ -12,23 +12,6 @@ import { SnapRpcHandlerRequestStruct, } from './types'; -abstract class Parent { - static readonly staticMemeber: string; - - protected myProtectedMethod() { - console.log((this.constructor as typeof Parent).staticMemeber); - } -} - -class Child extends Parent { - static readonly staticMemeber = 'Hello, world!'; - - public doSomething() { - this.myProtectedMethod(); - } -} -new Child().doSomething(); - export abstract class BaseSnapRpcHandler implements ISnapRpcExecutable { static instance: ISnapRpcHandler | null = null; @@ -62,6 +45,10 @@ export abstract class BaseSnapRpcHandler implements ISnapRpcExecutable { } protected async postExecute(response: SnapRpcHandlerResponse): Promise { + logger.info( + `[SnapRpcHandler.postExecute] Response: ${JSON.stringify(response)}`, + ); + try { if (this.responseStruct) { assert(response, this.responseStruct); @@ -71,10 +58,6 @@ export abstract class BaseSnapRpcHandler implements ISnapRpcExecutable { logger.info(`[SnapRpcHandler.postExecute] Error: ${error.message}`); throw new SnapRpcValidationError('Response is invalid'); } - - logger.info( - `[SnapRpcHandler.postExecute] Response: ${JSON.stringify(response)}`, - ); } async execute( From c866108731b84fd464e903874a7150df18960953 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 25 Apr 2024 18:04:37 +0800 Subject: [PATCH 017/362] feat: add permission validate on snap index (#24) * feat: add permission validate on snap index * feat: update permission --- .../bitcoin-wallet-snap/jest.config.js | 2 + .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 14 +- .../bitcoin-wallet-snap/src/index.test.ts | 152 ++++++++++++++++++ .../bitcoin-wallet-snap/src/index.ts | 37 ++--- .../src/modules/config/config.ts | 68 ++++++++ .../src/modules/config/index.ts | 70 +------- .../src/modules/config/permissions.ts | 56 +++++++ .../bitcoin-wallet-snap/src/rpcs/helpers.ts | 16 ++ .../bitcoin-wallet-snap/src/rpcs/index.ts | 5 +- .../src/rpcs/methods/get-balances.ts | 4 +- .../src/rpcs/methods/index.ts | 3 + 12 files changed, 324 insertions(+), 105 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/index.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/config/config.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/config/permissions.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/methods/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/jest.config.js b/merged-packages/bitcoin-wallet-snap/jest.config.js index b7fb6b70..46f67ce7 100644 --- a/merged-packages/bitcoin-wallet-snap/jest.config.js +++ b/merged-packages/bitcoin-wallet-snap/jest.config.js @@ -14,6 +14,8 @@ module.exports = { './src/**/*.ts', '!./src/**/*.d.ts', '!./src/**/index.ts', + '!./src/modules/config/config.ts', + '!./src/modules/config/permissions.ts', '!./src/**/type?(s).ts', '!./src/**/exception?(s).ts', '!./test/**', diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 1aee7198..53bbab13 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -35,7 +35,6 @@ "bip32": "^4.0.0", "bitcoinjs-lib": "^6.1.5", "buffer": "^6.0.3", - "dotenv": "^16.4.5", "superstruct": "^1.0.3", "uuid": "^9.0.1" }, @@ -52,6 +51,7 @@ "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", "bip174": "^2.1.1", + "dotenv": "^16.4.5", "eslint": "^8.45.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "~2.26.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index e584d2d0..db3fbf9c 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "rWA8p3lwhc7PyoKokMPwssnTRiWd4ZeKbMes2iiDhos=", + "shasum": "2Q5sVtPMUWJOQJR9KmuvsXBK+gPvbLmAOCOeW88q1Pw=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -25,19 +25,11 @@ "endowment:keyring": { "allowedOrigins": [ "https://metamask.github.io", - "https://portfolio.metamask.io", - "http://localhost:8000" + "http://localhost:8000", + "https://portfolio.metamask.io" ] }, "snap_getBip32Entropy": [ - { - "path": ["m", "49'", "0'"], - "curve": "secp256k1" - }, - { - "path": ["m", "49'", "1'"], - "curve": "secp256k1" - }, { "path": ["m", "84'", "0'"], "curve": "secp256k1" diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts new file mode 100644 index 00000000..1f57d807 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.ts @@ -0,0 +1,152 @@ +import * as keyringApi from '@metamask/keyring-api'; +import { type Json, type JsonRpcRequest, SnapError } from '@metamask/snaps-sdk'; + +import { onRpcRequest, validateOrigin, onKeyringRequest } from '.'; +import { Config, originPermissions } from './modules/config'; +import { BtcKeyring } from './modules/keyring'; +import type { IStaticSnapRpcHandler } from './rpcs'; +import { BaseSnapRpcHandler, RpcHelper } from './rpcs'; +import type { StaticImplements } from './types/static'; + +jest.mock('@metamask/keyring-api', () => ({ + ...jest.requireActual('@metamask/keyring-api'), + handleKeyringRequest: jest.fn(), +})); + +jest.mock('./modules/logger/logger', () => ({ + logger: { + info: jest.fn(), + }, +})); + +describe('validateOrigin', () => { + it('does not throws error if the origin and method is match to the allowed list', () => { + const [origin, methods]: [string, Set] = originPermissions + .entries() + .next().value; + const method = methods.values().next().value; + expect(() => validateOrigin(origin, method)).not.toThrow(SnapError); + }); + + it('throws `Origin not found` error if not origin is provided', () => { + expect(() => validateOrigin('', 'chain_getBalances')).toThrow( + 'Origin not found', + ); + }); + + it('throws `Permission denied` error if origin not match to the allowed list', () => { + expect(() => validateOrigin('xyz', 'chain_getBalances')).toThrow( + 'Permission denied', + ); + }); + + it('throws `Permission denied` error if the method is not match to the allowed list', () => { + const elm = originPermissions.entries().next().value; + expect(() => validateOrigin(elm[0], 'some_method')).toThrow( + 'Permission denied', + ); + }); +}); + +describe('onRpcRequest', () => { + const createMockChainApiHandler = () => { + const handleRequestSpy = jest.fn(); + class MockChainApiHandler + extends BaseSnapRpcHandler + implements + StaticImplements + { + handleRequest = handleRequestSpy; + } + return { handler: MockChainApiHandler, handleRequestSpy }; + }; + + const executeRequest = async () => { + return onRpcRequest({ + origin: 'http://localhost:8000', + request: { + method: 'chain_getBalances', + params: { + scope: Config.avaliableNetworks[Config.chain][0], + }, + } as unknown as JsonRpcRequest, + }); + }; + + it('executes the rpc request', async () => { + const { handler, handleRequestSpy } = createMockChainApiHandler(); + jest.spyOn(RpcHelper, 'getChainApiHandler').mockReturnValue(handler); + handleRequestSpy.mockResolvedValueOnce({ + data: 1, + } as Json); + + const resposne = await executeRequest(); + + expect(resposne).toStrictEqual({ data: 1 }); + }); + + it('throws SnapError if an error catched', async () => { + const { handler, handleRequestSpy } = createMockChainApiHandler(); + jest.spyOn(RpcHelper, 'getChainApiHandler').mockReturnValue(handler); + handleRequestSpy.mockRejectedValue(new Error('error')); + + await expect(executeRequest()).rejects.toThrow(SnapError); + }); + + it('throws SnapError if an SnapError catched', async () => { + const { handler, handleRequestSpy } = createMockChainApiHandler(); + jest.spyOn(RpcHelper, 'getChainApiHandler').mockReturnValue(handler); + handleRequestSpy.mockRejectedValue(new SnapError('error')); + + await expect(executeRequest()).rejects.toThrow(SnapError); + }); +}); + +describe('onKeyringRequest', () => { + const createMockHandleKeyringRequest = () => { + const handleKeyringRequestSpy = jest.spyOn( + keyringApi, + 'handleKeyringRequest', + ); + return { handler: handleKeyringRequestSpy }; + }; + + const executeRequest = async () => { + return onKeyringRequest({ + origin: 'http://localhost:8000', + request: { + method: keyringApi.KeyringRpcMethod.CreateAccount, + params: { + scope: Config.avaliableNetworks[Config.chain][0], + }, + } as unknown as JsonRpcRequest, + }); + }; + + it('executes the rpc request', async () => { + const { handler } = createMockHandleKeyringRequest(); + + await executeRequest(); + + expect(handler).toHaveBeenCalledWith(expect.any(BtcKeyring), { + method: keyringApi.KeyringRpcMethod.CreateAccount, + params: { + scope: Config.avaliableNetworks[Config.chain][0], + }, + }); + }); + + it('throws SnapError if an error catched', async () => { + const { handler } = createMockHandleKeyringRequest(); + handler.mockRejectedValue(new Error('error')); + + await expect(executeRequest()).rejects.toThrow(SnapError); + }); + + it('throws SnapError if an SnapError catched', async () => { + const { handler } = createMockHandleKeyringRequest(); + handler.mockRejectedValue(new SnapError('error')); + + await expect(executeRequest()).rejects.toThrow(SnapError); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 0833fd8e..23d2fdaa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -6,38 +6,31 @@ import { SnapError, } from '@metamask/snaps-sdk'; -import { Chain, Config } from './modules/config'; +import { Config } from './modules/config'; +import { originPermissions } from './modules/config/permissions'; import { Factory } from './modules/factory'; import { logger } from './modules/logger/logger'; -import type { SnapRpcHandlerRequest, IStaticSnapRpcHandler } from './rpcs'; -import { CreateAccountHandler, GetBalancesHandler } from './rpcs'; +import type { SnapRpcHandlerRequest } from './rpcs'; +import { RpcHelper } from './rpcs/helpers'; -const validateOrigin = async (origin: string) => { - // TODO: validate origin - if (origin === '') { +export const validateOrigin = (origin: string, method: string) => { + if (!origin) { throw new SnapError('Origin not found'); } -}; - -const getHandler = (method: string): IStaticSnapRpcHandler => { - switch (method) { - case 'chain_createAccount': - return CreateAccountHandler; - case 'chain_getBalances': - return GetBalancesHandler; - default: - throw new SnapError(`Method not found`); + if (!originPermissions.get(origin)?.has(method)) { + throw new SnapError(`Permission denied`); } }; export const onRpcRequest: OnRpcRequestHandler = async (args) => { try { - logger.logLevel = parseInt(Config.logLevel, 10); const { request, origin } = args; const { method } = request; - await validateOrigin(origin); + validateOrigin(origin, method); - return await getHandler(method) + logger.logLevel = parseInt(Config.logLevel, 10); + + return await RpcHelper.getChainApiHandler(method) .getInstance() .execute(request.params as SnapRpcHandlerRequest); } catch (error) { @@ -49,11 +42,15 @@ export const onRpcRequest: OnRpcRequestHandler = async (args) => { }; export const onKeyringRequest: OnKeyringRequestHandler = async ({ + origin, request, }): Promise => { try { + validateOrigin(origin, request.method); + logger.logLevel = parseInt(Config.logLevel, 10); - const keyring = Factory.createKeyring(Chain.Bitcoin); + + const keyring = Factory.createKeyring(Config.chain); return (await handleKeyringRequest( keyring, request, diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/config/config.ts b/merged-packages/bitcoin-wallet-snap/src/modules/config/config.ts new file mode 100644 index 00000000..541cf769 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/config/config.ts @@ -0,0 +1,68 @@ +import { + type BtcAccountConfig, + type BtcTransactionConfig, + Network as BtcNetwork, + DataClient, + BtcAsset, +} 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[]; + }; + avaliableAssets: { + [key in Chain]: string[]; + }; + chain: Chain; + logLevel: string; +}; + +export const Config: SnapConfig = { + transaction: { + [Chain.Bitcoin]: { + dataClient: { + read: { + type: + // eslint-disable-next-line no-restricted-globals + (process.env.DATA_CLIENT_READ_TYPE as DataClient) ?? + DataClient.BlockChair, + }, + }, + }, + }, + account: { + [Chain.Bitcoin]: { + enableMultiAccounts: false, + defaultAccountIndex: 0, + defaultAccountType: 'p2wpkh', + deriver: 'BIP32', + }, + }, + avaliableNetworks: { + [Chain.Bitcoin]: Object.values(BtcNetwork), + }, + avaliableAssets: { + [Chain.Bitcoin]: Object.values(BtcAsset), + }, + chain: Chain.Bitcoin, + // eslint-disable-next-line no-restricted-globals + logLevel: process.env.LOG_LEVEL ?? '6', +}; 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 541cf769..ae8d958f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts @@ -1,68 +1,2 @@ -import { - type BtcAccountConfig, - type BtcTransactionConfig, - Network as BtcNetwork, - DataClient, - BtcAsset, -} 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[]; - }; - avaliableAssets: { - [key in Chain]: string[]; - }; - chain: Chain; - logLevel: string; -}; - -export const Config: SnapConfig = { - transaction: { - [Chain.Bitcoin]: { - dataClient: { - read: { - type: - // eslint-disable-next-line no-restricted-globals - (process.env.DATA_CLIENT_READ_TYPE as DataClient) ?? - DataClient.BlockChair, - }, - }, - }, - }, - account: { - [Chain.Bitcoin]: { - enableMultiAccounts: false, - defaultAccountIndex: 0, - defaultAccountType: 'p2wpkh', - deriver: 'BIP32', - }, - }, - avaliableNetworks: { - [Chain.Bitcoin]: Object.values(BtcNetwork), - }, - avaliableAssets: { - [Chain.Bitcoin]: Object.values(BtcAsset), - }, - chain: Chain.Bitcoin, - // eslint-disable-next-line no-restricted-globals - logLevel: process.env.LOG_LEVEL ?? '6', -}; +export * from './config'; +export * from './permissions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/config/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/config/permissions.ts new file mode 100644 index 00000000..033a6f76 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/config/permissions.ts @@ -0,0 +1,56 @@ +import { KeyringRpcMethod } from '@metamask/keyring-api'; + +export const originPermissions = new Map>([ + [ + 'metamask', + new Set([ + // Keyring methods + KeyringRpcMethod.ListAccounts, + KeyringRpcMethod.GetAccount, + KeyringRpcMethod.FilterAccountChains, + KeyringRpcMethod.DeleteAccount, + KeyringRpcMethod.ListRequests, + KeyringRpcMethod.GetRequest, + KeyringRpcMethod.SubmitRequest, + KeyringRpcMethod.RejectRequest, + // Chain API methods + 'chain_getBalances', + ]), + ], + [ + 'http://localhost:8000', + new Set([ + // Keyring methods + KeyringRpcMethod.ListAccounts, + KeyringRpcMethod.GetAccount, + KeyringRpcMethod.CreateAccount, + KeyringRpcMethod.FilterAccountChains, + KeyringRpcMethod.UpdateAccount, + KeyringRpcMethod.DeleteAccount, + KeyringRpcMethod.ListRequests, + KeyringRpcMethod.GetRequest, + KeyringRpcMethod.ApproveRequest, + KeyringRpcMethod.RejectRequest, + // Chain API methods + 'chain_getBalances', + ]), + ], + [ + 'https://metamask.github.io', + new Set([ + // Keyring methods + KeyringRpcMethod.ListAccounts, + KeyringRpcMethod.GetAccount, + KeyringRpcMethod.CreateAccount, + KeyringRpcMethod.FilterAccountChains, + KeyringRpcMethod.UpdateAccount, + KeyringRpcMethod.DeleteAccount, + KeyringRpcMethod.ListRequests, + KeyringRpcMethod.GetRequest, + KeyringRpcMethod.ApproveRequest, + KeyringRpcMethod.RejectRequest, + // Chain API methods + 'chain_getBalances', + ]), + ], +]); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts new file mode 100644 index 00000000..05a88d0b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts @@ -0,0 +1,16 @@ +import { SnapRpcError } from './exceptions'; +import { CreateAccountHandler, GetBalancesHandler } from './methods'; +import type { IStaticSnapRpcHandler } from './types'; + +export class RpcHelper { + static getChainApiHandler(method: string): IStaticSnapRpcHandler { + switch (method) { + case 'chain_createAccount': + return CreateAccountHandler; + case 'chain_getBalances': + return GetBalancesHandler; + default: + throw new SnapRpcError(`Method not found`); + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts index a19d2263..34dcf6e5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts @@ -1,6 +1,5 @@ -export * from './methods/create-account'; -export * from './methods/get-balances'; -export * from './methods/send-transaction'; +export * from './methods'; export * from './types'; export * from './base'; +export * from './helpers'; export * from './exceptions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts index 4764a805..525242bd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts @@ -1,7 +1,7 @@ import type { Infer } from 'superstruct'; import { object, string, assign, array, record } from 'superstruct'; -import { Chain } from '../../modules/config'; +import { Config } from '../../modules/config'; import { Factory } from '../../modules/factory'; import { TransactionService, @@ -50,7 +50,7 @@ export class GetBalancesHandler const { scope, accounts, assets } = params; const txService = new TransactionService( - Factory.createTransactionMgr(Chain.Bitcoin, scope), + Factory.createTransactionMgr(Config.chain, scope), new TransactionStateManager(), ); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/index.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/index.ts new file mode 100644 index 00000000..14eb7510 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/index.ts @@ -0,0 +1,3 @@ +export * from './create-account'; +export * from './get-balances'; +export * from './send-transaction'; From 695e98c629a0afc4ad5913a5b3251c9e064ab7fd Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 25 Apr 2024 18:37:44 +0800 Subject: [PATCH 018/362] feat: support transactional in state manager (#20) * feat: support transactional in state manager * feat: do not rollback if it is not force commit --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/modules/keyring/keyring.ts | 39 +- .../src/modules/snap/state.test.ts | 520 +++++++++++++++++- .../src/modules/snap/state.ts | 143 ++++- 4 files changed, 676 insertions(+), 28 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index db3fbf9c..60e17f92 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "2Q5sVtPMUWJOQJR9KmuvsXBK+gPvbLmAOCOeW88q1Pw=", + "shasum": "aXC8zXKNhQ8UphNuJ62OLWRnVZ0YiAsksEJH1zK+mKg=", "location": { "npm": { "filePath": "dist/bundle.js", 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 ac93c84b..10d53ffa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts @@ -72,9 +72,9 @@ export class BtcKeyring implements Keyring { ); // TODO: Create account with index 0 for now for phase 1 scope, update to use increment index later - const index = this.options.defaultIndex; - + const index = Config.account[Config.chain].defaultAccountIndex; const account = await accountMgr.unlock(index); + logger.info( `[BtcKeyring.createAccount] Account unlocked: ${account.address}`, ); @@ -90,16 +90,17 @@ export class BtcKeyring implements Keyring { )}`, ); - // TODO: Add 2 phases commit - await this.stateMgr.addWallet({ - account: keyringAccount, - type: account.type, - index, - scope: options?.scope, - }); - - await this.#emitEvent(KeyringEvent.AccountCreated, { - account: keyringAccount, + await this.stateMgr.withTransaction(async () => { + await this.stateMgr.addWallet({ + account: keyringAccount, + type: account.type, + index, + scope: options?.scope, + }); + + await this.#emitEvent(KeyringEvent.AccountCreated, { + account: keyringAccount, + }); }); return keyringAccount; @@ -120,9 +121,10 @@ export class BtcKeyring implements Keyring { async updateAccount(account: KeyringAccount): Promise { try { - // TODO: Add 2 phases commit - await this.stateMgr.updateAccount(account); - await this.#emitEvent(KeyringEvent.AccountUpdated, { account }); + await this.stateMgr.withTransaction(async () => { + await this.stateMgr.updateAccount(account); + await this.#emitEvent(KeyringEvent.AccountUpdated, { account }); + }, true); } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[BtcKeyring.updateAccount] Error: ${error.message}`); @@ -132,9 +134,10 @@ export class BtcKeyring implements Keyring { async deleteAccount(id: string): Promise { try { - // TODO: Add 2 phases commit - await this.stateMgr.removeAccounts([id]); - await this.#emitEvent(KeyringEvent.AccountDeleted, { id }); + await this.stateMgr.withTransaction(async () => { + await this.stateMgr.removeAccounts([id]); + await this.#emitEvent(KeyringEvent.AccountDeleted, { id }); + }, true); } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[BtcKeyring.deleteAccount] Error: ${error.message}`); 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 index e0a35dc2..69d15f24 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.test.ts @@ -1,13 +1,44 @@ import { expect } from '@jest/globals'; +import { StateError } from './exceptions'; import { SnapHelper } from './helpers'; import { MutexLock } from './lock'; import { SnapStateManager } from './state'; +jest.mock('../logger/logger', () => ({ + logger: { + info: jest.fn(), + }, +})); + +type MockTransactionDetail = { + txnHash: string; + cnt: number; +}; + +type MockTransactionDetails = { + [key in string]: MockTransactionDetail; +}; + +type MockTransactions = string[]; + +type MockState = { + transaction: MockTransactions; + trasansactionDetails: MockTransactionDetails; +}; + +type MockExecuteTransactionInput = { + txnHash: string; + id: string; + cnt: number; +}; + describe('SnapStateManager', () => { - const createMockStateManager = (createLock?: boolean) => { + const createMockStateManager = ( + createLock?: boolean, + ) => { const updateDataSpy = jest.fn(); - class MockSnapStateManager extends SnapStateManager { + class MockSnapStateManager extends SnapStateManager { constructor() { super(createLock); } @@ -16,14 +47,126 @@ describe('SnapStateManager', () => { return this.get(); } - async updateData(data: any) { + async updateData(data: StateDataInput) { await this.update(async (state) => updateDataSpy(state, data)); } } + const instance = new MockSnapStateManager(); + + const executeTransationFn = async ( + data: StateDataInput, + delay: number, + isThrowError?: boolean, + isForceCommit?: boolean, + ) => { + if (isForceCommit === false || isForceCommit === true) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + await instance.withTransaction(async (state) => { + await instance.updateData(data); + await new Promise((resolve) => setTimeout(resolve, delay)); + if (isThrowError) { + throw new Error('executeTransationFn error'); + } + }, isForceCommit); + } else { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + await instance.withTransaction(async (state) => { + await instance.updateData(data); + await new Promise((resolve) => setTimeout(resolve, delay)); + if (isThrowError) { + throw new Error('executeTransationFn error'); + } + }); + } + }; + + const executeFn = async (data, delay, isThrowError = false) => { + await instance.updateData(data); + await new Promise((resolve) => setTimeout(resolve, delay)); + if (isThrowError) { + throw new Error('executeFn error'); + } + }; + return { - instance: new MockSnapStateManager(), + instance, updateDataSpy, + executeTransationFn, + executeFn, + }; + }; + + const createMockState = (initState: MockState) => { + const setStateDataFn = async (data: MockState) => { + initState.transaction = [...data.transaction]; + initState.trasansactionDetails = Object.entries( + data.trasansactionDetails, + ).reduce( + (acc, [key, value]: [key: string, value: MockTransactionDetail]) => { + acc[key] = { + ...value, + }; + return acc; + }, + {}, + ); + }; + + const updateDataFn = ( + state: MockState, + data: MockExecuteTransactionInput, + ) => { + if ( + Object.prototype.hasOwnProperty.call( + state.trasansactionDetails, + data.id, + ) === false + ) { + state.transaction.push(data.id); + state.trasansactionDetails[data.id] = { + txnHash: data.txnHash, + cnt: data.cnt, + }; + } else { + state.trasansactionDetails[data.id] = { + txnHash: data.txnHash, + cnt: state.trasansactionDetails[data.id].cnt + data.cnt, + }; + } + }; + + const getStateDataSpy = jest + .spyOn(SnapHelper, 'getStateData') + .mockImplementation(async () => { + return { + transaction: [...initState.transaction], + trasansactionDetails: Object.entries( + initState.trasansactionDetails, + ).reduce( + ( + acc, + [key, value]: [key: string, value: MockTransactionDetail], + ) => { + acc[key] = { + ...value, + }; + return acc; + }, + {}, + ), + }; + }); + + const setStateDataSpy = jest + .spyOn(SnapHelper, 'setStateData') + .mockImplementation(setStateDataFn); + + return { + setStateDataFn, + updateDataFn, + getStateDataSpy, + setStateDataSpy, }; }; @@ -98,4 +241,373 @@ describe('SnapStateManager', () => { expect(updateDataSpy).toHaveBeenCalledTimes(1); }); }); + + describe('withTransaction', () => { + it('executes callback code', async () => { + const initState = { + transaction: ['id'], + trasansactionDetails: { + id: { + txnHash: 'hash', + cnt: 4, + }, + }, + }; + + const { getStateDataSpy, updateDataFn } = createMockState(initState); + + const { updateDataSpy, executeTransationFn } = createMockStateManager< + MockState, + MockExecuteTransactionInput + >(); + + updateDataSpy.mockImplementation(updateDataFn); + + const promiseArr = [ + executeTransationFn( + { + txnHash: 'hash-final', + id: 'id', + cnt: 2, + }, + 30, + ), + executeTransationFn( + { + txnHash: 'hash2', + id: 'id2', + cnt: 5, + }, + 0, + ), + ]; + + await Promise.all(promiseArr); + expect(initState.transaction).toStrictEqual(['id', 'id2']); + + expect(initState.trasansactionDetails).toStrictEqual({ + id: { + txnHash: 'hash-final', + cnt: 6, + }, + id2: { + txnHash: 'hash2', + cnt: 5, + }, + }); + expect(getStateDataSpy).toHaveBeenCalledTimes(promiseArr.length * 2); + // expect(setStateDataSpy).toHaveBeenCalledTimes(promiseArr.length); + expect(updateDataSpy).toHaveBeenCalledTimes(promiseArr.length); + }); + + it('does rollback if an error catched and force commit is true', async () => { + const initState = { + transaction: ['id'], + trasansactionDetails: { + id: { + txnHash: 'hash', + cnt: 4, + }, + }, + }; + const { getStateDataSpy, updateDataFn } = createMockState(initState); + const { updateDataSpy, executeTransationFn } = createMockStateManager< + MockState, + MockExecuteTransactionInput + >(); + updateDataSpy.mockImplementation(updateDataFn); + + const promiseArr = [ + executeTransationFn( + { + txnHash: 'hash4', + id: 'id', + cnt: 1, + }, + 10, + ), + executeTransationFn( + { + txnHash: 'hash-final', + id: 'id', + cnt: 2, + }, + 30, + true, + ), + executeTransationFn( + { + txnHash: 'hash2', + id: 'id2', + cnt: 5, + }, + 0, + ), + ]; + + await Promise.allSettled(promiseArr); + + expect(initState.transaction).toStrictEqual(['id', 'id2']); + expect(initState.trasansactionDetails).toStrictEqual({ + id: { + txnHash: 'hash4', + cnt: 5, + }, + id2: { + txnHash: 'hash2', + cnt: 5, + }, + }); + expect(getStateDataSpy).toHaveBeenCalledTimes(promiseArr.length * 2); + expect(updateDataSpy).toHaveBeenCalledTimes(promiseArr.length); + }); + + it('does not trigger rollback if an error catched but force commit is false', async () => { + const isForceCommit = false; + const initState: MockState = { + transaction: ['id'], + trasansactionDetails: { + id: { + txnHash: 'hash', + cnt: 4, + }, + }, + }; + const { setStateDataSpy, updateDataFn } = createMockState(initState); + const { updateDataSpy, executeTransationFn } = createMockStateManager< + MockState, + MockExecuteTransactionInput + >(); + updateDataSpy.mockImplementation(updateDataFn); + + let expectedError; + try { + await executeTransationFn( + { + txnHash: 'hash-final', + id: 'id', + cnt: 2, + }, + 30, + true, + isForceCommit, + ); + } catch (error) { + expectedError = error; + } finally { + expect(expectedError).toBeInstanceOf(Error); + expect(initState.transaction).toStrictEqual(['id']); + expect(setStateDataSpy).toHaveBeenCalledTimes(0); + expect(initState.trasansactionDetails).toStrictEqual({ + id: { + txnHash: 'hash', + cnt: 4, + }, + }); + } + }); + + it('does not rollback if rollback failed and force commit is true', async () => { + const isForceCommit = true; + const initState: MockState = { + transaction: ['id'], + trasansactionDetails: { + id: { + txnHash: 'hash', + cnt: 4, + }, + }, + }; + const { setStateDataSpy, updateDataFn, setStateDataFn } = + createMockState(initState); + const { updateDataSpy, executeTransationFn } = createMockStateManager< + MockState, + MockExecuteTransactionInput + >(); + setStateDataSpy + .mockImplementationOnce(setStateDataFn) + .mockImplementationOnce(() => { + throw new Error('rollback error'); + }); + updateDataSpy.mockImplementation(updateDataFn); + + const promiseArr = [ + executeTransationFn( + { + txnHash: 'hash-final', + id: 'id', + cnt: 2, + }, + 30, + true, + isForceCommit, + ), + executeTransationFn( + { + txnHash: 'hash-final', + id: 'id', + cnt: 2, + }, + 30, + true, + false, + ), + ]; + await Promise.allSettled(promiseArr); + + expect(initState.transaction).toStrictEqual(['id']); + expect(initState.trasansactionDetails).toStrictEqual({ + id: { + txnHash: 'hash-final', + cnt: 6, + }, + }); + }); + + it('does not have racing condition', async () => { + const initState = { + transaction: [], + trasansactionDetails: {}, + }; + const { getStateDataSpy, updateDataFn } = createMockState(initState); + const { updateDataSpy, executeTransationFn, executeFn } = + createMockStateManager(); + + updateDataSpy.mockImplementation(updateDataFn); + + const promiseArr = [ + executeTransationFn( + { + txnHash: 'hash', + id: 'id', + cnt: 2, + }, + 30, + ), + executeTransationFn( + { + txnHash: 'hash2', + id: 'id2', + cnt: 5, + }, + 20, + ), + executeFn( + { + txnHash: 'hash32', + id: 'id3', + cnt: 8, + }, + 0, + ), + executeTransationFn( + { + txnHash: 'hash-updated', + id: 'id', + cnt: 1, + }, + 30, + ), + executeTransationFn( + { + txnHash: 'hash3', + id: 'id3', + cnt: 2, + }, + 10, + ), + executeTransationFn( + { + txnHash: 'hash-updated-final', + id: 'id', + cnt: 3, + }, + 2, + ), + ]; + + await Promise.all(promiseArr); + + expect(initState.transaction).toStrictEqual(['id', 'id2', 'id3']); + expect(initState.trasansactionDetails).toStrictEqual({ + id: { + txnHash: 'hash-updated-final', + cnt: 6, + }, + id2: { + txnHash: 'hash2', + cnt: 5, + }, + id3: { + txnHash: 'hash3', + cnt: 10, + }, + }); + expect(getStateDataSpy).toHaveBeenCalledTimes(promiseArr.length * 2 - 1); + expect(updateDataSpy).toHaveBeenCalledTimes(promiseArr.length); + }); + + it('throws `Failed to begin transaction` error, if the transaction can not init', async () => { + const initState = { + transaction: [], + trasansactionDetails: {}, + }; + + const { getStateDataSpy } = createMockState(initState); + + const { executeTransationFn } = createMockStateManager< + MockState, + MockExecuteTransactionInput + >(); + + getStateDataSpy.mockResolvedValue(undefined); + + await expect( + executeTransationFn( + { + txnHash: 'hash-final', + id: 'id', + cnt: 2, + }, + 30, + ), + ).rejects.toThrow('Failed to begin transaction'); + }); + + it('throws StateError error, if an StateError catched', async () => { + const initState = { + transaction: [], + trasansactionDetails: {}, + }; + + const { setStateDataSpy, setStateDataFn, updateDataFn } = + createMockState(initState); + + const { executeTransationFn, updateDataSpy } = createMockStateManager< + MockState, + MockExecuteTransactionInput + >(); + + updateDataSpy.mockImplementation(updateDataFn); + + // firsy mockImplementation is to mock the set data actions + setStateDataSpy + .mockImplementationOnce(async () => { + throw new StateError('setStateDataSpy'); + // second mockImplementation is to mock the rollback actions + }) + .mockImplementationOnce(setStateDataFn); + + await expect( + executeTransationFn( + { + txnHash: 'hash-final', + id: 'id', + cnt: 2, + }, + 30, + ), + ).rejects.toThrow(StateError); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts b/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts index 1b463247..7b44d9aa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts @@ -1,13 +1,33 @@ -import type { Mutex } from 'async-mutex'; +import type { MutexInterface } from 'async-mutex'; +import { v4 as uuidv4 } from 'uuid'; +import { logger } from '../logger/logger'; +import { StateError } from './exceptions'; import { SnapHelper } from './helpers'; import { MutexLock } from './lock'; +export type Transaction = { + id?: string; + orgState?: State; + current?: State; + isRollingBack: boolean; + isForceCommit: boolean; +}; + export abstract class SnapStateManager { - protected readonly mtx: Mutex; + protected readonly mtx: MutexInterface; + + #transaction: Transaction; constructor(createLock = false) { this.mtx = MutexLock.acquire(createLock); + this.#transaction = { + id: undefined, + orgState: undefined, + current: undefined, + isRollingBack: false, + isForceCommit: false, + }; } protected async get(): Promise { @@ -19,12 +39,125 @@ export abstract class SnapStateManager { } protected async update( - update: (state: State) => Promise, + callback: (state: State) => Promise, ): Promise { - return this.mtx.runExclusive(async () => { + if (this.mtx.isLocked()) { + if (this.#transaction.current) { + logger.info( + `SnapStateManager.update [${ + this.#transactionId + }]: transaction is processing, use existing state`, + ); + await callback(this.#transaction.current); + if (this.#transaction.isForceCommit) { + await this.set(this.#transaction.current); + } + return; + } + logger.info( + `SnapStateManager.update: transaction is not exist, create lock after prev lock is released`, + ); + } + await this.mtx.runExclusive(async () => { const state = await this.get(); - await update(state); + await callback(state); await this.set(state); }); } + + /** + * This method executes the callback code in a transaction-like format. It creates a lock to ensure the state is not intercepted during the transaction and initializes a transaction with the current state, original state, and transaction ID. If there is an error during the transaction, the state is rolled back to the original state. However, if the lock has no time limit, it might cause a deadlock if the transaction is not completed. + * + * @param callback - A Promise function that takes the state as an argument. + * @param isForceCommit - The flag to enable the transaction committed if any internal commit has execute. + */ + public async withTransaction( + callback: (state: State) => Promise, + isForceCommit = false, + ): Promise { + await this.mtx.runExclusive(async () => { + await this.#beginTransaction(isForceCommit); + + if ( + !this.#transaction.current || + !this.#transaction.orgState || + !this.#transaction.id + ) { + throw new StateError('Failed to begin transaction'); + } + + logger.info( + `SnapStateManager.withTransaction [${ + this.#transactionId + }]: begin transaction`, + ); + + try { + await callback(this.#transaction.current); + await this.set(this.#transaction.current); + } catch (error) { + logger.info( + `SnapStateManager.withTransaction [${ + this.#transactionId + }]: error : ${JSON.stringify(error)}`, + ); + if (this.#transaction.isForceCommit) { + // we only need to rollback if the transaction is committed + await this.#rollback(); + } + if (error instanceof StateError) { + throw error; + } + throw new StateError(error); + } finally { + this.#cleanUpTransaction(); + } + }); + } + + async #beginTransaction(isForceCommit: boolean): Promise { + this.#transaction = { + id: uuidv4(), + orgState: await this.get(), + current: await this.get(), // make sure the current is not referenced to orgState + isRollingBack: false, + isForceCommit, + }; + } + + async #rollback(): Promise { + try { + if (!this.#transaction.isRollingBack && this.#transaction.orgState) { + logger.info( + `SnapStateManager.rollback [${ + this.#transactionId + }]: attemp to rollback state`, + ); + this.#transaction.isRollingBack = true; + await this.set(this.#transaction.orgState); + this.#cleanUpTransaction(); + } + } catch (error) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.info( + `SnapStateManager.rollback [${ + this.#transactionId + }]: error : ${JSON.stringify(error)}`, + ); + this.#cleanUpTransaction(); + throw new StateError('Failed to rollback state'); + } + } + + #cleanUpTransaction(): void { + this.#transaction.orgState = undefined; + this.#transaction.current = undefined; + this.#transaction.id = undefined; + this.#transaction.isRollingBack = false; + this.#transaction.isForceCommit = false; + } + + get #transactionId(): string { + return this.#transaction.id ?? ''; + } } From 46da7aa035aeca5538813b8a1deb53f6540747b4 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 26 Apr 2024 17:44:33 +0800 Subject: [PATCH 019/362] fix: refine coding structure (#25) * fix: refine coding structure * fix: lint style --- .../bitcoin-wallet-snap/jest.config.js | 3 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/{modules => }/config/config.ts | 8 +- .../src/{modules => }/config/index.ts | 0 .../src/{modules => }/config/permissions.ts | 1 + .../bitcoin-wallet-snap/src/index.test.ts | 3 +- .../bitcoin-wallet-snap/src/index.ts | 29 +++-- .../src/modules/async/helpers.test.ts | 15 --- .../src/modules/async/helpers.ts | 19 --- .../src/modules/async/index.ts | 1 - .../modules/bitcoin/account/account.test.ts | 6 +- .../src/modules/bitcoin/account/account.ts | 19 ++- .../src/modules/bitcoin/account/constants.ts | 5 - .../modules/bitcoin/account/deriver.test.ts | 53 +++----- .../src/modules/bitcoin/account/deriver.ts | 33 +++-- .../modules/bitcoin/account/factory.test.ts | 2 +- .../src/modules/bitcoin/account/factory.ts | 2 +- .../modules/bitcoin/account/helpers.test.ts | 115 ------------------ .../src/modules/bitcoin/account/helpers.ts | 25 ---- .../src/modules/bitcoin/account/index.ts | 2 - .../modules/bitcoin/account/manager.test.ts | 3 +- .../src/modules/bitcoin/account/manager.ts | 20 ++- .../modules/bitcoin/account/signer.test.ts | 6 +- .../src/modules/bitcoin/account/signer.ts | 2 +- .../src/modules/bitcoin/account/types.ts | 2 +- .../src/modules/bitcoin/config/index.ts | 1 - .../src/modules/bitcoin/config/types.ts | 2 +- .../modules/bitcoin/{config => }/constants.ts | 6 + .../data-client/clients/blockchair.test.ts | 2 +- .../bitcoin/data-client/clients/blockchair.ts | 6 +- .../data-client/clients/blockstream.test.ts | 17 +-- .../data-client/clients/blockstream.ts | 9 +- .../bitcoin/data-client/factory.test.ts | 2 +- .../modules/bitcoin/data-client/factory.ts | 3 +- .../modules/bitcoin/network/helpers.test.ts | 24 ---- .../src/modules/bitcoin/network/helpers.ts | 16 --- .../src/modules/bitcoin/network/index.ts | 1 - .../bitcoin/transaction/manager.test.ts | 8 +- .../modules/bitcoin/transaction/manager.ts | 5 +- .../src/modules/bitcoin/utils/index.ts | 2 + .../src/modules/bitcoin/utils/network.test.ts | 22 ++++ .../src/modules/bitcoin/utils/network.ts | 20 +++ .../src/modules/bitcoin/utils/payment.test.ts | 96 +++++++++++++++ .../src/modules/bitcoin/utils/payment.ts | 32 +++++ .../src/modules/factory.test.ts | 4 +- .../src/modules/factory.ts | 11 +- .../src/modules/keyring/keyring.test.ts | 9 +- .../src/modules/keyring/keyring.ts | 7 +- .../src/modules/keyring/state.test.ts | 30 +---- .../src/modules/keyring/state.ts | 20 +-- .../src/modules/keyring/types.ts | 3 +- .../src/modules/snap/state.test.ts | 2 +- .../src/modules/snap/state.ts | 8 +- .../modules/transaction/transaction.test.ts | 2 +- .../src/modules/transaction/transaction.ts | 1 + .../bitcoin-wallet-snap/src/rpcs/base.ts | 10 +- .../bitcoin-wallet-snap/src/rpcs/helpers.ts | 5 +- .../src/rpcs/methods/create-account.ts | 10 +- .../src/rpcs/methods/get-balances.ts | 4 +- .../bitcoin-wallet-snap/src/rpcs/types.ts | 2 +- .../src/utils/async.test.ts | 11 ++ .../bitcoin-wallet-snap/src/utils/async.ts | 24 ++++ .../bitcoin-wallet-snap/src/utils/error.ts | 16 +++ .../bitcoin-wallet-snap/src/utils/index.ts | 4 + .../src/utils/string.test.ts | 35 ++++++ .../bitcoin-wallet-snap/src/utils/string.ts | 21 ++++ .../src/{types => utils}/superstruct.test.ts | 2 +- .../src/{types => utils}/superstruct.ts | 2 +- .../bitcoin-wallet-snap/test/utils.ts | 2 +- 69 files changed, 461 insertions(+), 434 deletions(-) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/config/config.ts (90%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/config/index.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/config/permissions.ts (98%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/async/helpers.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/async/helpers.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/async/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/constants.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/helpers.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/helpers.ts rename merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/{config => }/constants.ts (79%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/helpers.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/helpers.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/payment.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/payment.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/async.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/async.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/error.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/string.ts rename merged-packages/bitcoin-wallet-snap/src/{types => utils}/superstruct.test.ts (97%) rename merged-packages/bitcoin-wallet-snap/src/{types => utils}/superstruct.ts (86%) diff --git a/merged-packages/bitcoin-wallet-snap/jest.config.js b/merged-packages/bitcoin-wallet-snap/jest.config.js index 46f67ce7..47ca301b 100644 --- a/merged-packages/bitcoin-wallet-snap/jest.config.js +++ b/merged-packages/bitcoin-wallet-snap/jest.config.js @@ -14,8 +14,7 @@ module.exports = { './src/**/*.ts', '!./src/**/*.d.ts', '!./src/**/index.ts', - '!./src/modules/config/config.ts', - '!./src/modules/config/permissions.ts', + '!./src/config/*.ts', '!./src/**/type?(s).ts', '!./src/**/exception?(s).ts', '!./test/**', diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 60e17f92..0becfe87 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "aXC8zXKNhQ8UphNuJ62OLWRnVZ0YiAsksEJH1zK+mKg=", + "shasum": "t+8Rza9mVDHvTcW+yXved6QxBSZk1Nhmk7nqIHmgXfg=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/config/config.ts b/merged-packages/bitcoin-wallet-snap/src/config/config.ts similarity index 90% rename from merged-packages/bitcoin-wallet-snap/src/modules/config/config.ts rename to merged-packages/bitcoin-wallet-snap/src/config/config.ts index 541cf769..476e48c0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/config/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/config.ts @@ -1,10 +1,12 @@ +import type { + BtcAccountConfig, + BtcTransactionConfig, +} from '../modules/bitcoin/config'; import { - type BtcAccountConfig, - type BtcTransactionConfig, Network as BtcNetwork, DataClient, BtcAsset, -} from '../bitcoin/config'; +} from '../modules/bitcoin/constants'; export enum Chain { Bitcoin = 'Bitcoin', diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts b/merged-packages/bitcoin-wallet-snap/src/config/index.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/config/index.ts rename to merged-packages/bitcoin-wallet-snap/src/config/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/config/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts similarity index 98% rename from merged-packages/bitcoin-wallet-snap/src/modules/config/permissions.ts rename to merged-packages/bitcoin-wallet-snap/src/config/permissions.ts index 033a6f76..abf1f13b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/config/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts @@ -33,6 +33,7 @@ export const originPermissions = new Map>([ KeyringRpcMethod.RejectRequest, // Chain API methods 'chain_getBalances', + 'chain_createAccount', ]), ], [ diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts index 1f57d807..7a722a6d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.ts @@ -2,7 +2,7 @@ import * as keyringApi from '@metamask/keyring-api'; import { type Json, type JsonRpcRequest, SnapError } from '@metamask/snaps-sdk'; import { onRpcRequest, validateOrigin, onKeyringRequest } from '.'; -import { Config, originPermissions } from './modules/config'; +import { Config, originPermissions } from './config'; import { BtcKeyring } from './modules/keyring'; import type { IStaticSnapRpcHandler } from './rpcs'; import { BaseSnapRpcHandler, RpcHelper } from './rpcs'; @@ -16,6 +16,7 @@ jest.mock('@metamask/keyring-api', () => ({ jest.mock('./modules/logger/logger', () => ({ logger: { info: jest.fn(), + error: jest.fn(), }, })); diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 23d2fdaa..634df7da 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -4,10 +4,11 @@ import { type OnKeyringRequestHandler, type Json, SnapError, + MethodNotFoundError, } from '@metamask/snaps-sdk'; -import { Config } from './modules/config'; -import { originPermissions } from './modules/config/permissions'; +import { Config } from './config'; +import { originPermissions } from './config/permissions'; import { Factory } from './modules/factory'; import { logger } from './modules/logger/logger'; import type { SnapRpcHandlerRequest } from './rpcs'; @@ -34,10 +35,16 @@ export const onRpcRequest: OnRpcRequestHandler = async (args) => { .getInstance() .execute(request.params as SnapRpcHandlerRequest); } catch (error) { - if (error instanceof SnapError) { - throw error; + let snapError = error; + if ( + !(snapError instanceof MethodNotFoundError || error instanceof SnapError) + ) { + snapError = new SnapError(error); } - throw new SnapError(error.message); + logger.error( + `onRpcRequest error: ${JSON.stringify(snapError.toJSON(), null, 2)}`, + ); + throw snapError; } }; @@ -56,9 +63,15 @@ export const onKeyringRequest: OnKeyringRequestHandler = async ({ request, )) as unknown as Promise; } catch (error) { - if (error instanceof SnapError) { - throw error; + let snapError = error; + if ( + !(snapError instanceof MethodNotFoundError || error instanceof SnapError) + ) { + snapError = new SnapError(error); } - throw new SnapError(error.message); + logger.error( + `onKeyringRequest error: ${JSON.stringify(snapError.toJSON(), null, 2)}`, + ); + throw snapError; } }; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/async/helpers.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/async/helpers.test.ts deleted file mode 100644 index 8e442a65..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/async/helpers.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { expect, jest } from '@jest/globals'; - -import { AsyncHelper } from './helpers'; - -describe('AsyncHelper', function () { - describe('processBatch', function () { - it('processes the array in batch', async function () { - const fn = jest.fn() as any; - const mockArr = new Array(99).fill(0); - - await AsyncHelper.processBatch(mockArr, fn); - expect(fn).toHaveBeenCalledTimes(mockArr.length); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/async/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/modules/async/helpers.ts deleted file mode 100644 index fe385984..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/async/helpers.ts +++ /dev/null @@ -1,19 +0,0 @@ -export class AsyncHelper { - static async processBatch( - arr: Data[], - callback: (item: Data) => Promise, - batchSize = 50, - ): Promise { - let from = 0; - let to = batchSize; - while (from < arr.length) { - const batch: Promise[] = []; - for (let i = from; i < Math.min(to, arr.length); i++) { - batch.push(callback(arr[i])); - } - await Promise.all(batch); - from += batchSize; - to += batchSize; - } - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/async/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/async/index.ts deleted file mode 100644 index c5f595cf..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/async/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './helpers'; 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 index bd4cdb02..742ba564 100644 --- 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 @@ -2,14 +2,14 @@ import type { Network, Payment } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; +import { ScriptType } from '../constants'; +import * as utils from '../utils/payment'; 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'); + const getPaymentSpy = jest.spyOn(utils, 'getBtcPaymentInst'); getPaymentSpy.mockReturnValue({ address, } as unknown as Payment); 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 index 9d1410be..c755df7a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.ts @@ -1,9 +1,10 @@ import type { Network, Payment } from 'bitcoinjs-lib'; -import { Buffer } from 'buffer'; +import type { Buffer } from 'buffer'; import type { StaticImplements } from '../../../types/static'; -import { ScriptType } from './constants'; -import { AddressHelper } from './helpers'; +import { hexToBuffer } from '../../../utils'; +import { ScriptType } from '../constants'; +import { getBtcPaymentInst } from '../utils/payment'; import type { IBtcAccount, IStaticBtcAccount, IAccountSigner } from './types'; export class BtcAccount implements IBtcAccount { @@ -59,15 +60,23 @@ export class BtcAccount implements IBtcAccount { get payment() { if (!this.#payment) { - this.#payment = AddressHelper.getPayment( + this.#payment = getBtcPaymentInst( this.scriptType, - Buffer.from(this.pubkey, 'hex'), + this.pubkeyToBuf(this.pubkey), this.network, ); } return this.#payment; } + protected pubkeyToBuf(pubkey: string): Buffer { + try { + return hexToBuffer(pubkey, false); + } catch (error) { + throw new Error('Public key is invalid'); + } + } + async sign(signMessage: Buffer): Promise { return await this.signer.sign(signMessage); } 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 deleted file mode 100644 index 3512876c..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum ScriptType { - P2pkh = 'p2pkh', - P2shP2wkh = 'p2sh-p2wpkh', - P2wpkh = 'p2wpkh', -} 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 index 7d4ddfc0..a2580f95 100644 --- 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 @@ -7,10 +7,9 @@ import { networks } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; import { createMockBip32Instance } from '../../../../test/utils'; +import * as strUtils from '../../../utils/string'; import { SnapHelper } from '../../snap'; import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; -import { DeriverError } from './exceptions'; -import { AddressHelper } from './helpers'; jest.mock('bip32', () => { return { @@ -65,7 +64,7 @@ describe('BtcAccountBip32Deriver', () => { }; }; - describe('fromSeed', () => { + describe('createBip32FromSeed', () => { it('returns an BIP32Interface', () => { const network = networks.testnet; const { fromSeedSpy } = createMockBip32Factory(); @@ -75,7 +74,7 @@ describe('BtcAccountBip32Deriver', () => { ); const deriver = new BtcAccountBip32Deriver(network); - deriver.fromSeed(seed); + deriver.createBip32FromSeed(seed); expect(fromSeedSpy).toHaveBeenCalledWith(seed, network); }); @@ -94,13 +93,13 @@ describe('BtcAccountBip32Deriver', () => { const deriver = new BtcAccountBip32Deriver(network); - expect(() => deriver.fromSeed(seed)).toThrow( + expect(() => deriver.createBip32FromSeed(seed)).toThrow( 'Unable to construct BIP32 node from seed', ); }); }); - describe('fromPrivateKey', () => { + describe('createBip32FromPrivateKey', () => { it('returns an BIP32Interface', () => { const network = networks.testnet; const { fromPrivateKeySpy } = createMockBip32Factory(); @@ -114,7 +113,7 @@ describe('BtcAccountBip32Deriver', () => { ); const deriver = new BtcAccountBip32Deriver(network); - deriver.fromPrivateKey(privateKey, chainCode); + deriver.createBip32FromPrivateKey(privateKey, chainCode); expect(fromPrivateKeySpy).toHaveBeenCalledWith( privateKey, @@ -141,9 +140,9 @@ describe('BtcAccountBip32Deriver', () => { const deriver = new BtcAccountBip32Deriver(network); - expect(() => deriver.fromPrivateKey(privateKey, chainCode)).toThrow( - 'Unable to construct BIP32 node from private key', - ); + expect(() => + deriver.createBip32FromPrivateKey(privateKey, chainCode), + ).toThrow('Unable to construct BIP32 node from private key'); }); }); @@ -192,8 +191,8 @@ describe('BtcAccountBip32Deriver', () => { const network = networks.testnet; const path = ['m', "84'", "0'"]; createMockBip32Entropy(); - const addressHelperSpy = jest.spyOn(AddressHelper, 'trimHexPrefix'); - addressHelperSpy.mockImplementation(() => { + const spy = jest.spyOn(strUtils, 'hexToBuffer'); + spy.mockImplementation(() => { throw new Error('error'); }); const deriver = new BtcAccountBip32Deriver(network); @@ -207,9 +206,9 @@ describe('BtcAccountBip32Deriver', () => { const network = networks.testnet; const path = ['m', "84'", "0'"]; createMockBip32Entropy(); - const addressHelperSpy = jest.spyOn(AddressHelper, 'trimHexPrefix'); - addressHelperSpy - .mockImplementationOnce((val: string) => val) + const spy = jest.spyOn(strUtils, 'hexToBuffer'); + spy + .mockImplementationOnce((val: string) => Buffer.from(val, 'hex')) .mockImplementationOnce(() => { throw new Error('error'); }); @@ -244,17 +243,6 @@ describe('BtcAccountBip32Deriver', () => { '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); - }); }); }); @@ -302,7 +290,7 @@ describe('BtcAccountBip44Deriver', () => { expect(deriveHardenedSpy).toHaveBeenCalledTimes(2); }); - it('throws DeriverError if the private key is missing', async () => { + it('throws `Deriver private key is missing` error if the private key is missing', async () => { const network = networks.testnet; const path = ['m', "84'", "0'"]; const { deriverSpy } = createMockBip44Entropy(); @@ -316,16 +304,5 @@ describe('BtcAccountBip44Deriver', () => { '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/deriver.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/deriver.ts index f59464c0..ee791ead 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/deriver.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/deriver.ts @@ -2,11 +2,11 @@ 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 type { Buffer } from 'buffer'; +import { compactError, hexToBuffer } from '../../../utils'; import { SnapHelper } from '../../snap/helpers'; import { DeriverError } from './exceptions'; -import { AddressHelper } from './helpers'; import type { IBtcAccountDeriver } from './types'; export abstract class BtcAccountDeriver implements IBtcAccountDeriver { @@ -21,7 +21,7 @@ export abstract class BtcAccountDeriver implements IBtcAccountDeriver { abstract getRoot(path: string[]): Promise; - fromSeed(seed: Buffer): BIP32Interface { + createBip32FromSeed(seed: Buffer): BIP32Interface { try { return this.bip32Api.fromSeed(seed, this.network); } catch (error) { @@ -29,7 +29,10 @@ export abstract class BtcAccountDeriver implements IBtcAccountDeriver { } } - fromPrivateKey(privateKey: Buffer, chainNode: Buffer): BIP32Interface { + createBip32FromPrivateKey( + privateKey: Buffer, + chainNode: Buffer, + ): BIP32Interface { try { return this.bip32Api.fromPrivateKey(privateKey, chainNode, this.network); } catch (error) { @@ -43,7 +46,7 @@ export abstract class BtcAccountDeriver implements IBtcAccountDeriver { protected pkToBuf(pk: string): Buffer { try { - return this.#toBuffer(pk); + return hexToBuffer(pk); } catch (error) { throw new DeriverError('Private key is invalid'); } @@ -51,15 +54,11 @@ export abstract class BtcAccountDeriver implements IBtcAccountDeriver { protected chainCodeToBuf(chainCode: string): Buffer { try { - return this.#toBuffer(chainCode); + return hexToBuffer(chainCode); } catch (error) { throw new DeriverError('Chain code is invalid'); } } - - #toBuffer(val: string): Buffer { - return Buffer.from(AddressHelper.trimHexPrefix(val), 'hex'); - } } export class BtcAccountBip44Deriver extends BtcAccountDeriver { @@ -71,12 +70,12 @@ export class BtcAccountBip44Deriver extends BtcAccountDeriver { throw new DeriverError('Deriver private key is missing'); } const privateKeyBuffer = this.pkToBuf(deriverNode.privateKey); - const root = this.fromSeed(privateKeyBuffer); + const root = this.createBip32FromSeed(privateKeyBuffer); return root .deriveHardened(parseInt(path[1].slice(0, -1), 10)) .deriveHardened(0); } catch (error) { - throw new DeriverError(error); + throw compactError(error, DeriverError); } } } @@ -95,7 +94,10 @@ export class BtcAccountBip32Deriver extends BtcAccountDeriver { const privateKeyBuffer = this.pkToBuf(deriver.privateKey); const chainCodeBuffer = this.chainCodeToBuf(deriver.chainCode); - const root = this.fromPrivateKey(privateKeyBuffer, chainCodeBuffer); + const root = this.createBip32FromPrivateKey( + privateKeyBuffer, + chainCodeBuffer, + ); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore // ignore checking since no function to set depth for node @@ -107,10 +109,7 @@ export class BtcAccountBip32Deriver extends BtcAccountDeriver { return root; } catch (error) { - if (error instanceof DeriverError) { - throw error; - } - throw new DeriverError(error); + throw compactError(error, 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 index d2d1d1ed..7e2ff7d1 100644 --- 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 @@ -1,7 +1,7 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; -import { ScriptType } from './constants'; +import { ScriptType } from '../constants'; import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; import { BtcAccountMgrFactory } from './factory'; import * as manager from './manager'; 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 index 21d493da..0cf99a48 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/factory.ts @@ -2,8 +2,8 @@ import { type Network } from 'bitcoinjs-lib'; import { type IAccountMgr } from '../../keyring'; import { type BtcAccountConfig } from '../config'; +import { ScriptType } from '../constants'; import { P2WPKHAccount } from './account'; -import { ScriptType } from './constants'; import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; import { AccountMgrFactoryError } from './exceptions'; import { BtcAccountMgr } from './manager'; 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 deleted file mode 100644 index ccd1febd..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/helpers.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -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/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/helpers.ts deleted file mode 100644 index 133f2a24..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/helpers.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 index 86be70b0..d082cdba 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/index.ts @@ -1,7 +1,5 @@ export * from './account'; -export * from './constants'; export * from './factory'; -export * from './helpers'; export * from './types'; export * from './signer'; export * from './deriver'; 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 index cd64729e..1c0ad59c 100644 --- 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 @@ -3,6 +3,7 @@ import { networks } from 'bitcoinjs-lib'; import { createMockBip32Instance } from '../../../../test/utils'; import { P2WPKHAccount } from './account'; import { BtcAccountBip32Deriver } from './deriver'; +import { AccountMgrError } from './exceptions'; import { BtcAccountMgr } from './manager'; describe('BtcAccountMgr', () => { @@ -42,7 +43,7 @@ describe('BtcAccountMgr', () => { network, ); - await expect(instance.unlock(0)).rejects.toThrow('Error'); + await expect(instance.unlock(0)).rejects.toThrow(AccountMgrError); }); }); }); 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 92237923..e99400ef 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 @@ -1,6 +1,7 @@ import type { BIP32Interface } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; +import { compactError } from '../../../utils'; import { type IAccount, type IAccountMgr } from '../../keyring'; import { AccountMgrError } from './exceptions'; import { AccountSigner } from './signer'; @@ -25,7 +26,6 @@ export class BtcAccountMgr implements IAccountMgr { async unlock(index: number): Promise { try { - //eslint -disable-next-line @typescript-eslint/naming-convention const AccountCtor = this.accountCtor; const rootNode = await this.deriver.getRoot(AccountCtor.path); @@ -43,7 +43,23 @@ export class BtcAccountMgr implements IAccountMgr { this.getHdSigner(rootNode), ); } catch (error) { - throw new AccountMgrError(error); + throw compactError(error, AccountMgrError); + } + } + + protected getFingerPrintInHex(rootNode: BIP32Interface) { + try { + return rootNode.fingerprint.toString('hex'); + } catch (error) { + throw new Error('Unable to get fingerprint in hex'); + } + } + + protected getPublicKeyInHex(rootNode: BIP32Interface) { + try { + return rootNode.publicKey.toString('hex'); + } catch (error) { + throw new Error('Unable to get public key in hex'); } } 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 index 80983f04..a38a6bad 100644 --- 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 @@ -30,11 +30,13 @@ describe('AccountSigner', () => { const network = networks.testnet; const { instance: node, deriveHardenedSpy } = createMockBip32Instance(network); - deriveHardenedSpy.mockReturnValue(new Error('invalid path')); + deriveHardenedSpy.mockReturnValue(new Error('error')); const instance = new AccountSigner(node); - expect(() => instance.derivePath("m/0'/0")).toThrow('invalid path'); + expect(() => instance.derivePath("m/0'/0")).toThrow( + 'Unable to derive path', + ); }); }); 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 index dcd8079d..e30ba238 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/signer.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/signer.ts @@ -34,7 +34,7 @@ export class AccountSigner implements HDSignerAsync, IAccountSigner { }, this.node); return new AccountSigner(childNode, this.fingerprint); } catch (error) { - throw new Error('invalid path'); + throw new Error('Unable to derive path'); } } 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 index 89ed4e5c..951833f1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/types.ts @@ -3,7 +3,7 @@ import type { Network, Payment } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; import type { IAccount } from '../../keyring'; -import type { ScriptType } from './constants'; +import type { ScriptType } from '../constants'; export type IBtcAccountDeriver = { getRoot(path: string[]): Promise; 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 index 33c8572a..fcb073fe 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/index.ts @@ -1,2 +1 @@ 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 index 54bb1b42..c1418867 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 @@ -1,4 +1,4 @@ -import type { DataClient } from './constants'; +import type { DataClient } from '../constants'; export type BtcTransactionConfig = { dataClient: { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/constants.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/constants.ts similarity index 79% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/constants.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/constants.ts index ee2386d9..eac2e304 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/constants.ts @@ -1,3 +1,9 @@ +export enum ScriptType { + P2pkh = 'p2pkh', + P2shP2wkh = 'p2sh-p2wpkh', + P2wpkh = 'p2wpkh', +} + export enum Network { Mainnet = 'bip122:000000000019d6689c085ae165831e93', Testnet = 'bip122:000000000933ea01ad0ee984209779ba', diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts index f93a1c2a..97b4c203 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts @@ -110,7 +110,7 @@ describe('BlockChairClient', () => { ); }); - it('throws DataClientError error if an DataClientError catched', async () => { + it('throws DataClientError error if an fetch response is falsely', async () => { const { fetchSpy } = createMockFetch(); fetchSpy.mockResolvedValueOnce({ diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts index b49c9569..ff7698a8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts @@ -1,5 +1,6 @@ import { type Network, networks } from 'bitcoinjs-lib'; +import { compactError } from '../../../../utils'; import { logger } from '../../../logger/logger'; import { type Balances } from '../../../transaction'; import { DataClientError } from '../exceptions'; @@ -89,10 +90,7 @@ export class BlockChairClient implements IReadDataClient { return data; }, {}); } catch (error) { - if (error instanceof DataClientError) { - throw error; - } - throw new DataClientError(error); + throw compactError(error, DataClientError); } } } 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 b7110e5d..9eea0abd 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 @@ -4,7 +4,7 @@ import { generateAccounts, generateBlockStreamAccountStats, } from '../../../../../test/utils'; -import { AsyncHelper } from '../../../async'; +import * as asyncUtils from '../../../../utils/async'; import { DataClientError } from '../exceptions'; import { BlockStreamClient } from './blockstream'; @@ -85,7 +85,7 @@ describe('BlockStreamClient', () => { }); it('throws DataClientError error if an non DataClientError catched', async () => { - const asyncHelperSpy = jest.spyOn(AsyncHelper, 'processBatch'); + const asyncHelperSpy = jest.spyOn(asyncUtils, 'processBatch'); asyncHelperSpy.mockRejectedValue(new Error('error')); const accounts = generateAccounts(1); const addresses = accounts.map((account) => account.address); @@ -96,18 +96,5 @@ describe('BlockStreamClient', () => { DataClientError, ); }); - - it('throws DataClientError error if an DataClientError catched', async () => { - const asyncHelperSpy = jest.spyOn(AsyncHelper, 'processBatch'); - asyncHelperSpy.mockRejectedValue(new DataClientError('error')); - const accounts = generateAccounts(1); - const addresses = accounts.map((account) => account.address); - - const instance = new BlockStreamClient({ network: networks.testnet }); - - await expect(instance.getBalances(addresses)).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 0593441d..fdea7da6 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 @@ -1,6 +1,6 @@ import { type Network, networks } from 'bitcoinjs-lib'; -import { AsyncHelper } from '../../../async'; +import { compactError, processBatch } from '../../../../utils'; import { logger } from '../../../logger/logger'; import { type Balances } from '../../../transaction'; import { DataClientError } from '../exceptions'; @@ -65,7 +65,7 @@ export class BlockStreamClient implements IReadDataClient { try { const responses: Balances = {}; - await AsyncHelper.processBatch(addresses, async (address: string) => { + await processBatch(addresses, async (address: string) => { logger.info(`[BlockStreamClient.getBalance] address: ${address}`); let balance = 0; try { @@ -90,10 +90,7 @@ export class BlockStreamClient implements IReadDataClient { return responses; } catch (error) { - if (error instanceof DataClientError) { - throw error; - } - throw new DataClientError(error); + throw compactError(error, DataClientError); } } } 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 index d11985bb..ff5520dc 100644 --- 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 @@ -1,6 +1,6 @@ import { networks } from 'bitcoinjs-lib'; -import { DataClient } from '../config'; +import { DataClient } from '../constants'; import { BlockChairClient } from './clients/blockchair'; import { BlockStreamClient } from './clients/blockstream'; import { DataClientFactory } from './factory'; 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 index 18a28fb6..77941d06 100644 --- 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 @@ -1,6 +1,7 @@ import { type Network } from 'bitcoinjs-lib'; -import { DataClient, type BtcTransactionConfig } from '../config'; +import { type BtcTransactionConfig } from '../config'; +import { DataClient } from '../constants'; import { BlockChairClient } from './clients/blockchair'; import { BlockStreamClient } from './clients/blockstream'; import { DataClientError } from './exceptions'; 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 deleted file mode 100644 index a56d8be4..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/helpers.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -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/network/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/helpers.ts deleted file mode 100644 index 3f264110..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/helpers.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index c5f595cf..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/network/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './helpers'; 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 index 30f6573b..cc8cebf4 100644 --- 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 @@ -2,7 +2,7 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; import { generateAccounts } from '../../../../test/utils'; -import { BtcAsset } from '../config'; +import { BtcAsset } from '../constants'; import type { IReadDataClient } from '../data-client'; import { BtcTransactionMgr } from './manager'; @@ -50,7 +50,7 @@ describe('BtcTransactionMgr', () => { expect(getBalanceSpy).toHaveBeenCalledWith(addresses); }); - it('throws `Only one asset is supported` error if the given assert more than 1', async () => { + it('throws `Only one asset is supported` error if the given asset more than 1', async () => { const { instance } = createMockReadDataClient(); const { instance: txnMgr } = createMockBtcTransactionMgr(instance); const accounts = generateAccounts(2); @@ -61,7 +61,7 @@ describe('BtcTransactionMgr', () => { ).rejects.toThrow('Only one asset is supported'); }); - it('throws `Invalid asset` error if the BTC assert is given and current network is testnet network', async () => { + it('throws `Invalid asset` error if the BTC asset is given and current network is testnet network', async () => { const { instance } = createMockReadDataClient(); const { instance: txnMgr } = createMockBtcTransactionMgr(instance); const accounts = generateAccounts(2); @@ -72,7 +72,7 @@ describe('BtcTransactionMgr', () => { ).rejects.toThrow('Invalid asset'); }); - it('throws `Invalid asset` error if the TBTC assert is given and current network is bitcoin network', async () => { + it('throws `Invalid asset` error if the TBTC asset is given and current network is bitcoin network', async () => { const { instance } = createMockReadDataClient(); const { instance: txnMgr } = createMockBtcTransactionMgr( instance, 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 9b864838..072f2cd9 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,12 +1,13 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; +import { compactError } from '../../../utils'; import type { ITransactionMgr, Balances, AssetBalances, } from '../../transaction/types'; -import { BtcAsset } from '../config'; +import { BtcAsset } from '../constants'; import { type IReadDataClient } from '../data-client'; import { TransactionMgrError } from './exceptions'; import type { BtcTransactionMgrOptions } from './types'; @@ -60,7 +61,7 @@ export class BtcTransactionMgr implements ITransactionMgr { { balances: {} }, ); } catch (error) { - throw new TransactionMgrError(error); + throw compactError(error, TransactionMgrError); } } } diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/index.ts new file mode 100644 index 00000000..43620c9e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/index.ts @@ -0,0 +1,2 @@ +export * from './payment'; +export * from './network'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.test.ts new file mode 100644 index 00000000..f3f5a6fa --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.test.ts @@ -0,0 +1,22 @@ +import { networks } from 'bitcoinjs-lib'; + +import { Network } from '../constants'; +import { getBtcNetwork } from './network'; + +describe('getBtcNetwork', () => { + it('returns bitcoin testnet network', () => { + const result = getBtcNetwork(Network.Testnet); + expect(result).toStrictEqual(networks.testnet); + }); + + it('returns bitcoin mainnet network', () => { + const result = getBtcNetwork(Network.Mainnet); + expect(result).toStrictEqual(networks.bitcoin); + }); + + it('throws `Invalid network` error if the given network is not support', () => { + expect(() => + getBtcNetwork('someInvalidNetwork' as unknown as Network), + ).toThrow('Invalid network'); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.ts new file mode 100644 index 00000000..3bb15169 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.ts @@ -0,0 +1,20 @@ +import { networks } from 'bitcoinjs-lib'; + +import { Network as NetworkEnum } from '../constants'; + +/** + * Method to get bitcoinjs-lib network by CAIP2 Chain Id string. + * + * @param network - The CAIP2 Chain Id. + * @returns The instance of bitcoinjs-lib network. + */ +export function getBtcNetwork(network: string) { + switch (network) { + case NetworkEnum.Mainnet: + return networks.bitcoin; + case NetworkEnum.Testnet: + return networks.testnet; + default: + throw new Error('Invalid network'); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/payment.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/payment.test.ts new file mode 100644 index 00000000..7fbc90fd --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/payment.test.ts @@ -0,0 +1,96 @@ +import * as biplib from 'bitcoinjs-lib'; +import { Buffer } from 'buffer'; + +import { ScriptType } from '../constants'; +import { getBtcPaymentInst } from './payment'; + +jest.mock('bitcoinjs-lib', () => { + return { + ...jest.requireActual('bitcoinjs-lib'), + payments: { + p2pkh: jest.fn(), + p2sh: jest.fn(), + p2wpkh: jest.fn(), + }, + }; +}); + +class MockPayment implements biplib.Payment {} + +const createMockPayment = (type) => { + const spy = jest.spyOn(biplib.payments, type); + spy.mockReturnValue(new MockPayment()); + return { + spy, + }; +}; + +describe('getBtcPaymentInst', () => { + it('returns P2pkh payment instance', () => { + const { spy } = createMockPayment('p2pkh'); + const pubkey = Buffer.from('pubkey', 'hex'); + + const result = getBtcPaymentInst( + 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 = getBtcPaymentInst( + 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 = getBtcPaymentInst( + 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(() => + getBtcPaymentInst( + 'sometype' as unknown as ScriptType, + pubkey, + biplib.networks.testnet, + ), + ).toThrow('Invalid script type'); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/payment.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/payment.ts new file mode 100644 index 00000000..7a54d5a8 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/payment.ts @@ -0,0 +1,32 @@ +import { type Network, type Payment, payments } from 'bitcoinjs-lib'; +import type { Buffer } from 'buffer'; + +import { ScriptType } from '../constants'; + +/** + * Method to get bitcoinjs-lib payment instance by an bitcoin script type. + * + * @param type - Script type of the bitcoin account - P2pkhm, P2shP2wkh or P2wpkh. + * @param pubkey - Public key of the account. + * @param network - Bitcoinjs-lib network. + * @returns The instance of bitcoinjs-lib payment. + */ +export function getBtcPaymentInst( + type: ScriptType, + pubkey: Buffer, + network: Network, +): Payment { + 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'); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts index 68f1fe89..64d65b77 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts @@ -1,7 +1,7 @@ +import { Chain } from '../config'; import { BtcAccountMgr } from './bitcoin/account'; -import { Network } from './bitcoin/config'; +import { Network } from './bitcoin/constants'; import { BtcTransactionMgr } from './bitcoin/transaction'; -import { Chain } from './config'; import { Factory } from './factory'; import { BtcKeyring } from './keyring'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts index 128ec801..3b26ad59 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts @@ -1,19 +1,19 @@ import { type Keyring } from '@metamask/keyring-api'; +import { type Chain, Config } from '../config'; import { type IStaticSnapRpcHandler, SendTransactionHandler } from '../rpcs'; import { BtcAccountMgrFactory } from './bitcoin/account'; import { - type Network, 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, Config } from './config'; +import { getBtcNetwork } from './bitcoin/utils'; import { BtcKeyring, KeyringStateManager, type IAccountMgr } from './keyring'; import type { ITransactionMgr } from './transaction'; +// TODO: Temp solutio to support keyring in snap without keyring API export type CreateBtcKeyringOptions = { emitEvents: boolean; }; @@ -23,7 +23,7 @@ export class Factory { config: BtcTransactionConfig, network: string, ) { - const btcNetwork = NetworkHelper.getNetwork(network as Network); + const btcNetwork = getBtcNetwork(network); const readClient = DataClientFactory.createReadClient(config, btcNetwork); return new BtcTransactionMgr(readClient, { @@ -32,7 +32,7 @@ export class Factory { } static createBtcAccountMgr(config: BtcAccountConfig, network: string) { - const btcNetwork = NetworkHelper.getNetwork(network as Network); + const btcNetwork = getBtcNetwork(network); return BtcAccountMgrFactory.create(config, btcNetwork); } @@ -53,6 +53,7 @@ export class Factory { { defaultIndex: config.defaultAccountIndex, multiAccount: config.enableMultiAccounts, + // TODO: Temp solutio to support keyring in snap without keyring API emitEvents: options.emitEvents, }, ); 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 index 91eaa9be..1fb791c5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts @@ -1,11 +1,12 @@ +import { MethodNotFoundError } from '@metamask/snaps-sdk'; import { unknown } from 'superstruct'; import { generateAccounts } from '../../../test/utils'; +import { Chain, Config } from '../../config'; import type { IStaticSnapRpcHandler } from '../../rpcs'; import { BaseSnapRpcHandler } from '../../rpcs'; import type { StaticImplements } from '../../types/static'; -import { Network } from '../bitcoin/config'; -import { Chain, Config } from '../config'; +import { Network } from '../bitcoin/constants'; import { Factory } from '../factory'; import { BtcKeyringError } from './exceptions'; import { BtcKeyring } from './keyring'; @@ -207,7 +208,7 @@ describe('BtcKeyring', () => { expect(handleRequestSpy).toHaveBeenCalledWith(params); }); - it('throws `Method not found` if the method not support', async () => { + it('throws MethodNotFoundError if the method not support', async () => { const { instance: stateMgr } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); const account = generateAccounts(1)[0]; @@ -221,7 +222,7 @@ describe('BtcKeyring', () => { method: 'btc_doesNotExist', }, }), - ).rejects.toThrow('Method not found: btc_doesNotExist'); + ).rejects.toThrow(MethodNotFoundError); }); }); 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 10d53ffa..7706a63c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts @@ -6,12 +6,12 @@ import { type KeyringRequest, type KeyringResponse, } from '@metamask/keyring-api'; -import { type Json } from '@metamask/snaps-sdk'; +import { MethodNotFoundError, type Json } from '@metamask/snaps-sdk'; import { assert, StructError } from 'superstruct'; import { v4 as uuidv4 } from 'uuid'; +import { Config } from '../../config'; import type { SnapRpcHandlerRequest } from '../../rpcs'; -import { Config } from '../config'; import { Factory } from '../factory'; import { logger } from '../logger/logger'; import { SnapHelper } from '../snap'; @@ -161,7 +161,7 @@ export class BtcKeyring implements Keyring { protected async handleSubmitRequest(request: KeyringRequest): Promise { const { method, params } = request.request; if (!Object.prototype.hasOwnProperty.call(this.handlers, method)) { - throw new BtcKeyringError(`Method not found: ${method}`); + throw new MethodNotFoundError() as unknown as Error; } return this.handlers[method] @@ -173,6 +173,7 @@ export class BtcKeyring implements Keyring { event: KeyringEvent, data: Record, ): Promise { + // TODO: Temp solutio to support keyring in snap without keyring API if (this.options.emitEvents) { await emitSnapKeyringEvent(SnapHelper.wallet, event, data); } 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 index bd178c5f..d368f8cc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.test.ts @@ -1,5 +1,5 @@ import { generateAccounts } from '../../../test/utils'; -import { Network } from '../bitcoin/config'; +import { Network } from '../bitcoin/constants'; import { SnapHelper, StateError } from '../snap'; import { KeyringStateManager } from './state'; @@ -170,21 +170,6 @@ describe('BtcKeyring', () => { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions ).rejects.toThrow(`Account address ${address} already exists`); }); - - it('throw StateError if an error catched', async () => { - const { instance, getDataSpy } = createMockStateManager(); - const accountToSave = generateAccounts(1)[0]; - getDataSpy.mockRejectedValue(new Error('error')); - - await expect( - instance.addWallet({ - account: accountToSave, - type: accountToSave.type, - index: accountToSave.index, - scope: accountToSave.scope, - }), - ).rejects.toThrow(StateError); - }); }); describe('removeAccounts', () => { @@ -330,18 +315,5 @@ describe('BtcKeyring', () => { `Account address or type is immutable`, ); }); - - it('throw StateError if an error catched', async () => { - const { instance, getDataSpy } = createMockStateManager(); - getDataSpy.mockRejectedValue(new Error('error')); - const state = createInitState(1); - const accToUpdate = { - ...state.wallets[state.walletIds[0]].account, - }; - - await expect(instance.updateAccount(accToUpdate)).rejects.toThrow( - StateError, - ); - }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts index 23e283e1..1adbd397 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts @@ -2,6 +2,7 @@ import type { KeyringAccount } from '@metamask/keyring-api'; import type { Wallet } from '../../types/state'; import { type SnapState } from '../../types/state'; +import { compactError } from '../../utils'; import { SnapStateManager, StateError } from '../snap'; export class KeyringStateManager extends SnapStateManager { @@ -32,7 +33,7 @@ export class KeyringStateManager extends SnapStateManager { const state = await this.get(); return state.walletIds.map((id) => state.wallets[id].account); } catch (error) { - throw new StateError(error); + throw compactError(error, StateError); } } @@ -51,10 +52,7 @@ export class KeyringStateManager extends SnapStateManager { state.walletIds.push(id); }); } catch (error) { - if (error instanceof StateError) { - throw error; - } - throw new StateError(error); + throw compactError(error, StateError); } } @@ -79,10 +77,7 @@ export class KeyringStateManager extends SnapStateManager { state.wallets[account.id].account = account; }); } catch (error) { - if (error instanceof StateError) { - throw error; - } - throw new StateError(error); + throw compactError(error, StateError); } } @@ -102,10 +97,7 @@ export class KeyringStateManager extends SnapStateManager { state.walletIds = state.walletIds.filter((id) => !removeIds.has(id)); }); } catch (error) { - if (error instanceof StateError) { - throw error; - } - throw new StateError(error); + throw compactError(error, StateError); } } @@ -114,7 +106,7 @@ export class KeyringStateManager extends SnapStateManager { const state = await this.get(); return state.wallets[id]?.account ?? null; } catch (error) { - throw new StateError(error); + throw compactError(error, StateError); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts index 5750244c..fbe8db36 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts @@ -4,7 +4,7 @@ import type { Infer } from 'superstruct'; import { object } from 'superstruct'; import type { IStaticSnapRpcHandler } from '../../rpcs'; -import { scopeStruct } from '../../types/superstruct'; +import { scopeStruct } from '../../utils'; export type IAccountSigner = { sign(hash: Buffer): Promise; @@ -31,6 +31,7 @@ export type IAccountMgr = { export type KeyringOptions = Record & { defaultIndex: number; multiAccount?: boolean; + // TODO: Temp solutio to support keyring in snap without keyring API emitEvents?: boolean; }; 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 index 69d15f24..93fc041f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.test.ts @@ -395,7 +395,7 @@ describe('SnapStateManager', () => { } catch (error) { expectedError = error; } finally { - expect(expectedError).toBeInstanceOf(Error); + expect(expectedError).toBeInstanceOf(StateError); expect(initState.transaction).toStrictEqual(['id']); expect(setStateDataSpy).toHaveBeenCalledTimes(0); expect(initState.trasansactionDetails).toStrictEqual({ diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts b/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts index 7b44d9aa..8c4d4e9b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts @@ -1,6 +1,7 @@ import type { MutexInterface } from 'async-mutex'; import { v4 as uuidv4 } from 'uuid'; +import { compactError } from '../../utils'; import { logger } from '../logger/logger'; import { StateError } from './exceptions'; import { SnapHelper } from './helpers'; @@ -99,16 +100,13 @@ export abstract class SnapStateManager { logger.info( `SnapStateManager.withTransaction [${ this.#transactionId - }]: error : ${JSON.stringify(error)}`, + }]: error : ${JSON.stringify(error.message)}`, ); if (this.#transaction.isForceCommit) { // we only need to rollback if the transaction is committed await this.#rollback(); } - if (error instanceof StateError) { - throw error; - } - throw new StateError(error); + throw compactError(error, StateError); } finally { this.#cleanUpTransaction(); } 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 index d7947465..8739dd21 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.test.ts @@ -1,5 +1,5 @@ import { generateAccounts } from '../../../test/utils'; -import { BtcAsset } from '../bitcoin/config'; +import { BtcAsset } from '../bitcoin/constants'; import { TransactionServiceError } from './exceptions'; import { TransactionStateManager } from './state'; import { TransactionService } from './transaction'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.ts b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.ts index 49ed8b92..2505c492 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.ts @@ -2,6 +2,7 @@ import { TransactionServiceError } from './exceptions'; import type { TransactionStateManager } from './state'; import type { AssetBalances, ITransactionMgr } from './types'; +// Keep this class until we dont need to using state to manage transaction export class TransactionService { protected readonly transactionMgr: ITransactionMgr; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts index 18d43845..1d20c134 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts @@ -1,7 +1,8 @@ import { type Struct, assert } from 'superstruct'; import { logger } from '../modules/logger/logger'; -import { SnapRpcError, SnapRpcValidationError } from './exceptions'; +import { compactError } from '../utils'; +import { SnapRpcValidationError } from './exceptions'; import { type ISnapRpcExecutable, type SnapRpcHandlerOptions, @@ -69,12 +70,7 @@ export abstract class BaseSnapRpcHandler implements ISnapRpcExecutable { await this.postExecute(result); return result; } catch (error) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.info(`[SnapRpcHandler.execute] Error: ${error.message}`); - if (error instanceof SnapRpcValidationError) { - throw error; - } - throw new SnapRpcError(error.message); + throw compactError(error, SnapRpcValidationError); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts index 05a88d0b..90bbecfc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts @@ -1,4 +1,5 @@ -import { SnapRpcError } from './exceptions'; +import { MethodNotFoundError } from '@metamask/snaps-sdk'; + import { CreateAccountHandler, GetBalancesHandler } from './methods'; import type { IStaticSnapRpcHandler } from './types'; @@ -10,7 +11,7 @@ export class RpcHelper { case 'chain_getBalances': return GetBalancesHandler; default: - throw new SnapRpcError(`Method not found`); + throw new MethodNotFoundError() as unknown as Error; } } } 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 index 64ae5ed4..2bcf038e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts @@ -1,8 +1,7 @@ import type { KeyringAccount } from '@metamask/keyring-api'; import type { Infer } from 'superstruct'; -import { object, number, assign } from 'superstruct'; -import { Config } from '../../modules/config'; +import { Config } from '../../config'; import { Factory } from '../../modules/factory'; import { BtcKeyring, KeyringStateManager } from '../../modules/keyring'; import type { StaticImplements } from '../../types/static'; @@ -22,12 +21,7 @@ export class CreateAccountHandler StaticImplements { static override get requestStruct() { - return assign( - object({ - index: number(), - }), - SnapRpcHandlerRequestStruct, - ); + return SnapRpcHandlerRequestStruct; } async handleRequest( diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts index 525242bd..c01dbd40 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts @@ -1,14 +1,14 @@ import type { Infer } from 'superstruct'; import { object, string, assign, array, record } from 'superstruct'; -import { Config } from '../../modules/config'; +import { Config } from '../../config'; import { Factory } from '../../modules/factory'; import { TransactionService, TransactionStateManager, } from '../../modules/transaction'; import type { StaticImplements } from '../../types/static'; -import { assetsStruct, numberStringStruct } from '../../types/superstruct'; +import { assetsStruct, numberStringStruct } from '../../utils/superstruct'; import { BaseSnapRpcHandler } from '../base'; import type { IStaticSnapRpcHandler, SnapRpcHandlerResponse } from '../types'; import { SnapRpcHandlerRequestStruct } from '../types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts index b7ba9f71..64ecd83a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts @@ -2,7 +2,7 @@ import type { Json } from '@metamask/snaps-sdk'; import type { Infer } from 'superstruct'; import { object, type Struct } from 'superstruct'; -import { scopeStruct } from '../types/superstruct'; +import { scopeStruct } from '../utils'; export const SnapRpcHandlerRequestStruct = object({ scope: scopeStruct, diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/async.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/async.test.ts new file mode 100644 index 00000000..11a673fb --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/async.test.ts @@ -0,0 +1,11 @@ +import { processBatch } from './async'; + +describe('processBatch', function () { + it('processes the array in batch', async function () { + const fn = jest.fn() as any; + const mockArr = new Array(99).fill(0); + + await processBatch(mockArr, fn); + expect(fn).toHaveBeenCalledTimes(mockArr.length); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/async.ts b/merged-packages/bitcoin-wallet-snap/src/utils/async.ts new file mode 100644 index 00000000..74f3b1f6 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/async.ts @@ -0,0 +1,24 @@ +/** + * Method to execute the given promise callback by the size of the data in batch operation. + * + * @param arr - Array of data to be processed in batch. + * @param callback - Promise callback to be executed on each item of the array. + * @param batchSize - Size of the batch operation, default is 50. + */ +export async function processBatch( + arr: Data[], + callback: (item: Data) => Promise, + batchSize = 50, +): Promise { + let from = 0; + let to = batchSize; + while (from < arr.length) { + const batch: Promise[] = []; + for (let i = from; i < Math.min(to, arr.length); i++) { + batch.push(callback(arr[i])); + } + await Promise.all(batch); + from += batchSize; + to += batchSize; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/error.ts b/merged-packages/bitcoin-wallet-snap/src/utils/error.ts new file mode 100644 index 00000000..ff565eaa --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/error.ts @@ -0,0 +1,16 @@ +/** + * Compact error to a specific error instance. + * + * @param error - Error instance. + * @param ErrCtor - Error constructor. + * @returns Compacted Error instance. + */ +export function compactError( + error: ErrorInstance, + ErrCtor: new (message?: string) => ErrorInstance, +): ErrorInstance { + if (error instanceof ErrCtor) { + return error; + } + return new ErrCtor(error.message); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/utils/index.ts new file mode 100644 index 00000000..d022966a --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/index.ts @@ -0,0 +1,4 @@ +export * from './error'; +export * from './string'; +export * from './async'; +export * from './superstruct'; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts new file mode 100644 index 00000000..94144d5f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts @@ -0,0 +1,35 @@ +import { Buffer } from 'buffer'; + +import { trimHexPrefix, hexToBuffer } from './string'; + +describe('trimHexPrefix', () => { + it('trims hex prefix', () => { + const key = '0x1234'; + const result = trimHexPrefix(key); + + expect(result).toBe('1234'); + }); + + it('returns key as is if it does not have hex prefix', () => { + const key = '1234'; + const result = trimHexPrefix(key); + + expect(result).toBe('1234'); + }); +}); + +describe('hexToBuffer', () => { + it('conver a hex string to buffer with trimed prefix', () => { + const key = '0x1234'; + const result = hexToBuffer(key); + + expect(result).toStrictEqual(Buffer.from('1234', 'hex')); + }); + + it('conver a hex string to buffer without trimed prefix', () => { + const key = '0x1234'; + const result = hexToBuffer(key, false); + + expect(result).toStrictEqual(Buffer.from('0x1234', 'hex')); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts new file mode 100644 index 00000000..393d201a --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts @@ -0,0 +1,21 @@ +import { Buffer } from 'buffer'; +/** + * Method to trim the hex prefix from an hex string. + * + * @param hexStr - Hex string. + * @returns Hex string without the prefix. + */ +export function trimHexPrefix(hexStr: string) { + return hexStr.startsWith('0x') ? hexStr.substring(2) : hexStr; +} + +/** + * Method to convert a hex string to a buffer. + * + * @param hexStr - Hex string. + * @param trimPrefix - Flag to trim the hex prefix. + * @returns Buffer instance. + */ +export function hexToBuffer(hexStr: string, trimPrefix = true) { + return Buffer.from(trimPrefix ? trimHexPrefix(hexStr) : hexStr, 'hex'); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/types/superstruct.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts similarity index 97% rename from merged-packages/bitcoin-wallet-snap/src/types/superstruct.test.ts rename to merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts index cb4db46c..d288ca22 100644 --- a/merged-packages/bitcoin-wallet-snap/src/types/superstruct.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts @@ -1,6 +1,6 @@ import { assert } from 'superstruct'; -import { Config } from '../modules/config'; +import { Config } from '../config'; import * as superstruct from './superstruct'; describe('superstruct', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/types/superstruct.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts similarity index 86% rename from merged-packages/bitcoin-wallet-snap/src/types/superstruct.ts rename to merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts index 4d6af29a..f19cfc0a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/types/superstruct.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts @@ -1,6 +1,6 @@ import { enums, string, pattern } from 'superstruct'; -import { Config } from '../modules/config'; +import { Config } from '../config'; export const assetsStruct = enums(Config.avaliableAssets[Config.chain]); diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index 4af15f05..bb6a94a8 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -2,7 +2,7 @@ import type { BIP32Interface } from 'bip32'; import type { Network } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; -import { Network as NetworkEnum } from '../src/modules/bitcoin/config'; +import { Network as NetworkEnum } from '../src/modules/bitcoin/constants'; import blockChairData from './fixtures/blockchair.json'; import blockStreamData from './fixtures/blockstream.json'; From b11b0c7fba6f29a3c30d15939b3cba115c04271b Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Sat, 27 Apr 2024 15:33:51 +0800 Subject: [PATCH 020/362] chore: restructure the code repo to fit to chain api and keyring api structure (#26) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/modules/bitcoin/account/account.ts | 9 ++- .../src/modules/bitcoin/account/manager.ts | 18 +++++ .../{transaction => chain}/exceptions.ts | 0 .../bitcoin/{transaction => chain}/index.ts | 0 .../{transaction => chain}/manager.test.ts | 0 .../bitcoin/{transaction => chain}/manager.ts | 26 ++++++- .../bitcoin/{transaction => chain}/types.ts | 0 .../bitcoin/data-client/clients/blockchair.ts | 2 +- .../data-client/clients/blockstream.ts | 2 +- .../src/modules/chain/constants.ts | 5 ++ .../src/modules/chain/index.ts | 2 + .../src/modules/chain/types.ts | 42 +++++++++++ .../src/modules/factory.test.ts | 2 +- .../src/modules/factory.ts | 4 +- .../src/modules/keyring/keyring.test.ts | 2 + .../src/modules/keyring/state.ts | 3 +- .../src/modules/keyring/types.ts | 29 +++++++ .../src/modules/transaction/exceptions.ts | 3 - .../src/modules/transaction/index.ts | 4 - .../src/modules/transaction/state.ts | 4 - .../modules/transaction/transaction.test.ts | 52 ------------- .../src/modules/transaction/transaction.ts | 30 -------- .../src/modules/transaction/types.ts | 17 ----- .../src/rpcs/methods/get-balances.ts | 11 +-- .../src/rpcs/methods/send-transaction.ts | 75 +++++++++++++++++-- 26 files changed, 207 insertions(+), 137 deletions(-) rename merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/{transaction => chain}/exceptions.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/{transaction => chain}/index.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/{transaction => chain}/manager.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/{transaction => chain}/manager.ts (74%) rename merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/{transaction => chain}/types.ts (100%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/chain/constants.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/chain/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/transaction/exceptions.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/transaction/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/transaction/state.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/transaction/types.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 0becfe87..83249b4a 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "t+8Rza9mVDHvTcW+yXved6QxBSZk1Nhmk7nqIHmgXfg=", + "shasum": "G3LuD1J/Bgm5rD6O5ZmYY5lOMGOZF/grKiKmyAvuWm0=", "location": { "npm": { "filePath": "dist/bundle.js", 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 index c755df7a..03bd50ae 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.ts @@ -77,8 +77,13 @@ export class BtcAccount implements IBtcAccount { } } - async sign(signMessage: Buffer): Promise { - return await this.signer.sign(signMessage); + async sign(message: Buffer): Promise { + return await this.signer.sign(message); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async signTransaction(tx: Txn): Promise { + throw new Error('Method not implemented.'); } } 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 e99400ef..660b0cde 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 @@ -1,7 +1,9 @@ +import type { Json } from '@metamask/snaps-sdk'; import type { BIP32Interface } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; import { compactError } from '../../../utils'; +import type { TransactionIntent } from '../../chain/types'; import { type IAccount, type IAccountMgr } from '../../keyring'; import { AccountMgrError } from './exceptions'; import { AccountSigner } from './signer'; @@ -47,6 +49,22 @@ export class BtcAccountMgr implements IAccountMgr { } } + /* eslint-disable */ + async createTransaction( + acount: IAccount, + txn: TransactionIntent, + options: { + metadata: Record; + fee: number; + }, + ): Promise<{ + txn: Buffer; + txnJson: Record; + }> { + throw new Error('Method not implemented.'); + } + /* eslint-disable */ + protected getFingerPrintInHex(rootNode: BIP32Interface) { try { return rootNode.fingerprint.toString('hex'); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/exceptions.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/exceptions.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/exceptions.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/index.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/index.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/manager.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/manager.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/manager.test.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/manager.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/manager.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/manager.ts similarity index 74% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/manager.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/manager.ts index 072f2cd9..8088ec93 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/manager.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/manager.ts @@ -6,7 +6,10 @@ import type { ITransactionMgr, Balances, AssetBalances, -} from '../../transaction/types'; + TransactionIntent, + Pagination, + Fees, +} from '../../chain/types'; import { BtcAsset } from '../constants'; import { type IReadDataClient } from '../data-client'; import { TransactionMgrError } from './exceptions'; @@ -64,4 +67,25 @@ export class BtcTransactionMgr implements ITransactionMgr { throw compactError(error, TransactionMgrError); } } + /* eslint-disable */ + async estimateFees(): Promise { + throw new Error('Method not implemented.'); + } + + boardcastTransaction(txn: string) { + throw new Error('Method not implemented.'); + } + + listTransactions(address: string, pagination: Pagination) { + throw new Error('Method not implemented.'); + } + + getTransaction(txnHash: string) { + throw new Error('Method not implemented.'); + } + + getDataForTransaction(address: string, transactionIntent: TransactionIntent) { + throw new Error('Method not implemented.'); + } + /* eslint-disable */ } diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/types.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/transaction/types.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/types.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts index ff7698a8..40fab7e3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts @@ -1,8 +1,8 @@ import { type Network, networks } from 'bitcoinjs-lib'; import { compactError } from '../../../../utils'; +import { type Balances } from '../../../chain'; import { logger } from '../../../logger/logger'; -import { type Balances } from '../../../transaction'; import { DataClientError } from '../exceptions'; import type { IReadDataClient } from '../types'; 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 fdea7da6..88874755 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 @@ -1,8 +1,8 @@ import { type Network, networks } from 'bitcoinjs-lib'; import { compactError, processBatch } from '../../../../utils'; +import { type Balances } from '../../../chain'; import { logger } from '../../../logger/logger'; -import { type Balances } from '../../../transaction'; import { DataClientError } from '../exceptions'; import type { IReadDataClient } from '../types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/chain/constants.ts b/merged-packages/bitcoin-wallet-snap/src/modules/chain/constants.ts new file mode 100644 index 00000000..9e9587e4 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/chain/constants.ts @@ -0,0 +1,5 @@ +export enum FeeRatio { + Fast = 'fast', + Medium = 'medium', + Slow = 'slow', +} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/chain/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/chain/index.ts new file mode 100644 index 00000000..33c8572a --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/chain/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './constants'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts new file mode 100644 index 00000000..06c77636 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts @@ -0,0 +1,42 @@ +import type { FeeRatio } from './constants'; + +export type Balances = Record; + +export type Balance = { + amount: number; +}; + +export type AssetBalances = { + balances: { + [address: string]: { + [asset: string]: Balance; + }; + }; +}; + +export type Fee = { + type: FeeRatio; + rate: number; +}; + +export type Fees = { + fees: Fee[]; +}; + +export type TransactionIntent = { + amounts: Record; +}; + +export type Pagination = { + limit: number; + offset: number; +}; + +export type ITransactionMgr = { + getBalances(addresses: string[], assets: string[]): Promise; + estimateFees(): Promise; + boardcastTransaction(txn: string); + listTransactions(address: string, pagination: Pagination); + getTransaction(txnHash: string); + getDataForTransaction(address: string, transactionIntent: TransactionIntent); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts index 64d65b77..689fb50e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts @@ -1,7 +1,7 @@ import { Chain } from '../config'; import { BtcAccountMgr } from './bitcoin/account'; +import { BtcTransactionMgr } from './bitcoin/chain'; import { Network } from './bitcoin/constants'; -import { BtcTransactionMgr } from './bitcoin/transaction'; import { Factory } from './factory'; import { BtcKeyring } from './keyring'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts index 3b26ad59..9a36c795 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts @@ -3,15 +3,15 @@ import { type Keyring } from '@metamask/keyring-api'; import { type Chain, Config } from '../config'; import { type IStaticSnapRpcHandler, SendTransactionHandler } from '../rpcs'; import { BtcAccountMgrFactory } from './bitcoin/account'; +import { BtcTransactionMgr } from './bitcoin/chain'; import { type BtcAccountConfig, type BtcTransactionConfig, } from './bitcoin/config'; import { DataClientFactory } from './bitcoin/data-client/factory'; -import { BtcTransactionMgr } from './bitcoin/transaction'; import { getBtcNetwork } from './bitcoin/utils'; +import type { ITransactionMgr } from './chain/types'; import { BtcKeyring, KeyringStateManager, type IAccountMgr } from './keyring'; -import type { ITransactionMgr } from './transaction'; // TODO: Temp solutio to support keyring in snap without keyring API export type CreateBtcKeyringOptions = { 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 index 1fb791c5..76eff5dd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts @@ -29,6 +29,8 @@ describe('BtcKeyring', () => { const unlockSpy = jest.fn(); class AccountMgr implements IAccountMgr { unlock = unlockSpy; + + createTransaction = jest.fn(); } jest .spyOn(Factory, 'createAccountMgr') diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts index 1adbd397..fbb3e031 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts @@ -1,9 +1,8 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import type { Wallet } from '../../types/state'; -import { type SnapState } from '../../types/state'; import { compactError } from '../../utils'; import { SnapStateManager, StateError } from '../snap'; +import type { Wallet, SnapState } from './types'; export class KeyringStateManager extends SnapStateManager { protected override async get(): Promise { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts index fbe8db36..3088d908 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts @@ -1,3 +1,4 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; import { type Json } from '@metamask/snaps-sdk'; import type { Buffer } from 'buffer'; import type { Infer } from 'superstruct'; @@ -5,6 +6,21 @@ import { object } from 'superstruct'; import type { IStaticSnapRpcHandler } from '../../rpcs'; import { scopeStruct } from '../../utils'; +import type { TransactionIntent } from '../chain'; + +export type Wallet = { + account: KeyringAccount; + type: string; + index: number; + scope: string; +}; + +export type Wallets = Record; + +export type SnapState = { + walletIds: string[]; + wallets: Wallets; +}; export type IAccountSigner = { sign(hash: Buffer): Promise; @@ -22,10 +38,23 @@ export type IAccount = { pubkey: string; type: string; signer: IAccountSigner; + signTransaction(tx: Txn): Promise; + sign(message: Buffer): Promise; }; export type IAccountMgr = { unlock(index: number): Promise; + createTransaction( + acount: IAccount, + txn: TransactionIntent, + options: { + metadata: Record; + fee: number; + }, + ): Promise<{ + txn: Buffer; + txnJson: Record; + }>; }; export type KeyringOptions = Record & { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/exceptions.ts deleted file mode 100644 index 6b250ff1..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/exceptions.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index fb3a6883..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index 05387d77..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/state.ts +++ /dev/null @@ -1,4 +0,0 @@ -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.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.test.ts deleted file mode 100644 index 8739dd21..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { generateAccounts } from '../../../test/utils'; -import { BtcAsset } from '../bitcoin/constants'; -import { TransactionServiceError } from './exceptions'; -import { TransactionStateManager } from './state'; -import { TransactionService } from './transaction'; -import type { ITransactionMgr } from './types'; - -describe('TransactionService', () => { - const createMockTxnMgr = () => { - const getBalancesSpy = jest.fn(); - - class MockTxnMgr implements ITransactionMgr { - getBalances = getBalancesSpy; - } - return { - instance: new MockTxnMgr(), - getBalancesSpy, - }; - }; - - describe('getBalance', () => { - it('calls getBalances with transactionMgr', async () => { - const { instance, getBalancesSpy } = createMockTxnMgr(); - const accounts = generateAccounts(1); - const addresses = accounts.map((account) => account.address); - - const service = new TransactionService( - instance, - new TransactionStateManager(), - ); - await service.getBalances(addresses, [BtcAsset.TBtc]); - - expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [BtcAsset.TBtc]); - }); - - it('throws TransactionServiceError if getBalances failed', async () => { - const { instance, getBalancesSpy } = createMockTxnMgr(); - getBalancesSpy.mockRejectedValue(new Error('error')); - const accounts = generateAccounts(1); - const addresses = accounts.map((account) => account.address); - - const service = new TransactionService( - instance, - new TransactionStateManager(), - ); - - await expect( - service.getBalances(addresses, [BtcAsset.TBtc]), - ).rejects.toThrow(TransactionServiceError); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.ts b/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.ts deleted file mode 100644 index 2505c492..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/transaction.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { TransactionServiceError } from './exceptions'; -import type { TransactionStateManager } from './state'; -import type { AssetBalances, ITransactionMgr } from './types'; - -// Keep this class until we dont need to using state to manage transaction -export class TransactionService { - protected readonly transactionMgr: ITransactionMgr; - - protected readonly transactionStateManager: TransactionStateManager; - - constructor( - transactionMgr: ITransactionMgr, - transactionStateManager: TransactionStateManager, - ) { - this.transactionMgr = transactionMgr; - this.transactionStateManager = transactionStateManager; - } - - async getBalances( - addresses: string[], - assets: string[], - ): Promise { - try { - const result = await this.transactionMgr.getBalances(addresses, assets); - 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 deleted file mode 100644 index 753d7e90..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/transaction/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type Balances = Record; - -export type Balance = { - amount: number; -}; - -export type AssetBalances = { - balances: { - [address: string]: { - [asset: string]: Balance; - }; - }; -}; - -export type ITransactionMgr = { - getBalances(addresses: string[], assets: string[]): Promise; -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts index c01dbd40..04c51aeb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts @@ -3,10 +3,6 @@ import { object, string, assign, array, record } from 'superstruct'; import { Config } from '../../config'; import { Factory } from '../../modules/factory'; -import { - TransactionService, - TransactionStateManager, -} from '../../modules/transaction'; import type { StaticImplements } from '../../types/static'; import { assetsStruct, numberStringStruct } from '../../utils/superstruct'; import { BaseSnapRpcHandler } from '../base'; @@ -49,12 +45,9 @@ export class GetBalancesHandler async handleRequest(params: GetBalancesParams): Promise { const { scope, accounts, assets } = params; - const txService = new TransactionService( - Factory.createTransactionMgr(Config.chain, scope), - new TransactionStateManager(), - ); + const chainApi = Factory.createTransactionMgr(Config.chain, scope); - const balances = await txService.getBalances(accounts, assets); + const balances = await chainApi.getBalances(accounts, assets); const response = { balances: Object.entries(balances.balances).reduce( diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts index a0a7995a..17f55aeb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts @@ -1,16 +1,24 @@ -import type { Infer } from 'superstruct'; -import { unknown } from 'superstruct'; +import { object, string, assign, type Infer, record } from 'superstruct'; -import type { AssetBalances } from '../../modules/transaction'; +/* eslint-disable */ +import { Config } from '../../config'; +import type { TransactionIntent } from '../../modules/chain/types'; +import { Factory } from '../../modules/factory'; +/* eslint-disable */ import type { StaticImplements } from '../../types/static'; +import { numberStringStruct } from '../../utils'; import { BaseSnapRpcHandler } from '../base'; -import type { IStaticSnapRpcHandler, SnapRpcHandlerResponse } from '../types'; +import { + SnapRpcHandlerRequestStruct, + type IStaticSnapRpcHandler, + type SnapRpcHandlerResponse, +} from '../types'; export type SendTransactionParams = Infer< typeof SendTransactionHandler.requestStruct >; -export type SendTransactionResponse = SnapRpcHandlerResponse & AssetBalances; +export type SendTransactionResponse = SnapRpcHandlerResponse; export class SendTransactionHandler extends BaseSnapRpcHandler @@ -18,13 +26,66 @@ export class SendTransactionHandler StaticImplements { static override get requestStruct() { - return unknown(); + return assign( + object({ + account: string(), + intent: object({ + amounts: record(string(), numberStringStruct), + }), + }), + SnapRpcHandlerRequestStruct, + ); } async handleRequest( // eslint-disable-next-line @typescript-eslint/no-unused-vars params: SendTransactionParams, ): Promise { - throw new Error('Method not supported'); + throw new Error('Method not implemented.'); + // const { scope, account: address, intent } = params; + // /* eslint-disable */ + // const transactionIntent: TransactionIntent = Object.entries( + // intent.amounts, + // ).reduce( + // (acc, [account, amount]) => { + // acc.amounts[account] = parseInt(amount, 10); // assume satoshi + // return acc; + // }, + // { amounts: {} }, + // ); + + // // TODO: Get account by address or pass account object from Keyring + // const accountMgr = Factory.createAccountMgr(Config.chain, scope); + // const account = await accountMgr.unlock(0); + // if (!account || account.address !== address) { + // throw new Error('Account not found'); + // } + + // const chainApi = Factory.createTransactionMgr(Config.chain, scope); + + // const feesResp = await chainApi.estimateFees(); + + // // TODO: Ask user to confirm fee + // const fee = feesResp.fees[0].rate; + + // const data = await chainApi.getDataForTransaction( + // address, + // transactionIntent, + // ); + + // const { txn, txnJson } = await accountMgr.createTransaction( + // account, + // transactionIntent, + // { + // metadata: data, + // fee, + // }, + // ); + + // // TODO: Create dailog with txnJson, and ask user to confirm txn + // const txnHash = await account.signTransaction(txn); + + // return await chainApi.boardcastTransaction(txnHash); + /* eslint-disable */ } } From 5374ded6d2a1be99cc56cad1e83b803d08eeedc2 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 29 Apr 2024 11:27:52 +0800 Subject: [PATCH 021/362] feat: add api key for blockchair (#27) --- .../bitcoin-wallet-snap/.env.example | 7 +++ merged-packages/bitcoin-wallet-snap/README.md | 6 ++- .../bitcoin-wallet-snap/jest.config.js | 1 + .../bitcoin-wallet-snap/snap.config.ts | 5 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/config/config.ts | 4 ++ .../src/modules/bitcoin/config/types.ts | 3 ++ .../data-client/clients/blockchair.test.ts | 47 +++++++++++++++++++ .../bitcoin/data-client/clients/blockchair.ts | 9 +++- .../modules/bitcoin/data-client/factory.ts | 8 +++- 10 files changed, 85 insertions(+), 7 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/.env.example b/merged-packages/bitcoin-wallet-snap/.env.example index 4e21456f..00f4aaa8 100644 --- a/merged-packages/bitcoin-wallet-snap/.env.example +++ b/merged-packages/bitcoin-wallet-snap/.env.example @@ -1,6 +1,13 @@ # Description: Environment variables for read data client # Possible Options: BlockStream | BlockChair +# Default: BlockChair +# Required: false DATA_CLIENT_READ_TYPE=BlockStream # Description: Environment variables for Log Level, 0 does not log anything, 6 logs everything # Possible Options: 0-6 +# Default: 0 +# Required: false LOG_LEVEL=6 +# Description: Environment variables for API key of BlockChair +# Required: false +BLOCKCHAIR_API_KEY= \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index 1a9ce2fe..72c36cbf 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -1 +1,5 @@ -# BTC Snap +# Bitcoin Manager Snap + +## Configuration + +please see `.env.example` for reference diff --git a/merged-packages/bitcoin-wallet-snap/jest.config.js b/merged-packages/bitcoin-wallet-snap/jest.config.js index 47ca301b..867c7682 100644 --- a/merged-packages/bitcoin-wallet-snap/jest.config.js +++ b/merged-packages/bitcoin-wallet-snap/jest.config.js @@ -17,6 +17,7 @@ module.exports = { '!./src/config/*.ts', '!./src/**/type?(s).ts', '!./src/**/exception?(s).ts', + '!./src/**/constant?(s).ts', '!./test/**', './src/index.ts', ], diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts index 992c8186..902252dd 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.config.ts +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -11,10 +11,11 @@ const config: SnapConfig = { port: 8080, }, environment: { - // eslint-disable-next-line n/no-process-env + /* eslint-disable */ LOG_LEVEL: process.env.LOG_LEVEL, - // eslint-disable-next-line n/no-process-env DATA_CLIENT_READ_TYPE: process.env.DATA_CLIENT_READ_TYPE, + BLOCKCHAIR_API_KEY: process.env.BLOCKCHAIR_API_KEY, + /* eslint-disable */ }, polyfills: true, }; diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 83249b4a..8e65e90c 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "G3LuD1J/Bgm5rD6O5ZmYY5lOMGOZF/grKiKmyAvuWm0=", + "shasum": "xeaCNE7705eJ1xJlg5+raHErnAE/gwK/RMdQ7bONYg4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/config/config.ts b/merged-packages/bitcoin-wallet-snap/src/config/config.ts index 476e48c0..8e5a1981 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/config.ts @@ -46,6 +46,10 @@ export const Config: SnapConfig = { // eslint-disable-next-line no-restricted-globals (process.env.DATA_CLIENT_READ_TYPE as DataClient) ?? DataClient.BlockChair, + options: { + // eslint-disable-next-line no-restricted-globals + apiKey: process.env.BLOCKCHAIR_API_KEY, + }, }, }, }, 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 c1418867..8f5e8e0c 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 @@ -1,9 +1,12 @@ +import type { Json } from '@metamask/snaps-sdk'; + import type { DataClient } from '../constants'; export type BtcTransactionConfig = { dataClient: { read: { type: DataClient; + options?: Record; }; }; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts index 97b4c203..d0754e49 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts @@ -48,6 +48,53 @@ describe('BlockChairClient', () => { }); }); + describe('get', () => { + it('append api key to query url if option `apiKey` is present', async () => { + const { fetchSpy } = createMockFetch(); + const accounts = generateAccounts(1); + const addresses = accounts.map((account) => account.address); + const mockResponse = generateBlockChairGetBalanceResp(addresses); + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const instance = new BlockChairClient({ + network: networks.testnet, + apiKey: 'key', + }); + await instance.getBalances(addresses); + + expect(fetchSpy).toHaveBeenCalledWith( + `https://api.blockchair.com/bitcoin/testnet/addresses/balances?addresses=${addresses.join( + ',', + )}&key=key`, + { method: 'GET' }, + ); + }); + + it('does not append api key if option `apiKey` is absent', async () => { + const { fetchSpy } = createMockFetch(); + const accounts = generateAccounts(1); + const addresses = accounts.map((account) => account.address); + const mockResponse = generateBlockChairGetBalanceResp(addresses); + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const instance = new BlockChairClient({ network: networks.testnet }); + await instance.getBalances(addresses); + + expect(fetchSpy).toHaveBeenCalledWith( + `https://api.blockchair.com/bitcoin/testnet/addresses/balances?addresses=${addresses.join( + ',', + )}`, + { method: 'GET' }, + ); + }); + }); + describe('getBalances', () => { it('returns balances', async () => { const { fetchSpy } = createMockFetch(); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts index 40fab7e3..3764f601 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts @@ -8,6 +8,7 @@ import type { IReadDataClient } from '../types'; export type BlockChairClientOptions = { network: Network; + apiKey?: string; }; /* eslint-disable */ @@ -63,7 +64,13 @@ export class BlockChairClient implements IReadDataClient { } protected async get(endpoint: string): Promise { - const response = await fetch(`${this.baseUrl}${endpoint}`, { + const url = new URL(`${this.baseUrl}${endpoint}`); + // TODO: Update to proxy + if (this.options.apiKey) { + url.searchParams.append('key', this.options.apiKey); + } + + const response = await fetch(url.toString(), { method: 'GET', }); 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 index 77941d06..ef00324b 100644 --- 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 @@ -12,11 +12,15 @@ export class DataClientFactory { config: BtcTransactionConfig, network: Network, ): IReadDataClient { - switch (config.dataClient.read.type) { + const { type, options } = config.dataClient.read; + switch (type) { case DataClient.BlockStream: return new BlockStreamClient({ network }); case DataClient.BlockChair: - return new BlockChairClient({ network }); + return new BlockChairClient({ + network, + apiKey: options?.apiKey?.toString(), + }); default: throw new DataClientError( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions From a65475c802ac2a33fbc3f3fe27a6276a14f1382a Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 29 Apr 2024 17:45:44 +0800 Subject: [PATCH 022/362] fix: fix snap publish package is not using builded snap config (#29) --- .../bitcoin-wallet-snap/package.json | 1 + .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/index.ts | 31 +++--- .../bitcoin-wallet-snap/src/rpcs/base.ts | 13 +-- .../src/rpcs/methods/send-transaction.ts | 104 ++++++++++-------- .../bitcoin-wallet-snap/src/utils/error.ts | 50 +++++++++ 6 files changed, 131 insertions(+), 70 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 53bbab13..55586b1f 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -10,6 +10,7 @@ "main": "./dist/bundle.js", "files": [ "dist/", + "images/", "snap.manifest.json" ], "scripts": { diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 8e65e90c..4f616a31 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "xeaCNE7705eJ1xJlg5+raHErnAE/gwK/RMdQ7bONYg4=", + "shasum": "4oWOBip3XuC8bIzaDbnCt4Ip1osDUrUnyrI7lcE47sA=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 634df7da..e2cafdec 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -3,8 +3,8 @@ import { type OnRpcRequestHandler, type OnKeyringRequestHandler, type Json, + UnauthorizedError, SnapError, - MethodNotFoundError, } from '@metamask/snaps-sdk'; import { Config } from './config'; @@ -13,36 +13,38 @@ import { Factory } from './modules/factory'; import { logger } from './modules/logger/logger'; import type { SnapRpcHandlerRequest } from './rpcs'; import { RpcHelper } from './rpcs/helpers'; +import { isSnapRpcError } from './utils'; -export const validateOrigin = (origin: string, method: string) => { +export const validateOrigin = (origin: string, method: string): void => { if (!origin) { - throw new SnapError('Origin not found'); + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw new UnauthorizedError('Origin not found'); } if (!originPermissions.get(origin)?.has(method)) { - throw new SnapError(`Permission denied`); + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw new UnauthorizedError(`Permission denied`); } }; export const onRpcRequest: OnRpcRequestHandler = async (args) => { + logger.logLevel = parseInt(Config.logLevel, 10); + try { const { request, origin } = args; const { method } = request; validateOrigin(origin, method); - logger.logLevel = parseInt(Config.logLevel, 10); - return await RpcHelper.getChainApiHandler(method) .getInstance() .execute(request.params as SnapRpcHandlerRequest); } catch (error) { let snapError = error; - if ( - !(snapError instanceof MethodNotFoundError || error instanceof SnapError) - ) { + + if (!isSnapRpcError(error)) { snapError = new SnapError(error); } logger.error( - `onRpcRequest error: ${JSON.stringify(snapError.toJSON(), null, 2)}`, + `onKeyringRequest error: ${JSON.stringify(snapError.toJSON(), null, 2)}`, ); throw snapError; } @@ -52,11 +54,11 @@ export const onKeyringRequest: OnKeyringRequestHandler = async ({ origin, request, }): Promise => { + logger.logLevel = parseInt(Config.logLevel, 10); + try { validateOrigin(origin, request.method); - logger.logLevel = parseInt(Config.logLevel, 10); - const keyring = Factory.createKeyring(Config.chain); return (await handleKeyringRequest( keyring, @@ -64,9 +66,8 @@ export const onKeyringRequest: OnKeyringRequestHandler = async ({ )) as unknown as Promise; } catch (error) { let snapError = error; - if ( - !(snapError instanceof MethodNotFoundError || error instanceof SnapError) - ) { + + if (!isSnapRpcError(error)) { snapError = new SnapError(error); } logger.error( diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts index 1d20c134..a032dd88 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts @@ -1,7 +1,6 @@ import { type Struct, assert } from 'superstruct'; import { logger } from '../modules/logger/logger'; -import { compactError } from '../utils'; import { SnapRpcValidationError } from './exceptions'; import { type ISnapRpcExecutable, @@ -64,14 +63,10 @@ export abstract class BaseSnapRpcHandler implements ISnapRpcExecutable { async execute( params: SnapRpcHandlerRequest, ): Promise { - try { - await this.preExecute(params); - const result = await this.handleRequest(params); - await this.postExecute(result); - return result; - } catch (error) { - throw compactError(error, SnapRpcValidationError); - } + await this.preExecute(params); + const result = await this.handleRequest(params); + await this.postExecute(result); + return result; } static getInstance( diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts index 17f55aeb..3f1f0ae1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts @@ -2,7 +2,7 @@ import { object, string, assign, type Infer, record } from 'superstruct'; /* eslint-disable */ import { Config } from '../../config'; -import type { TransactionIntent } from '../../modules/chain/types'; +import type { Fee, Fees, TransactionIntent } from '../../modules/chain/types'; import { Factory } from '../../modules/factory'; /* eslint-disable */ import type { StaticImplements } from '../../types/static'; @@ -13,6 +13,7 @@ import { type IStaticSnapRpcHandler, type SnapRpcHandlerResponse, } from '../types'; +import { Json, UserRejectedRequestError } from '@metamask/snaps-sdk'; export type SendTransactionParams = Infer< typeof SendTransactionHandler.requestStruct @@ -41,51 +42,64 @@ export class SendTransactionHandler // eslint-disable-next-line @typescript-eslint/no-unused-vars params: SendTransactionParams, ): Promise { - throw new Error('Method not implemented.'); - // const { scope, account: address, intent } = params; + const { scope, account: address, intent } = params; // /* eslint-disable */ - // const transactionIntent: TransactionIntent = Object.entries( - // intent.amounts, - // ).reduce( - // (acc, [account, amount]) => { - // acc.amounts[account] = parseInt(amount, 10); // assume satoshi - // return acc; - // }, - // { amounts: {} }, - // ); - - // // TODO: Get account by address or pass account object from Keyring - // const accountMgr = Factory.createAccountMgr(Config.chain, scope); - // const account = await accountMgr.unlock(0); - // if (!account || account.address !== address) { - // throw new Error('Account not found'); - // } - - // const chainApi = Factory.createTransactionMgr(Config.chain, scope); - - // const feesResp = await chainApi.estimateFees(); - - // // TODO: Ask user to confirm fee - // const fee = feesResp.fees[0].rate; - - // const data = await chainApi.getDataForTransaction( - // address, - // transactionIntent, - // ); - - // const { txn, txnJson } = await accountMgr.createTransaction( - // account, - // transactionIntent, - // { - // metadata: data, - // fee, - // }, - // ); - - // // TODO: Create dailog with txnJson, and ask user to confirm txn - // const txnHash = await account.signTransaction(txn); - - // return await chainApi.boardcastTransaction(txnHash); + const transactionIntent: TransactionIntent = Object.entries( + intent.amounts, + ).reduce( + (acc, [account, amount]) => { + acc.amounts[account] = parseInt(amount, 10); // assume satoshi + return acc; + }, + { amounts: {} }, + ); + + // TODO: Get account by address or pass account object from Keyring + const accountMgr = Factory.createAccountMgr(Config.chain, scope); + const account = await accountMgr.unlock(0); + if (!account || account.address !== address) { + throw new Error('Account not found'); + } + + const chainApi = Factory.createTransactionMgr(Config.chain, scope); + + const feesResp = await chainApi.estimateFees(); + + const fee = await this.getFeeConsensus(feesResp); + + const data = await chainApi.getDataForTransaction( + address, + transactionIntent, + ); + + const { txn, txnJson } = await accountMgr.createTransaction( + account, + transactionIntent, + { + metadata: data, + fee, + }, + ); + + if ((await this.getTxnConsensus(txnJson)) === false) { + throw new UserRejectedRequestError(); + } + + const txnHash = await account.signTransaction(txn); + + return await chainApi.boardcastTransaction(txnHash); /* eslint-disable */ } + + protected async getFeeConsensus(fees: Fees): Promise { + // TODO: Ask user to confirm fee + return fees.fees[0].rate; + } + + protected async getTxnConsensus( + txnJson: Record, + ): Promise { + // TODO: Ask user to confirm txn + return true; + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/error.ts b/merged-packages/bitcoin-wallet-snap/src/utils/error.ts index ff565eaa..dbe1ef8e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/error.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/error.ts @@ -1,3 +1,23 @@ +import { + MethodNotFoundError, + UserRejectedRequestError, + MethodNotSupportedError, + ParseError, + ResourceNotFoundError, + ResourceUnavailableError, + TransactionRejected, + ChainDisconnectedError, + DisconnectedError, + UnauthorizedError, + UnsupportedMethodError, + InternalError, + InvalidInputError, + InvalidParamsError, + InvalidRequestError, + LimitExceededError, + SnapError, +} from '@metamask/snaps-sdk'; + /** * Compact error to a specific error instance. * @@ -14,3 +34,33 @@ export function compactError( } return new ErrCtor(error.message); } + +/** + * A Method to determind the given error is an Snap Error. + * + * @param error - Error instance. + * @returns Result in boolean. + */ +export function isSnapRpcError(error: Error): boolean { + const errors = [ + SnapError, + MethodNotFoundError, + UserRejectedRequestError, + MethodNotSupportedError, + MethodNotFoundError, + ParseError, + ResourceNotFoundError, + ResourceUnavailableError, + TransactionRejected, + ChainDisconnectedError, + DisconnectedError, + UnauthorizedError, + UnsupportedMethodError, + InternalError, + InvalidInputError, + InvalidParamsError, + InvalidRequestError, + LimitExceededError, + ]; + return errors.some((errType) => error instanceof errType); +} From cd1b08e851d4f2860df38b8b82ae21b73b831fbc Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 29 Apr 2024 18:45:29 +0800 Subject: [PATCH 023/362] fix: remove non ready code (#30) * fix: remove non ready code * fix: lint issue * Update send-transaction.ts --- .../src/rpcs/methods/send-transaction.ts | 124 +++++++++--------- 1 file changed, 59 insertions(+), 65 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts index 3f1f0ae1..d5ed8f6f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts @@ -1,10 +1,5 @@ import { object, string, assign, type Infer, record } from 'superstruct'; -/* eslint-disable */ -import { Config } from '../../config'; -import type { Fee, Fees, TransactionIntent } from '../../modules/chain/types'; -import { Factory } from '../../modules/factory'; -/* eslint-disable */ import type { StaticImplements } from '../../types/static'; import { numberStringStruct } from '../../utils'; import { BaseSnapRpcHandler } from '../base'; @@ -13,7 +8,6 @@ import { type IStaticSnapRpcHandler, type SnapRpcHandlerResponse, } from '../types'; -import { Json, UserRejectedRequestError } from '@metamask/snaps-sdk'; export type SendTransactionParams = Infer< typeof SendTransactionHandler.requestStruct @@ -39,67 +33,67 @@ export class SendTransactionHandler } async handleRequest( - // eslint-disable-next-line @typescript-eslint/no-unused-vars + /* eslint-disable */ params: SendTransactionParams, - ): Promise { - const { scope, account: address, intent } = params; - // /* eslint-disable */ - const transactionIntent: TransactionIntent = Object.entries( - intent.amounts, - ).reduce( - (acc, [account, amount]) => { - acc.amounts[account] = parseInt(amount, 10); // assume satoshi - return acc; - }, - { amounts: {} }, - ); - - // TODO: Get account by address or pass account object from Keyring - const accountMgr = Factory.createAccountMgr(Config.chain, scope); - const account = await accountMgr.unlock(0); - if (!account || account.address !== address) { - throw new Error('Account not found'); - } - - const chainApi = Factory.createTransactionMgr(Config.chain, scope); - - const feesResp = await chainApi.estimateFees(); - - const fee = await this.getFeeConsensus(feesResp); - - const data = await chainApi.getDataForTransaction( - address, - transactionIntent, - ); - - const { txn, txnJson } = await accountMgr.createTransaction( - account, - transactionIntent, - { - metadata: data, - fee, - }, - ); - - if ((await this.getTxnConsensus(txnJson)) === false) { - throw new UserRejectedRequestError(); - } - - const txnHash = await account.signTransaction(txn); - - return await chainApi.boardcastTransaction(txnHash); /* eslint-disable */ + ): Promise { + throw new Error('Method not implemented'); + // const { scope, account: address, intent } = params; + // const transactionIntent: TransactionIntent = Object.entries( + // intent.amounts, + // ).reduce( + // (acc, [account, amount]) => { + // acc.amounts[account] = parseInt(amount, 10); // assume satoshi + // return acc; + // }, + // { amounts: {} }, + // ); + + // // TODO: Get account by address or pass account object from Keyring + // const accountMgr = Factory.createAccountMgr(Config.chain, scope); + // const account = await accountMgr.unlock(0); + // if (!account || account.address !== address) { + // throw new Error('Account not found'); + // } + + // const chainApi = Factory.createTransactionMgr(Config.chain, scope); + + // const feesResp = await chainApi.estimateFees(); + + // const fee = await this.getFeeConsensus(feesResp); + + // const data = await chainApi.getDataForTransaction( + // address, + // transactionIntent, + // ); + + // const { txn, txnJson } = await accountMgr.createTransaction( + // account, + // transactionIntent, + // { + // metadata: data, + // fee, + // }, + // ); + + // if ((await this.getTxnConsensus(txnJson)) === false) { + // throw new UserRejectedRequestError(); + // } + + // const txnHash = await account.signTransaction(txn); + + // return await chainApi.boardcastTransaction(txnHash); } - protected async getFeeConsensus(fees: Fees): Promise { - // TODO: Ask user to confirm fee - return fees.fees[0].rate; - } - - protected async getTxnConsensus( - txnJson: Record, - ): Promise { - // TODO: Ask user to confirm txn - return true; - } + // protected async getFeeConsensus(fees: Fees): Promise { + // // TODO: Ask user to confirm fee + // return fees.fees[0].rate; + // } + + // protected async getTxnConsensus( + // txnJson: Record, + // ): Promise { + // // TODO: Ask user to confirm txn + // return true; + // } } From 394050e0ede4ebdd361b3c11a3896351fb6e6409 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 30 Apr 2024 12:12:50 +0800 Subject: [PATCH 024/362] fix: restructure code 3 (#31) * fix: rename coding class * fix: remove sign method * fix: update keyring path --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/config/config.ts | 20 +-- .../src/modules/bitcoin/account/exceptions.ts | 7 - .../modules/bitcoin/account/factory.test.ts | 89 ------------- .../src/modules/bitcoin/account/factory.ts | 27 ---- .../modules/bitcoin/account/manager.test.ts | 49 ------- .../src/modules/bitcoin/chain/exceptions.ts | 2 +- .../src/modules/bitcoin/chain/index.ts | 2 +- .../{manager.test.ts => service.test.ts} | 24 ++-- .../bitcoin/chain/{manager.ts => service.ts} | 18 +-- .../src/modules/bitcoin/chain/types.ts | 2 +- .../src/modules/bitcoin/config/types.ts | 4 +- .../modules/bitcoin/data-client/factory.ts | 4 +- .../src/modules/bitcoin/data-client/types.ts | 2 +- .../{account => wallet}/account.test.ts | 25 +++- .../bitcoin/{account => wallet}/account.ts | 9 -- .../{account => wallet}/deriver.test.ts | 0 .../bitcoin/{account => wallet}/deriver.ts | 0 .../src/modules/bitcoin/wallet/exceptions.ts | 7 + .../modules/bitcoin/wallet/factory.test.ts | 63 +++++++++ .../src/modules/bitcoin/wallet/factory.ts | 17 +++ .../bitcoin/{account => wallet}/index.ts | 2 +- .../{account => wallet}/signer.test.ts | 0 .../bitcoin/{account => wallet}/signer.ts | 0 .../bitcoin/{account => wallet}/types.ts | 1 - .../src/modules/bitcoin/wallet/wallet.test.ts | 125 ++++++++++++++++++ .../{account/manager.ts => wallet/wallet.ts} | 61 ++++++--- .../src/modules/chain/types.ts | 2 +- .../src/modules/factory.test.ts | 20 +-- .../src/modules/factory.ts | 40 +++--- .../src/modules/keyring/keyring.test.ts | 27 ++-- .../src/modules/keyring/keyring.ts | 22 +-- .../src/modules/keyring/state.test.ts | 20 +-- .../src/modules/keyring/types.ts | 9 +- .../src/rpcs/methods/create-account.ts | 4 +- .../src/rpcs/methods/get-balances.ts | 2 +- .../src/rpcs/methods/send-transaction.ts | 8 +- 37 files changed, 398 insertions(+), 318 deletions(-) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/exceptions.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/factory.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/factory.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.test.ts rename merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/{manager.test.ts => service.test.ts} (76%) rename merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/{manager.ts => service.ts} (79%) rename merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/{account => wallet}/account.test.ts (77%) rename merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/{account => wallet}/account.ts (88%) rename merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/{account => wallet}/deriver.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/{account => wallet}/deriver.ts (100%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/exceptions.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts rename merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/{account => wallet}/index.ts (83%) rename merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/{account => wallet}/signer.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/{account => wallet}/signer.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/{account => wallet}/types.ts (97%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts rename merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/{account/manager.ts => wallet/wallet.ts} (52%) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 4f616a31..5d901578 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "4oWOBip3XuC8bIzaDbnCt4Ip1osDUrUnyrI7lcE47sA=", + "shasum": "AoQ2JVkXbCe0Mp3cINaN0e4kdmMLKj7UJTQZpaFXJpE=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/config/config.ts b/merged-packages/bitcoin-wallet-snap/src/config/config.ts index 8e5a1981..3fdc4e65 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/config.ts @@ -1,6 +1,6 @@ import type { - BtcAccountConfig, - BtcTransactionConfig, + BtcWalletConfig, + BtcOnChainServiceConfig, } from '../modules/bitcoin/config'; import { Network as BtcNetwork, @@ -16,17 +16,17 @@ export type NetworkConfig = { [key in string]: string; }; -export type TransactionConfig = { - [Chain.Bitcoin]: BtcTransactionConfig; +export type OnChainServiceConfig = { + [Chain.Bitcoin]: BtcOnChainServiceConfig; }; -export type AccountConfig = { - [Chain.Bitcoin]: BtcAccountConfig; +export type WalletConfig = { + [Chain.Bitcoin]: BtcWalletConfig; }; export type SnapConfig = { - transaction: TransactionConfig; - account: AccountConfig; + onChainService: OnChainServiceConfig; + wallet: WalletConfig; avaliableNetworks: { [key in Chain]: string[]; }; @@ -38,7 +38,7 @@ export type SnapConfig = { }; export const Config: SnapConfig = { - transaction: { + onChainService: { [Chain.Bitcoin]: { dataClient: { read: { @@ -54,7 +54,7 @@ export const Config: SnapConfig = { }, }, }, - account: { + wallet: { [Chain.Bitcoin]: { enableMultiAccounts: false, defaultAccountIndex: 0, 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 deleted file mode 100644 index 4b407262..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/exceptions.ts +++ /dev/null @@ -1,7 +0,0 @@ -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.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/factory.test.ts deleted file mode 100644 index 7e2ff7d1..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/factory.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -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: ScriptType.P2wpkh, - deriver: 'BIP32', - enableMultiAccounts: false, - }, - 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', - enableMultiAccounts: false, - }, - 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', - enableMultiAccounts: false, - }, - networks.testnet, - ), - ).toThrow('Invalid script type'); - }); - }); -}); 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 deleted file mode 100644 index 0cf99a48..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/factory.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { type Network } from 'bitcoinjs-lib'; - -import { type IAccountMgr } from '../../keyring'; -import { type BtcAccountConfig } from '../config'; -import { ScriptType } from '../constants'; -import { P2WPKHAccount } from './account'; -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/manager.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.test.ts deleted file mode 100644 index 1c0ad59c..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { networks } from 'bitcoinjs-lib'; - -import { createMockBip32Instance } from '../../../../test/utils'; -import { P2WPKHAccount } from './account'; -import { BtcAccountBip32Deriver } from './deriver'; -import { AccountMgrError } from './exceptions'; -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(AccountMgrError); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/exceptions.ts index 36ea1b04..7cad9bca 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/exceptions.ts @@ -1,3 +1,3 @@ import { CustomError } from '../../exceptions'; -export class TransactionMgrError extends CustomError {} +export class BtcOnChainServiceError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/index.ts index 471b4a69..5ce37176 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/index.ts @@ -1,3 +1,3 @@ export * from './exceptions'; -export * from './manager'; +export * from './service'; export * from './types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/manager.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts similarity index 76% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/manager.test.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts index cc8cebf4..d9d5e049 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/manager.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts @@ -4,9 +4,9 @@ import { networks } from 'bitcoinjs-lib'; import { generateAccounts } from '../../../../test/utils'; import { BtcAsset } from '../constants'; import type { IReadDataClient } from '../data-client'; -import { BtcTransactionMgr } from './manager'; +import { BtcOnChainService } from './service'; -describe('BtcTransactionMgr', () => { +describe('BtcOnChainService', () => { const createMockReadDataClient = () => { const getBalanceSpy = jest.fn(); @@ -19,11 +19,11 @@ describe('BtcTransactionMgr', () => { }; }; - const createMockBtcTransactionMgr = ( + const createMockBtcService = ( readDataClient: IReadDataClient, network: Network = networks.testnet, ) => { - const instance = new BtcTransactionMgr(readDataClient, { + const instance = new BtcOnChainService(readDataClient, { network, }); @@ -35,7 +35,7 @@ describe('BtcTransactionMgr', () => { describe('getBalance', () => { it('calls getBalances with readClient', async () => { const { instance, getBalanceSpy } = createMockReadDataClient(); - const { instance: txnMgr } = createMockBtcTransactionMgr(instance); + const { instance: txnService } = createMockBtcService(instance); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); getBalanceSpy.mockResolvedValue( @@ -45,36 +45,36 @@ describe('BtcTransactionMgr', () => { }, {}), ); - await txnMgr.getBalances(addresses, [BtcAsset.TBtc]); + await txnService.getBalances(addresses, [BtcAsset.TBtc]); expect(getBalanceSpy).toHaveBeenCalledWith(addresses); }); it('throws `Only one asset is supported` error if the given asset more than 1', async () => { const { instance } = createMockReadDataClient(); - const { instance: txnMgr } = createMockBtcTransactionMgr(instance); + const { instance: txnService } = createMockBtcService(instance); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); await expect( - txnMgr.getBalances(addresses, [BtcAsset.TBtc, BtcAsset.Btc]), + txnService.getBalances(addresses, [BtcAsset.TBtc, BtcAsset.Btc]), ).rejects.toThrow('Only one asset is supported'); }); it('throws `Invalid asset` error if the BTC asset is given and current network is testnet network', async () => { const { instance } = createMockReadDataClient(); - const { instance: txnMgr } = createMockBtcTransactionMgr(instance); + const { instance: txnService } = createMockBtcService(instance); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); await expect( - txnMgr.getBalances(addresses, [BtcAsset.Btc]), + txnService.getBalances(addresses, [BtcAsset.Btc]), ).rejects.toThrow('Invalid asset'); }); it('throws `Invalid asset` error if the TBTC asset is given and current network is bitcoin network', async () => { const { instance } = createMockReadDataClient(); - const { instance: txnMgr } = createMockBtcTransactionMgr( + const { instance: txnService } = createMockBtcService( instance, networks.bitcoin, ); @@ -82,7 +82,7 @@ describe('BtcTransactionMgr', () => { const addresses = accounts.map((account) => account.address); await expect( - txnMgr.getBalances(addresses, [BtcAsset.TBtc]), + txnService.getBalances(addresses, [BtcAsset.TBtc]), ).rejects.toThrow('Invalid asset'); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/manager.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts similarity index 79% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/manager.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts index 8088ec93..05a05478 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/manager.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts @@ -3,7 +3,7 @@ import { networks } from 'bitcoinjs-lib'; import { compactError } from '../../../utils'; import type { - ITransactionMgr, + IOnChainService, Balances, AssetBalances, TransactionIntent, @@ -12,15 +12,15 @@ import type { } from '../../chain/types'; import { BtcAsset } from '../constants'; import { type IReadDataClient } from '../data-client'; -import { TransactionMgrError } from './exceptions'; -import type { BtcTransactionMgrOptions } from './types'; +import { BtcOnChainServiceError } from './exceptions'; +import type { BtcOnChainServiceOptions } from './types'; -export class BtcTransactionMgr implements ITransactionMgr { +export class BtcOnChainService implements IOnChainService { protected readonly readClient: IReadDataClient; - protected readonly options: BtcTransactionMgrOptions; + protected readonly options: BtcOnChainServiceOptions; - constructor(readClient: IReadDataClient, options: BtcTransactionMgrOptions) { + constructor(readClient: IReadDataClient, options: BtcOnChainServiceOptions) { this.readClient = readClient; this.options = options; } @@ -35,7 +35,7 @@ export class BtcTransactionMgr implements ITransactionMgr { ): Promise { try { if (assets.length > 1) { - throw new TransactionMgrError('Only one asset is supported'); + throw new BtcOnChainServiceError('Only one asset is supported'); } const allowedAssets = new Set( @@ -47,7 +47,7 @@ export class BtcTransactionMgr implements ITransactionMgr { (this.network === networks.testnet && assets[0] !== BtcAsset.TBtc) || (this.network === networks.bitcoin && assets[0] !== BtcAsset.Btc) ) { - throw new TransactionMgrError('Invalid asset'); + throw new BtcOnChainServiceError('Invalid asset'); } const balance: Balances = await this.readClient.getBalances(addresses); @@ -64,7 +64,7 @@ export class BtcTransactionMgr implements ITransactionMgr { { balances: {} }, ); } catch (error) { - throw compactError(error, TransactionMgrError); + throw compactError(error, BtcOnChainServiceError); } } /* eslint-disable */ diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/types.ts index 01e37768..60ce9afb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/types.ts @@ -1,5 +1,5 @@ import type { Network } from 'bitcoinjs-lib'; -export type BtcTransactionMgrOptions = { +export type BtcOnChainServiceOptions = { network: Network; }; 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 8f5e8e0c..f4a4d32c 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 @@ -2,7 +2,7 @@ import type { Json } from '@metamask/snaps-sdk'; import type { DataClient } from '../constants'; -export type BtcTransactionConfig = { +export type BtcOnChainServiceConfig = { dataClient: { read: { type: DataClient; @@ -11,7 +11,7 @@ export type BtcTransactionConfig = { }; }; -export type BtcAccountConfig = { +export type BtcWalletConfig = { enableMultiAccounts: boolean; defaultAccountIndex: number; defaultAccountType: string; 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 index ef00324b..7588b1ed 100644 --- 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 @@ -1,6 +1,6 @@ import { type Network } from 'bitcoinjs-lib'; -import { type BtcTransactionConfig } from '../config'; +import { type BtcOnChainServiceConfig } from '../config'; import { DataClient } from '../constants'; import { BlockChairClient } from './clients/blockchair'; import { BlockStreamClient } from './clients/blockstream'; @@ -9,7 +9,7 @@ import type { IReadDataClient } from './types'; export class DataClientFactory { static createReadClient( - config: BtcTransactionConfig, + config: BtcOnChainServiceConfig, network: Network, ): IReadDataClient { const { type, options } = config.dataClient.read; 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 index 1c64b971..7b3a96b2 100644 --- 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 @@ -1,4 +1,4 @@ -import { type Balances } from '../../transaction'; +import { type Balances } from '../../chain'; export type IReadDataClient = { getBalances(address: string[]): Promise; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.test.ts similarity index 77% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.test.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.test.ts index 742ba564..8dd13539 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.test.ts @@ -76,17 +76,28 @@ describe('BtcAccount', () => { expect(() => instance.address).toThrow('Payment address is missing'); }); - }); - describe('sign', () => { - it('signs a message with signer', async () => { + it('throws `Public key is invalid` error if the public key is invalid', async () => { const network = networks.testnet; - const { instance, signerSpy } = await createMockAccount(network); + const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; + createMockPaymentInstance(address); + + const signerSpy = jest.fn(); + const index = 0; + const hdPath = [`m`, `0'`, `0`, `${index}`].join('/'); - const message = Buffer.from('test'); - await instance.sign(message); + const instance = new P2WPKHAccount( + 'ddddddddddddd', + index, + hdPath, + undefined as unknown as string, + network, + P2WPKHAccount.scriptType, + `bip122:${P2WPKHAccount.scriptType.toLowerCase()}`, + { sign: signerSpy } as unknown as IAccountSigner, + ); - expect(signerSpy).toHaveBeenCalledWith(message); + expect(() => instance.address).toThrow('Public key is invalid'); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.ts similarity index 88% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.ts index 03bd50ae..c7d32092 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.ts @@ -76,15 +76,6 @@ export class BtcAccount implements IBtcAccount { throw new Error('Public key is invalid'); } } - - async sign(message: Buffer): Promise { - return await this.signer.sign(message); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async signTransaction(tx: Txn): Promise { - throw new Error('Method not implemented.'); - } } export class P2WPKHAccount diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/deriver.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/deriver.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/deriver.test.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/deriver.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/deriver.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/deriver.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/deriver.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/deriver.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/exceptions.ts new file mode 100644 index 00000000..1fd7d3b2 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/exceptions.ts @@ -0,0 +1,7 @@ +import { CustomError } from '../../exceptions'; + +export class DeriverError extends CustomError {} + +export class WalletFactoryError extends CustomError {} + +export class WalletError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.test.ts new file mode 100644 index 00000000..50f0a2e5 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.test.ts @@ -0,0 +1,63 @@ +import type { Network } from 'bitcoinjs-lib'; +import { networks } from 'bitcoinjs-lib'; + +import { ScriptType } from '../constants'; +import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; +import { BtcWalletFactory } from './factory'; +import type { IBtcAccountDeriver } from './types'; +import * as manager from './wallet'; + +describe('BtcWalletFactory', () => { + class MockBtcWallet extends manager.BtcWallet { + getDeriver() { + return this.deriver; + } + } + + const createMockBtcWallet = () => { + const spy = jest + .spyOn(manager, 'BtcWallet') + .mockImplementation((deriver: IBtcAccountDeriver, network: Network) => { + return new MockBtcWallet(deriver, network); + }); + return { + spy, + }; + }; + + describe('create', () => { + it('creates BtcWallet instance with `BtcAccountBip32Deriver`', () => { + const { spy } = createMockBtcWallet(); + + const instance = BtcWalletFactory.create( + { + defaultAccountIndex: 0, + defaultAccountType: ScriptType.P2wpkh, + deriver: 'BIP32', + enableMultiAccounts: false, + }, + networks.testnet, + ) as unknown as MockBtcWallet; + + expect(spy).toHaveBeenCalled(); + expect(instance.getDeriver()).toBeInstanceOf(BtcAccountBip32Deriver); + }); + + it('creates BtcWallet instance with `BtcAccountBip44Deriver`', () => { + const { spy } = createMockBtcWallet(); + + const instance = BtcWalletFactory.create( + { + defaultAccountIndex: 0, + defaultAccountType: ScriptType.P2wpkh, + deriver: 'BIP44', + enableMultiAccounts: false, + }, + networks.testnet, + ) as unknown as MockBtcWallet; + + expect(spy).toHaveBeenCalled(); + expect(instance.getDeriver()).toBeInstanceOf(BtcAccountBip44Deriver); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts new file mode 100644 index 00000000..9933dc02 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts @@ -0,0 +1,17 @@ +import { type Network } from 'bitcoinjs-lib'; + +import { type IWallet } from '../../keyring'; +import { type BtcWalletConfig } from '../config'; +import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; +import { BtcWallet } from './wallet'; + +export class BtcWalletFactory { + static create(config: BtcWalletConfig, network: Network): IWallet { + return new BtcWallet( + config.deriver === 'BIP44' + ? new BtcAccountBip44Deriver(network) + : new BtcAccountBip32Deriver(network), + network, + ); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/index.ts similarity index 83% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/index.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/index.ts index d082cdba..7115f817 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/index.ts @@ -3,4 +3,4 @@ export * from './factory'; export * from './types'; export * from './signer'; export * from './deriver'; -export * from './manager'; +export * from './wallet'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/signer.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/signer.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/signer.test.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/signer.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/signer.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/signer.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/signer.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/signer.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts similarity index 97% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/types.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts index 951833f1..53921e99 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts @@ -20,7 +20,6 @@ export type IAccountSigner = { export type IBtcAccount = IAccount & { payment: Payment; - signer: IAccountSigner; }; export type IStaticBtcAccount = { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts new file mode 100644 index 00000000..84457653 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts @@ -0,0 +1,125 @@ +import { networks } from 'bitcoinjs-lib'; +import { Buffer } from 'buffer'; + +import { createMockBip32Instance } from '../../../../test/utils'; +import { ScriptType } from '../constants'; +import { P2SHP2WPKHAccount, P2WPKHAccount } from './account'; +import { BtcAccountBip32Deriver } from './deriver'; +import { WalletError } from './exceptions'; +import { BtcWallet } from './wallet'; + +describe('BtcWallet', () => { + const createMockWallet = (network) => { + 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 BtcWallet( + new BtcAccountBip32Deriver(network), + network, + ); + return { + instance, + rootSpy, + childSpy, + rootNode, + childNode, + }; + }; + + describe('unlock', () => { + it('returns an `Account` objec with P2wpkh type', async () => { + const network = networks.testnet; + const { rootSpy, childSpy, instance, rootNode } = + createMockWallet(network); + const idx = 0; + + const result = await instance.unlock(idx, ScriptType.P2wpkh); + + expect(result).toBeInstanceOf(P2WPKHAccount); + expect(rootSpy).toHaveBeenCalledWith(P2WPKHAccount.path); + expect(childSpy).toHaveBeenCalledWith(rootNode, idx); + }); + + it('returns an `Account` object with P2shP2wkh type', async () => { + const network = networks.testnet; + const { rootSpy, childSpy, instance, rootNode } = + createMockWallet(network); + const idx = 0; + + const result = await instance.unlock(idx, ScriptType.P2shP2wkh); + + expect(result).toBeInstanceOf(P2SHP2WPKHAccount); + expect(rootSpy).toHaveBeenCalledWith(P2SHP2WPKHAccount.path); + expect(childSpy).toHaveBeenCalledWith(rootNode, idx); + }); + + it('throws `Unable to get fingerprint in hex` error if the fingerprint can not toString', async () => { + const network = networks.testnet; + const idx = 0; + const { instance, rootNode } = createMockWallet(network); + rootNode.fingerprint = undefined as unknown as Buffer; + + await expect(instance.unlock(idx, ScriptType.P2wpkh)).rejects.toThrow( + `Unable to get fingerprint in hex`, + ); + }); + + it('throws `Unable to get public key in hex` error if the fingerprint can not toString', async () => { + const network = networks.testnet; + const idx = 0; + const { instance, childNode } = createMockWallet(network); + childNode.publicKey = undefined as unknown as Buffer; + + await expect(instance.unlock(idx, ScriptType.P2wpkh)).rejects.toThrow( + `Unable to get public key in hex`, + ); + }); + + it('throws error if the account cannot be unlocked', async () => { + const network = networks.testnet; + const idx = 0; + const { instance } = createMockWallet(network); + + await expect(instance.unlock(idx, ScriptType.P2pkh)).rejects.toThrow( + WalletError, + ); + }); + }); + + describe('createTransaction', () => { + it('throws `Method not implemented` error', async () => { + const network = networks.testnet; + const idx = 0; + const { instance } = createMockWallet(network); + + const account = await instance.unlock(idx, ScriptType.P2wpkh); + + await expect( + instance.createTransaction(account, {} as any, {} as any), + ).rejects.toThrow('Method not implemented.'); + }); + }); + + describe('signTransaction', () => { + it('throws `Method not implemented` error', async () => { + const network = networks.testnet; + const idx = 0; + const { instance } = createMockWallet(network); + + const account = await instance.unlock(idx, ScriptType.P2wpkh); + + await expect( + instance.signTransaction( + account.signer, + Buffer.from('0x12312313', 'hex'), + ), + ).rejects.toThrow('Method not implemented.'); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts similarity index 52% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts index 660b0cde..f71518d0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/account/manager.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts @@ -1,58 +1,67 @@ import type { Json } from '@metamask/snaps-sdk'; import type { BIP32Interface } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; +import { type Buffer } from 'buffer'; import { compactError } from '../../../utils'; import type { TransactionIntent } from '../../chain/types'; -import { type IAccount, type IAccountMgr } from '../../keyring'; -import { AccountMgrError } from './exceptions'; +import type { IAccountSigner } from '../../keyring'; +import { type IAccount, type IWallet } from '../../keyring'; +import { ScriptType } from '../constants'; +import { P2WPKHAccount, P2SHP2WPKHAccount } from './account'; +import { WalletError } from './exceptions'; import { AccountSigner } from './signer'; import type { IStaticBtcAccount, IBtcAccountDeriver } from './types'; -export class BtcAccountMgr implements IAccountMgr { +export class BtcWallet implements IWallet { protected readonly deriver: IBtcAccountDeriver; - protected readonly accountCtor: IStaticBtcAccount; - protected readonly network: Network; - constructor( - deriver: IBtcAccountDeriver, - accountCtor: IStaticBtcAccount, - network: Network, - ) { + constructor(deriver: IBtcAccountDeriver, network: Network) { this.deriver = deriver; - this.accountCtor = accountCtor; this.network = network; } - async unlock(index: number): Promise { - try { - const AccountCtor = this.accountCtor; + protected getAccountCtor(type: string): IStaticBtcAccount { + switch (type.toLowerCase()) { + case ScriptType.P2wpkh.toLowerCase(): + return P2WPKHAccount; + case ScriptType.P2shP2wkh.toLowerCase(): + return P2SHP2WPKHAccount; + default: + throw new WalletError('Invalid script type'); + } + } + async unlock(index: number, type: string): Promise { + try { + const AccountCtor = this.getAccountCtor(type); 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 AccountCtor( - rootNode.fingerprint.toString('hex'), + this.getFingerPrintInHex(rootNode), index, hdPath, - childNode.publicKey.toString('hex'), + this.getPublicKeyInHex(childNode), this.network, AccountCtor.scriptType, `bip122:${AccountCtor.scriptType.toLowerCase()}`, this.getHdSigner(rootNode), ); } catch (error) { - throw compactError(error, AccountMgrError); + throw compactError(error, WalletError); } } - /* eslint-disable */ async createTransaction( + // eslint-disable-next-line @typescript-eslint/no-unused-vars acount: IAccount, + // eslint-disable-next-line @typescript-eslint/no-unused-vars txn: TransactionIntent, + // eslint-disable-next-line @typescript-eslint/no-unused-vars options: { metadata: Record; fee: number; @@ -61,9 +70,23 @@ export class BtcAccountMgr implements IAccountMgr { txn: Buffer; txnJson: Record; }> { + // create PSBT + // add PSBT input + // add PSBT output + // out PSBT base64 buffer + throw new Error('Method not implemented.'); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async signTransaction(signer: IAccountSigner, txn: Buffer): Promise { + // convert txn to PSBT + // validate PSBT + // finalize PSBT + // sign PSBT + // verify sign + // out txn hex throw new Error('Method not implemented.'); } - /* eslint-disable */ protected getFingerPrintInHex(rootNode: BIP32Interface) { try { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts index 06c77636..87d77c73 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts @@ -32,7 +32,7 @@ export type Pagination = { offset: number; }; -export type ITransactionMgr = { +export type IOnChainService = { getBalances(addresses: string[], assets: string[]): Promise; estimateFees(): Promise; boardcastTransaction(txn: string); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts index 689fb50e..8bb722b0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts @@ -1,27 +1,27 @@ import { Chain } from '../config'; -import { BtcAccountMgr } from './bitcoin/account'; -import { BtcTransactionMgr } from './bitcoin/chain'; +import { BtcOnChainService } from './bitcoin/chain'; import { Network } from './bitcoin/constants'; +import { BtcWallet } from './bitcoin/wallet'; import { Factory } from './factory'; import { BtcKeyring } from './keyring'; describe('Factory', () => { - describe('createTransactionMgr', () => { - it('creates BtcTransactionMgr instance', () => { - const instance = Factory.createTransactionMgr( + describe('createOnChainServiceProvider', () => { + it('creates BtcOnChainService instance', () => { + const instance = Factory.createOnChainServiceProvider( Chain.Bitcoin, Network.Testnet, ); - expect(instance).toBeInstanceOf(BtcTransactionMgr); + expect(instance).toBeInstanceOf(BtcOnChainService); }); }); - describe('createAccountMgr', () => { - it('creates BtcAccountMgr instance', () => { - const instance = Factory.createAccountMgr(Chain.Bitcoin, Network.Testnet); + describe('createWallet', () => { + it('creates BtcWallet instance', () => { + const instance = Factory.createWallet(Chain.Bitcoin, Network.Testnet); - expect(instance).toBeInstanceOf(BtcAccountMgr); + expect(instance).toBeInstanceOf(BtcWallet); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts index 9a36c795..b8847603 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts @@ -2,16 +2,16 @@ import { type Keyring } from '@metamask/keyring-api'; import { type Chain, Config } from '../config'; import { type IStaticSnapRpcHandler, SendTransactionHandler } from '../rpcs'; -import { BtcAccountMgrFactory } from './bitcoin/account'; -import { BtcTransactionMgr } from './bitcoin/chain'; +import { BtcOnChainService } from './bitcoin/chain'; import { - type BtcAccountConfig, - type BtcTransactionConfig, + type BtcWalletConfig, + type BtcOnChainServiceConfig, } from './bitcoin/config'; import { DataClientFactory } from './bitcoin/data-client/factory'; import { getBtcNetwork } from './bitcoin/utils'; -import type { ITransactionMgr } from './chain/types'; -import { BtcKeyring, KeyringStateManager, type IAccountMgr } from './keyring'; +import { BtcWalletFactory } from './bitcoin/wallet'; +import type { IOnChainService } from './chain/types'; +import { BtcKeyring, KeyringStateManager, type IWallet } from './keyring'; // TODO: Temp solutio to support keyring in snap without keyring API export type CreateBtcKeyringOptions = { @@ -19,21 +19,21 @@ export type CreateBtcKeyringOptions = { }; export class Factory { - static createBtcTransactionMgr( - config: BtcTransactionConfig, + static createBtcOnChainServiceProvider( + config: BtcOnChainServiceConfig, network: string, ) { const btcNetwork = getBtcNetwork(network); const readClient = DataClientFactory.createReadClient(config, btcNetwork); - return new BtcTransactionMgr(readClient, { + return new BtcOnChainService(readClient, { network: btcNetwork, }); } - static createBtcAccountMgr(config: BtcAccountConfig, network: string) { + static createBtcWallet(config: BtcWalletConfig, network: string) { const btcNetwork = getBtcNetwork(network); - return BtcAccountMgrFactory.create(config, btcNetwork); + return BtcWalletFactory.create(config, btcNetwork); } static createBtcKeyringRpcMapping(): Record { @@ -44,7 +44,7 @@ export class Factory { } static createBtcKeyring( - config: BtcAccountConfig, + config: BtcWalletConfig, options: CreateBtcKeyringOptions, ): BtcKeyring { return new BtcKeyring( @@ -59,16 +59,22 @@ export class Factory { ); } - static createTransactionMgr(chain: Chain, scope: string): ITransactionMgr { - return Factory.createBtcTransactionMgr(Config.transaction[chain], scope); + static createOnChainServiceProvider( + chain: Chain, + scope: string, + ): IOnChainService { + return Factory.createBtcOnChainServiceProvider( + Config.onChainService[chain], + scope, + ); } - static createAccountMgr(chain: Chain, scope: string): IAccountMgr { - return Factory.createBtcAccountMgr(Config.account[chain], scope); + static createWallet(chain: Chain, scope: string): IWallet { + return Factory.createBtcWallet(Config.wallet[chain], scope); } static createKeyring(chain: Chain): Keyring { - return Factory.createBtcKeyring(Config.account[chain], { + return Factory.createBtcKeyring(Config.wallet[chain], { emitEvents: true, }); } 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 index 76eff5dd..35867602 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts @@ -11,7 +11,7 @@ import { Factory } from '../factory'; import { BtcKeyringError } from './exceptions'; import { BtcKeyring } from './keyring'; import { KeyringStateManager } from './state'; -import type { IAccountMgr } from './types'; +import type { IWallet } from './types'; jest.mock('../logger/logger', () => ({ logger: { @@ -25,17 +25,19 @@ jest.mock('@metamask/keyring-api', () => ({ })); describe('BtcKeyring', () => { - const createMockAccountMgr = () => { + const createMockWallet = () => { const unlockSpy = jest.fn(); - class AccountMgr implements IAccountMgr { + class Wallet implements IWallet { unlock = unlockSpy; + signTransaction = jest.fn(); + createTransaction = jest.fn(); } jest - .spyOn(Factory, 'createAccountMgr') + .spyOn(Factory, 'createWallet') .mockImplementation() - .mockReturnValue(new AccountMgr()); + .mockReturnValue(new Wallet()); return { unlockSpy, }; @@ -106,9 +108,13 @@ describe('BtcKeyring', () => { }; }; + const getHdPath = (index: number) => { + return [`m`, `0'`, `0`, `${index}`].join('/'); + }; + describe('createAccount', () => { it('creates account', async () => { - const { unlockSpy } = createMockAccountMgr(); + const { unlockSpy } = createMockWallet(); const { instance: stateMgr, addWalletSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); const scope = Network.Testnet; @@ -116,7 +122,7 @@ describe('BtcKeyring', () => { unlockSpy.mockResolvedValue({ address: account.address, - hdPath: account.options.hdPath, + hdPath: getHdPath(account.options.index), index: account.options.index, type: account.type, }); @@ -126,7 +132,8 @@ describe('BtcKeyring', () => { }); expect(unlockSpy).toHaveBeenCalledWith( - Config.account[Chain.Bitcoin].defaultAccountIndex, + Config.wallet[Chain.Bitcoin].defaultAccountIndex, + Config.wallet[Chain.Bitcoin].defaultAccountType, ); expect(addWalletSpy).toHaveBeenCalledWith({ account: { @@ -139,14 +146,14 @@ describe('BtcKeyring', () => { }, methods: account.methods, }, - type: account.type, + hdPath: getHdPath(account.options.index), index: account.options.index, scope, }); }); it('throws BtcKeyringError if an error catched', async () => { - const { unlockSpy } = createMockAccountMgr(); + const { unlockSpy } = createMockWallet(); const { instance: stateMgr } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); unlockSpy.mockRejectedValue(new Error('error')); 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 7706a63c..bef71ad1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts @@ -22,7 +22,7 @@ import { type ChainRPCHandlers, type CreateAccountOptions, type IAccount, - type IAccountMgr, + type IWallet, type KeyringOptions, } from './types'; @@ -66,21 +66,21 @@ export class BtcKeyring implements Keyring { try { assert(options, CreateAccountOptionsStruct); - const accountMgr: IAccountMgr = Factory.createAccountMgr( - Config.chain, - options.scope, - ); + const wallet: IWallet = Factory.createWallet(Config.chain, options.scope); // TODO: Create account with index 0 for now for phase 1 scope, update to use increment index later - const index = Config.account[Config.chain].defaultAccountIndex; - const account = await accountMgr.unlock(index); + const index = Config.wallet[Config.chain].defaultAccountIndex; + const account = await wallet.unlock( + index, + Config.wallet[Config.chain].defaultAccountType, + ); logger.info( `[BtcKeyring.createAccount] Account unlocked: ${account.address}`, ); const keyringAccount = this.newKeyringAccount(account, { - ...options, + scope: options.scope, index, }); @@ -93,9 +93,9 @@ export class BtcKeyring implements Keyring { await this.stateMgr.withTransaction(async () => { await this.stateMgr.addWallet({ account: keyringAccount, - type: account.type, - index, - scope: options?.scope, + hdPath: account.hdPath, + index: account.index, + scope: options.scope, }); await this.#emitEvent(KeyringEvent.AccountCreated, { 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 index d368f8cc..0fb055be 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.test.ts @@ -3,7 +3,7 @@ import { Network } from '../bitcoin/constants'; import { SnapHelper, StateError } from '../snap'; import { KeyringStateManager } from './state'; -describe('BtcKeyring', () => { +describe('KeyringStateManager', () => { const createMockStateManager = () => { const getDataSpy = jest.spyOn(SnapHelper, 'getStateData'); const setDataSpy = jest.spyOn(SnapHelper, 'setStateData'); @@ -14,6 +14,10 @@ describe('BtcKeyring', () => { }; }; + const getHdPath = (index: number) => { + return [`m`, `0'`, `0`, `${index}`].join('/'); + }; + const createInitState = (cnt = 1, scope = Network.Testnet) => { const generatedAccounts = generateAccounts(cnt); return { @@ -21,7 +25,7 @@ describe('BtcKeyring', () => { wallets: generatedAccounts.reduce((acc, account) => { acc[account.id] = { account, - type: account.type, + hdPath: getHdPath(account.options.index), index: account.options.index, scope, }; @@ -98,7 +102,7 @@ describe('BtcKeyring', () => { await instance.addWallet({ account: accountToSave, - type: accountToSave.type, + hdPath: getHdPath(accountToSave.index), index: accountToSave.index, scope: accountToSave.scope, }); @@ -107,7 +111,7 @@ describe('BtcKeyring', () => { expect(setDataSpy).toHaveBeenCalledTimes(1); expect(state.wallets[accountToSave.id]).toStrictEqual({ account: accountToSave, - type: accountToSave.type, + hdPath: getHdPath(accountToSave.index), index: accountToSave.index, scope: accountToSave.scope, }); @@ -121,7 +125,7 @@ describe('BtcKeyring', () => { await instance.addWallet({ account: accountToSave, - type: accountToSave.type, + hdPath: getHdPath(accountToSave.index), index: accountToSave.index, scope: accountToSave.scope, }); @@ -130,7 +134,7 @@ describe('BtcKeyring', () => { expect(setDataSpy).toHaveBeenCalledTimes(1); expect(state.wallets[accountToSave.id]).toStrictEqual({ account: accountToSave, - type: accountToSave.type, + hdPath: getHdPath(accountToSave.index), index: accountToSave.index, scope: accountToSave.scope, }); @@ -145,7 +149,7 @@ describe('BtcKeyring', () => { await expect( instance.addWallet({ account: accountToSave, - type: accountToSave.type, + hdPath: getHdPath(accountToSave.index), index: accountToSave.index, scope: accountToSave.scope, }), @@ -163,7 +167,7 @@ describe('BtcKeyring', () => { await expect( instance.addWallet({ account: accountToSave, - type: accountToSave.type, + hdPath: getHdPath(accountToSave.index), index: accountToSave.index, scope: accountToSave.scope, }), diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts index 3088d908..6d128f9f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts @@ -10,7 +10,7 @@ import type { TransactionIntent } from '../chain'; export type Wallet = { account: KeyringAccount; - type: string; + hdPath: string; index: number; scope: string; }; @@ -38,12 +38,11 @@ export type IAccount = { pubkey: string; type: string; signer: IAccountSigner; - signTransaction(tx: Txn): Promise; - sign(message: Buffer): Promise; }; -export type IAccountMgr = { - unlock(index: number): Promise; +export type IWallet = { + unlock(index: number, type: string): Promise; + signTransaction(signer: IAccountSigner, txn: Buffer): Promise; createTransaction( acount: IAccount, txn: TransactionIntent, 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 index 2bcf038e..e9f4d9b6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts @@ -31,8 +31,8 @@ export class CreateAccountHandler new KeyringStateManager(), Factory.createBtcKeyringRpcMapping(), { - defaultIndex: Config.account[Config.chain].defaultAccountIndex, - multiAccount: Config.account[Config.chain].enableMultiAccounts, + defaultIndex: Config.wallet[Config.chain].defaultAccountIndex, + multiAccount: Config.wallet[Config.chain].enableMultiAccounts, emitEvents: false, }, ); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts index 04c51aeb..19362888 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts @@ -45,7 +45,7 @@ export class GetBalancesHandler async handleRequest(params: GetBalancesParams): Promise { const { scope, accounts, assets } = params; - const chainApi = Factory.createTransactionMgr(Config.chain, scope); + const chainApi = Factory.createOnChainServiceProvider(Config.chain, scope); const balances = await chainApi.getBalances(accounts, assets); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts index d5ed8f6f..3ea6b685 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts @@ -50,13 +50,13 @@ export class SendTransactionHandler // ); // // TODO: Get account by address or pass account object from Keyring - // const accountMgr = Factory.createAccountMgr(Config.chain, scope); - // const account = await accountMgr.unlock(0); + // const wallet = Factory.createWallet(Config.chain, scope); + // const account = await wallet.unlock(0); // if (!account || account.address !== address) { // throw new Error('Account not found'); // } - // const chainApi = Factory.createTransactionMgr(Config.chain, scope); + // const chainApi = Factory.createOnChainServiceProvider(Config.chain, scope); // const feesResp = await chainApi.estimateFees(); @@ -67,7 +67,7 @@ export class SendTransactionHandler // transactionIntent, // ); - // const { txn, txnJson } = await accountMgr.createTransaction( + // const { txn, txnJson } = await wallet.createTransaction( // account, // transactionIntent, // { From 13dcd7165334baa9dcb4f7c3c7034a3c8dd51d51 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 30 Apr 2024 13:05:24 +0800 Subject: [PATCH 025/362] chore: add commit method in state (#32) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/modules/keyring/keyring.ts | 4 +- .../src/modules/snap/state.test.ts | 47 ++++++++----------- .../src/modules/snap/state.ts | 28 ++++++----- 4 files changed, 40 insertions(+), 41 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 5d901578..2db8acb7 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "AoQ2JVkXbCe0Mp3cINaN0e4kdmMLKj7UJTQZpaFXJpE=", + "shasum": "3wW9wiy943vs9HYVsWHODtmYkBnqCy9jFHCerkNVfyQ=", "location": { "npm": { "filePath": "dist/bundle.js", 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 bef71ad1..d5246a88 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts @@ -124,7 +124,7 @@ export class BtcKeyring implements Keyring { await this.stateMgr.withTransaction(async () => { await this.stateMgr.updateAccount(account); await this.#emitEvent(KeyringEvent.AccountUpdated, { account }); - }, true); + }); } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[BtcKeyring.updateAccount] Error: ${error.message}`); @@ -137,7 +137,7 @@ export class BtcKeyring implements Keyring { await this.stateMgr.withTransaction(async () => { await this.stateMgr.removeAccounts([id]); await this.#emitEvent(KeyringEvent.AccountDeleted, { id }); - }, true); + }); } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[BtcKeyring.deleteAccount] Error: ${error.message}`); 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 index 93fc041f..10741303 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.test.ts @@ -58,27 +58,19 @@ describe('SnapStateManager', () => { data: StateDataInput, delay: number, isThrowError?: boolean, - isForceCommit?: boolean, + isCommit?: boolean, ) => { - if (isForceCommit === false || isForceCommit === true) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - await instance.withTransaction(async (state) => { - await instance.updateData(data); - await new Promise((resolve) => setTimeout(resolve, delay)); - if (isThrowError) { - throw new Error('executeTransationFn error'); - } - }, isForceCommit); - } else { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - await instance.withTransaction(async (state) => { - await instance.updateData(data); - await new Promise((resolve) => setTimeout(resolve, delay)); - if (isThrowError) { - throw new Error('executeTransationFn error'); - } - }); - } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + await instance.withTransaction(async (state) => { + await instance.updateData(data); + if (isCommit === true) { + await instance.commit(); + } + await new Promise((resolve) => setTimeout(resolve, delay)); + if (isThrowError) { + throw new Error('executeTransationFn error'); + } + }); }; const executeFn = async (data, delay, isThrowError = false) => { @@ -300,7 +292,7 @@ describe('SnapStateManager', () => { expect(updateDataSpy).toHaveBeenCalledTimes(promiseArr.length); }); - it('does rollback if an error catched and force commit is true', async () => { + it('does rollback if an error catched and has committed', async () => { const initState = { transaction: ['id'], trasansactionDetails: { @@ -334,6 +326,7 @@ describe('SnapStateManager', () => { }, 30, true, + true, ), executeTransationFn( { @@ -362,8 +355,8 @@ describe('SnapStateManager', () => { expect(updateDataSpy).toHaveBeenCalledTimes(promiseArr.length); }); - it('does not trigger rollback if an error catched but force commit is false', async () => { - const isForceCommit = false; + it('does not trigger rollback if an error catched and has not committed', async () => { + const hasCommited = false; const initState: MockState = { transaction: ['id'], trasansactionDetails: { @@ -390,7 +383,7 @@ describe('SnapStateManager', () => { }, 30, true, - isForceCommit, + hasCommited, ); } catch (error) { expectedError = error; @@ -407,8 +400,8 @@ describe('SnapStateManager', () => { } }); - it('does not rollback if rollback failed and force commit is true', async () => { - const isForceCommit = true; + it('does not rollback if rollback failed and has committed', async () => { + const committed = true; const initState: MockState = { transaction: ['id'], trasansactionDetails: { @@ -440,7 +433,7 @@ describe('SnapStateManager', () => { }, 30, true, - isForceCommit, + committed, ), executeTransationFn( { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts b/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts index 8c4d4e9b..8ed304c6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts @@ -1,4 +1,4 @@ -import type { MutexInterface } from 'async-mutex'; +import { type MutexInterface } from 'async-mutex'; import { v4 as uuidv4 } from 'uuid'; import { compactError } from '../../utils'; @@ -12,7 +12,7 @@ export type Transaction = { orgState?: State; current?: State; isRollingBack: boolean; - isForceCommit: boolean; + hasCommited: boolean; }; export abstract class SnapStateManager { @@ -27,7 +27,7 @@ export abstract class SnapStateManager { orgState: undefined, current: undefined, isRollingBack: false, - isForceCommit: false, + hasCommited: false, }; } @@ -50,7 +50,7 @@ export abstract class SnapStateManager { }]: transaction is processing, use existing state`, ); await callback(this.#transaction.current); - if (this.#transaction.isForceCommit) { + if (this.#transaction.hasCommited) { await this.set(this.#transaction.current); } return; @@ -70,14 +70,12 @@ export abstract class SnapStateManager { * This method executes the callback code in a transaction-like format. It creates a lock to ensure the state is not intercepted during the transaction and initializes a transaction with the current state, original state, and transaction ID. If there is an error during the transaction, the state is rolled back to the original state. However, if the lock has no time limit, it might cause a deadlock if the transaction is not completed. * * @param callback - A Promise function that takes the state as an argument. - * @param isForceCommit - The flag to enable the transaction committed if any internal commit has execute. */ public async withTransaction( callback: (state: State) => Promise, - isForceCommit = false, ): Promise { await this.mtx.runExclusive(async () => { - await this.#beginTransaction(isForceCommit); + await this.#beginTransaction(); if ( !this.#transaction.current || @@ -102,7 +100,7 @@ export abstract class SnapStateManager { this.#transactionId }]: error : ${JSON.stringify(error.message)}`, ); - if (this.#transaction.isForceCommit) { + if (this.#transaction.hasCommited) { // we only need to rollback if the transaction is committed await this.#rollback(); } @@ -113,13 +111,21 @@ export abstract class SnapStateManager { }); } - async #beginTransaction(isForceCommit: boolean): Promise { + async commit() { + if (!this.#transaction.current || !this.#transaction.orgState) { + throw new StateError('Failed to commit transaction'); + } + this.#transaction.hasCommited = true; + await this.set(this.#transaction.current); + } + + async #beginTransaction(): Promise { this.#transaction = { id: uuidv4(), orgState: await this.get(), current: await this.get(), // make sure the current is not referenced to orgState isRollingBack: false, - isForceCommit, + hasCommited: false, }; } @@ -152,7 +158,7 @@ export abstract class SnapStateManager { this.#transaction.current = undefined; this.#transaction.id = undefined; this.#transaction.isRollingBack = false; - this.#transaction.isForceCommit = false; + this.#transaction.hasCommited = false; } get #transactionId(): string { From d45f2ec2a503a0c9b6d7f5ade2e4e39b41894af0 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 30 Apr 2024 14:49:58 +0800 Subject: [PATCH 026/362] feat: add method to convert sat to btc (#34) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/modules/bitcoin/utils/unit.test.ts | 44 +++++++++++++++++++ .../src/modules/bitcoin/utils/unit.ts | 22 ++++++++++ .../src/rpcs/methods/get-balances.ts | 3 +- 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 2db8acb7..9f82deed 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "3wW9wiy943vs9HYVsWHODtmYkBnqCy9jFHCerkNVfyQ=", + "shasum": "qfEdBTwDi2DYv9xIqtun9i60iuPkhbT4DNkgKo1Jw9c=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.test.ts new file mode 100644 index 00000000..63149ef1 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.test.ts @@ -0,0 +1,44 @@ +import { satsToBtc, btcToSats } from './unit'; + +describe('satsToBtc', () => { + it('returns Btc unit', () => { + const sats = 1234567899; + expect(satsToBtc(sats)).toBe('12.34567899'); + }); + + it('returns Btc unit with max Satoshis', () => { + const maxSats = 21 * Math.pow(10, 15); + + expect(satsToBtc(maxSats)).toBe('210000000.00000000'); + }); + + it('returns Btc unit with min Satoshis', () => { + const minSats = 1; + expect(satsToBtc(minSats)).toBe('0.00000001'); + }); + + it('throw an error if then given Satoshis in float', () => { + const sats = 1.1; + expect(() => satsToBtc(sats)).toThrow(Error); + }); +}); + +describe('btcToSats', () => { + it('returns Btc unit', () => { + const sats = 1234567899; + const btc = satsToBtc(sats); + expect(btcToSats(parseFloat(btc))).toBe('1234567899'); + }); + + it('returns Btc unit with max Satoshis', () => { + const maxSats = 21 * Math.pow(10, 15); + const btc = satsToBtc(maxSats); + expect(btcToSats(parseFloat(btc))).toBe('21000000000000000'); + }); + + it('returns Btc unit with min Satoshis', () => { + const minSats = 1; + const btc = satsToBtc(minSats); + expect(btcToSats(parseFloat(btc))).toBe('1'); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.ts new file mode 100644 index 00000000..479b2534 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.ts @@ -0,0 +1,22 @@ +/** + * A Method to convert BTC to Satoshis. + * + * @param sats - Satoshis. + * @returns Btc unit in string fixed to 8 decimal places. + */ +export function satsToBtc(sats: number): string { + if (!Number.isInteger(sats)) { + throw new TypeError('satsToBtc must be called on a interger number'); + } + return (sats / 1e8).toFixed(8); +} + +/** + * A Method to convert Satoshis to Btcs. + * + * @param btc - Btc. + * @returns Btc unit in string fixed to 8 decimal places. + */ +export function btcToSats(btc: number): string { + return (btc * 1e8).toFixed(0); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts index 19362888..e8aee430 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts @@ -2,6 +2,7 @@ import type { Infer } from 'superstruct'; import { object, string, assign, array, record } from 'superstruct'; import { Config } from '../../config'; +import { satsToBtc } from '../../modules/bitcoin/utils/unit'; import { Factory } from '../../modules/factory'; import type { StaticImplements } from '../../types/static'; import { assetsStruct, numberStringStruct } from '../../utils/superstruct'; @@ -55,7 +56,7 @@ export class GetBalancesHandler balancesObj[address] = Object.entries(assetBalances).reduce( (assetBalanceObj, [asset, balance]) => { assetBalanceObj[asset] = { - amount: balance.amount.toString(), + amount: satsToBtc(balance.amount), }; return assetBalanceObj; }, From ef3ff298e79cfe0ad5bdeeda6ebc1cc1f86fe80a Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 30 Apr 2024 20:07:19 +0800 Subject: [PATCH 027/362] chore: move config to factory (#35) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/index.ts | 2 +- .../src/modules/factory.test.ts | 10 +++------- .../bitcoin-wallet-snap/src/modules/factory.ts | 17 +++++++---------- .../src/modules/keyring/keyring.ts | 2 +- .../src/rpcs/methods/get-balances.ts | 3 +-- .../src/rpcs/methods/send-transaction.ts | 4 ++-- 7 files changed, 16 insertions(+), 24 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 9f82deed..578f4314 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "qfEdBTwDi2DYv9xIqtun9i60iuPkhbT4DNkgKo1Jw9c=", + "shasum": "UpQ57ZHPwpSRYZn/lGILdQlrLk38KBZXkPcaW+bRajQ=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index e2cafdec..857bfe5d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -59,7 +59,7 @@ export const onKeyringRequest: OnKeyringRequestHandler = async ({ try { validateOrigin(origin, request.method); - const keyring = Factory.createKeyring(Config.chain); + const keyring = Factory.createKeyring(); return (await handleKeyringRequest( keyring, request, diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts index 8bb722b0..c9835078 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts @@ -1,4 +1,3 @@ -import { Chain } from '../config'; import { BtcOnChainService } from './bitcoin/chain'; import { Network } from './bitcoin/constants'; import { BtcWallet } from './bitcoin/wallet'; @@ -8,10 +7,7 @@ import { BtcKeyring } from './keyring'; describe('Factory', () => { describe('createOnChainServiceProvider', () => { it('creates BtcOnChainService instance', () => { - const instance = Factory.createOnChainServiceProvider( - Chain.Bitcoin, - Network.Testnet, - ); + const instance = Factory.createOnChainServiceProvider(Network.Testnet); expect(instance).toBeInstanceOf(BtcOnChainService); }); @@ -19,7 +15,7 @@ describe('Factory', () => { describe('createWallet', () => { it('creates BtcWallet instance', () => { - const instance = Factory.createWallet(Chain.Bitcoin, Network.Testnet); + const instance = Factory.createWallet(Network.Testnet); expect(instance).toBeInstanceOf(BtcWallet); }); @@ -27,7 +23,7 @@ describe('Factory', () => { describe('createKeyring', () => { it('creates BtcKeyring instance', () => { - const instance = Factory.createKeyring(Chain.Bitcoin); + const instance = Factory.createKeyring(); expect(instance).toBeInstanceOf(BtcKeyring); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts index b8847603..1e2d2938 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts @@ -1,6 +1,6 @@ import { type Keyring } from '@metamask/keyring-api'; -import { type Chain, Config } from '../config'; +import { Config } from '../config'; import { type IStaticSnapRpcHandler, SendTransactionHandler } from '../rpcs'; import { BtcOnChainService } from './bitcoin/chain'; import { @@ -59,22 +59,19 @@ export class Factory { ); } - static createOnChainServiceProvider( - chain: Chain, - scope: string, - ): IOnChainService { + static createOnChainServiceProvider(scope: string): IOnChainService { return Factory.createBtcOnChainServiceProvider( - Config.onChainService[chain], + Config.onChainService[Config.chain], scope, ); } - static createWallet(chain: Chain, scope: string): IWallet { - return Factory.createBtcWallet(Config.wallet[chain], scope); + static createWallet(scope: string): IWallet { + return Factory.createBtcWallet(Config.wallet[Config.chain], scope); } - static createKeyring(chain: Chain): Keyring { - return Factory.createBtcKeyring(Config.wallet[chain], { + static createKeyring(): Keyring { + return Factory.createBtcKeyring(Config.wallet[Config.chain], { emitEvents: true, }); } 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 d5246a88..ad00d464 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts @@ -66,7 +66,7 @@ export class BtcKeyring implements Keyring { try { assert(options, CreateAccountOptionsStruct); - const wallet: IWallet = Factory.createWallet(Config.chain, options.scope); + const wallet: IWallet = Factory.createWallet(options.scope); // TODO: Create account with index 0 for now for phase 1 scope, update to use increment index later const index = Config.wallet[Config.chain].defaultAccountIndex; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts index e8aee430..4d191d30 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts @@ -1,7 +1,6 @@ import type { Infer } from 'superstruct'; import { object, string, assign, array, record } from 'superstruct'; -import { Config } from '../../config'; import { satsToBtc } from '../../modules/bitcoin/utils/unit'; import { Factory } from '../../modules/factory'; import type { StaticImplements } from '../../types/static'; @@ -46,7 +45,7 @@ export class GetBalancesHandler async handleRequest(params: GetBalancesParams): Promise { const { scope, accounts, assets } = params; - const chainApi = Factory.createOnChainServiceProvider(Config.chain, scope); + const chainApi = Factory.createOnChainServiceProvider(scope); const balances = await chainApi.getBalances(accounts, assets); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts index 3ea6b685..b96c7f50 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts @@ -50,13 +50,13 @@ export class SendTransactionHandler // ); // // TODO: Get account by address or pass account object from Keyring - // const wallet = Factory.createWallet(Config.chain, scope); + // const wallet = Factory.createWallet(scope); // const account = await wallet.unlock(0); // if (!account || account.address !== address) { // throw new Error('Account not found'); // } - // const chainApi = Factory.createOnChainServiceProvider(Config.chain, scope); + // const chainApi = Factory.createOnChainServiceProvider(scope); // const feesResp = await chainApi.estimateFees(); From 51907db139341e786771705f4ecf64326f1d64be Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 3 May 2024 08:32:07 +0800 Subject: [PATCH 028/362] fix: update btc asset (#44) --- .../bitcoin-wallet-snap/src/modules/bitcoin/constants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/constants.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/constants.ts index eac2e304..86cef91f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/constants.ts @@ -15,6 +15,6 @@ export enum DataClient { } export enum BtcAsset { - Btc = 'bip122:000000000019d6689c085ae165831e93:slip44:0', - TBtc = 'bip122:000000000933ea01ad0ee984209779ba:slip44:0', + Btc = 'bip122:000000000019d6689c085ae165831e93/slip44:0', + TBtc = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0', } From 35eb57eee1e055aff080850f8508fcb7a719bd71 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 3 May 2024 09:47:41 +0200 Subject: [PATCH 029/362] feat: add ListAccountsButton card (#43) --- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 578f4314..ad9c4e53 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "UpQ57ZHPwpSRYZn/lGILdQlrLk38KBZXkPcaW+bRajQ=", + "shasum": "rLfn/qFNzc5cwG+Ives5d8n0hmKK5A2AGP4Zv+SDiK8=", "location": { "npm": { "filePath": "dist/bundle.js", From 4e12a9f99ed5fa569b1630924a986e0fc2112fe7 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 3 May 2024 16:45:51 +0800 Subject: [PATCH 030/362] feat: add keyring API - btc_sendmany skeleton (#41) * feat: add btc send many skeleton * fix: refactor code structure * chore: re-structure --- .../bitcoin-wallet-snap/src/config/config.ts | 2 +- .../src/{modules => }/factory.test.ts | 6 +- .../src/{modules => }/factory.ts | 42 ++---- .../bitcoin-wallet-snap/src/index.test.ts | 33 +++-- .../bitcoin-wallet-snap/src/index.ts | 13 +- .../src/{modules => }/keyring/exceptions.ts | 2 +- .../src/{modules => }/keyring/index.ts | 0 .../src/{modules => }/keyring/keyring.test.ts | 69 ++++++--- .../src/{modules => }/keyring/keyring.ts | 32 ++-- .../src/{modules => }/keyring/state.test.ts | 50 ++++++- .../src/{modules => }/keyring/state.ts | 22 ++- .../src/{modules => }/keyring/types.ts | 6 +- .../src/modules/bitcoin/chain/exceptions.ts | 2 +- .../modules/bitcoin/data-client/exceptions.ts | 2 +- .../src/modules/bitcoin/wallet/exceptions.ts | 2 +- .../src/modules/bitcoin/wallet/factory.ts | 2 +- .../src/modules/bitcoin/wallet/types.ts | 2 +- .../src/modules/bitcoin/wallet/wallet.test.ts | 17 ++- .../src/modules/bitcoin/wallet/wallet.ts | 10 +- .../src/modules/chain/types.ts | 2 + .../{exceptions => exception}/exceptions.ts | 0 .../{exceptions => exception}/index.ts | 0 .../src/{rpcs => modules/rpc}/base.ts | 7 +- .../src/{rpcs => modules/rpc}/exceptions.ts | 2 +- .../src/modules/rpc/index.ts | 3 + .../src/{rpcs => modules/rpc}/types.ts | 2 +- .../src/modules/snap/exceptions.ts | 2 +- .../src/modules/snap/state.ts | 3 - .../src/rpcs/{methods => }/create-account.ts | 32 ++-- .../src/rpcs/{methods => }/get-balances.ts | 19 ++- .../src/rpcs/helper.test.ts | 26 ++++ .../bitcoin-wallet-snap/src/rpcs/helpers.ts | 30 ++-- .../bitcoin-wallet-snap/src/rpcs/index.ts | 6 +- .../src/rpcs/methods/index.ts | 3 - .../src/rpcs/methods/send-transaction.ts | 99 ------------- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 137 ++++++++++++++++++ .../bitcoin-wallet-snap/src/types/state.ts | 15 -- .../bitcoin-wallet-snap/test/utils.ts | 2 +- 38 files changed, 441 insertions(+), 263 deletions(-) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/factory.test.ts (81%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/factory.ts (56%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/keyring/exceptions.ts (68%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/keyring/index.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/keyring/keyring.test.ts (84%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/keyring/keyring.ts (88%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/keyring/state.test.ts (86%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/keyring/state.ts (85%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/keyring/types.ts (91%) rename merged-packages/bitcoin-wallet-snap/src/modules/{exceptions => exception}/exceptions.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/modules/{exceptions => exception}/index.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{rpcs => modules/rpc}/base.ts (93%) rename merged-packages/bitcoin-wallet-snap/src/{rpcs => modules/rpc}/exceptions.ts (67%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/rpc/index.ts rename merged-packages/bitcoin-wallet-snap/src/{rpcs => modules/rpc}/types.ts (96%) rename merged-packages/bitcoin-wallet-snap/src/rpcs/{methods => }/create-account.ts (50%) rename merged-packages/bitcoin-wallet-snap/src/rpcs/{methods => }/get-balances.ts (79%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/methods/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/types/state.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/config/config.ts b/merged-packages/bitcoin-wallet-snap/src/config/config.ts index 3fdc4e65..4bef712f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/config.ts @@ -58,7 +58,7 @@ export const Config: SnapConfig = { [Chain.Bitcoin]: { enableMultiAccounts: false, defaultAccountIndex: 0, - defaultAccountType: 'p2wpkh', + defaultAccountType: 'bip122:p2wpkh', deriver: 'BIP32', }, }, diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/factory.test.ts similarity index 81% rename from merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts rename to merged-packages/bitcoin-wallet-snap/src/factory.test.ts index c9835078..22f0ed06 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/factory.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.test.ts @@ -1,8 +1,8 @@ -import { BtcOnChainService } from './bitcoin/chain'; -import { Network } from './bitcoin/constants'; -import { BtcWallet } from './bitcoin/wallet'; import { Factory } from './factory'; import { BtcKeyring } from './keyring'; +import { BtcOnChainService } from './modules/bitcoin/chain'; +import { Network } from './modules/bitcoin/constants'; +import { BtcWallet } from './modules/bitcoin/wallet'; describe('Factory', () => { describe('createOnChainServiceProvider', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts similarity index 56% rename from merged-packages/bitcoin-wallet-snap/src/modules/factory.ts rename to merged-packages/bitcoin-wallet-snap/src/factory.ts index 1e2d2938..6a9b871f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.ts @@ -1,19 +1,18 @@ import { type Keyring } from '@metamask/keyring-api'; -import { Config } from '../config'; -import { type IStaticSnapRpcHandler, SendTransactionHandler } from '../rpcs'; -import { BtcOnChainService } from './bitcoin/chain'; +import { Config } from './config'; +import { BtcKeyring, KeyringStateManager, type IWallet } from './keyring'; +import { BtcOnChainService } from './modules/bitcoin/chain'; import { type BtcWalletConfig, type BtcOnChainServiceConfig, -} from './bitcoin/config'; -import { DataClientFactory } from './bitcoin/data-client/factory'; -import { getBtcNetwork } from './bitcoin/utils'; -import { BtcWalletFactory } from './bitcoin/wallet'; -import type { IOnChainService } from './chain/types'; -import { BtcKeyring, KeyringStateManager, type IWallet } from './keyring'; +} from './modules/bitcoin/config'; +import { DataClientFactory } from './modules/bitcoin/data-client/factory'; +import { getBtcNetwork } from './modules/bitcoin/utils'; +import { BtcWalletFactory } from './modules/bitcoin/wallet'; +import type { IOnChainService } from './modules/chain/types'; -// TODO: Temp solutio to support keyring in snap without keyring API +// TODO: Temp solution to support keyring in snap without keyring API export type CreateBtcKeyringOptions = { emitEvents: boolean; }; @@ -36,27 +35,16 @@ export class Factory { return BtcWalletFactory.create(config, btcNetwork); } - static createBtcKeyringRpcMapping(): Record { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - btc_sendTransaction: SendTransactionHandler, - }; - } - static createBtcKeyring( config: BtcWalletConfig, options: CreateBtcKeyringOptions, ): BtcKeyring { - return new BtcKeyring( - new KeyringStateManager(), - Factory.createBtcKeyringRpcMapping(), - { - defaultIndex: config.defaultAccountIndex, - multiAccount: config.enableMultiAccounts, - // TODO: Temp solutio to support keyring in snap without keyring API - emitEvents: options.emitEvents, - }, - ); + return new BtcKeyring(new KeyringStateManager(), { + defaultIndex: config.defaultAccountIndex, + multiAccount: config.enableMultiAccounts, + // TODO: Temp solutio to support keyring in snap without keyring API + emitEvents: options.emitEvents, + }); } static createOnChainServiceProvider(scope: string): IOnChainService { diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts index 7a722a6d..0b4cb7da 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.ts @@ -1,11 +1,16 @@ import * as keyringApi from '@metamask/keyring-api'; -import { type Json, type JsonRpcRequest, SnapError } from '@metamask/snaps-sdk'; +import { + type Json, + type JsonRpcRequest, + SnapError, + MethodNotFoundError, +} from '@metamask/snaps-sdk'; import { onRpcRequest, validateOrigin, onKeyringRequest } from '.'; import { Config, originPermissions } from './config'; -import { BtcKeyring } from './modules/keyring'; -import type { IStaticSnapRpcHandler } from './rpcs'; -import { BaseSnapRpcHandler, RpcHelper } from './rpcs'; +import { BtcKeyring } from './keyring'; +import { BaseSnapRpcHandler, type IStaticSnapRpcHandler } from './modules/rpc'; +import { RpcHelper } from './rpcs'; import type { StaticImplements } from './types/static'; jest.mock('@metamask/keyring-api', () => ({ @@ -76,7 +81,10 @@ describe('onRpcRequest', () => { it('executes the rpc request', async () => { const { handler, handleRequestSpy } = createMockChainApiHandler(); - jest.spyOn(RpcHelper, 'getChainApiHandler').mockReturnValue(handler); + jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_getBalances: handler, + }); handleRequestSpy.mockResolvedValueOnce({ data: 1, } as Json); @@ -87,16 +95,21 @@ describe('onRpcRequest', () => { }); it('throws SnapError if an error catched', async () => { - const { handler, handleRequestSpy } = createMockChainApiHandler(); - jest.spyOn(RpcHelper, 'getChainApiHandler').mockReturnValue(handler); - handleRequestSpy.mockRejectedValue(new Error('error')); + const { handler } = createMockChainApiHandler(); + jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_createAccount: handler, + }); - await expect(executeRequest()).rejects.toThrow(SnapError); + await expect(executeRequest()).rejects.toThrow(MethodNotFoundError); }); it('throws SnapError if an SnapError catched', async () => { const { handler, handleRequestSpy } = createMockChainApiHandler(); - jest.spyOn(RpcHelper, 'getChainApiHandler').mockReturnValue(handler); + jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_getBalances: handler, + }); handleRequestSpy.mockRejectedValue(new SnapError('error')); await expect(executeRequest()).rejects.toThrow(SnapError); diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 857bfe5d..0ec75e7c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -5,13 +5,14 @@ import { type Json, UnauthorizedError, SnapError, + MethodNotFoundError, } from '@metamask/snaps-sdk'; import { Config } from './config'; import { originPermissions } from './config/permissions'; -import { Factory } from './modules/factory'; +import { Factory } from './factory'; import { logger } from './modules/logger/logger'; -import type { SnapRpcHandlerRequest } from './rpcs'; +import type { SnapRpcHandlerRequest } from './modules/rpc'; import { RpcHelper } from './rpcs/helpers'; import { isSnapRpcError } from './utils'; @@ -34,7 +35,13 @@ export const onRpcRequest: OnRpcRequestHandler = async (args) => { const { method } = request; validateOrigin(origin, method); - return await RpcHelper.getChainApiHandler(method) + const methodHanlders = RpcHelper.getChainRpcApiHandlers(); + + if (!Object.prototype.hasOwnProperty.call(methodHanlders, method)) { + throw new MethodNotFoundError() as unknown as Error; + } + + return await methodHanlders[method] .getInstance() .execute(request.params as SnapRpcHandlerRequest); } catch (error) { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts similarity index 68% rename from merged-packages/bitcoin-wallet-snap/src/modules/keyring/exceptions.ts rename to merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts index 077e1dca..2439ed8e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts @@ -1,4 +1,4 @@ -import { CustomError } from '../exceptions'; +import { CustomError } from '../modules/exception'; export class BtcKeyringError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/index.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/index.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/keyring/index.ts rename to merged-packages/bitcoin-wallet-snap/src/keyring/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts similarity index 84% rename from merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts rename to merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts index 35867602..802f931e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts @@ -1,19 +1,19 @@ import { MethodNotFoundError } from '@metamask/snaps-sdk'; import { unknown } from 'superstruct'; -import { generateAccounts } from '../../../test/utils'; -import { Chain, Config } from '../../config'; -import type { IStaticSnapRpcHandler } from '../../rpcs'; -import { BaseSnapRpcHandler } from '../../rpcs'; -import type { StaticImplements } from '../../types/static'; -import { Network } from '../bitcoin/constants'; +import { generateAccounts } from '../../test/utils'; +import { Chain, Config } from '../config'; import { Factory } from '../factory'; +import { Network } from '../modules/bitcoin/constants'; +import { type IStaticSnapRpcHandler, BaseSnapRpcHandler } from '../modules/rpc'; +import { RpcHelper } from '../rpcs/helpers'; +import type { StaticImplements } from '../types/static'; import { BtcKeyringError } from './exceptions'; import { BtcKeyring } from './keyring'; import { KeyringStateManager } from './state'; import type { IWallet } from './types'; -jest.mock('../logger/logger', () => ({ +jest.mock('../modules/logger/logger', () => ({ logger: { info: jest.fn(), }, @@ -61,6 +61,10 @@ describe('BtcKeyring', () => { KeyringStateManager.prototype, 'getAccount', ); + const getWalletByAddressNScopeSpy = jest.spyOn( + KeyringStateManager.prototype, + 'getWalletByAddressNScope', + ); return { instance: new KeyringStateManager(), @@ -69,6 +73,7 @@ describe('BtcKeyring', () => { removeAccountsSpy, getAccountSpy, updateAccountSpy, + getWalletByAddressNScopeSpy, }; }; @@ -94,13 +99,14 @@ describe('BtcKeyring', () => { const createMockKeyring = (stateMgr: KeyringStateManager) => { const { instance: RpcHandler, handleRequestSpy } = createMockChainRPCHandler(); - const chainRPCHanlers = { + + jest.spyOn(RpcHelper, 'getKeyringRpcApiHandlers').mockReturnValue({ // eslint-disable-next-line @typescript-eslint/naming-convention - btc_sendTransaction: RpcHandler, - }; + btc_sendmany: RpcHandler, + }); return { - instance: new BtcKeyring(stateMgr, chainRPCHanlers, { + instance: new BtcKeyring(stateMgr, { defaultIndex: 0, multiAccount: false, }), @@ -192,24 +198,34 @@ describe('BtcKeyring', () => { describe('submitRequest', () => { it('calls SnapRpcHandler if the method support', async () => { - const { instance: stateMgr } = createMockStateMgr(); + const { instance: stateMgr, getWalletByAddressNScopeSpy } = + createMockStateMgr(); const { instance: keyring, handleRequestSpy } = createMockKeyring(stateMgr); const account = generateAccounts(1)[0]; const params = { scope: 'bip122:000000000019d6689c085ae165831e93', - accounts: [ - 'bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah', - 'bc1qf5n2h6mgelkls4497pkpemew55xpew90td2qae', - ], - assets: ['bip122:000000000019d6689c085ae165831e93/asset:0'], + amounts: { + bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah: '0.01', + bc1qf5n2h6mgelkls4497pkpemew55xpew90td2qae: '0.01', + }, + comment: 'testing', + subtractFeeFrom: ['bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah'], + replaceable: false, }; + getWalletByAddressNScopeSpy.mockResolvedValue({ + account, + index: account.options.index, + scope: account.options.scope, + hdPath: getHdPath(account.options.index), + }); + await keyring.submitRequest({ id: account.id, scope: Network.Testnet, account: account.address, request: { - method: 'btc_sendTransaction', + method: 'btc_sendmany', params, }, }); @@ -217,6 +233,23 @@ describe('BtcKeyring', () => { expect(handleRequestSpy).toHaveBeenCalledWith(params); }); + it('throws `Account not found` error if the account address not found', async () => { + const { instance: stateMgr } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + const account = generateAccounts(1)[0]; + + await expect( + keyring.submitRequest({ + id: account.id, + scope: Network.Testnet, + account: account.address, + request: { + method: 'btc_sendmany', + }, + }), + ).rejects.toThrow('Account not found'); + }); + it('throws MethodNotFoundError if the method not support', async () => { const { instance: stateMgr } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts similarity index 88% rename from merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts rename to merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts index ad00d464..95edb5f0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts @@ -10,11 +10,12 @@ import { MethodNotFoundError, type Json } from '@metamask/snaps-sdk'; import { assert, StructError } from 'superstruct'; import { v4 as uuidv4 } from 'uuid'; -import { Config } from '../../config'; -import type { SnapRpcHandlerRequest } from '../../rpcs'; +import { Config } from '../config'; import { Factory } from '../factory'; -import { logger } from '../logger/logger'; -import { SnapHelper } from '../snap'; +import { logger } from '../modules/logger/logger'; +import type { SnapRpcHandlerRequest } from '../modules/rpc'; +import { SnapHelper } from '../modules/snap'; +import { RpcHelper } from '../rpcs/helpers'; import { BtcKeyringError } from './exceptions'; import type { KeyringStateManager } from './state'; import { @@ -35,15 +36,12 @@ export class BtcKeyring implements Keyring { protected readonly handlers: ChainRPCHandlers; - constructor( - stateMgr: KeyringStateManager, - chainRPCHanlers: ChainRPCHandlers, - options: KeyringOptions, - ) { + constructor(stateMgr: KeyringStateManager, options: KeyringOptions) { this.stateMgr = stateMgr; this.options = options; - this.keyringMethods = Object.keys(chainRPCHanlers); - this.handlers = chainRPCHanlers; + const mapping = RpcHelper.getKeyringRpcApiHandlers(); + this.keyringMethods = Object.keys(mapping); + this.handlers = mapping; } async listAccounts(): Promise { @@ -159,13 +157,23 @@ export class BtcKeyring implements Keyring { } protected async handleSubmitRequest(request: KeyringRequest): Promise { + const { scope, account } = request; const { method, params } = request.request; if (!Object.prototype.hasOwnProperty.call(this.handlers, method)) { throw new MethodNotFoundError() as unknown as Error; } + const walletData = await this.stateMgr.getWalletByAddressNScope( + account, + scope, + ); + + if (!walletData) { + throw new Error('Account not found'); + } + return this.handlers[method] - .getInstance() + .getInstance(walletData) .execute(params as unknown as SnapRpcHandlerRequest); } diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts similarity index 86% rename from merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.test.ts rename to merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts index 0fb055be..6939943f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts @@ -1,6 +1,6 @@ -import { generateAccounts } from '../../../test/utils'; -import { Network } from '../bitcoin/constants'; -import { SnapHelper, StateError } from '../snap'; +import { generateAccounts } from '../../test/utils'; +import { Network } from '../modules/bitcoin/constants'; +import { SnapHelper, StateError } from '../modules/snap'; import { KeyringStateManager } from './state'; describe('KeyringStateManager', () => { @@ -261,7 +261,7 @@ describe('KeyringStateManager', () => { const accToUpdate = { ...state.wallets[state.walletIds[0]].account, - methods: ['btc_sendTransactions'], + methods: ['btc_sendmanys'], }; await instance.updateAccount(accToUpdate); @@ -320,4 +320,46 @@ describe('KeyringStateManager', () => { ); }); }); + + describe('getWalletByAddressNScope', () => { + it('returns result', async () => { + const { instance, getDataSpy } = createMockStateManager(); + const state = createInitState(20); + getDataSpy.mockResolvedValue(state); + const { address } = state.wallets[state.walletIds[0]].account; + const { scope } = state.wallets[state.walletIds[0]]; + + const result = await instance.getWalletByAddressNScope(address, scope); + + expect(getDataSpy).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual(state.wallets[state.walletIds[0]]); + }); + + it('returns null if the address does not exist', async () => { + const { instance, getDataSpy } = createMockStateManager(); + const state = createInitState(20); + getDataSpy.mockResolvedValue(state); + const { address } = generateAccounts(1, 'notexist', 'notexist')[0]; + + const result = await instance.getWalletByAddressNScope( + address, + Network.Testnet, + ); + + 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 state = createInitState(20); + const { address } = state.wallets[state.walletIds[0]].account; + const { scope } = state.wallets[state.walletIds[0]]; + + await expect( + instance.getWalletByAddressNScope(address, scope), + ).rejects.toThrow(StateError); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/state.ts similarity index 85% rename from merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts rename to merged-packages/bitcoin-wallet-snap/src/keyring/state.ts index fbb3e031..758a4aeb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/state.ts @@ -1,7 +1,7 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import { compactError } from '../../utils'; -import { SnapStateManager, StateError } from '../snap'; +import { SnapStateManager, StateError } from '../modules/snap'; +import { compactError } from '../utils'; import type { Wallet, SnapState } from './types'; export class KeyringStateManager extends SnapStateManager { @@ -109,6 +109,24 @@ export class KeyringStateManager extends SnapStateManager { } } + async getWalletByAddressNScope( + address: string, + scope: string, + ): Promise { + try { + const state = await this.get(); + return ( + Object.values(state.wallets).find( + (wallet) => + wallet.account.address.toString() === address.toLowerCase() && + wallet.scope.toLowerCase() === scope.toLowerCase(), + ) ?? null + ); + } catch (error) { + throw compactError(error, StateError); + } + } + protected getAccountByAddress( state: SnapState, address: string, diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts similarity index 91% rename from merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts rename to merged-packages/bitcoin-wallet-snap/src/keyring/types.ts index 6d128f9f..1c94d950 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/keyring/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts @@ -4,9 +4,9 @@ import type { Buffer } from 'buffer'; import type { Infer } from 'superstruct'; import { object } from 'superstruct'; -import type { IStaticSnapRpcHandler } from '../../rpcs'; -import { scopeStruct } from '../../utils'; -import type { TransactionIntent } from '../chain'; +import type { TransactionIntent } from '../modules/chain'; +import type { IStaticSnapRpcHandler } from '../modules/rpc'; +import { scopeStruct } from '../utils'; export type Wallet = { account: KeyringAccount; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/exceptions.ts index 7cad9bca..0b31028d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/exceptions.ts @@ -1,3 +1,3 @@ -import { CustomError } from '../../exceptions'; +import { CustomError } from '../../exception'; export class BtcOnChainServiceError extends CustomError {} 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 index c23c55be..c2039307 100644 --- 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 @@ -1,3 +1,3 @@ -import { CustomError } from '../../exceptions'; +import { CustomError } from '../../exception'; export class DataClientError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/exceptions.ts index 1fd7d3b2..b4bec314 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/exceptions.ts @@ -1,4 +1,4 @@ -import { CustomError } from '../../exceptions'; +import { CustomError } from '../../exception'; export class DeriverError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts index 9933dc02..9de12c38 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts @@ -1,6 +1,6 @@ import { type Network } from 'bitcoinjs-lib'; -import { type IWallet } from '../../keyring'; +import { type IWallet } from '../../../keyring'; import { type BtcWalletConfig } from '../config'; import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; import { BtcWallet } from './wallet'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts index 53921e99..7f226b44 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts @@ -2,7 +2,7 @@ import type { BIP32Interface } from 'bip32'; import type { Network, Payment } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; -import type { IAccount } from '../../keyring'; +import type { IAccount } from '../../../keyring'; import type { ScriptType } from '../constants'; export type IBtcAccountDeriver = { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts index 84457653..d2f0cd04 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts @@ -33,7 +33,20 @@ describe('BtcWallet', () => { }; describe('unlock', () => { - it('returns an `Account` objec with P2wpkh type', async () => { + it('returns an `Account` objec with type bip122:p2wpkh', async () => { + const network = networks.testnet; + const { rootSpy, childSpy, instance, rootNode } = + createMockWallet(network); + const idx = 0; + + const result = await instance.unlock(idx, `bip122:p2wpkh`); + + expect(result).toBeInstanceOf(P2WPKHAccount); + expect(rootSpy).toHaveBeenCalledWith(P2WPKHAccount.path); + expect(childSpy).toHaveBeenCalledWith(rootNode, idx); + }); + + it('returns an `Account` objec with type `p2wpkh`', async () => { const network = networks.testnet; const { rootSpy, childSpy, instance, rootNode } = createMockWallet(network); @@ -46,7 +59,7 @@ describe('BtcWallet', () => { expect(childSpy).toHaveBeenCalledWith(rootNode, idx); }); - it('returns an `Account` object with P2shP2wkh type', async () => { + it('returns an `Account` object with type `p2shp2wkh`', async () => { const network = networks.testnet; const { rootSpy, childSpy, instance, rootNode } = createMockWallet(network); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts index f71518d0..109d7e3b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts @@ -3,10 +3,10 @@ import type { BIP32Interface } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; import { type Buffer } from 'buffer'; +import type { IAccountSigner } from '../../../keyring'; +import { type IAccount, type IWallet } from '../../../keyring'; import { compactError } from '../../../utils'; import type { TransactionIntent } from '../../chain/types'; -import type { IAccountSigner } from '../../keyring'; -import { type IAccount, type IWallet } from '../../keyring'; import { ScriptType } from '../constants'; import { P2WPKHAccount, P2SHP2WPKHAccount } from './account'; import { WalletError } from './exceptions'; @@ -24,7 +24,11 @@ export class BtcWallet implements IWallet { } protected getAccountCtor(type: string): IStaticBtcAccount { - switch (type.toLowerCase()) { + let scriptType = type; + if (type.includes('bip122:')) { + scriptType = type.split(':')[1]; + } + switch (scriptType.toLowerCase()) { case ScriptType.P2wpkh.toLowerCase(): return P2WPKHAccount; case ScriptType.P2shP2wkh.toLowerCase(): diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts index 87d77c73..a9f0c017 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts @@ -25,6 +25,8 @@ export type Fees = { export type TransactionIntent = { amounts: Record; + subtractFeeFrom: string[]; + replaceable: boolean; }; export type Pagination = { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/exceptions/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/exception/exceptions.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/exceptions/exceptions.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/exception/exceptions.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/exceptions/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/exception/index.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/exceptions/index.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/exception/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts b/merged-packages/bitcoin-wallet-snap/src/modules/rpc/base.ts similarity index 93% rename from merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/rpc/base.ts index a032dd88..31cf4d39 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/base.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/rpc/base.ts @@ -1,6 +1,6 @@ import { type Struct, assert } from 'superstruct'; -import { logger } from '../modules/logger/logger'; +import { logger } from '../logger/logger'; import { SnapRpcValidationError } from './exceptions'; import { type ISnapRpcExecutable, @@ -73,9 +73,6 @@ export abstract class BaseSnapRpcHandler implements ISnapRpcExecutable { this: IStaticSnapRpcHandler, options?: SnapRpcHandlerOptions, ): ISnapRpcHandler { - if (this.instance === null) { - this.instance = new this(options); - } - return this.instance; + return new this(options); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/rpc/exceptions.ts similarity index 67% rename from merged-packages/bitcoin-wallet-snap/src/rpcs/exceptions.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/rpc/exceptions.ts index 9f25ea65..029ccf23 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/rpc/exceptions.ts @@ -1,4 +1,4 @@ -import { CustomError } from '../modules/exceptions'; +import { CustomError } from '../exception'; export class SnapRpcError extends CustomError {} export class SnapRpcValidationError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/rpc/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/rpc/index.ts new file mode 100644 index 00000000..0f4d2283 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/rpc/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './base'; +export * from './exceptions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/rpc/types.ts similarity index 96% rename from merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts rename to merged-packages/bitcoin-wallet-snap/src/modules/rpc/types.ts index 64ecd83a..6c93621d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/rpc/types.ts @@ -2,7 +2,7 @@ import type { Json } from '@metamask/snaps-sdk'; import type { Infer } from 'superstruct'; import { object, type Struct } from 'superstruct'; -import { scopeStruct } from '../utils'; +import { scopeStruct } from '../../utils'; export const SnapRpcHandlerRequestStruct = object({ scope: scopeStruct, diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/snap/exceptions.ts index 276aed22..5999cf38 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/snap/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/exceptions.ts @@ -1,3 +1,3 @@ -import { CustomError } from '../exceptions'; +import { CustomError } from '../exception'; export class StateError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts b/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts index 8ed304c6..509b37fb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts @@ -50,9 +50,6 @@ export abstract class SnapStateManager { }]: transaction is processing, use existing state`, ); await callback(this.#transaction.current); - if (this.#transaction.hasCommited) { - await this.set(this.#transaction.current); - } return; } logger.info( diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts similarity index 50% rename from merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts rename to merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts index e9f4d9b6..8ebe98e4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/create-account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts @@ -1,13 +1,17 @@ import type { KeyringAccount } from '@metamask/keyring-api'; import type { Infer } from 'superstruct'; -import { Config } from '../../config'; -import { Factory } from '../../modules/factory'; -import { BtcKeyring, KeyringStateManager } from '../../modules/keyring'; -import type { StaticImplements } from '../../types/static'; -import { BaseSnapRpcHandler } from '../base'; -import type { IStaticSnapRpcHandler, SnapRpcHandlerResponse } from '../types'; -import { SnapRpcHandlerRequestStruct } from '../types'; +import { Config } from '../config'; +import { BtcKeyring, KeyringStateManager } from '../keyring'; +import { + SnapRpcHandlerRequestStruct, + BaseSnapRpcHandler, +} from '../modules/rpc'; +import type { + IStaticSnapRpcHandler, + SnapRpcHandlerResponse, +} from '../modules/rpc'; +import type { StaticImplements } from '../types/static'; export type CreateAccountParams = Infer< typeof CreateAccountHandler.requestStruct @@ -27,15 +31,11 @@ export class CreateAccountHandler async handleRequest( params: CreateAccountParams, ): Promise { - const keyring = new BtcKeyring( - new KeyringStateManager(), - Factory.createBtcKeyringRpcMapping(), - { - defaultIndex: Config.wallet[Config.chain].defaultAccountIndex, - multiAccount: Config.wallet[Config.chain].enableMultiAccounts, - emitEvents: false, - }, - ); + const keyring = new BtcKeyring(new KeyringStateManager(), { + defaultIndex: Config.wallet[Config.chain].defaultAccountIndex, + multiAccount: Config.wallet[Config.chain].enableMultiAccounts, + emitEvents: false, + }); const account = await keyring.createAccount({ scope: params.scope, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts similarity index 79% rename from merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts rename to merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts index 4d191d30..36c81327 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts @@ -1,13 +1,18 @@ import type { Infer } from 'superstruct'; import { object, string, assign, array, record } from 'superstruct'; -import { satsToBtc } from '../../modules/bitcoin/utils/unit'; -import { Factory } from '../../modules/factory'; -import type { StaticImplements } from '../../types/static'; -import { assetsStruct, numberStringStruct } from '../../utils/superstruct'; -import { BaseSnapRpcHandler } from '../base'; -import type { IStaticSnapRpcHandler, SnapRpcHandlerResponse } from '../types'; -import { SnapRpcHandlerRequestStruct } from '../types'; +import { Factory } from '../factory'; +import { satsToBtc } from '../modules/bitcoin/utils/unit'; +import { + SnapRpcHandlerRequestStruct, + BaseSnapRpcHandler, +} from '../modules/rpc'; +import type { + IStaticSnapRpcHandler, + SnapRpcHandlerResponse, +} from '../modules/rpc'; +import type { StaticImplements } from '../types/static'; +import { assetsStruct, numberStringStruct } from '../utils/superstruct'; export type GetBalancesParams = Infer; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts new file mode 100644 index 00000000..da662b6f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts @@ -0,0 +1,26 @@ +import { CreateAccountHandler } from './create-account'; +import { GetBalancesHandler } from './get-balances'; +import { RpcHelper } from './helpers'; +import { SendManyHandler } from './sendmany'; + +describe('RpcHelper', () => { + describe('getChainRpcApiHandlers', () => { + it('returns handler', () => { + expect(RpcHelper.getChainRpcApiHandlers()).toStrictEqual({ + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_createAccount: CreateAccountHandler, + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_getBalances: GetBalancesHandler, + }); + }); + }); + + describe('getKeyringRpcApiHandlers', () => { + it('returns handler', () => { + expect(RpcHelper.getKeyringRpcApiHandlers()).toStrictEqual({ + // eslint-disable-next-line @typescript-eslint/naming-convention + btc_sendmany: SendManyHandler, + }); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts index 90bbecfc..0c83a8b7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts @@ -1,17 +1,21 @@ -import { MethodNotFoundError } from '@metamask/snaps-sdk'; - -import { CreateAccountHandler, GetBalancesHandler } from './methods'; -import type { IStaticSnapRpcHandler } from './types'; +import { CreateAccountHandler, GetBalancesHandler } from '.'; +import type { IStaticSnapRpcHandler } from '../modules/rpc'; +import { SendManyHandler } from './sendmany'; export class RpcHelper { - static getChainApiHandler(method: string): IStaticSnapRpcHandler { - switch (method) { - case 'chain_createAccount': - return CreateAccountHandler; - case 'chain_getBalances': - return GetBalancesHandler; - default: - throw new MethodNotFoundError() as unknown as Error; - } + static getChainRpcApiHandlers(): Record { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_createAccount: CreateAccountHandler, + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_getBalances: GetBalancesHandler, + }; + } + + static getKeyringRpcApiHandlers(): Record { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + btc_sendmany: SendManyHandler, + }; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts index 34dcf6e5..53521af2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts @@ -1,5 +1,3 @@ -export * from './methods'; -export * from './types'; -export * from './base'; +export * from './create-account'; +export * from './get-balances'; export * from './helpers'; -export * from './exceptions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/index.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/index.ts deleted file mode 100644 index 14eb7510..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './create-account'; -export * from './get-balances'; -export * from './send-transaction'; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts deleted file mode 100644 index b96c7f50..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/methods/send-transaction.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { object, string, assign, type Infer, record } from 'superstruct'; - -import type { StaticImplements } from '../../types/static'; -import { numberStringStruct } from '../../utils'; -import { BaseSnapRpcHandler } from '../base'; -import { - SnapRpcHandlerRequestStruct, - type IStaticSnapRpcHandler, - type SnapRpcHandlerResponse, -} from '../types'; - -export type SendTransactionParams = Infer< - typeof SendTransactionHandler.requestStruct ->; - -export type SendTransactionResponse = SnapRpcHandlerResponse; - -export class SendTransactionHandler - extends BaseSnapRpcHandler - implements - StaticImplements -{ - static override get requestStruct() { - return assign( - object({ - account: string(), - intent: object({ - amounts: record(string(), numberStringStruct), - }), - }), - SnapRpcHandlerRequestStruct, - ); - } - - async handleRequest( - /* eslint-disable */ - params: SendTransactionParams, - /* eslint-disable */ - ): Promise { - throw new Error('Method not implemented'); - // const { scope, account: address, intent } = params; - // const transactionIntent: TransactionIntent = Object.entries( - // intent.amounts, - // ).reduce( - // (acc, [account, amount]) => { - // acc.amounts[account] = parseInt(amount, 10); // assume satoshi - // return acc; - // }, - // { amounts: {} }, - // ); - - // // TODO: Get account by address or pass account object from Keyring - // const wallet = Factory.createWallet(scope); - // const account = await wallet.unlock(0); - // if (!account || account.address !== address) { - // throw new Error('Account not found'); - // } - - // const chainApi = Factory.createOnChainServiceProvider(scope); - - // const feesResp = await chainApi.estimateFees(); - - // const fee = await this.getFeeConsensus(feesResp); - - // const data = await chainApi.getDataForTransaction( - // address, - // transactionIntent, - // ); - - // const { txn, txnJson } = await wallet.createTransaction( - // account, - // transactionIntent, - // { - // metadata: data, - // fee, - // }, - // ); - - // if ((await this.getTxnConsensus(txnJson)) === false) { - // throw new UserRejectedRequestError(); - // } - - // const txnHash = await account.signTransaction(txn); - - // return await chainApi.boardcastTransaction(txnHash); - } - - // protected async getFeeConsensus(fees: Fees): Promise { - // // TODO: Ask user to confirm fee - // return fees.fees[0].rate; - // } - - // protected async getTxnConsensus( - // txnJson: Record, - // ): Promise { - // // TODO: Ask user to confirm txn - // return true; - // } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts new file mode 100644 index 00000000..8c333dd8 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -0,0 +1,137 @@ +import type { Json } from '@metamask/snaps-sdk'; +import { + object, + string, + assign, + type Infer, + record, + array, + boolean, +} from 'superstruct'; + +import { Factory } from '../factory'; +import type { IAccount, IWallet } from '../keyring'; +import { type Wallet as WalletData } from '../keyring'; +import type { Fees, TransactionIntent } from '../modules/chain'; +import { + SnapRpcHandlerRequestStruct, + BaseSnapRpcHandler, +} from '../modules/rpc'; +import type { + IStaticSnapRpcHandler, + SnapRpcHandlerRequest, + SnapRpcHandlerResponse, +} from '../modules/rpc'; +import type { StaticImplements } from '../types/static'; +import { numberStringStruct } from '../utils'; + +export type SendManyParams = Infer; + +export type SendManyResponse = SnapRpcHandlerResponse; + +export class SendManyHandler + extends BaseSnapRpcHandler + implements StaticImplements +{ + walletData: WalletData; + + wallet: IWallet; + + walletAccount: IAccount; + + constructor(walletData: WalletData) { + super(); + this.walletData = walletData; + } + + static override get requestStruct() { + return assign( + object({ + amounts: record(string(), numberStringStruct), + comment: string(), + subtractFeeFrom: array(string()), + replaceable: boolean(), + }), + SnapRpcHandlerRequestStruct, + ); + } + + protected override async preExecute( + params: SnapRpcHandlerRequest, + ): Promise { + await super.preExecute(params); + + const { scope, index, account } = this.walletData; + const wallet = Factory.createWallet(scope); + const unlocked = await wallet.unlock(index, account.type); + if (!unlocked || unlocked.address !== account.address) { + throw new Error('Account not found'); + } + + this.walletAccount = unlocked; + this.wallet = wallet; + } + + async handleRequest(params: SendManyParams): Promise { + const { scope } = this.walletData; + const chainApi = Factory.createOnChainServiceProvider(scope); + const transactionIntent = this.formatTxnIndents(params); + + const feesResp = await chainApi.estimateFees(); + + const fee = await this.getFeeConsensus(feesResp); + + const metadata = await chainApi.getDataForTransaction( + this.walletAccount.address, + transactionIntent, + ); + + const { txn, txnJson } = await this.wallet.createTransaction( + this.walletAccount, + transactionIntent, + { + metadata, + fee, + }, + ); + + if (!(await this.getTxnConsensus(txnJson))) { + throw new Error('User denied transaction request'); + } + + const txnHash = await this.wallet.signTransaction( + this.walletAccount.signer, + txn, + ); + + return await chainApi.boardcastTransaction(txnHash); + } + + protected formatTxnIndents(params: SendManyParams): TransactionIntent { + const { amounts, subtractFeeFrom, replaceable } = params; + return Object.entries(amounts).reduce( + (acc, [account, amount]) => { + acc[account] = parseInt(amount, 10); // assume satoshi + return acc; + }, + { + amounts: {}, + subtractFeeFrom, + replaceable, + }, + ); + } + + protected async getFeeConsensus(fees: Fees): Promise { + // TODO: Ask user to confirm fee + return fees.fees[0].rate; + } + + protected async getTxnConsensus( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + txnJson: Record, + ): Promise { + // TODO: Ask user to confirm txn + return true; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/types/state.ts b/merged-packages/bitcoin-wallet-snap/src/types/state.ts deleted file mode 100644 index b313e646..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/types/state.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; - -export type Wallet = { - account: KeyringAccount; - type: string; - index: number; - scope: string; -}; - -export type Wallets = Record; - -export type SnapState = { - walletIds: string[]; - wallets: Wallets; -}; diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index bb6a94a8..1b7cade9 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -35,7 +35,7 @@ export function generateAccounts(cnt = 1, addressPrefix = '', idPrefix = '') { scope: NetworkEnum.Testnet, index: i, }, - methods: ['btc_sendTransaction'], + methods: ['btc_sendmany'], }); } From 447b4a3df1318040fdff95e928153df3a49c4ceb Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 20 May 2024 18:34:38 +0800 Subject: [PATCH 031/362] feat: add chain API - chain_estimateFees (#18) * feat: add fee estimate * fix: lint issue * fix: test and lint issue * fix: update import type for estimate fee * Update base.ts * fix: use latest code error * Update estimate-fees.ts * chore: rebase changes * chore: add unit test * chore: update FE and convert sats to btc for estimate fee --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/config/permissions.ts | 3 + .../src/modules/bitcoin/chain/service.test.ts | 48 ++++++++- .../src/modules/bitcoin/chain/service.ts | 25 +++-- .../data-client/clients/blockchair.test.ts | 48 +++++++++ .../bitcoin/data-client/clients/blockchair.ts | 97 ++++++++++++++----- .../data-client/clients/blockstream.test.ts | 49 ++++++++++ .../data-client/clients/blockstream.ts | 38 +++++++- .../src/modules/bitcoin/data-client/types.ts | 6 ++ .../src/rpcs/estimate-fees.test.ts | 84 ++++++++++++++++ .../src/rpcs/estimate-fees.ts | 61 ++++++++++++ .../src/rpcs/helper.test.ts | 3 + .../bitcoin-wallet-snap/src/rpcs/helpers.ts | 3 + .../test/fixtures/blockchair.json | 66 ++++++++----- .../test/fixtures/blockstream.json | 30 ++++++ .../bitcoin-wallet-snap/test/utils.ts | 34 +++++++ 16 files changed, 535 insertions(+), 62 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index ad9c4e53..d1effe1d 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "rLfn/qFNzc5cwG+Ives5d8n0hmKK5A2AGP4Zv+SDiK8=", + "shasum": "oUPt1YGfl2raWm3t8NE/tjy9A62eKi8UL359cbdeK7o=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts index abf1f13b..c4f87213 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts @@ -15,6 +15,7 @@ export const originPermissions = new Map>([ KeyringRpcMethod.RejectRequest, // Chain API methods 'chain_getBalances', + 'chain_estimateFees', ]), ], [ @@ -34,6 +35,7 @@ export const originPermissions = new Map>([ // Chain API methods 'chain_getBalances', 'chain_createAccount', + 'chain_estimateFees', ]), ], [ @@ -52,6 +54,7 @@ export const originPermissions = new Map>([ KeyringRpcMethod.RejectRequest, // Chain API methods 'chain_getBalances', + 'chain_estimateFees', ]), ], ]); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts index d9d5e049..91d088b7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts @@ -2,20 +2,25 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; import { generateAccounts } from '../../../../test/utils'; +import { FeeRatio } from '../../chain'; import { BtcAsset } from '../constants'; import type { IReadDataClient } from '../data-client'; +import { BtcOnChainServiceError } from './exceptions'; import { BtcOnChainService } from './service'; describe('BtcOnChainService', () => { const createMockReadDataClient = () => { const getBalanceSpy = jest.fn(); - + const getFeeRatesSpy = jest.fn(); class MockReadDataClient implements IReadDataClient { getBalances = getBalanceSpy; + + getFeeRates = getFeeRatesSpy; } return { instance: new MockReadDataClient(), getBalanceSpy, + getFeeRatesSpy, }; }; @@ -86,4 +91,45 @@ describe('BtcOnChainService', () => { ).rejects.toThrow('Invalid asset'); }); }); + + describe('estimateFees', () => { + it('return estimateFees result', async () => { + const { instance, getFeeRatesSpy } = createMockReadDataClient(); + const { instance: txnMgr } = createMockBtcService(instance); + getFeeRatesSpy.mockResolvedValue({ + [FeeRatio.Fast]: 1.1, + [FeeRatio.Medium]: 1.2, + }); + + const result = await txnMgr.estimateFees(); + + expect(getFeeRatesSpy).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual({ + fees: [ + { + type: FeeRatio.Fast, + rate: 1.1, + }, + { + type: FeeRatio.Medium, + rate: 1.2, + }, + ], + }); + }); + + it('throws BtcOnChainServiceError error if an error catched', async () => { + const { instance, getFeeRatesSpy } = createMockReadDataClient(); + const { instance: txnMgr } = createMockBtcService( + instance, + networks.bitcoin, + ); + + getFeeRatesSpy.mockRejectedValue(new Error('error')); + + await expect(txnMgr.estimateFees()).rejects.toThrow( + BtcOnChainServiceError, + ); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts index 05a05478..465aa525 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts @@ -9,7 +9,8 @@ import type { TransactionIntent, Pagination, Fees, -} from '../../chain/types'; + FeeRatio, +} from '../../chain'; import { BtcAsset } from '../constants'; import { type IReadDataClient } from '../data-client'; import { BtcOnChainServiceError } from './exceptions'; @@ -38,9 +39,7 @@ export class BtcOnChainService implements IOnChainService { throw new BtcOnChainServiceError('Only one asset is supported'); } - const allowedAssets = new Set( - Object.entries(BtcAsset).map(([_, value]) => value.toString()), - ); + const allowedAssets = new Set(Object.values(BtcAsset)); if ( !allowedAssets.has(assets[0]) || @@ -67,11 +66,25 @@ export class BtcOnChainService implements IOnChainService { throw compactError(error, BtcOnChainServiceError); } } - /* eslint-disable */ + async estimateFees(): Promise { - throw new Error('Method not implemented.'); + try { + const result = await this.readClient.getFeeRates(); + + return { + fees: Object.entries(result).map( + ([key, value]: [key: FeeRatio, value: number]) => ({ + type: key, + rate: value, + }), + ), + }; + } catch (error) { + throw new BtcOnChainServiceError(error); + } } + /* eslint-disable */ boardcastTransaction(txn: string) { throw new Error('Method not implemented.'); } diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts index d0754e49..da2e8345 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts @@ -3,7 +3,9 @@ import { networks } from 'bitcoinjs-lib'; import { generateAccounts, generateBlockChairGetBalanceResp, + generateBlockChairGetStatsResp, } from '../../../../../test/utils'; +import { FeeRatio } from '../../../chain'; import { DataClientError } from '../exceptions'; import { BlockChairClient } from './blockchair'; @@ -175,4 +177,50 @@ describe('BlockChairClient', () => { ); }); }); + + describe('getFeeRates', () => { + it('returns fee rate', async () => { + const { fetchSpy } = createMockFetch(); + const mockResponse = generateBlockChairGetStatsResp(); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const instance = new BlockChairClient({ network: networks.testnet }); + const result = await instance.getFeeRates(); + + expect(result).toStrictEqual({ + [FeeRatio.Fast]: + mockResponse.data.suggested_transaction_fee_per_byte_sat, + }); + }); + + it('throws DataClientError error if an non DataClientError catched', async () => { + const { fetchSpy } = createMockFetch(); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockRejectedValue(new Error('error')), + }); + + const instance = new BlockChairClient({ network: networks.testnet }); + + await expect(instance.getFeeRates()).rejects.toThrow(DataClientError); + }); + + it('throws DataClientError error if an DataClientError catched', async () => { + const { fetchSpy } = createMockFetch(); + + fetchSpy.mockResolvedValueOnce({ + ok: false, + json: jest.fn().mockResolvedValue(null), + }); + + const instance = new BlockChairClient({ network: networks.testnet }); + + await expect(instance.getFeeRates()).rejects.toThrow(DataClientError); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts index 3764f601..f8780deb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts @@ -1,10 +1,10 @@ import { type Network, networks } from 'bitcoinjs-lib'; import { compactError } from '../../../../utils'; -import { type Balances } from '../../../chain'; +import { type Balances, FeeRatio } from '../../../chain'; import { logger } from '../../../logger/logger'; import { DataClientError } from '../exceptions'; -import type { IReadDataClient } from '../types'; +import type { GetFeeRatesResp, IReadDataClient } from '../types'; export type BlockChairClientOptions = { network: Network; @@ -12,35 +12,56 @@ export type BlockChairClientOptions = { }; /* eslint-disable */ +export type LargestTransaction = { + hash: string; + value_usd: number; +}; + export type GetBalanceResponse = { data: { [address: string]: number; }; - context: { - code: number; - source: string; - results: number; - state: number; +}; + +export type GetStatResponse = { + data: { + blocks: number; + transactions: number; + outputs: number; + circulation: number; + blocks_24h: number; + transactions_24h: number; + difficulty: number; + volume_24h: number; + mempool_transactions: number; + mempool_size: number; + mempool_tps: number; + mempool_total_fee_usd: number; + best_block_height: number; + best_block_hash: string; + best_block_time: string; + blockchain_size: number; + average_transaction_fee_24h: number; + inflation_24h: number; + median_transaction_fee_24h: number; + cdd_24h: number; + mempool_outputs: number; + largest_transaction_24h: LargestTransaction; + nodes: number; + hashrate_24h: string; + inflation_usd_24h: number; + average_transaction_fee_usd_24h: number; + median_transaction_fee_usd_24h: number; market_price_usd: number; - cache: { - live: boolean; - duration: number; - since: string; - until: string; - time: null; - }; - api: { - version: string; - last_major_update: string; - next_major_update: string; - documentation: string; - notice: string; - }; - servers: string; - time: number; - render_time: number; - full_time: number; - request_cost: number; + market_price_btc: number; + market_price_usd_change_24h_percentage: number; + market_cap_usd: number; + market_dominance_percentage: number; + next_retarget_time_estimate: string; + next_difficulty_estimate: number; + countdowns: never[]; + suggested_transaction_fee_per_byte_sat: number; + hodling_addresses: number; }; }; /* eslint-disable */ @@ -84,6 +105,12 @@ export class BlockChairClient implements IReadDataClient { async getBalances(addresses: string[]): Promise { try { + logger.info( + `[BlockChairClient.getBalance] start: { addresses : ${JSON.stringify( + addresses, + )} }`, + ); + const response = await this.get( `/addresses/balances?addresses=${addresses.join(',')}`, ); @@ -100,4 +127,22 @@ export class BlockChairClient implements IReadDataClient { throw compactError(error, DataClientError); } } + + async getFeeRates(): Promise { + try { + logger.info(`[BlockChairClient.getFeeRates] start:`); + const response = await this.get(`/stats`); + logger.info( + `[BlockChairClient.getFeeRates] response: ${JSON.stringify(response)}`, + ); + return { + [FeeRatio.Fast]: response.data.suggested_transaction_fee_per_byte_sat, + }; + } 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/clients/blockstream.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.test.ts index 9eea0abd..ada5033b 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 @@ -3,8 +3,10 @@ import { networks } from 'bitcoinjs-lib'; import { generateAccounts, generateBlockStreamAccountStats, + generateBlockStreamEstFeeResp, } from '../../../../../test/utils'; import * as asyncUtils from '../../../../utils/async'; +import { FeeRatio } from '../../../chain'; import { DataClientError } from '../exceptions'; import { BlockStreamClient } from './blockstream'; @@ -97,4 +99,51 @@ describe('BlockStreamClient', () => { ); }); }); + + describe('getFeeRates', () => { + it('returns fee rate', async () => { + const { fetchSpy } = createMockFetch(); + const mockResponse = generateBlockStreamEstFeeResp(); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + const result = await instance.getFeeRates(); + + expect(result).toStrictEqual({ + [FeeRatio.Fast]: mockResponse['1'], + [FeeRatio.Medium]: mockResponse['25'], + [FeeRatio.Slow]: mockResponse['144'], + }); + }); + + it('throws DataClientError error if an non DataClientError catched', async () => { + const { fetchSpy } = createMockFetch(); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockRejectedValue(new Error('error')), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + + await expect(instance.getFeeRates()).rejects.toThrow(DataClientError); + }); + + it('throws DataClientError error if an DataClientError catched', async () => { + const { fetchSpy } = createMockFetch(); + + fetchSpy.mockResolvedValueOnce({ + ok: false, + json: jest.fn().mockResolvedValue(null), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + + await expect(instance.getFeeRates()).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 88874755..9b4cd9f3 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 @@ -1,16 +1,18 @@ import { type Network, networks } from 'bitcoinjs-lib'; import { compactError, processBatch } from '../../../../utils'; -import { type Balances } from '../../../chain'; +import { type Balances, FeeRatio } from '../../../chain'; import { logger } from '../../../logger/logger'; import { DataClientError } from '../exceptions'; -import type { IReadDataClient } from '../types'; +import type { GetFeeRatesResp, IReadDataClient } from '../types'; export type BlockStreamClientOptions = { network: Network; }; /* eslint-disable */ +export type GetFeeEstimateResponse = Record; + export type GetAddressStatsResponse = { address: string; chain_stats: { @@ -31,10 +33,17 @@ export type GetAddressStatsResponse = { /* eslint-enable */ export class BlockStreamClient implements IReadDataClient { - options: BlockStreamClientOptions; + protected readonly options: BlockStreamClientOptions; + + protected readonly feeRateRatioMap: Record; constructor(options: BlockStreamClientOptions) { this.options = options; + this.feeRateRatioMap = { + [FeeRatio.Fast]: '1', + [FeeRatio.Medium]: '25', + [FeeRatio.Slow]: '144', + }; } get baseUrl(): string { @@ -93,4 +102,27 @@ export class BlockStreamClient implements IReadDataClient { throw compactError(error, DataClientError); } } + + async getFeeRates(): Promise { + try { + logger.info(`[BlockStreamClient.getFeeRates] start:`); + const response = await this.get(`/fee-estimates`); + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.info( + `[BlockStreamClient.getFeeRates] response: ${JSON.stringify(response)}`, + ); + return { + [FeeRatio.Fast]: response[this.feeRateRatioMap[FeeRatio.Fast]], + [FeeRatio.Medium]: response[this.feeRateRatioMap[FeeRatio.Medium]], + [FeeRatio.Slow]: response[this.feeRateRatioMap[FeeRatio.Slow]], + }; + } catch (error) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.info(`[BlockStreamClient.getFeeRates] error: ${error.message}`); + if (error instanceof DataClientError) { + throw error; + } + throw new DataClientError(error); + } + } } 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 index 7b3a96b2..d61987ad 100644 --- 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 @@ -1,7 +1,13 @@ +import type { FeeRatio } from '../../chain'; import { type Balances } from '../../chain'; +export type GetFeeRatesResp = { + [key in FeeRatio]?: number; +}; + export type IReadDataClient = { getBalances(address: string[]): Promise; + getFeeRates(): Promise; }; export type IWriteDataClient = { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.test.ts new file mode 100644 index 00000000..ac24200d --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.test.ts @@ -0,0 +1,84 @@ +import { Factory } from '../factory'; +import { Network } from '../modules/bitcoin/constants'; +import { satsToBtc } from '../modules/bitcoin/utils/unit'; +import { FeeRatio } from '../modules/chain'; +import { EstimateFeesHandler } from './estimate-fees'; + +jest.mock('../modules/logger/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + }, +})); + +describe('EstimateFeesHandler', () => { + describe('handleRequest', () => { + const createMockChainApiFactory = () => { + const estimateFeesSpy = jest.fn(); + + jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ + estimateFees: estimateFeesSpy, + getBalances: jest.fn(), + boardcastTransaction: jest.fn(), + listTransactions: jest.fn(), + getTransaction: jest.fn(), + getDataForTransaction: jest.fn(), + }); + return { + estimateFeesSpy, + }; + }; + + it('returns correct result', async () => { + const { estimateFeesSpy } = createMockChainApiFactory(); + + estimateFeesSpy.mockResolvedValue({ + fees: [ + { + type: FeeRatio.Fast, + rate: 10, + }, + { + type: FeeRatio.Medium, + rate: 5, + }, + { + type: FeeRatio.Slow, + rate: 1, + }, + ], + }); + + const result = await EstimateFeesHandler.getInstance().execute({ + scope: Network.Testnet, + }); + + expect(result).toStrictEqual({ + fees: [ + { + type: FeeRatio.Fast, + rate: satsToBtc(10), + }, + { + type: FeeRatio.Medium, + rate: satsToBtc(5), + }, + { + type: FeeRatio.Slow, + rate: satsToBtc(1), + }, + ], + }); + }); + + it('throws `Request params is invalid` when request parameter is not correct', async () => { + createMockChainApiFactory(); + + await expect( + EstimateFeesHandler.getInstance().execute({ + scope: 'some invalid value', + }), + ).rejects.toThrow('Request params is invalid'); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.ts new file mode 100644 index 00000000..bf636fa2 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.ts @@ -0,0 +1,61 @@ +import type { Infer } from 'superstruct'; +import { object, array, enums } from 'superstruct'; + +import { Factory } from '../factory'; +import { satsToBtc } from '../modules/bitcoin/utils/unit'; +import { FeeRatio } from '../modules/chain'; +import { + type IStaticSnapRpcHandler, + type SnapRpcHandlerResponse, + SnapRpcHandlerRequestStruct, + BaseSnapRpcHandler, +} from '../modules/rpc'; +import type { StaticImplements } from '../types/static'; +import { numberStringStruct } from '../utils'; + +export type EstimateFeesParams = Infer< + typeof EstimateFeesHandler.requestStruct +>; + +export type EstimateFeesResponse = SnapRpcHandlerResponse & + Infer; + +export class EstimateFeesHandler + extends BaseSnapRpcHandler + implements + StaticImplements +{ + static override get requestStruct() { + return SnapRpcHandlerRequestStruct; + } + + static override get responseStruct() { + return object({ + fees: array( + object({ + type: enums(Object.values(FeeRatio)), + rate: numberStringStruct, + }), + ), + }); + } + + async handleRequest( + params: EstimateFeesParams, + ): Promise { + const { scope } = params; + + const chainApi = Factory.createOnChainServiceProvider(scope); + + const fees = await chainApi.estimateFees(); + + const response = { + fees: fees.fees.map((fee) => ({ + type: fee.type, + rate: satsToBtc(fee.rate), + })), + }; + + return response; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts index da662b6f..fac65a33 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts @@ -1,4 +1,5 @@ import { CreateAccountHandler } from './create-account'; +import { EstimateFeesHandler } from './estimate-fees'; import { GetBalancesHandler } from './get-balances'; import { RpcHelper } from './helpers'; import { SendManyHandler } from './sendmany'; @@ -11,6 +12,8 @@ describe('RpcHelper', () => { chain_createAccount: CreateAccountHandler, // eslint-disable-next-line @typescript-eslint/naming-convention chain_getBalances: GetBalancesHandler, + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_estimateFees: EstimateFeesHandler, }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts index 0c83a8b7..797c832e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts @@ -1,5 +1,6 @@ import { CreateAccountHandler, GetBalancesHandler } from '.'; import type { IStaticSnapRpcHandler } from '../modules/rpc'; +import { EstimateFeesHandler } from './estimate-fees'; import { SendManyHandler } from './sendmany'; export class RpcHelper { @@ -9,6 +10,8 @@ export class RpcHelper { chain_createAccount: CreateAccountHandler, // eslint-disable-next-line @typescript-eslint/naming-convention chain_getBalances: GetBalancesHandler, + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_estimateFees: EstimateFeesHandler, }; } diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json index d9cee728..3fb597c7 100644 --- a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json +++ b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json @@ -1,31 +1,47 @@ { "getBalanceResp": { - "data": {}, - "context": { - "code": 200, - "source": "A", - "results": 2, - "state": 2587556, - "market_price_usd": 62471, - "cache": { - "live": true, - "duration": 20, - "since": "2024-04-19 01:15:47", - "until": "2024-04-19 01:16:07", - "time": null + "data": {} + }, + "getStatsResp": { + "data": { + "blocks": 2628955, + "transactions": 87249891, + "outputs": 236025835, + "circulation": 2099566938801961, + "blocks_24h": 19893, + "transactions_24h": 2311448, + "difficulty": 16384, + "volume_24h": 132646837532010, + "mempool_transactions": 146, + "mempool_size": 79331, + "mempool_tps": 0, + "mempool_total_fee_usd": 0, + "best_block_height": 2628954, + "best_block_hash": "0000000000000305719556770fef78ba95b2ed376d0ce4d17f6a91f69d6cfb76", + "best_block_time": "2024-04-23 11:03:12", + "blockchain_size": 37448283346, + "average_transaction_fee_24h": 9691, + "inflation_24h": 24283444779, + "median_transaction_fee_24h": 8146, + "cdd_24h": 202902.24909198366, + "mempool_outputs": 1704, + "largest_transaction_24h": { + "hash": "e2f2c5483fc5e11fcc20a2221a784d2d8d3e26e15a8a2eb346b9a5b65682e001", + "value_usd": 0 }, - "api": { - "version": "2.0.95-ie", - "last_major_update": "2022-11-07 02:00:00", - "next_major_update": "2023-11-12 02:00:00", - "documentation": "https://blockchair.com/api/docs", - "notice": "Plaese note that on November 12th, 2023 public support for the following blockchains will be dropped: Mixin, Ethereum Testnet (Goerli)" - }, - "servers": "API4,TBTC3", - "time": 0.4156050682067871, - "render_time": 0.0018379688262939453, - "full_time": 0.41744303703308105, - "request_cost": 1.003 + "hashrate_24h": "17592186044416", + "inflation_usd_24h": 0, + "average_transaction_fee_usd_24h": 0, + "median_transaction_fee_usd_24h": 0, + "market_price_usd": 0, + "market_price_btc": 0, + "market_price_usd_change_24h_percentage": 0, + "market_cap_usd": 0, + "market_dominance_percentage": 0, + "next_retarget_time_estimate": "2024-04-23 11:52:16", + "next_difficulty_estimate": 5461, + "suggested_transaction_fee_per_byte_sat": 1, + "hodling_addresses": 11723857 } } } diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json index ff3211e7..889ba3df 100644 --- a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json +++ b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json @@ -15,5 +15,35 @@ "spent_txo_sum": 0, "tx_count": 0 } + }, + "feeEstimateResp": { + "22": 52.373999999999995, + "6": 52.373999999999995, + "144": 46.793, + "504": 46.793, + "15": 52.373999999999995, + "19": 52.373999999999995, + "7": 52.373999999999995, + "21": 52.373999999999995, + "10": 55.343, + "3": 46.793, + "11": 55.343, + "24": 52.373999999999995, + "17": 52.373999999999995, + "5": 52.373999999999995, + "4": 52.375, + "1": 46.793, + "1008": 46.793, + "9": 52.373999999999995, + "12": 52.373999999999995, + "16": 52.373999999999995, + "18": 52.373999999999995, + "20": 52.373999999999995, + "2": 46.793, + "14": 52.373999999999995, + "13": 52.373999999999995, + "23": 52.373999999999995, + "25": 52.373999999999995, + "8": 52.373999999999995 } } diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index 1b7cade9..a77425cd 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -196,3 +196,37 @@ export function generateBlockChairGetBalanceResp(addresses: string[]) { } return resp; } + +/** + * Method to generate blockchair getStats resp. + * + * @returns A blockchair getStats resp. + */ +export function generateBlockChairGetStatsResp() { + const template = blockChairData.getStatsResp; + const resp: typeof template = { ...template }; + Object.entries(template.data).forEach(([key, value]) => { + if (typeof value === 'number') { + if (value === 0) { + resp.data[key] = randomNum(100); + } + resp.data[key] = randomNum(value); + } + }); + resp.data['suggested_transaction_fee_per_byte_sat'] = randomNum(20); + return resp; +} + +/** + * Method to generate blockstream estimate fee resp. + * + * @returns A blockstream estimate fee resp. + */ +export function generateBlockStreamEstFeeResp() { + const template = blockStreamData.feeEstimateResp; + const resp: typeof template = { ...template }; + Object.keys(template).forEach((key) => { + resp[key] = Math.min(0.1, randomNum(40)); + }); + return resp; +} From 22abc84416f6a3f0114944fd3585fa415d1a8352 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 20 May 2024 18:51:26 +0800 Subject: [PATCH 032/362] feat: add chain API - chain_getDataForTransaction (#42) * feat: add chain api - get data for transaction * feat: implement get data for transaction * fix: fix code format style --- .../src/config/permissions.ts | 3 + .../src/modules/bitcoin/chain/service.test.ts | 73 +++++++++++++- .../src/modules/bitcoin/chain/service.ts | 24 ++++- .../data-client/clients/blockchair.test.ts | 80 +++++++++++++++ .../bitcoin/data-client/clients/blockchair.ts | 82 +++++++++++++++- .../data-client/clients/blockstream.test.ts | 97 +++++++++++++++++++ .../data-client/clients/blockstream.ts | 47 ++++++++- .../src/modules/bitcoin/data-client/types.ts | 4 +- .../src/modules/chain/types.ts | 18 +++- .../src/rpcs/get-transaction-data.test.ts | 77 +++++++++++++++ .../src/rpcs/get-transaction-data.ts | 69 +++++++++++++ .../src/rpcs/helper.test.ts | 3 + .../bitcoin-wallet-snap/src/rpcs/helpers.ts | 3 + .../test/fixtures/blockchair.json | 39 ++++++++ .../test/fixtures/blockstream.json | 13 +++ .../bitcoin-wallet-snap/test/utils.ts | 72 ++++++++++++++ 16 files changed, 693 insertions(+), 11 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts index c4f87213..219aecd6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts @@ -15,6 +15,7 @@ export const originPermissions = new Map>([ KeyringRpcMethod.RejectRequest, // Chain API methods 'chain_getBalances', + 'chain_getDataForTransaction', 'chain_estimateFees', ]), ], @@ -35,6 +36,7 @@ export const originPermissions = new Map>([ // Chain API methods 'chain_getBalances', 'chain_createAccount', + 'chain_getDataForTransaction', 'chain_estimateFees', ]), ], @@ -54,6 +56,7 @@ export const originPermissions = new Map>([ KeyringRpcMethod.RejectRequest, // Chain API methods 'chain_getBalances', + 'chain_getDataForTransaction', 'chain_estimateFees', ]), ], diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts index 91d088b7..b72fa5a0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts @@ -1,25 +1,39 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; -import { generateAccounts } from '../../../../test/utils'; +import { + generateAccounts, + generateBlockChairGetUtxosResp, +} from '../../../../test/utils'; import { FeeRatio } from '../../chain'; import { BtcAsset } from '../constants'; import type { IReadDataClient } from '../data-client'; import { BtcOnChainServiceError } from './exceptions'; import { BtcOnChainService } from './service'; +jest.mock('../../logger/logger', () => ({ + logger: { + info: jest.fn(), + }, +})); + describe('BtcOnChainService', () => { const createMockReadDataClient = () => { const getBalanceSpy = jest.fn(); + const getUtxosSpy = jest.fn(); const getFeeRatesSpy = jest.fn(); class MockReadDataClient implements IReadDataClient { getBalances = getBalanceSpy; + getUtxos = getUtxosSpy; + getFeeRates = getFeeRatesSpy; } + return { instance: new MockReadDataClient(), getBalanceSpy, + getUtxosSpy, getFeeRatesSpy, }; }; @@ -92,6 +106,63 @@ describe('BtcOnChainService', () => { }); }); + describe('getUtxos', () => { + it('calls getUtxos with readClient', async () => { + const { instance, getUtxosSpy } = createMockReadDataClient(); + const { instance: txnService } = createMockBtcService(instance); + const accounts = generateAccounts(2); + const sender = accounts[0].address; + const receiver = accounts[1].address; + const mockResponse = generateBlockChairGetUtxosResp(sender, 10); + const utxos = mockResponse.data[sender].utxo.map((utxo) => ({ + block: utxo.block_id, + txnHash: utxo.transaction_hash, + index: utxo.index, + value: utxo.value, + })); + + getUtxosSpy.mockResolvedValue(utxos); + + const result = await txnService.getDataForTransaction(sender, { + amounts: { + [receiver]: 100, + }, + subtractFeeFrom: [], + replaceable: true, + }); + + expect(getUtxosSpy).toHaveBeenCalledWith(sender); + expect(result).toStrictEqual({ + data: { + utxos, + }, + }); + }); + + it('throws error if readClient fail', async () => { + const { instance, getUtxosSpy } = createMockReadDataClient(); + const { instance: txnService } = createMockBtcService( + instance, + networks.bitcoin, + ); + const accounts = generateAccounts(2); + const sender = accounts[0].address; + const receiver = accounts[1].address; + + getUtxosSpy.mockRejectedValue(new Error('error')); + + await expect( + txnService.getDataForTransaction(sender, { + amounts: { + [receiver]: 100, + }, + subtractFeeFrom: [], + replaceable: true, + }), + ).rejects.toThrow(BtcOnChainServiceError); + }); + }); + describe('estimateFees', () => { it('return estimateFees result', async () => { const { instance, getFeeRatesSpy } = createMockReadDataClient(); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts index 465aa525..c971d5a3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts @@ -2,6 +2,7 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; import { compactError } from '../../../utils'; +import type { FeeRatio } from '../../chain'; import type { IOnChainService, Balances, @@ -9,8 +10,8 @@ import type { TransactionIntent, Pagination, Fees, - FeeRatio, -} from '../../chain'; + TransactionData, +} from '../../chain/types'; import { BtcAsset } from '../constants'; import { type IReadDataClient } from '../data-client'; import { BtcOnChainServiceError } from './exceptions'; @@ -96,9 +97,22 @@ export class BtcOnChainService implements IOnChainService { getTransaction(txnHash: string) { throw new Error('Method not implemented.'); } + /* eslint-disable */ - getDataForTransaction(address: string, transactionIntent: TransactionIntent) { - throw new Error('Method not implemented.'); + //eslint-disable-next-line @typescript-eslint/no-unused-vars + async getDataForTransaction( + address: string, + transactionIntent?: TransactionIntent, + ): Promise { + try { + const data = await this.readClient.getUtxos(address); + return { + data: { + utxos: data, + }, + }; + } catch (error) { + throw compactError(error, BtcOnChainServiceError); + } } - /* eslint-disable */ } diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts index da2e8345..3584a8aa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts @@ -3,6 +3,7 @@ import { networks } from 'bitcoinjs-lib'; import { generateAccounts, generateBlockChairGetBalanceResp, + generateBlockChairGetUtxosResp, generateBlockChairGetStatsResp, } from '../../../../../test/utils'; import { FeeRatio } from '../../../chain'; @@ -223,4 +224,83 @@ describe('BlockChairClient', () => { await expect(instance.getFeeRates()).rejects.toThrow(DataClientError); }); }); + + describe('getUtxos', () => { + it('returns utxos', async () => { + const { fetchSpy } = createMockFetch(); + const accounts = generateAccounts(1); + const { address } = accounts[0]; + const mockResponse = generateBlockChairGetUtxosResp(address, 10); + const expectedResult = mockResponse.data[address].utxo.map((utxo) => ({ + block: utxo.block_id, + txnHash: utxo.transaction_hash, + index: utxo.index, + value: utxo.value, + })); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const instance = new BlockChairClient({ network: networks.testnet }); + const result = await instance.getUtxos(address); + + expect(result).toStrictEqual(expectedResult); + }); + + it('fetchs with pagination if utxos more than limit', async () => { + const { fetchSpy } = createMockFetch(); + const accounts = generateAccounts(1); + const { address } = accounts[0]; + + const pagesToTest = 3; + const limit = 1000; + let expectedResult: unknown[] = []; + + for (let i = 0; i < pagesToTest; i++) { + const mockResponse = generateBlockChairGetUtxosResp( + address, + i === pagesToTest - 1 ? 0 : limit, + ); + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + expectedResult = expectedResult.concat( + mockResponse.data[address].utxo.map((utxo) => ({ + block: utxo.block_id, + txnHash: utxo.transaction_hash, + index: utxo.index, + value: utxo.value, + })), + ); + } + + const instance = new BlockChairClient({ network: networks.testnet }); + const result = await instance.getUtxos(address); + + expect(result).toStrictEqual(expectedResult); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it('throws `Data not avaiable` error if given address not found from the result', async () => { + const { fetchSpy } = createMockFetch(); + const accounts = generateAccounts(2); + const requestAddress = accounts[0].address; + const actualRespAddress = accounts[1].address; + const mockResponse = generateBlockChairGetUtxosResp(actualRespAddress, 1); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const instance = new BlockChairClient({ network: networks.testnet }); + + await expect(instance.getUtxos(requestAddress)).rejects.toThrow( + `Data not avaiable`, + ); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts index f8780deb..7ca2143a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts @@ -1,7 +1,7 @@ import { type Network, networks } from 'bitcoinjs-lib'; import { compactError } from '../../../../utils'; -import { type Balances, FeeRatio } from '../../../chain'; +import { type Balances, type Utxo, FeeRatio } from '../../../chain'; import { logger } from '../../../logger/logger'; import { DataClientError } from '../exceptions'; import type { GetFeeRatesResp, IReadDataClient } from '../types'; @@ -64,6 +64,37 @@ export type GetStatResponse = { hodling_addresses: number; }; }; +export type GetUtxosResponse = { + data: { + [address: string]: { + address: { + type: string; + script_hex: string; + balance: number; + balance_usd: number; + received: number; + received_usd: number; + spent: number; + spent_usd: number; + output_count: number; + unspent_output_count: number; + first_seen_receiving: string; + last_seen_receiving: string; + first_seen_spending: string; + last_seen_spending: string; + scripthash_type: null; + transaction_count: null; + }; + transactions: any[]; + utxo: { + block_id: number; + transaction_hash: string; + index: number; + value: number; + }[]; + }; + }; +}; /* eslint-disable */ export class BlockChairClient implements IReadDataClient { @@ -128,6 +159,55 @@ export class BlockChairClient implements IReadDataClient { } } + // TODO: add logic to get UTXOs that sufficiently cover the amount, to reduce the number of requests + async getUtxos( + address: string, + includeUnconfirmed?: boolean, + ): Promise { + try { + let process = true; + let offset = 0; + const limit = 1000; + const data: Utxo[] = []; + + while (process) { + let url = `/dashboards/address/${address}?limit=0,${limit}&offset=0,${offset}`; + if (!includeUnconfirmed) { + url += '&state=latest'; + } + + const response = await this.get(url); + + logger.info( + `[BlockChairClient.getUtxos] response: ${JSON.stringify(response)}`, + ); + + if (!Object.prototype.hasOwnProperty.call(response.data, address)) { + throw new DataClientError(`Data not avaiable`); + } + + response.data[address].utxo.forEach((utxo) => { + data.push({ + block: utxo.block_id, + txnHash: utxo.transaction_hash, + index: utxo.index, + value: utxo.value, + }); + }); + + offset += 1; + + if (response.data[address].utxo.length < limit) { + process = false; + } + } + + return data; + } catch (error) { + throw compactError(error, DataClientError); + } + } + async getFeeRates(): Promise { try { logger.info(`[BlockChairClient.getFeeRates] start:`); 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 ada5033b..84dbb774 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 @@ -3,6 +3,7 @@ import { networks } from 'bitcoinjs-lib'; import { generateAccounts, generateBlockStreamAccountStats, + generateBlockStreamGetUtxosResp, generateBlockStreamEstFeeResp, } from '../../../../../test/utils'; import * as asyncUtils from '../../../../utils/async'; @@ -146,4 +147,100 @@ describe('BlockStreamClient', () => { await expect(instance.getFeeRates()).rejects.toThrow(DataClientError); }); }); + + describe('getUtxos', () => { + it('returns utxos', async () => { + const { fetchSpy } = createMockFetch(); + const accounts = generateAccounts(1); + const { address } = accounts[0]; + const mockResponse = generateBlockStreamGetUtxosResp(100); + const expectedResult = mockResponse.map((utxo) => ({ + block: utxo.status.block_height, + txnHash: utxo.txid, + index: utxo.vout, + value: utxo.value, + })); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + const result = await instance.getUtxos(address); + + expect(result).toStrictEqual(expectedResult); + }); + + it('includes unconfirmed if includeUnconfirmed is true', async () => { + const { fetchSpy } = createMockFetch(); + const accounts = generateAccounts(1); + const { address } = accounts[0]; + const mockConfirmedResponse = generateBlockStreamGetUtxosResp(100); + const mockUnconfirmedResponse = generateBlockStreamGetUtxosResp( + 100, + false, + ); + const combinedResponse = mockConfirmedResponse.concat( + mockUnconfirmedResponse, + ); + const expectedResult = combinedResponse.map((utxo) => ({ + block: utxo.status.block_height, + txnHash: utxo.txid, + index: utxo.vout, + value: utxo.value, + })); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(combinedResponse), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + const result = await instance.getUtxos(address, true); + + expect(result).toStrictEqual(expectedResult); + }); + + it('filters unconfirmed utxos if includeUnconfirmed is true', async () => { + const { fetchSpy } = createMockFetch(); + const accounts = generateAccounts(1); + const { address } = accounts[0]; + const mockConfirmedResponse = generateBlockStreamGetUtxosResp(100); + const mockUnconfirmedResponse = generateBlockStreamGetUtxosResp( + 100, + false, + ); + const combinedResponse = mockConfirmedResponse.concat( + mockUnconfirmedResponse, + ); + const expectedResult = mockConfirmedResponse.map((utxo) => ({ + block: utxo.status.block_height, + txnHash: utxo.txid, + index: utxo.vout, + value: utxo.value, + })); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(combinedResponse), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + const result = await instance.getUtxos(address, false); + + expect(result).toStrictEqual(expectedResult); + }); + + it('throws DataClientError error if an non DataClientError catched', async () => { + const { fetchSpy } = createMockFetch(); + fetchSpy.mockRejectedValue(new Error('error')); + const accounts = generateAccounts(1); + const { address } = accounts[0]; + + const instance = new BlockStreamClient({ network: networks.testnet }); + + await expect(instance.getUtxos(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 9b4cd9f3..cd7e2452 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 @@ -1,7 +1,7 @@ import { type Network, networks } from 'bitcoinjs-lib'; import { compactError, processBatch } from '../../../../utils'; -import { type Balances, FeeRatio } from '../../../chain'; +import { type Balances, type Utxo, FeeRatio } from '../../../chain'; import { logger } from '../../../logger/logger'; import { DataClientError } from '../exceptions'; import type { GetFeeRatesResp, IReadDataClient } from '../types'; @@ -30,6 +30,18 @@ export type GetAddressStatsResponse = { tx_count: number; }; }; + +export type GetUtxosResponse = { + txid: string; + vout: number; + status: { + confirmed: boolean; + block_height: number; + block_hash: string; + block_time: number; + }; + value: number; +}[]; /* eslint-enable */ export class BlockStreamClient implements IReadDataClient { @@ -103,6 +115,39 @@ export class BlockStreamClient implements IReadDataClient { } } + async getUtxos( + address: string, + includeUnconfirmed?: boolean, + ): Promise { + try { + const response = await this.get( + `/address/${address}/utxo`, + ); + + logger.info( + `[BlockStreamClient.getUtxos] response: ${JSON.stringify(response)}`, + ); + + const data: Utxo[] = []; + + for (const utxo of response) { + if (!includeUnconfirmed && !utxo.status.confirmed) { + continue; + } + data.push({ + block: utxo.status.block_height, + txnHash: utxo.txid, + index: utxo.vout, + value: utxo.value, + }); + } + + return data; + } catch (error) { + throw compactError(error, DataClientError); + } + } + async getFeeRates(): Promise { try { logger.info(`[BlockStreamClient.getFeeRates] start:`); 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 index d61987ad..f87a2d4a 100644 --- 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 @@ -1,5 +1,4 @@ -import type { FeeRatio } from '../../chain'; -import { type Balances } from '../../chain'; +import type { Balances, FeeRatio, Utxo } from '../../chain'; export type GetFeeRatesResp = { [key in FeeRatio]?: number; @@ -7,6 +6,7 @@ export type GetFeeRatesResp = { export type IReadDataClient = { getBalances(address: string[]): Promise; + getUtxos(address: string, includeUnconfirmed?: boolean): Promise; getFeeRates(): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts index a9f0c017..33d7ffd3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts @@ -29,6 +29,19 @@ export type TransactionIntent = { replaceable: boolean; }; +export type Utxo = { + block: number; + txnHash: string; + index: number; + value: number; +}; + +export type TransactionData = { + data: { + utxos: Utxo[]; + }; +}; + export type Pagination = { limit: number; offset: number; @@ -40,5 +53,8 @@ export type IOnChainService = { boardcastTransaction(txn: string); listTransactions(address: string, pagination: Pagination); getTransaction(txnHash: string); - getDataForTransaction(address: string, transactionIntent: TransactionIntent); + getDataForTransaction( + address: string, + transactionIntent?: TransactionIntent, + ): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.test.ts new file mode 100644 index 00000000..c75bf8e8 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.test.ts @@ -0,0 +1,77 @@ +import { + generateAccounts, + generateBlockChairGetUtxosResp, +} from '../../test/utils'; +import { Factory } from '../factory'; +import { Network } from '../modules/bitcoin/constants'; +import { GetTransactionDataHandler } from './get-transaction-data'; + +jest.mock('../modules/logger/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + }, +})); + +describe('GetTransactionDataHandler', () => { + describe('handleRequest', () => { + const createMockChainApiFactory = () => { + const getDataForTransactionSpy = jest.fn(); + + jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ + estimateFees: jest.fn(), + getBalances: jest.fn(), + boardcastTransaction: jest.fn(), + listTransactions: jest.fn(), + getTransaction: jest.fn(), + getDataForTransaction: getDataForTransactionSpy, + }); + return { + getDataForTransactionSpy, + }; + }; + + it('returns correct result', async () => { + const { getDataForTransactionSpy } = createMockChainApiFactory(); + const accounts = generateAccounts(2); + const sender = accounts[0].address; + const mockResponse = generateBlockChairGetUtxosResp(sender, 10); + const utxos = mockResponse.data[sender].utxo.map((utxo) => ({ + block: utxo.block_id, + txnHash: utxo.transaction_hash, + index: utxo.index, + value: utxo.value, + })); + + getDataForTransactionSpy.mockResolvedValue({ + data: { + utxos, + }, + }); + + const result = await GetTransactionDataHandler.getInstance().execute({ + scope: Network.Testnet, + account: sender, + }); + + expect(result).toStrictEqual({ + data: { + utxos: utxos.map((utxo) => ({ + ...utxo, + value: utxo.value.toString(), + })), + }, + }); + }); + + it('throws `Request params is invalid` when request parameter is not correct', async () => { + createMockChainApiFactory(); + + await expect( + GetTransactionDataHandler.getInstance().execute({ + scope: Network.Testnet, + }), + ).rejects.toThrow('Request params is invalid'); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.ts new file mode 100644 index 00000000..9d93c784 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.ts @@ -0,0 +1,69 @@ +import type { Infer } from 'superstruct'; +import { array, assign, number, object, string } from 'superstruct'; + +import { Factory } from '../factory'; +import { + BaseSnapRpcHandler, + SnapRpcHandlerRequestStruct, +} from '../modules/rpc'; +import type { + IStaticSnapRpcHandler, + SnapRpcHandlerResponse, +} from '../modules/rpc'; +import type { StaticImplements } from '../types/static'; + +export type GetTransactionDataParams = Infer< + typeof GetTransactionDataHandler.requestStruct +>; + +export type GetTransactionDataResponse = SnapRpcHandlerResponse & + Infer; + +export class GetTransactionDataHandler + extends BaseSnapRpcHandler + implements + StaticImplements +{ + static override get requestStruct() { + return assign( + object({ + account: string(), + }), + SnapRpcHandlerRequestStruct, + ); + } + + static override get responseStruct() { + return object({ + data: object({ + utxos: array( + object({ + block: number(), + txnHash: string(), + index: number(), + value: string(), + }), + ), + }), + }); + } + + async handleRequest( + params: GetTransactionDataParams, + ): Promise { + const chainApi = Factory.createOnChainServiceProvider(params.scope); + + const result = await chainApi.getDataForTransaction(params.account); + + return { + data: { + utxos: result.data.utxos.map((utxo) => ({ + block: utxo.block, + txnHash: utxo.txnHash, + index: utxo.index, + value: utxo.value.toString(), + })), + }, + }; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts index fac65a33..8551675b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts @@ -1,6 +1,7 @@ import { CreateAccountHandler } from './create-account'; import { EstimateFeesHandler } from './estimate-fees'; import { GetBalancesHandler } from './get-balances'; +import { GetTransactionDataHandler } from './get-transaction-data'; import { RpcHelper } from './helpers'; import { SendManyHandler } from './sendmany'; @@ -13,6 +14,8 @@ describe('RpcHelper', () => { // eslint-disable-next-line @typescript-eslint/naming-convention chain_getBalances: GetBalancesHandler, // eslint-disable-next-line @typescript-eslint/naming-convention + chain_getDataForTransaction: GetTransactionDataHandler, + // eslint-disable-next-line @typescript-eslint/naming-convention chain_estimateFees: EstimateFeesHandler, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts index 797c832e..207dcf5d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts @@ -1,6 +1,7 @@ import { CreateAccountHandler, GetBalancesHandler } from '.'; import type { IStaticSnapRpcHandler } from '../modules/rpc'; import { EstimateFeesHandler } from './estimate-fees'; +import { GetTransactionDataHandler } from './get-transaction-data'; import { SendManyHandler } from './sendmany'; export class RpcHelper { @@ -11,6 +12,8 @@ export class RpcHelper { // eslint-disable-next-line @typescript-eslint/naming-convention chain_getBalances: GetBalancesHandler, // eslint-disable-next-line @typescript-eslint/naming-convention + chain_getDataForTransaction: GetTransactionDataHandler, + // eslint-disable-next-line @typescript-eslint/naming-convention chain_estimateFees: EstimateFeesHandler, }; } diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json index 3fb597c7..f0174d22 100644 --- a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json +++ b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json @@ -43,5 +43,44 @@ "suggested_transaction_fee_per_byte_sat": 1, "hodling_addresses": 11723857 } + }, + "getUtxoResp": { + "data": { + "tb1qlq94vt9uh07fwunsgdyycpkv24uev05ywjua0r": { + "address": { + "type": "witness_v0_scripthash", + "script_hex": "0014f80b562cbcbbfc97727043484c06cc5579963e84", + "balance": 2374, + "balance_usd": 0, + "received": 7174, + "received_usd": 0, + "spent": 4800, + "spent_usd": 0, + "output_count": 5, + "unspent_output_count": 2, + "first_seen_receiving": "2024-04-12 05:29:15", + "last_seen_receiving": "2024-04-12 07:00:08", + "first_seen_spending": "2024-04-12 05:49:16", + "last_seen_spending": "2024-04-12 07:00:08", + "scripthash_type": null, + "transaction_count": null + }, + "transactions": [], + "utxo": [ + { + "block_id": 2586047, + "transaction_hash": "5c38297331e1dcdb42db691f435d9d119f228054d5f39c88e74329bef5513d77", + "index": 0, + "value": 600 + }, + { + "block_id": 2586041, + "transaction_hash": "6bea08c31f0b4da537f73400ac9da99b34458b22ec9fcf5184944e3a1796a8db", + "index": 1, + "value": 1774 + } + ] + } + } } } diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json index 889ba3df..613a04e7 100644 --- a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json +++ b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json @@ -16,6 +16,19 @@ "tx_count": 0 } }, + "getUtxoResp": [ + { + "txid": "bf9955de6df6d2b35aa301142ddeb85259f62dbdb5a5382ac7199b91bf6aa165", + "vout": 1, + "status": { + "confirmed": true, + "block_height": 2581760, + "block_hash": "000000000000000eba4e476fd2e7985221a2ae28ea2696c97bde455a4d40ec81", + "block_time": 1710329183 + }, + "value": 28019 + } + ], "feeEstimateResp": { "22": 52.373999999999995, "6": 52.373999999999995, diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index a77425cd..325ebd82 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -197,6 +197,45 @@ export function generateBlockChairGetBalanceResp(addresses: string[]) { return resp; } +/** + * Method to generate blockchair getUtxos resp by address. + * + * @param address - address in string. + * @param utxosCount - utxos count. + * @returns An array of blockchair getUtxos response. + */ +export function generateBlockChairGetUtxosResp( + address: string, + utxosCount: number, +) { + const template = blockChairData.getUtxoResp; + const data = { ...template.data.tb1qlq94vt9uh07fwunsgdyycpkv24uev05ywjua0r }; + let idx = -1; + const resp = { + data: { + [address]: { + ...data, + utxo: Array.from({ length: utxosCount }, () => { + idx += 1; + return { + block_id: randomNum(1000000), + transaction_hash: randomNum(1000000) + .toString(16) + .padStart( + template.data.tb1qlq94vt9uh07fwunsgdyycpkv24uev05ywjua0r.utxo[0] + .transaction_hash.length, + '0', + ), + index: idx, + value: randomNum(1000000), + }; + }), + }, + }, + }; + return resp; +} + /** * Method to generate blockchair getStats resp. * @@ -217,6 +256,39 @@ export function generateBlockChairGetStatsResp() { return resp; } +/** + * Method to generate blockstream getUtxos resp. + * + * @param utxosCount - utxos count. + * @returns An array of blockstream getUtxos response. + */ +export function generateBlockStreamGetUtxosResp( + utxosCount: number, + confirmed: boolean = true, +) { + const template = blockStreamData.getUtxoResp; + let idx = -1; + const resp = Array.from({ length: utxosCount }, () => { + idx += 1; + return { + txid: randomNum(1000000) + .toString(16) + .padStart(template[0].txid.length, '0'), + vout: idx, + status: { + confirmed: confirmed, + block_height: randomNum(1000000), + block_hash: randomNum(1000000) + .toString(16) + .padStart(template[0].status.block_hash.length, '0'), + block_time: 1710329183, + }, + value: randomNum(1000000), + }; + }); + return resp; +} + /** * Method to generate blockstream estimate fee resp. * From 525b6a467745acaabdeac30ef4629ead8ae41eb3 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 20 May 2024 18:54:14 +0800 Subject: [PATCH 033/362] chore: add unit test for get balances (#58) --- .../src/rpcs/get-balances.test.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts new file mode 100644 index 00000000..c20f4e61 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts @@ -0,0 +1,83 @@ +import { generateAccounts } from '../../test/utils'; +import { Factory } from '../factory'; +import { BtcAsset, Network } from '../modules/bitcoin/constants'; +import { GetBalancesHandler } from './get-balances'; + +jest.mock('../modules/logger/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + }, +})); + +describe('GetBalancesHandler', () => { + describe('handleRequest', () => { + const createMockChainApiFactory = () => { + const getBalancesSpy = jest.fn(); + + jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ + estimateFees: jest.fn(), + getBalances: getBalancesSpy, + boardcastTransaction: jest.fn(), + listTransactions: jest.fn(), + getTransaction: jest.fn(), + getDataForTransaction: jest.fn(), + }); + return { + getBalancesSpy, + }; + }; + + it('gets balances', async () => { + const { getBalancesSpy } = createMockChainApiFactory(); + + const accounts = generateAccounts(2); + const addresses = accounts.map((account) => account.address); + const mockResp = { + balances: addresses.reduce((acc, address) => { + acc[address] = { + [BtcAsset.TBtc]: { + amount: 100, + }, + }; + return acc; + }, {}), + }; + const expected = { + balances: addresses.reduce((acc, address) => { + acc[address] = { + [BtcAsset.TBtc]: { + amount: '0.00000100', + }, + }; + return acc; + }, {}), + }; + + getBalancesSpy.mockResolvedValue(mockResp); + + const result = await GetBalancesHandler.getInstance().execute({ + scope: Network.Testnet, + accounts: addresses, + assets: [BtcAsset.TBtc], + }); + + expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [BtcAsset.TBtc]); + expect(result).toStrictEqual(expected); + }); + + it('throws `Request params is invalid` when request parameter is not correct', async () => { + createMockChainApiFactory(); + const accounts = generateAccounts(2); + const addresses = accounts.map((account) => account.address); + + await expect( + GetBalancesHandler.getInstance().execute({ + scope: Network.Testnet, + accounts: addresses, + assets: ['some-asset'], + }), + ).rejects.toThrow('Request params is invalid'); + }); + }); +}); From f4f60725b307bef84bd156fb823c5a08886dd373 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 20 May 2024 19:08:00 +0800 Subject: [PATCH 034/362] feat: implement broadcast txn api (#57) * feat: implement broadcast txn api * chore: remove console.log * chore: update service unit test * chore: update lint style --- .../bitcoin-wallet-snap/.env.example | 5 ++ .../bitcoin-wallet-snap/snap.config.ts | 1 + .../bitcoin-wallet-snap/src/config/config.ts | 11 +++ .../src/config/permissions.ts | 3 + .../bitcoin-wallet-snap/src/factory.ts | 4 +- .../bitcoin-wallet-snap/src/index.ts | 2 +- .../src/modules/bitcoin/chain/service.test.ts | 73 +++++++++++++++---- .../src/modules/bitcoin/chain/service.ts | 22 ++++-- .../src/modules/bitcoin/config/types.ts | 4 + .../data-client/clients/blockchair.test.ts | 61 +++++++++++++++- .../bitcoin/data-client/clients/blockchair.ts | 72 ++++++++++++++++-- .../bitcoin/data-client/factory.test.ts | 45 +++++++++++- .../modules/bitcoin/data-client/factory.ts | 21 +++++- .../src/modules/bitcoin/data-client/types.ts | 2 +- .../src/modules/chain/types.ts | 2 +- .../src/rpcs/broadcast-transaction.test.ts | 57 +++++++++++++++ .../src/rpcs/broadcast-transaction.ts | 57 +++++++++++++++ .../src/rpcs/helper.test.ts | 3 + .../bitcoin-wallet-snap/src/rpcs/helpers.ts | 3 + .../test/fixtures/blockchair.json | 5 ++ .../bitcoin-wallet-snap/test/utils.ts | 11 +++ 21 files changed, 429 insertions(+), 35 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.ts diff --git a/merged-packages/bitcoin-wallet-snap/.env.example b/merged-packages/bitcoin-wallet-snap/.env.example index 00f4aaa8..6ba8529b 100644 --- a/merged-packages/bitcoin-wallet-snap/.env.example +++ b/merged-packages/bitcoin-wallet-snap/.env.example @@ -3,6 +3,11 @@ # Default: BlockChair # Required: false DATA_CLIENT_READ_TYPE=BlockStream +# Description: Environment variables for write data client +# Possible Options: BlockChair +# Default: BlockChair +# Required: false +DATA_CLIENT_WRITE_TYPE=BlockChair # Description: Environment variables for Log Level, 0 does not log anything, 6 logs everything # Possible Options: 0-6 # Default: 0 diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts index 902252dd..e0e93521 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.config.ts +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -14,6 +14,7 @@ const config: SnapConfig = { /* eslint-disable */ LOG_LEVEL: process.env.LOG_LEVEL, DATA_CLIENT_READ_TYPE: process.env.DATA_CLIENT_READ_TYPE, + DATA_CLIENT_WRITE_TYPE: process.env.DATA_CLIENT_WRITE_TYPE, BLOCKCHAIR_API_KEY: process.env.BLOCKCHAIR_API_KEY, /* eslint-disable */ }, diff --git a/merged-packages/bitcoin-wallet-snap/src/config/config.ts b/merged-packages/bitcoin-wallet-snap/src/config/config.ts index 4bef712f..3cf73acf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/config.ts @@ -51,6 +51,17 @@ export const Config: SnapConfig = { apiKey: process.env.BLOCKCHAIR_API_KEY, }, }, + + write: { + type: + // eslint-disable-next-line no-restricted-globals + (process.env.DATA_CLIENT_WRITE_TYPE as DataClient) ?? + DataClient.BlockChair, + options: { + // eslint-disable-next-line no-restricted-globals + apiKey: process.env.BLOCKCHAIR_API_KEY, + }, + }, }, }, }, diff --git a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts index 219aecd6..9bbb8756 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts @@ -15,6 +15,7 @@ export const originPermissions = new Map>([ KeyringRpcMethod.RejectRequest, // Chain API methods 'chain_getBalances', + 'chain_broadcastTransaction', 'chain_getDataForTransaction', 'chain_estimateFees', ]), @@ -36,6 +37,7 @@ export const originPermissions = new Map>([ // Chain API methods 'chain_getBalances', 'chain_createAccount', + 'chain_broadcastTransaction', 'chain_getDataForTransaction', 'chain_estimateFees', ]), @@ -56,6 +58,7 @@ export const originPermissions = new Map>([ KeyringRpcMethod.RejectRequest, // Chain API methods 'chain_getBalances', + 'chain_broadcastTransaction', 'chain_getDataForTransaction', 'chain_estimateFees', ]), diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts index 6a9b871f..8a9bc77d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.ts @@ -24,8 +24,8 @@ export class Factory { ) { const btcNetwork = getBtcNetwork(network); const readClient = DataClientFactory.createReadClient(config, btcNetwork); - - return new BtcOnChainService(readClient, { + const writeClient = DataClientFactory.createWriteClient(config, btcNetwork); + return new BtcOnChainService(readClient, writeClient, { network: btcNetwork, }); } diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 0ec75e7c..a77468ad 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -51,7 +51,7 @@ export const onRpcRequest: OnRpcRequestHandler = async (args) => { snapError = new SnapError(error); } logger.error( - `onKeyringRequest error: ${JSON.stringify(snapError.toJSON(), null, 2)}`, + `onRpcRequest error: ${JSON.stringify(snapError.toJSON(), null, 2)}`, ); throw snapError; } diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts index b72fa5a0..b67e4b24 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts @@ -3,11 +3,12 @@ import { networks } from 'bitcoinjs-lib'; import { generateAccounts, + generateBlockChairBroadcastTransactionResp, generateBlockChairGetUtxosResp, } from '../../../../test/utils'; import { FeeRatio } from '../../chain'; import { BtcAsset } from '../constants'; -import type { IReadDataClient } from '../data-client'; +import type { IReadDataClient, IWriteDataClient } from '../data-client'; import { BtcOnChainServiceError } from './exceptions'; import { BtcOnChainService } from './service'; @@ -38,13 +39,30 @@ describe('BtcOnChainService', () => { }; }; + const createMockWriteDataClient = () => { + const sendTransactionSpy = jest.fn(); + + class MockWriteDataClient implements IWriteDataClient { + sendTransaction = sendTransactionSpy; + } + return { + instance: new MockWriteDataClient(), + sendTransactionSpy, + }; + }; + const createMockBtcService = ( - readDataClient: IReadDataClient, + readDataClient?: IReadDataClient, + writeDataClient?: IWriteDataClient, network: Network = networks.testnet, ) => { - const instance = new BtcOnChainService(readDataClient, { - network, - }); + const instance = new BtcOnChainService( + readDataClient ?? createMockReadDataClient().instance, + writeDataClient ?? createMockWriteDataClient().instance, + { + network, + }, + ); return { instance, @@ -95,6 +113,7 @@ describe('BtcOnChainService', () => { const { instance } = createMockReadDataClient(); const { instance: txnService } = createMockBtcService( instance, + undefined, networks.bitcoin, ); const accounts = generateAccounts(2); @@ -141,10 +160,7 @@ describe('BtcOnChainService', () => { it('throws error if readClient fail', async () => { const { instance, getUtxosSpy } = createMockReadDataClient(); - const { instance: txnService } = createMockBtcService( - instance, - networks.bitcoin, - ); + const { instance: txnService } = createMockBtcService(instance); const accounts = generateAccounts(2); const sender = accounts[0].address; const receiver = accounts[1].address; @@ -191,10 +207,7 @@ describe('BtcOnChainService', () => { it('throws BtcOnChainServiceError error if an error catched', async () => { const { instance, getFeeRatesSpy } = createMockReadDataClient(); - const { instance: txnMgr } = createMockBtcService( - instance, - networks.bitcoin, - ); + const { instance: txnMgr } = createMockBtcService(instance); getFeeRatesSpy.mockRejectedValue(new Error('error')); @@ -203,4 +216,38 @@ describe('BtcOnChainService', () => { ); }); }); + + describe('boardcastTransaction', () => { + const signedTransaction = + '02000000000101ec81faa8b57add4c8fb3958dd8f04667f5cd829a7b94199f4400be9e52cda0760000000000ffffffff015802000000000000160014f80b562cbcbbfc97727043484c06cc5579963e8402473044022011ec3f7ea7a7cac7cb891a1ea498d94ca3cd082339b9b2620ba5421ca7cbdf3d022062f34411d6aa5335c2bd7ff4c940adb962e9509133b86a2d97996552fd811f2c012102ceea82614fdb14871ef881498c55c5dbdc24b4633d29b42040dd18b4285540f500000000'; + + it('calls sendTransaction with writeClient', async () => { + const { instance, sendTransactionSpy } = createMockWriteDataClient(); + const { instance: txnService } = createMockBtcService( + undefined, + instance, + ); + + const resp = generateBlockChairBroadcastTransactionResp(); + sendTransactionSpy.mockResolvedValue(resp.data.transaction_hash); + + const result = await txnService.boardcastTransaction(signedTransaction); + + expect(sendTransactionSpy).toHaveBeenCalledWith(signedTransaction); + expect(result).toStrictEqual(resp.data.transaction_hash); + }); + + it('throws BtcOnChainServiceErrorr if write client execute fail', async () => { + const { instance, sendTransactionSpy } = createMockWriteDataClient(); + const { instance: txnService } = createMockBtcService( + undefined, + instance, + ); + sendTransactionSpy.mockRejectedValue(new Error('error')); + + await expect( + txnService.boardcastTransaction(signedTransaction), + ).rejects.toThrow(BtcOnChainServiceError); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts index c971d5a3..1a9574ff 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts @@ -13,6 +13,7 @@ import type { TransactionData, } from '../../chain/types'; import { BtcAsset } from '../constants'; +import type { IWriteDataClient } from '../data-client'; import { type IReadDataClient } from '../data-client'; import { BtcOnChainServiceError } from './exceptions'; import type { BtcOnChainServiceOptions } from './types'; @@ -20,10 +21,17 @@ import type { BtcOnChainServiceOptions } from './types'; export class BtcOnChainService implements IOnChainService { protected readonly readClient: IReadDataClient; + protected readonly writeClient: IWriteDataClient; + protected readonly options: BtcOnChainServiceOptions; - constructor(readClient: IReadDataClient, options: BtcOnChainServiceOptions) { + constructor( + readClient: IReadDataClient, + writeClient: IWriteDataClient, + options: BtcOnChainServiceOptions, + ) { this.readClient = readClient; + this.writeClient = writeClient; this.options = options; } @@ -86,10 +94,6 @@ export class BtcOnChainService implements IOnChainService { } /* eslint-disable */ - boardcastTransaction(txn: string) { - throw new Error('Method not implemented.'); - } - listTransactions(address: string, pagination: Pagination) { throw new Error('Method not implemented.'); } @@ -115,4 +119,12 @@ export class BtcOnChainService implements IOnChainService { throw compactError(error, BtcOnChainServiceError); } } + + async boardcastTransaction(signedTransaction: string): Promise { + try { + return await this.writeClient.sendTransaction(signedTransaction); + } catch (error) { + throw compactError(error, BtcOnChainServiceError); + } + } } 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 f4a4d32c..a732c410 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 @@ -8,6 +8,10 @@ export type BtcOnChainServiceConfig = { type: DataClient; options?: Record; }; + write: { + type: DataClient; + options?: Record; + }; }; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts index 3584a8aa..a7eb5ec8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts @@ -2,6 +2,7 @@ import { networks } from 'bitcoinjs-lib'; import { generateAccounts, + generateBlockChairBroadcastTransactionResp, generateBlockChairGetBalanceResp, generateBlockChairGetUtxosResp, generateBlockChairGetStatsResp, @@ -51,7 +52,7 @@ describe('BlockChairClient', () => { }); }); - describe('get', () => { + describe('getApiUrl', () => { it('append api key to query url if option `apiKey` is present', async () => { const { fetchSpy } = createMockFetch(); const accounts = generateAccounts(1); @@ -303,4 +304,62 @@ describe('BlockChairClient', () => { ); }); }); + + describe('sendTransaction', () => { + const signedTransaction = + '02000000000101ec81faa8b57add4c8fb3958dd8f04667f5cd829a7b94199f4400be9e52cda0760000000000ffffffff015802000000000000160014f80b562cbcbbfc97727043484c06cc5579963e8402473044022011ec3f7ea7a7cac7cb891a1ea498d94ca3cd082339b9b2620ba5421ca7cbdf3d022062f34411d6aa5335c2bd7ff4c940adb962e9509133b86a2d97996552fd811f2c012102ceea82614fdb14871ef881498c55c5dbdc24b4633d29b42040dd18b4285540f500000000'; + + it('broadcasts an transaction', async () => { + const { fetchSpy } = createMockFetch(); + const mockResponse = generateBlockChairBroadcastTransactionResp(); + const expectedResult = mockResponse.data; + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const instance = new BlockChairClient({ network: networks.testnet }); + const result = await instance.sendTransaction(signedTransaction); + + expect(result).toStrictEqual(expectedResult.transaction_hash); + }); + + it('throws DataClientError error if an fetch response is falsely', async () => { + const { fetchSpy } = createMockFetch(); + + fetchSpy.mockResolvedValueOnce({ + ok: false, + json: jest.fn().mockResolvedValue(null), + }); + + const instance = new BlockChairClient({ network: networks.testnet }); + + await expect(instance.sendTransaction(signedTransaction)).rejects.toThrow( + DataClientError, + ); + }); + + it('conducts error message with response`s context if status is 400', async () => { + const { fetchSpy } = createMockFetch(); + + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 400, + json: jest.fn().mockResolvedValue({ + data: null, + context: { + code: 400, + error: + 'Invalid transaction. Error: Transaction already in block chain', + }, + }), + }); + + const instance = new BlockChairClient({ network: networks.testnet }); + await expect(instance.sendTransaction(signedTransaction)).rejects.toThrow( + 'Failed to post data from blockchair: Invalid transaction. Error: Transaction already in block chain', + ); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts index 7ca2143a..3f2058f4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts @@ -1,10 +1,15 @@ +import type { Json } from '@metamask/snaps-sdk'; import { type Network, networks } from 'bitcoinjs-lib'; import { compactError } from '../../../../utils'; import { type Balances, type Utxo, FeeRatio } from '../../../chain'; import { logger } from '../../../logger/logger'; import { DataClientError } from '../exceptions'; -import type { GetFeeRatesResp, IReadDataClient } from '../types'; +import type { + GetFeeRatesResp, + IReadDataClient, + IWriteDataClient, +} from '../types'; export type BlockChairClientOptions = { network: Network; @@ -95,9 +100,15 @@ export type GetUtxosResponse = { }; }; }; + +export type PostTransactionResponse = { + data: { + transaction_hash: string; + }; +}; /* eslint-disable */ -export class BlockChairClient implements IReadDataClient { +export class BlockChairClient implements IReadDataClient, IWriteDataClient { options: BlockChairClientOptions; constructor(options: BlockChairClientOptions) { @@ -115,14 +126,17 @@ export class BlockChairClient implements IReadDataClient { } } - protected async get(endpoint: string): Promise { + protected getApiUrl(endpoint: string): string { const url = new URL(`${this.baseUrl}${endpoint}`); // TODO: Update to proxy if (this.options.apiKey) { url.searchParams.append('key', this.options.apiKey); } + return url.toString(); + } - const response = await fetch(url.toString(), { + protected async get(endpoint: string): Promise { + const response = await fetch(this.getApiUrl(endpoint), { method: 'GET', }); @@ -134,6 +148,30 @@ export class BlockChairClient implements IReadDataClient { return response.json() as unknown as Resp; } + protected async post(endpoint: string, body: Json): Promise { + const response = await fetch(this.getApiUrl(endpoint), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (response.status == 400) { + const res = await response.json(); + throw new DataClientError( + `Failed to post data from blockchair: ${res.context.error}`, + ); + } + + if (!response.ok) { + throw new DataClientError( + `Failed to post data from blockchair: ${response.statusText}`, + ); + } + return response.json() as unknown as Resp; + } + async getBalances(addresses: string[]): Promise { try { logger.info( @@ -219,10 +257,28 @@ export class BlockChairClient implements IReadDataClient { [FeeRatio.Fast]: response.data.suggested_transaction_fee_per_byte_sat, }; } catch (error) { - if (error instanceof DataClientError) { - throw error; - } - throw new DataClientError(error); + throw compactError(error, DataClientError); + } + } + + async sendTransaction(signedTransaction: string): Promise { + try { + const response = await this.post( + `/push/transaction`, + { + data: signedTransaction, + }, + ); + + logger.info( + `[BlockChairClient.sendTransaction] response: ${JSON.stringify( + response, + )}`, + ); + + return response.data.transaction_hash; + } catch (error) { + throw compactError(error, DataClientError); } } } 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 index ff5520dc..0c9aa8a1 100644 --- 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 @@ -9,7 +9,12 @@ describe('DataClientFactory', () => { describe('createReadClient', () => { it('creates BlockStreamClient', () => { const instance = DataClientFactory.createReadClient( - { dataClient: { read: { type: DataClient.BlockStream } } }, + { + dataClient: { + read: { type: DataClient.BlockStream }, + write: { type: DataClient.BlockChair }, + }, + }, networks.testnet, ); @@ -18,7 +23,12 @@ describe('DataClientFactory', () => { it('creates BlockChairClient', () => { const instance = DataClientFactory.createReadClient( - { dataClient: { read: { type: DataClient.BlockChair } } }, + { + dataClient: { + read: { type: DataClient.BlockChair }, + write: { type: DataClient.BlockChair }, + }, + }, networks.testnet, ); @@ -31,6 +41,37 @@ describe('DataClientFactory', () => { { dataClient: { read: { type: 'SomeClient' as unknown as DataClient }, + write: { type: DataClient.BlockChair }, + }, + }, + networks.testnet, + ), + ).toThrow('Unsupported client type: SomeClient'); + }); + }); + + describe('createWriteClient', () => { + it('creates BlockChairClient', () => { + const instance = DataClientFactory.createWriteClient( + { + dataClient: { + read: { type: DataClient.BlockChair }, + write: { type: DataClient.BlockChair }, + }, + }, + networks.testnet, + ); + + expect(instance).toBeInstanceOf(BlockChairClient); + }); + + it('throws `Unsupported client type` if the given client is not support', () => { + expect(() => + DataClientFactory.createWriteClient( + { + dataClient: { + read: { type: DataClient.BlockChair }, + write: { type: 'SomeClient' as unknown as DataClient }, }, }, networks.testnet, 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 index 7588b1ed..f2d1b90f 100644 --- 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 @@ -5,7 +5,7 @@ import { DataClient } from '../constants'; import { BlockChairClient } from './clients/blockchair'; import { BlockStreamClient } from './clients/blockstream'; import { DataClientError } from './exceptions'; -import type { IReadDataClient } from './types'; +import type { IReadDataClient, IWriteDataClient } from './types'; export class DataClientFactory { static createReadClient( @@ -28,4 +28,23 @@ export class DataClientFactory { ); } } + + static createWriteClient( + config: BtcOnChainServiceConfig, + network: Network, + ): IWriteDataClient { + const { type, options } = config.dataClient.write; + switch (type) { + case DataClient.BlockChair: + return new BlockChairClient({ + network, + apiKey: options?.apiKey?.toString(), + }); + default: + throw new DataClientError( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Unsupported client type: ${config.dataClient.write.type}`, + ); + } + } } 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 index f87a2d4a..44610b95 100644 --- 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 @@ -11,7 +11,7 @@ export type IReadDataClient = { }; export type IWriteDataClient = { - sendTransaction(tx: string): Promise; + sendTransaction(tx: string): Promise; }; export type IDataClient = IReadDataClient & IWriteDataClient; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts index 33d7ffd3..9939e3d7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts @@ -50,7 +50,7 @@ export type Pagination = { export type IOnChainService = { getBalances(addresses: string[], assets: string[]): Promise; estimateFees(): Promise; - boardcastTransaction(txn: string); + boardcastTransaction(signedTransaction: string): Promise; listTransactions(address: string, pagination: Pagination); getTransaction(txnHash: string); getDataForTransaction( diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.test.ts new file mode 100644 index 00000000..2773dbed --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.test.ts @@ -0,0 +1,57 @@ +import { generateBlockChairBroadcastTransactionResp } from '../../test/utils'; +import { Factory } from '../factory'; +import { Network } from '../modules/bitcoin/constants'; +import { BroadcastTransactionHandler } from './broadcast-transaction'; + +jest.mock('../modules/logger/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + }, +})); + +describe('BroadcastTransactionHandler', () => { + describe('handleRequest', () => { + const createMockChainApiFactory = () => { + const boardcastTransactionSpy = jest.fn(); + + jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ + estimateFees: jest.fn(), + getBalances: jest.fn(), + boardcastTransaction: boardcastTransactionSpy, + listTransactions: jest.fn(), + getTransaction: jest.fn(), + getDataForTransaction: jest.fn(), + }); + return { + boardcastTransactionSpy, + }; + }; + + it('broadcast an transaction', async () => { + const { boardcastTransactionSpy } = createMockChainApiFactory(); + const resp = generateBlockChairBroadcastTransactionResp(); + boardcastTransactionSpy.mockResolvedValue(resp.data.transaction_hash); + + const result = await BroadcastTransactionHandler.getInstance().execute({ + scope: Network.Testnet, + signedTransaction: + '02000000000101ec81faa8b57add4c8fb3958dd8f04667f5cd829a7b94199f4400be9e52cda0760000000000ffffffff015802000000000000160014f80b562cbcbbfc97727043484c06cc5579963e8402473044022011ec3f7ea7a7cac7cb891a1ea498d94ca3cd082339b9b2620ba5421ca7cbdf3d022062f34411d6aa5335c2bd7ff4c940adb962e9509133b86a2d97996552fd811f2c012102ceea82614fdb14871ef881498c55c5dbdc24b4633d29b42040dd18b4285540f500000000', + }); + + expect(result).toStrictEqual({ + transactionId: resp.data.transaction_hash, + }); + }); + + it('throws `Request params is invalid` when request parameter is not correct', async () => { + createMockChainApiFactory(); + + await expect( + BroadcastTransactionHandler.getInstance().execute({ + scope: 'some invalid value', + }), + ).rejects.toThrow('Request params is invalid'); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.ts new file mode 100644 index 00000000..a3e586ed --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.ts @@ -0,0 +1,57 @@ +import type { Infer } from 'superstruct'; +import { object, string, assign } from 'superstruct'; + +import { Factory } from '../factory'; +import { + SnapRpcHandlerRequestStruct, + BaseSnapRpcHandler, +} from '../modules/rpc'; +import type { + IStaticSnapRpcHandler, + SnapRpcHandlerResponse, +} from '../modules/rpc'; +import type { StaticImplements } from '../types/static'; + +export type BroadcastTransactionParams = Infer< + typeof BroadcastTransactionHandler.requestStruct +>; + +export type BroadcastTransactionResponse = SnapRpcHandlerResponse & + Infer; + +export class BroadcastTransactionHandler + extends BaseSnapRpcHandler + implements + StaticImplements +{ + static override get requestStruct() { + return assign( + object({ + signedTransaction: string(), + }), + SnapRpcHandlerRequestStruct, + ); + } + + static override get responseStruct() { + return object({ + transactionId: string(), + }); + } + + async handleRequest( + params: BroadcastTransactionParams, + ): Promise { + const { scope, signedTransaction } = params; + + const chainApi = Factory.createOnChainServiceProvider(scope); + + const transactionId = await chainApi.boardcastTransaction( + signedTransaction, + ); + + return { + transactionId, + }; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts index 8551675b..9b653c5b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts @@ -1,3 +1,4 @@ +import { BroadcastTransactionHandler } from './broadcast-transaction'; import { CreateAccountHandler } from './create-account'; import { EstimateFeesHandler } from './estimate-fees'; import { GetBalancesHandler } from './get-balances'; @@ -14,6 +15,8 @@ describe('RpcHelper', () => { // eslint-disable-next-line @typescript-eslint/naming-convention chain_getBalances: GetBalancesHandler, // eslint-disable-next-line @typescript-eslint/naming-convention + chain_broadcastTransaction: BroadcastTransactionHandler, + // eslint-disable-next-line @typescript-eslint/naming-convention chain_getDataForTransaction: GetTransactionDataHandler, // eslint-disable-next-line @typescript-eslint/naming-convention chain_estimateFees: EstimateFeesHandler, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts index 207dcf5d..23630524 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts @@ -1,5 +1,6 @@ import { CreateAccountHandler, GetBalancesHandler } from '.'; import type { IStaticSnapRpcHandler } from '../modules/rpc'; +import { BroadcastTransactionHandler } from './broadcast-transaction'; import { EstimateFeesHandler } from './estimate-fees'; import { GetTransactionDataHandler } from './get-transaction-data'; import { SendManyHandler } from './sendmany'; @@ -12,6 +13,8 @@ export class RpcHelper { // eslint-disable-next-line @typescript-eslint/naming-convention chain_getBalances: GetBalancesHandler, // eslint-disable-next-line @typescript-eslint/naming-convention + chain_broadcastTransaction: BroadcastTransactionHandler, + // eslint-disable-next-line @typescript-eslint/naming-convention chain_getDataForTransaction: GetTransactionDataHandler, // eslint-disable-next-line @typescript-eslint/naming-convention chain_estimateFees: EstimateFeesHandler, diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json index f0174d22..992145b6 100644 --- a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json +++ b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json @@ -82,5 +82,10 @@ ] } } + }, + "broadcastTransactionResp": { + "data": { + "transaction_hash": "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098" + } } } diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index 325ebd82..3cae6016 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -302,3 +302,14 @@ export function generateBlockStreamEstFeeResp() { }); return resp; } + +/** + * Method to generate blockchair BroadcastTransaction resp. + * + * @returns An txn id of blockchair BroadcastTransaction response. + */ +export function generateBlockChairBroadcastTransactionResp() { + const template = blockChairData.broadcastTransactionResp; + const resp: typeof template = { ...template }; + return resp; +} From 71bfc8aeb09ae8e9d0ea560755a903cd5e0058c3 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 22 May 2024 15:32:15 +0800 Subject: [PATCH 035/362] feat: add bufferToString (#61) * feat: add bufferToString * chore: update lint style --- .../modules/bitcoin/wallet/account.test.ts | 23 ----------------- .../src/modules/bitcoin/wallet/account.ts | 11 +------- .../src/modules/bitcoin/wallet/wallet.test.ts | 22 ---------------- .../src/modules/bitcoin/wallet/wallet.ts | 6 ++--- .../src/utils/string.test.ts | 25 ++++++++++++++++--- .../bitcoin-wallet-snap/src/utils/string.ts | 21 +++++++++++++++- 6 files changed, 46 insertions(+), 62 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.test.ts index 8dd13539..eb6d2b07 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.test.ts @@ -76,28 +76,5 @@ describe('BtcAccount', () => { expect(() => instance.address).toThrow('Payment address is missing'); }); - - it('throws `Public key is invalid` error if the public key is invalid', async () => { - const network = networks.testnet; - const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; - createMockPaymentInstance(address); - - const signerSpy = jest.fn(); - const index = 0; - const hdPath = [`m`, `0'`, `0`, `${index}`].join('/'); - - const instance = new P2WPKHAccount( - 'ddddddddddddd', - index, - hdPath, - undefined as unknown as string, - network, - P2WPKHAccount.scriptType, - `bip122:${P2WPKHAccount.scriptType.toLowerCase()}`, - { sign: signerSpy } as unknown as IAccountSigner, - ); - - expect(() => instance.address).toThrow('Public key is invalid'); - }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.ts index c7d32092..38dfe76f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.ts @@ -1,5 +1,4 @@ import type { Network, Payment } from 'bitcoinjs-lib'; -import type { Buffer } from 'buffer'; import type { StaticImplements } from '../../../types/static'; import { hexToBuffer } from '../../../utils'; @@ -62,20 +61,12 @@ export class BtcAccount implements IBtcAccount { if (!this.#payment) { this.#payment = getBtcPaymentInst( this.scriptType, - this.pubkeyToBuf(this.pubkey), + hexToBuffer(this.pubkey), this.network, ); } return this.#payment; } - - protected pubkeyToBuf(pubkey: string): Buffer { - try { - return hexToBuffer(pubkey, false); - } catch (error) { - throw new Error('Public key is invalid'); - } - } } export class P2WPKHAccount diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts index d2f0cd04..9611408e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts @@ -72,28 +72,6 @@ describe('BtcWallet', () => { expect(childSpy).toHaveBeenCalledWith(rootNode, idx); }); - it('throws `Unable to get fingerprint in hex` error if the fingerprint can not toString', async () => { - const network = networks.testnet; - const idx = 0; - const { instance, rootNode } = createMockWallet(network); - rootNode.fingerprint = undefined as unknown as Buffer; - - await expect(instance.unlock(idx, ScriptType.P2wpkh)).rejects.toThrow( - `Unable to get fingerprint in hex`, - ); - }); - - it('throws `Unable to get public key in hex` error if the fingerprint can not toString', async () => { - const network = networks.testnet; - const idx = 0; - const { instance, childNode } = createMockWallet(network); - childNode.publicKey = undefined as unknown as Buffer; - - await expect(instance.unlock(idx, ScriptType.P2wpkh)).rejects.toThrow( - `Unable to get public key in hex`, - ); - }); - it('throws error if the account cannot be unlocked', async () => { const network = networks.testnet; const idx = 0; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts index 109d7e3b..496bb466 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts @@ -5,7 +5,7 @@ import { type Buffer } from 'buffer'; import type { IAccountSigner } from '../../../keyring'; import { type IAccount, type IWallet } from '../../../keyring'; -import { compactError } from '../../../utils'; +import { bufferToString, compactError } from '../../../utils'; import type { TransactionIntent } from '../../chain/types'; import { ScriptType } from '../constants'; import { P2WPKHAccount, P2SHP2WPKHAccount } from './account'; @@ -46,10 +46,10 @@ export class BtcWallet implements IWallet { const hdPath = [`m`, `0'`, `0`, `${index}`].join('/'); return new AccountCtor( - this.getFingerPrintInHex(rootNode), + bufferToString(rootNode.fingerprint, 'hex'), index, hdPath, - this.getPublicKeyInHex(childNode), + bufferToString(childNode.publicKey, 'hex'), this.network, AccountCtor.scriptType, `bip122:${AccountCtor.scriptType.toLowerCase()}`, diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts index 94144d5f..31fb19ee 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts @@ -1,6 +1,6 @@ import { Buffer } from 'buffer'; -import { trimHexPrefix, hexToBuffer } from './string'; +import { trimHexPrefix, hexToBuffer, bufferToString } from './string'; describe('trimHexPrefix', () => { it('trims hex prefix', () => { @@ -19,17 +19,36 @@ describe('trimHexPrefix', () => { }); describe('hexToBuffer', () => { - it('conver a hex string to buffer with trimed prefix', () => { + it('converts a hex string to buffer with trimed prefix', () => { const key = '0x1234'; const result = hexToBuffer(key); expect(result).toStrictEqual(Buffer.from('1234', 'hex')); }); - it('conver a hex string to buffer without trimed prefix', () => { + it('converts a hex string to buffer without trimed prefix', () => { const key = '0x1234'; const result = hexToBuffer(key, false); expect(result).toStrictEqual(Buffer.from('0x1234', 'hex')); }); + + it('throws `Unable to convert hexStr to buffer` error if the execution fail', () => { + expect(() => hexToBuffer(null as unknown as string)).toThrow( + 'Unable to convert hexStr to buffer', + ); + }); +}); + +describe('bufferToString', () => { + it('converts a buffer to string with encoding', () => { + const result = bufferToString(Buffer.from('1234', 'hex'), 'hex'); + expect(result).toBe('1234'); + }); + + it('throws `Unable to convert buffer to string` error if the execution fail', () => { + expect(() => bufferToString(undefined as unknown as Buffer, 'hex')).toThrow( + 'Unable to convert buffer to string', + ); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts index 393d201a..de1de195 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts @@ -17,5 +17,24 @@ export function trimHexPrefix(hexStr: string) { * @returns Buffer instance. */ export function hexToBuffer(hexStr: string, trimPrefix = true) { - return Buffer.from(trimPrefix ? trimHexPrefix(hexStr) : hexStr, 'hex'); + try { + return Buffer.from(trimPrefix ? trimHexPrefix(hexStr) : hexStr, 'hex'); + } catch (error) { + throw new Error('Unable to convert hexStr to buffer'); + } +} + +/** + * Method to convert a buffer to a string safely. + * + * @param buffer - Hex string. + * @param encoding - Buffer encoding. + * @returns Converted String. + */ +export function bufferToString(buffer: Buffer, encoding: BufferEncoding) { + try { + return buffer.toString(encoding); + } catch (error) { + throw new Error('Unable to convert buffer to string'); + } } From 73d9ec31e5aa8b8be7c46e2878069fd4bd747201 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 22 May 2024 15:57:17 +0800 Subject: [PATCH 036/362] fix: change to keyring state method get wallet (#62) * fix: change getWalletByAddressNScope to getWallet - change getWalletByAddressNScope to getWallet - move IAccount , IWallet , IAccountSigner from keyring to wallet * fix: lint issue --- .../bitcoin-wallet-snap/src/factory.ts | 5 +-- .../src/keyring/keyring.test.ts | 16 ++++----- .../src/keyring/keyring.ts | 21 ++++++----- .../src/keyring/state.test.ts | 23 +++++------- .../bitcoin-wallet-snap/src/keyring/state.ts | 13 ++----- .../bitcoin-wallet-snap/src/keyring/types.ts | 36 ------------------- .../src/modules/bitcoin/wallet/factory.ts | 2 +- .../src/modules/bitcoin/wallet/types.ts | 2 +- .../src/modules/bitcoin/wallet/wallet.test.ts | 6 +--- .../src/modules/bitcoin/wallet/wallet.ts | 14 ++++---- .../src/modules/wallet/index.ts | 1 + .../src/modules/wallet/types.ts | 35 ++++++++++++++++++ .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 2 +- 13 files changed, 79 insertions(+), 97 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/wallet/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/wallet/types.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts index 8a9bc77d..70499bc4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.ts @@ -1,7 +1,7 @@ import { type Keyring } from '@metamask/keyring-api'; import { Config } from './config'; -import { BtcKeyring, KeyringStateManager, type IWallet } from './keyring'; +import { BtcKeyring, KeyringStateManager } from './keyring'; import { BtcOnChainService } from './modules/bitcoin/chain'; import { type BtcWalletConfig, @@ -10,7 +10,8 @@ import { import { DataClientFactory } from './modules/bitcoin/data-client/factory'; import { getBtcNetwork } from './modules/bitcoin/utils'; import { BtcWalletFactory } from './modules/bitcoin/wallet'; -import type { IOnChainService } from './modules/chain/types'; +import type { IOnChainService } from './modules/chain'; +import type { IWallet } from './modules/wallet'; // TODO: Temp solution to support keyring in snap without keyring API export type CreateBtcKeyringOptions = { diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts index 802f931e..261b312f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts @@ -6,12 +6,12 @@ import { Chain, Config } from '../config'; import { Factory } from '../factory'; import { Network } from '../modules/bitcoin/constants'; import { type IStaticSnapRpcHandler, BaseSnapRpcHandler } from '../modules/rpc'; +import type { IWallet } from '../modules/wallet'; import { RpcHelper } from '../rpcs/helpers'; import type { StaticImplements } from '../types/static'; import { BtcKeyringError } from './exceptions'; import { BtcKeyring } from './keyring'; import { KeyringStateManager } from './state'; -import type { IWallet } from './types'; jest.mock('../modules/logger/logger', () => ({ logger: { @@ -61,10 +61,7 @@ describe('BtcKeyring', () => { KeyringStateManager.prototype, 'getAccount', ); - const getWalletByAddressNScopeSpy = jest.spyOn( - KeyringStateManager.prototype, - 'getWalletByAddressNScope', - ); + const getWalletSpy = jest.spyOn(KeyringStateManager.prototype, 'getWallet'); return { instance: new KeyringStateManager(), @@ -73,7 +70,7 @@ describe('BtcKeyring', () => { removeAccountsSpy, getAccountSpy, updateAccountSpy, - getWalletByAddressNScopeSpy, + getWalletSpy, }; }; @@ -198,13 +195,12 @@ describe('BtcKeyring', () => { describe('submitRequest', () => { it('calls SnapRpcHandler if the method support', async () => { - const { instance: stateMgr, getWalletByAddressNScopeSpy } = - createMockStateMgr(); + const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); const { instance: keyring, handleRequestSpy } = createMockKeyring(stateMgr); const account = generateAccounts(1)[0]; const params = { - scope: 'bip122:000000000019d6689c085ae165831e93', + scope: 'bip122:000000000933ea01ad0ee984209779ba', amounts: { bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah: '0.01', bc1qf5n2h6mgelkls4497pkpemew55xpew90td2qae: '0.01', @@ -213,7 +209,7 @@ describe('BtcKeyring', () => { subtractFeeFrom: ['bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah'], replaceable: false, }; - getWalletByAddressNScopeSpy.mockResolvedValue({ + getWalletSpy.mockResolvedValue({ account, index: account.options.index, scope: account.options.scope, diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts index 95edb5f0..4bca24ec 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts @@ -15,6 +15,7 @@ import { Factory } from '../factory'; import { logger } from '../modules/logger/logger'; import type { SnapRpcHandlerRequest } from '../modules/rpc'; import { SnapHelper } from '../modules/snap'; +import type { IAccount, IWallet } from '../modules/wallet'; import { RpcHelper } from '../rpcs/helpers'; import { BtcKeyringError } from './exceptions'; import type { KeyringStateManager } from './state'; @@ -22,8 +23,6 @@ import { CreateAccountOptionsStruct, type ChainRPCHandlers, type CreateAccountOptions, - type IAccount, - type IWallet, type KeyringOptions, } from './types'; @@ -163,18 +162,22 @@ export class BtcKeyring implements Keyring { throw new MethodNotFoundError() as unknown as Error; } - const walletData = await this.stateMgr.getWalletByAddressNScope( - account, - scope, - ); + const walletData = await this.stateMgr.getWallet(account); if (!walletData) { throw new Error('Account not found'); } - return this.handlers[method] - .getInstance(walletData) - .execute(params as unknown as SnapRpcHandlerRequest); + if (walletData.scope !== scope) { + throw new Error( + `Account's scope does not match with the request's scope`, + ); + } + + return this.handlers[method].getInstance(walletData).execute({ + ...params, + scope, + } as unknown as SnapRpcHandlerRequest); } async #emitEvent( diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts index 6939943f..0516bad1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts @@ -321,30 +321,26 @@ describe('KeyringStateManager', () => { }); }); - describe('getWalletByAddressNScope', () => { + describe('getWallet', () => { it('returns result', async () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); getDataSpy.mockResolvedValue(state); - const { address } = state.wallets[state.walletIds[0]].account; - const { scope } = state.wallets[state.walletIds[0]]; + const { id } = state.wallets[state.walletIds[0]].account; - const result = await instance.getWalletByAddressNScope(address, scope); + const result = await instance.getWallet(id); expect(getDataSpy).toHaveBeenCalledTimes(1); expect(result).toStrictEqual(state.wallets[state.walletIds[0]]); }); - it('returns null if the address does not exist', async () => { + it('returns null if the id does not exist', async () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); getDataSpy.mockResolvedValue(state); - const { address } = generateAccounts(1, 'notexist', 'notexist')[0]; + const { id } = generateAccounts(1, 'notexist', 'notexist')[0]; - const result = await instance.getWalletByAddressNScope( - address, - Network.Testnet, - ); + const result = await instance.getWallet(id); expect(getDataSpy).toHaveBeenCalledTimes(1); expect(result).toBeNull(); @@ -354,12 +350,9 @@ describe('KeyringStateManager', () => { const { instance, getDataSpy } = createMockStateManager(); getDataSpy.mockRejectedValue(new Error('error')); const state = createInitState(20); - const { address } = state.wallets[state.walletIds[0]].account; - const { scope } = state.wallets[state.walletIds[0]]; + const { id } = state.wallets[state.walletIds[0]].account; - await expect( - instance.getWalletByAddressNScope(address, scope), - ).rejects.toThrow(StateError); + await expect(instance.getWallet(id)).rejects.toThrow(StateError); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/state.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/state.ts index 758a4aeb..3587b1d1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/state.ts @@ -109,19 +109,10 @@ export class KeyringStateManager extends SnapStateManager { } } - async getWalletByAddressNScope( - address: string, - scope: string, - ): Promise { + async getWallet(id: string): Promise { try { const state = await this.get(); - return ( - Object.values(state.wallets).find( - (wallet) => - wallet.account.address.toString() === address.toLowerCase() && - wallet.scope.toLowerCase() === scope.toLowerCase(), - ) ?? null - ); + return state.wallets[id] ?? null; } catch (error) { throw compactError(error, StateError); } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts index 1c94d950..76d310b5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts @@ -1,10 +1,8 @@ import type { KeyringAccount } from '@metamask/keyring-api'; import { type Json } from '@metamask/snaps-sdk'; -import type { Buffer } from 'buffer'; import type { Infer } from 'superstruct'; import { object } from 'superstruct'; -import type { TransactionIntent } from '../modules/chain'; import type { IStaticSnapRpcHandler } from '../modules/rpc'; import { scopeStruct } from '../utils'; @@ -22,40 +20,6 @@ export type SnapState = { wallets: Wallets; }; -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 IWallet = { - unlock(index: number, type: string): Promise; - signTransaction(signer: IAccountSigner, txn: Buffer): Promise; - createTransaction( - acount: IAccount, - txn: TransactionIntent, - options: { - metadata: Record; - fee: number; - }, - ): Promise<{ - txn: Buffer; - txnJson: Record; - }>; -}; - export type KeyringOptions = Record & { defaultIndex: number; multiAccount?: boolean; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts index 9de12c38..e3c7992d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts @@ -1,6 +1,6 @@ import { type Network } from 'bitcoinjs-lib'; -import { type IWallet } from '../../../keyring'; +import type { IWallet } from '../../wallet'; import { type BtcWalletConfig } from '../config'; import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; import { BtcWallet } from './wallet'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts index 7f226b44..d88ff9e9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts @@ -2,7 +2,7 @@ import type { BIP32Interface } from 'bip32'; import type { Network, Payment } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; -import type { IAccount } from '../../../keyring'; +import type { IAccount } from '../../wallet'; import type { ScriptType } from '../constants'; export type IBtcAccountDeriver = { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts index 9611408e..57bf6faf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts @@ -1,5 +1,4 @@ import { networks } from 'bitcoinjs-lib'; -import { Buffer } from 'buffer'; import { createMockBip32Instance } from '../../../../test/utils'; import { ScriptType } from '../constants'; @@ -106,10 +105,7 @@ describe('BtcWallet', () => { const account = await instance.unlock(idx, ScriptType.P2wpkh); await expect( - instance.signTransaction( - account.signer, - Buffer.from('0x12312313', 'hex'), - ), + instance.signTransaction(account.signer, '0x12312313'), ).rejects.toThrow('Method not implemented.'); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts index 496bb466..7bf7bce2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts @@ -1,17 +1,19 @@ import type { Json } from '@metamask/snaps-sdk'; import type { BIP32Interface } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; -import { type Buffer } from 'buffer'; -import type { IAccountSigner } from '../../../keyring'; -import { type IAccount, type IWallet } from '../../../keyring'; import { bufferToString, compactError } from '../../../utils'; import type { TransactionIntent } from '../../chain/types'; +import type { IAccount, IWallet } from '../../wallet'; import { ScriptType } from '../constants'; import { P2WPKHAccount, P2SHP2WPKHAccount } from './account'; import { WalletError } from './exceptions'; import { AccountSigner } from './signer'; -import type { IStaticBtcAccount, IBtcAccountDeriver } from './types'; +import type { + IStaticBtcAccount, + IBtcAccountDeriver, + IAccountSigner, +} from './types'; export class BtcWallet implements IWallet { protected readonly deriver: IBtcAccountDeriver; @@ -71,7 +73,7 @@ export class BtcWallet implements IWallet { fee: number; }, ): Promise<{ - txn: Buffer; + txn: string; txnJson: Record; }> { // create PSBT @@ -82,7 +84,7 @@ export class BtcWallet implements IWallet { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - async signTransaction(signer: IAccountSigner, txn: Buffer): Promise { + async signTransaction(signer: IAccountSigner, txn: string): Promise { // convert txn to PSBT // validate PSBT // finalize PSBT diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/wallet/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/wallet/index.ts new file mode 100644 index 00000000..fcb073fe --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/wallet/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/wallet/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/wallet/types.ts new file mode 100644 index 00000000..833994fb --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/wallet/types.ts @@ -0,0 +1,35 @@ +import type { Json } from '@metamask/snaps-sdk'; +import type { Buffer } from 'buffer'; + +import type { TransactionIntent } from '../chain'; + +export type IAccount = { + mfp: string; + index: number; + address: string; + hdPath: string; + pubkey: string; + type: string; + signer: IAccountSigner; +}; + +export type IWallet = { + unlock(index: number, type: string): Promise; + signTransaction(signer: IAccountSigner, txn: string): Promise; + createTransaction( + acount: IAccount, + txn: TransactionIntent, + options: Record, + ): Promise<{ + txn: string; + txnJson: Record; + }>; +}; + +export type IAccountSigner = { + sign(hash: Buffer): Promise; + derivePath(path: string): IAccountSigner; + verify(hash: Buffer, signature: Buffer): boolean; + publicKey: Buffer; + fingerprint: Buffer; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 8c333dd8..2a7aedd9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -10,7 +10,6 @@ import { } from 'superstruct'; import { Factory } from '../factory'; -import type { IAccount, IWallet } from '../keyring'; import { type Wallet as WalletData } from '../keyring'; import type { Fees, TransactionIntent } from '../modules/chain'; import { @@ -22,6 +21,7 @@ import type { SnapRpcHandlerRequest, SnapRpcHandlerResponse, } from '../modules/rpc'; +import type { IAccount, IWallet } from '../modules/wallet'; import type { StaticImplements } from '../types/static'; import { numberStringStruct } from '../utils'; From 231e1bfd15394d071d7df55eb9b13f410ca58ede Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 May 2024 12:36:55 +0800 Subject: [PATCH 037/362] build(deps-dev): bump @metamask/snaps-jest from 7.0.2 to 8.0.0 (#50) * build(deps-dev): bump @metamask/snaps-jest from 7.0.2 to 8.0.0 Bumps @metamask/snaps-jest from 7.0.2 to 8.0.0. --- updated-dependencies: - dependency-name: "@metamask/snaps-jest" dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * Revert "build(deps-dev): bump @metamask/snaps-jest from 7.0.2 to 8.0.0" This reverts commit 16372ef9edabee97cb7607f6e1d70bc213241a20. * chore: resolve conflict by manual update package --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: stanleyyuen <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 55586b1f..2be429fc 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -48,7 +48,7 @@ "@metamask/eslint-config-nodejs": "^12.1.0", "@metamask/eslint-config-typescript": "^12.1.0", "@metamask/snaps-cli": "^6.1.0", - "@metamask/snaps-jest": "^7.0.2", + "@metamask/snaps-jest": "^8.0.0", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", "bip174": "^2.1.1", From 9381c1fc87b6d855727f0f1163e2a3002550afa0 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 24 May 2024 18:12:36 +0800 Subject: [PATCH 038/362] chore: re structure (#66) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/{modules/chain/types.ts => chain.ts} | 19 ++++++++----------- .../bitcoin-wallet-snap/src/factory.ts | 4 ++-- .../src/keyring/keyring.test.ts | 2 +- .../src/keyring/keyring.ts | 2 +- .../src/modules/bitcoin/chain/service.test.ts | 2 +- .../src/modules/bitcoin/chain/service.ts | 9 ++++----- .../data-client/clients/blockchair.test.ts | 2 +- .../bitcoin/data-client/clients/blockchair.ts | 3 ++- .../data-client/clients/blockstream.test.ts | 2 +- .../data-client/clients/blockstream.ts | 3 ++- .../src/modules/bitcoin/data-client/types.ts | 3 ++- .../modules/bitcoin/wallet/account.test.ts | 2 +- .../src/modules/bitcoin/wallet/account.ts | 3 ++- .../src/modules/bitcoin/wallet/factory.ts | 2 +- .../src/modules/bitcoin/wallet/signer.ts | 2 +- .../src/modules/bitcoin/wallet/types.ts | 18 ++++++++---------- .../src/modules/bitcoin/wallet/wallet.ts | 10 +++------- .../src/modules/chain/constants.ts | 5 ----- .../src/modules/chain/index.ts | 2 -- .../src/modules/wallet/index.ts | 1 - .../src/rpcs/estimate-fees.test.ts | 2 +- .../src/rpcs/estimate-fees.ts | 2 +- .../src/rpcs/get-transaction-data.ts | 5 ++++- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 4 ++-- .../{modules/wallet/types.ts => wallet.ts} | 2 +- 26 files changed, 51 insertions(+), 62 deletions(-) rename merged-packages/bitcoin-wallet-snap/src/{modules/chain/types.ts => chain.ts} (84%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/chain/constants.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/chain/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/wallet/index.ts rename merged-packages/bitcoin-wallet-snap/src/{modules/wallet/types.ts => wallet.ts} (93%) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index d1effe1d..bedbedd2 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "oUPt1YGfl2raWm3t8NE/tjy9A62eKi8UL359cbdeK7o=", + "shasum": "BCDqywsSgjhNr1mRIXpUSVq1Ly5NXITzKkMQ2At+sLI=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts b/merged-packages/bitcoin-wallet-snap/src/chain.ts similarity index 84% rename from merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts rename to merged-packages/bitcoin-wallet-snap/src/chain.ts index 9939e3d7..e5d4f8ff 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/chain/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/chain.ts @@ -1,4 +1,10 @@ -import type { FeeRatio } from './constants'; +import type { Json } from '@metamask/snaps-sdk'; + +export enum FeeRatio { + Fast = 'fast', + Medium = 'medium', + Slow = 'slow', +} export type Balances = Record; @@ -29,17 +35,8 @@ export type TransactionIntent = { replaceable: boolean; }; -export type Utxo = { - block: number; - txnHash: string; - index: number; - value: number; -}; - export type TransactionData = { - data: { - utxos: Utxo[]; - }; + data: Record; }; export type Pagination = { diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts index 70499bc4..37607465 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.ts @@ -1,5 +1,6 @@ import { type Keyring } from '@metamask/keyring-api'; +import type { IOnChainService } from './chain'; import { Config } from './config'; import { BtcKeyring, KeyringStateManager } from './keyring'; import { BtcOnChainService } from './modules/bitcoin/chain'; @@ -10,8 +11,7 @@ import { import { DataClientFactory } from './modules/bitcoin/data-client/factory'; import { getBtcNetwork } from './modules/bitcoin/utils'; import { BtcWalletFactory } from './modules/bitcoin/wallet'; -import type { IOnChainService } from './modules/chain'; -import type { IWallet } from './modules/wallet'; +import type { IWallet } from './wallet'; // TODO: Temp solution to support keyring in snap without keyring API export type CreateBtcKeyringOptions = { diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts index 261b312f..881e9930 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts @@ -6,9 +6,9 @@ import { Chain, Config } from '../config'; import { Factory } from '../factory'; import { Network } from '../modules/bitcoin/constants'; import { type IStaticSnapRpcHandler, BaseSnapRpcHandler } from '../modules/rpc'; -import type { IWallet } from '../modules/wallet'; import { RpcHelper } from '../rpcs/helpers'; import type { StaticImplements } from '../types/static'; +import type { IWallet } from '../wallet'; import { BtcKeyringError } from './exceptions'; import { BtcKeyring } from './keyring'; import { KeyringStateManager } from './state'; diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts index 4bca24ec..76f69755 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts @@ -15,8 +15,8 @@ import { Factory } from '../factory'; import { logger } from '../modules/logger/logger'; import type { SnapRpcHandlerRequest } from '../modules/rpc'; import { SnapHelper } from '../modules/snap'; -import type { IAccount, IWallet } from '../modules/wallet'; import { RpcHelper } from '../rpcs/helpers'; +import type { IAccount, IWallet } from '../wallet'; import { BtcKeyringError } from './exceptions'; import type { KeyringStateManager } from './state'; import { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts index b67e4b24..f5743d31 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts @@ -6,7 +6,7 @@ import { generateBlockChairBroadcastTransactionResp, generateBlockChairGetUtxosResp, } from '../../../../test/utils'; -import { FeeRatio } from '../../chain'; +import { FeeRatio } from '../../../chain'; import { BtcAsset } from '../constants'; import type { IReadDataClient, IWriteDataClient } from '../data-client'; import { BtcOnChainServiceError } from './exceptions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts index 1a9574ff..28cc0e84 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts @@ -1,9 +1,8 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; -import { compactError } from '../../../utils'; -import type { FeeRatio } from '../../chain'; import type { + FeeRatio, IOnChainService, Balances, AssetBalances, @@ -11,10 +10,10 @@ import type { Pagination, Fees, TransactionData, -} from '../../chain/types'; +} from '../../../chain'; +import { compactError } from '../../../utils'; import { BtcAsset } from '../constants'; -import type { IWriteDataClient } from '../data-client'; -import { type IReadDataClient } from '../data-client'; +import type { IWriteDataClient, IReadDataClient } from '../data-client'; import { BtcOnChainServiceError } from './exceptions'; import type { BtcOnChainServiceOptions } from './types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts index a7eb5ec8..f4eca7ac 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts @@ -7,7 +7,7 @@ import { generateBlockChairGetUtxosResp, generateBlockChairGetStatsResp, } from '../../../../../test/utils'; -import { FeeRatio } from '../../../chain'; +import { FeeRatio } from '../../../../chain'; import { DataClientError } from '../exceptions'; import { BlockChairClient } from './blockchair'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts index 3f2058f4..3bafe4d8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts @@ -1,9 +1,10 @@ import type { Json } from '@metamask/snaps-sdk'; import { type Network, networks } from 'bitcoinjs-lib'; +import { type Balances, FeeRatio } from '../../../../chain'; import { compactError } from '../../../../utils'; -import { type Balances, type Utxo, FeeRatio } from '../../../chain'; import { logger } from '../../../logger/logger'; +import type { Utxo } from '../../wallet'; import { DataClientError } from '../exceptions'; import type { GetFeeRatesResp, 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 84dbb774..703eb020 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 @@ -6,8 +6,8 @@ import { generateBlockStreamGetUtxosResp, generateBlockStreamEstFeeResp, } from '../../../../../test/utils'; +import { FeeRatio } from '../../../../chain'; import * as asyncUtils from '../../../../utils/async'; -import { FeeRatio } from '../../../chain'; import { DataClientError } from '../exceptions'; import { BlockStreamClient } from './blockstream'; 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 cd7e2452..4309e692 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 @@ -1,8 +1,9 @@ import { type Network, networks } from 'bitcoinjs-lib'; +import { type Balances, FeeRatio } from '../../../../chain'; import { compactError, processBatch } from '../../../../utils'; -import { type Balances, type Utxo, FeeRatio } from '../../../chain'; import { logger } from '../../../logger/logger'; +import type { Utxo } from '../../wallet'; import { DataClientError } from '../exceptions'; import type { GetFeeRatesResp, IReadDataClient } from '../types'; 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 index 44610b95..6f9ad9e3 100644 --- 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 @@ -1,4 +1,5 @@ -import type { Balances, FeeRatio, Utxo } from '../../chain'; +import type { Balances, FeeRatio } from '../../../chain'; +import type { Utxo } from '../wallet'; export type GetFeeRatesResp = { [key in FeeRatio]?: number; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.test.ts index eb6d2b07..89e08757 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.test.ts @@ -2,10 +2,10 @@ import type { Network, Payment } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; +import type { IAccountSigner } from '../../../wallet'; import { ScriptType } from '../constants'; import * as utils from '../utils/payment'; import { P2WPKHAccount } from './account'; -import type { IAccountSigner } from './types'; describe('BtcAccount', () => { const createMockPaymentInstance = (address: string | undefined) => { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.ts index 38dfe76f..89b5b14b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.ts @@ -2,9 +2,10 @@ import type { Network, Payment } from 'bitcoinjs-lib'; import type { StaticImplements } from '../../../types/static'; import { hexToBuffer } from '../../../utils'; +import type { IAccountSigner } from '../../../wallet'; import { ScriptType } from '../constants'; import { getBtcPaymentInst } from '../utils/payment'; -import type { IBtcAccount, IStaticBtcAccount, IAccountSigner } from './types'; +import type { IBtcAccount, IStaticBtcAccount } from './types'; export class BtcAccount implements IBtcAccount { #address: string; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts index e3c7992d..e2a5199b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts @@ -1,6 +1,6 @@ import { type Network } from 'bitcoinjs-lib'; -import type { IWallet } from '../../wallet'; +import type { IWallet } from '../../../wallet'; import { type BtcWalletConfig } from '../config'; import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; import { BtcWallet } from './wallet'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/signer.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/signer.ts index e30ba238..186827f9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/signer.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/signer.ts @@ -2,7 +2,7 @@ import type { BIP32Interface } from 'bip32'; import type { HDSignerAsync } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; -import type { IAccountSigner } from './types'; +import type { IAccountSigner } from '../../../wallet'; export class AccountSigner implements HDSignerAsync, IAccountSigner { readonly publicKey: Buffer; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts index d88ff9e9..e445d975 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts @@ -1,8 +1,7 @@ import type { BIP32Interface } from 'bip32'; import type { Network, Payment } from 'bitcoinjs-lib'; -import type { Buffer } from 'buffer'; -import type { IAccount } from '../../wallet'; +import type { IAccount, IAccountSigner } from '../../../wallet'; import type { ScriptType } from '../constants'; export type IBtcAccountDeriver = { @@ -10,14 +9,6 @@ export type IBtcAccountDeriver = { 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; }; @@ -36,3 +27,10 @@ export type IStaticBtcAccount = { signer: IAccountSigner, ): IBtcAccount; }; + +export type Utxo = { + block: number; + txnHash: string; + index: number; + value: number; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts index 7bf7bce2..52c81a2f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts @@ -2,18 +2,14 @@ import type { Json } from '@metamask/snaps-sdk'; import type { BIP32Interface } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; +import type { TransactionIntent } from '../../../chain'; import { bufferToString, compactError } from '../../../utils'; -import type { TransactionIntent } from '../../chain/types'; -import type { IAccount, IWallet } from '../../wallet'; +import type { IAccount, IAccountSigner, IWallet } from '../../../wallet'; import { ScriptType } from '../constants'; import { P2WPKHAccount, P2SHP2WPKHAccount } from './account'; import { WalletError } from './exceptions'; import { AccountSigner } from './signer'; -import type { - IStaticBtcAccount, - IBtcAccountDeriver, - IAccountSigner, -} from './types'; +import type { IStaticBtcAccount, IBtcAccountDeriver } from './types'; export class BtcWallet implements IWallet { protected readonly deriver: IBtcAccountDeriver; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/chain/constants.ts b/merged-packages/bitcoin-wallet-snap/src/modules/chain/constants.ts deleted file mode 100644 index 9e9587e4..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/chain/constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum FeeRatio { - Fast = 'fast', - Medium = 'medium', - Slow = 'slow', -} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/chain/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/chain/index.ts deleted file mode 100644 index 33c8572a..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/chain/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './types'; -export * from './constants'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/wallet/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/wallet/index.ts deleted file mode 100644 index fcb073fe..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/wallet/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.test.ts index ac24200d..58b6a45c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.test.ts @@ -1,7 +1,7 @@ +import { FeeRatio } from '../chain'; import { Factory } from '../factory'; import { Network } from '../modules/bitcoin/constants'; import { satsToBtc } from '../modules/bitcoin/utils/unit'; -import { FeeRatio } from '../modules/chain'; import { EstimateFeesHandler } from './estimate-fees'; jest.mock('../modules/logger/logger', () => ({ diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.ts index bf636fa2..ed89ffdc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.ts @@ -1,9 +1,9 @@ import type { Infer } from 'superstruct'; import { object, array, enums } from 'superstruct'; +import { FeeRatio } from '../chain'; import { Factory } from '../factory'; import { satsToBtc } from '../modules/bitcoin/utils/unit'; -import { FeeRatio } from '../modules/chain'; import { type IStaticSnapRpcHandler, type SnapRpcHandlerResponse, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.ts index 9d93c784..cfdf9cc9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.ts @@ -2,6 +2,7 @@ import type { Infer } from 'superstruct'; import { array, assign, number, object, string } from 'superstruct'; import { Factory } from '../factory'; +import type { Utxo } from '../modules/bitcoin/wallet'; import { BaseSnapRpcHandler, SnapRpcHandlerRequestStruct, @@ -55,9 +56,11 @@ export class GetTransactionDataHandler const result = await chainApi.getDataForTransaction(params.account); + const utoxs = result.data.utxos as unknown as Utxo[]; + return { data: { - utxos: result.data.utxos.map((utxo) => ({ + utxos: utoxs.map((utxo) => ({ block: utxo.block, txnHash: utxo.txnHash, index: utxo.index, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 2a7aedd9..fc038d25 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -9,9 +9,9 @@ import { boolean, } from 'superstruct'; +import type { Fees, TransactionIntent } from '../chain'; import { Factory } from '../factory'; import { type Wallet as WalletData } from '../keyring'; -import type { Fees, TransactionIntent } from '../modules/chain'; import { SnapRpcHandlerRequestStruct, BaseSnapRpcHandler, @@ -21,9 +21,9 @@ import type { SnapRpcHandlerRequest, SnapRpcHandlerResponse, } from '../modules/rpc'; -import type { IAccount, IWallet } from '../modules/wallet'; import type { StaticImplements } from '../types/static'; import { numberStringStruct } from '../utils'; +import type { IAccount, IWallet } from '../wallet'; export type SendManyParams = Infer; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/wallet/types.ts b/merged-packages/bitcoin-wallet-snap/src/wallet.ts similarity index 93% rename from merged-packages/bitcoin-wallet-snap/src/modules/wallet/types.ts rename to merged-packages/bitcoin-wallet-snap/src/wallet.ts index 833994fb..081aa6b6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/wallet/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/wallet.ts @@ -1,7 +1,7 @@ import type { Json } from '@metamask/snaps-sdk'; import type { Buffer } from 'buffer'; -import type { TransactionIntent } from '../chain'; +import type { TransactionIntent } from './chain'; export type IAccount = { mfp: string; From e1849954d4cf7d012c7c442d1f5d213899d5cd7f Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Sat, 25 May 2024 09:56:52 +0800 Subject: [PATCH 039/362] fix: update resp of chainService - boardcastTransaction (#68) --- merged-packages/bitcoin-wallet-snap/src/chain.ts | 6 +++++- .../src/modules/bitcoin/chain/service.test.ts | 4 +++- .../src/modules/bitcoin/chain/service.ts | 12 ++++++++++-- .../src/rpcs/broadcast-transaction.test.ts | 4 +++- .../src/rpcs/broadcast-transaction.ts | 8 +------- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/chain.ts b/merged-packages/bitcoin-wallet-snap/src/chain.ts index e5d4f8ff..2577e17d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/chain.ts +++ b/merged-packages/bitcoin-wallet-snap/src/chain.ts @@ -44,10 +44,14 @@ export type Pagination = { offset: number; }; +export type CommitedTransaction = { + transactionId: string; +}; + export type IOnChainService = { getBalances(addresses: string[], assets: string[]): Promise; estimateFees(): Promise; - boardcastTransaction(signedTransaction: string): Promise; + boardcastTransaction(signedTransaction: string): Promise; listTransactions(address: string, pagination: Pagination); getTransaction(txnHash: string); getDataForTransaction( diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts index f5743d31..ebef840f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts @@ -234,7 +234,9 @@ describe('BtcOnChainService', () => { const result = await txnService.boardcastTransaction(signedTransaction); expect(sendTransactionSpy).toHaveBeenCalledWith(signedTransaction); - expect(result).toStrictEqual(resp.data.transaction_hash); + expect(result).toStrictEqual({ + transactionId: resp.data.transaction_hash, + }); }); it('throws BtcOnChainServiceErrorr if write client execute fail', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts index 28cc0e84..0a6cab37 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts @@ -10,6 +10,7 @@ import type { Pagination, Fees, TransactionData, + CommitedTransaction, } from '../../../chain'; import { compactError } from '../../../utils'; import { BtcAsset } from '../constants'; @@ -119,9 +120,16 @@ export class BtcOnChainService implements IOnChainService { } } - async boardcastTransaction(signedTransaction: string): Promise { + async boardcastTransaction( + signedTransaction: string, + ): Promise { try { - return await this.writeClient.sendTransaction(signedTransaction); + const transactionId = await this.writeClient.sendTransaction( + signedTransaction, + ); + return { + transactionId, + }; } catch (error) { throw compactError(error, BtcOnChainServiceError); } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.test.ts index 2773dbed..b098983e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.test.ts @@ -31,7 +31,9 @@ describe('BroadcastTransactionHandler', () => { it('broadcast an transaction', async () => { const { boardcastTransactionSpy } = createMockChainApiFactory(); const resp = generateBlockChairBroadcastTransactionResp(); - boardcastTransactionSpy.mockResolvedValue(resp.data.transaction_hash); + boardcastTransactionSpy.mockResolvedValue({ + transactionId: resp.data.transaction_hash, + }); const result = await BroadcastTransactionHandler.getInstance().execute({ scope: Network.Testnet, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.ts index a3e586ed..effafe88 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.ts @@ -46,12 +46,6 @@ export class BroadcastTransactionHandler const chainApi = Factory.createOnChainServiceProvider(scope); - const transactionId = await chainApi.boardcastTransaction( - signedTransaction, - ); - - return { - transactionId, - }; + return await chainApi.boardcastTransaction(signedTransaction); } } From aa3ec9b269a3ca374c8bd10e6ce6d65ab8568969 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Sat, 25 May 2024 09:57:29 +0800 Subject: [PATCH 040/362] chore: update snap provider instance name (#69) --- .../src/keyring/keyring.test.ts | 26 +++++++++++++++++++ .../src/keyring/keyring.ts | 2 +- .../src/modules/snap/helpers.test.ts | 10 +++---- .../src/modules/snap/helpers.ts | 12 ++++----- 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts index 881e9930..c8e064cd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts @@ -246,6 +246,32 @@ describe('BtcKeyring', () => { ).rejects.toThrow('Account not found'); }); + it("throws `Account's scope does not match with the request's scope` error if given scope is not match with the account", async () => { + const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + const account = generateAccounts(1)[0]; + + getWalletSpy.mockResolvedValue({ + account, + index: account.options.index, + scope: account.options.scope, + hdPath: getHdPath(account.options.index), + }); + + await expect( + keyring.submitRequest({ + id: account.id, + scope: Network.Mainnet, + account: account.address, + request: { + method: 'btc_sendmany', + }, + }), + ).rejects.toThrow( + "Account's scope does not match with the request's scope", + ); + }); + it('throws MethodNotFoundError if the method not support', async () => { const { instance: stateMgr } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts index 76f69755..12da4269 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts @@ -186,7 +186,7 @@ export class BtcKeyring implements Keyring { ): Promise { // TODO: Temp solutio to support keyring in snap without keyring API if (this.options.emitEvents) { - await emitSnapKeyringEvent(SnapHelper.wallet, event, data); + await emitSnapKeyringEvent(SnapHelper.provider, event, data); } } 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 index 5fb7bef7..459889ce 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.test.ts @@ -10,7 +10,7 @@ jest.mock('@metamask/key-tree', () => ({ describe('SnapHelper', () => { describe('getBip44Deriver', () => { it('gets bip44 deriver', async () => { - const spy = jest.spyOn(SnapHelper.wallet, 'request'); + const spy = jest.spyOn(SnapHelper.provider, 'request'); const coinType = 1001; await SnapHelper.getBip44Deriver(coinType); @@ -26,7 +26,7 @@ describe('SnapHelper', () => { describe('getBip32Deriver', () => { it('gets bip32 deriver', async () => { - const spy = jest.spyOn(SnapHelper.wallet, 'request'); + const spy = jest.spyOn(SnapHelper.provider, 'request'); const path = ['m', "84'", "0'"]; const curve = 'secp256k1'; @@ -44,7 +44,7 @@ describe('SnapHelper', () => { describe('confirmDialog', () => { it('calls snap_dialog', async () => { - const spy = jest.spyOn(SnapHelper.wallet, 'request'); + const spy = jest.spyOn(SnapHelper.provider, 'request'); const testcase = { header: 'header', subHeader: 'subHeader', @@ -78,7 +78,7 @@ describe('SnapHelper', () => { describe('getStateData', () => { it('gets state data', async () => { - const spy = jest.spyOn(SnapHelper.wallet, 'request'); + const spy = jest.spyOn(SnapHelper.provider, 'request'); const testcase = { state: { transaction: [ @@ -106,7 +106,7 @@ describe('SnapHelper', () => { describe('setStateData', () => { it('sets state data', async () => { - const spy = jest.spyOn(SnapHelper.wallet, 'request'); + const spy = jest.spyOn(SnapHelper.provider, 'request'); const testcase = { state: { transaction: [ 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 782def2a..42aa28fe 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.ts @@ -15,12 +15,12 @@ import { declare const snap: SnapsProvider; export class SnapHelper { - static wallet: SnapsProvider = snap; + static provider: SnapsProvider = snap; static async getBip44Deriver( coinType: number, ): Promise { - const bip44Node = await SnapHelper.wallet.request({ + const bip44Node = await SnapHelper.provider.request({ method: 'snap_getBip44Entropy', params: { coinType, @@ -33,7 +33,7 @@ export class SnapHelper { path: string[], curve: 'secp256k1' | 'ed25519', ): Promise { - const node = await SnapHelper.wallet.request({ + const node = await SnapHelper.provider.request({ method: 'snap_getBip32Entropy', params: { path, @@ -48,7 +48,7 @@ export class SnapHelper { subHeader: string, body: Record, ): Promise { - return SnapHelper.wallet.request({ + return SnapHelper.provider.request({ method: 'snap_dialog', params: { type: 'confirmation', @@ -65,7 +65,7 @@ export class SnapHelper { } static async getStateData(): Promise { - return (await SnapHelper.wallet.request({ + return (await SnapHelper.provider.request({ method: 'snap_manageState', params: { operation: 'get', @@ -74,7 +74,7 @@ export class SnapHelper { } static async setStateData(data: State) { - await SnapHelper.wallet.request({ + await SnapHelper.provider.request({ method: 'snap_manageState', params: { operation: 'update', From f522102097cd4337cc7ddc4e426655647c704067 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 28 May 2024 11:42:16 +0800 Subject: [PATCH 041/362] feat: implement keyring api handler btc_sendmany (#65) * feat: add btc_sendmany method * chore: update error handle * chore: add validation * fix: fix RBF sequence --- .../bitcoin-wallet-snap/package.json | 2 + .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/chain.ts | 34 +- .../src/config/permissions.ts | 3 + .../src/keyring/keyring.ts | 3 +- .../src/keyring/state.test.ts | 10 +- .../src/modules/bitcoin/chain/service.test.ts | 6 +- .../src/modules/bitcoin/chain/service.ts | 2 +- .../src/modules/bitcoin/constants.ts | 25 + .../data-client/clients/blockstream.test.ts | 6 +- .../data-client/clients/blockstream.ts | 12 +- .../src/modules/bitcoin/utils/index.ts | 1 + .../src/modules/bitcoin/utils/unit.test.ts | 14 +- .../src/modules/bitcoin/utils/unit.ts | 15 + .../bitcoin/wallet/coin-select.test.ts | 45 ++ .../src/modules/bitcoin/wallet/coin-select.ts | 68 +++ .../src/modules/bitcoin/wallet/exceptions.ts | 10 + .../src/modules/bitcoin/wallet/index.ts | 1 + .../src/modules/bitcoin/wallet/psbt.test.ts | 456 ++++++++++++++++++ .../src/modules/bitcoin/wallet/psbt.ts | 153 ++++++ .../src/modules/bitcoin/wallet/types.ts | 27 ++ .../src/modules/bitcoin/wallet/wallet.test.ts | 273 +++++++++-- .../src/modules/bitcoin/wallet/wallet.ts | 132 +++-- .../src/modules/rpc/base.ts | 17 +- .../src/modules/rpc/exceptions.ts | 2 +- .../src/modules/snap/helpers.test.ts | 42 +- .../src/modules/snap/helpers.ts | 39 +- .../src/rpcs/broadcast-transaction.test.ts | 14 +- .../src/rpcs/broadcast-transaction.ts | 2 +- .../src/rpcs/estimate-fees.test.ts | 6 +- .../src/rpcs/estimate-fees.ts | 4 +- .../src/rpcs/get-balances.test.ts | 6 +- .../src/rpcs/get-balances.ts | 4 +- .../src/rpcs/get-transaction-data.test.ts | 6 +- .../src/rpcs/sendmany.test.ts | 362 ++++++++++++++ .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 156 ++++-- .../src/utils/superstruct.test.ts | 24 +- .../src/utils/superstruct.ts | 5 +- .../bitcoin-wallet-snap/src/wallet.ts | 78 ++- .../bitcoin-wallet-snap/test/utils.ts | 43 +- 40 files changed, 1916 insertions(+), 194 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/coin-select.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/coin-select.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/psbt.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/psbt.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 2be429fc..22c3e243 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -36,6 +36,8 @@ "bip32": "^4.0.0", "bitcoinjs-lib": "^6.1.5", "buffer": "^6.0.3", + "coinselect": "^3.1.13", + "ecpair": "^2.1.0", "superstruct": "^1.0.3", "uuid": "^9.0.1" }, diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index bedbedd2..422914e6 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "BCDqywsSgjhNr1mRIXpUSVq1Ly5NXITzKkMQ2At+sLI=", + "shasum": "vWypO8fpSt9kBJueagQk+bvEqiVggRfRJcbdQYPzl5I=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/chain.ts b/merged-packages/bitcoin-wallet-snap/src/chain.ts index 2577e17d..8884fc43 100644 --- a/merged-packages/bitcoin-wallet-snap/src/chain.ts +++ b/merged-packages/bitcoin-wallet-snap/src/chain.ts @@ -1,4 +1,24 @@ import type { Json } from '@metamask/snaps-sdk'; +import type { Infer } from 'superstruct'; +import { object, define, string, array, boolean } from 'superstruct'; + +const transactionIntentAmts = () => + define>( + 'transactionIntentAmts', + (value: Record) => { + if (Object.entries(value).length === 0) { + return 'Transaction must have at least one recipient'; + } + + for (const val of Object.values(value)) { + if (val <= 0) { + return 'Invalid amount for send'; + } + } + + return true; + }, + ); export enum FeeRatio { Fast = 'fast', @@ -29,11 +49,13 @@ export type Fees = { fees: Fee[]; }; -export type TransactionIntent = { - amounts: Record; - subtractFeeFrom: string[]; - replaceable: boolean; -}; +export const TransactionIntentStruct = object({ + amounts: transactionIntentAmts(), + subtractFeeFrom: array(string()), + replaceable: boolean(), +}); + +export type TransactionIntent = Infer; export type TransactionData = { data: Record; @@ -51,7 +73,7 @@ export type CommitedTransaction = { export type IOnChainService = { getBalances(addresses: string[], assets: string[]): Promise; estimateFees(): Promise; - boardcastTransaction(signedTransaction: string): Promise; + broadcastTransaction(signedTransaction: string): Promise; listTransactions(address: string, pagination: Pagination); getTransaction(txnHash: string); getDataForTransaction( diff --git a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts index 9bbb8756..7d4ebddd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts @@ -13,6 +13,7 @@ export const originPermissions = new Map>([ KeyringRpcMethod.GetRequest, KeyringRpcMethod.SubmitRequest, KeyringRpcMethod.RejectRequest, + KeyringRpcMethod.SubmitRequest, // Chain API methods 'chain_getBalances', 'chain_broadcastTransaction', @@ -34,6 +35,7 @@ export const originPermissions = new Map>([ KeyringRpcMethod.GetRequest, KeyringRpcMethod.ApproveRequest, KeyringRpcMethod.RejectRequest, + KeyringRpcMethod.SubmitRequest, // Chain API methods 'chain_getBalances', 'chain_createAccount', @@ -56,6 +58,7 @@ export const originPermissions = new Map>([ KeyringRpcMethod.GetRequest, KeyringRpcMethod.ApproveRequest, KeyringRpcMethod.RejectRequest, + KeyringRpcMethod.SubmitRequest, // Chain API methods 'chain_getBalances', 'chain_broadcastTransaction', diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts index 12da4269..44c13fce 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts @@ -158,6 +158,7 @@ export class BtcKeyring implements Keyring { protected async handleSubmitRequest(request: KeyringRequest): Promise { const { scope, account } = request; const { method, params } = request.request; + if (!Object.prototype.hasOwnProperty.call(this.handlers, method)) { throw new MethodNotFoundError() as unknown as Error; } @@ -184,7 +185,7 @@ export class BtcKeyring implements Keyring { event: KeyringEvent, data: Record, ): Promise { - // TODO: Temp solutio to support keyring in snap without keyring API + // TODO: Temp solution to support keyring in snap without extentions support if (this.options.emitEvents) { await emitSnapKeyringEvent(SnapHelper.provider, event, data); } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts index 0516bad1..d7e43f73 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts @@ -120,7 +120,7 @@ describe('KeyringStateManager', () => { it('adds an new wallet when state is not empty', async () => { const { instance, getDataSpy, setDataSpy } = createMockStateManager(); const state = createInitState(5); - const accountToSave = generateAccounts(1, 'new', 'new')[0]; + const accountToSave = generateAccounts(1, 'new')[0]; getDataSpy.mockResolvedValue(state); await instance.addWallet({ @@ -197,7 +197,7 @@ describe('KeyringStateManager', () => { 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]; + const nonExistAcc = generateAccounts(1, 'notexist')[0]; getDataSpy.mockResolvedValue(state); await expect( @@ -236,7 +236,7 @@ describe('KeyringStateManager', () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); getDataSpy.mockResolvedValue(state); - const { id } = generateAccounts(1, 'notexist', 'notexist')[0]; + const { id } = generateAccounts(1, 'notexist')[0]; const result = await instance.getAccount(id); @@ -281,7 +281,7 @@ describe('KeyringStateManager', () => { it('throw StateError if the account does not exist', async () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); - const accToUpdate = generateAccounts(1, 'notexist', 'notexist')[0]; + const accToUpdate = generateAccounts(1, 'notexist')[0]; getDataSpy.mockResolvedValue(state); await expect(instance.updateAccount(accToUpdate)).rejects.toThrow( @@ -338,7 +338,7 @@ describe('KeyringStateManager', () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); getDataSpy.mockResolvedValue(state); - const { id } = generateAccounts(1, 'notexist', 'notexist')[0]; + const { id } = generateAccounts(1, 'notexist')[0]; const result = await instance.getWallet(id); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts index ebef840f..7fde9494 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts @@ -217,7 +217,7 @@ describe('BtcOnChainService', () => { }); }); - describe('boardcastTransaction', () => { + describe('broadcastTransaction', () => { const signedTransaction = '02000000000101ec81faa8b57add4c8fb3958dd8f04667f5cd829a7b94199f4400be9e52cda0760000000000ffffffff015802000000000000160014f80b562cbcbbfc97727043484c06cc5579963e8402473044022011ec3f7ea7a7cac7cb891a1ea498d94ca3cd082339b9b2620ba5421ca7cbdf3d022062f34411d6aa5335c2bd7ff4c940adb962e9509133b86a2d97996552fd811f2c012102ceea82614fdb14871ef881498c55c5dbdc24b4633d29b42040dd18b4285540f500000000'; @@ -231,7 +231,7 @@ describe('BtcOnChainService', () => { const resp = generateBlockChairBroadcastTransactionResp(); sendTransactionSpy.mockResolvedValue(resp.data.transaction_hash); - const result = await txnService.boardcastTransaction(signedTransaction); + const result = await txnService.broadcastTransaction(signedTransaction); expect(sendTransactionSpy).toHaveBeenCalledWith(signedTransaction); expect(result).toStrictEqual({ @@ -248,7 +248,7 @@ describe('BtcOnChainService', () => { sendTransactionSpy.mockRejectedValue(new Error('error')); await expect( - txnService.boardcastTransaction(signedTransaction), + txnService.broadcastTransaction(signedTransaction), ).rejects.toThrow(BtcOnChainServiceError); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts index 0a6cab37..4d8cf776 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts @@ -120,7 +120,7 @@ export class BtcOnChainService implements IOnChainService { } } - async boardcastTransaction( + async broadcastTransaction( signedTransaction: string, ): Promise { try { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/constants.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/constants.ts index 86cef91f..d334a55d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/constants.ts @@ -18,3 +18,28 @@ export enum BtcAsset { Btc = 'bip122:000000000019d6689c085ae165831e93/slip44:0', TBtc = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0', } + +// reference https://help.magiceden.io/en/articles/8665399-navigating-bitcoin-dust-understanding-limits-and-safeguarding-your-transactions-on-magic-eden +// "Dust" is defined in terms of dustRelayFee, +// which has units satoshis-per-kilobyte. +// If you'd pay more in fees than the value of the output +// to spend something, then we consider it dust. +// A typical spendable non-segwit txout is 34 bytes big, and will +// need a CTxIn of at least 148 bytes to spend: +// so dust is a spendable txout less than +// 182*dustRelayFee/1000 (in satoshis). +// 546 satoshis at the default rate of 3000 sat/kvB. +// A typical spendable segwit P2WPKH txout is 31 bytes big, and will +// need a CTxIn of at least 67 bytes to spend: +// so dust is a spendable txout less than +// 98*dustRelayFee/1000 (in satoshis). +// 294 satoshis at the default rate of 3000 sat/kvB. +export const DustLimit = { + /* eslint-disable */ + p2pkh: 546, + 'p2sh-p2wpkh': 540, + p2wpkh: 294, + /* eslint-disable */ +}; + +export const MaxStandardTxWeight = 400000; 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 703eb020..35aea6b4 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 @@ -115,9 +115,9 @@ describe('BlockStreamClient', () => { const result = await instance.getFeeRates(); expect(result).toStrictEqual({ - [FeeRatio.Fast]: mockResponse['1'], - [FeeRatio.Medium]: mockResponse['25'], - [FeeRatio.Slow]: mockResponse['144'], + [FeeRatio.Fast]: Math.round(mockResponse['1']), + [FeeRatio.Medium]: Math.round(mockResponse['25']), + [FeeRatio.Slow]: Math.round(mockResponse['144']), }); }); 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 4309e692..91f0c2c7 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 @@ -158,9 +158,15 @@ export class BlockStreamClient implements IReadDataClient { `[BlockStreamClient.getFeeRates] response: ${JSON.stringify(response)}`, ); return { - [FeeRatio.Fast]: response[this.feeRateRatioMap[FeeRatio.Fast]], - [FeeRatio.Medium]: response[this.feeRateRatioMap[FeeRatio.Medium]], - [FeeRatio.Slow]: response[this.feeRateRatioMap[FeeRatio.Slow]], + [FeeRatio.Fast]: Math.round( + response[this.feeRateRatioMap[FeeRatio.Fast]], + ), + [FeeRatio.Medium]: Math.round( + response[this.feeRateRatioMap[FeeRatio.Medium]], + ), + [FeeRatio.Slow]: Math.round( + response[this.feeRateRatioMap[FeeRatio.Slow]], + ), }; } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/index.ts index 43620c9e..b89021ae 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/index.ts @@ -1,2 +1,3 @@ export * from './payment'; export * from './network'; +export * from './unit'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.test.ts index 63149ef1..fcef376e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.test.ts @@ -1,4 +1,5 @@ -import { satsToBtc, btcToSats } from './unit'; +import { DustLimit, ScriptType } from '../constants'; +import { satsToBtc, btcToSats, isDust } from './unit'; describe('satsToBtc', () => { it('returns Btc unit', () => { @@ -42,3 +43,14 @@ describe('btcToSats', () => { expect(btcToSats(parseFloat(btc))).toBe('1'); }); }); + +describe('isDust', () => { + it('returns result', () => { + expect(isDust(DustLimit[ScriptType.P2wpkh] + 1, ScriptType.P2wpkh)).toBe( + false, + ); + expect(isDust(DustLimit[ScriptType.P2wpkh] - 1, ScriptType.P2wpkh)).toBe( + true, + ); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.ts index 479b2534..f00394a3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.ts @@ -1,3 +1,6 @@ +import type { ScriptType } from '../constants'; +import { DustLimit } from '../constants'; + /** * A Method to convert BTC to Satoshis. * @@ -20,3 +23,15 @@ export function satsToBtc(sats: number): string { export function btcToSats(btc: number): string { return (btc * 1e8).toFixed(0); } + +/** + * A Method to determine the given amount is dust base on the hardcoded dust limit by script type. + * + * @param amt - Compare amount. + * @param scriptType - Script type. + * @returns Boolean result. + */ +export function isDust(amt: number, scriptType: ScriptType): boolean { + // TODO: calculate dust threshold by network fee rate + return amt < DustLimit[scriptType]; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/coin-select.test.ts new file mode 100644 index 00000000..ebcd287c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/coin-select.test.ts @@ -0,0 +1,45 @@ +import { Buffer } from 'buffer'; + +import { + generateAccounts, + generateFormatedUtxos, +} from '../../../../test/utils'; +import { CoinSelectService } from './coin-select'; + +describe('CoinSelectService', () => { + describe('selectCoins', () => { + it('selects utxos', async () => { + const accounts = generateAccounts(2); + const { address } = accounts[0]; + const utxos = generateFormatedUtxos(address, 2, 2000, 1000000); + + const coinSelectService = new CoinSelectService(1); + + const result = coinSelectService.selectCoins( + utxos, + [{ address: accounts[1].address, value: 1000 }], + Buffer.from('scriptOutput'), + ); + + expect(result).toHaveProperty('inputs', expect.any(Array)); + expect(result).toHaveProperty('outputs', expect.any(Array)); + expect(result).toHaveProperty('fee', expect.any(Number)); + }); + + it('throws `not enough funds` error if the given utxos is not sufficient', async () => { + const accounts = generateAccounts(2); + const { address } = accounts[0]; + const utxos = generateFormatedUtxos(address, 1, 1, 1); + + const coinSelectService = new CoinSelectService(100); + + expect(() => + coinSelectService.selectCoins( + utxos, + [{ address: accounts[1].address, value: 1000 }], + Buffer.from('scriptOutput'), + ), + ).toThrow('Not enough funds'); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/coin-select.ts new file mode 100644 index 00000000..00691b6f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/coin-select.ts @@ -0,0 +1,68 @@ +import { crypto as cryptoUtils } from 'bitcoinjs-lib'; +import type { Buffer } from 'buffer'; +import coinSelect from 'coinselect'; + +import { hexToBuffer } from '../../../utils'; +import { UtxoServiceError } from './exceptions'; +import type { SpendTo, SelectedUtxos, Utxo } from './types'; + +export class CoinSelectService { + protected readonly feeRate: number; + + constructor(feeRate: number) { + this.feeRate = Math.round(feeRate); + } + + /** + * Selects UTXOs to spend for segwit output. + * @param utxos - Array of UTXOs. + * @param spendTos - Array of SpendTo objects. + * @param script - Script hash of the segwit output. + * @returns Selected UTXOs. + */ + selectCoins( + utxos: Utxo[], + spendTos: SpendTo[], + script: Buffer, + ): SelectedUtxos { + const utxosMap = new Map(); + + const spendFrom = utxos.map((utxo) => { + const id = cryptoUtils + .sha256( + hexToBuffer( + `${utxo.txnHash},${utxo.block},${utxo.index},${utxo.value}`, + false, + ), + ) + .toString('hex'); + + utxosMap.set(id, utxo); + + return { + id, + value: utxo.value, + script, + }; + }); + + const result = coinSelect(spendFrom, spendTos, this.feeRate); + + if (!result.inputs || !result.outputs) { + throw new UtxoServiceError('Not enough funds'); + } + + const inputs: Utxo[] = []; + for (const input of result.inputs) { + const utxo = utxosMap.get(input.id); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + inputs.push(utxo!); + } + + return { + ...result, + inputs, + }; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/exceptions.ts index b4bec314..66b2d8cd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/exceptions.ts @@ -5,3 +5,13 @@ export class DeriverError extends CustomError {} export class WalletFactoryError extends CustomError {} export class WalletError extends CustomError {} + +export class PsbtServiceError extends CustomError {} + +export class PsbtSigValidateError extends CustomError {} + +export class PsbtValidateError extends CustomError {} + +export class PsbtUpdateWithnessUtxoError extends CustomError {} + +export class UtxoServiceError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/index.ts index 7115f817..f85939e0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/index.ts @@ -1,3 +1,4 @@ +export * from './exceptions'; export * from './account'; export * from './factory'; export * from './types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/psbt.test.ts new file mode 100644 index 00000000..99c0ee9c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/psbt.test.ts @@ -0,0 +1,456 @@ +import ecc from '@bitcoinerlab/secp256k1'; +import type { SLIP10NodeInterface } from '@metamask/key-tree'; +import { BIP32Factory } from 'bip32'; +import { Transaction, networks } from 'bitcoinjs-lib'; +import ECPairFactory from 'ecpair'; + +import { generateFormatedUtxos } from '../../../../test/utils'; +import { hexToBuffer } from '../../../utils'; +import { SnapHelper } from '../../snap'; +import { MaxStandardTxWeight, ScriptType } from '../constants'; +import { BtcAccountBip32Deriver } from './deriver'; +import { PsbtServiceError } from './exceptions'; +import { PsbtService } from './psbt'; +import { BtcWallet } from './wallet'; + +jest.mock('../../logger/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + }, +})); + +describe('PsbtService', () => { + const createMockBip32Instance = (network) => { + const ECPair = ECPairFactory(ecc); + const bip32 = BIP32Factory(ecc); + + const keyPair = ECPair.makeRandom(); + const deriver = bip32.fromSeed(keyPair.publicKey, network); + + const jsonData = { + privateKey: deriver.privateKey?.toString('hex'), + publicKey: deriver.publicKey.toString('hex'), + chainCode: deriver.chainCode.toString('hex'), + depth: deriver.depth, + index: deriver.index, + curve: 'secp256k1', + masterFingerprint: undefined, + parentFingerprint: 0, + }; + jest.spyOn(SnapHelper, 'getBip32Deriver').mockResolvedValue({ + ...jsonData, + chainCodeBytes: deriver.chainCode, + privateKeyBytes: deriver.privateKey, + publicKeyBytes: deriver.publicKey, + toJSON: jest.fn().mockReturnValue(jsonData), + } as unknown as SLIP10NodeInterface); + + return { + instance: new BtcAccountBip32Deriver(network), + }; + }; + + const createMockWallet = (network) => { + const { instance: deriver } = createMockBip32Instance(network); + + const instance = new BtcWallet(deriver, network); + return { + instance, + }; + }; + + describe('constructor', () => { + const preparePsbt = async () => { + const network = networks.testnet; + const service = new PsbtService(network); + const wallet = createMockWallet(network); + const sender = await wallet.instance.unlock(0, ScriptType.P2wpkh); + const mfpBuf = hexToBuffer(sender.mfp, false); + const pubkeyBuf = hexToBuffer(sender.pubkey, false); + const receiver1 = await wallet.instance.unlock(1, ScriptType.P2wpkh); + const receiver2 = await wallet.instance.unlock(2, ScriptType.P2wpkh); + const receivers = [receiver1, receiver2]; + const finalizeSpy = jest.spyOn(service.psbt, 'finalizeAllInputs'); + const outputVal = 1000; + const fee = 500; + const outputs = receivers.map((account) => ({ + address: account.address, + value: outputVal, + })); + // it has to limit the inputs because it may fail due to alert of paying too much gas fee + const inputs = generateFormatedUtxos( + sender.address, + 1, + outputVal * outputs.length + fee, + outputVal * outputs.length + fee, + ); + + service.addOutputs(outputs); + + service.addInputs( + inputs, + mfpBuf, + pubkeyBuf, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + sender.payment.output!, + sender.hdPath, + true, + ); + + return { + service, + sender, + finalizeSpy, + }; + }; + + it('constructor with a new psbt object', async () => { + const network = networks.testnet; + + const service = new PsbtService(network); + + expect(service.psbt.txInputs).toHaveLength(0); + }); + + it('constructor with an psbt base string', async () => { + const { service } = await preparePsbt(); + const psbtBase64 = service.toBase64(); + + const newService = PsbtService.fromBase64(networks.testnet, psbtBase64); + + expect(newService.psbt.txInputs).toHaveLength(1); + expect(newService.psbt.txOutputs).toHaveLength(2); + }); + }); + + describe('addInputs', () => { + it('adds witnessUtxos to psbt object', async () => { + const network = networks.testnet; + const service = new PsbtService(network); + const wallet = createMockWallet(network); + const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); + const inputs = generateFormatedUtxos(account.address, 2); + const mfpBuf = hexToBuffer(account.mfp, false); + const pubkeyBuf = hexToBuffer(account.pubkey, false); + const psbtSpy = jest.spyOn(service.psbt, 'addInput'); + + service.addInputs( + inputs, + mfpBuf, + pubkeyBuf, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + account.payment.output!, + account.hdPath, + false, + ); + + expect(service.psbt.txInputs).toHaveLength(2); + + for (let i = 0; i < inputs.length; i++) { + expect(psbtSpy).toHaveBeenNthCalledWith(i + 1, { + hash: inputs[i].txnHash, + index: inputs[i].index, + witnessUtxo: { + script: account.payment.output, + value: inputs[i].value, + }, + bip32Derivation: [ + { + masterFingerprint: mfpBuf, + path: account.hdPath, + pubkey: pubkeyBuf, + }, + ], + sequence: Transaction.DEFAULT_SEQUENCE, + }); + } + }); + + it('opt-ins RBF into the psbt input', async () => { + const network = networks.testnet; + const service = new PsbtService(network); + const wallet = createMockWallet(network); + const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); + const inputs = generateFormatedUtxos(account.address, 2); + const mfpBuf = hexToBuffer(account.mfp, false); + const pubkeyBuf = hexToBuffer(account.pubkey, false); + const psbtSpy = jest.spyOn(service.psbt, 'addInput'); + + service.addInputs( + inputs, + mfpBuf, + pubkeyBuf, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + account.payment.output!, + account.hdPath, + true, + ); + + for (let i = 0; i < inputs.length; i++) { + expect(psbtSpy).toHaveBeenNthCalledWith(i + 1, { + hash: inputs[i].txnHash, + index: inputs[i].index, + witnessUtxo: { + script: account.payment.output, + value: inputs[i].value, + }, + bip32Derivation: [ + { + masterFingerprint: mfpBuf, + path: account.hdPath, + pubkey: pubkeyBuf, + }, + ], + sequence: Transaction.DEFAULT_SEQUENCE - 2, + }); + } + }); + + it('throws `Failed to add inputs in PSBT` error if adding inputs fails', async () => { + const network = networks.testnet; + const service = new PsbtService(network); + const wallet = createMockWallet(network); + const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); + const inputs = generateFormatedUtxos(account.address, 2); + const mfpBuf = hexToBuffer(account.mfp, false); + const pubkeyBuf = hexToBuffer(account.pubkey, false); + + jest.spyOn(service.psbt, 'addInput').mockImplementation(() => { + throw new Error('error'); + }); + + expect(() => { + service.addInputs( + inputs, + mfpBuf, + pubkeyBuf, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + account.payment.output!, + account.hdPath, + true, + ); + }).toThrow('Failed to add inputs in PSBT'); + }); + }); + + describe('addOutputs', () => { + it('adds outputs to psbt object', async () => { + const network = networks.testnet; + const service = new PsbtService(network); + const wallet = createMockWallet(network); + const receiver1 = await wallet.instance.unlock(1, ScriptType.P2wpkh); + const receiver2 = await wallet.instance.unlock(2, ScriptType.P2wpkh); + const receivers = [receiver1, receiver2]; + + const outputs = receivers.map((account) => ({ + address: account.address, + value: 1000, + })); + + service.addOutputs(outputs); + + expect(service.psbt.txOutputs).toHaveLength(outputs.length); + + for (let i = 0; i < service.psbt.txOutputs.length; i++) { + expect(service.psbt.txOutputs[i]).toHaveProperty( + 'script', + receivers[i].payment.output, + ); + expect(service.psbt.txOutputs[i]).toHaveProperty( + 'value', + outputs[i].value, + ); + expect(service.psbt.txOutputs[i]).toHaveProperty( + 'address', + outputs[i].address, + ); + } + }); + + it('throws `Failed to add outputs in PSBT` error if adding outputs fails', async () => { + const network = networks.testnet; + const service = new PsbtService(network); + + const outputs = [ + { + address: 'address', + value: 1000, + }, + ]; + + expect(() => service.addOutputs(outputs)).toThrow( + 'Failed to add outputs in PSBT', + ); + }); + }); + + describe('toBase64', () => { + it('returns base64 string of psbt object', async () => { + const network = networks.testnet; + const service = new PsbtService(network); + + const result = service.toBase64(); + + expect(result).toBe(service.psbt.toBase64()); + }); + + it('throws `Failed to output PSBT string` error if the operation failed', async () => { + const network = networks.testnet; + const service = new PsbtService(network); + + jest.spyOn(service.psbt, 'toBase64').mockImplementation(() => { + throw new Error('error'); + }); + + expect(() => service.toBase64()).toThrow('Failed to output PSBT string'); + }); + }); + + describe('signNVerify', () => { + const prepareToSign = async () => { + const network = networks.testnet; + const service = new PsbtService(network); + const wallet = createMockWallet(network); + const sender = await wallet.instance.unlock(0, ScriptType.P2wpkh); + const inputs = generateFormatedUtxos(sender.address, 2); + const mfpBuf = hexToBuffer(sender.mfp, false); + const pubkeyBuf = hexToBuffer(sender.pubkey, false); + const receiver1 = await wallet.instance.unlock(1, ScriptType.P2wpkh); + const receiver2 = await wallet.instance.unlock(2, ScriptType.P2wpkh); + const receivers = [receiver1, receiver2]; + const signSpy = jest.spyOn(service.psbt, 'signAllInputsHDAsync'); + const verifySpy = jest.spyOn( + service.psbt, + 'validateSignaturesOfAllInputs', + ); + + const outputs = receivers.map((account) => ({ + address: account.address, + value: 1000, + })); + + service.addOutputs(outputs); + + service.addInputs( + inputs, + mfpBuf, + pubkeyBuf, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + sender.payment.output!, + sender.hdPath, + true, + ); + + return { + service, + sender, + signSpy, + verifySpy, + }; + }; + + it('signs all inputs with the given signer', async () => { + const { service, sender, signSpy, verifySpy } = await prepareToSign(); + + await service.signNVerify(sender.signer); + + expect(signSpy).toHaveBeenCalledWith(sender.signer); + expect(verifySpy).toHaveBeenCalledWith(expect.any(Function)); + }); + + it("throws `Invalid signature to sign the PSBT's inputs` error if signature is incorrect", async () => { + const { service, sender, verifySpy } = await prepareToSign(); + + verifySpy.mockReturnValue(false); + + await expect(service.signNVerify(sender.signer)).rejects.toThrow( + "Invalid signature to sign the PSBT's inputs", + ); + }); + }); + + describe('finalize', () => { + const prepareToFinalize = async () => { + const network = networks.testnet; + const service = new PsbtService(network); + const wallet = createMockWallet(network); + const sender = await wallet.instance.unlock(0, ScriptType.P2wpkh); + const mfpBuf = hexToBuffer(sender.mfp, false); + const pubkeyBuf = hexToBuffer(sender.pubkey, false); + const receiver1 = await wallet.instance.unlock(1, ScriptType.P2wpkh); + const receiver2 = await wallet.instance.unlock(2, ScriptType.P2wpkh); + const receivers = [receiver1, receiver2]; + + const finalizeSpy = jest.spyOn(service.psbt, 'finalizeAllInputs'); + const transactionWeightSpy = jest.spyOn(Transaction.prototype, 'weight'); + const transactionHexSpy = jest.spyOn(Transaction.prototype, 'toHex'); + + const outputVal = 1000; + const fee = 500; + const outputs = receivers.map((account) => ({ + address: account.address, + value: outputVal, + })); + // it has to limit the inputs because it may fail due to alert of paying too much gas fee + const inputs = generateFormatedUtxos( + sender.address, + 1, + outputVal * outputs.length + fee, + outputVal * outputs.length + fee, + ); + + service.addOutputs(outputs); + + service.addInputs( + inputs, + mfpBuf, + pubkeyBuf, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + sender.payment.output!, + sender.hdPath, + true, + ); + + await service.signNVerify(sender.signer); + + return { + service, + sender, + finalizeSpy, + transactionWeightSpy, + transactionHexSpy, + }; + }; + + it('returns an transaction hex', async () => { + const { service, finalizeSpy, transactionHexSpy } = + await prepareToFinalize(); + + const txHex = service.finalize(); + + expect(txHex).not.toBeNull(); + expect(txHex).not.toBe(''); + expect(finalizeSpy).toHaveBeenCalled(); + expect(transactionHexSpy).toHaveBeenCalled(); + }); + + it('throws `Transaction is too large` error if the txn weight is too large', async () => { + const { service, transactionWeightSpy } = await prepareToFinalize(); + + transactionWeightSpy.mockReturnValue(MaxStandardTxWeight + 1000); + + expect(() => service.finalize()).toThrow(`Transaction is too large`); + }); + + it('throws PsbtServiceError error if finalize operation failed', async () => { + const { service, finalizeSpy } = await prepareToFinalize(); + + finalizeSpy.mockImplementation(() => { + throw new Error('error'); + }); + + expect(() => service.finalize()).toThrow(PsbtServiceError); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/psbt.ts new file mode 100644 index 00000000..0b006c87 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/psbt.ts @@ -0,0 +1,153 @@ +import ecc from '@bitcoinerlab/secp256k1'; +import type { Network } from 'bitcoinjs-lib'; +import { Psbt, Transaction } from 'bitcoinjs-lib'; +import type { Buffer } from 'buffer'; +import ECPairFactory from 'ecpair'; + +import { compactError } from '../../../utils'; +import type { IAccountSigner } from '../../../wallet'; +import { logger } from '../../logger/logger'; +import { MaxStandardTxWeight } from '../constants'; +import { PsbtServiceError } from './exceptions'; +import type { SpendTo, Utxo } from './types'; +// import { PsbtData } from "./psbt-data"; + +const ECPair = ECPairFactory(ecc); + +export class PsbtService { + // protected readonly psbtData: PsbtData; + protected readonly network: Network; + + protected readonly _psbt: Psbt; + + get psbt() { + return this._psbt; + } + + constructor(network: Network, psbt?: Psbt) { + if (psbt === undefined) { + this._psbt = new Psbt({ network }); + } else { + this._psbt = psbt; + } + // this.validator = new PsbtValidator(this._psbt, network); + // this._psbtData = new PsbtData(this._psbt, network); + } + + static fromBase64(network: Network, base64Psbt: string): PsbtService { + const psbt = Psbt.fromBase64(base64Psbt, { network }); + const service = new PsbtService(network, psbt); + + // To make sure the psbt is created from the PSBT creator's server (The Snap) + // service.validator.validate(); + + return service; + } + + addInputs( + inputs: Utxo[], + mfp: Buffer, + changeAddressPubkey: Buffer, + changeAddressScriptHash: Buffer, + changeAddressHdPath: string, + replaceable: boolean, + ) { + try { + for (const input of inputs) { + this._psbt.addInput({ + hash: input.txnHash, + index: input.index, + witnessUtxo: { + script: changeAddressScriptHash, + value: input.value, + }, + // This is useful because as long as you store the masterFingerprint on + // the PSBT Creator's server, you can have the PSBT Creator do the heavy + // lifting with derivation from your m/84'/0'/0' xpub, (deriving only 0/0 ) + // and your signer just needs to pass in an HDSigner interface (ie. bip32 library) + bip32Derivation: [ + { + masterFingerprint: mfp, + path: changeAddressHdPath, + pubkey: changeAddressPubkey, + }, + ], + + // reference : https://en.bitcoin.it/wiki/BIP_0125 + // A transaction is considered to have opted in to allowing replacement of itself if any of its inputs have an nSequence number less than (0xffffffff - 1). + // we use max sequence number - 2 to void conflicting with other possible uses of nSequence + sequence: replaceable + ? Transaction.DEFAULT_SEQUENCE - 2 + : Transaction.DEFAULT_SEQUENCE, + }); + } + } catch (error) { + logger.error('Failed to add inputs', error); + throw new PsbtServiceError('Failed to add inputs in PSBT'); + } + } + + addOutputs(outputs: SpendTo[]) { + try { + this._psbt.addOutputs(outputs); + } catch (error) { + logger.error('Failed to add outputs', error); + throw new PsbtServiceError('Failed to add outputs in PSBT'); + } + } + + toBase64(): string { + try { + return this._psbt.toBase64(); + } catch (error) { + logger.error('Failed to convert to base64', error); + throw new PsbtServiceError('Failed to output PSBT string'); + } + } + + async signNVerify(signer: IAccountSigner) { + try { + // This function signAllInputsHDAsync is used to sign all inputs with the signer. + // When using the method signAllInputsHDAsync, it is important to note that the signer must derive from the root node as well as the finderprint. + // For further reference, please see the getHdSigner method in BtcWallet. + await this._psbt.signAllInputsHDAsync(signer); + + if ( + !this._psbt.validateSignaturesOfAllInputs( + (pubkey: Buffer, msghash: Buffer, signature: Buffer) => + this.validateInputs(pubkey, msghash, signature), + ) + ) { + throw new PsbtServiceError( + "Invalid signature to sign the PSBT's inputs", + ); + } + } catch (error) { + throw compactError(error, PsbtServiceError); + } + } + + finalize(): string { + try { + this._psbt.finalizeAllInputs(); + + const txHex = this._psbt.extractTransaction().toHex(); + + const weight = this._psbt.extractTransaction().weight(); + + if (weight > MaxStandardTxWeight) { + throw new PsbtServiceError('Transaction is too large'); + } + + return txHex; + } catch (error) { + throw compactError(error, PsbtServiceError); + } + } + + protected validateInputs(pubkey: Buffer, msghash: Buffer, signature: Buffer) { + // As the signer is extract from root node, however, the pubkey is derived from the child node + // So, using the ECPair to verify the signature is easier + return ECPair.fromPublicKey(pubkey).verify(msghash, signature); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts index e445d975..f9f47ab3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts @@ -11,6 +11,7 @@ export type IBtcAccountDeriver = { export type IBtcAccount = IAccount & { payment: Payment; + scriptType: ScriptType; }; export type IStaticBtcAccount = { @@ -28,6 +29,32 @@ export type IStaticBtcAccount = { ): IBtcAccount; }; +export type CreateTransactionOptions = { + utxos: Utxo[]; + fee: number; + subtractFeeFrom: string[]; + // + // BIP125 opt-in RBF flag, + // + replaceable: boolean; +}; + +export type SpendTo = { + address: string; + value: number; +}; + +export type SelectedOutput = { + address?: string; + value: number; +}; + +export type SelectedUtxos = { + inputs: Utxo[]; + outputs: SelectedOutput[]; + fee: number; +}; + export type Utxo = { block: number; txnHash: string; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts index 57bf6faf..3cdb44d6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts @@ -1,74 +1,115 @@ +import ecc from '@bitcoinerlab/secp256k1'; +import type { SLIP10NodeInterface } from '@metamask/key-tree'; +import { BIP32Factory } from 'bip32'; import { networks } from 'bitcoinjs-lib'; +import ECPairFactory from 'ecpair'; -import { createMockBip32Instance } from '../../../../test/utils'; -import { ScriptType } from '../constants'; +import { generateFormatedUtxos } from '../../../../test/utils'; +import { SnapHelper } from '../../snap'; +import { DustLimit, ScriptType } from '../constants'; import { P2SHP2WPKHAccount, P2WPKHAccount } from './account'; +import { CoinSelectService } from './coin-select'; import { BtcAccountBip32Deriver } from './deriver'; import { WalletError } from './exceptions'; +import { PsbtService } from './psbt'; import { BtcWallet } from './wallet'; describe('BtcWallet', () => { - const createMockWallet = (network) => { + const createMockBip32Instance = (network) => { + const ECPair = ECPairFactory(ecc); + const bip32 = BIP32Factory(ecc); + + const keyPair = ECPair.makeRandom(); + const deriver = bip32.fromSeed(keyPair.publicKey, network); + + const jsonData = { + privateKey: deriver.privateKey?.toString('hex'), + publicKey: deriver.publicKey.toString('hex'), + chainCode: deriver.chainCode.toString('hex'), + depth: deriver.depth, + index: deriver.index, + curve: 'secp256k1', + masterFingerprint: undefined, + parentFingerprint: 0, + }; + jest.spyOn(SnapHelper, 'getBip32Deriver').mockResolvedValue({ + ...jsonData, + chainCodeBytes: deriver.chainCode, + privateKeyBytes: deriver.privateKey, + publicKeyBytes: deriver.publicKey, + toJSON: jest.fn().mockReturnValue(jsonData), + } as unknown as SLIP10NodeInterface); + 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); + return { + instance: new BtcAccountBip32Deriver(network), + rootSpy, + childSpy, + }; + }; + + const createMockWallet = (network) => { + const { + instance: deriver, + rootSpy, + childSpy, + } = createMockBip32Instance(network); - const instance = new BtcWallet( - new BtcAccountBip32Deriver(network), - network, - ); + const instance = new BtcWallet(deriver, network); return { instance, rootSpy, childSpy, - rootNode, - childNode, + }; + }; + + const createMockTxnIndent = (address: string, amount: number) => { + return { + amounts: { + [address]: amount, + }, + subtractFeeFrom: [], + replaceable: false, }; }; describe('unlock', () => { it('returns an `Account` objec with type bip122:p2wpkh', async () => { const network = networks.testnet; - const { rootSpy, childSpy, instance, rootNode } = - createMockWallet(network); + const { rootSpy, childSpy, instance } = createMockWallet(network); const idx = 0; const result = await instance.unlock(idx, `bip122:p2wpkh`); expect(result).toBeInstanceOf(P2WPKHAccount); expect(rootSpy).toHaveBeenCalledWith(P2WPKHAccount.path); - expect(childSpy).toHaveBeenCalledWith(rootNode, idx); + expect(childSpy).toHaveBeenCalledWith(expect.any(Object), idx); }); it('returns an `Account` objec with type `p2wpkh`', async () => { const network = networks.testnet; - const { rootSpy, childSpy, instance, rootNode } = - createMockWallet(network); + const { rootSpy, childSpy, instance } = createMockWallet(network); const idx = 0; const result = await instance.unlock(idx, ScriptType.P2wpkh); expect(result).toBeInstanceOf(P2WPKHAccount); expect(rootSpy).toHaveBeenCalledWith(P2WPKHAccount.path); - expect(childSpy).toHaveBeenCalledWith(rootNode, idx); + expect(childSpy).toHaveBeenCalledWith(expect.any(Object), idx); }); it('returns an `Account` object with type `p2shp2wkh`', async () => { const network = networks.testnet; - const { rootSpy, childSpy, instance, rootNode } = - createMockWallet(network); + const { rootSpy, childSpy, instance } = createMockWallet(network); const idx = 0; const result = await instance.unlock(idx, ScriptType.P2shP2wkh); expect(result).toBeInstanceOf(P2SHP2WPKHAccount); expect(rootSpy).toHaveBeenCalledWith(P2SHP2WPKHAccount.path); - expect(childSpy).toHaveBeenCalledWith(rootNode, idx); + expect(childSpy).toHaveBeenCalledWith(expect.any(Object), idx); }); it('throws error if the account cannot be unlocked', async () => { @@ -83,30 +124,190 @@ describe('BtcWallet', () => { }); describe('createTransaction', () => { - it('throws `Method not implemented` error', async () => { + it('creates an transaction', async () => { const network = networks.testnet; - const idx = 0; - const { instance } = createMockWallet(network); + const { instance } = createMockBip32Instance(network); + const wallet = new BtcWallet(instance, network); + const account = await wallet.unlock(0, ScriptType.P2wpkh); + const utxos = generateFormatedUtxos(account.address, 2); - const account = await instance.unlock(idx, ScriptType.P2wpkh); + const result = await wallet.createTransaction( + account, + createMockTxnIndent(account.address, DustLimit[account.scriptType] + 1), + { + utxos, + fee: 1, + subtractFeeFrom: [], + replaceable: false, + }, + ); + + expect(result).toStrictEqual({ + txn: expect.any(String), + txnJson: { + feeRate: 1, + estimatedFee: expect.any(Number), + sender: account.address, + recipients: expect.any(Array), + changes: expect.any(Array), + }, + }); + }); + + it('passes correct parameter to CoinSelectService', async () => { + const network = networks.testnet; + const { instance } = createMockBip32Instance(network); + const wallet = new BtcWallet(instance, network); + const account = await wallet.unlock(0, ScriptType.P2wpkh); + const utxos = generateFormatedUtxos(account.address, 2); + const coinSelectServiceSpy = jest.spyOn( + CoinSelectService.prototype, + 'selectCoins', + ); + + await wallet.createTransaction( + account, + createMockTxnIndent(account.address, DustLimit[account.scriptType] + 1), + { + utxos, + fee: 1, + subtractFeeFrom: [], + replaceable: false, + }, + ); + + expect(coinSelectServiceSpy).toHaveBeenCalledWith( + utxos, + [ + { + address: account.address, + value: DustLimit[account.scriptType] + 1, + }, + ], + account.payment.output, + ); + }); + + it('remove dist change', async () => { + const network = networks.testnet; + const { instance } = createMockBip32Instance(network); + const wallet = new BtcWallet(instance, network); + const chgAccount = await wallet.unlock(0, ScriptType.P2wpkh); + const recipient = await wallet.unlock(1, ScriptType.P2wpkh); + const utxos = generateFormatedUtxos(recipient.address, 2); + const coinSelectServiceSpy = jest.spyOn( + CoinSelectService.prototype, + 'selectCoins', + ); + const psbtServiceSpy = jest + .spyOn(PsbtService.prototype, 'addOutputs') + .mockReturnThis(); + + // to avoid modifiy the original object when we needed to test the output + psbtServiceSpy.mockReturnThis(); + coinSelectServiceSpy.mockReturnValue({ + inputs: utxos, + outputs: [ + { + address: recipient.address, + value: 500, + }, + { + value: DustLimit[chgAccount.scriptType] - 1, + }, + ], + fee: 100, + }); + + await wallet.createTransaction( + chgAccount, + createMockTxnIndent(recipient.address, 500), + { + utxos, + fee: 1, + subtractFeeFrom: [], + replaceable: false, + }, + ); + + expect(psbtServiceSpy).toHaveBeenCalledWith([ + { + address: recipient.address, + value: 500, + }, + ]); + }); + + it('throws `Transaction amount too small` error the transaction output is too small', async () => { + const network = networks.testnet; + const { instance } = createMockBip32Instance(network); + const wallet = new BtcWallet(instance, network); + const account = await wallet.unlock(0, ScriptType.P2wpkh); + const utxos = generateFormatedUtxos(account.address, 2, 8000, 8000); + + await expect( + wallet.createTransaction( + account, + createMockTxnIndent(account.address, 1), + { + utxos, + fee: 20, + subtractFeeFrom: [], + replaceable: false, + }, + ), + ).rejects.toThrow('Transaction amount too small'); + }); + + it('throws `Unable to get account script hash` error if the account script hash is undefined', async () => { + const network = networks.testnet; + const { instance } = createMockBip32Instance(network); + const wallet = new BtcWallet(instance, network); + const account = await wallet.unlock(0, ScriptType.P2wpkh); + const utxos = generateFormatedUtxos(account.address, 2); + account.payment.output = undefined; await expect( - instance.createTransaction(account, {} as any, {} as any), - ).rejects.toThrow('Method not implemented.'); + wallet.createTransaction( + account, + createMockTxnIndent( + account.address, + DustLimit[account.scriptType] + 1, + ), + { + utxos, + fee: 1, + subtractFeeFrom: [], + replaceable: false, + }, + ), + ).rejects.toThrow('Unable to get account script hash'); }); }); describe('signTransaction', () => { - it('throws `Method not implemented` error', async () => { + it('signs an transaction', async () => { const network = networks.testnet; - const idx = 0; - const { instance } = createMockWallet(network); + const { instance } = createMockBip32Instance(network); + const wallet = new BtcWallet(instance, network); + const account = await wallet.unlock(0, ScriptType.P2wpkh); + const utxos = generateFormatedUtxos(account.address, 2); - const account = await instance.unlock(idx, ScriptType.P2wpkh); + const { txn } = await wallet.createTransaction( + account, + createMockTxnIndent(account.address, DustLimit[account.scriptType] + 1), + { + utxos, + fee: 1, + subtractFeeFrom: [], + replaceable: false, + }, + ); - await expect( - instance.signTransaction(account.signer, '0x12312313'), - ).rejects.toThrow('Method not implemented.'); + const sign = await wallet.signTransaction(account.signer, txn); + + expect(sign).not.toBeNull(); + expect(sign).not.toBe(''); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts index 52c81a2f..7048bd59 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts @@ -3,13 +3,22 @@ import type { BIP32Interface } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; import type { TransactionIntent } from '../../../chain'; -import { bufferToString, compactError } from '../../../utils'; -import type { IAccount, IAccountSigner, IWallet } from '../../../wallet'; +import { bufferToString, compactError, hexToBuffer } from '../../../utils'; +import type { IAccountSigner, IWallet } from '../../../wallet'; import { ScriptType } from '../constants'; +import { isDust } from '../utils'; import { P2WPKHAccount, P2SHP2WPKHAccount } from './account'; +import { CoinSelectService } from './coin-select'; import { WalletError } from './exceptions'; +import { PsbtService } from './psbt'; import { AccountSigner } from './signer'; -import type { IStaticBtcAccount, IBtcAccountDeriver } from './types'; +import type { + IStaticBtcAccount, + IBtcAccountDeriver, + IBtcAccount, + SpendTo, + CreateTransactionOptions, +} from './types'; export class BtcWallet implements IWallet { protected readonly deriver: IBtcAccountDeriver; @@ -36,7 +45,7 @@ export class BtcWallet implements IWallet { } } - async unlock(index: number, type: string): Promise { + async unlock(index: number, type: string): Promise { try { const AccountCtor = this.getAccountCtor(type); const rootNode = await this.deriver.getRoot(AccountCtor.path); @@ -59,51 +68,96 @@ export class BtcWallet implements IWallet { } async createTransaction( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - acount: IAccount, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + account: IBtcAccount, txn: TransactionIntent, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - options: { - metadata: Record; - fee: number; - }, + options: CreateTransactionOptions, ): Promise<{ txn: string; txnJson: Record; }> { - // create PSBT - // add PSBT input - // add PSBT output - // out PSBT base64 buffer - throw new Error('Method not implemented.'); - } + const scriptOutput = account.payment.output; + const { scriptType } = account; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async signTransaction(signer: IAccountSigner, txn: string): Promise { - // convert txn to PSBT - // validate PSBT - // finalize PSBT - // sign PSBT - // verify sign - // out txn hex - throw new Error('Method not implemented.'); - } + if (!scriptOutput) { + throw new WalletError('Unable to get account script hash'); + } - protected getFingerPrintInHex(rootNode: BIP32Interface) { - try { - return rootNode.fingerprint.toString('hex'); - } catch (error) { - throw new Error('Unable to get fingerprint in hex'); + // as fee rate can be 0, we need to ensure it is at least 1 + // TODO the min fee rate can be setting by parameter + const feeRate = Math.max(1, options.fee); + + const coinSelectService = new CoinSelectService(feeRate); + const spendTos = Object.entries(txn.amounts).map(([address, value]) => { + return { + address, + value, + }; + }); + + const { inputs, outputs, fee } = coinSelectService.selectCoins( + options.utxos, + spendTos, + scriptOutput, + ); + + const changes: SpendTo[] = []; + const recipients: SpendTo[] = []; + const formattedOutputs: SpendTo[] = []; + for (const output of outputs) { + if (output.address === undefined) { + // discard change output if it is dust and add to fees + if (isDust(output.value, scriptType)) { + continue; + } + changes.push({ + address: account.address, + value: output.value, + }); + } else { + // dust outputs is forbidden + if (isDust(output.value, scriptType)) { + throw new WalletError('Transaction amount too small'); + } + recipients.push({ + address: output.address, + value: output.value, + }); + } + formattedOutputs.push({ + address: output.address ?? account.address, + value: output.value, + }); } + + const psbtService = new PsbtService(this.network); + + psbtService.addInputs( + inputs, + hexToBuffer(account.mfp, false), + hexToBuffer(account.pubkey, false), + scriptOutput, + account.hdPath, + options.replaceable, + ); + + psbtService.addOutputs(formattedOutputs); + + return { + txn: psbtService.toBase64(), + txnJson: { + feeRate: options.fee, + estimatedFee: fee, + sender: account.address, + recipients, + changes, + }, + }; } - protected getPublicKeyInHex(rootNode: BIP32Interface) { - try { - return rootNode.publicKey.toString('hex'); - } catch (error) { - throw new Error('Unable to get public key in hex'); - } + async signTransaction(signer: IAccountSigner, txn: string): Promise { + const psbtService = PsbtService.fromBase64(this.network, txn); + await psbtService.signNVerify(signer); + return psbtService.finalize(); } protected getHdSigner(rootNode: BIP32Interface) { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/rpc/base.ts b/merged-packages/bitcoin-wallet-snap/src/modules/rpc/base.ts index 31cf4d39..d051c457 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/rpc/base.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/rpc/base.ts @@ -1,7 +1,8 @@ +import { InvalidParamsError } from '@metamask/snaps-sdk'; import { type Struct, assert } from 'superstruct'; import { logger } from '../logger/logger'; -import { SnapRpcValidationError } from './exceptions'; +import { InvalidSnapRpcResponseError } from './exceptions'; import { type ISnapRpcExecutable, type SnapRpcHandlerOptions, @@ -19,6 +20,8 @@ export abstract class BaseSnapRpcHandler implements ISnapRpcExecutable { static readonly responseStruct?: Struct; + protected isThrowValidationError = false; + abstract handleRequest( params: SnapRpcHandlerRequest, ): Promise; @@ -40,7 +43,7 @@ export abstract class BaseSnapRpcHandler implements ISnapRpcExecutable { } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[SnapRpcHandler.preExecute] Error: ${error.message}`); - throw new SnapRpcValidationError('Request params is invalid'); + this.throwValidationError(error.message); } } @@ -56,7 +59,9 @@ export abstract class BaseSnapRpcHandler implements ISnapRpcExecutable { } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[SnapRpcHandler.postExecute] Error: ${error.message}`); - throw new SnapRpcValidationError('Response is invalid'); + throw new InvalidSnapRpcResponseError( + 'Invalid Response', + ) as unknown as Error; } } @@ -75,4 +80,10 @@ export abstract class BaseSnapRpcHandler implements ISnapRpcExecutable { ): ISnapRpcHandler { return new this(options); } + + protected throwValidationError(message: string): void { + throw new InvalidParamsError( + this.isThrowValidationError ? message : undefined, + ) as unknown as Error; + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/rpc/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/modules/rpc/exceptions.ts index 029ccf23..50b6a304 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/rpc/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/rpc/exceptions.ts @@ -1,4 +1,4 @@ import { CustomError } from '../exception'; export class SnapRpcError extends CustomError {} -export class SnapRpcValidationError extends CustomError {} +export class InvalidSnapRpcResponseError extends SnapRpcError {} 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 index 459889ce..be8a6594 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.test.ts @@ -1,5 +1,5 @@ import { expect } from '@jest/globals'; -import { heading, panel, text, divider } from '@metamask/snaps-sdk'; +import { heading, panel, text, divider, row } from '@metamask/snaps-sdk'; import { SnapHelper } from './helpers'; @@ -48,9 +48,21 @@ describe('SnapHelper', () => { const testcase = { header: 'header', subHeader: 'subHeader', - body: { - content: 'content', - }, + body: [ + { + label: 'Label1', + value: 'Value1', + }, + { + label: 'Label2', + value: [ + { + label: 'SubLabel1', + value: 'SubValue1', + }, + ], + }, + ], }; await SnapHelper.confirmDialog( @@ -67,8 +79,26 @@ describe('SnapHelper', () => { heading(testcase.header), text(testcase.subHeader), divider(), - ...Object.entries(testcase.body).map(([key, value]) => - text(`**${key}**:\n ${value}`), + row( + testcase.body[0].label, + text(testcase.body[0].value as unknown as string), + ), + text(`**${testcase.body[1].label}**:`), + row( + ( + testcase.body[1].value[0] as unknown as { + label: string; + value: string; + } + ).label, + text( + ( + testcase.body[1].value[0] as unknown as { + label: string; + value: string; + } + ).value, + ), ), ]), }, 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 42aa28fe..f7d288cb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.ts @@ -3,13 +3,14 @@ import { getBIP44AddressKeyDeriver, type SLIP10NodeInterface, } from '@metamask/key-tree'; -import type { DialogResult, Json } from '@metamask/snaps-sdk'; +import type { Component, DialogResult, Json } from '@metamask/snaps-sdk'; import { heading, panel, text, divider, type SnapsProvider, + row, } from '@metamask/snaps-sdk'; declare const snap: SnapsProvider; @@ -46,20 +47,38 @@ export class SnapHelper { static async confirmDialog( header: string, subHeader: string, - body: Record, + body: { + label: string; + value: + | string + | { + label: string; + value: string; + }[]; + }[], ): Promise { + const components: Component[] = [ + heading(header), + text(subHeader), + divider(), + ]; + + for (const { label, value } of body) { + if (typeof value === 'string') { + components.push(row(label, text(value))); + } else { + components.push(text(`**${label}**:`)); + for (const { label: lb, value: val } of value) { + components.push(row(lb, text(val))); + } + } + } + return SnapHelper.provider.request({ method: 'snap_dialog', params: { type: 'confirmation', - content: panel([ - heading(header), - text(subHeader), - divider(), - ...Object.entries(body).map(([key, value]) => - text(`**${key}**:\n ${value}`), - ), - ]), + content: panel(components), }, }); } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.test.ts index b098983e..8d6e0a00 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.test.ts @@ -1,3 +1,5 @@ +import { InvalidParamsError } from '@metamask/snaps-sdk'; + import { generateBlockChairBroadcastTransactionResp } from '../../test/utils'; import { Factory } from '../factory'; import { Network } from '../modules/bitcoin/constants'; @@ -13,25 +15,25 @@ jest.mock('../modules/logger/logger', () => ({ describe('BroadcastTransactionHandler', () => { describe('handleRequest', () => { const createMockChainApiFactory = () => { - const boardcastTransactionSpy = jest.fn(); + const broadcastTransactionSpy = jest.fn(); jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ estimateFees: jest.fn(), getBalances: jest.fn(), - boardcastTransaction: boardcastTransactionSpy, + broadcastTransaction: broadcastTransactionSpy, listTransactions: jest.fn(), getTransaction: jest.fn(), getDataForTransaction: jest.fn(), }); return { - boardcastTransactionSpy, + broadcastTransactionSpy, }; }; it('broadcast an transaction', async () => { - const { boardcastTransactionSpy } = createMockChainApiFactory(); + const { broadcastTransactionSpy } = createMockChainApiFactory(); const resp = generateBlockChairBroadcastTransactionResp(); - boardcastTransactionSpy.mockResolvedValue({ + broadcastTransactionSpy.mockResolvedValue({ transactionId: resp.data.transaction_hash, }); @@ -53,7 +55,7 @@ describe('BroadcastTransactionHandler', () => { BroadcastTransactionHandler.getInstance().execute({ scope: 'some invalid value', }), - ).rejects.toThrow('Request params is invalid'); + ).rejects.toThrow(InvalidParamsError); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.ts index effafe88..25ea9d0d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.ts @@ -46,6 +46,6 @@ export class BroadcastTransactionHandler const chainApi = Factory.createOnChainServiceProvider(scope); - return await chainApi.boardcastTransaction(signedTransaction); + return await chainApi.broadcastTransaction(signedTransaction); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.test.ts index 58b6a45c..af3e0c80 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.test.ts @@ -1,3 +1,5 @@ +import { InvalidParamsError } from '@metamask/snaps-sdk'; + import { FeeRatio } from '../chain'; import { Factory } from '../factory'; import { Network } from '../modules/bitcoin/constants'; @@ -19,7 +21,7 @@ describe('EstimateFeesHandler', () => { jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ estimateFees: estimateFeesSpy, getBalances: jest.fn(), - boardcastTransaction: jest.fn(), + broadcastTransaction: jest.fn(), listTransactions: jest.fn(), getTransaction: jest.fn(), getDataForTransaction: jest.fn(), @@ -78,7 +80,7 @@ describe('EstimateFeesHandler', () => { EstimateFeesHandler.getInstance().execute({ scope: 'some invalid value', }), - ).rejects.toThrow('Request params is invalid'); + ).rejects.toThrow(InvalidParamsError); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.ts index ed89ffdc..a07a9106 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.ts @@ -11,7 +11,7 @@ import { BaseSnapRpcHandler, } from '../modules/rpc'; import type { StaticImplements } from '../types/static'; -import { numberStringStruct } from '../utils'; +import { positiveStringStruct } from '../utils'; export type EstimateFeesParams = Infer< typeof EstimateFeesHandler.requestStruct @@ -34,7 +34,7 @@ export class EstimateFeesHandler fees: array( object({ type: enums(Object.values(FeeRatio)), - rate: numberStringStruct, + rate: positiveStringStruct, }), ), }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts index c20f4e61..046f2311 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts @@ -1,3 +1,5 @@ +import { InvalidParamsError } from '@metamask/snaps-sdk'; + import { generateAccounts } from '../../test/utils'; import { Factory } from '../factory'; import { BtcAsset, Network } from '../modules/bitcoin/constants'; @@ -18,7 +20,7 @@ describe('GetBalancesHandler', () => { jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ estimateFees: jest.fn(), getBalances: getBalancesSpy, - boardcastTransaction: jest.fn(), + broadcastTransaction: jest.fn(), listTransactions: jest.fn(), getTransaction: jest.fn(), getDataForTransaction: jest.fn(), @@ -77,7 +79,7 @@ describe('GetBalancesHandler', () => { accounts: addresses, assets: ['some-asset'], }), - ).rejects.toThrow('Request params is invalid'); + ).rejects.toThrow(InvalidParamsError); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts index 36c81327..0261bed9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts @@ -12,7 +12,7 @@ import type { SnapRpcHandlerResponse, } from '../modules/rpc'; import type { StaticImplements } from '../types/static'; -import { assetsStruct, numberStringStruct } from '../utils/superstruct'; +import { assetsStruct, positiveStringStruct } from '../utils/superstruct'; export type GetBalancesParams = Infer; @@ -40,7 +40,7 @@ export class GetBalancesHandler record( assetsStruct, object({ - amount: numberStringStruct, + amount: positiveStringStruct, }), ), ), diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.test.ts index c75bf8e8..32315b13 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.test.ts @@ -1,3 +1,5 @@ +import { InvalidParamsError } from '@metamask/snaps-sdk'; + import { generateAccounts, generateBlockChairGetUtxosResp, @@ -21,7 +23,7 @@ describe('GetTransactionDataHandler', () => { jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ estimateFees: jest.fn(), getBalances: jest.fn(), - boardcastTransaction: jest.fn(), + broadcastTransaction: jest.fn(), listTransactions: jest.fn(), getTransaction: jest.fn(), getDataForTransaction: getDataForTransactionSpy, @@ -71,7 +73,7 @@ describe('GetTransactionDataHandler', () => { GetTransactionDataHandler.getInstance().execute({ scope: Network.Testnet, }), - ).rejects.toThrow('Request params is invalid'); + ).rejects.toThrow(InvalidParamsError); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts new file mode 100644 index 00000000..658299bd --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -0,0 +1,362 @@ +import ecc from '@bitcoinerlab/secp256k1'; +import type { SLIP10NodeInterface } from '@metamask/key-tree'; +import { + InvalidParamsError, + UserRejectedRequestError, +} from '@metamask/snaps-sdk'; +import { BIP32Factory } from 'bip32'; +import { networks } from 'bitcoinjs-lib'; +import ECPairFactory from 'ecpair'; +import { v4 as uuidv4 } from 'uuid'; + +import { + generateBlockChairBroadcastTransactionResp, + generateBlockChairGetUtxosResp, +} from '../../test/utils'; +import { FeeRatio } from '../chain'; +import { Factory } from '../factory'; +import { DustLimit, Network, ScriptType } from '../modules/bitcoin/constants'; +import { satsToBtc } from '../modules/bitcoin/utils/unit'; +import type { IBtcAccount } from '../modules/bitcoin/wallet'; +import { BtcAccountBip32Deriver, BtcWallet } from '../modules/bitcoin/wallet'; +import { SnapHelper } from '../modules/snap'; +import type { IAccount } from '../wallet'; +import { SendManyHandler } from './sendmany'; +import type { SendManyParams } from './sendmany'; + +jest.mock('../modules/logger/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + }, +})); + +describe('SendManyHandler', () => { + describe('handleRequest', () => { + const createMockChainApiFactory = () => { + const getDataForTransactionSpy = jest.fn(); + const estimatedFeeSpy = jest.fn(); + const broadcastTransactionSpy = jest.fn(); + + jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ + estimateFees: estimatedFeeSpy, + getBalances: jest.fn(), + broadcastTransaction: broadcastTransactionSpy, + listTransactions: jest.fn(), + getTransaction: jest.fn(), + getDataForTransaction: getDataForTransactionSpy, + }); + return { + getDataForTransactionSpy, + estimatedFeeSpy, + broadcastTransactionSpy, + }; + }; + + const createMockBip32Instance = (network) => { + const ECPair = ECPairFactory(ecc); + const bip32 = BIP32Factory(ecc); + + const keyPair = ECPair.makeRandom(); + const deriver = bip32.fromSeed(keyPair.publicKey, network); + + const jsonData = { + privateKey: deriver.privateKey?.toString('hex'), + publicKey: deriver.publicKey.toString('hex'), + chainCode: deriver.chainCode.toString('hex'), + depth: deriver.depth, + index: deriver.index, + curve: 'secp256k1', + masterFingerprint: undefined, + parentFingerprint: 0, + }; + jest.spyOn(SnapHelper, 'getBip32Deriver').mockResolvedValue({ + ...jsonData, + chainCodeBytes: deriver.chainCode, + privateKeyBytes: deriver.privateKey, + publicKeyBytes: deriver.publicKey, + toJSON: jest.fn().mockReturnValue(jsonData), + } as unknown as SLIP10NodeInterface); + + const rootSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getRoot'); + const childSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getChild'); + + return { + instance: new BtcAccountBip32Deriver(network), + rootSpy, + childSpy, + }; + }; + + const createSenderNReceipents = async ( + network, + caip2Network: string, + receipentCnt: number, + ) => { + const { instance } = createMockBip32Instance(network); + const wallet = new BtcWallet(instance, network); + const sender = await wallet.unlock(0, ScriptType.P2wpkh); + const keyringAccount = { + type: sender.type, + id: uuidv4(), + address: sender.address, + options: { + scope: caip2Network, + index: sender.index, + }, + methods: ['btc_sendmany'], + }; + const receipents: IAccount[] = []; + for (let i = 1; i < receipentCnt + 1; i++) { + receipents.push(await wallet.unlock(i, ScriptType.P2wpkh)); + } + + return { + sender, + keyringAccount, + receipents, + }; + }; + + const createSendManyParams = ( + receipents: IAccount[], + caip2Network: string, + dryrun: boolean, + ): SendManyParams => { + return { + amounts: receipents.reduce((acc, receipent: IBtcAccount) => { + acc[receipent.address] = satsToBtc( + DustLimit[receipent.scriptType] + 1, + ); + return acc; + }, {}), + comment: '', + subtractFeeFrom: [], + replaceable: false, + dryrun, + scope: caip2Network, + } as unknown as SendManyParams; + }; + + const createMockGetDataForTransactionResp = ( + address: string, + counter: number, + ) => { + const mockResponse = generateBlockChairGetUtxosResp(address, counter); + return mockResponse.data[address].utxo.map((utxo) => ({ + block: utxo.block_id, + txnHash: utxo.transaction_hash, + index: utxo.index, + value: utxo.value, + })); + }; + + const createMockBroadcastTransactionResp = () => { + return generateBlockChairBroadcastTransactionResp().data.transaction_hash; + }; + + const prepareSendMany = async (network, caip2Network) => { + const { + getDataForTransactionSpy, + estimatedFeeSpy, + broadcastTransactionSpy, + } = createMockChainApiFactory(); + const snapHelperSpy = jest.spyOn(SnapHelper, 'confirmDialog'); + const { sender, keyringAccount, receipents } = + await createSenderNReceipents(network, caip2Network, 2); + + const broadcastResp = createMockBroadcastTransactionResp(); + + getDataForTransactionSpy.mockResolvedValue({ + data: { + utxos: createMockGetDataForTransactionResp(sender.address, 10), + }, + }); + estimatedFeeSpy.mockResolvedValue({ + fees: [ + { + type: FeeRatio.Fast, + rate: 1, + }, + ], + }); + broadcastTransactionSpy.mockResolvedValue({ + transactionId: broadcastResp, + }); + snapHelperSpy.mockResolvedValue(true); + + return { + sender, + keyringAccount, + receipents, + broadcastResp, + getDataForTransactionSpy, + estimatedFeeSpy, + broadcastTransactionSpy, + snapHelperSpy, + }; + }; + + it('returns correct result', async () => { + const network = networks.testnet; + const caip2Network = Network.Testnet; + const { + keyringAccount, + receipents, + broadcastResp, + getDataForTransactionSpy, + estimatedFeeSpy, + broadcastTransactionSpy, + } = await prepareSendMany(network, caip2Network); + + const result = await SendManyHandler.getInstance({ + scope: caip2Network, + index: 0, + account: keyringAccount, + }).execute(createSendManyParams(receipents, caip2Network, false)); + + expect(result).toStrictEqual({ txId: broadcastResp }); + expect(estimatedFeeSpy).toHaveBeenCalledTimes(1); + expect(getDataForTransactionSpy).toHaveBeenCalledTimes(1); + expect(broadcastTransactionSpy).toHaveBeenCalledTimes(1); + }); + + it('does not broadcast transaction if in dryrun mode', async () => { + const network = networks.testnet; + const caip2Network = Network.Testnet; + const { keyringAccount, receipents, broadcastTransactionSpy } = + await prepareSendMany(network, caip2Network); + + await SendManyHandler.getInstance({ + scope: caip2Network, + index: 0, + account: keyringAccount, + }).execute(createSendManyParams(receipents, caip2Network, true)); + + expect(broadcastTransactionSpy).toHaveBeenCalledTimes(0); + }); + + it('throws `Request params is invalid` error when request parameter is not correct', async () => { + createMockChainApiFactory(); + + await expect( + SendManyHandler.getInstance().execute({ + scope: Network.Testnet, + }), + ).rejects.toThrow(InvalidParamsError); + }); + + it('throws `Account not found` error when given address not match', async () => { + createMockChainApiFactory(); + const network = networks.testnet; + const caip2Network = Network.Testnet; + const { keyringAccount, receipents } = await createSenderNReceipents( + network, + caip2Network, + 2, + ); + await expect( + SendManyHandler.getInstance({ + scope: caip2Network, + index: 20, + account: keyringAccount, + }).execute(createSendManyParams(receipents, caip2Network, false)), + ).rejects.toThrow('Account not found'); + }); + + it('throws `Transaction must have at least one recipient` error if no recipient provided', async () => { + createMockChainApiFactory(); + const network = networks.testnet; + const caip2Network = Network.Testnet; + const { keyringAccount, receipents } = await createSenderNReceipents( + network, + caip2Network, + 0, + ); + await expect( + SendManyHandler.getInstance({ + scope: caip2Network, + index: 0, + account: keyringAccount, + }).execute(createSendManyParams(receipents, caip2Network, false)), + ).rejects.toThrow('Transaction must have at least one recipient'); + }); + + it('throws `Invalid amount for send` error if sending amount is <= 0', async () => { + const network = networks.testnet; + const caip2Network = Network.Testnet; + createMockChainApiFactory(); + const { keyringAccount, receipents } = await createSenderNReceipents( + network, + caip2Network, + 2, + ); + + await expect( + SendManyHandler.getInstance({ + scope: caip2Network, + index: 0, + account: keyringAccount, + }).execute({ + ...createSendManyParams(receipents, caip2Network, false), + amounts: { + [receipents[0].address]: satsToBtc(500), + [receipents[1].address]: satsToBtc(0), + }, + }), + ).rejects.toThrow('Invalid amount for send'); + }); + + it('throws `Invalid response` error if the response is unexpected', async () => { + const network = networks.testnet; + const caip2Network = Network.Testnet; + const { keyringAccount, receipents, broadcastTransactionSpy } = + await prepareSendMany(network, caip2Network); + + broadcastTransactionSpy.mockResolvedValue({ + transactionId: { + txId: 'invalid', + }, + }); + await expect( + SendManyHandler.getInstance({ + scope: caip2Network, + index: 0, + account: keyringAccount, + }).execute(createSendManyParams(receipents, caip2Network, false)), + ).rejects.toThrow('Invalid Response'); + }); + + it('throws UserRejectedRequestError error if user denied the transaction', async () => { + const network = networks.testnet; + const caip2Network = Network.Testnet; + const { snapHelperSpy, keyringAccount, receipents } = + await prepareSendMany(network, caip2Network); + snapHelperSpy.mockResolvedValue(false); + + await expect( + SendManyHandler.getInstance({ + scope: caip2Network, + index: 0, + account: keyringAccount, + }).execute(createSendManyParams(receipents, caip2Network, false)), + ).rejects.toThrow(UserRejectedRequestError); + }); + + it('throws `Failed to commit transaction on chain` error if the transaction is fail to commit', async () => { + const network = networks.testnet; + const caip2Network = Network.Testnet; + const { broadcastTransactionSpy, keyringAccount, receipents } = + await prepareSendMany(network, caip2Network); + broadcastTransactionSpy.mockRejectedValue(new Error('error')); + + await expect( + SendManyHandler.getInstance({ + scope: caip2Network, + index: 0, + account: keyringAccount, + }).execute(createSendManyParams(receipents, caip2Network, false)), + ).rejects.toThrow('Failed to commit transaction on chain'); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index fc038d25..1ff87f9d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -1,4 +1,4 @@ -import type { Json } from '@metamask/snaps-sdk'; +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; import { object, string, @@ -7,38 +7,61 @@ import { record, array, boolean, + assert, } from 'superstruct'; -import type { Fees, TransactionIntent } from '../chain'; +import { + TransactionIntentStruct, + type Fees, + type IOnChainService, + type TransactionIntent, +} from '../chain'; import { Factory } from '../factory'; import { type Wallet as WalletData } from '../keyring'; +import { btcToSats, satsToBtc } from '../modules/bitcoin/utils'; +import { logger } from '../modules/logger/logger'; import { SnapRpcHandlerRequestStruct, BaseSnapRpcHandler, } from '../modules/rpc'; -import type { - IStaticSnapRpcHandler, - SnapRpcHandlerRequest, - SnapRpcHandlerResponse, -} from '../modules/rpc'; +import type { IStaticSnapRpcHandler } from '../modules/rpc'; +import { SnapHelper } from '../modules/snap'; import type { StaticImplements } from '../types/static'; -import { numberStringStruct } from '../utils'; +import { positiveStringStruct } from '../utils'; import type { IAccount, IWallet } from '../wallet'; export type SendManyParams = Infer; -export type SendManyResponse = SnapRpcHandlerResponse; +export type SendManyResponse = Infer; + +export type TxnJson = { + feeRate: number; + estimatedFee: number; + sender: string; + recipients: { + address: string; + value: number; + }[]; + changes: { + address: string; + value: number; + }[]; +}; export class SendManyHandler extends BaseSnapRpcHandler implements StaticImplements { + protected override isThrowValidationError = true; + walletData: WalletData; wallet: IWallet; walletAccount: IAccount; + transactionIntent: TransactionIntent; + constructor(walletData: WalletData) { super(); this.walletData = walletData; @@ -47,35 +70,48 @@ export class SendManyHandler static override get requestStruct() { return assign( object({ - amounts: record(string(), numberStringStruct), + amounts: record(string(), positiveStringStruct), comment: string(), subtractFeeFrom: array(string()), replaceable: boolean(), + dryrun: boolean(), }), SnapRpcHandlerRequestStruct, ); } - protected override async preExecute( - params: SnapRpcHandlerRequest, - ): Promise { + static override get responseStruct() { + return object({ + txId: string(), + }); + } + + protected override async preExecute(params: SendManyParams): Promise { await super.preExecute(params); + const transactionIntent = this.formatTxnIndents(params); + try { + assert(transactionIntent, TransactionIntentStruct); + } catch (error) { + this.throwValidationError(error.message); + } const { scope, index, account } = this.walletData; const wallet = Factory.createWallet(scope); const unlocked = await wallet.unlock(index, account.type); + if (!unlocked || unlocked.address !== account.address) { throw new Error('Account not found'); } + this.transactionIntent = transactionIntent; this.walletAccount = unlocked; this.wallet = wallet; } async handleRequest(params: SendManyParams): Promise { const { scope } = this.walletData; + const { dryrun } = params; const chainApi = Factory.createOnChainServiceProvider(scope); - const transactionIntent = this.formatTxnIndents(params); const feesResp = await chainApi.estimateFees(); @@ -83,20 +119,22 @@ export class SendManyHandler const metadata = await chainApi.getDataForTransaction( this.walletAccount.address, - transactionIntent, + this.transactionIntent, ); const { txn, txnJson } = await this.wallet.createTransaction( this.walletAccount, - transactionIntent, + this.transactionIntent, { - metadata, + utxos: metadata.data.utxos, fee, + subtractFeeFrom: params.subtractFeeFrom, + replaceable: params.replaceable, }, ); - if (!(await this.getTxnConsensus(txnJson))) { - throw new Error('User denied transaction request'); + if (!(await this.getTxnConsensus(txnJson as unknown as TxnJson))) { + throw new UserRejectedRequestError() as unknown as Error; } const txnHash = await this.wallet.signTransaction( @@ -104,34 +142,78 @@ export class SendManyHandler txn, ); - return await chainApi.boardcastTransaction(txnHash); + if (dryrun) { + return { + txId: txnHash, + }; + } + + return { + txId: await this.broadcastTransaction(chainApi, txnHash), + }; } protected formatTxnIndents(params: SendManyParams): TransactionIntent { const { amounts, subtractFeeFrom, replaceable } = params; - return Object.entries(amounts).reduce( - (acc, [account, amount]) => { - acc[account] = parseInt(amount, 10); // assume satoshi + return { + amounts: Object.entries(amounts).reduce((acc, [address, amount]) => { + acc[address] = parseInt(btcToSats(parseFloat(amount)), 10); return acc; - }, - { - amounts: {}, - subtractFeeFrom, - replaceable, - }, - ); + }, {}), + subtractFeeFrom, + replaceable, + }; } protected async getFeeConsensus(fees: Fees): Promise { // TODO: Ask user to confirm fee - return fees.fees[0].rate; + return fees.fees[fees.fees.length - 1].rate; + } + + protected async getTxnConsensus(txnJson: TxnJson): Promise { + return (await SnapHelper.confirmDialog( + 'Do you want to send this transaction?', + 'Transaction details', + [ + { + label: 'Fee Rate', + value: satsToBtc(txnJson.feeRate), + }, + { + label: 'Estimated Fee', + value: satsToBtc(txnJson.estimatedFee), + }, + { + label: 'Sender', + value: txnJson.sender, + }, + { + label: 'Recipients', + value: txnJson.recipients.map(({ address, value }) => ({ + label: address, + value: satsToBtc(value), + })), + }, + { + label: 'Changes', + value: txnJson.changes.map(({ address, value }) => ({ + label: address, + value: satsToBtc(value), + })), + }, + ], + )) as boolean; } - protected async getTxnConsensus( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - txnJson: Record, - ): Promise { - // TODO: Ask user to confirm txn - return true; + protected async broadcastTransaction( + chainApi: IOnChainService, + txnHash: string, + ): Promise { + try { + return (await chainApi.broadcastTransaction(txnHash)).transactionId; + } catch (error) { + logger.error('Failed to broadcast transaction', error); + throw new Error('Failed to commit transaction on chain'); + } } } diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts index d288ca22..06495791 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts @@ -4,39 +4,39 @@ import { Config } from '../config'; import * as superstruct from './superstruct'; describe('superstruct', () => { - describe('numberStringStruct', () => { + describe('positiveStringStruct', () => { it('validates correctly', () => { - expect(() => assert('1', superstruct.numberStringStruct)).not.toThrow( + expect(() => assert('1', superstruct.positiveStringStruct)).not.toThrow( Error, ); - expect(() => assert('1.2', superstruct.numberStringStruct)).not.toThrow( + expect(() => assert('1.2', superstruct.positiveStringStruct)).not.toThrow( Error, ); - expect(() => assert('0', superstruct.numberStringStruct)).not.toThrow( + expect(() => assert('0', superstruct.positiveStringStruct)).not.toThrow( Error, ); expect(() => - assert('0.0023', superstruct.numberStringStruct), + assert('0.0023', superstruct.positiveStringStruct), ).not.toThrow(); - expect(() => assert('0101', superstruct.numberStringStruct)).toThrow( + expect(() => assert('0101', superstruct.positiveStringStruct)).toThrow( Error, ); - expect(() => assert('0101.1', superstruct.numberStringStruct)).toThrow( + expect(() => assert('0101.1', superstruct.positiveStringStruct)).toThrow( Error, ); - expect(() => assert('-0101', superstruct.numberStringStruct)).toThrow( + expect(() => assert('-0101', superstruct.positiveStringStruct)).toThrow( Error, ); - expect(() => assert('-1.3', superstruct.numberStringStruct)).toThrow( + expect(() => assert('-1.3', superstruct.positiveStringStruct)).toThrow( Error, ); - expect(() => assert(' 1.3', superstruct.numberStringStruct)).toThrow( + expect(() => assert(' 1.3', superstruct.positiveStringStruct)).toThrow( Error, ); - expect(() => assert('+1-3', superstruct.numberStringStruct)).toThrow( + expect(() => assert('+1-3', superstruct.positiveStringStruct)).toThrow( Error, ); - expect(() => assert('abc', superstruct.numberStringStruct)).toThrow( + expect(() => assert('abc', superstruct.positiveStringStruct)).toThrow( Error, ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts index f19cfc0a..a7c15dda 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts @@ -6,4 +6,7 @@ export const assetsStruct = enums(Config.avaliableAssets[Config.chain]); export const scopeStruct = enums(Config.avaliableNetworks[Config.chain]); -export const numberStringStruct = pattern(string(), /^(?!0\d)(\d+(\.\d+)?)$/u); +export const positiveStringStruct = pattern( + string(), + /^(?!0\d)(\d+(\.\d+)?)$/u, +); diff --git a/merged-packages/bitcoin-wallet-snap/src/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/wallet.ts index 081aa6b6..ea2ba37c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/wallet.ts @@ -4,21 +4,66 @@ import type { Buffer } from 'buffer'; import type { TransactionIntent } from './chain'; export type IAccount = { + /** + * Master fingerpring of the devied node. + */ mfp: string; + /** + * Index of the devied node. + */ index: number; + /** + * Address of the account. + */ address: string; + /** + * HD path of the account. + */ hdPath: string; + /** + * Public key of the account. + */ pubkey: string; + /** + * Type of the account, e.g `bip122:p2pwh`. + */ type: string; + /** + * IAccountSigner object derived from the root node. + */ signer: IAccountSigner; }; export type IWallet = { + /** + * A method to unlock an account by index and script type. + * + * @param index - Index to derive from the node. + * @param type - Script type of the unlocked account, e.g `bip122:p2pkh`. + * @returns A promise that resolves to an IAccount object. + */ unlock(index: number, type: string): Promise; + + /** + * A method to sign an transaction by the given encoded txn string. + * + * @param signer - The signer object to sign the transaction. + * @param txn - The encoded txn string to convert back to an transaction. + * @returns A promise that resolves to an string of signed transaction. + */ signTransaction(signer: IAccountSigner, txn: string): Promise; + + /** + * A method to create an transaction by the given account, transaction intent and options. + * + * @param acount - The IAccount object to create the transaction. + * @param txnIntent - The encoded txn string to convert back to an transaction. + * @param options - The options to create the transaction. + * @returns A promise that resolves to an object contains txnHash and txnJson. + */ createTransaction( acount: IAccount, - txn: TransactionIntent, + txnIntent: TransactionIntent, options: Record, ): Promise<{ txn: string; @@ -27,9 +72,40 @@ export type IWallet = { }; export type IAccountSigner = { + /** + * A method to create an transaction by the given account, transaction intent and options. + * + * @param acount - The IAccount object to create the transaction. + * @param txnIntent - The encoded txn string to convert back to an transaction. + * @param options - The options to create the transaction. + * @returns A promise that resolves to an object contains txnHash and txnJson. + */ sign(hash: Buffer): Promise; + + /** + * A method to derive an IAccountSigner object by hd path. + * + * @param path - The hd path in string, e.g `m'\0'\0`. + * @returns An IAccountSigner derived by the given path. + */ derivePath(path: string): IAccountSigner; + + /** + * A method to veriy the signature by the derived node of an IAccountSigner object. + * + * @param hash - The hash of the transaction in buffer. + * @param signature - The signature of the signed transaction in buffer. + * @returns Verify result in Boolean. + */ verify(hash: Buffer, signature: Buffer): boolean; + + /** + * Public Key of the current devied node for verify an signature. + */ publicKey: Buffer; + + /** + * Fingerprint of the current devied node for verify an signature. + */ fingerprint: Buffer; }; diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index 3cae6016..96068c54 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -1,6 +1,7 @@ import type { BIP32Interface } from 'bip32'; import type { Network } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; +import { v4 as uuidv4 } from 'uuid'; import { Network as NetworkEnum } from '../src/modules/bitcoin/constants'; import blockChairData from './fixtures/blockchair.json'; @@ -11,23 +12,19 @@ import blockStreamData from './fixtures/blockstream.json'; * * @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 = '') { +export function generateAccounts(cnt = 1, addressPrefix = '') { 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++) { accounts.push({ type: 'bip122:p2wpkh', - id: - baseUUID.slice(0, baseUUID.length - i.toString().length) + i.toString(), + id: uuidv4(), address: baseAddress.slice(0, baseAddress.length - i.toString().length) + i.toString(), @@ -207,6 +204,8 @@ export function generateBlockChairGetBalanceResp(addresses: string[]) { export function generateBlockChairGetUtxosResp( address: string, utxosCount: number, + minAmount: number = 0, + maxAmount: number = 1000000, ) { const template = blockChairData.getUtxoResp; const data = { ...template.data.tb1qlq94vt9uh07fwunsgdyycpkv24uev05ywjua0r }; @@ -227,7 +226,7 @@ export function generateBlockChairGetUtxosResp( '0', ), index: idx, - value: randomNum(1000000), + value: Math.max(randomNum(maxAmount), minAmount), }; }), }, @@ -313,3 +312,33 @@ export function generateBlockChairBroadcastTransactionResp() { const resp: typeof template = { ...template }; return resp; } + +/** + * Method to generate formated utxos with blockchair resp. + * + * @param address - the utxos owner address. + * @param utxoCnt - count of the utox to be generated. + * @param minAmount - min amount of each utxo value. + * @param maxAmount - max amount of each utxo value. + * @returns An formatted utxo array. + */ +export function generateFormatedUtxos( + address: string, + utxoCnt: number, + minAmt?: number, + maxAmt?: number, +) { + const rawUtxos = generateBlockChairGetUtxosResp( + address, + utxoCnt, + minAmt, + maxAmt, + ); + const formattedUtxos = rawUtxos.data[address].utxo.map((utxo) => ({ + block: utxo.block_id, + txnHash: utxo.transaction_hash, + index: utxo.index, + value: utxo.value, + })); + return formattedUtxos; +} From 9c1c4ef2cfa28ffdc5ee92a7e47d35363d6b8204 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 28 May 2024 11:46:32 +0800 Subject: [PATCH 042/362] feat: add while list domain (#71) --- .../bitcoin-wallet-snap/snap.manifest.json | 11 +- .../src/config/permissions.ts | 102 +++++++----------- 2 files changed, 46 insertions(+), 67 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 422914e6..d61e417e 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -17,6 +17,13 @@ } } }, + "initialConnections": { + "https://metamask.github.io": {}, + "http://localhost:8000": {}, + "https://portfolio.metamask.io": {}, + "https://portfolio-builds.metafi-dev.codefi.network": {}, + "https://dev.portfolio.metamask.io": {} + }, "initialPermissions": { "endowment:rpc": { "dapps": true, @@ -26,7 +33,9 @@ "allowedOrigins": [ "https://metamask.github.io", "http://localhost:8000", - "https://portfolio.metamask.io" + "https://portfolio.metamask.io", + "https://portfolio-builds.metafi-dev.codefi.network", + "https://dev.portfolio.metamask.io" ] }, "snap_getBip32Entropy": [ diff --git a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts index 7d4ebddd..95196ca3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts @@ -1,69 +1,39 @@ import { KeyringRpcMethod } from '@metamask/keyring-api'; -export const originPermissions = new Map>([ - [ - 'metamask', - new Set([ - // Keyring methods - KeyringRpcMethod.ListAccounts, - KeyringRpcMethod.GetAccount, - KeyringRpcMethod.FilterAccountChains, - KeyringRpcMethod.DeleteAccount, - KeyringRpcMethod.ListRequests, - KeyringRpcMethod.GetRequest, - KeyringRpcMethod.SubmitRequest, - KeyringRpcMethod.RejectRequest, - KeyringRpcMethod.SubmitRequest, - // Chain API methods - 'chain_getBalances', - 'chain_broadcastTransaction', - 'chain_getDataForTransaction', - 'chain_estimateFees', - ]), - ], - [ - 'http://localhost:8000', - new Set([ - // Keyring methods - KeyringRpcMethod.ListAccounts, - KeyringRpcMethod.GetAccount, - KeyringRpcMethod.CreateAccount, - KeyringRpcMethod.FilterAccountChains, - KeyringRpcMethod.UpdateAccount, - KeyringRpcMethod.DeleteAccount, - KeyringRpcMethod.ListRequests, - KeyringRpcMethod.GetRequest, - KeyringRpcMethod.ApproveRequest, - KeyringRpcMethod.RejectRequest, - KeyringRpcMethod.SubmitRequest, - // Chain API methods - 'chain_getBalances', - 'chain_createAccount', - 'chain_broadcastTransaction', - 'chain_getDataForTransaction', - 'chain_estimateFees', - ]), - ], - [ - 'https://metamask.github.io', - new Set([ - // Keyring methods - KeyringRpcMethod.ListAccounts, - KeyringRpcMethod.GetAccount, - KeyringRpcMethod.CreateAccount, - KeyringRpcMethod.FilterAccountChains, - KeyringRpcMethod.UpdateAccount, - KeyringRpcMethod.DeleteAccount, - KeyringRpcMethod.ListRequests, - KeyringRpcMethod.GetRequest, - KeyringRpcMethod.ApproveRequest, - KeyringRpcMethod.RejectRequest, - KeyringRpcMethod.SubmitRequest, - // Chain API methods - 'chain_getBalances', - 'chain_broadcastTransaction', - 'chain_getDataForTransaction', - 'chain_estimateFees', - ]), - ], +const allowSet = new Set([ + // Keyring methods + KeyringRpcMethod.ListAccounts, + KeyringRpcMethod.GetAccount, + KeyringRpcMethod.CreateAccount, + KeyringRpcMethod.FilterAccountChains, + KeyringRpcMethod.UpdateAccount, + KeyringRpcMethod.DeleteAccount, + KeyringRpcMethod.ListRequests, + KeyringRpcMethod.GetRequest, + KeyringRpcMethod.ApproveRequest, + KeyringRpcMethod.RejectRequest, + KeyringRpcMethod.SubmitRequest, + // Chain API methods + 'chain_getBalances', + 'chain_broadcastTransaction', + 'chain_getDataForTransaction', + 'chain_estimateFees', ]); + +const allowedOrigins = [ + 'https://metamask.github.io', + 'https://portfolio.metamask.io', + 'https://portfolio-builds.metafi-dev.codefi.network', + 'https://dev.portfolio.metamask.io', +]; + +const local = 'http://localhost:8000'; +const metamask = 'metamask'; + +export const originPermissions = new Map>([]); + +for (const origin of allowedOrigins) { + originPermissions.set(origin, allowSet); +} +originPermissions.set(metamask, allowSet); +originPermissions.set(local, new Set([...allowSet, 'chain_createAccount'])); From 6e81a84d21b7007e214bbab1d94322fce7c6627c Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 28 May 2024 11:50:14 +0800 Subject: [PATCH 043/362] feat: add auto install script (#67) * feat: add auto install script * Update build-preinstalled-snap.js --- .../bitcoin-wallet-snap/package.json | 3 +- .../scripts/build-preinstalled-snap.js | 70 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 22c3e243..8aa38f9d 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -15,7 +15,8 @@ ], "scripts": { "allow-scripts": "yarn workspace root allow-scripts", - "build": "mm-snap build", + "build": "mm-snap build && yarn build-preinstalled-snap", + "build-preinstalled-snap": "node scripts/build-preinstalled-snap.js", "build:clean": "yarn clean && yarn build", "clean": "rimraf dist", "lint": "yarn lint:eslint && yarn lint:misc --check", diff --git a/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js b/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js new file mode 100644 index 00000000..0c6e675b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js @@ -0,0 +1,70 @@ +// @ts-check +/* eslint-disable */ + +const { readFileSync, writeFileSync } = require('node:fs'); +const { join } = require('node:path'); + +const packageFile = require('../package.json'); + +console.log('[preinstalled-snap] - attempt to build preinstalled snap'); + +/** + * Read the contents of a file and return as a string. + * @param {string} filePath - Path to file. + * @returns {string} File as utf-8 string. + */ +function readFileContents(filePath) { + try { + return readFileSync(filePath, 'utf8'); + } catch (error) { + console.error(`Error reading file from disk: ${filePath}`, error); + throw error; + } +} + +// Paths to the files +const bundlePath = require.resolve('../dist/bundle.js'); +const iconPath = require.resolve('../images/icon.svg'); +const manifestPath = require.resolve('../snap.manifest.json'); + +// File Contents +const bundle = readFileContents(bundlePath); +const icon = readFileContents(iconPath); +const manifest = readFileContents(manifestPath); + +const snapId = + /** @type {import('@metamask/snaps-controllers').PreinstalledSnap['snapId']} */ ( + `npm:${packageFile.name}` + ); + +/** + * @type {import('@metamask/snaps-controllers').PreinstalledSnap} + */ +const preinstalledSnap = { + snapId, + manifest: JSON.parse(manifest), + files: [ + { + path: 'images/icon.svg', + value: icon, + }, + { + path: 'dist/bundle.js', + value: bundle, + }, + ], + removable: false, +}; + +// Write preinstalled-snap file +try { + const outputPath = join(__dirname, '..', 'dist/preinstalled-snap.json'); + writeFileSync(outputPath, JSON.stringify(preinstalledSnap, null, 0)); + + console.log( + `[preinstalled-snap] - successfully created preinstalled snap at ${outputPath}`, + ); +} catch (error) { + console.error('Error writing combined file to disk:', error); + throw error; +} From 15e8eff65eb2b279400ffc9769feaaacaee407a2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 May 2024 15:44:43 +0800 Subject: [PATCH 044/362] 0.1.1 (#73) * 0.1.1 * chore: update change log --------- Co-authored-by: github-actions Co-authored-by: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> --- .../bitcoin-wallet-snap/CHANGELOG.md | 39 ++++++++++++++++++- .../bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 4656a044..f6bcec7f 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,4 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -[Unreleased]: git+https://github.com/MetaMask/bitcoin/ +## [0.1.1] + +### Added + +- feat: add auto install script ([#67](https://github.com/MetaMask/bitcoin/pull/67)) +- feat: add while list domain ([#71](https://github.com/MetaMask/bitcoin/pull/71)) +- feat: implement keyring api - btc_sendmany ([#65](https://github.com/MetaMask/bitcoin/pull/65)) +- chore: update snap provider instance name ([#69](https://github.com/MetaMask/bitcoin/pull/69)) +- build(deps-dev): bump @metamask/snaps-jest from 7.0.2 to 8.0.0 ([#50](https://github.com/MetaMask/bitcoin/pull/50)) +- feat: add bufferToString method ([#61](https://github.com/MetaMask/bitcoin/pull/61)) +- feat: add chain API - chain_broadcastTransaction ([#57](https://github.com/MetaMask/bitcoin/pull/57)) +- chore: add unit test for get balances ([#58](https://github.com/MetaMask/bitcoin/pull/58)) +- feat: add chain API - chain_getDataForTransaction ([#42](https://github.com/MetaMask/bitcoin/pull/42)) +- feat: add chain API - chain_estimateFees ([#18](https://github.com/MetaMask/bitcoin/pull/18)) +- feat: add keyring API - btc_sendmany skeleton ([#41](https://github.com/MetaMask/bitcoin/pull/41)) +- feat: add ListAccountsButton card ([#43](https://github.com/MetaMask/bitcoin/pull/43)) +- feat: add methods to support satoshi to btc, and btc to satoshi ([#34](https://github.com/MetaMask/bitcoin/pull/34)) +- chore: add commit method in state management ([#32](https://github.com/MetaMask/bitcoin/pull/32)) +- feat: add api key for blockchair ([#27](https://github.com/MetaMask/bitcoin/pull/27)) +- feat: support transactional state management ([#20](https://github.com/MetaMask/bitcoin/pull/20)) +- feat: add permission validation ([#24](https://github.com/MetaMask/bitcoin/pull/24)) +- feat: add validation on api response ([#17](https://github.com/MetaMask/bitcoin/pull/17)) +- feat: implement chain api - get balances ([#16](https://github.com/MetaMask/bitcoin/pull/16)) +- feat: setup init cd to publish snap to npm public registry ([#15](https://github.com/MetaMask/bitcoin/pull/15)) +- feat: implement chain api skeleton ([#14](https://github.com/MetaMask/bitcoin/pull/14)) +- feat: emit keyring event before state store ([#13](https://github.com/MetaMask/bitcoin/pull/13)) +- feat: implement keyring api ([#12](https://github.com/MetaMask/bitcoin/pull/12)) +- feat: add blockchair ([#11](https://github.com/MetaMask/bitcoin/pull/11)) +- chore: update keyring type and methods permission ([#10](https://github.com/MetaMask/bitcoin/pull/10)) +- chore: update snap icon ([#9](https://github.com/MetaMask/bitcoin/pull/9)) +- build(deps): bump @metamask/keyring-api from 5.1.0 to 6.0.0 ([#6](https://github.com/MetaMask/bitcoin/pull/6)) +- build(deps-dev): bump @metamask/snaps-jest from 6.0.2 to 7.0.2 ([#7](https://github.com/MetaMask/bitcoin/pull/7)) +- feat: add snap unit test ([#1](https://github.com/MetaMask/bitcoin/pull/1)) +- feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) +- feat: init commit + +[Unreleased]: git+https://github.com/MetaMask/bitcoin/compare/v0.1.1...HEAD +[0.1.1]: git+https://github.com/MetaMask/bitcoin/releases/tag/v0.1.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 8aa38f9d..288aa73d 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin", - "version": "0.1.0", + "version": "0.1.1", "description": "A MetaMask snap to manage Bitcoin", "repository": { "type": "git", From 492b86f83008458cee2a3b4c612c51939fe52868 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 28 May 2024 16:27:02 +0800 Subject: [PATCH 045/362] fix: update change log format (#76) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index f6bcec7f..52e5c3e1 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -43,5 +43,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: git+https://github.com/MetaMask/bitcoin/compare/v0.1.1...HEAD -[0.1.1]: git+https://github.com/MetaMask/bitcoin/releases/tag/v0.1.1 +[Unreleased]: https://github.com/MetaMask/bitcoin/compare/v0.1.1...HEAD +[0.1.1]: https://github.com/MetaMask/bitcoin/releases/tag/v0.1.1 From 394599c982bbeb42deae608d0a2ea40326c3b6c5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 May 2024 16:38:33 +0800 Subject: [PATCH 046/362] 0.1.2 (#77) * 0.1.2 * chore: update change log --------- Co-authored-by: github-actions Co-authored-by: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> --- .../bitcoin-wallet-snap/CHANGELOG.md | 22 ++++++++++++++++++- .../bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 52e5c3e1..780a8a4e 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,12 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.2] + +### Changed + +- fix: update change log format ([#76](https://github.com/MetaMask/bitcoin/pull/76)) +- fix: update package.json ([#76](https://github.com/MetaMask/bitcoin/pull/74)) + ## [0.1.1] ### Added +- fix: update resp of chainService - boardcastTransaction ([#68](https://github.com/MetaMask/bitcoin/pull/68)) - feat: add auto install script ([#67](https://github.com/MetaMask/bitcoin/pull/67)) - feat: add while list domain ([#71](https://github.com/MetaMask/bitcoin/pull/71)) +- chore: re structure ([#66](https://github.com/MetaMask/bitcoin/pull/66)) +- fix: change to keyring state method get wallet ([#62](https://github.com/MetaMask/bitcoin/pull/62)) - feat: implement keyring api - btc_sendmany ([#65](https://github.com/MetaMask/bitcoin/pull/65)) - chore: update snap provider instance name ([#69](https://github.com/MetaMask/bitcoin/pull/69)) - build(deps-dev): bump @metamask/snaps-jest from 7.0.2 to 8.0.0 ([#50](https://github.com/MetaMask/bitcoin/pull/50)) @@ -22,12 +32,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add chain API - chain_getDataForTransaction ([#42](https://github.com/MetaMask/bitcoin/pull/42)) - feat: add chain API - chain_estimateFees ([#18](https://github.com/MetaMask/bitcoin/pull/18)) - feat: add keyring API - btc_sendmany skeleton ([#41](https://github.com/MetaMask/bitcoin/pull/41)) +- fix: update btc asset ([#44](https://github.com/MetaMask/bitcoin/pull/44)) - feat: add ListAccountsButton card ([#43](https://github.com/MetaMask/bitcoin/pull/43)) +- chore: move config to factory ([#35](https://github.com/MetaMask/bitcoin/pull/35)) - feat: add methods to support satoshi to btc, and btc to satoshi ([#34](https://github.com/MetaMask/bitcoin/pull/34)) - chore: add commit method in state management ([#32](https://github.com/MetaMask/bitcoin/pull/32)) +- fix: restructure code ([#31](https://github.com/MetaMask/bitcoin/pull/31)) +- fix: remove non ready code ([#30](https://github.com/MetaMask/bitcoin/pull/30)) +- fix: fix snap publish package is not using builded snap config ([#29](https://github.com/MetaMask/bitcoin/pull/29)) - feat: add api key for blockchair ([#27](https://github.com/MetaMask/bitcoin/pull/27)) +- chore: restructure the code repo to fit to chain api and keyring api structure ([#26](https://github.com/MetaMask/bitcoin/pull/26)) +- fix: refine coding structure ([#25](https://github.com/MetaMask/bitcoin/pull/25)) - feat: support transactional state management ([#20](https://github.com/MetaMask/bitcoin/pull/20)) - feat: add permission validation ([#24](https://github.com/MetaMask/bitcoin/pull/24)) +- fix: remove un use code in BaseSnapRpcHandler ([#19](https://github.com/MetaMask/bitcoin/pull/19)) - feat: add validation on api response ([#17](https://github.com/MetaMask/bitcoin/pull/17)) - feat: implement chain api - get balances ([#16](https://github.com/MetaMask/bitcoin/pull/16)) - feat: setup init cd to publish snap to npm public registry ([#15](https://github.com/MetaMask/bitcoin/pull/15)) @@ -37,11 +55,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add blockchair ([#11](https://github.com/MetaMask/bitcoin/pull/11)) - chore: update keyring type and methods permission ([#10](https://github.com/MetaMask/bitcoin/pull/10)) - chore: update snap icon ([#9](https://github.com/MetaMask/bitcoin/pull/9)) +- fix: the ci pipeline for metamask task issue ([#8](https://github.com/MetaMask/bitcoin/pull/8)) - build(deps): bump @metamask/keyring-api from 5.1.0 to 6.0.0 ([#6](https://github.com/MetaMask/bitcoin/pull/6)) - build(deps-dev): bump @metamask/snaps-jest from 6.0.2 to 7.0.2 ([#7](https://github.com/MetaMask/bitcoin/pull/7)) - feat: add snap unit test ([#1](https://github.com/MetaMask/bitcoin/pull/1)) - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/bitcoin/compare/v0.1.1...HEAD +[Unreleased]: https://github.com/MetaMask/bitcoin/compare/v0.1.2...HEAD +[0.1.2]: https://github.com/MetaMask/bitcoin/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/MetaMask/bitcoin/releases/tag/v0.1.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 288aa73d..6865e6d2 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin", - "version": "0.1.1", + "version": "0.1.2", "description": "A MetaMask snap to manage Bitcoin", "repository": { "type": "git", From 1f6ea5cb5bc15ce0edebcd3cfe0f657d4f01b00d Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 30 May 2024 18:33:14 +0800 Subject: [PATCH 047/362] chore: update send many dialog and convert txnJson to TransactionInfo Object (#83) --- .../bitcoin-wallet-snap/jest.config.js | 2 + .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../bitcoin-wallet-snap/src/chain.ts | 8 +- .../bitcoin-wallet-snap/src/config/config.ts | 18 ++ .../src/config/permissions.ts | 3 - .../src/modules/bitcoin/chain/service.test.ts | 25 ++- .../src/modules/bitcoin/chain/service.ts | 7 +- .../src/modules/bitcoin/constants.ts | 6 + .../modules/bitcoin/utils/explorer.test.ts | 27 +++ .../src/modules/bitcoin/utils/explorer.ts | 24 +++ .../src/modules/bitcoin/utils/index.ts | 1 + .../src/modules/bitcoin/utils/network.test.ts | 18 +- .../src/modules/bitcoin/utils/network.ts | 18 ++ .../modules/bitcoin/wallet/address.test.ts | 61 ++++++ .../src/modules/bitcoin/wallet/address.ts | 37 ++++ .../src/modules/bitcoin/wallet/amount.test.ts | 33 ++++ .../src/modules/bitcoin/wallet/amount.ts | 39 ++++ .../bitcoin/wallet/transactionInfo.test.ts | 61 ++++++ .../modules/bitcoin/wallet/transactionInfo.ts | 61 ++++++ .../src/modules/bitcoin/wallet/wallet.test.ts | 28 ++- .../src/modules/bitcoin/wallet/wallet.ts | 52 ++--- .../src/modules/snap/helpers.test.ts | 61 ++---- .../src/modules/snap/helpers.ts | 40 +--- .../src/rpcs/broadcast-transaction.test.ts | 61 ------ .../src/rpcs/broadcast-transaction.ts | 51 ----- .../src/rpcs/estimate-fees.test.ts | 86 --------- .../src/rpcs/estimate-fees.ts | 61 ------ .../src/rpcs/get-balances.test.ts | 5 +- .../src/rpcs/get-balances.ts | 3 +- .../src/rpcs/get-transaction-data.test.ts | 79 -------- .../src/rpcs/get-transaction-data.ts | 72 ------- .../rpcs/{helper.test.ts => helpers.test.ts} | 15 +- .../bitcoin-wallet-snap/src/rpcs/helpers.ts | 15 +- .../src/rpcs/sendmany.test.ts | 177 +++++++++++++----- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 125 ++++++++----- .../bitcoin-wallet-snap/src/utils/string.ts | 24 ++- .../bitcoin-wallet-snap/src/wallet.ts | 19 +- 37 files changed, 763 insertions(+), 664 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/explorer.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/explorer.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/address.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/address.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/transactionInfo.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/transactionInfo.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.ts rename merged-packages/bitcoin-wallet-snap/src/rpcs/{helper.test.ts => helpers.test.ts} (61%) diff --git a/merged-packages/bitcoin-wallet-snap/jest.config.js b/merged-packages/bitcoin-wallet-snap/jest.config.js index 867c7682..a33c3968 100644 --- a/merged-packages/bitcoin-wallet-snap/jest.config.js +++ b/merged-packages/bitcoin-wallet-snap/jest.config.js @@ -7,6 +7,7 @@ module.exports = { restoreMocks: true, resetMocks: true, verbose: true, + testPathIgnorePatterns: ['/node_modules/', '/__BAK__/'], 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 @@ -14,6 +15,7 @@ module.exports = { './src/**/*.ts', '!./src/**/*.d.ts', '!./src/**/index.ts', + '!./src/**/__BAK__/**', '!./src/config/*.ts', '!./src/**/type?(s).ts', '!./src/**/exception?(s).ts', diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index d61e417e..6d867065 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.1.0", + "version": "0.1.2", "description": "A MetaMask snap to manage Bitcoin", "proposedName": "Bitcoin Manager", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "vWypO8fpSt9kBJueagQk+bvEqiVggRfRJcbdQYPzl5I=", + "shasum": "aLYl2Hvq1WIZqAOq8d7mt7N41bUZrd5Xua0ycUjGDtE=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/chain.ts b/merged-packages/bitcoin-wallet-snap/src/chain.ts index 8884fc43..14db8c61 100644 --- a/merged-packages/bitcoin-wallet-snap/src/chain.ts +++ b/merged-packages/bitcoin-wallet-snap/src/chain.ts @@ -2,6 +2,8 @@ import type { Json } from '@metamask/snaps-sdk'; import type { Infer } from 'superstruct'; import { object, define, string, array, boolean } from 'superstruct'; +import type { IAmount } from './wallet'; + const transactionIntentAmts = () => define>( 'transactionIntentAmts', @@ -29,7 +31,7 @@ export enum FeeRatio { export type Balances = Record; export type Balance = { - amount: number; + amount: IAmount; }; export type AssetBalances = { @@ -42,7 +44,7 @@ export type AssetBalances = { export type Fee = { type: FeeRatio; - rate: number; + rate: IAmount; }; export type Fees = { @@ -72,7 +74,7 @@ export type CommitedTransaction = { export type IOnChainService = { getBalances(addresses: string[], assets: string[]): Promise; - estimateFees(): Promise; + getFeeRates(): Promise; broadcastTransaction(signedTransaction: string): Promise; listTransactions(address: string, pagination: Pagination); getTransaction(txnHash: string); diff --git a/merged-packages/bitcoin-wallet-snap/src/config/config.ts b/merged-packages/bitcoin-wallet-snap/src/config/config.ts index 3cf73acf..a71f06d4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/config.ts @@ -6,6 +6,7 @@ import { Network as BtcNetwork, DataClient, BtcAsset, + BtcUnit, } from '../modules/bitcoin/constants'; export enum Chain { @@ -33,6 +34,14 @@ export type SnapConfig = { avaliableAssets: { [key in Chain]: string[]; }; + unit: { + [key in Chain]: string; + }; + explorer: { + [key in Chain]: { + [network in string]: string; + }; + }; chain: Chain; logLevel: string; }; @@ -79,6 +88,15 @@ export const Config: SnapConfig = { avaliableAssets: { [Chain.Bitcoin]: Object.values(BtcAsset), }, + unit: { + [Chain.Bitcoin]: BtcUnit.Btc, + }, + explorer: { + [Chain.Bitcoin]: { + [BtcNetwork.Mainnet]: 'https://blockstream.info', + [BtcNetwork.Testnet]: 'https://blockstream.info/testnet', + }, + }, chain: Chain.Bitcoin, // eslint-disable-next-line no-restricted-globals logLevel: process.env.LOG_LEVEL ?? '6', diff --git a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts index 95196ca3..7621caeb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts @@ -15,9 +15,6 @@ const allowSet = new Set([ KeyringRpcMethod.SubmitRequest, // Chain API methods 'chain_getBalances', - 'chain_broadcastTransaction', - 'chain_getDataForTransaction', - 'chain_estimateFees', ]); const allowedOrigins = [ diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts index 7fde9494..fc813df9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts @@ -9,6 +9,7 @@ import { import { FeeRatio } from '../../../chain'; import { BtcAsset } from '../constants'; import type { IReadDataClient, IWriteDataClient } from '../data-client'; +import { BtcAmount } from '../wallet/amount'; import { BtcOnChainServiceError } from './exceptions'; import { BtcOnChainService } from './service'; @@ -82,9 +83,17 @@ describe('BtcOnChainService', () => { }, {}), ); - await txnService.getBalances(addresses, [BtcAsset.TBtc]); + const result = await txnService.getBalances(addresses, [BtcAsset.TBtc]); expect(getBalanceSpy).toHaveBeenCalledWith(addresses); + + Object.values(result.balances).forEach((assetBalances) => { + expect(assetBalances).toStrictEqual({ + [BtcAsset.TBtc]: { + amount: expect.any(BtcAmount), + }, + }); + }); }); it('throws `Only one asset is supported` error if the given asset more than 1', async () => { @@ -179,8 +188,8 @@ describe('BtcOnChainService', () => { }); }); - describe('estimateFees', () => { - it('return estimateFees result', async () => { + describe('getFeeRates', () => { + it('return getFeeRates result', async () => { const { instance, getFeeRatesSpy } = createMockReadDataClient(); const { instance: txnMgr } = createMockBtcService(instance); getFeeRatesSpy.mockResolvedValue({ @@ -188,21 +197,23 @@ describe('BtcOnChainService', () => { [FeeRatio.Medium]: 1.2, }); - const result = await txnMgr.estimateFees(); + const result = await txnMgr.getFeeRates(); expect(getFeeRatesSpy).toHaveBeenCalledTimes(1); expect(result).toStrictEqual({ fees: [ { type: FeeRatio.Fast, - rate: 1.1, + rate: expect.any(BtcAmount), }, { type: FeeRatio.Medium, - rate: 1.2, + rate: expect.any(BtcAmount), }, ], }); + expect(result.fees[0].rate.value).toBe(1.1); + expect(result.fees[1].rate.value).toBe(1.2); }); it('throws BtcOnChainServiceError error if an error catched', async () => { @@ -211,7 +222,7 @@ describe('BtcOnChainService', () => { getFeeRatesSpy.mockRejectedValue(new Error('error')); - await expect(txnMgr.estimateFees()).rejects.toThrow( + await expect(txnMgr.getFeeRates()).rejects.toThrow( BtcOnChainServiceError, ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts index 4d8cf776..7f43ba6a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts @@ -15,6 +15,7 @@ import type { import { compactError } from '../../../utils'; import { BtcAsset } from '../constants'; import type { IWriteDataClient, IReadDataClient } from '../data-client'; +import { BtcAmount } from '../wallet/amount'; import { BtcOnChainServiceError } from './exceptions'; import type { BtcOnChainServiceOptions } from './types'; @@ -64,7 +65,7 @@ export class BtcOnChainService implements IOnChainService { (acc: AssetBalances, address: string) => { acc.balances[address] = { [assets[0]]: { - amount: balance[address], + amount: new BtcAmount(balance[address]), }, }; return acc; @@ -76,7 +77,7 @@ export class BtcOnChainService implements IOnChainService { } } - async estimateFees(): Promise { + async getFeeRates(): Promise { try { const result = await this.readClient.getFeeRates(); @@ -84,7 +85,7 @@ export class BtcOnChainService implements IOnChainService { fees: Object.entries(result).map( ([key, value]: [key: FeeRatio, value: number]) => ({ type: key, - rate: value, + rate: new BtcAmount(value), }), ), }; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/constants.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/constants.ts index d334a55d..58e194e3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/constants.ts @@ -19,6 +19,12 @@ export enum BtcAsset { TBtc = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0', } +export enum BtcUnit { + Btc = 'BTC', + TBtc = 'tBTC', + Sat = 'satoshi', +} + // reference https://help.magiceden.io/en/articles/8665399-navigating-bitcoin-dust-understanding-limits-and-safeguarding-your-transactions-on-magic-eden // "Dust" is defined in terms of dustRelayFee, // which has units satoshis-per-kilobyte. diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/explorer.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/explorer.test.ts new file mode 100644 index 00000000..4b62b18e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/explorer.test.ts @@ -0,0 +1,27 @@ +import { Chain, Config } from '../../../config'; +import { Network } from '../constants'; +import { getExplorerUrl } from './explorer'; + +describe('getExplorerUrl', () => { + const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; + + it('returns a bitcoin testnet explorer url', () => { + const result = getExplorerUrl(address, Network.Testnet); + expect(result).toBe( + `${Config.explorer[Chain.Bitcoin][Network.Testnet]}/address/${address}`, + ); + }); + + it('returns a bitcoin mainnet explorer url', () => { + const result = getExplorerUrl(address, Network.Mainnet); + expect(result).toBe( + `${Config.explorer[Chain.Bitcoin][Network.Mainnet]}/address/${address}`, + ); + }); + + it('throws `Invalid network` error if the given network is not support', () => { + expect(() => getExplorerUrl(address, 'some invalid network')).toThrow( + 'Invalid network', + ); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/explorer.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/explorer.ts new file mode 100644 index 00000000..d75743e3 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/explorer.ts @@ -0,0 +1,24 @@ +import { Chain, Config } from '../../../config'; +import { Network } from '../constants'; + +/** + * Method to get ExplorerUrl by CAIP2 Chain Id string. + * + * @param address - The address to get the Explorer string for. + * @param network - The CAIP2 Chain Id. + * @returns The Explorer string. + */ +export function getExplorerUrl(address: string, network: string): string { + switch (network) { + case Network.Mainnet: + return `${ + Config.explorer[Chain.Bitcoin][Network.Mainnet] + }/address/${address}`; + case Network.Testnet: + return `${ + Config.explorer[Chain.Bitcoin][Network.Testnet] + }/address/${address}`; + default: + throw new Error('Invalid network'); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/index.ts index b89021ae..fb79250f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/index.ts @@ -1,3 +1,4 @@ export * from './payment'; export * from './network'; export * from './unit'; +export * from './explorer'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.test.ts index f3f5a6fa..3d76c58a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.test.ts @@ -1,7 +1,7 @@ import { networks } from 'bitcoinjs-lib'; import { Network } from '../constants'; -import { getBtcNetwork } from './network'; +import { getBtcNetwork, getCaip2Network } from './network'; describe('getBtcNetwork', () => { it('returns bitcoin testnet network', () => { @@ -20,3 +20,19 @@ describe('getBtcNetwork', () => { ).toThrow('Invalid network'); }); }); + +describe('getCaip2Network', () => { + it('returns caip2 testnet network', () => { + const result = getCaip2Network(networks.testnet); + expect(result).toStrictEqual(Network.Testnet); + }); + + it('returns caip2 mainnet network', () => { + const result = getCaip2Network(networks.bitcoin); + expect(result).toStrictEqual(Network.Mainnet); + }); + + it('throws `Invalid network` error if the given network is not support', () => { + expect(() => getCaip2Network(networks.regtest)).toThrow('Invalid network'); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.ts index 3bb15169..29408dc5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.ts @@ -1,3 +1,4 @@ +import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; import { Network as NetworkEnum } from '../constants'; @@ -18,3 +19,20 @@ export function getBtcNetwork(network: string) { throw new Error('Invalid network'); } } + +/** + * Method to get CAIP2 Chain Id string by bitcoinjs-lib network. + * + * @param network - The instance of bitcoinjs-lib network. + * @returns The CAIP2 Chain Id. + */ +export function getCaip2Network(network: Network): NetworkEnum { + switch (network) { + case networks.bitcoin: + return NetworkEnum.Mainnet; + case networks.testnet: + return NetworkEnum.Testnet; + default: + throw new Error('Invalid network'); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/address.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/address.test.ts new file mode 100644 index 00000000..0a647d21 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/address.test.ts @@ -0,0 +1,61 @@ +import { networks } from 'bitcoinjs-lib'; + +import { Network } from '../constants'; +import { getExplorerUrl } from '../utils'; +import { BtcAddress } from './address'; + +describe('BtcAddress', () => { + const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; + + describe('toString', () => { + it('returns a address', async () => { + const network = networks.testnet; + const val = new BtcAddress(address, network); + expect(val.toString()).toStrictEqual(address); + }); + + it('returns a shortern address', async () => { + const network = networks.testnet; + const val = new BtcAddress(address, network); + expect(val.toString(true)).toBe('tb1qt...aeu'); + }); + }); + + describe('toJson', () => { + it('returns a json', async () => { + const network = networks.testnet; + const val = new BtcAddress(address, network); + expect(val.toJson()).toStrictEqual({ + address: val.toString(true), + rawAddress: val.toString(false), + explorerUrl: val.explorerUrl, + }); + }); + }); + + describe('valueOf', () => { + it('returns a value', async () => { + const network = networks.testnet; + const val = new BtcAddress(address, network); + expect(val.valueOf()).toStrictEqual(val.address); + }); + }); + + describe('explorerUrl', () => { + it('returns a testnet explorerUrl', async () => { + const network = networks.testnet; + const val = new BtcAddress(address, network); + expect(val.explorerUrl).toStrictEqual( + getExplorerUrl(address, Network.Testnet), + ); + }); + + it('returns a mainnet explorerUrl', async () => { + const network = networks.bitcoin; + const val = new BtcAddress(address, network); + expect(val.explorerUrl).toStrictEqual( + getExplorerUrl(address, Network.Mainnet), + ); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/address.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/address.ts new file mode 100644 index 00000000..4c6debc3 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/address.ts @@ -0,0 +1,37 @@ +import type { Json } from '@metamask/snaps-sdk'; +import type { Network } from 'bitcoinjs-lib'; + +import { replaceMiddleChar } from '../../../utils'; +import type { IAddress } from '../../../wallet'; +import { getCaip2Network, getExplorerUrl } from '../utils'; + +export class BtcAddress implements IAddress { + address: string; + + network: Network; + + constructor(address: string, network: Network) { + this.address = address; + this.network = network; + } + + get explorerUrl(): string { + return getExplorerUrl(this.address, getCaip2Network(this.network)); + } + + valueOf(): string { + return this.address; + } + + toString(isShortern = false): string { + return isShortern ? replaceMiddleChar(this.address, 5, 3) : this.address; + } + + toJson(): Record { + return { + address: this.toString(true), + rawAddress: this.address, + explorerUrl: this.explorerUrl, + }; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.test.ts new file mode 100644 index 00000000..1e2269d5 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.test.ts @@ -0,0 +1,33 @@ +import { satsToBtc } from '../utils'; +import { BtcAmount } from './amount'; + +describe('BtcAmount', () => { + describe('toString', () => { + it('returns a satsToBtc string with unit', async () => { + const val = new BtcAmount(1); + expect(val.toString(true)).toBe(`${satsToBtc(val.value)} BTC`); + }); + + it('returns a satsToBtc string without unit', async () => { + const val = new BtcAmount(1); + expect(val.toString(false)).toStrictEqual(satsToBtc(val.value)); + }); + }); + + describe('toJson', () => { + it('returns a json', async () => { + const val = new BtcAmount(1); + expect(val.toJson()).toStrictEqual({ + value: val.toString(true), + rawValue: val.value, + }); + }); + }); + + describe('valueOf', () => { + it('returns a value', async () => { + const val = new BtcAmount(1); + expect(val.valueOf()).toStrictEqual(val.value); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.ts new file mode 100644 index 00000000..0067bf42 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.ts @@ -0,0 +1,39 @@ +import type { Json } from '@metamask/snaps-sdk'; + +import { Chain, Config } from '../../../config'; +import type { IAmount } from '../../../wallet'; +import { satsToBtc } from '../utils'; + +export class BtcAmount implements IAmount { + #_value: number; + + constructor(value: number) { + this.#_value = value; + } + + public get value(): number { + return this.#_value; + } + + public set value(newValue: number) { + this.#_value = newValue; + } + + public valueOf(): number { + return this.value; + } + + public toString(withUnit = false): string { + if (!withUnit) { + return `${satsToBtc(this.value)}`; + } + return `${satsToBtc(this.value)} ${Config.unit[Chain.Bitcoin]}`; + } + + public toJson(): Record { + return { + value: this.toString(true), + rawValue: this.value, + }; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/transactionInfo.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/transactionInfo.test.ts new file mode 100644 index 00000000..c0820bd6 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/transactionInfo.test.ts @@ -0,0 +1,61 @@ +import { networks } from 'bitcoinjs-lib'; + +import { generateAccounts } from '../../../../test/utils'; +import { satsToBtc } from '../utils'; +import { BtcAddress } from './address'; +import { BtcAmount } from './amount'; +import { BtcTransactionInfo } from './transactionInfo'; + +describe('BtcTransactionInfo', () => { + describe('toJson', () => { + it('returns a json', async () => { + const accounts = generateAccounts(5); + const addresses = accounts.map((account) => account.address); + const info = new BtcTransactionInfo(); + info.feeRate.value = 100; + info.txnFee.value = 10000; + info.sender = new BtcAddress(addresses[0], networks.testnet); + let total = info.txnFee.value; + + for (let i = 1; i < addresses.length; i++) { + total += 100000; + info.recipients.set( + new BtcAddress(addresses[i], networks.testnet), + new BtcAmount(100000), + ); + } + info.changes.set( + new BtcAddress(addresses[0], networks.testnet), + new BtcAmount(500), + ); + total += 500; + + const expectedRecipients = [...info.recipients.entries()].map( + ([address, value]) => { + return { + ...address.toJson(), + ...value.toJson(), + }; + }, + ); + + const expectedChanges = [...info.changes.entries()].map( + ([address, value]) => { + return { + ...address.toJson(), + ...value.toJson(), + }; + }, + ); + + expect(info.toJson()).toStrictEqual({ + feeRate: `${satsToBtc(100)} BTC`, + txnFee: `${satsToBtc(10000)} BTC`, + sender: addresses[0], + recipients: expectedRecipients, + changes: expectedChanges, + total: `${satsToBtc(total)} BTC`, + }); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/transactionInfo.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/transactionInfo.ts new file mode 100644 index 00000000..0eaff759 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/transactionInfo.ts @@ -0,0 +1,61 @@ +import type { Json } from '@metamask/snaps-sdk'; + +import type { ITransactionInfo } from '../../../wallet'; +import type { BtcAddress } from './address'; +import { BtcAmount } from './amount'; + +export class BtcTransactionInfo implements ITransactionInfo { + recipients: Map; + + changes: Map; + + sender?: BtcAddress; + + feeRate: BtcAmount; + + txnFee: BtcAmount; + + _total: BtcAmount; + + constructor() { + this.recipients = new Map(); + this.changes = new Map(); + this.feeRate = new BtcAmount(1); + this.txnFee = new BtcAmount(0); + this._total = new BtcAmount(0); + } + + get total(): BtcAmount { + this._total.value = this.txnFee.value; + this.recipients.forEach((value) => { + this._total.value += value.value; + }); + this.changes.forEach((value) => { + this._total.value += value.value; + }); + return this._total; + } + + recipientsToJson(recipients: Map) { + const recipientsArr: Json[] = []; + + recipients.forEach((value, address) => { + recipientsArr.push({ + ...address.toJson(), + ...value.toJson(), + }); + }); + return recipientsArr; + } + + toJson>(): InfoJson { + return { + feeRate: this.feeRate.toString(true), + txnFee: this.txnFee.toString(true), + sender: this.sender?.toString(), + recipients: this.recipientsToJson(this.recipients), + changes: this.recipientsToJson(this.changes), + total: this.total.toString(true), + } as unknown as InfoJson; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts index 3cdb44d6..5fb4a5e0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts @@ -12,6 +12,7 @@ import { CoinSelectService } from './coin-select'; import { BtcAccountBip32Deriver } from './deriver'; import { WalletError } from './exceptions'; import { PsbtService } from './psbt'; +import { BtcTransactionInfo } from './transactionInfo'; import { BtcWallet } from './wallet'; describe('BtcWallet', () => { @@ -129,7 +130,7 @@ describe('BtcWallet', () => { const { instance } = createMockBip32Instance(network); const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(account.address, 2); + const utxos = generateFormatedUtxos(account.address, 2, 10000, 10000); const result = await wallet.createTransaction( account, @@ -142,16 +143,18 @@ describe('BtcWallet', () => { }, ); + const info: BtcTransactionInfo = + result.txnInfo as unknown as BtcTransactionInfo; + expect(result).toStrictEqual({ txn: expect.any(String), - txnJson: { - feeRate: 1, - estimatedFee: expect.any(Number), - sender: account.address, - recipients: expect.any(Array), - changes: expect.any(Array), - }, + txnInfo: expect.any(BtcTransactionInfo), }); + + expect(info.sender?.address).toStrictEqual(account.address); + expect(info.feeRate?.value).toBe(1); + expect(info.recipients?.size).toBe(1); + expect(info.changes?.size).toBe(1); }); it('passes correct parameter to CoinSelectService', async () => { @@ -219,7 +222,7 @@ describe('BtcWallet', () => { fee: 100, }); - await wallet.createTransaction( + const result = await wallet.createTransaction( chgAccount, createMockTxnIndent(recipient.address, 500), { @@ -230,12 +233,19 @@ describe('BtcWallet', () => { }, ); + const info: BtcTransactionInfo = + result.txnInfo as unknown as BtcTransactionInfo; + expect(psbtServiceSpy).toHaveBeenCalledWith([ { address: recipient.address, value: 500, }, ]); + + expect(info.txnFee.value).toStrictEqual( + 100 + DustLimit[chgAccount.scriptType] - 1, + ); }); it('throws `Transaction amount too small` error the transaction output is too small', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts index 7048bd59..148e634d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts @@ -1,17 +1,23 @@ -import type { Json } from '@metamask/snaps-sdk'; import type { BIP32Interface } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; import type { TransactionIntent } from '../../../chain'; import { bufferToString, compactError, hexToBuffer } from '../../../utils'; -import type { IAccountSigner, IWallet } from '../../../wallet'; +import type { + IAccountSigner, + ITransactionInfo, + IWallet, +} from '../../../wallet'; import { ScriptType } from '../constants'; import { isDust } from '../utils'; import { P2WPKHAccount, P2SHP2WPKHAccount } from './account'; +import { BtcAddress } from './address'; +import { BtcAmount } from './amount'; import { CoinSelectService } from './coin-select'; import { WalletError } from './exceptions'; import { PsbtService } from './psbt'; import { AccountSigner } from './signer'; +import { BtcTransactionInfo } from './transactionInfo'; import type { IStaticBtcAccount, IBtcAccountDeriver, @@ -73,7 +79,7 @@ export class BtcWallet implements IWallet { options: CreateTransactionOptions, ): Promise<{ txn: string; - txnJson: Record; + txnInfo: ITransactionInfo; }> { const scriptOutput = account.payment.output; const { scriptType } = account; @@ -100,30 +106,36 @@ export class BtcWallet implements IWallet { scriptOutput, ); - const changes: SpendTo[] = []; - const recipients: SpendTo[] = []; - const formattedOutputs: SpendTo[] = []; + const info = new BtcTransactionInfo(); + info.feeRate.value = feeRate; + info.txnFee.value = fee; + info.sender = new BtcAddress(account.address, this.network); + + const psbtOutputs: SpendTo[] = []; + for (const output of outputs) { if (output.address === undefined) { // discard change output if it is dust and add to fees if (isDust(output.value, scriptType)) { + info.txnFee.value += output.value; continue; } - changes.push({ - address: account.address, - value: output.value, - }); + info.changes.set( + new BtcAddress(account.address, this.network), + new BtcAmount(output.value), + ); } else { // dust outputs is forbidden if (isDust(output.value, scriptType)) { throw new WalletError('Transaction amount too small'); } - recipients.push({ - address: output.address, - value: output.value, - }); + info.recipients.set( + new BtcAddress(output.address, this.network), + new BtcAmount(output.value), + ); } - formattedOutputs.push({ + + psbtOutputs.push({ address: output.address ?? account.address, value: output.value, }); @@ -140,17 +152,11 @@ export class BtcWallet implements IWallet { options.replaceable, ); - psbtService.addOutputs(formattedOutputs); + psbtService.addOutputs(psbtOutputs); return { txn: psbtService.toBase64(), - txnJson: { - feeRate: options.fee, - estimatedFee: fee, - sender: account.address, - recipients, - changes, - }, + txnInfo: info, }; } 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 index be8a6594..e434ac07 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.test.ts @@ -1,4 +1,5 @@ import { expect } from '@jest/globals'; +import type { Component } from '@metamask/snaps-sdk'; import { heading, panel, text, divider, row } from '@metamask/snaps-sdk'; import { SnapHelper } from './helpers'; @@ -45,62 +46,22 @@ describe('SnapHelper', () => { describe('confirmDialog', () => { it('calls snap_dialog', async () => { const spy = jest.spyOn(SnapHelper.provider, 'request'); - const testcase = { - header: 'header', - subHeader: 'subHeader', - body: [ - { - label: 'Label1', - value: 'Value1', - }, - { - label: 'Label2', - value: [ - { - label: 'SubLabel1', - value: 'SubValue1', - }, - ], - }, - ], - }; + const components: Component[] = [ + heading('header'), + text('subHeader'), + divider(), + row('Label1', text('Value1')), + text('**Label2**:'), + row('SubLabel1', text('SubValue1')), + ]; - await SnapHelper.confirmDialog( - testcase.header, - testcase.subHeader, - testcase.body, - ); + await SnapHelper.confirmDialog(components); expect(spy).toHaveBeenCalledWith({ method: 'snap_dialog', params: { type: 'confirmation', - content: panel([ - heading(testcase.header), - text(testcase.subHeader), - divider(), - row( - testcase.body[0].label, - text(testcase.body[0].value as unknown as string), - ), - text(`**${testcase.body[1].label}**:`), - row( - ( - testcase.body[1].value[0] as unknown as { - label: string; - value: string; - } - ).label, - text( - ( - testcase.body[1].value[0] as unknown as { - label: string; - value: string; - } - ).value, - ), - ), - ]), + content: panel(components), }, }); }); 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 f7d288cb..67868771 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.ts @@ -4,14 +4,7 @@ import { type SLIP10NodeInterface, } from '@metamask/key-tree'; import type { Component, DialogResult, Json } from '@metamask/snaps-sdk'; -import { - heading, - panel, - text, - divider, - type SnapsProvider, - row, -} from '@metamask/snaps-sdk'; +import { panel, type SnapsProvider } from '@metamask/snaps-sdk'; declare const snap: SnapsProvider; @@ -44,36 +37,7 @@ export class SnapHelper { return node as SLIP10NodeInterface; } - static async confirmDialog( - header: string, - subHeader: string, - body: { - label: string; - value: - | string - | { - label: string; - value: string; - }[]; - }[], - ): Promise { - const components: Component[] = [ - heading(header), - text(subHeader), - divider(), - ]; - - for (const { label, value } of body) { - if (typeof value === 'string') { - components.push(row(label, text(value))); - } else { - components.push(text(`**${label}**:`)); - for (const { label: lb, value: val } of value) { - components.push(row(lb, text(val))); - } - } - } - + static async confirmDialog(components: Component[]): Promise { return SnapHelper.provider.request({ method: 'snap_dialog', params: { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.test.ts deleted file mode 100644 index 8d6e0a00..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { InvalidParamsError } from '@metamask/snaps-sdk'; - -import { generateBlockChairBroadcastTransactionResp } from '../../test/utils'; -import { Factory } from '../factory'; -import { Network } from '../modules/bitcoin/constants'; -import { BroadcastTransactionHandler } from './broadcast-transaction'; - -jest.mock('../modules/logger/logger', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - }, -})); - -describe('BroadcastTransactionHandler', () => { - describe('handleRequest', () => { - const createMockChainApiFactory = () => { - const broadcastTransactionSpy = jest.fn(); - - jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ - estimateFees: jest.fn(), - getBalances: jest.fn(), - broadcastTransaction: broadcastTransactionSpy, - listTransactions: jest.fn(), - getTransaction: jest.fn(), - getDataForTransaction: jest.fn(), - }); - return { - broadcastTransactionSpy, - }; - }; - - it('broadcast an transaction', async () => { - const { broadcastTransactionSpy } = createMockChainApiFactory(); - const resp = generateBlockChairBroadcastTransactionResp(); - broadcastTransactionSpy.mockResolvedValue({ - transactionId: resp.data.transaction_hash, - }); - - const result = await BroadcastTransactionHandler.getInstance().execute({ - scope: Network.Testnet, - signedTransaction: - '02000000000101ec81faa8b57add4c8fb3958dd8f04667f5cd829a7b94199f4400be9e52cda0760000000000ffffffff015802000000000000160014f80b562cbcbbfc97727043484c06cc5579963e8402473044022011ec3f7ea7a7cac7cb891a1ea498d94ca3cd082339b9b2620ba5421ca7cbdf3d022062f34411d6aa5335c2bd7ff4c940adb962e9509133b86a2d97996552fd811f2c012102ceea82614fdb14871ef881498c55c5dbdc24b4633d29b42040dd18b4285540f500000000', - }); - - expect(result).toStrictEqual({ - transactionId: resp.data.transaction_hash, - }); - }); - - it('throws `Request params is invalid` when request parameter is not correct', async () => { - createMockChainApiFactory(); - - await expect( - BroadcastTransactionHandler.getInstance().execute({ - scope: 'some invalid value', - }), - ).rejects.toThrow(InvalidParamsError); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.ts deleted file mode 100644 index 25ea9d0d..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/broadcast-transaction.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Infer } from 'superstruct'; -import { object, string, assign } from 'superstruct'; - -import { Factory } from '../factory'; -import { - SnapRpcHandlerRequestStruct, - BaseSnapRpcHandler, -} from '../modules/rpc'; -import type { - IStaticSnapRpcHandler, - SnapRpcHandlerResponse, -} from '../modules/rpc'; -import type { StaticImplements } from '../types/static'; - -export type BroadcastTransactionParams = Infer< - typeof BroadcastTransactionHandler.requestStruct ->; - -export type BroadcastTransactionResponse = SnapRpcHandlerResponse & - Infer; - -export class BroadcastTransactionHandler - extends BaseSnapRpcHandler - implements - StaticImplements -{ - static override get requestStruct() { - return assign( - object({ - signedTransaction: string(), - }), - SnapRpcHandlerRequestStruct, - ); - } - - static override get responseStruct() { - return object({ - transactionId: string(), - }); - } - - async handleRequest( - params: BroadcastTransactionParams, - ): Promise { - const { scope, signedTransaction } = params; - - const chainApi = Factory.createOnChainServiceProvider(scope); - - return await chainApi.broadcastTransaction(signedTransaction); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.test.ts deleted file mode 100644 index af3e0c80..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { InvalidParamsError } from '@metamask/snaps-sdk'; - -import { FeeRatio } from '../chain'; -import { Factory } from '../factory'; -import { Network } from '../modules/bitcoin/constants'; -import { satsToBtc } from '../modules/bitcoin/utils/unit'; -import { EstimateFeesHandler } from './estimate-fees'; - -jest.mock('../modules/logger/logger', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - }, -})); - -describe('EstimateFeesHandler', () => { - describe('handleRequest', () => { - const createMockChainApiFactory = () => { - const estimateFeesSpy = jest.fn(); - - jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ - estimateFees: estimateFeesSpy, - getBalances: jest.fn(), - broadcastTransaction: jest.fn(), - listTransactions: jest.fn(), - getTransaction: jest.fn(), - getDataForTransaction: jest.fn(), - }); - return { - estimateFeesSpy, - }; - }; - - it('returns correct result', async () => { - const { estimateFeesSpy } = createMockChainApiFactory(); - - estimateFeesSpy.mockResolvedValue({ - fees: [ - { - type: FeeRatio.Fast, - rate: 10, - }, - { - type: FeeRatio.Medium, - rate: 5, - }, - { - type: FeeRatio.Slow, - rate: 1, - }, - ], - }); - - const result = await EstimateFeesHandler.getInstance().execute({ - scope: Network.Testnet, - }); - - expect(result).toStrictEqual({ - fees: [ - { - type: FeeRatio.Fast, - rate: satsToBtc(10), - }, - { - type: FeeRatio.Medium, - rate: satsToBtc(5), - }, - { - type: FeeRatio.Slow, - rate: satsToBtc(1), - }, - ], - }); - }); - - it('throws `Request params is invalid` when request parameter is not correct', async () => { - createMockChainApiFactory(); - - await expect( - EstimateFeesHandler.getInstance().execute({ - scope: 'some invalid value', - }), - ).rejects.toThrow(InvalidParamsError); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.ts deleted file mode 100644 index a07a9106..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fees.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { Infer } from 'superstruct'; -import { object, array, enums } from 'superstruct'; - -import { FeeRatio } from '../chain'; -import { Factory } from '../factory'; -import { satsToBtc } from '../modules/bitcoin/utils/unit'; -import { - type IStaticSnapRpcHandler, - type SnapRpcHandlerResponse, - SnapRpcHandlerRequestStruct, - BaseSnapRpcHandler, -} from '../modules/rpc'; -import type { StaticImplements } from '../types/static'; -import { positiveStringStruct } from '../utils'; - -export type EstimateFeesParams = Infer< - typeof EstimateFeesHandler.requestStruct ->; - -export type EstimateFeesResponse = SnapRpcHandlerResponse & - Infer; - -export class EstimateFeesHandler - extends BaseSnapRpcHandler - implements - StaticImplements -{ - static override get requestStruct() { - return SnapRpcHandlerRequestStruct; - } - - static override get responseStruct() { - return object({ - fees: array( - object({ - type: enums(Object.values(FeeRatio)), - rate: positiveStringStruct, - }), - ), - }); - } - - async handleRequest( - params: EstimateFeesParams, - ): Promise { - const { scope } = params; - - const chainApi = Factory.createOnChainServiceProvider(scope); - - const fees = await chainApi.estimateFees(); - - const response = { - fees: fees.fees.map((fee) => ({ - type: fee.type, - rate: satsToBtc(fee.rate), - })), - }; - - return response; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts index 046f2311..fb5a5e4e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts @@ -3,6 +3,7 @@ import { InvalidParamsError } from '@metamask/snaps-sdk'; import { generateAccounts } from '../../test/utils'; import { Factory } from '../factory'; import { BtcAsset, Network } from '../modules/bitcoin/constants'; +import { BtcAmount } from '../modules/bitcoin/wallet/amount'; import { GetBalancesHandler } from './get-balances'; jest.mock('../modules/logger/logger', () => ({ @@ -18,7 +19,7 @@ describe('GetBalancesHandler', () => { const getBalancesSpy = jest.fn(); jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ - estimateFees: jest.fn(), + getFeeRates: jest.fn(), getBalances: getBalancesSpy, broadcastTransaction: jest.fn(), listTransactions: jest.fn(), @@ -39,7 +40,7 @@ describe('GetBalancesHandler', () => { balances: addresses.reduce((acc, address) => { acc[address] = { [BtcAsset.TBtc]: { - amount: 100, + amount: new BtcAmount(100), }, }; return acc; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts index 0261bed9..971676a2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts @@ -2,7 +2,6 @@ import type { Infer } from 'superstruct'; import { object, string, assign, array, record } from 'superstruct'; import { Factory } from '../factory'; -import { satsToBtc } from '../modules/bitcoin/utils/unit'; import { SnapRpcHandlerRequestStruct, BaseSnapRpcHandler, @@ -60,7 +59,7 @@ export class GetBalancesHandler balancesObj[address] = Object.entries(assetBalances).reduce( (assetBalanceObj, [asset, balance]) => { assetBalanceObj[asset] = { - amount: satsToBtc(balance.amount), + amount: balance.amount.toString(), }; return assetBalanceObj; }, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.test.ts deleted file mode 100644 index 32315b13..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { InvalidParamsError } from '@metamask/snaps-sdk'; - -import { - generateAccounts, - generateBlockChairGetUtxosResp, -} from '../../test/utils'; -import { Factory } from '../factory'; -import { Network } from '../modules/bitcoin/constants'; -import { GetTransactionDataHandler } from './get-transaction-data'; - -jest.mock('../modules/logger/logger', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - }, -})); - -describe('GetTransactionDataHandler', () => { - describe('handleRequest', () => { - const createMockChainApiFactory = () => { - const getDataForTransactionSpy = jest.fn(); - - jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ - estimateFees: jest.fn(), - getBalances: jest.fn(), - broadcastTransaction: jest.fn(), - listTransactions: jest.fn(), - getTransaction: jest.fn(), - getDataForTransaction: getDataForTransactionSpy, - }); - return { - getDataForTransactionSpy, - }; - }; - - it('returns correct result', async () => { - const { getDataForTransactionSpy } = createMockChainApiFactory(); - const accounts = generateAccounts(2); - const sender = accounts[0].address; - const mockResponse = generateBlockChairGetUtxosResp(sender, 10); - const utxos = mockResponse.data[sender].utxo.map((utxo) => ({ - block: utxo.block_id, - txnHash: utxo.transaction_hash, - index: utxo.index, - value: utxo.value, - })); - - getDataForTransactionSpy.mockResolvedValue({ - data: { - utxos, - }, - }); - - const result = await GetTransactionDataHandler.getInstance().execute({ - scope: Network.Testnet, - account: sender, - }); - - expect(result).toStrictEqual({ - data: { - utxos: utxos.map((utxo) => ({ - ...utxo, - value: utxo.value.toString(), - })), - }, - }); - }); - - it('throws `Request params is invalid` when request parameter is not correct', async () => { - createMockChainApiFactory(); - - await expect( - GetTransactionDataHandler.getInstance().execute({ - scope: Network.Testnet, - }), - ).rejects.toThrow(InvalidParamsError); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.ts deleted file mode 100644 index cfdf9cc9..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-data.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { Infer } from 'superstruct'; -import { array, assign, number, object, string } from 'superstruct'; - -import { Factory } from '../factory'; -import type { Utxo } from '../modules/bitcoin/wallet'; -import { - BaseSnapRpcHandler, - SnapRpcHandlerRequestStruct, -} from '../modules/rpc'; -import type { - IStaticSnapRpcHandler, - SnapRpcHandlerResponse, -} from '../modules/rpc'; -import type { StaticImplements } from '../types/static'; - -export type GetTransactionDataParams = Infer< - typeof GetTransactionDataHandler.requestStruct ->; - -export type GetTransactionDataResponse = SnapRpcHandlerResponse & - Infer; - -export class GetTransactionDataHandler - extends BaseSnapRpcHandler - implements - StaticImplements -{ - static override get requestStruct() { - return assign( - object({ - account: string(), - }), - SnapRpcHandlerRequestStruct, - ); - } - - static override get responseStruct() { - return object({ - data: object({ - utxos: array( - object({ - block: number(), - txnHash: string(), - index: number(), - value: string(), - }), - ), - }), - }); - } - - async handleRequest( - params: GetTransactionDataParams, - ): Promise { - const chainApi = Factory.createOnChainServiceProvider(params.scope); - - const result = await chainApi.getDataForTransaction(params.account); - - const utoxs = result.data.utxos as unknown as Utxo[]; - - return { - data: { - utxos: utoxs.map((utxo) => ({ - block: utxo.block, - txnHash: utxo.txnHash, - index: utxo.index, - value: utxo.value.toString(), - })), - }, - }; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts similarity index 61% rename from merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts rename to merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts index 9b653c5b..f98ee66b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/helper.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts @@ -1,8 +1,5 @@ -import { BroadcastTransactionHandler } from './broadcast-transaction'; import { CreateAccountHandler } from './create-account'; -import { EstimateFeesHandler } from './estimate-fees'; import { GetBalancesHandler } from './get-balances'; -import { GetTransactionDataHandler } from './get-transaction-data'; import { RpcHelper } from './helpers'; import { SendManyHandler } from './sendmany'; @@ -14,12 +11,12 @@ describe('RpcHelper', () => { chain_createAccount: CreateAccountHandler, // eslint-disable-next-line @typescript-eslint/naming-convention chain_getBalances: GetBalancesHandler, - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_broadcastTransaction: BroadcastTransactionHandler, - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_getDataForTransaction: GetTransactionDataHandler, - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_estimateFees: EstimateFeesHandler, + // // eslint-disable-next-line @typescript-eslint/naming-convention + // chain_broadcastTransaction: BroadcastTransactionHandler, + // // eslint-disable-next-line @typescript-eslint/naming-convention + // chain_getDataForTransaction: GetTransactionDataHandler, + // // eslint-disable-next-line @typescript-eslint/naming-convention + // chain_estimateFees: EstimateFeesHandler, }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts index 23630524..13f75632 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts @@ -1,8 +1,5 @@ import { CreateAccountHandler, GetBalancesHandler } from '.'; import type { IStaticSnapRpcHandler } from '../modules/rpc'; -import { BroadcastTransactionHandler } from './broadcast-transaction'; -import { EstimateFeesHandler } from './estimate-fees'; -import { GetTransactionDataHandler } from './get-transaction-data'; import { SendManyHandler } from './sendmany'; export class RpcHelper { @@ -12,12 +9,12 @@ export class RpcHelper { chain_createAccount: CreateAccountHandler, // eslint-disable-next-line @typescript-eslint/naming-convention chain_getBalances: GetBalancesHandler, - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_broadcastTransaction: BroadcastTransactionHandler, - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_getDataForTransaction: GetTransactionDataHandler, - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_estimateFees: EstimateFeesHandler, + // // eslint-disable-next-line @typescript-eslint/naming-convention + // chain_broadcastTransaction: BroadcastTransactionHandler, + // // eslint-disable-next-line @typescript-eslint/naming-convention + // chain_getDataForTransaction: GetTransactionDataHandler, + // // eslint-disable-next-line @typescript-eslint/naming-convention + // chain_estimateFees: EstimateFeesHandler, }; } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 658299bd..1b023a8a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -19,6 +19,9 @@ import { DustLimit, Network, ScriptType } from '../modules/bitcoin/constants'; import { satsToBtc } from '../modules/bitcoin/utils/unit'; import type { IBtcAccount } from '../modules/bitcoin/wallet'; import { BtcAccountBip32Deriver, BtcWallet } from '../modules/bitcoin/wallet'; +import { BtcAddress } from '../modules/bitcoin/wallet/address'; +import { BtcAmount } from '../modules/bitcoin/wallet/amount'; +import { BtcTransactionInfo } from '../modules/bitcoin/wallet/transactionInfo'; import { SnapHelper } from '../modules/snap'; import type { IAccount } from '../wallet'; import { SendManyHandler } from './sendmany'; @@ -35,11 +38,11 @@ describe('SendManyHandler', () => { describe('handleRequest', () => { const createMockChainApiFactory = () => { const getDataForTransactionSpy = jest.fn(); - const estimatedFeeSpy = jest.fn(); + const getFeeRatesSpy = jest.fn(); const broadcastTransactionSpy = jest.fn(); jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ - estimateFees: estimatedFeeSpy, + getFeeRates: getFeeRatesSpy, getBalances: jest.fn(), broadcastTransaction: broadcastTransactionSpy, listTransactions: jest.fn(), @@ -48,7 +51,7 @@ describe('SendManyHandler', () => { }); return { getDataForTransactionSpy, - estimatedFeeSpy, + getFeeRatesSpy, broadcastTransactionSpy, }; }; @@ -88,10 +91,10 @@ describe('SendManyHandler', () => { }; }; - const createSenderNReceipents = async ( + const createSenderNRecipients = async ( network, caip2Network: string, - receipentCnt: number, + recipientCnt: number, ) => { const { instance } = createMockBip32Instance(network); const wallet = new BtcWallet(instance, network); @@ -106,31 +109,32 @@ describe('SendManyHandler', () => { }, methods: ['btc_sendmany'], }; - const receipents: IAccount[] = []; - for (let i = 1; i < receipentCnt + 1; i++) { - receipents.push(await wallet.unlock(i, ScriptType.P2wpkh)); + const recipients: IAccount[] = []; + for (let i = 1; i < recipientCnt + 1; i++) { + recipients.push(await wallet.unlock(i, ScriptType.P2wpkh)); } return { sender, keyringAccount, - receipents, + recipients, }; }; const createSendManyParams = ( - receipents: IAccount[], + recipients: IAccount[], caip2Network: string, dryrun: boolean, + comment = '', ): SendManyParams => { return { - amounts: receipents.reduce((acc, receipent: IBtcAccount) => { - acc[receipent.address] = satsToBtc( - DustLimit[receipent.scriptType] + 1, + amounts: recipients.reduce((acc, recipient: IBtcAccount) => { + acc[recipient.address] = satsToBtc( + DustLimit[recipient.scriptType] + 1, ); return acc; }, {}), - comment: '', + comment, subtractFeeFrom: [], replaceable: false, dryrun, @@ -142,7 +146,12 @@ describe('SendManyHandler', () => { address: string, counter: number, ) => { - const mockResponse = generateBlockChairGetUtxosResp(address, counter); + const mockResponse = generateBlockChairGetUtxosResp( + address, + counter, + 100000, + 100000, + ); return mockResponse.data[address].utxo.map((utxo) => ({ block: utxo.block_id, txnHash: utxo.transaction_hash, @@ -158,12 +167,13 @@ describe('SendManyHandler', () => { const prepareSendMany = async (network, caip2Network) => { const { getDataForTransactionSpy, - estimatedFeeSpy, + getFeeRatesSpy, broadcastTransactionSpy, } = createMockChainApiFactory(); const snapHelperSpy = jest.spyOn(SnapHelper, 'confirmDialog'); - const { sender, keyringAccount, receipents } = - await createSenderNReceipents(network, caip2Network, 2); + + const { sender, keyringAccount, recipients } = + await createSenderNRecipients(network, caip2Network, 2); const broadcastResp = createMockBroadcastTransactionResp(); @@ -172,11 +182,11 @@ describe('SendManyHandler', () => { utxos: createMockGetDataForTransactionResp(sender.address, 10), }, }); - estimatedFeeSpy.mockResolvedValue({ + getFeeRatesSpy.mockResolvedValue({ fees: [ { type: FeeRatio.Fast, - rate: 1, + rate: new BtcAmount(1), }, ], }); @@ -188,10 +198,10 @@ describe('SendManyHandler', () => { return { sender, keyringAccount, - receipents, + recipients, broadcastResp, getDataForTransactionSpy, - estimatedFeeSpy, + getFeeRatesSpy, broadcastTransactionSpy, snapHelperSpy, }; @@ -202,10 +212,10 @@ describe('SendManyHandler', () => { const caip2Network = Network.Testnet; const { keyringAccount, - receipents, + recipients, broadcastResp, getDataForTransactionSpy, - estimatedFeeSpy, + getFeeRatesSpy, broadcastTransactionSpy, } = await prepareSendMany(network, caip2Network); @@ -213,10 +223,10 @@ describe('SendManyHandler', () => { scope: caip2Network, index: 0, account: keyringAccount, - }).execute(createSendManyParams(receipents, caip2Network, false)); + }).execute(createSendManyParams(recipients, caip2Network, false)); expect(result).toStrictEqual({ txId: broadcastResp }); - expect(estimatedFeeSpy).toHaveBeenCalledTimes(1); + expect(getFeeRatesSpy).toHaveBeenCalledTimes(1); expect(getDataForTransactionSpy).toHaveBeenCalledTimes(1); expect(broadcastTransactionSpy).toHaveBeenCalledTimes(1); }); @@ -224,18 +234,79 @@ describe('SendManyHandler', () => { it('does not broadcast transaction if in dryrun mode', async () => { const network = networks.testnet; const caip2Network = Network.Testnet; - const { keyringAccount, receipents, broadcastTransactionSpy } = + const { keyringAccount, recipients, broadcastTransactionSpy } = await prepareSendMany(network, caip2Network); await SendManyHandler.getInstance({ scope: caip2Network, index: 0, account: keyringAccount, - }).execute(createSendManyParams(receipents, caip2Network, true)); + }).execute(createSendManyParams(recipients, caip2Network, true)); expect(broadcastTransactionSpy).toHaveBeenCalledTimes(0); }); + it('does create comment component in dialog if consumer has provide the comment', async () => { + const network = networks.testnet; + const caip2Network = Network.Testnet; + const { keyringAccount, recipients, snapHelperSpy } = + await prepareSendMany(network, caip2Network); + + await SendManyHandler.getInstance({ + scope: caip2Network, + index: 0, + account: keyringAccount, + }).execute( + createSendManyParams(recipients, caip2Network, true, 'test comment'), + ); + + const calls = snapHelperSpy.mock.calls[0][0]; + + expect(calls[calls.length - 4]).toStrictEqual({ + type: 'row', + label: 'Comment', + value: { type: 'text', value: 'test comment' }, + }); + }); + + it('display `Recipient` as label in dialog if there is only 1 receipient', async () => { + const network = networks.testnet; + const caip2Network = Network.Testnet; + const { keyringAccount, recipients, snapHelperSpy } = + await prepareSendMany(network, caip2Network); + const walletCreateTxnSpy = jest.spyOn( + BtcWallet.prototype, + 'createTransaction', + ); + const walletSignTxnSpy = jest.spyOn( + BtcWallet.prototype, + 'signTransaction', + ); + + const info = new BtcTransactionInfo(); + info.recipients.set( + new BtcAddress(recipients[0].address, networks.testnet), + new BtcAmount(100), + ); + + walletCreateTxnSpy.mockResolvedValue({ + txn: 'txn', + txnInfo: info, + }); + + walletSignTxnSpy.mockResolvedValue('txId'); + + await SendManyHandler.getInstance({ + scope: caip2Network, + index: 0, + account: keyringAccount, + }).execute(createSendManyParams([recipients[0]], caip2Network, true)); + + const calls = snapHelperSpy.mock.calls[0][0]; + + expect(calls[3]).toHaveProperty('label', 'Recipient'); + }); + it('throws `Request params is invalid` error when request parameter is not correct', async () => { createMockChainApiFactory(); @@ -250,7 +321,7 @@ describe('SendManyHandler', () => { createMockChainApiFactory(); const network = networks.testnet; const caip2Network = Network.Testnet; - const { keyringAccount, receipents } = await createSenderNReceipents( + const { keyringAccount, recipients } = await createSenderNRecipients( network, caip2Network, 2, @@ -260,7 +331,7 @@ describe('SendManyHandler', () => { scope: caip2Network, index: 20, account: keyringAccount, - }).execute(createSendManyParams(receipents, caip2Network, false)), + }).execute(createSendManyParams(recipients, caip2Network, false)), ).rejects.toThrow('Account not found'); }); @@ -268,7 +339,7 @@ describe('SendManyHandler', () => { createMockChainApiFactory(); const network = networks.testnet; const caip2Network = Network.Testnet; - const { keyringAccount, receipents } = await createSenderNReceipents( + const { keyringAccount, recipients } = await createSenderNRecipients( network, caip2Network, 0, @@ -278,15 +349,37 @@ describe('SendManyHandler', () => { scope: caip2Network, index: 0, account: keyringAccount, - }).execute(createSendManyParams(receipents, caip2Network, false)), + }).execute(createSendManyParams(recipients, caip2Network, false)), ).rejects.toThrow('Transaction must have at least one recipient'); }); + it('throws `No fee rates available` error if no fee rate returns from chain service', async () => { + const { getFeeRatesSpy } = createMockChainApiFactory(); + const network = networks.testnet; + const caip2Network = Network.Testnet; + const { keyringAccount, recipients } = await createSenderNRecipients( + network, + caip2Network, + 10, + ); + getFeeRatesSpy.mockResolvedValue({ + fees: [], + }); + + await expect( + SendManyHandler.getInstance({ + scope: caip2Network, + index: 0, + account: keyringAccount, + }).execute(createSendManyParams(recipients, caip2Network, false)), + ).rejects.toThrow('No fee rates available'); + }); + it('throws `Invalid amount for send` error if sending amount is <= 0', async () => { const network = networks.testnet; const caip2Network = Network.Testnet; createMockChainApiFactory(); - const { keyringAccount, receipents } = await createSenderNReceipents( + const { keyringAccount, recipients } = await createSenderNRecipients( network, caip2Network, 2, @@ -298,10 +391,10 @@ describe('SendManyHandler', () => { index: 0, account: keyringAccount, }).execute({ - ...createSendManyParams(receipents, caip2Network, false), + ...createSendManyParams(recipients, caip2Network, false), amounts: { - [receipents[0].address]: satsToBtc(500), - [receipents[1].address]: satsToBtc(0), + [recipients[0].address]: satsToBtc(500), + [recipients[1].address]: satsToBtc(0), }, }), ).rejects.toThrow('Invalid amount for send'); @@ -310,7 +403,7 @@ describe('SendManyHandler', () => { it('throws `Invalid response` error if the response is unexpected', async () => { const network = networks.testnet; const caip2Network = Network.Testnet; - const { keyringAccount, receipents, broadcastTransactionSpy } = + const { keyringAccount, recipients, broadcastTransactionSpy } = await prepareSendMany(network, caip2Network); broadcastTransactionSpy.mockResolvedValue({ @@ -323,14 +416,14 @@ describe('SendManyHandler', () => { scope: caip2Network, index: 0, account: keyringAccount, - }).execute(createSendManyParams(receipents, caip2Network, false)), + }).execute(createSendManyParams(recipients, caip2Network, false)), ).rejects.toThrow('Invalid Response'); }); it('throws UserRejectedRequestError error if user denied the transaction', async () => { const network = networks.testnet; const caip2Network = Network.Testnet; - const { snapHelperSpy, keyringAccount, receipents } = + const { snapHelperSpy, keyringAccount, recipients } = await prepareSendMany(network, caip2Network); snapHelperSpy.mockResolvedValue(false); @@ -339,14 +432,14 @@ describe('SendManyHandler', () => { scope: caip2Network, index: 0, account: keyringAccount, - }).execute(createSendManyParams(receipents, caip2Network, false)), + }).execute(createSendManyParams(recipients, caip2Network, false)), ).rejects.toThrow(UserRejectedRequestError); }); it('throws `Failed to commit transaction on chain` error if the transaction is fail to commit', async () => { const network = networks.testnet; const caip2Network = Network.Testnet; - const { broadcastTransactionSpy, keyringAccount, receipents } = + const { broadcastTransactionSpy, keyringAccount, recipients } = await prepareSendMany(network, caip2Network); broadcastTransactionSpy.mockRejectedValue(new Error('error')); @@ -355,7 +448,7 @@ describe('SendManyHandler', () => { scope: caip2Network, index: 0, account: keyringAccount, - }).execute(createSendManyParams(receipents, caip2Network, false)), + }).execute(createSendManyParams(recipients, caip2Network, false)), ).rejects.toThrow('Failed to commit transaction on chain'); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 1ff87f9d..83a95fbf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -1,4 +1,11 @@ -import { UserRejectedRequestError } from '@metamask/snaps-sdk'; +import type { Component } from '@metamask/snaps-sdk'; +import { + UserRejectedRequestError, + divider, + text, + heading, + row, +} from '@metamask/snaps-sdk'; import { object, string, @@ -12,13 +19,12 @@ import { import { TransactionIntentStruct, - type Fees, type IOnChainService, type TransactionIntent, } from '../chain'; import { Factory } from '../factory'; import { type Wallet as WalletData } from '../keyring'; -import { btcToSats, satsToBtc } from '../modules/bitcoin/utils'; +import { btcToSats } from '../modules/bitcoin/utils'; import { logger } from '../modules/logger/logger'; import { SnapRpcHandlerRequestStruct, @@ -28,23 +34,25 @@ import type { IStaticSnapRpcHandler } from '../modules/rpc'; import { SnapHelper } from '../modules/snap'; import type { StaticImplements } from '../types/static'; import { positiveStringStruct } from '../utils'; -import type { IAccount, IWallet } from '../wallet'; +import type { IAccount, ITransactionInfo, IWallet } from '../wallet'; export type SendManyParams = Infer; export type SendManyResponse = Infer; export type TxnJson = { - feeRate: number; - estimatedFee: number; + feeRate: string; + txnFee: string; + total: string; sender: string; recipients: { address: string; - value: number; + explorerUrl: string; + value: string; }[]; changes: { address: string; - value: number; + value: string; }[]; }; @@ -113,16 +121,20 @@ export class SendManyHandler const { dryrun } = params; const chainApi = Factory.createOnChainServiceProvider(scope); - const feesResp = await chainApi.estimateFees(); + const feesResp = await chainApi.getFeeRates(); + + if (feesResp.fees.length === 0) { + throw new Error('No fee rates available'); + } - const fee = await this.getFeeConsensus(feesResp); + const fee = Math.max(feesResp.fees[feesResp.fees.length - 1].rate.value, 1); const metadata = await chainApi.getDataForTransaction( this.walletAccount.address, this.transactionIntent, ); - const { txn, txnJson } = await this.wallet.createTransaction( + const { txn, txnInfo } = await this.wallet.createTransaction( this.walletAccount, this.transactionIntent, { @@ -133,7 +145,7 @@ export class SendManyHandler }, ); - if (!(await this.getTxnConsensus(txnJson as unknown as TxnJson))) { + if (!(await this.getTxnConsensus(txnInfo, params.comment))) { throw new UserRejectedRequestError() as unknown as Error; } @@ -165,44 +177,59 @@ export class SendManyHandler }; } - protected async getFeeConsensus(fees: Fees): Promise { - // TODO: Ask user to confirm fee - return fees.fees[fees.fees.length - 1].rate; - } + protected async getTxnConsensus( + txnInfo: ITransactionInfo, + comment: string, + ): Promise { + const header = `Send Request`; + const intro = `Review the request by [portfolio.metamask.io](https://portfolio.metamask.io/) before proceeding. Once the transaction is made, it's irreversible.`; + const recipientsLabel = `Recipient`; + const amountLabel = `Amount`; + const commentLabel = `Comment`; + const networkFeeRateLabel = `Network fee rate`; + const networkFeeLabel = `Network fee`; + const totalLabel = `Total`; + + const components: Component[] = [heading(header), text(intro), divider()]; + const info = txnInfo.toJson(); + + const isMoreThanOneRecipient = + info.recipients.length + info.changes.length > 1; + + let i = 0; + + const addReceiptentsToComponents = (data: { + address: string; + explorerUrl: string; + value: string; + }) => { + components.push( + row( + isMoreThanOneRecipient + ? `${recipientsLabel} ${i + 1}` + : recipientsLabel, + text(`[${data.address}](${data.explorerUrl})`), + ), + ); + components.push(row(amountLabel, text(data.value, false))); + components.push(divider()); + i += 1; + }; + + info.recipients.forEach(addReceiptentsToComponents); + info.changes.forEach(addReceiptentsToComponents); + + if (comment.trim().length > 0) { + components.push(row(commentLabel, text(comment.trim()))); + } + + components.push(row(networkFeeLabel, text(`${info.txnFee}`, false))); + + components.push(row(networkFeeRateLabel, text(`${info.feeRate}`, false))); + + components.push(row(totalLabel, text(`${info.total}`, false))); - protected async getTxnConsensus(txnJson: TxnJson): Promise { - return (await SnapHelper.confirmDialog( - 'Do you want to send this transaction?', - 'Transaction details', - [ - { - label: 'Fee Rate', - value: satsToBtc(txnJson.feeRate), - }, - { - label: 'Estimated Fee', - value: satsToBtc(txnJson.estimatedFee), - }, - { - label: 'Sender', - value: txnJson.sender, - }, - { - label: 'Recipients', - value: txnJson.recipients.map(({ address, value }) => ({ - label: address, - value: satsToBtc(value), - })), - }, - { - label: 'Changes', - value: txnJson.changes.map(({ address, value }) => ({ - label: address, - value: satsToBtc(value), - })), - }, - ], - )) as boolean; + return (await SnapHelper.confirmDialog(components)) as boolean; } protected async broadcastTransaction( diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts index de1de195..14e08e62 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts @@ -25,7 +25,7 @@ export function hexToBuffer(hexStr: string, trimPrefix = true) { } /** - * Method to convert a buffer to a string safely. + * Method to convert a buffer to a string. * * @param buffer - Hex string. * @param encoding - Buffer encoding. @@ -38,3 +38,25 @@ export function bufferToString(buffer: Buffer, encoding: BufferEncoding) { throw new Error('Unable to convert buffer to string'); } } + +/** + * Method to format a string by replacing the middle characters. + * + * @param str - String to replace. + * @param headLength - Length of head. + * @param tailLength - Length of tail. + * @param replaceStr - String to replace with. Default is '...'. + * @returns Formatted string. + */ +export function replaceMiddleChar( + str: string, + headLength: number, + tailLength: number, + replaceStr = '...', +) { + if (!str) { + return str; + } + + return `${str.substr(0, headLength)}${replaceStr}${str.substr(-tailLength)}`; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/wallet.ts index ea2ba37c..3ca273f1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/wallet.ts @@ -3,6 +3,23 @@ import type { Buffer } from 'buffer'; import type { TransactionIntent } from './chain'; +export type ITransactionInfo = { + /** + * The transaction json. + */ + toJson>(): TxnInfoJson; +}; + +export type IAddress = { + toString(isShortern?: boolean): string; + explorerUrl: string; +}; + +export type IAmount = { + value: number; + toString(withUnit?: boolean): string; +}; + export type IAccount = { /** * Master fingerpring of the devied node. @@ -67,7 +84,7 @@ export type IWallet = { options: Record, ): Promise<{ txn: string; - txnJson: Record; + txnInfo: ITransactionInfo; }>; }; From 9f0391b69fc7f439edbfb97d9f030eee1cf3da36 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 31 May 2024 09:59:36 +0800 Subject: [PATCH 048/362] chore: update get balances api (#84) * chore: update get balance resp * chore: apply keyring api 6.3.1 * chore: implement get balance in keyring * chore: update permission * fix: index test error --- .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/config/permissions.ts | 2 +- .../bitcoin-wallet-snap/src/index.test.ts | 24 ++- .../src/keyring/keyring.test.ts | 49 +++++- .../src/keyring/keyring.ts | 37 ++++- .../src/modules/bitcoin/wallet/amount.test.ts | 2 + .../src/modules/bitcoin/wallet/amount.ts | 17 ++- .../src/rpcs/get-balances.test.ts | 139 ++++++++++++++++-- .../src/rpcs/get-balances.ts | 95 +++++++----- .../src/rpcs/keyring-rpc.ts | 30 ++++ .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 19 +-- .../src/utils/string.test.ts | 18 ++- .../bitcoin-wallet-snap/src/wallet.ts | 1 + 14 files changed, 341 insertions(+), 96 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 6865e6d2..48d4fdfd 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -31,7 +31,7 @@ "dependencies": { "@bitcoinerlab/secp256k1": "^1.1.1", "@metamask/key-tree": "^9.0.0", - "@metamask/keyring-api": "^6.0.0", + "@metamask/keyring-api": "^6.3.1", "@metamask/snaps-sdk": "^4.0.0", "async-mutex": "^0.3.2", "bip32": "^4.0.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 6d867065..8af77733 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "aLYl2Hvq1WIZqAOq8d7mt7N41bUZrd5Xua0ycUjGDtE=", + "shasum": "UZtYMq5DqEBaz2MHixBocuCi3yCI7LcX7Nw+YGbulYc=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts index 7621caeb..b0597beb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts @@ -13,8 +13,8 @@ const allowSet = new Set([ KeyringRpcMethod.ApproveRequest, KeyringRpcMethod.RejectRequest, KeyringRpcMethod.SubmitRequest, + KeyringRpcMethod.GetAccountBalances, // Chain API methods - 'chain_getBalances', ]); const allowedOrigins = [ diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts index 0b4cb7da..039e8603 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.ts @@ -35,15 +35,15 @@ describe('validateOrigin', () => { }); it('throws `Origin not found` error if not origin is provided', () => { - expect(() => validateOrigin('', 'chain_getBalances')).toThrow( - 'Origin not found', - ); + expect(() => + validateOrigin('', keyringApi.KeyringRpcMethod.GetAccountBalances), + ).toThrow('Origin not found'); }); it('throws `Permission denied` error if origin not match to the allowed list', () => { - expect(() => validateOrigin('xyz', 'chain_getBalances')).toThrow( - 'Permission denied', - ); + expect(() => + validateOrigin('xyz', keyringApi.KeyringRpcMethod.GetAccountBalances), + ).toThrow('Permission denied'); }); it('throws `Permission denied` error if the method is not match to the allowed list', () => { @@ -71,7 +71,7 @@ describe('onRpcRequest', () => { return onRpcRequest({ origin: 'http://localhost:8000', request: { - method: 'chain_getBalances', + method: 'chain_createAccount', params: { scope: Config.avaliableNetworks[Config.chain][0], }, @@ -83,7 +83,7 @@ describe('onRpcRequest', () => { const { handler, handleRequestSpy } = createMockChainApiHandler(); jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({ // eslint-disable-next-line @typescript-eslint/naming-convention - chain_getBalances: handler, + chain_createAccount: handler, }); handleRequestSpy.mockResolvedValueOnce({ data: 1, @@ -95,11 +95,7 @@ describe('onRpcRequest', () => { }); it('throws SnapError if an error catched', async () => { - const { handler } = createMockChainApiHandler(); - jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({ - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_createAccount: handler, - }); + jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({}); await expect(executeRequest()).rejects.toThrow(MethodNotFoundError); }); @@ -108,7 +104,7 @@ describe('onRpcRequest', () => { const { handler, handleRequestSpy } = createMockChainApiHandler(); jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({ // eslint-disable-next-line @typescript-eslint/naming-convention - chain_getBalances: handler, + chain_createAccount: handler, }); handleRequestSpy.mockRejectedValue(new SnapError('error')); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts index c8e064cd..cb487dfd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts @@ -4,8 +4,9 @@ import { unknown } from 'superstruct'; import { generateAccounts } from '../../test/utils'; import { Chain, Config } from '../config'; import { Factory } from '../factory'; -import { Network } from '../modules/bitcoin/constants'; +import { BtcAsset, Network } from '../modules/bitcoin/constants'; import { type IStaticSnapRpcHandler, BaseSnapRpcHandler } from '../modules/rpc'; +import { GetBalancesHandler } from '../rpcs'; import { RpcHelper } from '../rpcs/helpers'; import type { StaticImplements } from '../types/static'; import type { IWallet } from '../wallet'; @@ -396,4 +397,50 @@ describe('BtcKeyring', () => { ); }); }); + + describe('getAccountBalances', () => { + it('executes `GetBalancesHandler` with correct parameter', async () => { + const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + const account = generateAccounts(1)[0]; + const assets = [BtcAsset.TBtc]; + const getBalancesHandlerSpy = jest.spyOn( + GetBalancesHandler.prototype, + 'execute', + ); + getBalancesHandlerSpy.mockResolvedValue( + assets.reduce((acc, asset) => { + acc[asset] = { + amount: '1', + unit: Config.unit[Chain.Bitcoin], + }; + return acc; + }), + ); + getWalletSpy.mockResolvedValue({ + account, + index: account.options.index, + scope: account.options.scope, + hdPath: getHdPath(account.options.index), + }); + + await keyring.getAccountBalances(account.id, [BtcAsset.TBtc]); + + expect(getBalancesHandlerSpy).toHaveBeenCalledWith({ + scope: account.options.scope, + assets, + }); + }); + + it('throws BtcKeyringError if an error catched', async () => { + const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + getWalletSpy.mockRejectedValue(new Error('error')); + const account = generateAccounts(1)[0]; + + await expect( + keyring.getAccountBalances(account.id, [BtcAsset.TBtc]), + ).rejects.toThrow(BtcKeyringError); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts index 44c13fce..1448e03a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts @@ -5,6 +5,8 @@ import { type KeyringAccount, type KeyringRequest, type KeyringResponse, + type Balance, + type CaipAssetType, } from '@metamask/keyring-api'; import { MethodNotFoundError, type Json } from '@metamask/snaps-sdk'; import { assert, StructError } from 'superstruct'; @@ -15,6 +17,7 @@ import { Factory } from '../factory'; import { logger } from '../modules/logger/logger'; import type { SnapRpcHandlerRequest } from '../modules/rpc'; import { SnapHelper } from '../modules/snap'; +import { GetBalancesHandler } from '../rpcs'; import { RpcHelper } from '../rpcs/helpers'; import type { IAccount, IWallet } from '../wallet'; import { BtcKeyringError } from './exceptions'; @@ -163,11 +166,7 @@ export class BtcKeyring implements Keyring { throw new MethodNotFoundError() as unknown as Error; } - const walletData = await this.stateMgr.getWallet(account); - - if (!walletData) { - throw new Error('Account not found'); - } + const walletData = await this.getAndVerifyWallet(account); if (walletData.scope !== scope) { throw new Error( @@ -205,4 +204,32 @@ export class BtcKeyring implements Keyring { methods: this.keyringMethods, } as unknown as KeyringAccount; } + + async getAccountBalances( + id: string, + assets: CaipAssetType[], + ): Promise> { + try { + const walletData = await this.getAndVerifyWallet(id); + + return (await GetBalancesHandler.getInstance(walletData).execute({ + scope: walletData.scope, + assets, + })) as unknown as Record; + } catch (error) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.info(`[BtcKeyring.getAccountBalances] Error: ${error.message}`); + throw new BtcKeyringError(error); + } + } + + protected async getAndVerifyWallet(id: string) { + const walletData = await this.stateMgr.getWallet(id); + + if (!walletData) { + throw new Error('Account not found'); + } + + return walletData; + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.test.ts index 1e2269d5..6f7cd7c0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.test.ts @@ -1,3 +1,4 @@ +import { Chain, Config } from '../../../config'; import { satsToBtc } from '../utils'; import { BtcAmount } from './amount'; @@ -19,6 +20,7 @@ describe('BtcAmount', () => { const val = new BtcAmount(1); expect(val.toJson()).toStrictEqual({ value: val.toString(true), + unit: Config.unit[Chain.Bitcoin], rawValue: val.value, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.ts index 0067bf42..c4fa689f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.ts @@ -11,28 +11,33 @@ export class BtcAmount implements IAmount { this.#_value = value; } - public get value(): number { + get value(): number { return this.#_value; } - public set value(newValue: number) { + set value(newValue: number) { this.#_value = newValue; } - public valueOf(): number { + get unit(): string { + return Config.unit[Chain.Bitcoin]; + } + + valueOf(): number { return this.value; } - public toString(withUnit = false): string { + toString(withUnit = false): string { if (!withUnit) { return `${satsToBtc(this.value)}`; } - return `${satsToBtc(this.value)} ${Config.unit[Chain.Bitcoin]}`; + return `${satsToBtc(this.value)} ${this.unit}`; } - public toJson(): Record { + toJson(): Record { return { value: this.toString(true), + unit: this.unit, rawValue: this.value, }; } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts index fb5a5e4e..d933458b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts @@ -1,9 +1,19 @@ +import ecc from '@bitcoinerlab/secp256k1'; +import type { SLIP10NodeInterface } from '@metamask/key-tree'; +import type { KeyringAccount } from '@metamask/keyring-api'; import { InvalidParamsError } from '@metamask/snaps-sdk'; +import { BIP32Factory } from 'bip32'; +import { networks } from 'bitcoinjs-lib'; +import ECPairFactory from 'ecpair'; +import { v4 as uuidv4 } from 'uuid'; import { generateAccounts } from '../../test/utils'; +import { Config } from '../config'; import { Factory } from '../factory'; -import { BtcAsset, Network } from '../modules/bitcoin/constants'; +import { BtcAsset, Network, ScriptType } from '../modules/bitcoin/constants'; +import { BtcAccountBip32Deriver, BtcWallet } from '../modules/bitcoin/wallet'; import { BtcAmount } from '../modules/bitcoin/wallet/amount'; +import { SnapHelper } from '../modules/snap'; import { GetBalancesHandler } from './get-balances'; jest.mock('../modules/logger/logger', () => ({ @@ -31,11 +41,78 @@ describe('GetBalancesHandler', () => { }; }; + const createMockBip32Instance = (network) => { + const ECPair = ECPairFactory(ecc); + const bip32 = BIP32Factory(ecc); + + const keyPair = ECPair.makeRandom(); + const deriver = bip32.fromSeed(keyPair.publicKey, network); + + const jsonData = { + privateKey: deriver.privateKey?.toString('hex'), + publicKey: deriver.publicKey.toString('hex'), + chainCode: deriver.chainCode.toString('hex'), + depth: deriver.depth, + index: deriver.index, + curve: 'secp256k1', + masterFingerprint: undefined, + parentFingerprint: 0, + }; + jest.spyOn(SnapHelper, 'getBip32Deriver').mockResolvedValue({ + ...jsonData, + chainCodeBytes: deriver.chainCode, + privateKeyBytes: deriver.privateKey, + publicKeyBytes: deriver.publicKey, + toJSON: jest.fn().mockReturnValue(jsonData), + } as unknown as SLIP10NodeInterface); + + const rootSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getRoot'); + const childSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getChild'); + + return { + instance: new BtcAccountBip32Deriver(network), + rootSpy, + childSpy, + }; + }; + + const createMockAccount = async (network, caip2Network) => { + const { instance } = createMockBip32Instance(network); + const wallet = new BtcWallet(instance, network); + const sender = await wallet.unlock(0, ScriptType.P2wpkh); + const keyringAccount = { + type: sender.type, + id: uuidv4(), + address: sender.address, + options: { + scope: caip2Network, + index: sender.index, + }, + methods: ['btc_sendmany'], + }; + + const walletData = { + account: keyringAccount as unknown as KeyringAccount, + hdPath: sender.hdPath, + index: sender.index, + scope: caip2Network, + }; + + return { + keyringAccount, + walletData, + sender, + }; + }; + it('gets balances', async () => { + const network = networks.testnet; + const caip2Network = Network.Testnet; const { getBalancesSpy } = createMockChainApiFactory(); - const accounts = generateAccounts(2); - const addresses = accounts.map((account) => account.address); + const { walletData } = await createMockAccount(network, caip2Network); + + const addresses = [walletData.account.address]; const mockResp = { balances: addresses.reduce((acc, address) => { acc[address] = { @@ -46,22 +123,61 @@ describe('GetBalancesHandler', () => { return acc; }, {}), }; + const expected = { - balances: addresses.reduce((acc, address) => { + [BtcAsset.TBtc]: { + amount: '0.00000100', + unit: Config.unit[Config.chain], + }, + }; + + getBalancesSpy.mockResolvedValue(mockResp); + + const result = await GetBalancesHandler.getInstance(walletData).execute({ + scope: walletData.scope, + assets: [BtcAsset.TBtc], + }); + + expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [BtcAsset.TBtc]); + expect(result).toStrictEqual(expected); + }); + + it('gets balances of the request account only', async () => { + const network = networks.testnet; + const caip2Network = Network.Testnet; + const { getBalancesSpy } = createMockChainApiFactory(); + const accounts = generateAccounts(10); + const { walletData } = await createMockAccount(network, caip2Network); + + const addresses = [walletData.account.address]; + const mockResp = { + balances: [ + ...addresses, + ...accounts.map((account) => account.address), + ].reduce((acc, address) => { acc[address] = { [BtcAsset.TBtc]: { - amount: '0.00000100', + amount: new BtcAmount(100), + }, + 'some-asset': { + amount: new BtcAmount(100), }, }; return acc; }, {}), }; + const expected = { + [BtcAsset.TBtc]: { + amount: '0.00000100', + unit: Config.unit[Config.chain], + }, + }; + getBalancesSpy.mockResolvedValue(mockResp); - const result = await GetBalancesHandler.getInstance().execute({ + const result = await GetBalancesHandler.getInstance(walletData).execute({ scope: Network.Testnet, - accounts: addresses, assets: [BtcAsset.TBtc], }); @@ -70,14 +186,13 @@ describe('GetBalancesHandler', () => { }); it('throws `Request params is invalid` when request parameter is not correct', async () => { - createMockChainApiFactory(); - const accounts = generateAccounts(2); - const addresses = accounts.map((account) => account.address); + const network = networks.testnet; + const caip2Network = Network.Testnet; + const { walletData } = await createMockAccount(network, caip2Network); await expect( - GetBalancesHandler.getInstance().execute({ + GetBalancesHandler.getInstance(walletData).execute({ scope: Network.Testnet, - accounts: addresses, assets: ['some-asset'], }), ).rejects.toThrow(InvalidParamsError); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts index 971676a2..1318b4a1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts @@ -1,17 +1,18 @@ import type { Infer } from 'superstruct'; -import { object, string, assign, array, record } from 'superstruct'; +import { object, assign, array, record, enums } from 'superstruct'; +import { Config } from '../config'; import { Factory } from '../factory'; -import { - SnapRpcHandlerRequestStruct, - BaseSnapRpcHandler, -} from '../modules/rpc'; +import { type Wallet as WalletData } from '../keyring'; +import { SnapRpcHandlerRequestStruct } from '../modules/rpc'; import type { IStaticSnapRpcHandler, SnapRpcHandlerResponse, } from '../modules/rpc'; import type { StaticImplements } from '../types/static'; import { assetsStruct, positiveStringStruct } from '../utils/superstruct'; +import type { IAmount } from '../wallet'; +import { KeyringRpcHandler } from './keyring-rpc'; export type GetBalancesParams = Infer; @@ -19,13 +20,14 @@ export type GetBalancesResponse = SnapRpcHandlerResponse & Infer; export class GetBalancesHandler - extends BaseSnapRpcHandler + extends KeyringRpcHandler implements StaticImplements { + protected override isThrowValidationError = true; + static override get requestStruct() { return assign( object({ - accounts: array(string()), assets: array(assetsStruct), }), SnapRpcHandlerRequestStruct, @@ -33,44 +35,61 @@ export class GetBalancesHandler } static override get responseStruct() { - return object({ - balances: record( - string(), - record( - assetsStruct, - object({ - amount: positiveStringStruct, - }), - ), - ), - }); + const unit = Config.unit[Config.chain]; + return record( + assetsStruct, + object({ + amount: positiveStringStruct, + unit: enums([unit]), + }), + ); + } + + constructor(walletData: WalletData) { + super(); + this.walletData = walletData; } async handleRequest(params: GetBalancesParams): Promise { - const { scope, accounts, assets } = params; + const { scope, assets } = params; const chainApi = Factory.createOnChainServiceProvider(scope); + const addresses = [this.walletAccount.address]; + const addressesSet = new Set(addresses); + const assetsSet = new Set(assets); - const balances = await chainApi.getBalances(accounts, assets); + const balances = await chainApi.getBalances(addresses, assets); - const response = { - balances: Object.entries(balances.balances).reduce( - (balancesObj, [address, assetBalances]) => { - balancesObj[address] = Object.entries(assetBalances).reduce( - (assetBalanceObj, [asset, balance]) => { - assetBalanceObj[asset] = { - amount: balance.amount.toString(), - }; - return assetBalanceObj; - }, - {}, - ); - return balancesObj; - }, - {}, - ), - }; + const balancesVals = Object.entries(balances.balances); + const balancesMap = new Map(); + + for (const [address, assetBalances] of balancesVals) { + if (!addressesSet.has(address)) { + continue; + } + for (const asset in assetBalances) { + if (!assetsSet.has(asset)) { + continue; + } + + const { amount } = assetBalances[asset]; + const currentAmount = balancesMap.get(asset); + if (currentAmount) { + currentAmount.value += amount.value; + } - return response; + balancesMap.set(asset, currentAmount ?? amount); + } + } + + return Object.fromEntries( + [...balancesMap.entries()].map(([asset, amount]) => [ + asset, + { + amount: amount.toString(), + unit: amount.unit, + }, + ]), + ); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts new file mode 100644 index 00000000..932a55e3 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts @@ -0,0 +1,30 @@ +import { Factory } from '../factory'; +import { type Wallet as WalletData } from '../keyring'; +import { BaseSnapRpcHandler } from '../modules/rpc'; +import type { SnapRpcHandlerRequest } from '../modules/rpc'; +import type { IAccount, IWallet } from '../wallet'; + +export abstract class KeyringRpcHandler extends BaseSnapRpcHandler { + walletData: WalletData; + + wallet: IWallet; + + walletAccount: IAccount; + + protected override async preExecute( + params: SnapRpcHandlerRequest, + ): Promise { + await super.preExecute(params); + + const { scope, index, account } = this.walletData; + const wallet = Factory.createWallet(scope); + const unlocked = await wallet.unlock(index, account.type); + + if (!unlocked || unlocked.address !== account.address) { + throw new Error('Account not found'); + } + + this.walletAccount = unlocked; + this.wallet = wallet; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 83a95fbf..722eb068 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -26,15 +26,13 @@ import { Factory } from '../factory'; import { type Wallet as WalletData } from '../keyring'; import { btcToSats } from '../modules/bitcoin/utils'; import { logger } from '../modules/logger/logger'; -import { - SnapRpcHandlerRequestStruct, - BaseSnapRpcHandler, -} from '../modules/rpc'; +import { SnapRpcHandlerRequestStruct } from '../modules/rpc'; import type { IStaticSnapRpcHandler } from '../modules/rpc'; import { SnapHelper } from '../modules/snap'; import type { StaticImplements } from '../types/static'; import { positiveStringStruct } from '../utils'; import type { IAccount, ITransactionInfo, IWallet } from '../wallet'; +import { KeyringRpcHandler } from './keyring-rpc'; export type SendManyParams = Infer; @@ -57,7 +55,7 @@ export type TxnJson = { }; export class SendManyHandler - extends BaseSnapRpcHandler + extends KeyringRpcHandler implements StaticImplements { protected override isThrowValidationError = true; @@ -102,18 +100,7 @@ export class SendManyHandler } catch (error) { this.throwValidationError(error.message); } - - const { scope, index, account } = this.walletData; - const wallet = Factory.createWallet(scope); - const unlocked = await wallet.unlock(index, account.type); - - if (!unlocked || unlocked.address !== account.address) { - throw new Error('Account not found'); - } - this.transactionIntent = transactionIntent; - this.walletAccount = unlocked; - this.wallet = wallet; } async handleRequest(params: SendManyParams): Promise { diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts index 31fb19ee..46ce3fe2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts @@ -1,6 +1,11 @@ import { Buffer } from 'buffer'; -import { trimHexPrefix, hexToBuffer, bufferToString } from './string'; +import { + trimHexPrefix, + hexToBuffer, + bufferToString, + replaceMiddleChar, +} from './string'; describe('trimHexPrefix', () => { it('trims hex prefix', () => { @@ -52,3 +57,14 @@ describe('bufferToString', () => { ); }); }); + +describe('replaceMiddleChar', () => { + const str = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; + it('replaces the middle of a string', () => { + expect(replaceMiddleChar(str, 5, 3)).toBe('tb1qt...aeu'); + }); + + it('does not replace if the string is empty', () => { + expect(replaceMiddleChar('', 5, 3)).toBe(''); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/wallet.ts index 3ca273f1..30b13dc7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/wallet.ts @@ -17,6 +17,7 @@ export type IAddress = { export type IAmount = { value: number; + unit: string; toString(withUnit?: boolean): string; }; From 6271237a66889dd3bd338cca3d52fae71d8959b3 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 31 May 2024 10:08:59 +0800 Subject: [PATCH 049/362] Feat/implement chain api get transaction status (#85) * feat: add chain api chain_getTransactionStatus * chore: remove confirmation from get status --- .../bitcoin-wallet-snap/src/chain.ts | 12 +- .../src/config/permissions.ts | 1 + .../src/modules/bitcoin/chain/service.test.ts | 37 +++- .../src/modules/bitcoin/chain/service.ts | 13 +- .../data-client/clients/blockchair.test.ts | 88 +++++++++- .../bitcoin/data-client/clients/blockchair.ts | 141 +++++++++++++++- .../data-client/clients/blockstream.test.ts | 65 ++++++- .../data-client/clients/blockstream.ts | 45 ++++- .../src/modules/bitcoin/data-client/types.ts | 3 +- .../src/rpcs/get-balances.test.ts | 2 +- .../src/rpcs/get-transaction-status.test.ts | 64 +++++++ .../src/rpcs/get-transaction-status.ts | 56 +++++++ .../src/rpcs/helpers.test.ts | 8 +- .../bitcoin-wallet-snap/src/rpcs/helpers.ts | 9 +- .../src/rpcs/sendmany.test.ts | 2 +- .../test/fixtures/blockchair.json | 119 +++++++++++++ .../test/fixtures/blockstream.json | 158 ++++++++++++++++++ .../bitcoin-wallet-snap/test/utils.ts | 68 ++++++++ 18 files changed, 871 insertions(+), 20 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/chain.ts b/merged-packages/bitcoin-wallet-snap/src/chain.ts index 14db8c61..c7a1dad3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/chain.ts +++ b/merged-packages/bitcoin-wallet-snap/src/chain.ts @@ -28,6 +28,16 @@ export enum FeeRatio { Slow = 'slow', } +export enum TransactionStatus { + Confirmed = 'confirmed', + Pending = 'pending', + Failed = 'failed', +} + +export type TransactionStatusData = { + status: TransactionStatus; +}; + export type Balances = Record; export type Balance = { @@ -77,7 +87,7 @@ export type IOnChainService = { getFeeRates(): Promise; broadcastTransaction(signedTransaction: string): Promise; listTransactions(address: string, pagination: Pagination); - getTransaction(txnHash: string); + getTransactionStatus(txnHash: string): Promise; getDataForTransaction( address: string, transactionIntent?: TransactionIntent, diff --git a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts index b0597beb..9a8f95c5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts @@ -15,6 +15,7 @@ const allowSet = new Set([ KeyringRpcMethod.SubmitRequest, KeyringRpcMethod.GetAccountBalances, // Chain API methods + 'chain_getTransactionStatus', ]); const allowedOrigins = [ diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts index fc813df9..3167b811 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts @@ -6,7 +6,7 @@ import { generateBlockChairBroadcastTransactionResp, generateBlockChairGetUtxosResp, } from '../../../../test/utils'; -import { FeeRatio } from '../../../chain'; +import { FeeRatio, TransactionStatus } from '../../../chain'; import { BtcAsset } from '../constants'; import type { IReadDataClient, IWriteDataClient } from '../data-client'; import { BtcAmount } from '../wallet/amount'; @@ -24,12 +24,15 @@ describe('BtcOnChainService', () => { const getBalanceSpy = jest.fn(); const getUtxosSpy = jest.fn(); const getFeeRatesSpy = jest.fn(); + const getTransactionStatusSpy = jest.fn(); class MockReadDataClient implements IReadDataClient { getBalances = getBalanceSpy; getUtxos = getUtxosSpy; getFeeRates = getFeeRatesSpy; + + getTransactionStatus = getTransactionStatusSpy; } return { @@ -37,6 +40,7 @@ describe('BtcOnChainService', () => { getBalanceSpy, getUtxosSpy, getFeeRatesSpy, + getTransactionStatusSpy, }; }; @@ -263,4 +267,35 @@ describe('BtcOnChainService', () => { ).rejects.toThrow(BtcOnChainServiceError); }); }); + + describe('getTransactionStatus', () => { + const txnHash = + '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; + + it('return getTransactionStatus result', async () => { + const { instance, getTransactionStatusSpy } = createMockReadDataClient(); + const { instance: txnMgr } = createMockBtcService(instance); + getTransactionStatusSpy.mockResolvedValue({ + status: TransactionStatus.Confirmed, + }); + + const result = await txnMgr.getTransactionStatus(txnHash); + + expect(getTransactionStatusSpy).toHaveBeenCalledWith(txnHash); + expect(result).toStrictEqual({ + status: TransactionStatus.Confirmed, + }); + }); + + it('throws BtcOnChainServiceError error if an error catched', async () => { + const { instance, getTransactionStatusSpy } = createMockReadDataClient(); + const { instance: txnMgr } = createMockBtcService(instance); + + getTransactionStatusSpy.mockRejectedValue(new Error('error')); + + await expect(txnMgr.getTransactionStatus(txnHash)).rejects.toThrow( + BtcOnChainServiceError, + ); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts index 7f43ba6a..e88b6129 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts @@ -94,19 +94,22 @@ export class BtcOnChainService implements IOnChainService { } } - /* eslint-disable */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars listTransactions(address: string, pagination: Pagination) { throw new Error('Method not implemented.'); } - getTransaction(txnHash: string) { - throw new Error('Method not implemented.'); + async getTransactionStatus(txnHash: string) { + try { + return await this.readClient.getTransactionStatus(txnHash); + } catch (error) { + throw new BtcOnChainServiceError(error); + } } - /* eslint-disable */ - //eslint-disable-next-line @typescript-eslint/no-unused-vars async getDataForTransaction( address: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars transactionIntent?: TransactionIntent, ): Promise { try { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts index f4eca7ac..e2f4bdfc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts @@ -6,8 +6,9 @@ import { generateBlockChairGetBalanceResp, generateBlockChairGetUtxosResp, generateBlockChairGetStatsResp, + generateBlockChairTransactionDashboard, } from '../../../../../test/utils'; -import { FeeRatio } from '../../../../chain'; +import { FeeRatio, TransactionStatus } from '../../../../chain'; import { DataClientError } from '../exceptions'; import { BlockChairClient } from './blockchair'; @@ -362,4 +363,89 @@ describe('BlockChairClient', () => { ); }); }); + + describe('getTransactionStatus', () => { + const txnhash = + '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; + + it('returns correct result for confirmed transaction', async () => { + const { fetchSpy } = createMockFetch(); + const mockResponse = generateBlockChairTransactionDashboard( + txnhash, + 200000, + 200002, + true, + ); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const instance = new BlockChairClient({ network: networks.testnet }); + const result = await instance.getTransactionStatus(txnhash); + + expect(result).toStrictEqual({ + status: TransactionStatus.Confirmed, + }); + }); + + it('returns correct result for pending transaction', async () => { + const { fetchSpy } = createMockFetch(); + const mockResponse = generateBlockChairTransactionDashboard( + txnhash, + 200000, + 200002, + false, + ); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const instance = new BlockChairClient({ network: networks.testnet }); + const result = await instance.getTransactionStatus(txnhash); + + expect(result).toStrictEqual({ + status: TransactionStatus.Pending, + }); + }); + + it('returns pending status if blockchair result is empty', async () => { + const { fetchSpy } = createMockFetch(); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ + data: [], + context: { + state: 200002, + }, + }), + }); + + const instance = new BlockChairClient({ network: networks.testnet }); + const result = await instance.getTransactionStatus(txnhash); + + expect(result).toStrictEqual({ + status: TransactionStatus.Pending, + }); + }); + + it('throws DataClientError error if an DataClientError catched', async () => { + const { fetchSpy } = createMockFetch(); + + fetchSpy.mockResolvedValueOnce({ + ok: false, + json: jest.fn().mockResolvedValue(null), + }); + + const instance = new BlockChairClient({ network: networks.testnet }); + + await expect(instance.getTransactionStatus(txnhash)).rejects.toThrow( + DataClientError, + ); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts index 3bafe4d8..25b4c891 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts @@ -1,7 +1,8 @@ import type { Json } from '@metamask/snaps-sdk'; import { type Network, networks } from 'bitcoinjs-lib'; -import { type Balances, FeeRatio } from '../../../../chain'; +import type { TransactionStatusData } from '../../../../chain'; +import { type Balances, FeeRatio, TransactionStatus } from '../../../../chain'; import { compactError } from '../../../../utils'; import { logger } from '../../../logger/logger'; import type { Utxo } from '../../wallet'; @@ -70,6 +71,7 @@ export type GetStatResponse = { hodling_addresses: number; }; }; + export type GetUtxosResponse = { data: { [address: string]: { @@ -102,6 +104,99 @@ export type GetUtxosResponse = { }; }; +export type GetTransactionDashboardDataResponse = { + data: { + [key: string]: { + transaction: { + block_id: number; + id: number; + hash: string; + date: string; + time: string; + size: number; + weight: number; + version: number; + lock_time: number; + is_coinbase: boolean; + has_witness: boolean; + input_count: number; + output_count: number; + input_total: number; + input_total_usd: number; + output_total: number; + output_total_usd: number; + fee: number; + fee_usd: number; + fee_per_kb: number; + fee_per_kb_usd: number; + fee_per_kwu: number; + fee_per_kwu_usd: number; + cdd_total: number; + is_rbf: boolean; + }; + inputs: { + block_id: number; + transaction_id: number; + index: number; + transaction_hash: string; + date: string; + time: string; + value: number; + value_usd: number; + recipient: string; + type: string; + script_hex: string; + is_from_coinbase: boolean; + is_spendable: null; + is_spent: boolean; + spending_block_id: number | null; + spending_transaction_id: number | null; + spending_index: number | null; + spending_transaction_hash: string | null; + spending_date: string | null; + spending_time: string | null; + spending_value_usd: number | null; + spending_sequence: number | null; + spending_signature_hex: string | null; + spending_witness: string | null; + lifespan: number | null; + cdd: number | null; + }[]; + outputs: { + block_id: number; + transaction_id: number; + index: number; + transaction_hash: string; + date: string; + time: string; + value: number; + value_usd: number; + recipient: string; + type: string; + script_hex: string; + is_from_coinbase: boolean; + is_spendable: null; + is_spent: boolean; + spending_block_id: null; + spending_transaction_id: null; + spending_index: null; + spending_transaction_hash: null; + spending_date: null; + spending_time: null; + spending_value_usd: null; + spending_sequence: null; + spending_signature_hex: null; + spending_witness: null; + lifespan: null; + cdd: null; + }[]; + }; + }; + context: { + state: number; + }; +}; + export type PostTransactionResponse = { data: { transaction_hash: string; @@ -173,6 +268,25 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { return response.json() as unknown as Resp; } + protected async getTxnDashboardData( + txnHash: string, + ): Promise { + try { + logger.info(`[BlockChairClient.getTxnDashboardData] start:`); + const response = await this.get( + `/dashboards/transaction/${txnHash}`, + ); + logger.info( + `[BlockChairClient.getTxnDashboardData] response: ${JSON.stringify( + response, + )}`, + ); + return response; + } catch (error) { + throw compactError(error, DataClientError); + } + } + async getBalances(addresses: string[]): Promise { try { logger.info( @@ -282,4 +396,29 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { throw compactError(error, DataClientError); } } + + async getTransactionStatus(txnHash: string): Promise { + try { + const response = await this.getTxnDashboardData(txnHash); + + let status = TransactionStatus.Pending; + + if ( + typeof response.data === 'object' && + Object.prototype.hasOwnProperty.call(response.data, txnHash) + ) { + const isInMempool = response.data[txnHash].transaction.block_id === -1; + + status = isInMempool + ? TransactionStatus.Pending + : TransactionStatus.Confirmed; + } + + return { + status: status, + }; + } catch (error) { + throw compactError(error, DataClientError); + } + } } 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 35aea6b4..827d6756 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 @@ -5,8 +5,9 @@ import { generateBlockStreamAccountStats, generateBlockStreamGetUtxosResp, generateBlockStreamEstFeeResp, + generateBlockStreamTransactionStatusResp, } from '../../../../../test/utils'; -import { FeeRatio } from '../../../../chain'; +import { FeeRatio, TransactionStatus } from '../../../../chain'; import * as asyncUtils from '../../../../utils/async'; import { DataClientError } from '../exceptions'; import { BlockStreamClient } from './blockstream'; @@ -243,4 +244,66 @@ describe('BlockStreamClient', () => { await expect(instance.getUtxos(address)).rejects.toThrow(DataClientError); }); }); + + describe('getTransactionStatus', () => { + const txnhash = + '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; + + it('returns correct result for confirmed transaction', async () => { + const { fetchSpy } = createMockFetch(); + const mockTxnStatusResponse = generateBlockStreamTransactionStatusResp( + 200000, + true, + ); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockTxnStatusResponse), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + const result = await instance.getTransactionStatus(txnhash); + + expect(result).toStrictEqual({ + status: TransactionStatus.Confirmed, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('returns correct result for pending transaction', async () => { + const { fetchSpy } = createMockFetch(); + const mockTxnStatusResponse = generateBlockStreamTransactionStatusResp( + 200000, + false, + ); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockTxnStatusResponse), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + const result = await instance.getTransactionStatus(txnhash); + + expect(result).toStrictEqual({ + status: TransactionStatus.Pending, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('throws DataClientError error if an DataClientError catched', async () => { + const { fetchSpy } = createMockFetch(); + + fetchSpy.mockResolvedValueOnce({ + ok: false, + json: jest.fn().mockResolvedValue(null), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + + await expect(instance.getTransactionStatus(txnhash)).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 91f0c2c7..be72e89a 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 @@ -1,6 +1,7 @@ import { type Network, networks } from 'bitcoinjs-lib'; -import { type Balances, FeeRatio } from '../../../../chain'; +import type { TransactionStatusData } from '../../../../chain'; +import { type Balances, FeeRatio, TransactionStatus } from '../../../../chain'; import { compactError, processBatch } from '../../../../utils'; import { logger } from '../../../logger/logger'; import type { Utxo } from '../../wallet'; @@ -32,6 +33,13 @@ export type GetAddressStatsResponse = { }; }; +export type GetTransactionStatusResponse = { + confirmed: boolean; + block_height: number; + block_hash: string; + block_time: number; +}; + export type GetUtxosResponse = { txid: string; vout: number; @@ -43,6 +51,23 @@ export type GetUtxosResponse = { }; value: number; }[]; + +export type GetBlocksResponse = { + id: string; + height: number; + version: number; + timestamp: number; + tx_count: number; + size: number; + weight: number; + merkle_root: string; + previousblockhash: string; + mediantime: number; + nonce: number; + bits: number; + difficulty: number; +}[]; + /* eslint-enable */ export class BlockStreamClient implements IReadDataClient { @@ -177,4 +202,22 @@ export class BlockStreamClient implements IReadDataClient { throw new DataClientError(error); } } + + async getTransactionStatus(txnHash: string): Promise { + try { + const txnStatusResp = await this.get( + `/tx/${txnHash}/status`, + ); + + const status = txnStatusResp.confirmed + ? TransactionStatus.Confirmed + : TransactionStatus.Pending; + + return { + status, + }; + } catch (error) { + throw compactError(error, DataClientError); + } + } } 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 index 6f9ad9e3..7c157671 100644 --- 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 @@ -1,4 +1,4 @@ -import type { Balances, FeeRatio } from '../../../chain'; +import type { Balances, FeeRatio, TransactionStatusData } from '../../../chain'; import type { Utxo } from '../wallet'; export type GetFeeRatesResp = { @@ -9,6 +9,7 @@ export type IReadDataClient = { getBalances(address: string[]): Promise; getUtxos(address: string, includeUnconfirmed?: boolean): Promise; getFeeRates(): Promise; + getTransactionStatus(txnHash: string): Promise; }; export type IWriteDataClient = { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts index d933458b..7f71428d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts @@ -33,7 +33,7 @@ describe('GetBalancesHandler', () => { getBalances: getBalancesSpy, broadcastTransaction: jest.fn(), listTransactions: jest.fn(), - getTransaction: jest.fn(), + getTransactionStatus: jest.fn(), getDataForTransaction: jest.fn(), }); return { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts new file mode 100644 index 00000000..a8df6a93 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts @@ -0,0 +1,64 @@ +import { InvalidParamsError } from '@metamask/snaps-sdk'; + +import { TransactionStatus } from '../chain'; +import { Factory } from '../factory'; +import { Network } from '../modules/bitcoin/constants'; +import { GetTransactionStatusHandler } from './get-transaction-status'; + +jest.mock('../modules/logger/logger', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + }, +})); + +describe('GetBalancesHandler', () => { + const txnHash = + '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; + + describe('handleRequest', () => { + const createMockChainApiFactory = () => { + const getTransactionStatusSpy = jest.fn(); + + jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ + getFeeRates: jest.fn(), + getBalances: jest.fn(), + broadcastTransaction: jest.fn(), + listTransactions: jest.fn(), + getTransactionStatus: getTransactionStatusSpy, + getDataForTransaction: jest.fn(), + }); + return { + getTransactionStatusSpy, + }; + }; + + it('gets status', async () => { + const { getTransactionStatusSpy } = createMockChainApiFactory(); + + const mockResp = { + status: TransactionStatus.Confirmed, + }; + + getTransactionStatusSpy.mockResolvedValue(mockResp); + + const result = await GetTransactionStatusHandler.getInstance().execute({ + scope: Network.Testnet, + transactionId: txnHash, + }); + + expect(getTransactionStatusSpy).toHaveBeenCalledWith(txnHash); + expect(result).toStrictEqual({ + status: TransactionStatus.Confirmed, + }); + }); + + it('throws `Request params is invalid` when request parameter is not correct', async () => { + await expect( + GetTransactionStatusHandler.getInstance().execute({ + scope: Network.Testnet, + }), + ).rejects.toThrow(InvalidParamsError); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts new file mode 100644 index 00000000..e9f32428 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts @@ -0,0 +1,56 @@ +import type { Infer } from 'superstruct'; +import { object, string, assign, enums } from 'superstruct'; + +import { TransactionStatus } from '../chain'; +import { Factory } from '../factory'; +import { + SnapRpcHandlerRequestStruct, + BaseSnapRpcHandler, +} from '../modules/rpc'; +import type { + IStaticSnapRpcHandler, + SnapRpcHandlerResponse, +} from '../modules/rpc'; +import type { StaticImplements } from '../types/static'; + +export type GetTransactionStatusParams = Infer< + typeof GetTransactionStatusHandler.requestStruct +>; + +export type GetTransactionStatusResponse = SnapRpcHandlerResponse & + Infer; + +export class GetTransactionStatusHandler + extends BaseSnapRpcHandler + implements + StaticImplements +{ + static override get requestStruct() { + return assign( + object({ + transactionId: string(), + }), + SnapRpcHandlerRequestStruct, + ); + } + + static override get responseStruct() { + return object({ + status: enums(Object.values(TransactionStatus)), + }); + } + + async handleRequest( + params: GetTransactionStatusParams, + ): Promise { + const { scope, transactionId } = params; + + const chainApi = Factory.createOnChainServiceProvider(scope); + + const resp = await chainApi.getTransactionStatus(transactionId); + + return { + status: resp.status, + }; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts index f98ee66b..15329e6f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts @@ -1,5 +1,5 @@ import { CreateAccountHandler } from './create-account'; -import { GetBalancesHandler } from './get-balances'; +import { GetTransactionStatusHandler } from './get-transaction-status'; import { RpcHelper } from './helpers'; import { SendManyHandler } from './sendmany'; @@ -9,14 +9,16 @@ describe('RpcHelper', () => { expect(RpcHelper.getChainRpcApiHandlers()).toStrictEqual({ // eslint-disable-next-line @typescript-eslint/naming-convention chain_createAccount: CreateAccountHandler, - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_getBalances: GetBalancesHandler, + // // eslint-disable-next-line @typescript-eslint/naming-convention + // chain_getBalances: GetBalancesHandler, // // eslint-disable-next-line @typescript-eslint/naming-convention // chain_broadcastTransaction: BroadcastTransactionHandler, // // eslint-disable-next-line @typescript-eslint/naming-convention // chain_getDataForTransaction: GetTransactionDataHandler, // // eslint-disable-next-line @typescript-eslint/naming-convention // chain_estimateFees: EstimateFeesHandler, + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_getTransactionStatus: GetTransactionStatusHandler, }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts index 13f75632..3de34bde 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts @@ -1,5 +1,6 @@ -import { CreateAccountHandler, GetBalancesHandler } from '.'; +import { CreateAccountHandler } from '.'; import type { IStaticSnapRpcHandler } from '../modules/rpc'; +import { GetTransactionStatusHandler } from './get-transaction-status'; import { SendManyHandler } from './sendmany'; export class RpcHelper { @@ -7,14 +8,16 @@ export class RpcHelper { return { // eslint-disable-next-line @typescript-eslint/naming-convention chain_createAccount: CreateAccountHandler, - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_getBalances: GetBalancesHandler, + // // eslint-disable-next-line @typescript-eslint/naming-convention + // chain_getBalances: GetBalancesHandler, // // eslint-disable-next-line @typescript-eslint/naming-convention // chain_broadcastTransaction: BroadcastTransactionHandler, // // eslint-disable-next-line @typescript-eslint/naming-convention // chain_getDataForTransaction: GetTransactionDataHandler, // // eslint-disable-next-line @typescript-eslint/naming-convention // chain_estimateFees: EstimateFeesHandler, + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_getTransactionStatus: GetTransactionStatusHandler, }; } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 1b023a8a..8d8be87e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -46,7 +46,7 @@ describe('SendManyHandler', () => { getBalances: jest.fn(), broadcastTransaction: broadcastTransactionSpy, listTransactions: jest.fn(), - getTransaction: jest.fn(), + getTransactionStatus: jest.fn(), getDataForTransaction: getDataForTransactionSpy, }); return { diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json index 992145b6..f7028dd1 100644 --- a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json +++ b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json @@ -87,5 +87,124 @@ "data": { "transaction_hash": "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098" } + }, + "getDashboardTransaction": { + "data": { + "1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08": { + "transaction": { + "block_id": 2817600, + "id": 137371353, + "hash": "1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08", + "date": "2024-05-25", + "time": "2024-05-25 07:47:54", + "size": 192, + "weight": 438, + "version": 2, + "lock_time": 0, + "is_coinbase": false, + "has_witness": true, + "input_count": 1, + "output_count": 1, + "input_total": 6864, + "input_total_usd": 0, + "output_total": 500, + "output_total_usd": 0, + "fee": 6364, + "fee_usd": 0, + "fee_per_kb": 33145.832, + "fee_per_kb_usd": 0, + "fee_per_kwu": 14529.681, + "fee_per_kwu_usd": 0, + "cdd_total": 7.7915933333333e-5, + "is_rbf": true + }, + "inputs": [ + { + "block_id": 2817323, + "transaction_id": 137179822, + "index": 1, + "transaction_hash": "db78f36fc939d054a37210eed56ddc04be030c0f73c9029fe86ff14f1ebedf61", + "date": "2024-05-24", + "time": "2024-05-24 04:33:18", + "value": 6864, + "value_usd": 0, + "recipient": "tb1qlhkuysju47s642834n7f3tyk67mvnt2cfd9r7y", + "type": "witness_v0_keyhash", + "script_hex": "0014fdedc2425cafa1aaa8f1acfc98ac96d7b6c9ad58", + "is_from_coinbase": false, + "is_spendable": null, + "is_spent": true, + "spending_block_id": 2817600, + "spending_transaction_id": 137371353, + "spending_index": 0, + "spending_transaction_hash": "1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08", + "spending_date": "2024-05-25", + "spending_time": "2024-05-25 07:47:54", + "spending_value_usd": 0, + "spending_sequence": 4294967293, + "spending_signature_hex": "", + "spending_witness": "3045022100b8ab2eff6dde1ba3e4acac8c384bb67ec84d46c7eec55ccfdbacfe3cb8d77b910220733b3013de1a19e543cc48e204af7f0382c9548e73f0a6042dbc72a39c65b70d01,0217c65c157253d09d80e7f49c7595ae5cc82a31d3e787e3019a0260125a55b0d1", + "lifespan": 98076, + "cdd": 0 + } + ], + "outputs": [ + { + "block_id": 2817600, + "transaction_id": 137371353, + "index": 0, + "transaction_hash": "1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08", + "date": "2024-05-25", + "time": "2024-05-25 07:47:54", + "value": 500, + "value_usd": 0, + "recipient": "tb1qlhkuysju47s642834n7f3tyk67mvnt2cfd9r7y", + "type": "witness_v0_keyhash", + "script_hex": "0014fdedc2425cafa1aaa8f1acfc98ac96d7b6c9ad58", + "is_from_coinbase": false, + "is_spendable": null, + "is_spent": false, + "spending_block_id": null, + "spending_transaction_id": null, + "spending_index": null, + "spending_transaction_hash": null, + "spending_date": null, + "spending_time": null, + "spending_value_usd": null, + "spending_sequence": null, + "spending_signature_hex": null, + "spending_witness": null, + "lifespan": null, + "cdd": null + } + ] + } + }, + "context": { + "code": 200, + "source": "D", + "results": 1, + "state": 2818512, + "market_price_usd": 68030, + "cache": { + "live": true, + "duration": 20, + "since": "2024-05-29 07:44:19", + "until": "2024-05-29 07:44:39", + "time": null + }, + "api": { + "version": "2.0.95-ie", + "last_major_update": "2022-11-07 02:00:00", + "next_major_update": "2023-11-12 02:00:00", + "documentation": "https://blockchair.com/api/docs", + "notice": "Plaese note that on November 12th, 2023 public support for the following blockchains will be dropped: Mixin, Ethereum Testnet (Goerli)" + }, + "servers": "API4,TBTC3", + "time": 2.340773820877075, + "render_time": 0.04655718803405762, + "full_time": 2.387331008911133, + "request_cost": 1 + } } } diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json index 613a04e7..4e5e1ce2 100644 --- a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json +++ b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json @@ -58,5 +58,163 @@ "23": 52.373999999999995, "25": 52.373999999999995, "8": 52.373999999999995 + }, + "getLast10BlockResp": [ + { + "id": "000000000000000774b68e8353359959bb7243630a7c8994fbf7b8c53312b985", + "height": 2818504, + "version": 570884096, + "timestamp": 1716968525, + "tx_count": 6138, + "size": 2237729, + "weight": 3993176, + "merkle_root": "14cf491c608a3415b692570e286fb876936d7448a122d5fd64753d82f2a1cd4b", + "previousblockhash": "000000000000000953d3bd05c2692444ba9e9d8f81113c772ea1d2b2b82bafd9", + "mediantime": 1716968525, + "nonce": 4148296018, + "bits": 420164126, + "difficulty": 383618246.78880125 + }, + { + "id": "000000000000000953d3bd05c2692444ba9e9d8f81113c772ea1d2b2b82bafd9", + "height": 2818503, + "version": 641843200, + "timestamp": 1716968525, + "tx_count": 6296, + "size": 1931479, + "weight": 3992977, + "merkle_root": "89f7f7f2b096d04dc59182b02db2f43e5a4d2060947e3bb968d510cc25d66c1e", + "previousblockhash": "00000000000000094888e858eca8a2a2aac1624108a45bd4dceded927422e9d6", + "mediantime": 1716968524, + "nonce": 3737463343, + "bits": 420164126, + "difficulty": 383618246.78880125 + }, + { + "id": "00000000000000094888e858eca8a2a2aac1624108a45bd4dceded927422e9d6", + "height": 2818502, + "version": 677756928, + "timestamp": 1716968524, + "tx_count": 6191, + "size": 2033489, + "weight": 3992897, + "merkle_root": "5868743f6708e2102eadabceb85cb9ad31e1ed730dbd14c2f2b9771839e1abfa", + "previousblockhash": "000000004db90149f43d3a0c249e17953c7171a01c77868a2bf0781516142dc6", + "mediantime": 1716968524, + "nonce": 3766185333, + "bits": 420164126, + "difficulty": 383618246.78880125 + }, + { + "id": "000000004db90149f43d3a0c249e17953c7171a01c77868a2bf0781516142dc6", + "height": 2818501, + "version": 536870912, + "timestamp": 1716970927, + "tx_count": 9, + "size": 1871, + "weight": 5369, + "merkle_root": "3440666441c17245218a6c4b6c853505a94473e8955b3cca9945c6d74a9e5fba", + "previousblockhash": "00000000e5f036d8f93121377e83d4c6a7fcf1c0091c9e35da4f120149e3f857", + "mediantime": 1716968523, + "nonce": 1023899438, + "bits": 486604799, + "difficulty": 1.0 + }, + { + "id": "00000000e5f036d8f93121377e83d4c6a7fcf1c0091c9e35da4f120149e3f857", + "height": 2818500, + "version": 536870912, + "timestamp": 1716969726, + "tx_count": 2, + "size": 455, + "weight": 1508, + "merkle_root": "2d71a7cae3d8e76dc1601314f6c8cae0138fefd58d19d324048459c8181751c8", + "previousblockhash": "0000000040628f64b9310ac950b055234b75392944e7f143bdcf2c77c4a3399c", + "mediantime": 1716967324, + "nonce": 1017180888, + "bits": 486604799, + "difficulty": 1.0 + }, + { + "id": "0000000040628f64b9310ac950b055234b75392944e7f143bdcf2c77c4a3399c", + "height": 2818499, + "version": 536870912, + "timestamp": 1716968525, + "tx_count": 1, + "size": 250, + "weight": 892, + "merkle_root": "0ac6dc718de6c68eb0dae9b19a88494112e7e5c549dc94e0ea365b81d1517f41", + "previousblockhash": "000000009649cfb6a61dede6b96e40a664a0ceb99cda5ded6cf62a0c50b3f248", + "mediantime": 1716967323, + "nonce": 390285632, + "bits": 486604799, + "difficulty": 1.0 + }, + { + "id": "000000009649cfb6a61dede6b96e40a664a0ceb99cda5ded6cf62a0c50b3f248", + "height": 2818498, + "version": 536870912, + "timestamp": 1716967324, + "tx_count": 3, + "size": 660, + "weight": 2124, + "merkle_root": "1b7fe58a1b1be6c0bc3cf145da5f593962d1765dc114010e597dace157684cc8", + "previousblockhash": "0000000000000001e5f68dbe40f3364ebddf4f230ec9e49dd5eccd1813c8b257", + "mediantime": 1716967322, + "nonce": 4221530826, + "bits": 486604799, + "difficulty": 1.0 + }, + { + "id": "0000000000000001e5f68dbe40f3364ebddf4f230ec9e49dd5eccd1813c8b257", + "height": 2818497, + "version": 633970688, + "timestamp": 1716966123, + "tx_count": 6196, + "size": 2099705, + "weight": 3992492, + "merkle_root": "bb8c150aa19ee1e5580cc1925d06f18878a26f69fa8224f550dae07378c727a3", + "previousblockhash": "00000000caddc4212fe7617f933d4c954aa830d17343f57676fbd072eca68b9b", + "mediantime": 1716966123, + "nonce": 1829804593, + "bits": 420164126, + "difficulty": 383618246.78880125 + }, + { + "id": "00000000caddc4212fe7617f933d4c954aa830d17343f57676fbd072eca68b9b", + "height": 2818496, + "version": 536870912, + "timestamp": 1716969725, + "tx_count": 2, + "size": 473, + "weight": 1454, + "merkle_root": "2327409528477e7de65184b33f1e28b3b061c07b15bcd6cc40f03f969348afb5", + "previousblockhash": "00000000dd4d4b64c55d54edbdb3bc9634de0434f0d23292a5fa11511a8e6e90", + "mediantime": 1716966122, + "nonce": 4028091752, + "bits": 486604799, + "difficulty": 1.0 + }, + { + "id": "00000000dd4d4b64c55d54edbdb3bc9634de0434f0d23292a5fa11511a8e6e90", + "height": 2818495, + "version": 536870912, + "timestamp": 1716968524, + "tx_count": 1, + "size": 250, + "weight": 892, + "merkle_root": "70bc5770e60c093fa682dd860c2c4437aadf1276ed924d15036e7d6326f8189a", + "previousblockhash": "00000000aa597bac4c12b00b38ce4aec6547e028ad27811a7d1f4ac539051fa8", + "mediantime": 1716966121, + "nonce": 1607636480, + "bits": 486604799, + "difficulty": 1.0 + } + ], + "getTransactionStatus": { + "confirmed": true, + "block_height": 2817600, + "block_hash": "0000000000000008859508cb0ec5596e8d05363b0df010b9b02b7b4cd4dc261e", + "block_time": 1716623274 } } diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index 96068c54..5c0e7144 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -313,6 +313,74 @@ export function generateBlockChairBroadcastTransactionResp() { return resp; } +/** + * Method to generate blockstream last 10 blocks resp. + * + * @param blockHeight - A start number for the block height of the resp. + * @returns A blockstream last 10 blocks resp. + */ +export function generateBlockStreamLast10BlockResp(blockHeight: number) { + const template = blockStreamData.getLast10BlockResp; + const resp: typeof template = { ...template }; + for (let i = 0; i < template.length; i++) { + resp[i].height = blockHeight - i; + } + return resp; +} + +/** + * Method to generate blockstream transaction status resp. + * + * @param blockHeight - Block height of the transaction. + * @param confirmed - Confirm status of the transaction. + * @returns A blockstream transaction status resp. + */ +export function generateBlockStreamTransactionStatusResp( + blockHeight: number, + confirmed: boolean, +) { + const template = blockStreamData.getTransactionStatus; + const resp: typeof template = { ...template }; + resp.block_height = blockHeight; + resp.confirmed = confirmed; + return resp; +} + +/** + * Method to generate blockchair transaction dashboards resp. + * + * @param txnHash - Transaction hash of the transaction. + * @param txnBlockHeight - Block height of the transaction. + * @param txnBlockHeight - Block height of the last block. + * @param confirmed - Confirm status of the transaction. + * @returns A blockchair transaction dashboards resp. + */ +export function generateBlockChairTransactionDashboard( + txnHash: string, + txnBlockHeight: number, + lastBlockHeight: number, + confirmed: boolean, +) { + const template = blockChairData.getDashboardTransaction; + const data = Object.values(template.data)[0]; + const resp = { + data: { + [txnHash]: { + ...data, + transaction: { + ...data.transaction, + block_id: confirmed ? txnBlockHeight : -1, + }, + }, + }, + context: { + ...template.context, + state: lastBlockHeight, + }, + }; + return resp; +} + /** * Method to generate formated utxos with blockchair resp. * From 36644bb2b4175d09dee8364e97c81f3faae69166 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 31 May 2024 15:03:43 +0800 Subject: [PATCH 050/362] chore: refinement (#86) * chore: refinement * chore: add logging --- .../bitcoin-wallet-snap/jest.config.js | 3 +- .../bitcoin-wallet-snap/jest.setup.ts | 4 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../{modules => }/bitcoin/chain/exceptions.ts | 2 +- .../src/{modules => }/bitcoin/chain/index.ts | 0 .../bitcoin/chain/service.test.ts | 12 +- .../{modules => }/bitcoin/chain/service.ts | 17 +- .../src/{modules => }/bitcoin/chain/types.ts | 0 .../src/{modules => }/bitcoin/config/index.ts | 0 .../src/{modules => }/bitcoin/config/types.ts | 0 .../src/{modules => }/bitcoin/constants.ts | 0 .../data-client/clients/blockchair.test.ts | 10 +- .../bitcoin/data-client/clients/blockchair.ts | 8 +- .../data-client/clients/blockstream.test.ts | 12 +- .../data-client/clients/blockstream.ts | 12 +- .../bitcoin/data-client/exceptions.ts | 2 +- .../bitcoin/data-client/factory.test.ts | 0 .../bitcoin/data-client/factory.ts | 0 .../bitcoin/data-client/index.ts | 0 .../bitcoin/data-client/types.ts | 0 .../bitcoin/utils/explorer.test.ts | 2 +- .../{modules => }/bitcoin/utils/explorer.ts | 2 +- .../src/{modules => }/bitcoin/utils/index.ts | 0 .../bitcoin/utils/network.test.ts | 0 .../{modules => }/bitcoin/utils/network.ts | 0 .../bitcoin/utils/payment.test.ts | 0 .../{modules => }/bitcoin/utils/payment.ts | 0 .../{modules => }/bitcoin/utils/unit.test.ts | 0 .../src/{modules => }/bitcoin/utils/unit.ts | 0 .../bitcoin/wallet/account.test.ts | 2 +- .../{modules => }/bitcoin/wallet/account.ts | 10 +- .../bitcoin/wallet/address.test.ts | 0 .../{modules => }/bitcoin/wallet/address.ts | 4 +- .../bitcoin/wallet/amount.test.ts | 2 +- .../{modules => }/bitcoin/wallet/amount.ts | 4 +- .../bitcoin/wallet/coin-select.test.ts | 5 +- .../bitcoin/wallet/coin-select.ts | 2 +- .../src/bitcoin/wallet/deriver.test.ts | 229 +++++++++++++ .../{modules => }/bitcoin/wallet/deriver.ts | 4 +- .../bitcoin/wallet/exceptions.ts | 4 +- .../bitcoin/wallet/factory.test.ts | 0 .../{modules => }/bitcoin/wallet/factory.ts | 2 +- .../src/{modules => }/bitcoin/wallet/index.ts | 3 + .../{modules => }/bitcoin/wallet/psbt.test.ts | 54 +-- .../src/{modules => }/bitcoin/wallet/psbt.ts | 14 +- .../bitcoin/wallet/signer.test.ts | 2 +- .../{modules => }/bitcoin/wallet/signer.ts | 2 +- .../bitcoin/wallet/transactionInfo.test.ts | 2 +- .../bitcoin/wallet/transactionInfo.ts | 2 +- .../src/{modules => }/bitcoin/wallet/types.ts | 2 +- .../bitcoin/wallet/wallet.test.ts | 58 +--- .../{modules => }/bitcoin/wallet/wallet.ts | 36 +- .../bitcoin-wallet-snap/src/chain.ts | 76 +++-- .../bitcoin-wallet-snap/src/config/config.ts | 4 +- .../bitcoin-wallet-snap/src/factory.test.ts | 6 +- .../bitcoin-wallet-snap/src/factory.ts | 18 +- .../bitcoin-wallet-snap/src/index.test.ts | 33 +- .../bitcoin-wallet-snap/src/index.ts | 10 +- .../src/keyring/exceptions.ts | 2 +- .../src/keyring/keyring.test.ts | 14 +- .../src/keyring/keyring.ts | 8 +- .../src/keyring/state.test.ts | 4 +- .../bitcoin-wallet-snap/src/keyring/state.ts | 2 +- .../bitcoin-wallet-snap/src/keyring/types.ts | 2 +- .../{modules => libs}/exception/exceptions.ts | 0 .../src/{modules => libs}/exception/index.ts | 0 .../src/libs/logger/__mocks__/logger.ts | 17 + .../{modules => libs}/logger/logger.test.ts | 0 .../src/{modules => libs}/logger/logger.ts | 0 .../src/{modules => libs}/rpc/base.ts | 7 +- .../src/{modules => libs}/rpc/exceptions.ts | 0 .../src/{modules => libs}/rpc/index.ts | 0 .../bitcoin-wallet-snap/src/libs/rpc/types.ts | 56 ++++ .../src/libs/snap/__mocks__/helpers.ts | 25 ++ .../src/{modules => libs}/snap/exceptions.ts | 0 .../{modules => libs}/snap/helpers.test.ts | 0 .../src/{modules => libs}/snap/helpers.ts | 2 +- .../src/{modules => libs}/snap/index.ts | 0 .../src/{modules => libs}/snap/lock.test.ts | 0 .../src/{modules => libs}/snap/lock.ts | 0 .../src/{modules => libs}/snap/state.test.ts | 6 +- .../src/{modules => libs}/snap/state.ts | 0 .../modules/bitcoin/wallet/deriver.test.ts | 308 ------------------ .../src/modules/rpc/types.ts | 39 --- .../src/rpcs/create-account.ts | 7 +- .../src/rpcs/get-balances.test.ts | 84 ++--- .../src/rpcs/get-balances.ts | 68 ++-- .../src/rpcs/get-transaction-status.test.ts | 22 +- .../src/rpcs/get-transaction-status.ts | 21 +- .../bitcoin-wallet-snap/src/rpcs/helpers.ts | 2 +- .../src/rpcs/keyring-rpc.ts | 4 +- .../src/rpcs/sendmany.test.ts | 73 ++--- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 148 +++++---- ...let.mock.test.ts => snap-provider.mock.ts} | 5 +- .../bitcoin-wallet-snap/test/utils.ts | 59 +++- 95 files changed, 805 insertions(+), 858 deletions(-) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/chain/exceptions.ts (53%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/chain/index.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/chain/service.test.ts (97%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/chain/service.ts (94%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/chain/types.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/config/index.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/config/types.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/constants.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/data-client/clients/blockchair.test.ts (98%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/data-client/clients/blockchair.ts (98%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/data-client/clients/blockstream.test.ts (97%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/data-client/clients/blockstream.ts (95%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/data-client/exceptions.ts (50%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/data-client/factory.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/data-client/factory.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/data-client/index.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/data-client/types.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/utils/explorer.test.ts (94%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/utils/explorer.ts (93%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/utils/index.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/utils/network.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/utils/network.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/utils/payment.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/utils/payment.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/utils/unit.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/utils/unit.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/account.test.ts (97%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/account.ts (89%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/address.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/address.ts (88%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/amount.test.ts (95%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/amount.ts (88%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/coin-select.test.ts (93%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/coin-select.ts (97%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/deriver.ts (96%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/exceptions.ts (79%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/factory.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/factory.ts (91%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/index.ts (68%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/psbt.test.ts (89%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/psbt.ts (90%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/signer.test.ts (97%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/signer.ts (96%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/transactionInfo.test.ts (96%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/transactionInfo.ts (96%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/types.ts (94%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/wallet.test.ts (82%) rename merged-packages/bitcoin-wallet-snap/src/{modules => }/bitcoin/wallet/wallet.ts (83%) rename merged-packages/bitcoin-wallet-snap/src/{modules => libs}/exception/exceptions.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => libs}/exception/index.ts (100%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/logger/__mocks__/logger.ts rename merged-packages/bitcoin-wallet-snap/src/{modules => libs}/logger/logger.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => libs}/logger/logger.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => libs}/rpc/base.ts (92%) rename merged-packages/bitcoin-wallet-snap/src/{modules => libs}/rpc/exceptions.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => libs}/rpc/index.ts (100%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/rpc/types.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/__mocks__/helpers.ts rename merged-packages/bitcoin-wallet-snap/src/{modules => libs}/snap/exceptions.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => libs}/snap/helpers.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => libs}/snap/helpers.ts (96%) rename merged-packages/bitcoin-wallet-snap/src/{modules => libs}/snap/index.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => libs}/snap/lock.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => libs}/snap/lock.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{modules => libs}/snap/state.test.ts (99%) rename merged-packages/bitcoin-wallet-snap/src/{modules => libs}/snap/state.ts (100%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/deriver.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/modules/rpc/types.ts rename merged-packages/bitcoin-wallet-snap/test/{wallet.mock.test.ts => snap-provider.mock.ts} (89%) diff --git a/merged-packages/bitcoin-wallet-snap/jest.config.js b/merged-packages/bitcoin-wallet-snap/jest.config.js index a33c3968..3bc03249 100644 --- a/merged-packages/bitcoin-wallet-snap/jest.config.js +++ b/merged-packages/bitcoin-wallet-snap/jest.config.js @@ -7,7 +7,7 @@ module.exports = { restoreMocks: true, resetMocks: true, verbose: true, - testPathIgnorePatterns: ['/node_modules/', '/__BAK__/'], + testPathIgnorePatterns: ['/node_modules/', '/__BAK__/', '/__mocks__/'], 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 @@ -16,6 +16,7 @@ module.exports = { '!./src/**/*.d.ts', '!./src/**/index.ts', '!./src/**/__BAK__/**', + '!./src/**/__mocks__/**', '!./src/config/*.ts', '!./src/**/type?(s).ts', '!./src/**/exception?(s).ts', diff --git a/merged-packages/bitcoin-wallet-snap/jest.setup.ts b/merged-packages/bitcoin-wallet-snap/jest.setup.ts index 27a77684..c1c7b0fd 100644 --- a/merged-packages/bitcoin-wallet-snap/jest.setup.ts +++ b/merged-packages/bitcoin-wallet-snap/jest.setup.ts @@ -1,6 +1,6 @@ -import { WalletMock } from './test/wallet.mock.test'; +import { MockSnapProvider } from './test/snap-provider.mock'; // eslint-disable-next-line no-restricted-globals const globalAny: any = global; -globalAny.snap = new WalletMock(); +globalAny.snap = new MockSnapProvider(); diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 8af77733..f6d7f5cb 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "UZtYMq5DqEBaz2MHixBocuCi3yCI7LcX7Nw+YGbulYc=", + "shasum": "cYdAxSIHUKPIACvr6j9QWgn1XUiIcaqDRwfb7yYK7nY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts similarity index 53% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/exceptions.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts index 0b31028d..8321d50a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts @@ -1,3 +1,3 @@ -import { CustomError } from '../../exception'; +import { CustomError } from '../../libs/exception'; export class BtcOnChainServiceError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/index.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts similarity index 97% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts index 3167b811..ecce3daa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts @@ -5,19 +5,15 @@ import { generateAccounts, generateBlockChairBroadcastTransactionResp, generateBlockChairGetUtxosResp, -} from '../../../../test/utils'; -import { FeeRatio, TransactionStatus } from '../../../chain'; +} from '../../../test/utils'; +import { FeeRatio, TransactionStatus } from '../../chain'; import { BtcAsset } from '../constants'; import type { IReadDataClient, IWriteDataClient } from '../data-client'; -import { BtcAmount } from '../wallet/amount'; +import { BtcAmount } from '../wallet'; import { BtcOnChainServiceError } from './exceptions'; import { BtcOnChainService } from './service'; -jest.mock('../../logger/logger', () => ({ - logger: { - info: jest.fn(), - }, -})); +jest.mock('../../libs/logger/logger'); describe('BtcOnChainService', () => { const createMockReadDataClient = () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts similarity index 94% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index e88b6129..cdea5f11 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -7,15 +7,14 @@ import type { Balances, AssetBalances, TransactionIntent, - Pagination, Fees, TransactionData, CommitedTransaction, -} from '../../../chain'; -import { compactError } from '../../../utils'; +} from '../../chain'; +import { compactError } from '../../utils'; import { BtcAsset } from '../constants'; import type { IWriteDataClient, IReadDataClient } from '../data-client'; -import { BtcAmount } from '../wallet/amount'; +import { BtcAmount } from '../wallet'; import { BtcOnChainServiceError } from './exceptions'; import type { BtcOnChainServiceOptions } from './types'; @@ -94,11 +93,6 @@ export class BtcOnChainService implements IOnChainService { } } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - listTransactions(address: string, pagination: Pagination) { - throw new Error('Method not implemented.'); - } - async getTransactionStatus(txnHash: string) { try { return await this.readClient.getTransactionStatus(txnHash); @@ -138,4 +132,9 @@ export class BtcOnChainService implements IOnChainService { throw compactError(error, BtcOnChainServiceError); } } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + listTransactions() { + throw new Error('Method not implemented.'); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/types.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/chain/types.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/types.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/index.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/index.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/config/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/types.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/config/types.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/config/types.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/constants.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/constants.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.test.ts similarity index 98% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.test.ts index e2f4bdfc..15f0ccfd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.test.ts @@ -7,16 +7,12 @@ import { generateBlockChairGetUtxosResp, generateBlockChairGetStatsResp, generateBlockChairTransactionDashboard, -} from '../../../../../test/utils'; -import { FeeRatio, TransactionStatus } from '../../../../chain'; +} from '../../../../test/utils'; +import { FeeRatio, TransactionStatus } from '../../../chain'; import { DataClientError } from '../exceptions'; import { BlockChairClient } from './blockchair'; -jest.mock('../../../logger/logger', () => ({ - logger: { - info: jest.fn(), - }, -})); +jest.mock('../../../libs/logger/logger'); describe('BlockChairClient', () => { const createMockFetch = () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts similarity index 98% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts index 25b4c891..9414e8a7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts @@ -1,10 +1,10 @@ import type { Json } from '@metamask/snaps-sdk'; import { type Network, networks } from 'bitcoinjs-lib'; -import type { TransactionStatusData } from '../../../../chain'; -import { type Balances, FeeRatio, TransactionStatus } from '../../../../chain'; -import { compactError } from '../../../../utils'; -import { logger } from '../../../logger/logger'; +import type { TransactionStatusData } from '../../../chain'; +import { type Balances, FeeRatio, TransactionStatus } from '../../../chain'; +import { logger } from '../../../libs/logger/logger'; +import { compactError } from '../../../utils'; import type { Utxo } from '../../wallet'; import { DataClientError } from '../exceptions'; import type { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts similarity index 97% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts index 827d6756..2dc0373a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts @@ -6,17 +6,13 @@ import { generateBlockStreamGetUtxosResp, generateBlockStreamEstFeeResp, generateBlockStreamTransactionStatusResp, -} from '../../../../../test/utils'; -import { FeeRatio, TransactionStatus } from '../../../../chain'; -import * as asyncUtils from '../../../../utils/async'; +} from '../../../../test/utils'; +import { FeeRatio, TransactionStatus } from '../../../chain'; +import * as asyncUtils from '../../../utils/async'; import { DataClientError } from '../exceptions'; import { BlockStreamClient } from './blockstream'; -jest.mock('../../../logger/logger', () => ({ - logger: { - info: jest.fn(), - }, -})); +jest.mock('../../../libs/logger/logger'); describe('BlockStreamClient', () => { const createMockFetch = () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts similarity index 95% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts index be72e89a..846dab31 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/clients/blockstream.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts @@ -1,9 +1,9 @@ import { type Network, networks } from 'bitcoinjs-lib'; -import type { TransactionStatusData } from '../../../../chain'; -import { type Balances, FeeRatio, TransactionStatus } from '../../../../chain'; -import { compactError, processBatch } from '../../../../utils'; -import { logger } from '../../../logger/logger'; +import type { TransactionStatusData } from '../../../chain'; +import { type Balances, FeeRatio, TransactionStatus } from '../../../chain'; +import { logger } from '../../../libs/logger/logger'; +import { compactError, processBatch } from '../../../utils'; import type { Utxo } from '../../wallet'; import { DataClientError } from '../exceptions'; import type { GetFeeRatesResp, IReadDataClient } from '../types'; @@ -79,8 +79,8 @@ export class BlockStreamClient implements IReadDataClient { this.options = options; this.feeRateRatioMap = { [FeeRatio.Fast]: '1', - [FeeRatio.Medium]: '25', - [FeeRatio.Slow]: '144', + [FeeRatio.Medium]: '144', + [FeeRatio.Slow]: '1008', }; } diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/exceptions.ts similarity index 50% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/exceptions.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/exceptions.ts index c2039307..d46bde48 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/exceptions.ts @@ -1,3 +1,3 @@ -import { CustomError } from '../../exception'; +import { CustomError } from '../../libs/exception'; export class DataClientError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/factory.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/factory.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/factory.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/index.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/index.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/types.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/data-client/types.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/types.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/explorer.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.test.ts similarity index 94% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/explorer.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.test.ts index 4b62b18e..a7487cab 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/explorer.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.test.ts @@ -1,4 +1,4 @@ -import { Chain, Config } from '../../../config'; +import { Chain, Config } from '../../config'; import { Network } from '../constants'; import { getExplorerUrl } from './explorer'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/explorer.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts similarity index 93% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/explorer.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts index d75743e3..6bad0b10 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/explorer.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts @@ -1,4 +1,4 @@ -import { Chain, Config } from '../../../config'; +import { Chain, Config } from '../../config'; import { Network } from '../constants'; /** diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/index.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/network.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/payment.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/payment.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/payment.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/payment.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/utils/unit.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts similarity index 97% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts index 89e08757..e49eb86e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts @@ -2,7 +2,7 @@ import type { Network, Payment } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; -import type { IAccountSigner } from '../../../wallet'; +import type { IAccountSigner } from '../../wallet'; import { ScriptType } from '../constants'; import * as utils from '../utils/payment'; import { P2WPKHAccount } from './account'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts similarity index 89% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts index 89b5b14b..dc101f23 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts @@ -1,8 +1,8 @@ import type { Network, Payment } from 'bitcoinjs-lib'; -import type { StaticImplements } from '../../../types/static'; -import { hexToBuffer } from '../../../utils'; -import type { IAccountSigner } from '../../../wallet'; +import type { StaticImplements } from '../../types/static'; +import { hexToBuffer } from '../../utils'; +import type { IAccountSigner } from '../../wallet'; import { ScriptType } from '../constants'; import { getBtcPaymentInst } from '../utils/payment'; import type { IBtcAccount, IStaticBtcAccount } from './types'; @@ -48,7 +48,7 @@ export class BtcAccount implements IBtcAccount { this.type = type; } - get address() { + get address(): string { if (!this.#address) { if (!this.payment.address) { throw new Error('Payment address is missing'); @@ -58,7 +58,7 @@ export class BtcAccount implements IBtcAccount { return this.#address; } - get payment() { + get payment(): Payment { if (!this.#payment) { this.#payment = getBtcPaymentInst( this.scriptType, diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/address.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/address.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts similarity index 88% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/address.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts index 4c6debc3..7d3a8dc6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/address.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts @@ -1,8 +1,8 @@ import type { Json } from '@metamask/snaps-sdk'; import type { Network } from 'bitcoinjs-lib'; -import { replaceMiddleChar } from '../../../utils'; -import type { IAddress } from '../../../wallet'; +import { replaceMiddleChar } from '../../utils'; +import type { IAddress } from '../../wallet'; import { getCaip2Network, getExplorerUrl } from '../utils'; export class BtcAddress implements IAddress { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts similarity index 95% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts index 6f7cd7c0..48fd0685 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts @@ -1,4 +1,4 @@ -import { Chain, Config } from '../../../config'; +import { Chain, Config } from '../../config'; import { satsToBtc } from '../utils'; import { BtcAmount } from './amount'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts similarity index 88% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts index c4fa689f..7cb5d5dc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/amount.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts @@ -1,7 +1,7 @@ import type { Json } from '@metamask/snaps-sdk'; -import { Chain, Config } from '../../../config'; -import type { IAmount } from '../../../wallet'; +import { Chain, Config } from '../../config'; +import type { IAmount } from '../../wallet'; import { satsToBtc } from '../utils'; export class BtcAmount implements IAmount { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts similarity index 93% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/coin-select.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts index ebcd287c..73aba9aa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/coin-select.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts @@ -1,9 +1,6 @@ import { Buffer } from 'buffer'; -import { - generateAccounts, - generateFormatedUtxos, -} from '../../../../test/utils'; +import { generateAccounts, generateFormatedUtxos } from '../../../test/utils'; import { CoinSelectService } from './coin-select'; describe('CoinSelectService', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts similarity index 97% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/coin-select.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index 00691b6f..c6cfa69e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -2,7 +2,7 @@ import { crypto as cryptoUtils } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; import coinSelect from 'coinselect'; -import { hexToBuffer } from '../../../utils'; +import { hexToBuffer } from '../../utils'; import { UtxoServiceError } from './exceptions'; import type { SpendTo, SelectedUtxos, Utxo } from './types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts new file mode 100644 index 00000000..b673d8b4 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts @@ -0,0 +1,229 @@ +import ecc from '@bitcoinerlab/secp256k1'; +import { + type BIP44AddressKeyDeriver, + type SLIP10NodeInterface, +} from '@metamask/key-tree'; +import { networks } from 'bitcoinjs-lib'; +import { Buffer } from 'buffer'; +import ECPairFactory from 'ecpair'; + +import { SnapHelper } from '../../libs/snap'; +import * as strUtils from '../../utils/string'; +import { P2WPKHAccount } from './account'; +import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; + +jest.mock('../../libs/snap/helpers'); + +describe('BtcAccountBip32Deriver', () => { + const prepareBip32Deriver = async (network) => { + const deriver = new BtcAccountBip32Deriver(network); + const bip32Deriver = await SnapHelper.getBip32Deriver( + P2WPKHAccount.path, + deriver.curve, + ); + const pkBuffer = bip32Deriver.privateKeyBytes as unknown as Buffer; + const ccBuffer = bip32Deriver.chainCodeBytes as unknown as Buffer; + + return { + bip32Deriver, + deriver, + pkBuffer, + ccBuffer, + }; + }; + + describe('createBip32FromSeed', () => { + it('returns an BIP32Interface', async () => { + const network = networks.testnet; + const { deriver, pkBuffer } = await prepareBip32Deriver(network); + + const result = deriver.createBip32FromSeed(pkBuffer); + + expect(result.chainCode).toBeDefined(); + expect(result.chainCode).not.toBeNull(); + expect(result.privateKey).toBeDefined(); + expect(result.privateKey).not.toBeNull(); + expect(result.publicKey).toBeDefined(); + expect(result.publicKey).not.toBeNull(); + expect(result.depth).toBeDefined(); + expect(result.depth).not.toBeNull(); + expect(result.index).toBeDefined(); + expect(result.index).not.toBeNull(); + }); + + it('throws `Unable to construct BIP32 node from seed` if an error catched', () => { + const network = networks.testnet; + const seed = Buffer.from('', 'hex'); + + const deriver = new BtcAccountBip32Deriver(network); + + expect(() => deriver.createBip32FromSeed(seed)).toThrow( + 'Unable to construct BIP32 node from seed', + ); + }); + }); + + describe('createBip32FromPrivateKey', () => { + it('returns an BIP32Interface', async () => { + const network = networks.testnet; + const { deriver, pkBuffer, ccBuffer } = await prepareBip32Deriver( + network, + ); + + const result = deriver.createBip32FromPrivateKey(pkBuffer, ccBuffer); + + expect(result.chainCode).toBeDefined(); + expect(result.chainCode).not.toBeNull(); + expect(result.privateKey).toBeDefined(); + expect(result.privateKey).not.toBeNull(); + expect(result.publicKey).toBeDefined(); + expect(result.publicKey).not.toBeNull(); + expect(result.depth).toBeDefined(); + expect(result.depth).not.toBeNull(); + expect(result.index).toBeDefined(); + expect(result.index).not.toBeNull(); + }); + + it('throws `Unable to construct BIP32 node from private key` if an error catched', async () => { + const network = networks.testnet; + const deriver = new BtcAccountBip32Deriver(network); + const pkBuffer = Buffer.from(''); + const ccBuffer = Buffer.from(''); + + expect(() => + deriver.createBip32FromPrivateKey(pkBuffer, ccBuffer), + ).toThrow('Unable to construct BIP32 node from private key'); + }); + }); + + describe('getChild', () => { + it('returns an BIP32Interface', async () => { + const network = networks.testnet; + const { deriver, pkBuffer, ccBuffer } = await prepareBip32Deriver( + network, + ); + + const idx = 0; + + const node = deriver.createBip32FromPrivateKey(pkBuffer, ccBuffer); + + const result = await deriver.getChild(node, idx); + + expect(result.chainCode).toBeDefined(); + expect(result.chainCode).not.toBeNull(); + expect(result.privateKey).toBeDefined(); + expect(result.privateKey).not.toBeNull(); + expect(result.publicKey).toBeDefined(); + expect(result.publicKey).not.toBeNull(); + expect(result.depth).toBeDefined(); + expect(result.depth).not.toBeNull(); + expect(result.index).toBeDefined(); + expect(result.index).not.toBeNull(); + }); + }); + + describe('getRoot', () => { + it('returns an BIP32Interface', async () => { + const network = networks.testnet; + const { deriver, pkBuffer, ccBuffer } = await prepareBip32Deriver( + network, + ); + + const result = await deriver.getRoot(P2WPKHAccount.path); + + expect(result.chainCode).toStrictEqual(ccBuffer); + expect(result.privateKey).toStrictEqual(pkBuffer); + }); + + it('throws `Private key is invalid` if private key is invalid', async () => { + const network = networks.testnet; + const spy = jest.spyOn(strUtils, 'hexToBuffer'); + spy.mockImplementation(() => { + throw new Error('error'); + }); + const { deriver } = await prepareBip32Deriver(network); + + await expect(deriver.getRoot(P2WPKHAccount.path)).rejects.toThrow( + 'Private key is invalid', + ); + }); + + it('throws `Chain code is invalid` if chain code is invalid', async () => { + const network = networks.testnet; + const spy = jest.spyOn(strUtils, 'hexToBuffer'); + spy + .mockImplementationOnce((val: string) => Buffer.from(val, 'hex')) + .mockImplementationOnce(() => { + throw new Error('error'); + }); + const { deriver } = await prepareBip32Deriver(network); + + await expect(deriver.getRoot(P2WPKHAccount.path)).rejects.toThrow( + 'Chain code is invalid', + ); + }); + + it('throws DeriverError if private key is missing', async () => { + const network = networks.testnet; + const deriver = new BtcAccountBip32Deriver(network); + + jest + .spyOn(SnapHelper, 'getBip32Deriver') + .mockResolvedValue({} as unknown as SLIP10NodeInterface); + + await expect(deriver.getRoot(P2WPKHAccount.path)).rejects.toThrow( + 'Deriver private key is missing', + ); + }); + }); +}); + +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 } = P2WPKHAccount; + const ecpair = ECPairFactory(ecc); + const privateKey = ecpair.makeRandom().privateKey?.toString('hex'); + const { getBip44DeriverSpy, deriverSpy } = createMockBip44Entropy(); + + deriverSpy.mockResolvedValue({ + privateKey, + }); + + const deriver = new BtcAccountBip44Deriver(network); + await deriver.getRoot(path); + + expect(getBip44DeriverSpy).toHaveBeenCalledWith(0); + }); + + it('throws `Deriver private key is missing` error if the private key is missing', async () => { + const network = networks.testnet; + const { path } = P2WPKHAccount; + const { deriverSpy } = createMockBip44Entropy(); + deriverSpy.mockResolvedValue({ + privateKey: undefined, + }); + + const deriver = new BtcAccountBip44Deriver(network); + + await expect(deriver.getRoot(path)).rejects.toThrow( + 'Deriver private key is missing', + ); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/deriver.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts similarity index 96% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/deriver.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts index ee791ead..cd80ad4f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/deriver.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts @@ -4,8 +4,8 @@ import { BIP32Factory } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; -import { compactError, hexToBuffer } from '../../../utils'; -import { SnapHelper } from '../../snap/helpers'; +import { SnapHelper } from '../../libs/snap/helpers'; +import { compactError, hexToBuffer } from '../../utils'; import { DeriverError } from './exceptions'; import type { IBtcAccountDeriver } from './types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts similarity index 79% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/exceptions.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts index 66b2d8cd..01b0d6ac 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts @@ -1,4 +1,4 @@ -import { CustomError } from '../../exception'; +import { CustomError } from '../../libs/exception'; export class DeriverError extends CustomError {} @@ -6,6 +6,8 @@ export class WalletFactoryError extends CustomError {} export class WalletError extends CustomError {} +export class TransactionValidationError extends WalletError {} + export class PsbtServiceError extends CustomError {} export class PsbtSigValidateError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.ts similarity index 91% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.ts index e2a5199b..e3c7992d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.ts @@ -1,6 +1,6 @@ import { type Network } from 'bitcoinjs-lib'; -import type { IWallet } from '../../../wallet'; +import type { IWallet } from '../../wallet'; import { type BtcWalletConfig } from '../config'; import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; import { BtcWallet } from './wallet'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts similarity index 68% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/index.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts index f85939e0..7c36a8e7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts @@ -5,3 +5,6 @@ export * from './types'; export * from './signer'; export * from './deriver'; export * from './wallet'; +export * from './address'; +export * from './transactionInfo'; +export * from './amount'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts similarity index 89% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/psbt.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts index 99c0ee9c..9aea4198 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/psbt.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts @@ -1,60 +1,22 @@ -import ecc from '@bitcoinerlab/secp256k1'; -import type { SLIP10NodeInterface } from '@metamask/key-tree'; -import { BIP32Factory } from 'bip32'; import { Transaction, networks } from 'bitcoinjs-lib'; -import ECPairFactory from 'ecpair'; -import { generateFormatedUtxos } from '../../../../test/utils'; -import { hexToBuffer } from '../../../utils'; -import { SnapHelper } from '../../snap'; +import { generateFormatedUtxos } from '../../../test/utils'; +import { hexToBuffer } from '../../utils'; import { MaxStandardTxWeight, ScriptType } from '../constants'; import { BtcAccountBip32Deriver } from './deriver'; import { PsbtServiceError } from './exceptions'; import { PsbtService } from './psbt'; import { BtcWallet } from './wallet'; -jest.mock('../../logger/logger', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - }, -})); +jest.mock('../../libs/logger/logger'); +jest.mock('../../libs/snap/helpers'); describe('PsbtService', () => { - const createMockBip32Instance = (network) => { - const ECPair = ECPairFactory(ecc); - const bip32 = BIP32Factory(ecc); - - const keyPair = ECPair.makeRandom(); - const deriver = bip32.fromSeed(keyPair.publicKey, network); - - const jsonData = { - privateKey: deriver.privateKey?.toString('hex'), - publicKey: deriver.publicKey.toString('hex'), - chainCode: deriver.chainCode.toString('hex'), - depth: deriver.depth, - index: deriver.index, - curve: 'secp256k1', - masterFingerprint: undefined, - parentFingerprint: 0, - }; - jest.spyOn(SnapHelper, 'getBip32Deriver').mockResolvedValue({ - ...jsonData, - chainCodeBytes: deriver.chainCode, - privateKeyBytes: deriver.privateKey, - publicKeyBytes: deriver.publicKey, - toJSON: jest.fn().mockReturnValue(jsonData), - } as unknown as SLIP10NodeInterface); - - return { - instance: new BtcAccountBip32Deriver(network), - }; - }; - const createMockWallet = (network) => { - const { instance: deriver } = createMockBip32Instance(network); - - const instance = new BtcWallet(deriver, network); + const instance = new BtcWallet( + new BtcAccountBip32Deriver(network), + network, + ); return { instance, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts similarity index 90% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/psbt.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index 0b006c87..e94b6265 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -4,18 +4,16 @@ import { Psbt, Transaction } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; import ECPairFactory from 'ecpair'; -import { compactError } from '../../../utils'; -import type { IAccountSigner } from '../../../wallet'; -import { logger } from '../../logger/logger'; +import { logger } from '../../libs/logger/logger'; +import { compactError } from '../../utils'; +import type { IAccountSigner } from '../../wallet'; import { MaxStandardTxWeight } from '../constants'; import { PsbtServiceError } from './exceptions'; import type { SpendTo, Utxo } from './types'; -// import { PsbtData } from "./psbt-data"; const ECPair = ECPairFactory(ecc); export class PsbtService { - // protected readonly psbtData: PsbtData; protected readonly network: Network; protected readonly _psbt: Psbt; @@ -30,17 +28,11 @@ export class PsbtService { } else { this._psbt = psbt; } - // this.validator = new PsbtValidator(this._psbt, network); - // this._psbtData = new PsbtData(this._psbt, network); } static fromBase64(network: Network, base64Psbt: string): PsbtService { const psbt = Psbt.fromBase64(base64Psbt, { network }); const service = new PsbtService(network, psbt); - - // To make sure the psbt is created from the PSBT creator's server (The Snap) - // service.validator.validate(); - return service; } diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/signer.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.test.ts similarity index 97% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/signer.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.test.ts index a38a6bad..ebaf3083 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/signer.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.test.ts @@ -1,7 +1,7 @@ import { networks } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; -import { createMockBip32Instance } from '../../../../test/utils'; +import { createMockBip32Instance } from '../../../test/utils'; import { AccountSigner } from './signer'; describe('AccountSigner', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/signer.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts similarity index 96% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/signer.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts index 186827f9..72bb4e8e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/signer.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts @@ -2,7 +2,7 @@ import type { BIP32Interface } from 'bip32'; import type { HDSignerAsync } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; -import type { IAccountSigner } from '../../../wallet'; +import type { IAccountSigner } from '../../wallet'; export class AccountSigner implements HDSignerAsync, IAccountSigner { readonly publicKey: Buffer; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/transactionInfo.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transactionInfo.test.ts similarity index 96% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/transactionInfo.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transactionInfo.test.ts index c0820bd6..093800a3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/transactionInfo.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transactionInfo.test.ts @@ -1,6 +1,6 @@ import { networks } from 'bitcoinjs-lib'; -import { generateAccounts } from '../../../../test/utils'; +import { generateAccounts } from '../../../test/utils'; import { satsToBtc } from '../utils'; import { BtcAddress } from './address'; import { BtcAmount } from './amount'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/transactionInfo.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transactionInfo.ts similarity index 96% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/transactionInfo.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transactionInfo.ts index 0eaff759..e46a8001 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/transactionInfo.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transactionInfo.ts @@ -1,6 +1,6 @@ import type { Json } from '@metamask/snaps-sdk'; -import type { ITransactionInfo } from '../../../wallet'; +import type { ITransactionInfo } from '../../wallet'; import type { BtcAddress } from './address'; import { BtcAmount } from './amount'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts similarity index 94% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts index f9f47ab3..d57997f2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts @@ -1,7 +1,7 @@ import type { BIP32Interface } from 'bip32'; import type { Network, Payment } from 'bitcoinjs-lib'; -import type { IAccount, IAccountSigner } from '../../../wallet'; +import type { IAccount, IAccountSigner } from '../../wallet'; import type { ScriptType } from '../constants'; export type IBtcAccountDeriver = { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts similarity index 82% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index 5fb4a5e0..8ba60eb7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -1,11 +1,6 @@ -import ecc from '@bitcoinerlab/secp256k1'; -import type { SLIP10NodeInterface } from '@metamask/key-tree'; -import { BIP32Factory } from 'bip32'; import { networks } from 'bitcoinjs-lib'; -import ECPairFactory from 'ecpair'; -import { generateFormatedUtxos } from '../../../../test/utils'; -import { SnapHelper } from '../../snap'; +import { generateFormatedUtxos } from '../../../test/utils'; import { DustLimit, ScriptType } from '../constants'; import { P2SHP2WPKHAccount, P2WPKHAccount } from './account'; import { CoinSelectService } from './coin-select'; @@ -15,32 +10,11 @@ import { PsbtService } from './psbt'; import { BtcTransactionInfo } from './transactionInfo'; import { BtcWallet } from './wallet'; -describe('BtcWallet', () => { - const createMockBip32Instance = (network) => { - const ECPair = ECPairFactory(ecc); - const bip32 = BIP32Factory(ecc); - - const keyPair = ECPair.makeRandom(); - const deriver = bip32.fromSeed(keyPair.publicKey, network); - - const jsonData = { - privateKey: deriver.privateKey?.toString('hex'), - publicKey: deriver.publicKey.toString('hex'), - chainCode: deriver.chainCode.toString('hex'), - depth: deriver.depth, - index: deriver.index, - curve: 'secp256k1', - masterFingerprint: undefined, - parentFingerprint: 0, - }; - jest.spyOn(SnapHelper, 'getBip32Deriver').mockResolvedValue({ - ...jsonData, - chainCodeBytes: deriver.chainCode, - privateKeyBytes: deriver.privateKey, - publicKeyBytes: deriver.publicKey, - toJSON: jest.fn().mockReturnValue(jsonData), - } as unknown as SLIP10NodeInterface); +jest.mock('../../libs/snap/helpers'); +jest.mock('../../libs/logger/logger'); +describe('BtcWallet', () => { + const createMockDeriver = (network) => { const rootSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getRoot'); const childSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getChild'); @@ -52,11 +26,7 @@ describe('BtcWallet', () => { }; const createMockWallet = (network) => { - const { - instance: deriver, - rootSpy, - childSpy, - } = createMockBip32Instance(network); + const { instance: deriver, rootSpy, childSpy } = createMockDeriver(network); const instance = new BtcWallet(deriver, network); return { @@ -127,7 +97,7 @@ describe('BtcWallet', () => { describe('createTransaction', () => { it('creates an transaction', async () => { const network = networks.testnet; - const { instance } = createMockBip32Instance(network); + const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); const utxos = generateFormatedUtxos(account.address, 2, 10000, 10000); @@ -159,7 +129,7 @@ describe('BtcWallet', () => { it('passes correct parameter to CoinSelectService', async () => { const network = networks.testnet; - const { instance } = createMockBip32Instance(network); + const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); const utxos = generateFormatedUtxos(account.address, 2); @@ -193,7 +163,7 @@ describe('BtcWallet', () => { it('remove dist change', async () => { const network = networks.testnet; - const { instance } = createMockBip32Instance(network); + const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const chgAccount = await wallet.unlock(0, ScriptType.P2wpkh); const recipient = await wallet.unlock(1, ScriptType.P2wpkh); @@ -250,7 +220,7 @@ describe('BtcWallet', () => { it('throws `Transaction amount too small` error the transaction output is too small', async () => { const network = networks.testnet; - const { instance } = createMockBip32Instance(network); + const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); const utxos = generateFormatedUtxos(account.address, 2, 8000, 8000); @@ -269,9 +239,9 @@ describe('BtcWallet', () => { ).rejects.toThrow('Transaction amount too small'); }); - it('throws `Unable to get account script hash` error if the account script hash is undefined', async () => { + it('throws `Fail to get account script hash` error if the account script hash is undefined', async () => { const network = networks.testnet; - const { instance } = createMockBip32Instance(network); + const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); const utxos = generateFormatedUtxos(account.address, 2); @@ -291,14 +261,14 @@ describe('BtcWallet', () => { replaceable: false, }, ), - ).rejects.toThrow('Unable to get account script hash'); + ).rejects.toThrow('Fail to get account script hash'); }); }); describe('signTransaction', () => { it('signs an transaction', async () => { const network = networks.testnet; - const { instance } = createMockBip32Instance(network); + const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); const utxos = generateFormatedUtxos(account.address, 2); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts similarity index 83% rename from merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 148e634d..8234ef50 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -1,20 +1,17 @@ import type { BIP32Interface } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; -import type { TransactionIntent } from '../../../chain'; -import { bufferToString, compactError, hexToBuffer } from '../../../utils'; -import type { - IAccountSigner, - ITransactionInfo, - IWallet, -} from '../../../wallet'; +import type { TransactionIntent } from '../../chain'; +import { logger } from '../../libs/logger/logger'; +import { bufferToString, compactError, hexToBuffer } from '../../utils'; +import type { IAccountSigner, ITransactionInfo, IWallet } from '../../wallet'; import { ScriptType } from '../constants'; import { isDust } from '../utils'; import { P2WPKHAccount, P2SHP2WPKHAccount } from './account'; import { BtcAddress } from './address'; import { BtcAmount } from './amount'; import { CoinSelectService } from './coin-select'; -import { WalletError } from './exceptions'; +import { WalletError, TransactionValidationError } from './exceptions'; import { PsbtService } from './psbt'; import { AccountSigner } from './signer'; import { BtcTransactionInfo } from './transactionInfo'; @@ -85,7 +82,7 @@ export class BtcWallet implements IWallet { const { scriptType } = account; if (!scriptOutput) { - throw new WalletError('Unable to get account script hash'); + throw new WalletError('Fail to get account script hash'); } // as fee rate can be 0, we need to ensure it is at least 1 @@ -100,12 +97,28 @@ export class BtcWallet implements IWallet { }; }); + logger.info( + `[BtcWallet.createTransaction] Incoming inputs: ${JSON.stringify( + options.utxos, + null, + 2, + )}, Incoming outputs: ${JSON.stringify(spendTos, null, 2)}`, + ); + const { inputs, outputs, fee } = coinSelectService.selectCoins( options.utxos, spendTos, scriptOutput, ); + logger.info( + `[BtcWallet.createTransaction] Selected inputs: ${JSON.stringify( + inputs, + null, + 2, + )}, Selected outputs: ${JSON.stringify(outputs, null, 2)}`, + ); + const info = new BtcTransactionInfo(); info.feeRate.value = feeRate; info.txnFee.value = fee; @@ -117,6 +130,9 @@ export class BtcWallet implements IWallet { if (output.address === undefined) { // discard change output if it is dust and add to fees if (isDust(output.value, scriptType)) { + logger.info( + '[BtcWallet.createTransaction] Change is too small, adding to fees', + ); info.txnFee.value += output.value; continue; } @@ -127,7 +143,7 @@ export class BtcWallet implements IWallet { } else { // dust outputs is forbidden if (isDust(output.value, scriptType)) { - throw new WalletError('Transaction amount too small'); + throw new TransactionValidationError('Transaction amount too small'); } info.recipients.set( new BtcAddress(output.address, this.network), diff --git a/merged-packages/bitcoin-wallet-snap/src/chain.ts b/merged-packages/bitcoin-wallet-snap/src/chain.ts index c7a1dad3..01886d20 100644 --- a/merged-packages/bitcoin-wallet-snap/src/chain.ts +++ b/merged-packages/bitcoin-wallet-snap/src/chain.ts @@ -1,27 +1,7 @@ import type { Json } from '@metamask/snaps-sdk'; -import type { Infer } from 'superstruct'; -import { object, define, string, array, boolean } from 'superstruct'; import type { IAmount } from './wallet'; -const transactionIntentAmts = () => - define>( - 'transactionIntentAmts', - (value: Record) => { - if (Object.entries(value).length === 0) { - return 'Transaction must have at least one recipient'; - } - - for (const val of Object.values(value)) { - if (val <= 0) { - return 'Invalid amount for send'; - } - } - - return true; - }, - ); - export enum FeeRatio { Fast = 'fast', Medium = 'medium', @@ -61,35 +41,65 @@ export type Fees = { fees: Fee[]; }; -export const TransactionIntentStruct = object({ - amounts: transactionIntentAmts(), - subtractFeeFrom: array(string()), - replaceable: boolean(), -}); - -export type TransactionIntent = Infer; +export type TransactionIntent = { + amounts: Record; + subtractFeeFrom: string[]; + replaceable: boolean; +}; export type TransactionData = { data: Record; }; -export type Pagination = { - limit: number; - offset: number; -}; - export type CommitedTransaction = { transactionId: string; }; export type IOnChainService = { + /** + * A method to get the balances for multiple addresses and multipe assets. + * + * @param addresses - A array of the addresse to fetch. + * @param type - A array of the asset to fetch. + * @returns A promise that resolves to an AssetBalances object. + */ getBalances(addresses: string[], assets: string[]): Promise; + + /** + * A method to get the fee rate of the network. + * + * @returns A promise that resolves to an Fees object. + */ getFeeRates(): Promise; + + /** + * A method to broadcast the transaction on chain. + * + * @param signedTransaction - An signed transaction string. + * @returns A promise that resolves to an CommitedTransaction object. + */ broadcastTransaction(signedTransaction: string): Promise; - listTransactions(address: string, pagination: Pagination); + + /** + * A method to fetch the transaction status. + * + * @param txnHash - An transaction hash. + * @returns A promise that resolves to an TransactionStatusData object. + */ getTransactionStatus(txnHash: string): Promise; + + /** + * A method to fetch the require metadata to build an transaction. + * + * @param address - A address string. + * @param transactionIntent - An TransactionIntent Object. + * @returns A promise that resolves to an TransactionData object. + */ getDataForTransaction( address: string, transactionIntent?: TransactionIntent, ): Promise; + + // TODO: implement listTransactions + listTransactions(); }; diff --git a/merged-packages/bitcoin-wallet-snap/src/config/config.ts b/merged-packages/bitcoin-wallet-snap/src/config/config.ts index a71f06d4..ca1b99fd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/config.ts @@ -1,13 +1,13 @@ import type { BtcWalletConfig, BtcOnChainServiceConfig, -} from '../modules/bitcoin/config'; +} from '../bitcoin/config'; import { Network as BtcNetwork, DataClient, BtcAsset, BtcUnit, -} from '../modules/bitcoin/constants'; +} from '../bitcoin/constants'; export enum Chain { Bitcoin = 'Bitcoin', diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/factory.test.ts index 22f0ed06..c9835078 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.test.ts @@ -1,8 +1,8 @@ +import { BtcOnChainService } from './bitcoin/chain'; +import { Network } from './bitcoin/constants'; +import { BtcWallet } from './bitcoin/wallet'; import { Factory } from './factory'; import { BtcKeyring } from './keyring'; -import { BtcOnChainService } from './modules/bitcoin/chain'; -import { Network } from './modules/bitcoin/constants'; -import { BtcWallet } from './modules/bitcoin/wallet'; describe('Factory', () => { describe('createOnChainServiceProvider', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts index 37607465..f6974caa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.ts @@ -1,16 +1,16 @@ import { type Keyring } from '@metamask/keyring-api'; -import type { IOnChainService } from './chain'; -import { Config } from './config'; -import { BtcKeyring, KeyringStateManager } from './keyring'; -import { BtcOnChainService } from './modules/bitcoin/chain'; +import { BtcOnChainService } from './bitcoin/chain'; import { type BtcWalletConfig, type BtcOnChainServiceConfig, -} from './modules/bitcoin/config'; -import { DataClientFactory } from './modules/bitcoin/data-client/factory'; -import { getBtcNetwork } from './modules/bitcoin/utils'; -import { BtcWalletFactory } from './modules/bitcoin/wallet'; +} from './bitcoin/config'; +import { DataClientFactory } from './bitcoin/data-client/factory'; +import { getBtcNetwork } from './bitcoin/utils'; +import { BtcWalletFactory } from './bitcoin/wallet'; +import type { IOnChainService } from './chain'; +import { Config } from './config'; +import { BtcKeyring, KeyringStateManager } from './keyring'; import type { IWallet } from './wallet'; // TODO: Temp solution to support keyring in snap without keyring API @@ -43,7 +43,7 @@ export class Factory { return new BtcKeyring(new KeyringStateManager(), { defaultIndex: config.defaultAccountIndex, multiAccount: config.enableMultiAccounts, - // TODO: Temp solutio to support keyring in snap without keyring API + // TODO: Temp solution to support keyring in snap without keyring API emitEvents: options.emitEvents, }); } diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts index 039e8603..9c10a9f8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.ts @@ -9,22 +9,17 @@ import { import { onRpcRequest, validateOrigin, onKeyringRequest } from '.'; import { Config, originPermissions } from './config'; import { BtcKeyring } from './keyring'; -import { BaseSnapRpcHandler, type IStaticSnapRpcHandler } from './modules/rpc'; +import { BaseSnapRpcHandler, type IStaticSnapRpcHandler } from './libs/rpc'; import { RpcHelper } from './rpcs'; import type { StaticImplements } from './types/static'; +jest.mock('./libs/logger/logger'); + jest.mock('@metamask/keyring-api', () => ({ ...jest.requireActual('@metamask/keyring-api'), handleKeyringRequest: jest.fn(), })); -jest.mock('./modules/logger/logger', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - }, -})); - describe('validateOrigin', () => { it('does not throws error if the origin and method is match to the allowed list', () => { const [origin, methods]: [string, Set] = originPermissions @@ -35,15 +30,15 @@ describe('validateOrigin', () => { }); it('throws `Origin not found` error if not origin is provided', () => { - expect(() => - validateOrigin('', keyringApi.KeyringRpcMethod.GetAccountBalances), - ).toThrow('Origin not found'); + expect(() => validateOrigin('', 'chain_getTransactionStatus')).toThrow( + 'Origin not found', + ); }); it('throws `Permission denied` error if origin not match to the allowed list', () => { - expect(() => - validateOrigin('xyz', keyringApi.KeyringRpcMethod.GetAccountBalances), - ).toThrow('Permission denied'); + expect(() => validateOrigin('xyz', 'chain_getTransactionStatus')).toThrow( + 'Permission denied', + ); }); it('throws `Permission denied` error if the method is not match to the allowed list', () => { @@ -71,7 +66,7 @@ describe('onRpcRequest', () => { return onRpcRequest({ origin: 'http://localhost:8000', request: { - method: 'chain_createAccount', + method: 'chain_getTransactionStatus', params: { scope: Config.avaliableNetworks[Config.chain][0], }, @@ -83,7 +78,7 @@ describe('onRpcRequest', () => { const { handler, handleRequestSpy } = createMockChainApiHandler(); jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({ // eslint-disable-next-line @typescript-eslint/naming-convention - chain_createAccount: handler, + chain_getTransactionStatus: handler, }); handleRequestSpy.mockResolvedValueOnce({ data: 1, @@ -104,7 +99,7 @@ describe('onRpcRequest', () => { const { handler, handleRequestSpy } = createMockChainApiHandler(); jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({ // eslint-disable-next-line @typescript-eslint/naming-convention - chain_createAccount: handler, + chain_getTransactionStatus: handler, }); handleRequestSpy.mockRejectedValue(new SnapError('error')); @@ -125,7 +120,7 @@ describe('onKeyringRequest', () => { return onKeyringRequest({ origin: 'http://localhost:8000', request: { - method: keyringApi.KeyringRpcMethod.CreateAccount, + method: keyringApi.KeyringRpcMethod.ListAccounts, params: { scope: Config.avaliableNetworks[Config.chain][0], }, @@ -139,7 +134,7 @@ describe('onKeyringRequest', () => { await executeRequest(); expect(handler).toHaveBeenCalledWith(expect.any(BtcKeyring), { - method: keyringApi.KeyringRpcMethod.CreateAccount, + method: keyringApi.KeyringRpcMethod.ListAccounts, params: { scope: Config.avaliableNetworks[Config.chain][0], }, diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index a77468ad..6aa711cd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -11,8 +11,8 @@ import { import { Config } from './config'; import { originPermissions } from './config/permissions'; import { Factory } from './factory'; -import { logger } from './modules/logger/logger'; -import type { SnapRpcHandlerRequest } from './modules/rpc'; +import { logger } from './libs/logger/logger'; +import type { SnapRpcHandlerRequest } from './libs/rpc'; import { RpcHelper } from './rpcs/helpers'; import { isSnapRpcError } from './utils'; @@ -27,11 +27,13 @@ export const validateOrigin = (origin: string, method: string): void => { } }; -export const onRpcRequest: OnRpcRequestHandler = async (args) => { +export const onRpcRequest: OnRpcRequestHandler = async ({ + origin, + request, +}): Promise => { logger.logLevel = parseInt(Config.logLevel, 10); try { - const { request, origin } = args; const { method } = request; validateOrigin(origin, method); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts index 2439ed8e..27acf74c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts @@ -1,4 +1,4 @@ -import { CustomError } from '../modules/exception'; +import { CustomError } from '../libs/exception'; export class BtcKeyringError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts index cb487dfd..c2008861 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts @@ -2,10 +2,10 @@ import { MethodNotFoundError } from '@metamask/snaps-sdk'; import { unknown } from 'superstruct'; import { generateAccounts } from '../../test/utils'; +import { BtcAsset, Network } from '../bitcoin/constants'; import { Chain, Config } from '../config'; import { Factory } from '../factory'; -import { BtcAsset, Network } from '../modules/bitcoin/constants'; -import { type IStaticSnapRpcHandler, BaseSnapRpcHandler } from '../modules/rpc'; +import { type IStaticSnapRpcHandler, BaseSnapRpcHandler } from '../libs/rpc'; import { GetBalancesHandler } from '../rpcs'; import { RpcHelper } from '../rpcs/helpers'; import type { StaticImplements } from '../types/static'; @@ -14,11 +14,7 @@ import { BtcKeyringError } from './exceptions'; import { BtcKeyring } from './keyring'; import { KeyringStateManager } from './state'; -jest.mock('../modules/logger/logger', () => ({ - logger: { - info: jest.fn(), - }, -})); +jest.mock('../libs/logger/logger'); jest.mock('@metamask/keyring-api', () => ({ ...jest.requireActual('@metamask/keyring-api'), @@ -170,7 +166,7 @@ describe('BtcKeyring', () => { ).rejects.toThrow(BtcKeyringError); }); - it('throws `Invalid params to create account` if the create options is invalid', async () => { + it('throws `Invalid params to create an account` if the create options is invalid', async () => { const { instance: stateMgr } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); @@ -178,7 +174,7 @@ describe('BtcKeyring', () => { keyring.createAccount({ scope: 'invalid', }), - ).rejects.toThrow(`Invalid params to create account`); + ).rejects.toThrow(`Invalid params to create an account`); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts index 1448e03a..a9e2a446 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts @@ -14,9 +14,9 @@ import { v4 as uuidv4 } from 'uuid'; import { Config } from '../config'; import { Factory } from '../factory'; -import { logger } from '../modules/logger/logger'; -import type { SnapRpcHandlerRequest } from '../modules/rpc'; -import { SnapHelper } from '../modules/snap'; +import { logger } from '../libs/logger/logger'; +import type { SnapRpcHandlerRequest } from '../libs/rpc'; +import { SnapHelper } from '../libs/snap'; import { GetBalancesHandler } from '../rpcs'; import { RpcHelper } from '../rpcs/helpers'; import type { IAccount, IWallet } from '../wallet'; @@ -108,7 +108,7 @@ export class BtcKeyring implements Keyring { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[BtcKeyring.createAccount] Error: ${error.message}`); if (error instanceof StructError) { - throw new BtcKeyringError('Invalid params to create account'); + throw new BtcKeyringError('Invalid params to create an account'); } throw new BtcKeyringError(error); } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts index d7e43f73..eeddcc04 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts @@ -1,6 +1,6 @@ import { generateAccounts } from '../../test/utils'; -import { Network } from '../modules/bitcoin/constants'; -import { SnapHelper, StateError } from '../modules/snap'; +import { Network } from '../bitcoin/constants'; +import { SnapHelper, StateError } from '../libs/snap'; import { KeyringStateManager } from './state'; describe('KeyringStateManager', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/state.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/state.ts index 3587b1d1..7a0ec24a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/state.ts @@ -1,6 +1,6 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import { SnapStateManager, StateError } from '../modules/snap'; +import { SnapStateManager, StateError } from '../libs/snap'; import { compactError } from '../utils'; import type { Wallet, SnapState } from './types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts index 76d310b5..acae60e9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts @@ -3,7 +3,7 @@ import { type Json } from '@metamask/snaps-sdk'; import type { Infer } from 'superstruct'; import { object } from 'superstruct'; -import type { IStaticSnapRpcHandler } from '../modules/rpc'; +import type { IStaticSnapRpcHandler } from '../libs/rpc'; import { scopeStruct } from '../utils'; export type Wallet = { diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/exception/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/libs/exception/exceptions.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/exception/exceptions.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/exception/exceptions.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/exception/index.ts b/merged-packages/bitcoin-wallet-snap/src/libs/exception/index.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/exception/index.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/exception/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/logger/__mocks__/logger.ts b/merged-packages/bitcoin-wallet-snap/src/libs/logger/__mocks__/logger.ts new file mode 100644 index 00000000..12595b73 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/libs/logger/__mocks__/logger.ts @@ -0,0 +1,17 @@ +export class Logger { + log = jest.fn(); + + warn = jest.fn(); + + error = jest.fn(); + + debug = jest.fn(); + + info = jest.fn(); + + trace = jest.fn(); + + logLevel = 0; +} + +export const logger = new Logger(); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/logger/logger.test.ts b/merged-packages/bitcoin-wallet-snap/src/libs/logger/logger.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/logger/logger.test.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/logger/logger.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/logger/logger.ts b/merged-packages/bitcoin-wallet-snap/src/libs/logger/logger.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/logger/logger.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/logger/logger.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/rpc/base.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts similarity index 92% rename from merged-packages/bitcoin-wallet-snap/src/modules/rpc/base.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts index d051c457..656bf7fe 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/rpc/base.ts +++ b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts @@ -4,7 +4,6 @@ import { type Struct, assert } from 'superstruct'; import { logger } from '../logger/logger'; import { InvalidSnapRpcResponseError } from './exceptions'; import { - type ISnapRpcExecutable, type SnapRpcHandlerOptions, type ISnapRpcHandler, type IStaticSnapRpcHandler, @@ -13,7 +12,7 @@ import { SnapRpcHandlerRequestStruct, } from './types'; -export abstract class BaseSnapRpcHandler implements ISnapRpcExecutable { +export abstract class BaseSnapRpcHandler { static instance: ISnapRpcHandler | null = null; static readonly requestStruct: Struct = SnapRpcHandlerRequestStruct; @@ -59,9 +58,7 @@ export abstract class BaseSnapRpcHandler implements ISnapRpcExecutable { } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[SnapRpcHandler.postExecute] Error: ${error.message}`); - throw new InvalidSnapRpcResponseError( - 'Invalid Response', - ) as unknown as Error; + throw new InvalidSnapRpcResponseError('Invalid Response'); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/rpc/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/exceptions.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/rpc/exceptions.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/rpc/exceptions.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/rpc/index.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/index.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/rpc/index.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/rpc/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/types.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/types.ts new file mode 100644 index 00000000..a326f49e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/types.ts @@ -0,0 +1,56 @@ +import type { Json } from '@metamask/snaps-sdk'; +import { object, type Struct, type Infer } from 'superstruct'; + +import { scopeStruct } from '../../utils'; + +export const SnapRpcHandlerRequestStruct = object({ + scope: scopeStruct, +}); + +export type SnapRpcHandlerRequest = Json & + Infer; + +export type SnapRpcHandlerResponse = Json; + +export type SnapRpcHandlerOptions = Record | null; + +export type IStaticSnapRpcHandler = { + /** + * Superstruct for the request. + */ + requestStruct: Struct; + /** + * Superstruct for the response. + */ + reponseStruct?: Struct; + + /** + * A method to create a new instance of the rpc handler. + * + * @param options - An optional parameter to create the instance. + * @returns An handler object. + */ + new (options?: SnapRpcHandlerOptions): ISnapRpcHandler; + + /** + * A method to return the instance object of the rpc handler. + * + * @param this - The static instance of the handler class. + * @param options - An optional parameter to create the instance. + * @returns An handler object. + */ + getInstance( + this: IStaticSnapRpcHandler, + options?: SnapRpcHandlerOptions, + ): ISnapRpcHandler; +}; + +export type ISnapRpcHandler = { + /** + * A method to execute the rpc method. + * + * @param params - An struct contains the require parameter for the request. + * @returns A promise that resolves to an json. + */ + execute(params: SnapRpcHandlerRequest): Promise; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/__mocks__/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/__mocks__/helpers.ts new file mode 100644 index 00000000..b8b5abff --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/libs/snap/__mocks__/helpers.ts @@ -0,0 +1,25 @@ +import { type SLIP10NodeInterface } from '@metamask/key-tree'; +import { networks } from 'bitcoinjs-lib'; + +import { createRandomBip32Data } from '../../../../test/utils'; + +export class SnapHelper { + static getBip44Deriver = jest.fn(); + + static async getBip32Deriver( + path: string[], + curve: 'secp256k1' | 'ed25519', + ): Promise { + const { data } = createRandomBip32Data(networks.bitcoin, path, curve); + return { + ...data, + toJSON: jest.fn().mockReturnValue(data), + } as SLIP10NodeInterface; + } + + static confirmDialog = jest.fn(); + + static getStateData = jest.fn(); + + static setStateData = jest.fn(); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/exceptions.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/snap/exceptions.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/snap/exceptions.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.test.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.test.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.ts similarity index 96% rename from merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.ts index 67868771..e22c3bad 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/snap/helpers.ts +++ b/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.ts @@ -1,6 +1,6 @@ -import type { BIP44AddressKeyDeriver } from '@metamask/key-tree'; import { getBIP44AddressKeyDeriver, + type BIP44AddressKeyDeriver, type SLIP10NodeInterface, } from '@metamask/key-tree'; import type { Component, DialogResult, Json } from '@metamask/snaps-sdk'; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/index.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/index.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/snap/index.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/snap/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/lock.test.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/snap/lock.test.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/lock.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/snap/lock.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.test.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.test.ts similarity index 99% rename from merged-packages/bitcoin-wallet-snap/src/modules/snap/state.test.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/snap/state.test.ts index 10741303..1169876c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.test.ts @@ -5,11 +5,7 @@ import { SnapHelper } from './helpers'; import { MutexLock } from './lock'; import { SnapStateManager } from './state'; -jest.mock('../logger/logger', () => ({ - logger: { - info: jest.fn(), - }, -})); +jest.mock('../logger/logger'); type MockTransactionDetail = { txnHash: string; diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/modules/snap/state.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/snap/state.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/deriver.test.ts b/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/deriver.test.ts deleted file mode 100644 index a2580f95..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/bitcoin/wallet/deriver.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -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 * as strUtils from '../../../utils/string'; -import { SnapHelper } from '../../snap'; -import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; - -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('createBip32FromSeed', () => { - it('returns an BIP32Interface', () => { - const network = networks.testnet; - const { fromSeedSpy } = createMockBip32Factory(); - const seed = Buffer.from( - 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', - 'hex', - ); - - const deriver = new BtcAccountBip32Deriver(network); - deriver.createBip32FromSeed(seed); - - expect(fromSeedSpy).toHaveBeenCalledWith(seed, network); - }); - - it('throws `Unable to construct BIP32 node from seed` if an error catched', () => { - const network = networks.testnet; - const { fromSeedSpy } = createMockBip32Factory(); - const seed = Buffer.from( - 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', - 'hex', - ); - - fromSeedSpy.mockImplementation(() => { - throw new Error('error'); - }); - - const deriver = new BtcAccountBip32Deriver(network); - - expect(() => deriver.createBip32FromSeed(seed)).toThrow( - 'Unable to construct BIP32 node from seed', - ); - }); - }); - - describe('createBip32FromPrivateKey', () => { - 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.createBip32FromPrivateKey(privateKey, chainCode); - - expect(fromPrivateKeySpy).toHaveBeenCalledWith( - privateKey, - chainCode, - network, - ); - }); - - it('throws `Unable to construct BIP32 node from private key` if an error catched', () => { - const network = networks.testnet; - const { fromPrivateKeySpy } = createMockBip32Factory(); - const privateKey = Buffer.from( - 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', - 'hex', - ); - const chainCode = Buffer.from( - 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', - 'hex', - ); - - fromPrivateKeySpy.mockImplementation(() => { - throw new Error('error'); - }); - - const deriver = new BtcAccountBip32Deriver(network); - - expect(() => - deriver.createBip32FromPrivateKey(privateKey, chainCode), - ).toThrow('Unable to construct BIP32 node from private key'); - }); - }); - - 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 `Private key is invalid` if private key is invalid', async () => { - const network = networks.testnet; - const path = ['m', "84'", "0'"]; - createMockBip32Entropy(); - const spy = jest.spyOn(strUtils, 'hexToBuffer'); - spy.mockImplementation(() => { - throw new Error('error'); - }); - const deriver = new BtcAccountBip32Deriver(network); - - await expect(deriver.getRoot(path)).rejects.toThrow( - 'Private key is invalid', - ); - }); - - it('throws `Chain code is invalid` if chain code is invalid', async () => { - const network = networks.testnet; - const path = ['m', "84'", "0'"]; - createMockBip32Entropy(); - const spy = jest.spyOn(strUtils, 'hexToBuffer'); - spy - .mockImplementationOnce((val: string) => Buffer.from(val, 'hex')) - .mockImplementationOnce(() => { - throw new Error('error'); - }); - const deriver = new BtcAccountBip32Deriver(network); - - await expect(deriver.getRoot(path)).rejects.toThrow( - 'Chain code is invalid', - ); - }); - - 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', - ); - }); - }); -}); - -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 `Deriver private key is missing` error 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', - ); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/modules/rpc/types.ts b/merged-packages/bitcoin-wallet-snap/src/modules/rpc/types.ts deleted file mode 100644 index 6c93621d..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/modules/rpc/types.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Json } from '@metamask/snaps-sdk'; -import type { Infer } from 'superstruct'; -import { object, type Struct } from 'superstruct'; - -import { scopeStruct } from '../../utils'; - -export const SnapRpcHandlerRequestStruct = object({ - scope: scopeStruct, -}); - -export type SnapRpcHandlerRequest = Json & - Infer; - -export type SnapRpcHandlerResponse = Json; - -export type SnapRpcHandlerOptions = Record | null; - -export type IStaticSnapRpcHandler = { - requestStruct: Struct; - reponseStruct?: Struct; - instance: ISnapRpcHandler | null; - new (options?: SnapRpcHandlerOptions): ISnapRpcHandler; - getInstance( - this: IStaticSnapRpcHandler, - options?: SnapRpcHandlerOptions, - ): ISnapRpcHandler; -}; - -export type ISnapRpcValidator = { - validate(params: SnapRpcHandlerRequest): void; -}; - -export type ISnapRpcExecutable = { - execute(params: SnapRpcHandlerRequest): Promise; -}; - -export type ISnapRpcHandler = { - handleRequest(params: SnapRpcHandlerRequest): Promise; -} & ISnapRpcExecutable; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts index 8ebe98e4..166de9bb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts @@ -3,14 +3,11 @@ import type { Infer } from 'superstruct'; import { Config } from '../config'; import { BtcKeyring, KeyringStateManager } from '../keyring'; -import { - SnapRpcHandlerRequestStruct, - BaseSnapRpcHandler, -} from '../modules/rpc'; +import { SnapRpcHandlerRequestStruct, BaseSnapRpcHandler } from '../libs/rpc'; import type { IStaticSnapRpcHandler, SnapRpcHandlerResponse, -} from '../modules/rpc'; +} from '../libs/rpc'; import type { StaticImplements } from '../types/static'; export type CreateAccountParams = Infer< diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts index 7f71428d..96a1508d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts @@ -1,29 +1,25 @@ -import ecc from '@bitcoinerlab/secp256k1'; -import type { SLIP10NodeInterface } from '@metamask/key-tree'; import type { KeyringAccount } from '@metamask/keyring-api'; import { InvalidParamsError } from '@metamask/snaps-sdk'; -import { BIP32Factory } from 'bip32'; import { networks } from 'bitcoinjs-lib'; -import ECPairFactory from 'ecpair'; import { v4 as uuidv4 } from 'uuid'; import { generateAccounts } from '../../test/utils'; +import { Network, ScriptType } from '../bitcoin/constants'; +import { + BtcAccountBip32Deriver, + BtcWallet, + BtcAmount, +} from '../bitcoin/wallet'; import { Config } from '../config'; import { Factory } from '../factory'; -import { BtcAsset, Network, ScriptType } from '../modules/bitcoin/constants'; -import { BtcAccountBip32Deriver, BtcWallet } from '../modules/bitcoin/wallet'; -import { BtcAmount } from '../modules/bitcoin/wallet/amount'; -import { SnapHelper } from '../modules/snap'; import { GetBalancesHandler } from './get-balances'; -jest.mock('../modules/logger/logger', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - }, -})); +jest.mock('../libs/logger/logger'); +jest.mock('../libs/snap/helpers'); describe('GetBalancesHandler', () => { + const asset = Config.avaliableAssets[Config.chain][0]; + describe('handleRequest', () => { const createMockChainApiFactory = () => { const getBalancesSpy = jest.fn(); @@ -41,31 +37,7 @@ describe('GetBalancesHandler', () => { }; }; - const createMockBip32Instance = (network) => { - const ECPair = ECPairFactory(ecc); - const bip32 = BIP32Factory(ecc); - - const keyPair = ECPair.makeRandom(); - const deriver = bip32.fromSeed(keyPair.publicKey, network); - - const jsonData = { - privateKey: deriver.privateKey?.toString('hex'), - publicKey: deriver.publicKey.toString('hex'), - chainCode: deriver.chainCode.toString('hex'), - depth: deriver.depth, - index: deriver.index, - curve: 'secp256k1', - masterFingerprint: undefined, - parentFingerprint: 0, - }; - jest.spyOn(SnapHelper, 'getBip32Deriver').mockResolvedValue({ - ...jsonData, - chainCodeBytes: deriver.chainCode, - privateKeyBytes: deriver.privateKey, - publicKeyBytes: deriver.publicKey, - toJSON: jest.fn().mockReturnValue(jsonData), - } as unknown as SLIP10NodeInterface); - + const createMockDeriver = (network) => { const rootSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getRoot'); const childSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getChild'); @@ -77,7 +49,7 @@ describe('GetBalancesHandler', () => { }; const createMockAccount = async (network, caip2Network) => { - const { instance } = createMockBip32Instance(network); + const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const sender = await wallet.unlock(0, ScriptType.P2wpkh); const keyringAccount = { @@ -116,7 +88,7 @@ describe('GetBalancesHandler', () => { const mockResp = { balances: addresses.reduce((acc, address) => { acc[address] = { - [BtcAsset.TBtc]: { + [asset]: { amount: new BtcAmount(100), }, }; @@ -125,7 +97,7 @@ describe('GetBalancesHandler', () => { }; const expected = { - [BtcAsset.TBtc]: { + [asset]: { amount: '0.00000100', unit: Config.unit[Config.chain], }, @@ -135,10 +107,10 @@ describe('GetBalancesHandler', () => { const result = await GetBalancesHandler.getInstance(walletData).execute({ scope: walletData.scope, - assets: [BtcAsset.TBtc], + assets: [asset], }); - expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [BtcAsset.TBtc]); + expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [asset]); expect(result).toStrictEqual(expected); }); @@ -156,7 +128,7 @@ describe('GetBalancesHandler', () => { ...accounts.map((account) => account.address), ].reduce((acc, address) => { acc[address] = { - [BtcAsset.TBtc]: { + [asset]: { amount: new BtcAmount(100), }, 'some-asset': { @@ -168,7 +140,7 @@ describe('GetBalancesHandler', () => { }; const expected = { - [BtcAsset.TBtc]: { + [asset]: { amount: '0.00000100', unit: Config.unit[Config.chain], }, @@ -178,13 +150,29 @@ describe('GetBalancesHandler', () => { const result = await GetBalancesHandler.getInstance(walletData).execute({ scope: Network.Testnet, - assets: [BtcAsset.TBtc], + assets: [asset], }); - expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [BtcAsset.TBtc]); + expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [asset]); expect(result).toStrictEqual(expected); }); + it('throws `Fail to get the balances` when transaction status fetch failed', async () => { + const network = networks.testnet; + const caip2Network = Network.Testnet; + const { getBalancesSpy } = createMockChainApiFactory(); + const { walletData } = await createMockAccount(network, caip2Network); + + getBalancesSpy.mockRejectedValue(new Error('error')); + + await expect( + GetBalancesHandler.getInstance(walletData).execute({ + scope: Network.Testnet, + assets: [asset], + }), + ).rejects.toThrow(`Fail to get the balances`); + }); + it('throws `Request params is invalid` when request parameter is not correct', async () => { const network = networks.testnet; const caip2Network = Network.Testnet; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts index 1318b4a1..60c698f7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts @@ -4,11 +4,11 @@ import { object, assign, array, record, enums } from 'superstruct'; import { Config } from '../config'; import { Factory } from '../factory'; import { type Wallet as WalletData } from '../keyring'; -import { SnapRpcHandlerRequestStruct } from '../modules/rpc'; +import { SnapRpcError, SnapRpcHandlerRequestStruct } from '../libs/rpc'; import type { IStaticSnapRpcHandler, SnapRpcHandlerResponse, -} from '../modules/rpc'; +} from '../libs/rpc'; import type { StaticImplements } from '../types/static'; import { assetsStruct, positiveStringStruct } from '../utils/superstruct'; import type { IAmount } from '../wallet'; @@ -51,45 +51,49 @@ export class GetBalancesHandler } async handleRequest(params: GetBalancesParams): Promise { - const { scope, assets } = params; + try { + const { scope, assets } = params; - const chainApi = Factory.createOnChainServiceProvider(scope); - const addresses = [this.walletAccount.address]; - const addressesSet = new Set(addresses); - const assetsSet = new Set(assets); + const chainApi = Factory.createOnChainServiceProvider(scope); + const addresses = [this.walletAccount.address]; + const addressesSet = new Set(addresses); + const assetsSet = new Set(assets); - const balances = await chainApi.getBalances(addresses, assets); + const balances = await chainApi.getBalances(addresses, assets); - const balancesVals = Object.entries(balances.balances); - const balancesMap = new Map(); + const balancesVals = Object.entries(balances.balances); + const balancesMap = new Map(); - for (const [address, assetBalances] of balancesVals) { - if (!addressesSet.has(address)) { - continue; - } - for (const asset in assetBalances) { - if (!assetsSet.has(asset)) { + for (const [address, assetBalances] of balancesVals) { + if (!addressesSet.has(address)) { continue; } + for (const asset in assetBalances) { + if (!assetsSet.has(asset)) { + continue; + } - const { amount } = assetBalances[asset]; - const currentAmount = balancesMap.get(asset); - if (currentAmount) { - currentAmount.value += amount.value; - } + const { amount } = assetBalances[asset]; + const currentAmount = balancesMap.get(asset); + if (currentAmount) { + currentAmount.value += amount.value; + } - balancesMap.set(asset, currentAmount ?? amount); + balancesMap.set(asset, currentAmount ?? amount); + } } - } - return Object.fromEntries( - [...balancesMap.entries()].map(([asset, amount]) => [ - asset, - { - amount: amount.toString(), - unit: amount.unit, - }, - ]), - ); + return Object.fromEntries( + [...balancesMap.entries()].map(([asset, amount]) => [ + asset, + { + amount: amount.toString(), + unit: amount.unit, + }, + ]), + ); + } catch (error) { + throw new SnapRpcError('Fail to get the balances'); + } } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts index a8df6a93..5fcd4f6b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts @@ -1,16 +1,11 @@ import { InvalidParamsError } from '@metamask/snaps-sdk'; +import { Network } from '../bitcoin/constants'; import { TransactionStatus } from '../chain'; import { Factory } from '../factory'; -import { Network } from '../modules/bitcoin/constants'; import { GetTransactionStatusHandler } from './get-transaction-status'; -jest.mock('../modules/logger/logger', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - }, -})); +jest.mock('../libs/logger/logger'); describe('GetBalancesHandler', () => { const txnHash = @@ -53,6 +48,19 @@ describe('GetBalancesHandler', () => { }); }); + it('throws `Fail to get the transaction status` when transaction status fetch failed', async () => { + const { getTransactionStatusSpy } = createMockChainApiFactory(); + + getTransactionStatusSpy.mockRejectedValue(new Error('error')); + + await expect( + GetTransactionStatusHandler.getInstance().execute({ + scope: Network.Testnet, + transactionId: txnHash, + }), + ).rejects.toThrow(`Fail to get the transaction status`); + }); + it('throws `Request params is invalid` when request parameter is not correct', async () => { await expect( GetTransactionStatusHandler.getInstance().execute({ diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts index e9f32428..43474a33 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts @@ -6,11 +6,12 @@ import { Factory } from '../factory'; import { SnapRpcHandlerRequestStruct, BaseSnapRpcHandler, -} from '../modules/rpc'; + SnapRpcError, +} from '../libs/rpc'; import type { IStaticSnapRpcHandler, SnapRpcHandlerResponse, -} from '../modules/rpc'; +} from '../libs/rpc'; import type { StaticImplements } from '../types/static'; export type GetTransactionStatusParams = Infer< @@ -43,14 +44,18 @@ export class GetTransactionStatusHandler async handleRequest( params: GetTransactionStatusParams, ): Promise { - const { scope, transactionId } = params; + try { + const { scope, transactionId } = params; - const chainApi = Factory.createOnChainServiceProvider(scope); + const chainApi = Factory.createOnChainServiceProvider(scope); - const resp = await chainApi.getTransactionStatus(transactionId); + const resp = await chainApi.getTransactionStatus(transactionId); - return { - status: resp.status, - }; + return { + status: resp.status, + }; + } catch (error) { + throw new SnapRpcError('Fail to get the transaction status'); + } } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts index 3de34bde..e567e284 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts @@ -1,5 +1,5 @@ import { CreateAccountHandler } from '.'; -import type { IStaticSnapRpcHandler } from '../modules/rpc'; +import type { IStaticSnapRpcHandler } from '../libs/rpc'; import { GetTransactionStatusHandler } from './get-transaction-status'; import { SendManyHandler } from './sendmany'; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts index 932a55e3..50d653c6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts @@ -1,7 +1,7 @@ import { Factory } from '../factory'; import { type Wallet as WalletData } from '../keyring'; -import { BaseSnapRpcHandler } from '../modules/rpc'; -import type { SnapRpcHandlerRequest } from '../modules/rpc'; +import { BaseSnapRpcHandler } from '../libs/rpc'; +import type { SnapRpcHandlerRequest } from '../libs/rpc'; import type { IAccount, IWallet } from '../wallet'; export abstract class KeyringRpcHandler extends BaseSnapRpcHandler { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 8d8be87e..1156299f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -1,38 +1,33 @@ -import ecc from '@bitcoinerlab/secp256k1'; -import type { SLIP10NodeInterface } from '@metamask/key-tree'; import { InvalidParamsError, UserRejectedRequestError, } from '@metamask/snaps-sdk'; -import { BIP32Factory } from 'bip32'; import { networks } from 'bitcoinjs-lib'; -import ECPairFactory from 'ecpair'; import { v4 as uuidv4 } from 'uuid'; import { generateBlockChairBroadcastTransactionResp, generateBlockChairGetUtxosResp, } from '../../test/utils'; +import { DustLimit, Network, ScriptType } from '../bitcoin/constants'; +import { satsToBtc } from '../bitcoin/utils/unit'; +import type { IBtcAccount } from '../bitcoin/wallet'; +import { + BtcAccountBip32Deriver, + BtcWallet, + BtcAmount, + BtcAddress, + BtcTransactionInfo, +} from '../bitcoin/wallet'; import { FeeRatio } from '../chain'; import { Factory } from '../factory'; -import { DustLimit, Network, ScriptType } from '../modules/bitcoin/constants'; -import { satsToBtc } from '../modules/bitcoin/utils/unit'; -import type { IBtcAccount } from '../modules/bitcoin/wallet'; -import { BtcAccountBip32Deriver, BtcWallet } from '../modules/bitcoin/wallet'; -import { BtcAddress } from '../modules/bitcoin/wallet/address'; -import { BtcAmount } from '../modules/bitcoin/wallet/amount'; -import { BtcTransactionInfo } from '../modules/bitcoin/wallet/transactionInfo'; -import { SnapHelper } from '../modules/snap'; +import { SnapHelper } from '../libs/snap'; import type { IAccount } from '../wallet'; import { SendManyHandler } from './sendmany'; import type { SendManyParams } from './sendmany'; -jest.mock('../modules/logger/logger', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - }, -})); +jest.mock('../libs/logger/logger'); +jest.mock('../libs/snap/helpers'); describe('SendManyHandler', () => { describe('handleRequest', () => { @@ -56,38 +51,9 @@ describe('SendManyHandler', () => { }; }; - const createMockBip32Instance = (network) => { - const ECPair = ECPairFactory(ecc); - const bip32 = BIP32Factory(ecc); - - const keyPair = ECPair.makeRandom(); - const deriver = bip32.fromSeed(keyPair.publicKey, network); - - const jsonData = { - privateKey: deriver.privateKey?.toString('hex'), - publicKey: deriver.publicKey.toString('hex'), - chainCode: deriver.chainCode.toString('hex'), - depth: deriver.depth, - index: deriver.index, - curve: 'secp256k1', - masterFingerprint: undefined, - parentFingerprint: 0, - }; - jest.spyOn(SnapHelper, 'getBip32Deriver').mockResolvedValue({ - ...jsonData, - chainCodeBytes: deriver.chainCode, - privateKeyBytes: deriver.privateKey, - publicKeyBytes: deriver.publicKey, - toJSON: jest.fn().mockReturnValue(jsonData), - } as unknown as SLIP10NodeInterface); - - const rootSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getRoot'); - const childSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getChild'); - + const createMockDeriver = (network) => { return { instance: new BtcAccountBip32Deriver(network), - rootSpy, - childSpy, }; }; @@ -96,9 +62,10 @@ describe('SendManyHandler', () => { caip2Network: string, recipientCnt: number, ) => { - const { instance } = createMockBip32Instance(network); + const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const sender = await wallet.unlock(0, ScriptType.P2wpkh); + const keyringAccount = { type: sender.type, id: uuidv4(), @@ -353,7 +320,7 @@ describe('SendManyHandler', () => { ).rejects.toThrow('Transaction must have at least one recipient'); }); - it('throws `No fee rates available` error if no fee rate returns from chain service', async () => { + it('throws `Failed to send the transaction` error if no fee rate returns from chain service', async () => { const { getFeeRatesSpy } = createMockChainApiFactory(); const network = networks.testnet; const caip2Network = Network.Testnet; @@ -372,7 +339,7 @@ describe('SendManyHandler', () => { index: 0, account: keyringAccount, }).execute(createSendManyParams(recipients, caip2Network, false)), - ).rejects.toThrow('No fee rates available'); + ).rejects.toThrow('Failed to send the transaction'); }); it('throws `Invalid amount for send` error if sending amount is <= 0', async () => { @@ -436,7 +403,7 @@ describe('SendManyHandler', () => { ).rejects.toThrow(UserRejectedRequestError); }); - it('throws `Failed to commit transaction on chain` error if the transaction is fail to commit', async () => { + it('throws `Failed to send the transaction` error if the transaction is fail to commit', async () => { const network = networks.testnet; const caip2Network = Network.Testnet; const { broadcastTransactionSpy, keyringAccount, recipients } = @@ -449,7 +416,7 @@ describe('SendManyHandler', () => { index: 0, account: keyringAccount, }).execute(createSendManyParams(recipients, caip2Network, false)), - ).rejects.toThrow('Failed to commit transaction on chain'); + ).rejects.toThrow('Failed to send the transaction'); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 722eb068..78090c60 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -1,3 +1,4 @@ +import { BtcP2wpkhAddressStruct } from '@metamask/keyring-api'; import type { Component } from '@metamask/snaps-sdk'; import { UserRejectedRequestError, @@ -14,23 +15,19 @@ import { record, array, boolean, - assert, + refine, } from 'superstruct'; -import { - TransactionIntentStruct, - type IOnChainService, - type TransactionIntent, -} from '../chain'; +import { btcToSats } from '../bitcoin/utils'; +import { TransactionValidationError } from '../bitcoin/wallet'; +import { type TransactionIntent } from '../chain'; import { Factory } from '../factory'; import { type Wallet as WalletData } from '../keyring'; -import { btcToSats } from '../modules/bitcoin/utils'; -import { logger } from '../modules/logger/logger'; -import { SnapRpcHandlerRequestStruct } from '../modules/rpc'; -import type { IStaticSnapRpcHandler } from '../modules/rpc'; -import { SnapHelper } from '../modules/snap'; +import { logger } from '../libs/logger/logger'; +import { SnapRpcHandlerRequestStruct } from '../libs/rpc'; +import type { IStaticSnapRpcHandler } from '../libs/rpc'; +import { SnapHelper } from '../libs/snap'; import type { StaticImplements } from '../types/static'; -import { positiveStringStruct } from '../utils'; import type { IAccount, ITransactionInfo, IWallet } from '../wallet'; import { KeyringRpcHandler } from './keyring-rpc'; @@ -38,6 +35,26 @@ export type SendManyParams = Infer; export type SendManyResponse = Infer; +export const TransactionAmountStuct = refine( + record(BtcP2wpkhAddressStruct, string()), + 'TransactionAmountStuct', + (value: Record) => { + if (Object.entries(value).length === 0) { + return 'Transaction must have at least one recipient'; + } + + for (const val of Object.values(value)) { + const parsedVal = parseFloat(val); + // TODO: do we need to check for max values, max decimals? + if (Number.isNaN(parsedVal) || parsedVal <= 0) { + return 'Invalid amount for send'; + } + } + + return true; + }, +); + export type TxnJson = { feeRate: string; txnFee: string; @@ -76,9 +93,9 @@ export class SendManyHandler static override get requestStruct() { return assign( object({ - amounts: record(string(), positiveStringStruct), + amounts: TransactionAmountStuct, comment: string(), - subtractFeeFrom: array(string()), + subtractFeeFrom: array(BtcP2wpkhAddressStruct), replaceable: boolean(), dryrun: boolean(), }), @@ -95,61 +112,72 @@ export class SendManyHandler protected override async preExecute(params: SendManyParams): Promise { await super.preExecute(params); const transactionIntent = this.formatTxnIndents(params); - try { - assert(transactionIntent, TransactionIntentStruct); - } catch (error) { - this.throwValidationError(error.message); - } this.transactionIntent = transactionIntent; } async handleRequest(params: SendManyParams): Promise { - const { scope } = this.walletData; - const { dryrun } = params; - const chainApi = Factory.createOnChainServiceProvider(scope); + try { + const { scope } = this.walletData; + const { dryrun } = params; + const chainApi = Factory.createOnChainServiceProvider(scope); - const feesResp = await chainApi.getFeeRates(); + const feesResp = await chainApi.getFeeRates(); - if (feesResp.fees.length === 0) { - throw new Error('No fee rates available'); - } + if (feesResp.fees.length === 0) { + throw new Error('No fee rates available'); + } - const fee = Math.max(feesResp.fees[feesResp.fees.length - 1].rate.value, 1); + const fee = Math.max( + feesResp.fees[feesResp.fees.length - 1].rate.value, + 1, + ); - const metadata = await chainApi.getDataForTransaction( - this.walletAccount.address, - this.transactionIntent, - ); + const metadata = await chainApi.getDataForTransaction( + this.walletAccount.address, + this.transactionIntent, + ); - const { txn, txnInfo } = await this.wallet.createTransaction( - this.walletAccount, - this.transactionIntent, - { - utxos: metadata.data.utxos, - fee, - subtractFeeFrom: params.subtractFeeFrom, - replaceable: params.replaceable, - }, - ); + const { txn, txnInfo } = await this.wallet.createTransaction( + this.walletAccount, + this.transactionIntent, + { + utxos: metadata.data.utxos, + fee, + subtractFeeFrom: params.subtractFeeFrom, + replaceable: params.replaceable, + }, + ); - if (!(await this.getTxnConsensus(txnInfo, params.comment))) { - throw new UserRejectedRequestError() as unknown as Error; - } + if (!(await this.getTxnConsensus(txnInfo, params.comment))) { + throw new UserRejectedRequestError() as unknown as Error; + } - const txnHash = await this.wallet.signTransaction( - this.walletAccount.signer, - txn, - ); + const txnHash = await this.wallet.signTransaction( + this.walletAccount.signer, + txn, + ); + + if (dryrun) { + return { + txId: txnHash, + }; + } + + const result = await chainApi.broadcastTransaction(txnHash); - if (dryrun) { return { - txId: txnHash, + txId: result.transactionId, }; + } catch (error) { + logger.error('Failed to send the transaction', error); + if ( + error instanceof TransactionValidationError || + error instanceof UserRejectedRequestError + ) { + throw error as unknown as Error; + } + throw new Error('Failed to send the transaction'); } - - return { - txId: await this.broadcastTransaction(chainApi, txnHash), - }; } protected formatTxnIndents(params: SendManyParams): TransactionIntent { @@ -218,16 +246,4 @@ export class SendManyHandler return (await SnapHelper.confirmDialog(components)) as boolean; } - - protected async broadcastTransaction( - chainApi: IOnChainService, - txnHash: string, - ): Promise { - try { - return (await chainApi.broadcastTransaction(txnHash)).transactionId; - } catch (error) { - logger.error('Failed to broadcast transaction', error); - throw new Error('Failed to commit transaction on chain'); - } - } } diff --git a/merged-packages/bitcoin-wallet-snap/test/wallet.mock.test.ts b/merged-packages/bitcoin-wallet-snap/test/snap-provider.mock.ts similarity index 89% rename from merged-packages/bitcoin-wallet-snap/test/wallet.mock.test.ts rename to merged-packages/bitcoin-wallet-snap/test/snap-provider.mock.ts index 0e1af9fc..ccaa5eef 100644 --- a/merged-packages/bitcoin-wallet-snap/test/wallet.mock.test.ts +++ b/merged-packages/bitcoin-wallet-snap/test/snap-provider.mock.ts @@ -1,4 +1,4 @@ -export type Wallet = { +export type SnapProvider = { registerRpcMessageHandler: (fn) => unknown; request(options: { method: string; @@ -6,13 +6,14 @@ export type Wallet = { }): unknown; }; -export class WalletMock implements Wallet { +export class MockSnapProvider implements SnapProvider { public readonly registerRpcMessageHandler = jest.fn(); public readonly requestStub = jest.fn(); /* eslint-disable */ public readonly rpcStubs = { snap_getBip32Entropy: jest.fn(), + snap_getBip44Entropy: jest.fn(), snap_dialog: jest.fn(), snap_manageState: jest.fn(), }; diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index 5c0e7144..973a5786 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -1,9 +1,11 @@ -import type { BIP32Interface } from 'bip32'; +import ecc from '@bitcoinerlab/secp256k1'; +import { BIP32Factory, type BIP32Interface } from 'bip32'; import type { Network } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; +import ECPairFactory from 'ecpair'; import { v4 as uuidv4 } from 'uuid'; -import { Network as NetworkEnum } from '../src/modules/bitcoin/constants'; +import { Network as NetworkEnum } from '../src/bitcoin/constants'; import blockChairData from './fixtures/blockchair.json'; import blockStreamData from './fixtures/blockstream.json'; @@ -39,6 +41,59 @@ export function generateAccounts(cnt = 1, addressPrefix = '') { return accounts; } +/** + * Method to generate random bip32 deriver. + * + * @param network - Bitcoin network. + * @param path - Derived path. + * @param curve - Curve. + * @returns An Json data and the bip32 deriver. + */ +export function createRandomBip32Data( + network: Network, + path: string[], + curve: string, +) { + const ECPair = ECPairFactory(ecc); + const bip32 = BIP32Factory(ecc); + + const key = `${path.join('')}${curve}`; + const hexStr = Buffer.from(key, 'utf8').toString('hex'); + const bufferHex = Buffer.from(hexStr, 'hex'); + + const customRandomBufferFunc = (size: number): Buffer => { + const byteBuffer32 = Buffer.alloc(size); + bufferHex.copy(byteBuffer32); + return byteBuffer32; + }; + + const keyPair = ECPair.makeRandom({ + network, + rng: customRandomBufferFunc, + }); + + const deriver = bip32.fromSeed(keyPair.publicKey, network); + + const data = { + privateKey: deriver.privateKey?.toString('hex'), + publicKey: deriver.publicKey.toString('hex'), + chainCode: deriver.chainCode.toString('hex'), + depth: deriver.depth, + index: deriver.index, + curve, + masterFingerprint: undefined, + parentFingerprint: 0, + chainCodeBytes: deriver.chainCode, + privateKeyBytes: deriver.privateKey, + publicKeyBytes: deriver.publicKey, + }; + + return { + deriver, + data, + }; +} + /** * Method to generate mock bip32 instance. * From b1df12341d2677a64adec87fe5c30caf2515e50b Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 31 May 2024 18:04:28 +0800 Subject: [PATCH 051/362] chore: Update README.md and comments (#89) * Update README.md * Update README.md * fix: lint style * Update permissions.ts * fix: update comment * fix: update error --- merged-packages/bitcoin-wallet-snap/README.md | 79 ++++++++++- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin/data-client/clients/blockchair.ts | 2 +- .../src/bitcoin/utils/explorer.ts | 9 +- .../src/bitcoin/utils/network.test.ts | 14 +- .../src/bitcoin/utils/network.ts | 12 +- .../src/bitcoin/utils/payment.ts | 13 +- .../src/bitcoin/utils/unit.ts | 25 ++-- .../src/bitcoin/wallet/address.ts | 4 +- .../src/bitcoin/wallet/coin-select.ts | 11 +- .../src/bitcoin/wallet/exceptions.ts | 2 - .../src/bitcoin/wallet/psbt.ts | 6 +- .../src/bitcoin/wallet/wallet.ts | 2 +- .../bitcoin-wallet-snap/src/chain.ts | 37 ++--- .../src/config/permissions.ts | 28 +++- .../src/rpcs/get-balances.test.ts | 22 +-- .../src/rpcs/sendmany.test.ts | 100 +++++++------- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 1 - .../bitcoin-wallet-snap/src/types/static.ts | 7 + .../bitcoin-wallet-snap/src/utils/async.ts | 17 +-- .../bitcoin-wallet-snap/src/utils/error.ts | 14 +- .../src/utils/string.test.ts | 4 +- .../bitcoin-wallet-snap/src/utils/string.ts | 42 +++--- .../bitcoin-wallet-snap/src/wallet.ts | 130 +++++++++++++----- 24 files changed, 373 insertions(+), 210 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index 72c36cbf..54daf78e 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -2,4 +2,81 @@ ## Configuration -please see `.env.example` for reference +Rename `.env.example` to `.env` +Configurations are setup though `.env`, + +```bash +# Description: Environment variables for read data client +# Possible Options: BlockStream | BlockChair +# Default: BlockChair +# Required: false +DATA_CLIENT_READ_TYPE=BlockStream +# Description: Environment variables for write data client +# Possible Options: BlockChair +# Default: BlockChair +# Required: false +DATA_CLIENT_WRITE_TYPE=BlockChair +# Description: Environment variables for Log Level, 0 does not log anything, 6 logs everything +# Possible Options: 0-6 +# Default: 0 +# Required: false +LOG_LEVEL=6 +# Description: Environment variables for API key of BlockChair +# Required: false +BLOCKCHAIR_API_KEY= +``` + +## Rpcs: + +### API `chain_getTransactionStatus` + +example: + +```typescript +provider.request({ + method: 'wallet_invokeSnap', + params: { + snapId, + request: { + method: 'chain_getTransactionStatus', + params: { + scope: 'bip122:000000000933ea01ad0ee984209779ba', // the CAIP-2 chain ID of bitcoin + transactionId: '5639078d-742e-4901-8993-bc25a5ef6161', // the txn id of an bitcoin transaction + }, + }, + }, +}); +``` + +### API `btc_sendmany` + +example: + +```typescript +provider.request({ + method: 'wallet_invokeKeyring', + params: { + snapId, + request: { + method: 'keyring_submitRequest', + params: { + account: 'dc06350a-82db-434b-b113-066135804f63', // the uuid account id of the current account + id: 'da40b782-e054-4260-9a6a-c8717a022f92', // an random uuid for this request + scope: 'bip122:000000000933ea01ad0ee984209779ba', // the CAIP-2 chain ID of bitcoin + request: { + method: 'btc_sendmany', + params: { + amounts: { + ['tb1qlhkuysju47s642834n7f3tyk67mvnt2cfd9r7y']: '0.00000500', + }, // the recipient struct to indicate how many BTC to be received for each recipient + comment: 'some comment', + subtractFeeFrom: [], // not support yet + replaceable: false, // an flag to opt-in RBF + dryrun: true, // an flag to enable similation of the transaction, without broadcast to network + }, + }, + }, + }, + }, +}); +``` diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index f6d7f5cb..2369786e 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "cYdAxSIHUKPIACvr6j9QWgn1XUiIcaqDRwfb7yYK7nY=", + "shasum": "3yHVO4NTRGPyLuMFfesI1GYB26pej9DBGoB8IVhkqo4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts index 9414e8a7..8e84eb3c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts @@ -312,7 +312,7 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { } } - // TODO: add logic to get UTXOs that sufficiently cover the amount, to reduce the number of requests + // TODO: Get UTXOs that sufficiently cover the amount and fee, to reduce the number of requests async getUtxos( address: string, includeUnconfirmed?: boolean, diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts index 6bad0b10..ed7c152f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts @@ -2,11 +2,12 @@ import { Chain, Config } from '../../config'; import { Network } from '../constants'; /** - * Method to get ExplorerUrl by CAIP2 Chain Id string. + * Gets the explorer URL for a given bitcoin address and CAIP-2 Chain ID. * - * @param address - The address to get the Explorer string for. - * @param network - The CAIP2 Chain Id. - * @returns The Explorer string. + * @param address - The bitcoin address to get the explorer URL for. + * @param network - The CAIP-2 Chain ID. + * @returns The explorer URL as a string. + * @throws An error if an invalid network is provided. */ export function getExplorerUrl(address: string, network: string): string { switch (network) { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts index 3d76c58a..29e66ac3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts @@ -1,7 +1,7 @@ import { networks } from 'bitcoinjs-lib'; import { Network } from '../constants'; -import { getBtcNetwork, getCaip2Network } from './network'; +import { getBtcNetwork, getCaip2ChainId } from './network'; describe('getBtcNetwork', () => { it('returns bitcoin testnet network', () => { @@ -21,18 +21,18 @@ describe('getBtcNetwork', () => { }); }); -describe('getCaip2Network', () => { - it('returns caip2 testnet network', () => { - const result = getCaip2Network(networks.testnet); +describe('getCaip2ChainId', () => { + it('returns CAIP-2 testnet chain ID of bitcoin', () => { + const result = getCaip2ChainId(networks.testnet); expect(result).toStrictEqual(Network.Testnet); }); - it('returns caip2 mainnet network', () => { - const result = getCaip2Network(networks.bitcoin); + it('returns CAIP-2 mainnet chain ID of bitcoin', () => { + const result = getCaip2ChainId(networks.bitcoin); expect(result).toStrictEqual(Network.Mainnet); }); it('throws `Invalid network` error if the given network is not support', () => { - expect(() => getCaip2Network(networks.regtest)).toThrow('Invalid network'); + expect(() => getCaip2ChainId(networks.regtest)).toThrow('Invalid network'); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts index 29408dc5..b6078c15 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts @@ -4,10 +4,11 @@ import { networks } from 'bitcoinjs-lib'; import { Network as NetworkEnum } from '../constants'; /** - * Method to get bitcoinjs-lib network by CAIP2 Chain Id string. + * Gets a bitcoinjs-lib network instance based on a given CAIP-2 Chain ID. * - * @param network - The CAIP2 Chain Id. + * @param network - The CAIP-2 Chain ID. * @returns The instance of bitcoinjs-lib network. + * @throws An error if an invalid network is provided. */ export function getBtcNetwork(network: string) { switch (network) { @@ -21,12 +22,13 @@ export function getBtcNetwork(network: string) { } /** - * Method to get CAIP2 Chain Id string by bitcoinjs-lib network. + * Gets a CAIP-2 Chain ID based on a given bitcoinjs-lib network instance. * * @param network - The instance of bitcoinjs-lib network. - * @returns The CAIP2 Chain Id. + * @returns The CAIP-2 Chain ID. + * @throws An error if an invalid network is provided. */ -export function getCaip2Network(network: Network): NetworkEnum { +export function getCaip2ChainId(network: Network): NetworkEnum { switch (network) { case networks.bitcoin: return NetworkEnum.Mainnet; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.ts index 7a54d5a8..53c89a47 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.ts @@ -4,19 +4,20 @@ import type { Buffer } from 'buffer'; import { ScriptType } from '../constants'; /** - * Method to get bitcoinjs-lib payment instance by an bitcoin script type. + * Gets a bitcoinjs-lib payment instance based on a given bitcoin script type and public key. * - * @param type - Script type of the bitcoin account - P2pkhm, P2shP2wkh or P2wpkh. - * @param pubkey - Public key of the account. - * @param network - Bitcoinjs-lib network. + * @param scriptType - The script type of the bitcoin account (P2pkh, P2shP2wkh, or P2wpkh). + * @param pubkey - The public key of the account. + * @param network - The bitcoinjs-lib network. * @returns The instance of bitcoinjs-lib payment. + * @throws An error if an invalid script type is provided. */ export function getBtcPaymentInst( - type: ScriptType, + scriptType: ScriptType, pubkey: Buffer, network: Network, ): Payment { - switch (type) { + switch (scriptType) { case ScriptType.P2pkh: return payments.p2pkh({ pubkey, network }); case ScriptType.P2shP2wkh: diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts index f00394a3..301c08a3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts @@ -2,36 +2,37 @@ import type { ScriptType } from '../constants'; import { DustLimit } from '../constants'; /** - * A Method to convert BTC to Satoshis. + * Converts a number of satoshis to a string representing the equivalent amount of BTC. * - * @param sats - Satoshis. - * @returns Btc unit in string fixed to 8 decimal places. + * @param sats - The number of satoshis to convert. + * @returns The equivalent amount of BTC as a string, fixed to 8 decimal places. + * @throws A TypeError if sats is not an integer. */ export function satsToBtc(sats: number): string { if (!Number.isInteger(sats)) { - throw new TypeError('satsToBtc must be called on a interger number'); + throw new TypeError('satsToBtc must be called on an integer number'); } return (sats / 1e8).toFixed(8); } /** - * A Method to convert Satoshis to Btcs. + * Converts a number of BTC to a string representing the equivalent amount of satoshis. * - * @param btc - Btc. - * @returns Btc unit in string fixed to 8 decimal places. + * @param btc - The amount of BTC to convert. + * @returns The equivalent amount of satoshis as a string, rounded to the nearest integer. */ export function btcToSats(btc: number): string { return (btc * 1e8).toFixed(0); } /** - * A Method to determine the given amount is dust base on the hardcoded dust limit by script type. + * Determines if a given amount is considered dust based on the hardcoded dust limit for a given script type. * - * @param amt - Compare amount. - * @param scriptType - Script type. - * @returns Boolean result. + * @param amt - The amount to compare. + * @param scriptType - The script type for which to calculate the dust limit. + * @returns A boolean indicating whether the amount is considered dust. */ export function isDust(amt: number, scriptType: ScriptType): boolean { - // TODO: calculate dust threshold by network fee rate + // TODO: Calculate dust threshold by network fee rate, rather than hardcoding it. return amt < DustLimit[scriptType]; } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts index 7d3a8dc6..ef8e95b8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts @@ -3,7 +3,7 @@ import type { Network } from 'bitcoinjs-lib'; import { replaceMiddleChar } from '../../utils'; import type { IAddress } from '../../wallet'; -import { getCaip2Network, getExplorerUrl } from '../utils'; +import { getCaip2ChainId, getExplorerUrl } from '../utils'; export class BtcAddress implements IAddress { address: string; @@ -16,7 +16,7 @@ export class BtcAddress implements IAddress { } get explorerUrl(): string { - return getExplorerUrl(this.address, getCaip2Network(this.network)); + return getExplorerUrl(this.address, getCaip2ChainId(this.network)); } valueOf(): string { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index c6cfa69e..7b838135 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -3,7 +3,7 @@ import type { Buffer } from 'buffer'; import coinSelect from 'coinselect'; import { hexToBuffer } from '../../utils'; -import { UtxoServiceError } from './exceptions'; +import { TransactionValidationError } from './exceptions'; import type { SpendTo, SelectedUtxos, Utxo } from './types'; export class CoinSelectService { @@ -13,13 +13,6 @@ export class CoinSelectService { this.feeRate = Math.round(feeRate); } - /** - * Selects UTXOs to spend for segwit output. - * @param utxos - Array of UTXOs. - * @param spendTos - Array of SpendTo objects. - * @param script - Script hash of the segwit output. - * @returns Selected UTXOs. - */ selectCoins( utxos: Utxo[], spendTos: SpendTo[], @@ -49,7 +42,7 @@ export class CoinSelectService { const result = coinSelect(spendFrom, spendTos, this.feeRate); if (!result.inputs || !result.outputs) { - throw new UtxoServiceError('Not enough funds'); + throw new TransactionValidationError('Not enough funds'); } const inputs: Utxo[] = []; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts index 01b0d6ac..2d60e84f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts @@ -15,5 +15,3 @@ export class PsbtSigValidateError extends CustomError {} export class PsbtValidateError extends CustomError {} export class PsbtUpdateWithnessUtxoError extends CustomError {} - -export class UtxoServiceError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index e94b6265..99a7c71c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -8,7 +8,7 @@ import { logger } from '../../libs/logger/logger'; import { compactError } from '../../utils'; import type { IAccountSigner } from '../../wallet'; import { MaxStandardTxWeight } from '../constants'; -import { PsbtServiceError } from './exceptions'; +import { PsbtServiceError, TransactionValidationError } from './exceptions'; import type { SpendTo, Utxo } from './types'; const ECPair = ECPairFactory(ecc); @@ -110,7 +110,7 @@ export class PsbtService { this.validateInputs(pubkey, msghash, signature), ) ) { - throw new PsbtServiceError( + throw new TransactionValidationError( "Invalid signature to sign the PSBT's inputs", ); } @@ -128,7 +128,7 @@ export class PsbtService { const weight = this._psbt.extractTransaction().weight(); if (weight > MaxStandardTxWeight) { - throw new PsbtServiceError('Transaction is too large'); + throw new TransactionValidationError('Transaction is too large'); } return txHex; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 8234ef50..f4fb7db2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -86,7 +86,7 @@ export class BtcWallet implements IWallet { } // as fee rate can be 0, we need to ensure it is at least 1 - // TODO the min fee rate can be setting by parameter + // TODO: The min fee rate should be setting from parameter const feeRate = Math.max(1, options.fee); const coinSelectService = new CoinSelectService(feeRate); diff --git a/merged-packages/bitcoin-wallet-snap/src/chain.ts b/merged-packages/bitcoin-wallet-snap/src/chain.ts index 01886d20..9a6eac12 100644 --- a/merged-packages/bitcoin-wallet-snap/src/chain.ts +++ b/merged-packages/bitcoin-wallet-snap/src/chain.ts @@ -55,51 +55,54 @@ export type CommitedTransaction = { transactionId: string; }; +/** + * An interface that defines methods for interacting with a blockchain network. + */ export type IOnChainService = { /** - * A method to get the balances for multiple addresses and multipe assets. + * Gets the balances for multiple addresses and multiple assets. * - * @param addresses - A array of the addresse to fetch. - * @param type - A array of the asset to fetch. - * @returns A promise that resolves to an AssetBalances object. + * @param addresses - An array of addresses to fetch the balances for. + * @param assets - An array of assets to fetch the balances of. + * @returns A promise that resolves to an `AssetBalances` object. */ getBalances(addresses: string[], assets: string[]): Promise; /** - * A method to get the fee rate of the network. + * Gets the fee rates of the network. * - * @returns A promise that resolves to an Fees object. + * @returns A promise that resolves to a `Fees` object. */ getFeeRates(): Promise; /** - * A method to broadcast the transaction on chain. + * Broadcasts a signed transaction on the blockchain network. * - * @param signedTransaction - An signed transaction string. - * @returns A promise that resolves to an CommitedTransaction object. + * @param signedTransaction - A signed transaction string. + * @returns A promise that resolves to a `CommitedTransaction` object. */ broadcastTransaction(signedTransaction: string): Promise; /** - * A method to fetch the transaction status. + * Gets the status of a transaction with the given transaction hash. * - * @param txnHash - An transaction hash. - * @returns A promise that resolves to an TransactionStatusData object. + * @param txnHash - The transaction hash of the transaction to get the status of. + * @returns A promise that resolves to a `TransactionStatusData` object. */ getTransactionStatus(txnHash: string): Promise; /** - * A method to fetch the require metadata to build an transaction. + * Gets the required metadata to build a transaction for the given address and transaction intent. * - * @param address - A address string. - * @param transactionIntent - An TransactionIntent Object. - * @returns A promise that resolves to an TransactionData object. + * @param address - The address to build the transaction for. + * @param transactionIntent - The transaction intent object containing the transaction inputs and outputs. + * @returns A promise that resolves to a `TransactionData` object. */ getDataForTransaction( address: string, transactionIntent?: TransactionIntent, ): Promise; - // TODO: implement listTransactions + // TODO: Implement listTransactions in next phase listTransactions(); }; diff --git a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts index 9a8f95c5..c84b4144 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts @@ -1,6 +1,23 @@ import { KeyringRpcMethod } from '@metamask/keyring-api'; -const allowSet = new Set([ +const dappPermissions = new Set([ + // Keyring methods + KeyringRpcMethod.ListAccounts, + KeyringRpcMethod.GetAccount, + KeyringRpcMethod.CreateAccount, + KeyringRpcMethod.FilterAccountChains, + // KeyringRpcMethod.UpdateAccount, + // KeyringRpcMethod.DeleteAccount, + KeyringRpcMethod.ListRequests, + KeyringRpcMethod.GetRequest, + KeyringRpcMethod.ApproveRequest, + KeyringRpcMethod.RejectRequest, + KeyringRpcMethod.SubmitRequest, + // Chain API methods + 'chain_getTransactionStatus', +]); + +const metamaskPermissions = new Set([ // Keyring methods KeyringRpcMethod.ListAccounts, KeyringRpcMethod.GetAccount, @@ -31,7 +48,10 @@ const metamask = 'metamask'; export const originPermissions = new Map>([]); for (const origin of allowedOrigins) { - originPermissions.set(origin, allowSet); + originPermissions.set(origin, dappPermissions); } -originPermissions.set(metamask, allowSet); -originPermissions.set(local, new Set([...allowSet, 'chain_createAccount'])); +originPermissions.set(metamask, metamaskPermissions); +originPermissions.set( + local, + new Set([...dappPermissions, 'chain_createAccount']), +); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts index 96a1508d..7786d8fd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts @@ -48,7 +48,7 @@ describe('GetBalancesHandler', () => { }; }; - const createMockAccount = async (network, caip2Network) => { + const createMockAccount = async (network, caip2ChainId) => { const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const sender = await wallet.unlock(0, ScriptType.P2wpkh); @@ -57,7 +57,7 @@ describe('GetBalancesHandler', () => { id: uuidv4(), address: sender.address, options: { - scope: caip2Network, + scope: caip2ChainId, index: sender.index, }, methods: ['btc_sendmany'], @@ -67,7 +67,7 @@ describe('GetBalancesHandler', () => { account: keyringAccount as unknown as KeyringAccount, hdPath: sender.hdPath, index: sender.index, - scope: caip2Network, + scope: caip2ChainId, }; return { @@ -79,10 +79,10 @@ describe('GetBalancesHandler', () => { it('gets balances', async () => { const network = networks.testnet; - const caip2Network = Network.Testnet; + const caip2ChainId = Network.Testnet; const { getBalancesSpy } = createMockChainApiFactory(); - const { walletData } = await createMockAccount(network, caip2Network); + const { walletData } = await createMockAccount(network, caip2ChainId); const addresses = [walletData.account.address]; const mockResp = { @@ -116,10 +116,10 @@ describe('GetBalancesHandler', () => { it('gets balances of the request account only', async () => { const network = networks.testnet; - const caip2Network = Network.Testnet; + const caip2ChainId = Network.Testnet; const { getBalancesSpy } = createMockChainApiFactory(); const accounts = generateAccounts(10); - const { walletData } = await createMockAccount(network, caip2Network); + const { walletData } = await createMockAccount(network, caip2ChainId); const addresses = [walletData.account.address]; const mockResp = { @@ -159,9 +159,9 @@ describe('GetBalancesHandler', () => { it('throws `Fail to get the balances` when transaction status fetch failed', async () => { const network = networks.testnet; - const caip2Network = Network.Testnet; + const caip2ChainId = Network.Testnet; const { getBalancesSpy } = createMockChainApiFactory(); - const { walletData } = await createMockAccount(network, caip2Network); + const { walletData } = await createMockAccount(network, caip2ChainId); getBalancesSpy.mockRejectedValue(new Error('error')); @@ -175,8 +175,8 @@ describe('GetBalancesHandler', () => { it('throws `Request params is invalid` when request parameter is not correct', async () => { const network = networks.testnet; - const caip2Network = Network.Testnet; - const { walletData } = await createMockAccount(network, caip2Network); + const caip2ChainId = Network.Testnet; + const { walletData } = await createMockAccount(network, caip2ChainId); await expect( GetBalancesHandler.getInstance(walletData).execute({ diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 1156299f..3cfe58f9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -59,7 +59,7 @@ describe('SendManyHandler', () => { const createSenderNRecipients = async ( network, - caip2Network: string, + caip2ChainId: string, recipientCnt: number, ) => { const { instance } = createMockDeriver(network); @@ -71,7 +71,7 @@ describe('SendManyHandler', () => { id: uuidv4(), address: sender.address, options: { - scope: caip2Network, + scope: caip2ChainId, index: sender.index, }, methods: ['btc_sendmany'], @@ -90,7 +90,7 @@ describe('SendManyHandler', () => { const createSendManyParams = ( recipients: IAccount[], - caip2Network: string, + caip2ChainId: string, dryrun: boolean, comment = '', ): SendManyParams => { @@ -105,7 +105,7 @@ describe('SendManyHandler', () => { subtractFeeFrom: [], replaceable: false, dryrun, - scope: caip2Network, + scope: caip2ChainId, } as unknown as SendManyParams; }; @@ -131,7 +131,7 @@ describe('SendManyHandler', () => { return generateBlockChairBroadcastTransactionResp().data.transaction_hash; }; - const prepareSendMany = async (network, caip2Network) => { + const prepareSendMany = async (network, caip2ChainId) => { const { getDataForTransactionSpy, getFeeRatesSpy, @@ -140,7 +140,7 @@ describe('SendManyHandler', () => { const snapHelperSpy = jest.spyOn(SnapHelper, 'confirmDialog'); const { sender, keyringAccount, recipients } = - await createSenderNRecipients(network, caip2Network, 2); + await createSenderNRecipients(network, caip2ChainId, 2); const broadcastResp = createMockBroadcastTransactionResp(); @@ -176,7 +176,7 @@ describe('SendManyHandler', () => { it('returns correct result', async () => { const network = networks.testnet; - const caip2Network = Network.Testnet; + const caip2ChainId = Network.Testnet; const { keyringAccount, recipients, @@ -184,13 +184,13 @@ describe('SendManyHandler', () => { getDataForTransactionSpy, getFeeRatesSpy, broadcastTransactionSpy, - } = await prepareSendMany(network, caip2Network); + } = await prepareSendMany(network, caip2ChainId); const result = await SendManyHandler.getInstance({ - scope: caip2Network, + scope: caip2ChainId, index: 0, account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2Network, false)); + }).execute(createSendManyParams(recipients, caip2ChainId, false)); expect(result).toStrictEqual({ txId: broadcastResp }); expect(getFeeRatesSpy).toHaveBeenCalledTimes(1); @@ -200,31 +200,31 @@ describe('SendManyHandler', () => { it('does not broadcast transaction if in dryrun mode', async () => { const network = networks.testnet; - const caip2Network = Network.Testnet; + const caip2ChainId = Network.Testnet; const { keyringAccount, recipients, broadcastTransactionSpy } = - await prepareSendMany(network, caip2Network); + await prepareSendMany(network, caip2ChainId); await SendManyHandler.getInstance({ - scope: caip2Network, + scope: caip2ChainId, index: 0, account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2Network, true)); + }).execute(createSendManyParams(recipients, caip2ChainId, true)); expect(broadcastTransactionSpy).toHaveBeenCalledTimes(0); }); it('does create comment component in dialog if consumer has provide the comment', async () => { const network = networks.testnet; - const caip2Network = Network.Testnet; + const caip2ChainId = Network.Testnet; const { keyringAccount, recipients, snapHelperSpy } = - await prepareSendMany(network, caip2Network); + await prepareSendMany(network, caip2ChainId); await SendManyHandler.getInstance({ - scope: caip2Network, + scope: caip2ChainId, index: 0, account: keyringAccount, }).execute( - createSendManyParams(recipients, caip2Network, true, 'test comment'), + createSendManyParams(recipients, caip2ChainId, true, 'test comment'), ); const calls = snapHelperSpy.mock.calls[0][0]; @@ -238,9 +238,9 @@ describe('SendManyHandler', () => { it('display `Recipient` as label in dialog if there is only 1 receipient', async () => { const network = networks.testnet; - const caip2Network = Network.Testnet; + const caip2ChainId = Network.Testnet; const { keyringAccount, recipients, snapHelperSpy } = - await prepareSendMany(network, caip2Network); + await prepareSendMany(network, caip2ChainId); const walletCreateTxnSpy = jest.spyOn( BtcWallet.prototype, 'createTransaction', @@ -264,10 +264,10 @@ describe('SendManyHandler', () => { walletSignTxnSpy.mockResolvedValue('txId'); await SendManyHandler.getInstance({ - scope: caip2Network, + scope: caip2ChainId, index: 0, account: keyringAccount, - }).execute(createSendManyParams([recipients[0]], caip2Network, true)); + }).execute(createSendManyParams([recipients[0]], caip2ChainId, true)); const calls = snapHelperSpy.mock.calls[0][0]; @@ -287,46 +287,46 @@ describe('SendManyHandler', () => { it('throws `Account not found` error when given address not match', async () => { createMockChainApiFactory(); const network = networks.testnet; - const caip2Network = Network.Testnet; + const caip2ChainId = Network.Testnet; const { keyringAccount, recipients } = await createSenderNRecipients( network, - caip2Network, + caip2ChainId, 2, ); await expect( SendManyHandler.getInstance({ - scope: caip2Network, + scope: caip2ChainId, index: 20, account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2Network, false)), + }).execute(createSendManyParams(recipients, caip2ChainId, false)), ).rejects.toThrow('Account not found'); }); it('throws `Transaction must have at least one recipient` error if no recipient provided', async () => { createMockChainApiFactory(); const network = networks.testnet; - const caip2Network = Network.Testnet; + const caip2ChainId = Network.Testnet; const { keyringAccount, recipients } = await createSenderNRecipients( network, - caip2Network, + caip2ChainId, 0, ); await expect( SendManyHandler.getInstance({ - scope: caip2Network, + scope: caip2ChainId, index: 0, account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2Network, false)), + }).execute(createSendManyParams(recipients, caip2ChainId, false)), ).rejects.toThrow('Transaction must have at least one recipient'); }); it('throws `Failed to send the transaction` error if no fee rate returns from chain service', async () => { const { getFeeRatesSpy } = createMockChainApiFactory(); const network = networks.testnet; - const caip2Network = Network.Testnet; + const caip2ChainId = Network.Testnet; const { keyringAccount, recipients } = await createSenderNRecipients( network, - caip2Network, + caip2ChainId, 10, ); getFeeRatesSpy.mockResolvedValue({ @@ -335,30 +335,30 @@ describe('SendManyHandler', () => { await expect( SendManyHandler.getInstance({ - scope: caip2Network, + scope: caip2ChainId, index: 0, account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2Network, false)), + }).execute(createSendManyParams(recipients, caip2ChainId, false)), ).rejects.toThrow('Failed to send the transaction'); }); it('throws `Invalid amount for send` error if sending amount is <= 0', async () => { const network = networks.testnet; - const caip2Network = Network.Testnet; + const caip2ChainId = Network.Testnet; createMockChainApiFactory(); const { keyringAccount, recipients } = await createSenderNRecipients( network, - caip2Network, + caip2ChainId, 2, ); await expect( SendManyHandler.getInstance({ - scope: caip2Network, + scope: caip2ChainId, index: 0, account: keyringAccount, }).execute({ - ...createSendManyParams(recipients, caip2Network, false), + ...createSendManyParams(recipients, caip2ChainId, false), amounts: { [recipients[0].address]: satsToBtc(500), [recipients[1].address]: satsToBtc(0), @@ -369,9 +369,9 @@ describe('SendManyHandler', () => { it('throws `Invalid response` error if the response is unexpected', async () => { const network = networks.testnet; - const caip2Network = Network.Testnet; + const caip2ChainId = Network.Testnet; const { keyringAccount, recipients, broadcastTransactionSpy } = - await prepareSendMany(network, caip2Network); + await prepareSendMany(network, caip2ChainId); broadcastTransactionSpy.mockResolvedValue({ transactionId: { @@ -380,42 +380,42 @@ describe('SendManyHandler', () => { }); await expect( SendManyHandler.getInstance({ - scope: caip2Network, + scope: caip2ChainId, index: 0, account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2Network, false)), + }).execute(createSendManyParams(recipients, caip2ChainId, false)), ).rejects.toThrow('Invalid Response'); }); it('throws UserRejectedRequestError error if user denied the transaction', async () => { const network = networks.testnet; - const caip2Network = Network.Testnet; + const caip2ChainId = Network.Testnet; const { snapHelperSpy, keyringAccount, recipients } = - await prepareSendMany(network, caip2Network); + await prepareSendMany(network, caip2ChainId); snapHelperSpy.mockResolvedValue(false); await expect( SendManyHandler.getInstance({ - scope: caip2Network, + scope: caip2ChainId, index: 0, account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2Network, false)), + }).execute(createSendManyParams(recipients, caip2ChainId, false)), ).rejects.toThrow(UserRejectedRequestError); }); it('throws `Failed to send the transaction` error if the transaction is fail to commit', async () => { const network = networks.testnet; - const caip2Network = Network.Testnet; + const caip2ChainId = Network.Testnet; const { broadcastTransactionSpy, keyringAccount, recipients } = - await prepareSendMany(network, caip2Network); + await prepareSendMany(network, caip2ChainId); broadcastTransactionSpy.mockRejectedValue(new Error('error')); await expect( SendManyHandler.getInstance({ - scope: caip2Network, + scope: caip2ChainId, index: 0, account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2Network, false)), + }).execute(createSendManyParams(recipients, caip2ChainId, false)), ).rejects.toThrow('Failed to send the transaction'); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 78090c60..e23ed97b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -45,7 +45,6 @@ export const TransactionAmountStuct = refine( for (const val of Object.values(value)) { const parsedVal = parseFloat(val); - // TODO: do we need to check for max values, max decimals? if (Number.isNaN(parsedVal) || parsedVal <= 0) { return 'Invalid amount for send'; } diff --git a/merged-packages/bitcoin-wallet-snap/src/types/static.ts b/merged-packages/bitcoin-wallet-snap/src/types/static.ts index 48869c07..70a6b2d6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/types/static.ts +++ b/merged-packages/bitcoin-wallet-snap/src/types/static.ts @@ -1,3 +1,10 @@ +/** + * A type that enforces static implementation of a class. + * + * @template Inter - An interface that the class must implement. + * @template Cls - The class that implements the interface. + * @returns The instance type of the interface. + */ export type StaticImplements< // eslint-disable-next-line @typescript-eslint/no-explicit-any Inter extends new (...args: any[]) => any, diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/async.ts b/merged-packages/bitcoin-wallet-snap/src/utils/async.ts index 74f3b1f6..dd1eb527 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/async.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/async.ts @@ -1,21 +1,22 @@ /** - * Method to execute the given promise callback by the size of the data in batch operation. + * Executes the given promise callback on the data in batches. * - * @param arr - Array of data to be processed in batch. - * @param callback - Promise callback to be executed on each item of the array. - * @param batchSize - Size of the batch operation, default is 50. + * @param datas - An array of data to be processed in batches. + * @param callback - A promise callback to be executed on each item of the array. + * @param batchSize - The size of the batch operation, default is 50. + * @returns A promise that resolves when all batches have been processed. */ export async function processBatch( - arr: Data[], + datas: Data[], callback: (item: Data) => Promise, batchSize = 50, ): Promise { let from = 0; let to = batchSize; - while (from < arr.length) { + while (from < datas.length) { const batch: Promise[] = []; - for (let i = from; i < Math.min(to, arr.length); i++) { - batch.push(callback(arr[i])); + for (let i = from; i < Math.min(to, datas.length); i++) { + batch.push(callback(datas[i])); } await Promise.all(batch); from += batchSize; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/error.ts b/merged-packages/bitcoin-wallet-snap/src/utils/error.ts index dbe1ef8e..9c20c537 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/error.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/error.ts @@ -19,11 +19,11 @@ import { } from '@metamask/snaps-sdk'; /** - * Compact error to a specific error instance. + * Compacts an error to a specific error instance. * - * @param error - Error instance. - * @param ErrCtor - Error constructor. - * @returns Compacted Error instance. + * @param error - The error instance to be compacted. + * @param ErrCtor - The error constructor for the desired error instance. + * @returns The compacted error instance. */ export function compactError( error: ErrorInstance, @@ -36,10 +36,10 @@ export function compactError( } /** - * A Method to determind the given error is an Snap Error. + * Determines if the given error is a Snap RPC error. * - * @param error - Error instance. - * @returns Result in boolean. + * @param error - The error instance to be checked. + * @returns A boolean indicating whether the error is a Snap RPC error. */ export function isSnapRpcError(error: Error): boolean { const errors = [ diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts index 46ce3fe2..f0b3e8c4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts @@ -38,9 +38,9 @@ describe('hexToBuffer', () => { expect(result).toStrictEqual(Buffer.from('0x1234', 'hex')); }); - it('throws `Unable to convert hexStr to buffer` error if the execution fail', () => { + it('throws `Unable to convert hex string to buffer` error if the execution fail', () => { expect(() => hexToBuffer(null as unknown as string)).toThrow( - 'Unable to convert hexStr to buffer', + 'Unable to convert hex string to buffer', ); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts index 14e08e62..acf96baa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts @@ -1,35 +1,37 @@ import { Buffer } from 'buffer'; /** - * Method to trim the hex prefix from an hex string. + * Removes the '0x' prefix from a given hex string. * - * @param hexStr - Hex string. - * @returns Hex string without the prefix. + * @param hexStr - The hex string to remove the prefix from. + * @returns The hex string without the prefix. */ export function trimHexPrefix(hexStr: string) { return hexStr.startsWith('0x') ? hexStr.substring(2) : hexStr; } /** - * Method to convert a hex string to a buffer. + * Converts a hex string to a buffer instance. * - * @param hexStr - Hex string. - * @param trimPrefix - Flag to trim the hex prefix. - * @returns Buffer instance. + * @param hexStr - The hex string to convert to a buffer. + * @param trimPrefix - A flag indicating whether to remove the '0x' prefix from the hex string. + * @returns The buffer instance. + * @throws An error if the hex string cannot be converted to a buffer. */ export function hexToBuffer(hexStr: string, trimPrefix = true) { try { return Buffer.from(trimPrefix ? trimHexPrefix(hexStr) : hexStr, 'hex'); } catch (error) { - throw new Error('Unable to convert hexStr to buffer'); + throw new Error('Unable to convert hex string to buffer'); } } /** - * Method to convert a buffer to a string. + * Converts a buffer instance to a string. * - * @param buffer - Hex string. - * @param encoding - Buffer encoding. - * @returns Converted String. + * @param buffer - The buffer instance to convert to a string. + * @param encoding - The encoding to use when converting the buffer to a string. + * @returns The converted string. + * @throws An error if the buffer cannot be converted to a string. */ export function bufferToString(buffer: Buffer, encoding: BufferEncoding) { try { @@ -40,13 +42,13 @@ export function bufferToString(buffer: Buffer, encoding: BufferEncoding) { } /** - * Method to format a string by replacing the middle characters. + * Replaces the middle characters of a string with a given string. * - * @param str - String to replace. - * @param headLength - Length of head. - * @param tailLength - Length of tail. - * @param replaceStr - String to replace with. Default is '...'. - * @returns Formatted string. + * @param str - The string to replace. + * @param headLength - The length of the head of the string that should not be replaced. + * @param tailLength - The length of the tail of the string that should not be replaced. + * @param replaceStr - The string to replace the middle characters with. Default is '...'. + * @returns The formatted string. */ export function replaceMiddleChar( str: string, @@ -58,5 +60,7 @@ export function replaceMiddleChar( return str; } - return `${str.substr(0, headLength)}${replaceStr}${str.substr(-tailLength)}`; + return `${str.substring(0, headLength)}${replaceStr}${str.substring( + str.length - tailLength, + )}`; } diff --git a/merged-packages/bitcoin-wallet-snap/src/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/wallet.ts index 30b13dc7..3c79acf1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/wallet.ts @@ -3,84 +3,139 @@ import type { Buffer } from 'buffer'; import type { TransactionIntent } from './chain'; +/** + * An interface that defines a `toJson` method for getting a JSON representation of a transaction info object. + */ export type ITransactionInfo = { /** - * The transaction json. + * Returns a JSON representation of the transaction info object. + * + * @returns The JSON representation of the transaction info object. */ toJson>(): TxnInfoJson; }; +/** + * An interface that defines methods and properties for working with blockchain addresses. + */ export type IAddress = { - toString(isShortern?: boolean): string; + /** + * Returns the string representation of the address. + * + * @param isShorten - A boolean indicating whether the address should be shortened. + * @returns The string representation of the address. + */ + toString(isShorten?: boolean): string; + + /** + * The URL of the explorer for this address. + */ explorerUrl: string; + + /** + * Returns a JSON representation of the address. + * + * @returns The JSON representation of the address. + */ + toJson(): Record; }; +/** + * An interface that defines properties for working with amounts of cryptocurrency. + */ export type IAmount = { + /** + * The numeric value of the amount. + */ value: number; + + /** + * The unit of the amount, e.g. "BTC" or "ETH". + */ unit: string; + + /** + * Returns the string representation of the amount, with or without the unit. + * + * @param withUnit - A boolean indicating whether to include the unit in the string representation. + * @returns The string representation of the amount. + */ toString(withUnit?: boolean): string; + + /** + * Returns a JSON representation of the amount. + * + * @returns The JSON representation of the amount. + */ + toJson(): Record; }; +/** + * An interface that defines properties for an account, including its address, HD path, public key, and signer object. + */ export type IAccount = { /** - * Master fingerpring of the devied node. + * The master fingerprint of the derived node, as a string. */ mfp: string; /** - * Index of the devied node. + * The index of the derived node, as a number. */ index: number; /** - * Address of the account. + * The address of the account, as a string. */ address: string; /** - * HD path of the account. + * The HD path of the account, as a string. */ hdPath: string; /** - * Public key of the account. + * The public key of the account, as a string. */ pubkey: string; /** - * Type of the account, e.g `bip122:p2pwh`. + * The type of the account, e.g. `bip122:p2pwh`, as a string. */ type: string; /** - * IAccountSigner object derived from the root node. + * The `IAccountSigner` object derived from the root node. */ signer: IAccountSigner; }; +/** + * An interface that defines methods for unlocking accounts, signing transactions, and creating transactions. + */ export type IWallet = { /** - * A method to unlock an account by index and script type. + * Unlocks an account by index and script type. * - * @param index - Index to derive from the node. - * @param type - Script type of the unlocked account, e.g `bip122:p2pkh`. - * @returns A promise that resolves to an IAccount object. + * @param index - The index to derive from the node. + * @param type - The script type of the unlocked account, e.g. `bip122:p2pkh`. + * @returns A promise that resolves to an `IAccount` object. */ unlock(index: number, type: string): Promise; /** - * A method to sign an transaction by the given encoded txn string. + * Signs a transaction by the given encoded transaction string. * - * @param signer - The signer object to sign the transaction. - * @param txn - The encoded txn string to convert back to an transaction. - * @returns A promise that resolves to an string of signed transaction. + * @param signer - The `IAccountSigner` object to sign the transaction. + * @param txn - The encoded transaction string to convert back to a transaction. + * @returns A promise that resolves to a string of the signed transaction. */ signTransaction(signer: IAccountSigner, txn: string): Promise; /** - * A method to create an transaction by the given account, transaction intent and options. + * Creates a transaction using the given account, transaction intent, and options. * - * @param acount - The IAccount object to create the transaction. - * @param txnIntent - The encoded txn string to convert back to an transaction. - * @param options - The options to create the transaction. - * @returns A promise that resolves to an object contains txnHash and txnJson. + * @param account - The `IAccount` object to create the transaction. + * @param txnIntent - The transaction intent object containing the transaction inputs and outputs. + * @param options - The options to use when creating the transaction. + * @returns A promise that resolves to an object containing the transaction hash and transaction info. */ createTransaction( - acount: IAccount, + account: IAccount, txnIntent: TransactionIntent, options: Record, ): Promise<{ @@ -89,41 +144,42 @@ export type IWallet = { }>; }; +/** + * An interface that defines methods and properties for signing transactions and verifying signatures. + */ export type IAccountSigner = { /** - * A method to create an transaction by the given account, transaction intent and options. + * Signs a transaction hash. * - * @param acount - The IAccount object to create the transaction. - * @param txnIntent - The encoded txn string to convert back to an transaction. - * @param options - The options to create the transaction. - * @returns A promise that resolves to an object contains txnHash and txnJson. + * @param hash - The buffer containing the transaction hash to sign. + * @returns A promise that resolves to the signed result as a Buffer. */ sign(hash: Buffer): Promise; /** - * A method to derive an IAccountSigner object by hd path. + * Derives a new `IAccountSigner` object using an HD path. * - * @param path - The hd path in string, e.g `m'\0'\0`. - * @returns An IAccountSigner derived by the given path. + * @param path - The HD path in string format, e.g. `m'\0'\0`. + * @returns A new `IAccountSigner` object derived by the given path. */ derivePath(path: string): IAccountSigner; /** - * A method to veriy the signature by the derived node of an IAccountSigner object. + * Verifies a signature using the derived node of an `IAccountSigner` object. * - * @param hash - The hash of the transaction in buffer. - * @param signature - The signature of the signed transaction in buffer. - * @returns Verify result in Boolean. + * @param hash - The buffer containing the transaction hash. + * @param signature - The buffer containing the signature to verify. + * @returns A boolean indicating whether the signature is valid. */ verify(hash: Buffer, signature: Buffer): boolean; /** - * Public Key of the current devied node for verify an signature. + * The public key of the current derived node used for verifying signatures, as a Buffer. */ publicKey: Buffer; /** - * Fingerprint of the current devied node for verify an signature. + * The fingerprint of the current derived node used for verifying signatures, as a Buffer. */ fingerprint: Buffer; }; From 3fd47903674d8b0cd3523ea70ac87ac212c36ea7 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 3 Jun 2024 18:41:59 +0800 Subject: [PATCH 052/362] feat: add selection result (#96) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/bitcoin/chain/service.test.ts | 62 ++-- .../src/bitcoin/chain/service.ts | 4 +- .../data-client/clients/blockchair.test.ts | 18 +- .../bitcoin/data-client/clients/blockchair.ts | 20 +- .../data-client/clients/blockstream.test.ts | 22 +- .../data-client/clients/blockstream.ts | 12 +- .../src/bitcoin/data-client/types.ts | 4 +- .../src/bitcoin/wallet/address.test.ts | 45 +-- .../src/bitcoin/wallet/address.ts | 27 +- .../src/bitcoin/wallet/amount.test.ts | 12 - .../src/bitcoin/wallet/amount.ts | 22 +- .../src/bitcoin/wallet/coin-select.test.ts | 88 +++-- .../src/bitcoin/wallet/coin-select.ts | 70 ++-- .../src/bitcoin/wallet/exceptions.ts | 2 +- .../src/bitcoin/wallet/index.ts | 2 +- .../src/bitcoin/wallet/psbt.test.ts | 323 +++++------------- .../src/bitcoin/wallet/psbt.ts | 38 ++- .../src/bitcoin/wallet/selection-result.ts | 17 + .../bitcoin/wallet/transaction-info.test.ts | 66 ++++ .../src/bitcoin/wallet/transaction-info.ts | 98 ++++++ .../bitcoin/wallet/transaction-input.test.ts | 39 +++ .../src/bitcoin/wallet/transaction-input.ts | 42 +++ .../bitcoin/wallet/transaction-output.test.ts | 14 + .../src/bitcoin/wallet/transaction-output.ts | 22 ++ .../bitcoin/wallet/transactionInfo.test.ts | 61 ---- .../src/bitcoin/wallet/transactionInfo.ts | 61 ---- .../src/bitcoin/wallet/types.ts | 19 +- .../src/bitcoin/wallet/wallet.test.ts | 128 +++---- .../src/bitcoin/wallet/wallet.ts | 127 +++---- .../bitcoin-wallet-snap/src/chain.ts | 4 +- .../src/libs/snap/helpers.test.ts | 4 +- .../src/libs/snap/state.test.ts | 72 ++-- .../src/rpcs/get-transaction-status.test.ts | 8 +- .../src/rpcs/sendmany.test.ts | 29 +- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 77 ++--- .../bitcoin-wallet-snap/src/wallet.ts | 51 ++- .../bitcoin-wallet-snap/test/utils.ts | 8 +- 38 files changed, 817 insertions(+), 903 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/selection-result.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transactionInfo.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transactionInfo.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 2369786e..5cc1fcf5 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "3yHVO4NTRGPyLuMFfesI1GYB26pej9DBGoB8IVhkqo4=", + "shasum": "Nz/fHikjqUlxBPopJv1lTeWP6FaObv0z7jw4pMzpZns=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts index ecce3daa..8d4745c0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts @@ -73,7 +73,7 @@ describe('BtcOnChainService', () => { describe('getBalance', () => { it('calls getBalances with readClient', async () => { const { instance, getBalanceSpy } = createMockReadDataClient(); - const { instance: txnService } = createMockBtcService(instance); + const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); getBalanceSpy.mockResolvedValue( @@ -83,7 +83,7 @@ describe('BtcOnChainService', () => { }, {}), ); - const result = await txnService.getBalances(addresses, [BtcAsset.TBtc]); + const result = await txService.getBalances(addresses, [BtcAsset.TBtc]); expect(getBalanceSpy).toHaveBeenCalledWith(addresses); @@ -98,29 +98,29 @@ describe('BtcOnChainService', () => { it('throws `Only one asset is supported` error if the given asset more than 1', async () => { const { instance } = createMockReadDataClient(); - const { instance: txnService } = createMockBtcService(instance); + const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); await expect( - txnService.getBalances(addresses, [BtcAsset.TBtc, BtcAsset.Btc]), + txService.getBalances(addresses, [BtcAsset.TBtc, BtcAsset.Btc]), ).rejects.toThrow('Only one asset is supported'); }); it('throws `Invalid asset` error if the BTC asset is given and current network is testnet network', async () => { const { instance } = createMockReadDataClient(); - const { instance: txnService } = createMockBtcService(instance); + const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); await expect( - txnService.getBalances(addresses, [BtcAsset.Btc]), + txService.getBalances(addresses, [BtcAsset.Btc]), ).rejects.toThrow('Invalid asset'); }); it('throws `Invalid asset` error if the TBTC asset is given and current network is bitcoin network', async () => { const { instance } = createMockReadDataClient(); - const { instance: txnService } = createMockBtcService( + const { instance: txService } = createMockBtcService( instance, undefined, networks.bitcoin, @@ -129,7 +129,7 @@ describe('BtcOnChainService', () => { const addresses = accounts.map((account) => account.address); await expect( - txnService.getBalances(addresses, [BtcAsset.TBtc]), + txService.getBalances(addresses, [BtcAsset.TBtc]), ).rejects.toThrow('Invalid asset'); }); }); @@ -137,21 +137,21 @@ describe('BtcOnChainService', () => { describe('getUtxos', () => { it('calls getUtxos with readClient', async () => { const { instance, getUtxosSpy } = createMockReadDataClient(); - const { instance: txnService } = createMockBtcService(instance); + const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const sender = accounts[0].address; const receiver = accounts[1].address; const mockResponse = generateBlockChairGetUtxosResp(sender, 10); const utxos = mockResponse.data[sender].utxo.map((utxo) => ({ block: utxo.block_id, - txnHash: utxo.transaction_hash, + txHash: utxo.transaction_hash, index: utxo.index, value: utxo.value, })); getUtxosSpy.mockResolvedValue(utxos); - const result = await txnService.getDataForTransaction(sender, { + const result = await txService.getDataForTransaction(sender, { amounts: { [receiver]: 100, }, @@ -169,7 +169,7 @@ describe('BtcOnChainService', () => { it('throws error if readClient fail', async () => { const { instance, getUtxosSpy } = createMockReadDataClient(); - const { instance: txnService } = createMockBtcService(instance); + const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const sender = accounts[0].address; const receiver = accounts[1].address; @@ -177,7 +177,7 @@ describe('BtcOnChainService', () => { getUtxosSpy.mockRejectedValue(new Error('error')); await expect( - txnService.getDataForTransaction(sender, { + txService.getDataForTransaction(sender, { amounts: { [receiver]: 100, }, @@ -191,13 +191,13 @@ describe('BtcOnChainService', () => { describe('getFeeRates', () => { it('return getFeeRates result', async () => { const { instance, getFeeRatesSpy } = createMockReadDataClient(); - const { instance: txnMgr } = createMockBtcService(instance); + const { instance: txMgr } = createMockBtcService(instance); getFeeRatesSpy.mockResolvedValue({ [FeeRatio.Fast]: 1.1, [FeeRatio.Medium]: 1.2, }); - const result = await txnMgr.getFeeRates(); + const result = await txMgr.getFeeRates(); expect(getFeeRatesSpy).toHaveBeenCalledTimes(1); expect(result).toStrictEqual({ @@ -218,13 +218,11 @@ describe('BtcOnChainService', () => { it('throws BtcOnChainServiceError error if an error catched', async () => { const { instance, getFeeRatesSpy } = createMockReadDataClient(); - const { instance: txnMgr } = createMockBtcService(instance); + const { instance: txMgr } = createMockBtcService(instance); getFeeRatesSpy.mockRejectedValue(new Error('error')); - await expect(txnMgr.getFeeRates()).rejects.toThrow( - BtcOnChainServiceError, - ); + await expect(txMgr.getFeeRates()).rejects.toThrow(BtcOnChainServiceError); }); }); @@ -234,15 +232,12 @@ describe('BtcOnChainService', () => { it('calls sendTransaction with writeClient', async () => { const { instance, sendTransactionSpy } = createMockWriteDataClient(); - const { instance: txnService } = createMockBtcService( - undefined, - instance, - ); + const { instance: txService } = createMockBtcService(undefined, instance); const resp = generateBlockChairBroadcastTransactionResp(); sendTransactionSpy.mockResolvedValue(resp.data.transaction_hash); - const result = await txnService.broadcastTransaction(signedTransaction); + const result = await txService.broadcastTransaction(signedTransaction); expect(sendTransactionSpy).toHaveBeenCalledWith(signedTransaction); expect(result).toStrictEqual({ @@ -252,32 +247,29 @@ describe('BtcOnChainService', () => { it('throws BtcOnChainServiceErrorr if write client execute fail', async () => { const { instance, sendTransactionSpy } = createMockWriteDataClient(); - const { instance: txnService } = createMockBtcService( - undefined, - instance, - ); + const { instance: txService } = createMockBtcService(undefined, instance); sendTransactionSpy.mockRejectedValue(new Error('error')); await expect( - txnService.broadcastTransaction(signedTransaction), + txService.broadcastTransaction(signedTransaction), ).rejects.toThrow(BtcOnChainServiceError); }); }); describe('getTransactionStatus', () => { - const txnHash = + const txHash = '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; it('return getTransactionStatus result', async () => { const { instance, getTransactionStatusSpy } = createMockReadDataClient(); - const { instance: txnMgr } = createMockBtcService(instance); + const { instance: txMgr } = createMockBtcService(instance); getTransactionStatusSpy.mockResolvedValue({ status: TransactionStatus.Confirmed, }); - const result = await txnMgr.getTransactionStatus(txnHash); + const result = await txMgr.getTransactionStatus(txHash); - expect(getTransactionStatusSpy).toHaveBeenCalledWith(txnHash); + expect(getTransactionStatusSpy).toHaveBeenCalledWith(txHash); expect(result).toStrictEqual({ status: TransactionStatus.Confirmed, }); @@ -285,11 +277,11 @@ describe('BtcOnChainService', () => { it('throws BtcOnChainServiceError error if an error catched', async () => { const { instance, getTransactionStatusSpy } = createMockReadDataClient(); - const { instance: txnMgr } = createMockBtcService(instance); + const { instance: txMgr } = createMockBtcService(instance); getTransactionStatusSpy.mockRejectedValue(new Error('error')); - await expect(txnMgr.getTransactionStatus(txnHash)).rejects.toThrow( + await expect(txMgr.getTransactionStatus(txHash)).rejects.toThrow( BtcOnChainServiceError, ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index cdea5f11..62ac91e5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -93,9 +93,9 @@ export class BtcOnChainService implements IOnChainService { } } - async getTransactionStatus(txnHash: string) { + async getTransactionStatus(txHash: string) { try { - return await this.readClient.getTransactionStatus(txnHash); + return await this.readClient.getTransactionStatus(txHash); } catch (error) { throw new BtcOnChainServiceError(error); } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.test.ts index 15f0ccfd..c335d064 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.test.ts @@ -231,7 +231,7 @@ describe('BlockChairClient', () => { const mockResponse = generateBlockChairGetUtxosResp(address, 10); const expectedResult = mockResponse.data[address].utxo.map((utxo) => ({ block: utxo.block_id, - txnHash: utxo.transaction_hash, + txHash: utxo.transaction_hash, index: utxo.index, value: utxo.value, })); @@ -268,7 +268,7 @@ describe('BlockChairClient', () => { expectedResult = expectedResult.concat( mockResponse.data[address].utxo.map((utxo) => ({ block: utxo.block_id, - txnHash: utxo.transaction_hash, + txHash: utxo.transaction_hash, index: utxo.index, value: utxo.value, })), @@ -361,13 +361,13 @@ describe('BlockChairClient', () => { }); describe('getTransactionStatus', () => { - const txnhash = + const txHash = '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; it('returns correct result for confirmed transaction', async () => { const { fetchSpy } = createMockFetch(); const mockResponse = generateBlockChairTransactionDashboard( - txnhash, + txHash, 200000, 200002, true, @@ -379,7 +379,7 @@ describe('BlockChairClient', () => { }); const instance = new BlockChairClient({ network: networks.testnet }); - const result = await instance.getTransactionStatus(txnhash); + const result = await instance.getTransactionStatus(txHash); expect(result).toStrictEqual({ status: TransactionStatus.Confirmed, @@ -389,7 +389,7 @@ describe('BlockChairClient', () => { it('returns correct result for pending transaction', async () => { const { fetchSpy } = createMockFetch(); const mockResponse = generateBlockChairTransactionDashboard( - txnhash, + txHash, 200000, 200002, false, @@ -401,7 +401,7 @@ describe('BlockChairClient', () => { }); const instance = new BlockChairClient({ network: networks.testnet }); - const result = await instance.getTransactionStatus(txnhash); + const result = await instance.getTransactionStatus(txHash); expect(result).toStrictEqual({ status: TransactionStatus.Pending, @@ -422,7 +422,7 @@ describe('BlockChairClient', () => { }); const instance = new BlockChairClient({ network: networks.testnet }); - const result = await instance.getTransactionStatus(txnhash); + const result = await instance.getTransactionStatus(txHash); expect(result).toStrictEqual({ status: TransactionStatus.Pending, @@ -439,7 +439,7 @@ describe('BlockChairClient', () => { const instance = new BlockChairClient({ network: networks.testnet }); - await expect(instance.getTransactionStatus(txnhash)).rejects.toThrow( + await expect(instance.getTransactionStatus(txHash)).rejects.toThrow( DataClientError, ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts index 8e84eb3c..7eb9a348 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts @@ -268,16 +268,16 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { return response.json() as unknown as Resp; } - protected async getTxnDashboardData( - txnHash: string, + protected async getTxDashboardData( + txHash: string, ): Promise { try { - logger.info(`[BlockChairClient.getTxnDashboardData] start:`); + logger.info(`[BlockChairClient.getTxDashboardData] start:`); const response = await this.get( - `/dashboards/transaction/${txnHash}`, + `/dashboards/transaction/${txHash}`, ); logger.info( - `[BlockChairClient.getTxnDashboardData] response: ${JSON.stringify( + `[BlockChairClient.getTxDashboardData] response: ${JSON.stringify( response, )}`, ); @@ -342,7 +342,7 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { response.data[address].utxo.forEach((utxo) => { data.push({ block: utxo.block_id, - txnHash: utxo.transaction_hash, + txHash: utxo.transaction_hash, index: utxo.index, value: utxo.value, }); @@ -397,17 +397,17 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { } } - async getTransactionStatus(txnHash: string): Promise { + async getTransactionStatus(txHash: string): Promise { try { - const response = await this.getTxnDashboardData(txnHash); + const response = await this.getTxDashboardData(txHash); let status = TransactionStatus.Pending; if ( typeof response.data === 'object' && - Object.prototype.hasOwnProperty.call(response.data, txnHash) + Object.prototype.hasOwnProperty.call(response.data, txHash) ) { - const isInMempool = response.data[txnHash].transaction.block_id === -1; + const isInMempool = response.data[txHash].transaction.block_id === -1; status = isInMempool ? TransactionStatus.Pending diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts index 2dc0373a..ae127a5b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts @@ -153,7 +153,7 @@ describe('BlockStreamClient', () => { const mockResponse = generateBlockStreamGetUtxosResp(100); const expectedResult = mockResponse.map((utxo) => ({ block: utxo.status.block_height, - txnHash: utxo.txid, + txHash: utxo.txid, index: utxo.vout, value: utxo.value, })); @@ -183,7 +183,7 @@ describe('BlockStreamClient', () => { ); const expectedResult = combinedResponse.map((utxo) => ({ block: utxo.status.block_height, - txnHash: utxo.txid, + txHash: utxo.txid, index: utxo.vout, value: utxo.value, })); @@ -213,7 +213,7 @@ describe('BlockStreamClient', () => { ); const expectedResult = mockConfirmedResponse.map((utxo) => ({ block: utxo.status.block_height, - txnHash: utxo.txid, + txHash: utxo.txid, index: utxo.vout, value: utxo.value, })); @@ -242,23 +242,23 @@ describe('BlockStreamClient', () => { }); describe('getTransactionStatus', () => { - const txnhash = + const txHash = '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; it('returns correct result for confirmed transaction', async () => { const { fetchSpy } = createMockFetch(); - const mockTxnStatusResponse = generateBlockStreamTransactionStatusResp( + const mockTxStatusResponse = generateBlockStreamTransactionStatusResp( 200000, true, ); fetchSpy.mockResolvedValueOnce({ ok: true, - json: jest.fn().mockResolvedValue(mockTxnStatusResponse), + json: jest.fn().mockResolvedValue(mockTxStatusResponse), }); const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getTransactionStatus(txnhash); + const result = await instance.getTransactionStatus(txHash); expect(result).toStrictEqual({ status: TransactionStatus.Confirmed, @@ -268,18 +268,18 @@ describe('BlockStreamClient', () => { it('returns correct result for pending transaction', async () => { const { fetchSpy } = createMockFetch(); - const mockTxnStatusResponse = generateBlockStreamTransactionStatusResp( + const mockTxStatusResponse = generateBlockStreamTransactionStatusResp( 200000, false, ); fetchSpy.mockResolvedValueOnce({ ok: true, - json: jest.fn().mockResolvedValue(mockTxnStatusResponse), + json: jest.fn().mockResolvedValue(mockTxStatusResponse), }); const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getTransactionStatus(txnhash); + const result = await instance.getTransactionStatus(txHash); expect(result).toStrictEqual({ status: TransactionStatus.Pending, @@ -297,7 +297,7 @@ describe('BlockStreamClient', () => { const instance = new BlockStreamClient({ network: networks.testnet }); - await expect(instance.getTransactionStatus(txnhash)).rejects.toThrow( + await expect(instance.getTransactionStatus(txHash)).rejects.toThrow( DataClientError, ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts index 846dab31..227c6cb9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts @@ -80,7 +80,7 @@ export class BlockStreamClient implements IReadDataClient { this.feeRateRatioMap = { [FeeRatio.Fast]: '1', [FeeRatio.Medium]: '144', - [FeeRatio.Slow]: '1008', + [FeeRatio.Slow]: '144', }; } @@ -162,7 +162,7 @@ export class BlockStreamClient implements IReadDataClient { } data.push({ block: utxo.status.block_height, - txnHash: utxo.txid, + txHash: utxo.txid, index: utxo.vout, value: utxo.value, }); @@ -203,13 +203,13 @@ export class BlockStreamClient implements IReadDataClient { } } - async getTransactionStatus(txnHash: string): Promise { + async getTransactionStatus(txHash: string): Promise { try { - const txnStatusResp = await this.get( - `/tx/${txnHash}/status`, + const txStatusResp = await this.get( + `/tx/${txHash}/status`, ); - const status = txnStatusResp.confirmed + const status = txStatusResp.confirmed ? TransactionStatus.Confirmed : TransactionStatus.Pending; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/types.ts index 7c157671..f7c18ba5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/types.ts @@ -1,4 +1,4 @@ -import type { Balances, FeeRatio, TransactionStatusData } from '../../../chain'; +import type { Balances, FeeRatio, TransactionStatusData } from '../../chain'; import type { Utxo } from '../wallet'; export type GetFeeRatesResp = { @@ -9,7 +9,7 @@ export type IReadDataClient = { getBalances(address: string[]): Promise; getUtxos(address: string, includeUnconfirmed?: boolean): Promise; getFeeRates(): Promise; - getTransactionStatus(txnHash: string): Promise; + getTransactionStatus(txHash: string): Promise; }; export type IWriteDataClient = { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts index 0a647d21..b41d07c2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts @@ -1,7 +1,3 @@ -import { networks } from 'bitcoinjs-lib'; - -import { Network } from '../constants'; -import { getExplorerUrl } from '../utils'; import { BtcAddress } from './address'; describe('BtcAddress', () => { @@ -9,53 +5,20 @@ describe('BtcAddress', () => { describe('toString', () => { it('returns a address', async () => { - const network = networks.testnet; - const val = new BtcAddress(address, network); + const val = new BtcAddress(address); expect(val.toString()).toStrictEqual(address); }); it('returns a shortern address', async () => { - const network = networks.testnet; - const val = new BtcAddress(address, network); + const val = new BtcAddress(address); expect(val.toString(true)).toBe('tb1qt...aeu'); }); }); - describe('toJson', () => { - it('returns a json', async () => { - const network = networks.testnet; - const val = new BtcAddress(address, network); - expect(val.toJson()).toStrictEqual({ - address: val.toString(true), - rawAddress: val.toString(false), - explorerUrl: val.explorerUrl, - }); - }); - }); - describe('valueOf', () => { it('returns a value', async () => { - const network = networks.testnet; - const val = new BtcAddress(address, network); - expect(val.valueOf()).toStrictEqual(val.address); - }); - }); - - describe('explorerUrl', () => { - it('returns a testnet explorerUrl', async () => { - const network = networks.testnet; - const val = new BtcAddress(address, network); - expect(val.explorerUrl).toStrictEqual( - getExplorerUrl(address, Network.Testnet), - ); - }); - - it('returns a mainnet explorerUrl', async () => { - const network = networks.bitcoin; - const val = new BtcAddress(address, network); - expect(val.explorerUrl).toStrictEqual( - getExplorerUrl(address, Network.Mainnet), - ); + const val = new BtcAddress(address); + expect(val.valueOf()).toStrictEqual(val.value); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts index ef8e95b8..271bee60 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts @@ -1,37 +1,22 @@ -import type { Json } from '@metamask/snaps-sdk'; import type { Network } from 'bitcoinjs-lib'; import { replaceMiddleChar } from '../../utils'; import type { IAddress } from '../../wallet'; -import { getCaip2ChainId, getExplorerUrl } from '../utils'; export class BtcAddress implements IAddress { - address: string; + value: string; - network: Network; + network?: Network; - constructor(address: string, network: Network) { - this.address = address; - this.network = network; - } - - get explorerUrl(): string { - return getExplorerUrl(this.address, getCaip2ChainId(this.network)); + constructor(address: string) { + this.value = address; } valueOf(): string { - return this.address; + return this.value; } toString(isShortern = false): string { - return isShortern ? replaceMiddleChar(this.address, 5, 3) : this.address; - } - - toJson(): Record { - return { - address: this.toString(true), - rawAddress: this.address, - explorerUrl: this.explorerUrl, - }; + return isShortern ? replaceMiddleChar(this.value, 5, 3) : this.value; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts index 48fd0685..82985c15 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts @@ -1,4 +1,3 @@ -import { Chain, Config } from '../../config'; import { satsToBtc } from '../utils'; import { BtcAmount } from './amount'; @@ -15,17 +14,6 @@ describe('BtcAmount', () => { }); }); - describe('toJson', () => { - it('returns a json', async () => { - const val = new BtcAmount(1); - expect(val.toJson()).toStrictEqual({ - value: val.toString(true), - unit: Config.unit[Chain.Bitcoin], - rawValue: val.value, - }); - }); - }); - describe('valueOf', () => { it('returns a value', async () => { const val = new BtcAmount(1); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts index 7cb5d5dc..0b272924 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts @@ -1,22 +1,12 @@ -import type { Json } from '@metamask/snaps-sdk'; - import { Chain, Config } from '../../config'; import type { IAmount } from '../../wallet'; import { satsToBtc } from '../utils'; export class BtcAmount implements IAmount { - #_value: number; + value: number; constructor(value: number) { - this.#_value = value; - } - - get value(): number { - return this.#_value; - } - - set value(newValue: number) { - this.#_value = newValue; + this.value = value; } get unit(): string { @@ -33,12 +23,4 @@ export class BtcAmount implements IAmount { } return `${satsToBtc(this.value)} ${this.unit}`; } - - toJson(): Record { - return { - value: this.toString(true), - unit: this.unit, - rawValue: this.value, - }; - } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts index 73aba9aa..e01e03e4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts @@ -1,42 +1,84 @@ -import { Buffer } from 'buffer'; +import { networks } from 'bitcoinjs-lib'; -import { generateAccounts, generateFormatedUtxos } from '../../../test/utils'; +import { generateFormatedUtxos } from '../../../test/utils'; +import { ScriptType } from '../constants'; import { CoinSelectService } from './coin-select'; +import { BtcAccountBip32Deriver } from './deriver'; +import { SelectionResult } from './selection-result'; +import { TxInput } from './transaction-input'; +import { TxOutput } from './transaction-output'; +import { BtcWallet } from './wallet'; + +jest.mock('../../libs/snap/helpers'); describe('CoinSelectService', () => { + const createMockWallet = (network) => { + const instance = new BtcWallet( + new BtcAccountBip32Deriver(network), + network, + ); + return { + instance, + }; + }; + + const prepareCoinSlect = async ( + network, + outputVal = 1000, + inputMin = 2000, + inputMax = 1000000, + ) => { + const wallet = createMockWallet(network); + const sender = await wallet.instance.unlock(0, ScriptType.P2wpkh); + const receiver1 = await wallet.instance.unlock(1, ScriptType.P2wpkh); + const receiver2 = await wallet.instance.unlock(2, ScriptType.P2wpkh); + + const utxos = generateFormatedUtxos(sender.address, 2, inputMin, inputMax); + + const outputs = [new TxOutput(outputVal, receiver1.address)]; + + const inputs = utxos.map( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (utxo) => new TxInput(utxo, sender.payment.output!), + ); + + return { + sender, + receiver1, + receiver2, + utxos, + outputs, + inputs, + }; + }; + describe('selectCoins', () => { it('selects utxos', async () => { - const accounts = generateAccounts(2); - const { address } = accounts[0]; - const utxos = generateFormatedUtxos(address, 2, 2000, 1000000); + const network = networks.testnet; + const { inputs, outputs, sender } = await prepareCoinSlect(network); const coinSelectService = new CoinSelectService(1); - const result = coinSelectService.selectCoins( - utxos, - [{ address: accounts[1].address, value: 1000 }], - Buffer.from('scriptOutput'), - ); + const result = coinSelectService.selectCoins(inputs, outputs, sender); - expect(result).toHaveProperty('inputs', expect.any(Array)); - expect(result).toHaveProperty('outputs', expect.any(Array)); - expect(result).toHaveProperty('fee', expect.any(Number)); + expect(result).toBeInstanceOf(SelectionResult); + expect(result.fee).toBeGreaterThan(1); }); - it('throws `not enough funds` error if the given utxos is not sufficient', async () => { - const accounts = generateAccounts(2); - const { address } = accounts[0]; - const utxos = generateFormatedUtxos(address, 1, 1, 1); + it('throws `Insufficient funds` error if the given utxos is not sufficient', async () => { + const network = networks.testnet; + const { inputs, outputs, sender } = await prepareCoinSlect( + network, + 100000, + 1, + 10, + ); const coinSelectService = new CoinSelectService(100); expect(() => - coinSelectService.selectCoins( - utxos, - [{ address: accounts[1].address, value: 1000 }], - Buffer.from('scriptOutput'), - ), - ).toThrow('Not enough funds'); + coinSelectService.selectCoins(inputs, outputs, sender), + ).toThrow('Insufficient funds'); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index 7b838135..542d933c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -1,10 +1,11 @@ -import { crypto as cryptoUtils } from 'bitcoinjs-lib'; -import type { Buffer } from 'buffer'; import coinSelect from 'coinselect'; -import { hexToBuffer } from '../../utils'; -import { TransactionValidationError } from './exceptions'; -import type { SpendTo, SelectedUtxos, Utxo } from './types'; +import type { Recipient } from '../../wallet'; +import { TxValidationError } from './exceptions'; +import { SelectionResult } from './selection-result'; +import type { TxInput } from './transaction-input'; +import { TxOutput } from './transaction-output'; +import { type IBtcAccount } from './types'; export class CoinSelectService { protected readonly feeRate: number; @@ -14,48 +15,33 @@ export class CoinSelectService { } selectCoins( - utxos: Utxo[], - spendTos: SpendTo[], - script: Buffer, - ): SelectedUtxos { - const utxosMap = new Map(); - - const spendFrom = utxos.map((utxo) => { - const id = cryptoUtils - .sha256( - hexToBuffer( - `${utxo.txnHash},${utxo.block},${utxo.index},${utxo.value}`, - false, - ), - ) - .toString('hex'); - - utxosMap.set(id, utxo); - - return { - id, - value: utxo.value, - script, - }; - }); - - const result = coinSelect(spendFrom, spendTos, this.feeRate); + inputs: TxInput[], + recipients: Recipient[], + changeAccount: IBtcAccount, + ): SelectionResult { + const result = coinSelect(inputs, recipients, this.feeRate); if (!result.inputs || !result.outputs) { - throw new TransactionValidationError('Not enough funds'); + throw new TxValidationError('Insufficient funds'); } - const inputs: Utxo[] = []; - for (const input of result.inputs) { - const utxo = utxosMap.get(input.id); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - inputs.push(utxo!); + const selectedResult = new SelectionResult(); + selectedResult.fee = result.fee; + selectedResult.selectedInputs = result.inputs; + + for (const output of result.outputs) { + if (output.address) { + selectedResult.selectedOutputs.push( + new TxOutput(output.value, output.address), + ); + } else { + selectedResult.change = new TxOutput( + output.value, + changeAccount.address, + ); + } } - return { - ...result, - inputs, - }; + return selectedResult; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts index 2d60e84f..8dcb3463 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts @@ -6,7 +6,7 @@ export class WalletFactoryError extends CustomError {} export class WalletError extends CustomError {} -export class TransactionValidationError extends WalletError {} +export class TxValidationError extends WalletError {} export class PsbtServiceError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts index 7c36a8e7..c025f6ea 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts @@ -6,5 +6,5 @@ export * from './signer'; export * from './deriver'; export * from './wallet'; export * from './address'; -export * from './transactionInfo'; +export * from './transaction-info'; export * from './amount'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts index 9aea4198..6357706d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts @@ -6,6 +6,8 @@ import { MaxStandardTxWeight, ScriptType } from '../constants'; import { BtcAccountBip32Deriver } from './deriver'; import { PsbtServiceError } from './exceptions'; import { PsbtService } from './psbt'; +import { TxInput } from './transaction-input'; +import { TxOutput } from './transaction-output'; import { BtcWallet } from './wallet'; jest.mock('../../libs/logger/logger'); @@ -22,51 +24,60 @@ describe('PsbtService', () => { }; }; - describe('constructor', () => { - const preparePsbt = async () => { - const network = networks.testnet; - const service = new PsbtService(network); - const wallet = createMockWallet(network); - const sender = await wallet.instance.unlock(0, ScriptType.P2wpkh); - const mfpBuf = hexToBuffer(sender.mfp, false); - const pubkeyBuf = hexToBuffer(sender.pubkey, false); - const receiver1 = await wallet.instance.unlock(1, ScriptType.P2wpkh); - const receiver2 = await wallet.instance.unlock(2, ScriptType.P2wpkh); - const receivers = [receiver1, receiver2]; - const finalizeSpy = jest.spyOn(service.psbt, 'finalizeAllInputs'); - const outputVal = 1000; - const fee = 500; - const outputs = receivers.map((account) => ({ - address: account.address, - value: outputVal, - })); - // it has to limit the inputs because it may fail due to alert of paying too much gas fee - const inputs = generateFormatedUtxos( - sender.address, - 1, - outputVal * outputs.length + fee, - outputVal * outputs.length + fee, - ); + const preparePsbt = async (rbfOptIn = false, inputsCnt = 2) => { + const network = networks.testnet; + const service = new PsbtService(network); + const wallet = createMockWallet(network); + const sender = await wallet.instance.unlock(0, ScriptType.P2wpkh); + const receiver1 = await wallet.instance.unlock(1, ScriptType.P2wpkh); + const receiver2 = await wallet.instance.unlock(2, ScriptType.P2wpkh); + const receivers = [receiver1, receiver2]; + const finalizeSpy = jest.spyOn(service.psbt, 'finalizeAllInputs'); + const inputSpy = jest.spyOn(service.psbt, 'addInput'); + const outputSpy = jest.spyOn(service.psbt, 'addOutput'); + const signSpy = jest.spyOn(service.psbt, 'signAllInputsHDAsync'); + const verifySpy = jest.spyOn(service.psbt, 'validateSignaturesOfAllInputs'); + const transactionWeightSpy = jest.spyOn(Transaction.prototype, 'weight'); + const transactionHexSpy = jest.spyOn(Transaction.prototype, 'toHex'); + + const outputVal = 1000; + const fee = 500; + + const outputs = receivers.map( + (account) => new TxOutput(outputVal, account.address), + ); + const utxos = generateFormatedUtxos( + sender.address, + inputsCnt, + outputVal * outputs.length + fee, + outputVal * outputs.length + fee, + ); + const inputs = utxos.map( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (utxo) => new TxInput(utxo, sender.payment.output!), + ); - service.addOutputs(outputs); + service.addOutputs(outputs); - service.addInputs( - inputs, - mfpBuf, - pubkeyBuf, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - sender.payment.output!, - sender.hdPath, - true, - ); + service.addInputs(inputs, sender, rbfOptIn); - return { - service, - sender, - finalizeSpy, - }; + return { + service, + sender, + receivers, + finalizeSpy, + inputSpy, + outputSpy, + signSpy, + verifySpy, + inputs, + outputs, + transactionWeightSpy, + transactionHexSpy, }; + }; + describe('constructor', () => { it('constructor with a new psbt object', async () => { const network = networks.testnet; @@ -76,52 +87,38 @@ describe('PsbtService', () => { }); it('constructor with an psbt base string', async () => { - const { service } = await preparePsbt(); + const { service } = await preparePsbt(false, 2); const psbtBase64 = service.toBase64(); const newService = PsbtService.fromBase64(networks.testnet, psbtBase64); - expect(newService.psbt.txInputs).toHaveLength(1); + expect(newService.psbt.txInputs).toHaveLength(2); expect(newService.psbt.txOutputs).toHaveLength(2); }); }); describe('addInputs', () => { it('adds witnessUtxos to psbt object', async () => { - const network = networks.testnet; - const service = new PsbtService(network); - const wallet = createMockWallet(network); - const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); - const inputs = generateFormatedUtxos(account.address, 2); - const mfpBuf = hexToBuffer(account.mfp, false); - const pubkeyBuf = hexToBuffer(account.pubkey, false); - const psbtSpy = jest.spyOn(service.psbt, 'addInput'); - - service.addInputs( - inputs, - mfpBuf, - pubkeyBuf, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - account.payment.output!, - account.hdPath, + const { service, inputSpy, inputs, sender } = await preparePsbt( false, + 10, ); - expect(service.psbt.txInputs).toHaveLength(2); + expect(service.psbt.txInputs).toHaveLength(10); - for (let i = 0; i < inputs.length; i++) { - expect(psbtSpy).toHaveBeenNthCalledWith(i + 1, { - hash: inputs[i].txnHash, + for (let i = 0; i < service.psbt.txInputs.length; i++) { + expect(inputSpy).toHaveBeenNthCalledWith(i + 1, { + hash: inputs[i].txHash, index: inputs[i].index, witnessUtxo: { - script: account.payment.output, + script: inputs[i].scriptBuf, value: inputs[i].value, }, bip32Derivation: [ { - masterFingerprint: mfpBuf, - path: account.hdPath, - pubkey: pubkeyBuf, + masterFingerprint: hexToBuffer(sender.mfp, false), + path: sender.hdPath, + pubkey: hexToBuffer(sender.pubkey, false), }, ], sequence: Transaction.DEFAULT_SEQUENCE, @@ -130,38 +127,21 @@ describe('PsbtService', () => { }); it('opt-ins RBF into the psbt input', async () => { - const network = networks.testnet; - const service = new PsbtService(network); - const wallet = createMockWallet(network); - const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); - const inputs = generateFormatedUtxos(account.address, 2); - const mfpBuf = hexToBuffer(account.mfp, false); - const pubkeyBuf = hexToBuffer(account.pubkey, false); - const psbtSpy = jest.spyOn(service.psbt, 'addInput'); - - service.addInputs( - inputs, - mfpBuf, - pubkeyBuf, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - account.payment.output!, - account.hdPath, - true, - ); + const { service, inputSpy, inputs, sender } = await preparePsbt(true); - for (let i = 0; i < inputs.length; i++) { - expect(psbtSpy).toHaveBeenNthCalledWith(i + 1, { - hash: inputs[i].txnHash, + for (let i = 0; i < service.psbt.txInputs.length; i++) { + expect(inputSpy).toHaveBeenNthCalledWith(i + 1, { + hash: inputs[i].txHash, index: inputs[i].index, witnessUtxo: { - script: account.payment.output, + script: inputs[i].scriptBuf, value: inputs[i].value, }, bip32Derivation: [ { - masterFingerprint: mfpBuf, - path: account.hdPath, - pubkey: pubkeyBuf, + masterFingerprint: hexToBuffer(sender.mfp, false), + path: sender.hdPath, + pubkey: hexToBuffer(sender.pubkey, false), }, ], sequence: Transaction.DEFAULT_SEQUENCE - 2, @@ -170,48 +150,20 @@ describe('PsbtService', () => { }); it('throws `Failed to add inputs in PSBT` error if adding inputs fails', async () => { - const network = networks.testnet; - const service = new PsbtService(network); - const wallet = createMockWallet(network); - const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); - const inputs = generateFormatedUtxos(account.address, 2); - const mfpBuf = hexToBuffer(account.mfp, false); - const pubkeyBuf = hexToBuffer(account.pubkey, false); - - jest.spyOn(service.psbt, 'addInput').mockImplementation(() => { + const { service, inputSpy, sender, inputs } = await preparePsbt(); + inputSpy.mockImplementation(() => { throw new Error('error'); }); expect(() => { - service.addInputs( - inputs, - mfpBuf, - pubkeyBuf, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - account.payment.output!, - account.hdPath, - true, - ); + service.addInputs(inputs, sender, false); }).toThrow('Failed to add inputs in PSBT'); }); }); describe('addOutputs', () => { it('adds outputs to psbt object', async () => { - const network = networks.testnet; - const service = new PsbtService(network); - const wallet = createMockWallet(network); - const receiver1 = await wallet.instance.unlock(1, ScriptType.P2wpkh); - const receiver2 = await wallet.instance.unlock(2, ScriptType.P2wpkh); - const receivers = [receiver1, receiver2]; - - const outputs = receivers.map((account) => ({ - address: account.address, - value: 1000, - })); - - service.addOutputs(outputs); - + const { service, outputs, receivers } = await preparePsbt(); expect(service.psbt.txOutputs).toHaveLength(outputs.length); for (let i = 0; i < service.psbt.txOutputs.length; i++) { @@ -231,15 +183,10 @@ describe('PsbtService', () => { }); it('throws `Failed to add outputs in PSBT` error if adding outputs fails', async () => { - const network = networks.testnet; - const service = new PsbtService(network); - - const outputs = [ - { - address: 'address', - value: 1000, - }, - ]; + const { service, outputSpy, outputs } = await preparePsbt(); + outputSpy.mockImplementation(() => { + throw new Error('error'); + }); expect(() => service.addOutputs(outputs)).toThrow( 'Failed to add outputs in PSBT', @@ -270,50 +217,8 @@ describe('PsbtService', () => { }); describe('signNVerify', () => { - const prepareToSign = async () => { - const network = networks.testnet; - const service = new PsbtService(network); - const wallet = createMockWallet(network); - const sender = await wallet.instance.unlock(0, ScriptType.P2wpkh); - const inputs = generateFormatedUtxos(sender.address, 2); - const mfpBuf = hexToBuffer(sender.mfp, false); - const pubkeyBuf = hexToBuffer(sender.pubkey, false); - const receiver1 = await wallet.instance.unlock(1, ScriptType.P2wpkh); - const receiver2 = await wallet.instance.unlock(2, ScriptType.P2wpkh); - const receivers = [receiver1, receiver2]; - const signSpy = jest.spyOn(service.psbt, 'signAllInputsHDAsync'); - const verifySpy = jest.spyOn( - service.psbt, - 'validateSignaturesOfAllInputs', - ); - - const outputs = receivers.map((account) => ({ - address: account.address, - value: 1000, - })); - - service.addOutputs(outputs); - - service.addInputs( - inputs, - mfpBuf, - pubkeyBuf, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - sender.payment.output!, - sender.hdPath, - true, - ); - - return { - service, - sender, - signSpy, - verifySpy, - }; - }; - it('signs all inputs with the given signer', async () => { - const { service, sender, signSpy, verifySpy } = await prepareToSign(); + const { service, sender, signSpy, verifySpy } = await preparePsbt(); await service.signNVerify(sender.signer); @@ -322,7 +227,7 @@ describe('PsbtService', () => { }); it("throws `Invalid signature to sign the PSBT's inputs` error if signature is incorrect", async () => { - const { service, sender, verifySpy } = await prepareToSign(); + const { service, sender, verifySpy } = await preparePsbt(); verifySpy.mockReturnValue(false); @@ -333,62 +238,12 @@ describe('PsbtService', () => { }); describe('finalize', () => { - const prepareToFinalize = async () => { - const network = networks.testnet; - const service = new PsbtService(network); - const wallet = createMockWallet(network); - const sender = await wallet.instance.unlock(0, ScriptType.P2wpkh); - const mfpBuf = hexToBuffer(sender.mfp, false); - const pubkeyBuf = hexToBuffer(sender.pubkey, false); - const receiver1 = await wallet.instance.unlock(1, ScriptType.P2wpkh); - const receiver2 = await wallet.instance.unlock(2, ScriptType.P2wpkh); - const receivers = [receiver1, receiver2]; - - const finalizeSpy = jest.spyOn(service.psbt, 'finalizeAllInputs'); - const transactionWeightSpy = jest.spyOn(Transaction.prototype, 'weight'); - const transactionHexSpy = jest.spyOn(Transaction.prototype, 'toHex'); - - const outputVal = 1000; - const fee = 500; - const outputs = receivers.map((account) => ({ - address: account.address, - value: outputVal, - })); - // it has to limit the inputs because it may fail due to alert of paying too much gas fee - const inputs = generateFormatedUtxos( - sender.address, - 1, - outputVal * outputs.length + fee, - outputVal * outputs.length + fee, - ); - - service.addOutputs(outputs); - - service.addInputs( - inputs, - mfpBuf, - pubkeyBuf, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - sender.payment.output!, - sender.hdPath, - true, - ); + it('returns an transaction hex', async () => { + const { service, finalizeSpy, sender, transactionHexSpy } = + await preparePsbt(); await service.signNVerify(sender.signer); - return { - service, - sender, - finalizeSpy, - transactionWeightSpy, - transactionHexSpy, - }; - }; - - it('returns an transaction hex', async () => { - const { service, finalizeSpy, transactionHexSpy } = - await prepareToFinalize(); - const txHex = service.finalize(); expect(txHex).not.toBeNull(); @@ -397,8 +252,10 @@ describe('PsbtService', () => { expect(transactionHexSpy).toHaveBeenCalled(); }); - it('throws `Transaction is too large` error if the txn weight is too large', async () => { - const { service, transactionWeightSpy } = await prepareToFinalize(); + it('throws `Transaction is too large` error if the transaction weight is too large', async () => { + const { service, sender, transactionWeightSpy } = await preparePsbt(); + + await service.signNVerify(sender.signer); transactionWeightSpy.mockReturnValue(MaxStandardTxWeight + 1000); @@ -406,7 +263,9 @@ describe('PsbtService', () => { }); it('throws PsbtServiceError error if finalize operation failed', async () => { - const { service, finalizeSpy } = await prepareToFinalize(); + const { service, sender, finalizeSpy } = await preparePsbt(); + + await service.signNVerify(sender.signer); finalizeSpy.mockImplementation(() => { throw new Error('error'); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index 99a7c71c..0b7ab632 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -5,11 +5,13 @@ import type { Buffer } from 'buffer'; import ECPairFactory from 'ecpair'; import { logger } from '../../libs/logger/logger'; -import { compactError } from '../../utils'; +import { compactError, hexToBuffer } from '../../utils'; import type { IAccountSigner } from '../../wallet'; import { MaxStandardTxWeight } from '../constants'; -import { PsbtServiceError, TransactionValidationError } from './exceptions'; -import type { SpendTo, Utxo } from './types'; +import { PsbtServiceError, TxValidationError } from './exceptions'; +import type { TxInput } from './transaction-input'; +import type { TxOutput } from './transaction-output'; +import type { IBtcAccount } from './types'; const ECPair = ECPairFactory(ecc); @@ -37,20 +39,21 @@ export class PsbtService { } addInputs( - inputs: Utxo[], - mfp: Buffer, - changeAddressPubkey: Buffer, - changeAddressScriptHash: Buffer, - changeAddressHdPath: string, + inputs: TxInput[], + changeAccount: IBtcAccount, replaceable: boolean, ) { try { + const changeAddressHdPath = changeAccount.hdPath; + const changeAddressPubkey = hexToBuffer(changeAccount.pubkey, false); + const changeAddressMfp = hexToBuffer(changeAccount.mfp, false); + for (const input of inputs) { this._psbt.addInput({ - hash: input.txnHash, + hash: input.txHash, index: input.index, witnessUtxo: { - script: changeAddressScriptHash, + script: input.scriptBuf, value: input.value, }, // This is useful because as long as you store the masterFingerprint on @@ -59,7 +62,7 @@ export class PsbtService { // and your signer just needs to pass in an HDSigner interface (ie. bip32 library) bip32Derivation: [ { - masterFingerprint: mfp, + masterFingerprint: changeAddressMfp, path: changeAddressHdPath, pubkey: changeAddressPubkey, }, @@ -79,9 +82,14 @@ export class PsbtService { } } - addOutputs(outputs: SpendTo[]) { + addOutputs(outputs: TxOutput[]) { try { - this._psbt.addOutputs(outputs); + for (const output of outputs) { + this._psbt.addOutput({ + address: output.address, + value: output.value, + }); + } } catch (error) { logger.error('Failed to add outputs', error); throw new PsbtServiceError('Failed to add outputs in PSBT'); @@ -110,7 +118,7 @@ export class PsbtService { this.validateInputs(pubkey, msghash, signature), ) ) { - throw new TransactionValidationError( + throw new TxValidationError( "Invalid signature to sign the PSBT's inputs", ); } @@ -128,7 +136,7 @@ export class PsbtService { const weight = this._psbt.extractTransaction().weight(); if (weight > MaxStandardTxWeight) { - throw new TransactionValidationError('Transaction is too large'); + throw new TxValidationError('Transaction is too large'); } return txHex; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/selection-result.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/selection-result.ts new file mode 100644 index 00000000..a9549bc5 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/selection-result.ts @@ -0,0 +1,17 @@ +import type { TxInput } from './transaction-input'; +import type { TxOutput } from './transaction-output'; + +export class SelectionResult { + selectedInputs: TxInput[]; + + selectedOutputs: TxOutput[]; + + change: TxOutput; + + fee: number; + + constructor() { + this.selectedInputs = []; + this.selectedOutputs = []; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts new file mode 100644 index 00000000..d1a48dd7 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts @@ -0,0 +1,66 @@ +import { networks } from 'bitcoinjs-lib'; + +import { generateAccounts } from '../../../test/utils'; +import { getCaip2ChainId, getExplorerUrl, satsToBtc } from '../utils'; +import { BtcAddress } from './address'; +import { BtcTxInfo } from './transaction-info'; +import { TxOutput } from './transaction-output'; + +describe('BtcTxInfo', () => { + describe('toJson', () => { + it('returns a json', async () => { + const network = networks.testnet; + const accounts = generateAccounts(5); + const addresses = accounts.map((account) => account.address); + const fee = 10000; + let total = fee; + const outputs: TxOutput[] = []; + + for (let i = 1; i < addresses.length; i++) { + total += 100000; + outputs.push(new TxOutput(100000, addresses[i])); + } + const info = new BtcTxInfo( + new BtcAddress(addresses[0]), + outputs, + 10000, + 100, + network, + ); + + info.change = new TxOutput(500, addresses[0]); + total += 500; + + const expectedRecipients = outputs.map((recipient) => { + return { + address: recipient.destination.toString(true), + value: recipient.amount.toString(true), + explorerUrl: getExplorerUrl( + recipient.destination.value, + getCaip2ChainId(network), + ), + }; + }); + + const expectedChange = [ + { + address: info.change.destination.toString(true), + value: info.change.amount.toString(true), + explorerUrl: getExplorerUrl( + info.change.destination.value, + getCaip2ChainId(network), + ), + }, + ]; + + expect(info.toJson()).toStrictEqual({ + feeRate: `${satsToBtc(100)} BTC`, + txFee: `${satsToBtc(10000)} BTC`, + sender: addresses[0], + recipients: expectedRecipients, + changes: expectedChange, + total: `${satsToBtc(total)} BTC`, + }); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts new file mode 100644 index 00000000..05d0c1ba --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts @@ -0,0 +1,98 @@ +import type { Json } from '@metamask/snaps-sdk'; +import type { Network } from 'bitcoinjs-lib'; + +import type { ITxInfo } from '../../wallet'; +import { getCaip2ChainId, getExplorerUrl } from '../utils'; +import type { BtcAddress } from './address'; +import { BtcAmount } from './amount'; +import type { TxOutput } from './transaction-output'; + +export class BtcTxInfo implements ITxInfo { + #recipients: TxOutput[]; + + #feeRate: BtcAmount; + + change?: TxOutput; + + #sender: BtcAddress; + + #txFee: BtcAmount; + + #outputTotal: BtcAmount; + + #serializedRecipients: Json[]; + + #network: Network; + + constructor( + sender: BtcAddress, + outputs: TxOutput[], + fee: number, + feeRate: number, + network: Network, + ) { + this.#recipients = []; + this.#outputTotal = new BtcAmount(0); + this.#serializedRecipients = []; + this.#feeRate = new BtcAmount(feeRate); + this.#txFee = new BtcAmount(fee); + this.#network = network; + this.#sender = sender; + this.addRecipients(outputs); + } + + protected changeToJson(): Json { + return this.change + ? [ + { + address: this.change.destination.toString(true), + value: this.change.amount.toString(true), + explorerUrl: getExplorerUrl( + this.change.destination.value, + getCaip2ChainId(this.#network), + ), + }, + ] + : []; + } + + protected addRecipients(outputs: TxOutput[]): void { + for (const output of outputs) { + this.#outputTotal.value += output.value; + + this.#recipients.push(output); + + this.#serializedRecipients.push({ + address: output.destination.toString(true), + value: output.amount.toString(true), + explorerUrl: getExplorerUrl( + output.destination.value, + getCaip2ChainId(this.#network), + ), + }); + } + } + + bumpFee(val: number): void { + this.#txFee.value += val; + } + + get total(): BtcAmount { + return new BtcAmount( + this.#outputTotal.value + + (this.change ? this.change.value : 0) + + this.#txFee.value, + ); + } + + toJson>(): InfoJson { + return { + feeRate: this.#feeRate.toString(true), + txFee: this.#txFee.toString(true), + sender: this.#sender.toString(), + recipients: this.#serializedRecipients, + changes: this.changeToJson(), + total: this.total.toString(true), + } as unknown as InfoJson; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts new file mode 100644 index 00000000..2b0760f0 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts @@ -0,0 +1,39 @@ +import { networks } from 'bitcoinjs-lib'; + +import { generateFormatedUtxos } from '../../../test/utils'; +import { hexToBuffer } from '../../utils'; +import { ScriptType } from '../constants'; +import { BtcAccountBip32Deriver } from './deriver'; +import { TxInput } from './transaction-input'; +import { BtcWallet } from './wallet'; + +jest.mock('../../libs/snap/helpers'); + +describe('TxInput', () => { + const createMockWallet = (network) => { + const instance = new BtcWallet( + new BtcAccountBip32Deriver(network), + network, + ); + return { + instance, + }; + }; + + it('return correct property', async () => { + const wallet = createMockWallet(networks.testnet); + const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); + const script = account.payment.output?.toString('hex') as unknown as string; + const scriptBuf = hexToBuffer(script, false); + const utxo = generateFormatedUtxos(account.address, 1)[0]; + + const input = new TxInput(utxo, scriptBuf); + + expect(input.scriptBuf).toStrictEqual(scriptBuf); + expect(input.script).toStrictEqual(script); + expect(input.value).toStrictEqual(utxo.value); + expect(input.txHash).toStrictEqual(utxo.txHash); + expect(input.index).toStrictEqual(utxo.index); + expect(input.block).toStrictEqual(utxo.block); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts new file mode 100644 index 00000000..4f0d6d63 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts @@ -0,0 +1,42 @@ +import type { Buffer } from 'buffer'; + +import { bufferToString } from '../../utils'; +import type { IAmount } from '../../wallet'; +import { BtcAmount } from './amount'; +import type { Utxo } from './types'; + +export class TxInput { + scriptBuf: Buffer; + + amount: IAmount; + + utxo: Utxo; + + constructor(utxo: Utxo, script: Buffer) { + this.scriptBuf = script; + this.utxo = utxo; + this.amount = new BtcAmount(utxo.value); + } + + // consume by coinselect + get script(): string { + return bufferToString(this.scriptBuf, 'hex'); + } + + // consume by coinselect + get value(): number { + return this.amount.value; + } + + get txHash(): string { + return this.utxo.txHash; + } + + get index(): number { + return this.utxo.index; + } + + get block(): number { + return this.utxo.block; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts new file mode 100644 index 00000000..2ae196b7 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts @@ -0,0 +1,14 @@ +import { generateAccounts } from '../../../test/utils'; +import { TxOutput } from './transaction-output'; + +describe('TxOutput', () => { + it('return correct property', () => { + const account = generateAccounts(1)[0]; + + const input = new TxOutput(10, account.address); + + expect(input.value).toBe(10); + expect(input.address).toStrictEqual(account.address); + expect(input.value).toBe(10); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts new file mode 100644 index 00000000..93b3ad5c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts @@ -0,0 +1,22 @@ +import type { IAddress, IAmount } from '../../wallet'; +import { BtcAddress } from './address'; +import { BtcAmount } from './amount'; + +export class TxOutput { + amount: IAmount; + + destination: IAddress; + + constructor(value: number, address: string) { + this.amount = new BtcAmount(value); + this.destination = new BtcAddress(address); + } + + get value(): number { + return this.amount.value; + } + + get address(): string { + return this.destination.value; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transactionInfo.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transactionInfo.test.ts deleted file mode 100644 index 093800a3..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transactionInfo.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { networks } from 'bitcoinjs-lib'; - -import { generateAccounts } from '../../../test/utils'; -import { satsToBtc } from '../utils'; -import { BtcAddress } from './address'; -import { BtcAmount } from './amount'; -import { BtcTransactionInfo } from './transactionInfo'; - -describe('BtcTransactionInfo', () => { - describe('toJson', () => { - it('returns a json', async () => { - const accounts = generateAccounts(5); - const addresses = accounts.map((account) => account.address); - const info = new BtcTransactionInfo(); - info.feeRate.value = 100; - info.txnFee.value = 10000; - info.sender = new BtcAddress(addresses[0], networks.testnet); - let total = info.txnFee.value; - - for (let i = 1; i < addresses.length; i++) { - total += 100000; - info.recipients.set( - new BtcAddress(addresses[i], networks.testnet), - new BtcAmount(100000), - ); - } - info.changes.set( - new BtcAddress(addresses[0], networks.testnet), - new BtcAmount(500), - ); - total += 500; - - const expectedRecipients = [...info.recipients.entries()].map( - ([address, value]) => { - return { - ...address.toJson(), - ...value.toJson(), - }; - }, - ); - - const expectedChanges = [...info.changes.entries()].map( - ([address, value]) => { - return { - ...address.toJson(), - ...value.toJson(), - }; - }, - ); - - expect(info.toJson()).toStrictEqual({ - feeRate: `${satsToBtc(100)} BTC`, - txnFee: `${satsToBtc(10000)} BTC`, - sender: addresses[0], - recipients: expectedRecipients, - changes: expectedChanges, - total: `${satsToBtc(total)} BTC`, - }); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transactionInfo.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transactionInfo.ts deleted file mode 100644 index e46a8001..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transactionInfo.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { Json } from '@metamask/snaps-sdk'; - -import type { ITransactionInfo } from '../../wallet'; -import type { BtcAddress } from './address'; -import { BtcAmount } from './amount'; - -export class BtcTransactionInfo implements ITransactionInfo { - recipients: Map; - - changes: Map; - - sender?: BtcAddress; - - feeRate: BtcAmount; - - txnFee: BtcAmount; - - _total: BtcAmount; - - constructor() { - this.recipients = new Map(); - this.changes = new Map(); - this.feeRate = new BtcAmount(1); - this.txnFee = new BtcAmount(0); - this._total = new BtcAmount(0); - } - - get total(): BtcAmount { - this._total.value = this.txnFee.value; - this.recipients.forEach((value) => { - this._total.value += value.value; - }); - this.changes.forEach((value) => { - this._total.value += value.value; - }); - return this._total; - } - - recipientsToJson(recipients: Map) { - const recipientsArr: Json[] = []; - - recipients.forEach((value, address) => { - recipientsArr.push({ - ...address.toJson(), - ...value.toJson(), - }); - }); - return recipientsArr; - } - - toJson>(): InfoJson { - return { - feeRate: this.feeRate.toString(true), - txnFee: this.txnFee.toString(true), - sender: this.sender?.toString(), - recipients: this.recipientsToJson(this.recipients), - changes: this.recipientsToJson(this.changes), - total: this.total.toString(true), - } as unknown as InfoJson; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts index d57997f2..6ef5432a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts @@ -12,6 +12,7 @@ export type IBtcAccountDeriver = { export type IBtcAccount = IAccount & { payment: Payment; scriptType: ScriptType; + network: Network; }; export type IStaticBtcAccount = { @@ -39,25 +40,9 @@ export type CreateTransactionOptions = { replaceable: boolean; }; -export type SpendTo = { - address: string; - value: number; -}; - -export type SelectedOutput = { - address?: string; - value: number; -}; - -export type SelectedUtxos = { - inputs: Utxo[]; - outputs: SelectedOutput[]; - fee: number; -}; - export type Utxo = { block: number; - txnHash: string; + txHash: string; index: number; value: number; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index 8ba60eb7..c4628933 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -2,12 +2,17 @@ import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { DustLimit, ScriptType } from '../constants'; +import type { BtcAccount } from './account'; import { P2SHP2WPKHAccount, P2WPKHAccount } from './account'; +import { BtcAmount } from './amount'; import { CoinSelectService } from './coin-select'; import { BtcAccountBip32Deriver } from './deriver'; import { WalletError } from './exceptions'; import { PsbtService } from './psbt'; -import { BtcTransactionInfo } from './transactionInfo'; +import { SelectionResult } from './selection-result'; +import { BtcTxInfo } from './transaction-info'; +import { TxInput } from './transaction-input'; +import { TxOutput } from './transaction-output'; import { BtcWallet } from './wallet'; jest.mock('../../libs/snap/helpers'); @@ -36,14 +41,13 @@ describe('BtcWallet', () => { }; }; - const createMockTxnIndent = (address: string, amount: number) => { - return { - amounts: { - [address]: amount, + const createMockTxIndent = (address: string, amount: number) => { + return [ + { + address, + value: amount, }, - subtractFeeFrom: [], - replaceable: false, - }; + ]; }; describe('unlock', () => { @@ -104,7 +108,7 @@ describe('BtcWallet', () => { const result = await wallet.createTransaction( account, - createMockTxnIndent(account.address, DustLimit[account.scriptType] + 1), + createMockTxIndent(account.address, 15000), { utxos, fee: 1, @@ -112,19 +116,10 @@ describe('BtcWallet', () => { replaceable: false, }, ); - - const info: BtcTransactionInfo = - result.txnInfo as unknown as BtcTransactionInfo; - expect(result).toStrictEqual({ - txn: expect.any(String), - txnInfo: expect.any(BtcTransactionInfo), + tx: expect.any(String), + txInfo: expect.any(BtcTxInfo), }); - - expect(info.sender?.address).toStrictEqual(account.address); - expect(info.feeRate?.value).toBe(1); - expect(info.recipients?.size).toBe(1); - expect(info.changes?.size).toBe(1); }); it('passes correct parameter to CoinSelectService', async () => { @@ -140,7 +135,7 @@ describe('BtcWallet', () => { await wallet.createTransaction( account, - createMockTxnIndent(account.address, DustLimit[account.scriptType] + 1), + createMockTxIndent(account.address, DustLimit[account.scriptType] + 1), { utxos, fee: 1, @@ -150,24 +145,33 @@ describe('BtcWallet', () => { ); expect(coinSelectServiceSpy).toHaveBeenCalledWith( - utxos, - [ - { - address: account.address, - value: DustLimit[account.scriptType] + 1, - }, - ], - account.payment.output, + expect.any(Array), + expect.any(Array), + account, ); + + for (const input of coinSelectServiceSpy.mock.calls[0][0]) { + expect(input).toBeInstanceOf(TxInput); + } + + for (const output of coinSelectServiceSpy.mock.calls[0][1]) { + expect(output).toStrictEqual({ + address: account.address, + value: DustLimit[account.scriptType] + 1, + }); + } }); it('remove dist change', async () => { const network = networks.testnet; const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); - const chgAccount = await wallet.unlock(0, ScriptType.P2wpkh); + const chgAccount = (await wallet.unlock( + 0, + ScriptType.P2wpkh, + )) as unknown as BtcAccount; const recipient = await wallet.unlock(1, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(recipient.address, 2); + const utxos = generateFormatedUtxos(chgAccount.address, 2); const coinSelectServiceSpy = jest.spyOn( CoinSelectService.prototype, 'selectCoins', @@ -178,23 +182,24 @@ describe('BtcWallet', () => { // to avoid modifiy the original object when we needed to test the output psbtServiceSpy.mockReturnThis(); - coinSelectServiceSpy.mockReturnValue({ - inputs: utxos, - outputs: [ - { - address: recipient.address, - value: 500, - }, - { - value: DustLimit[chgAccount.scriptType] - 1, - }, - ], - fee: 100, - }); + + const selectionResult = new SelectionResult(); + selectionResult.selectedInputs = utxos.map( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (utxo) => new TxInput(utxo, chgAccount.payment.output!), + ); + selectionResult.selectedOutputs = [new TxOutput(500, recipient.address)]; + selectionResult.fee = 100; + selectionResult.change = new TxOutput( + DustLimit[chgAccount.scriptType] - 1, + chgAccount.address, + ); + + coinSelectServiceSpy.mockReturnValue(selectionResult); const result = await wallet.createTransaction( chgAccount, - createMockTxnIndent(recipient.address, 500), + createMockTxIndent(recipient.address, 500), { utxos, fee: 1, @@ -203,18 +208,19 @@ describe('BtcWallet', () => { }, ); - const info: BtcTransactionInfo = - result.txnInfo as unknown as BtcTransactionInfo; + const info: BtcTxInfo = result.txInfo as unknown as BtcTxInfo; - expect(psbtServiceSpy).toHaveBeenCalledWith([ - { - address: recipient.address, - value: 500, - }, - ]); + expect(psbtServiceSpy).toHaveBeenCalledWith( + selectionResult.selectedOutputs, + ); + + const jsonInfo = info.toJson(); - expect(info.txnFee.value).toStrictEqual( - 100 + DustLimit[chgAccount.scriptType] - 1, + expect(jsonInfo).toHaveProperty( + 'txFee', + new BtcAmount(100 + DustLimit[chgAccount.scriptType] - 1).toString( + true, + ), ); }); @@ -228,7 +234,7 @@ describe('BtcWallet', () => { await expect( wallet.createTransaction( account, - createMockTxnIndent(account.address, 1), + createMockTxIndent(account.address, 1), { utxos, fee: 20, @@ -250,7 +256,7 @@ describe('BtcWallet', () => { await expect( wallet.createTransaction( account, - createMockTxnIndent( + createMockTxIndent( account.address, DustLimit[account.scriptType] + 1, ), @@ -271,11 +277,11 @@ describe('BtcWallet', () => { const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(account.address, 2); + const utxos = generateFormatedUtxos(account.address, 2, 10000, 10000); - const { txn } = await wallet.createTransaction( + const { tx } = await wallet.createTransaction( account, - createMockTxnIndent(account.address, DustLimit[account.scriptType] + 1), + createMockTxIndent(account.address, DustLimit[account.scriptType] + 1), { utxos, fee: 1, @@ -284,7 +290,7 @@ describe('BtcWallet', () => { }, ); - const sign = await wallet.signTransaction(account.signer, txn); + const sign = await wallet.signTransaction(account.signer, tx); expect(sign).not.toBeNull(); expect(sign).not.toBe(''); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index f4fb7db2..3c617535 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -1,25 +1,28 @@ import type { BIP32Interface } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; -import type { TransactionIntent } from '../../chain'; import { logger } from '../../libs/logger/logger'; -import { bufferToString, compactError, hexToBuffer } from '../../utils'; -import type { IAccountSigner, ITransactionInfo, IWallet } from '../../wallet'; +import { bufferToString, compactError } from '../../utils'; +import type { + IAccountSigner, + IWallet, + Recipient, + TxCreationResult, +} from '../../wallet'; import { ScriptType } from '../constants'; import { isDust } from '../utils'; import { P2WPKHAccount, P2SHP2WPKHAccount } from './account'; import { BtcAddress } from './address'; -import { BtcAmount } from './amount'; import { CoinSelectService } from './coin-select'; -import { WalletError, TransactionValidationError } from './exceptions'; +import { WalletError, TxValidationError } from './exceptions'; import { PsbtService } from './psbt'; import { AccountSigner } from './signer'; -import { BtcTransactionInfo } from './transactionInfo'; +import { BtcTxInfo } from './transaction-info'; +import { TxInput } from './transaction-input'; import type { IStaticBtcAccount, IBtcAccountDeriver, IBtcAccount, - SpendTo, CreateTransactionOptions, } from './types'; @@ -72,12 +75,9 @@ export class BtcWallet implements IWallet { async createTransaction( account: IBtcAccount, - txn: TransactionIntent, + recipients: Recipient[], options: CreateTransactionOptions, - ): Promise<{ - txn: string; - txnInfo: ITransactionInfo; - }> { + ): Promise { const scriptOutput = account.payment.output; const { scriptType } = account; @@ -90,94 +90,59 @@ export class BtcWallet implements IWallet { const feeRate = Math.max(1, options.fee); const coinSelectService = new CoinSelectService(feeRate); - const spendTos = Object.entries(txn.amounts).map(([address, value]) => { - return { - address, - value, - }; - }); - - logger.info( - `[BtcWallet.createTransaction] Incoming inputs: ${JSON.stringify( - options.utxos, - null, - 2, - )}, Incoming outputs: ${JSON.stringify(spendTos, null, 2)}`, - ); - const { inputs, outputs, fee } = coinSelectService.selectCoins( - options.utxos, - spendTos, - scriptOutput, + const inputs = options.utxos.map((utxo) => new TxInput(utxo, scriptOutput)); + + const selectionResult = coinSelectService.selectCoins( + inputs, + recipients, + account, ); - logger.info( - `[BtcWallet.createTransaction] Selected inputs: ${JSON.stringify( - inputs, - null, - 2, - )}, Selected outputs: ${JSON.stringify(outputs, null, 2)}`, + // TODO: add support of subtractFeeFrom, and throw error if output is too small after subtraction + for (const output of selectionResult.selectedOutputs) { + if (isDust(output.value, scriptType)) { + throw new TxValidationError('Transaction amount too small'); + } + } + + const psbtOutputs = selectionResult.selectedOutputs; + const psbtInputs = selectionResult.selectedInputs; + + const info = new BtcTxInfo( + new BtcAddress(account.address), + selectionResult.selectedOutputs, + selectionResult.fee, + feeRate, + this.network, ); - const info = new BtcTransactionInfo(); - info.feeRate.value = feeRate; - info.txnFee.value = fee; - info.sender = new BtcAddress(account.address, this.network); - - const psbtOutputs: SpendTo[] = []; - - for (const output of outputs) { - if (output.address === undefined) { - // discard change output if it is dust and add to fees - if (isDust(output.value, scriptType)) { - logger.info( - '[BtcWallet.createTransaction] Change is too small, adding to fees', - ); - info.txnFee.value += output.value; - continue; - } - info.changes.set( - new BtcAddress(account.address, this.network), - new BtcAmount(output.value), + if (selectionResult.change && selectionResult.change.value > 0) { + if (isDust(selectionResult.change.value, scriptType)) { + logger.warn( + '[BtcWallet.createTransaction] Change is too small, adding to fees', ); + info.bumpFee(selectionResult.change.value); } else { - // dust outputs is forbidden - if (isDust(output.value, scriptType)) { - throw new TransactionValidationError('Transaction amount too small'); - } - info.recipients.set( - new BtcAddress(output.address, this.network), - new BtcAmount(output.value), - ); + info.change = selectionResult.change; + psbtOutputs.push(selectionResult.change); } - - psbtOutputs.push({ - address: output.address ?? account.address, - value: output.value, - }); } const psbtService = new PsbtService(this.network); - psbtService.addInputs( - inputs, - hexToBuffer(account.mfp, false), - hexToBuffer(account.pubkey, false), - scriptOutput, - account.hdPath, - options.replaceable, - ); + psbtService.addInputs(psbtInputs, account, options.replaceable); psbtService.addOutputs(psbtOutputs); return { - txn: psbtService.toBase64(), - txnInfo: info, + tx: psbtService.toBase64(), + txInfo: info, }; } - async signTransaction(signer: IAccountSigner, txn: string): Promise { - const psbtService = PsbtService.fromBase64(this.network, txn); + async signTransaction(signer: IAccountSigner, tx: string): Promise { + const psbtService = PsbtService.fromBase64(this.network, tx); await psbtService.signNVerify(signer); return psbtService.finalize(); } diff --git a/merged-packages/bitcoin-wallet-snap/src/chain.ts b/merged-packages/bitcoin-wallet-snap/src/chain.ts index 9a6eac12..45f640ff 100644 --- a/merged-packages/bitcoin-wallet-snap/src/chain.ts +++ b/merged-packages/bitcoin-wallet-snap/src/chain.ts @@ -86,10 +86,10 @@ export type IOnChainService = { /** * Gets the status of a transaction with the given transaction hash. * - * @param txnHash - The transaction hash of the transaction to get the status of. + * @param txHash - The transaction hash of the transaction to get the status of. * @returns A promise that resolves to a `TransactionStatusData` object. */ - getTransactionStatus(txnHash: string): Promise; + getTransactionStatus(txHash: string): Promise; /** * Gets the required metadata to build a transaction for the given address and transaction intent. diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts index e434ac07..430d1c04 100644 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts @@ -74,7 +74,7 @@ describe('SnapHelper', () => { state: { transaction: [ { - txnHash: 'hash', + txHash: 'hash', chainId: 'chainId', }, ], @@ -102,7 +102,7 @@ describe('SnapHelper', () => { state: { transaction: [ { - txnHash: 'hash', + txHash: 'hash', chainId: 'chainId', }, ], diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.test.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.test.ts index 1169876c..956542fc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.test.ts @@ -8,7 +8,7 @@ import { SnapStateManager } from './state'; jest.mock('../logger/logger'); type MockTransactionDetail = { - txnHash: string; + txHash: string; cnt: number; }; @@ -24,7 +24,7 @@ type MockState = { }; type MockExecuteTransactionInput = { - txnHash: string; + txHash: string; id: string; cnt: number; }; @@ -113,12 +113,12 @@ describe('SnapStateManager', () => { ) { state.transaction.push(data.id); state.trasansactionDetails[data.id] = { - txnHash: data.txnHash, + txHash: data.txHash, cnt: data.cnt, }; } else { state.trasansactionDetails[data.id] = { - txnHash: data.txnHash, + txHash: data.txHash, cnt: state.trasansactionDetails[data.id].cnt + data.cnt, }; } @@ -180,7 +180,7 @@ describe('SnapStateManager', () => { const state = { transaction: [ { - txnHash: 'hash', + txHash: 'hash', chainId: 'chainId', }, ], @@ -202,13 +202,13 @@ describe('SnapStateManager', () => { state: { transaction: [ { - txnHash: 'hash', + txHash: 'hash', chainId: 'chainId', }, ], }, data: { - txnHash: 'hash2', + txHash: 'hash2', chainId: 'chainId2', }, }; @@ -236,7 +236,7 @@ describe('SnapStateManager', () => { transaction: ['id'], trasansactionDetails: { id: { - txnHash: 'hash', + txHash: 'hash', cnt: 4, }, }, @@ -254,7 +254,7 @@ describe('SnapStateManager', () => { const promiseArr = [ executeTransationFn( { - txnHash: 'hash-final', + txHash: 'hash-final', id: 'id', cnt: 2, }, @@ -262,7 +262,7 @@ describe('SnapStateManager', () => { ), executeTransationFn( { - txnHash: 'hash2', + txHash: 'hash2', id: 'id2', cnt: 5, }, @@ -275,11 +275,11 @@ describe('SnapStateManager', () => { expect(initState.trasansactionDetails).toStrictEqual({ id: { - txnHash: 'hash-final', + txHash: 'hash-final', cnt: 6, }, id2: { - txnHash: 'hash2', + txHash: 'hash2', cnt: 5, }, }); @@ -293,7 +293,7 @@ describe('SnapStateManager', () => { transaction: ['id'], trasansactionDetails: { id: { - txnHash: 'hash', + txHash: 'hash', cnt: 4, }, }, @@ -308,7 +308,7 @@ describe('SnapStateManager', () => { const promiseArr = [ executeTransationFn( { - txnHash: 'hash4', + txHash: 'hash4', id: 'id', cnt: 1, }, @@ -316,7 +316,7 @@ describe('SnapStateManager', () => { ), executeTransationFn( { - txnHash: 'hash-final', + txHash: 'hash-final', id: 'id', cnt: 2, }, @@ -326,7 +326,7 @@ describe('SnapStateManager', () => { ), executeTransationFn( { - txnHash: 'hash2', + txHash: 'hash2', id: 'id2', cnt: 5, }, @@ -339,11 +339,11 @@ describe('SnapStateManager', () => { expect(initState.transaction).toStrictEqual(['id', 'id2']); expect(initState.trasansactionDetails).toStrictEqual({ id: { - txnHash: 'hash4', + txHash: 'hash4', cnt: 5, }, id2: { - txnHash: 'hash2', + txHash: 'hash2', cnt: 5, }, }); @@ -357,7 +357,7 @@ describe('SnapStateManager', () => { transaction: ['id'], trasansactionDetails: { id: { - txnHash: 'hash', + txHash: 'hash', cnt: 4, }, }, @@ -373,7 +373,7 @@ describe('SnapStateManager', () => { try { await executeTransationFn( { - txnHash: 'hash-final', + txHash: 'hash-final', id: 'id', cnt: 2, }, @@ -389,7 +389,7 @@ describe('SnapStateManager', () => { expect(setStateDataSpy).toHaveBeenCalledTimes(0); expect(initState.trasansactionDetails).toStrictEqual({ id: { - txnHash: 'hash', + txHash: 'hash', cnt: 4, }, }); @@ -402,7 +402,7 @@ describe('SnapStateManager', () => { transaction: ['id'], trasansactionDetails: { id: { - txnHash: 'hash', + txHash: 'hash', cnt: 4, }, }, @@ -423,7 +423,7 @@ describe('SnapStateManager', () => { const promiseArr = [ executeTransationFn( { - txnHash: 'hash-final', + txHash: 'hash-final', id: 'id', cnt: 2, }, @@ -433,7 +433,7 @@ describe('SnapStateManager', () => { ), executeTransationFn( { - txnHash: 'hash-final', + txHash: 'hash-final', id: 'id', cnt: 2, }, @@ -447,7 +447,7 @@ describe('SnapStateManager', () => { expect(initState.transaction).toStrictEqual(['id']); expect(initState.trasansactionDetails).toStrictEqual({ id: { - txnHash: 'hash-final', + txHash: 'hash-final', cnt: 6, }, }); @@ -467,7 +467,7 @@ describe('SnapStateManager', () => { const promiseArr = [ executeTransationFn( { - txnHash: 'hash', + txHash: 'hash', id: 'id', cnt: 2, }, @@ -475,7 +475,7 @@ describe('SnapStateManager', () => { ), executeTransationFn( { - txnHash: 'hash2', + txHash: 'hash2', id: 'id2', cnt: 5, }, @@ -483,7 +483,7 @@ describe('SnapStateManager', () => { ), executeFn( { - txnHash: 'hash32', + txHash: 'hash32', id: 'id3', cnt: 8, }, @@ -491,7 +491,7 @@ describe('SnapStateManager', () => { ), executeTransationFn( { - txnHash: 'hash-updated', + txHash: 'hash-updated', id: 'id', cnt: 1, }, @@ -499,7 +499,7 @@ describe('SnapStateManager', () => { ), executeTransationFn( { - txnHash: 'hash3', + txHash: 'hash3', id: 'id3', cnt: 2, }, @@ -507,7 +507,7 @@ describe('SnapStateManager', () => { ), executeTransationFn( { - txnHash: 'hash-updated-final', + txHash: 'hash-updated-final', id: 'id', cnt: 3, }, @@ -520,15 +520,15 @@ describe('SnapStateManager', () => { expect(initState.transaction).toStrictEqual(['id', 'id2', 'id3']); expect(initState.trasansactionDetails).toStrictEqual({ id: { - txnHash: 'hash-updated-final', + txHash: 'hash-updated-final', cnt: 6, }, id2: { - txnHash: 'hash2', + txHash: 'hash2', cnt: 5, }, id3: { - txnHash: 'hash3', + txHash: 'hash3', cnt: 10, }, }); @@ -554,7 +554,7 @@ describe('SnapStateManager', () => { await expect( executeTransationFn( { - txnHash: 'hash-final', + txHash: 'hash-final', id: 'id', cnt: 2, }, @@ -590,7 +590,7 @@ describe('SnapStateManager', () => { await expect( executeTransationFn( { - txnHash: 'hash-final', + txHash: 'hash-final', id: 'id', cnt: 2, }, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts index 5fcd4f6b..36017ab5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts @@ -8,7 +8,7 @@ import { GetTransactionStatusHandler } from './get-transaction-status'; jest.mock('../libs/logger/logger'); describe('GetBalancesHandler', () => { - const txnHash = + const txHash = '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; describe('handleRequest', () => { @@ -39,10 +39,10 @@ describe('GetBalancesHandler', () => { const result = await GetTransactionStatusHandler.getInstance().execute({ scope: Network.Testnet, - transactionId: txnHash, + transactionId: txHash, }); - expect(getTransactionStatusSpy).toHaveBeenCalledWith(txnHash); + expect(getTransactionStatusSpy).toHaveBeenCalledWith(txHash); expect(result).toStrictEqual({ status: TransactionStatus.Confirmed, }); @@ -56,7 +56,7 @@ describe('GetBalancesHandler', () => { await expect( GetTransactionStatusHandler.getInstance().execute({ scope: Network.Testnet, - transactionId: txnHash, + transactionId: txHash, }), ).rejects.toThrow(`Fail to get the transaction status`); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 3cfe58f9..436927f4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -16,9 +16,10 @@ import { BtcAccountBip32Deriver, BtcWallet, BtcAmount, + BtcTxInfo, BtcAddress, - BtcTransactionInfo, } from '../bitcoin/wallet'; +import { TxOutput } from '../bitcoin/wallet/transaction-output'; import { FeeRatio } from '../chain'; import { Factory } from '../factory'; import { SnapHelper } from '../libs/snap'; @@ -121,7 +122,7 @@ describe('SendManyHandler', () => { ); return mockResponse.data[address].utxo.map((utxo) => ({ block: utxo.block_id, - txnHash: utxo.transaction_hash, + txHash: utxo.transaction_hash, index: utxo.index, value: utxo.value, })); @@ -236,32 +237,34 @@ describe('SendManyHandler', () => { }); }); - it('display `Recipient` as label in dialog if there is only 1 receipient', async () => { + it('display `Recipient` as label in dialog if there is only 1 recipient', async () => { const network = networks.testnet; const caip2ChainId = Network.Testnet; const { keyringAccount, recipients, snapHelperSpy } = await prepareSendMany(network, caip2ChainId); - const walletCreateTxnSpy = jest.spyOn( + const walletCreateTxSpy = jest.spyOn( BtcWallet.prototype, 'createTransaction', ); - const walletSignTxnSpy = jest.spyOn( + const walletSignTxSpy = jest.spyOn( BtcWallet.prototype, 'signTransaction', ); - const info = new BtcTransactionInfo(); - info.recipients.set( - new BtcAddress(recipients[0].address, networks.testnet), - new BtcAmount(100), + const info = new BtcTxInfo( + new BtcAddress(recipients[0].address), + [new TxOutput(100000, recipients[0].address)], + 1, + 1, + network, ); - walletCreateTxnSpy.mockResolvedValue({ - txn: 'txn', - txnInfo: info, + walletCreateTxSpy.mockResolvedValue({ + tx: 'transaction', + txInfo: info, }); - walletSignTxnSpy.mockResolvedValue('txId'); + walletSignTxSpy.mockResolvedValue('txId'); await SendManyHandler.getInstance({ scope: caip2ChainId, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index e23ed97b..e7e4ec0c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -16,11 +16,11 @@ import { array, boolean, refine, + optional, } from 'superstruct'; import { btcToSats } from '../bitcoin/utils'; -import { TransactionValidationError } from '../bitcoin/wallet'; -import { type TransactionIntent } from '../chain'; +import { TxValidationError } from '../bitcoin/wallet'; import { Factory } from '../factory'; import { type Wallet as WalletData } from '../keyring'; import { logger } from '../libs/logger/logger'; @@ -28,7 +28,7 @@ import { SnapRpcHandlerRequestStruct } from '../libs/rpc'; import type { IStaticSnapRpcHandler } from '../libs/rpc'; import { SnapHelper } from '../libs/snap'; import type { StaticImplements } from '../types/static'; -import type { IAccount, ITransactionInfo, IWallet } from '../wallet'; +import type { ITxInfo } from '../wallet'; import { KeyringRpcHandler } from './keyring-rpc'; export type SendManyParams = Infer; @@ -54,9 +54,9 @@ export const TransactionAmountStuct = refine( }, ); -export type TxnJson = { +export type TxJson = { feeRate: string; - txnFee: string; + txFee: string; total: string; sender: string; recipients: { @@ -67,6 +67,7 @@ export type TxnJson = { changes: { address: string; value: string; + explorerUrl: string; }[]; }; @@ -76,14 +77,6 @@ export class SendManyHandler { protected override isThrowValidationError = true; - walletData: WalletData; - - wallet: IWallet; - - walletAccount: IAccount; - - transactionIntent: TransactionIntent; - constructor(walletData: WalletData) { super(); this.walletData = walletData; @@ -105,15 +98,10 @@ export class SendManyHandler static override get responseStruct() { return object({ txId: string(), + txHash: optional(string()), }); } - protected override async preExecute(params: SendManyParams): Promise { - await super.preExecute(params); - const transactionIntent = this.formatTxnIndents(params); - this.transactionIntent = transactionIntent; - } - async handleRequest(params: SendManyParams): Promise { try { const { scope } = this.walletData; @@ -131,14 +119,20 @@ export class SendManyHandler 1, ); + const recipients = Object.entries(params.amounts).map( + ([address, value]) => ({ + address, + value: parseInt(btcToSats(parseFloat(value)), 10), + }), + ); + const metadata = await chainApi.getDataForTransaction( this.walletAccount.address, - this.transactionIntent, ); - const { txn, txnInfo } = await this.wallet.createTransaction( + const { tx, txInfo } = await this.wallet.createTransaction( this.walletAccount, - this.transactionIntent, + recipients, { utxos: metadata.data.utxos, fee, @@ -147,22 +141,23 @@ export class SendManyHandler }, ); - if (!(await this.getTxnConsensus(txnInfo, params.comment))) { + if (!(await this.getTxConsensus(txInfo, params.comment))) { throw new UserRejectedRequestError() as unknown as Error; } - const txnHash = await this.wallet.signTransaction( + const txHash = await this.wallet.signTransaction( this.walletAccount.signer, - txn, + tx, ); if (dryrun) { return { - txId: txnHash, + txId: '', + txHash, }; } - const result = await chainApi.broadcastTransaction(txnHash); + const result = await chainApi.broadcastTransaction(txHash); return { txId: result.transactionId, @@ -170,7 +165,7 @@ export class SendManyHandler } catch (error) { logger.error('Failed to send the transaction', error); if ( - error instanceof TransactionValidationError || + error instanceof TxValidationError || error instanceof UserRejectedRequestError ) { throw error as unknown as Error; @@ -179,20 +174,8 @@ export class SendManyHandler } } - protected formatTxnIndents(params: SendManyParams): TransactionIntent { - const { amounts, subtractFeeFrom, replaceable } = params; - return { - amounts: Object.entries(amounts).reduce((acc, [address, amount]) => { - acc[address] = parseInt(btcToSats(parseFloat(amount)), 10); - return acc; - }, {}), - subtractFeeFrom, - replaceable, - }; - } - - protected async getTxnConsensus( - txnInfo: ITransactionInfo, + protected async getTxConsensus( + txInfo: ITxInfo, comment: string, ): Promise { const header = `Send Request`; @@ -205,14 +188,14 @@ export class SendManyHandler const totalLabel = `Total`; const components: Component[] = [heading(header), text(intro), divider()]; - const info = txnInfo.toJson(); + const info = txInfo.toJson(); const isMoreThanOneRecipient = info.recipients.length + info.changes.length > 1; let i = 0; - const addReceiptentsToComponents = (data: { + const addReciptentsToComponents = (data: { address: string; explorerUrl: string; value: string; @@ -230,14 +213,14 @@ export class SendManyHandler i += 1; }; - info.recipients.forEach(addReceiptentsToComponents); - info.changes.forEach(addReceiptentsToComponents); + info.recipients.forEach(addReciptentsToComponents); + info.changes.forEach(addReciptentsToComponents); if (comment.trim().length > 0) { components.push(row(commentLabel, text(comment.trim()))); } - components.push(row(networkFeeLabel, text(`${info.txnFee}`, false))); + components.push(row(networkFeeLabel, text(`${info.txFee}`, false))); components.push(row(networkFeeRateLabel, text(`${info.feeRate}`, false))); diff --git a/merged-packages/bitcoin-wallet-snap/src/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/wallet.ts index 3c79acf1..8cc5c0b9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/wallet.ts @@ -1,24 +1,37 @@ import type { Json } from '@metamask/snaps-sdk'; import type { Buffer } from 'buffer'; -import type { TransactionIntent } from './chain'; +export type Recipient = { + address: string; + value: number; +}; + +export type TxCreationResult = { + tx: string; + txInfo: ITxInfo; +}; /** * An interface that defines a `toJson` method for getting a JSON representation of a transaction info object. */ -export type ITransactionInfo = { +export type ITxInfo = { /** * Returns a JSON representation of the transaction info object. * * @returns The JSON representation of the transaction info object. */ - toJson>(): TxnInfoJson; + toJson>(): TxInfoJson; }; /** * An interface that defines methods and properties for working with blockchain addresses. */ export type IAddress = { + /** + * The string value of the address. + */ + value: string; + /** * Returns the string representation of the address. * @@ -26,18 +39,6 @@ export type IAddress = { * @returns The string representation of the address. */ toString(isShorten?: boolean): string; - - /** - * The URL of the explorer for this address. - */ - explorerUrl: string; - - /** - * Returns a JSON representation of the address. - * - * @returns The JSON representation of the address. - */ - toJson(): Record; }; /** @@ -61,13 +62,6 @@ export type IAmount = { * @returns The string representation of the amount. */ toString(withUnit?: boolean): string; - - /** - * Returns a JSON representation of the amount. - * - * @returns The JSON representation of the amount. - */ - toJson(): Record; }; /** @@ -121,27 +115,24 @@ export type IWallet = { * Signs a transaction by the given encoded transaction string. * * @param signer - The `IAccountSigner` object to sign the transaction. - * @param txn - The encoded transaction string to convert back to a transaction. + * @param transaction - The encoded transaction string to convert back to a transaction. * @returns A promise that resolves to a string of the signed transaction. */ - signTransaction(signer: IAccountSigner, txn: string): Promise; + signTransaction(signer: IAccountSigner, transaction: string): Promise; /** * Creates a transaction using the given account, transaction intent, and options. * * @param account - The `IAccount` object to create the transaction. - * @param txnIntent - The transaction intent object containing the transaction inputs and outputs. + * @param recipients - The transaction recipients. * @param options - The options to use when creating the transaction. * @returns A promise that resolves to an object containing the transaction hash and transaction info. */ createTransaction( account: IAccount, - txnIntent: TransactionIntent, + recipients: Recipient[], options: Record, - ): Promise<{ - txn: string; - txnInfo: ITransactionInfo; - }>; + ): Promise; }; /** diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index 973a5786..5152e881 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -404,14 +404,14 @@ export function generateBlockStreamTransactionStatusResp( /** * Method to generate blockchair transaction dashboards resp. * - * @param txnHash - Transaction hash of the transaction. + * @param txHash - Transaction hash of the transaction. * @param txnBlockHeight - Block height of the transaction. * @param txnBlockHeight - Block height of the last block. * @param confirmed - Confirm status of the transaction. * @returns A blockchair transaction dashboards resp. */ export function generateBlockChairTransactionDashboard( - txnHash: string, + txHash: string, txnBlockHeight: number, lastBlockHeight: number, confirmed: boolean, @@ -420,7 +420,7 @@ export function generateBlockChairTransactionDashboard( const data = Object.values(template.data)[0]; const resp = { data: { - [txnHash]: { + [txHash]: { ...data, transaction: { ...data.transaction, @@ -459,7 +459,7 @@ export function generateFormatedUtxos( ); const formattedUtxos = rawUtxos.data[address].utxo.map((utxo) => ({ block: utxo.block_id, - txnHash: utxo.transaction_hash, + txHash: utxo.transaction_hash, index: utxo.index, value: utxo.value, })); From 894010d45b08855585161deed3420161c9e11b04 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 4 Jun 2024 18:39:51 +0800 Subject: [PATCH 053/362] chore: update send many (#97) * chore: update send many * chore: update sendmany dialog --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/bitcoin/wallet/address.ts | 4 - .../src/bitcoin/wallet/coin-select.test.ts | 37 ++++- .../src/bitcoin/wallet/coin-select.ts | 38 +++-- .../src/bitcoin/wallet/psbt.test.ts | 80 ++++++++-- .../src/bitcoin/wallet/psbt.ts | 145 ++++++++++++------ .../src/bitcoin/wallet/selection-result.ts | 17 -- .../bitcoin/wallet/transaction-info.test.ts | 33 ++-- .../src/bitcoin/wallet/transaction-info.ts | 75 +++++---- .../bitcoin/wallet/transaction-input.test.ts | 9 +- .../src/bitcoin/wallet/transaction-input.ts | 15 +- .../src/bitcoin/wallet/transaction-output.ts | 17 +- .../src/bitcoin/wallet/types.ts | 9 ++ .../src/bitcoin/wallet/wallet.test.ts | 116 ++++++++------ .../src/bitcoin/wallet/wallet.ts | 75 +++++---- .../src/rpcs/sendmany.test.ts | 79 ++++++++-- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 38 +++-- 17 files changed, 518 insertions(+), 271 deletions(-) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/selection-result.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 5cc1fcf5..cb36df1b 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "Nz/fHikjqUlxBPopJv1lTeWP6FaObv0z7jw4pMzpZns=", + "shasum": "k2VftTYTbMrhIZSwAGQb91tdJAF/lprzhf+XXuUuHk4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts index 271bee60..71bf7abf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts @@ -1,13 +1,9 @@ -import type { Network } from 'bitcoinjs-lib'; - import { replaceMiddleChar } from '../../utils'; import type { IAddress } from '../../wallet'; export class BtcAddress implements IAddress { value: string; - network?: Network; - constructor(address: string) { this.value = address; } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts index e01e03e4..af4718ef 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts @@ -4,7 +4,6 @@ import { generateFormatedUtxos } from '../../../test/utils'; import { ScriptType } from '../constants'; import { CoinSelectService } from './coin-select'; import { BtcAccountBip32Deriver } from './deriver'; -import { SelectionResult } from './selection-result'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; import { BtcWallet } from './wallet'; @@ -59,10 +58,36 @@ describe('CoinSelectService', () => { const coinSelectService = new CoinSelectService(1); - const result = coinSelectService.selectCoins(inputs, outputs, sender); + const result = coinSelectService.selectCoins( + inputs, + outputs, + new TxOutput(0, sender.address), + ); - expect(result).toBeInstanceOf(SelectionResult); expect(result.fee).toBeGreaterThan(1); + expect(result.change).toBeDefined(); + expect(result.inputs.length).toBeGreaterThan(0); + expect(result.outputs.length).toBeGreaterThan(0); + }); + + it('converts output to TxOutput', async () => { + const network = networks.testnet; + const { inputs, outputs, sender } = await prepareCoinSlect(network); + + const coinSelectService = new CoinSelectService(1); + + const result = coinSelectService.selectCoins( + inputs, + outputs.map((output) => ({ + address: output.address, + value: output.value, + })), + new TxOutput(0, sender.address), + ); + + for (const output of result.outputs) { + expect(output).toBeInstanceOf(TxOutput); + } }); it('throws `Insufficient funds` error if the given utxos is not sufficient', async () => { @@ -77,7 +102,11 @@ describe('CoinSelectService', () => { const coinSelectService = new CoinSelectService(100); expect(() => - coinSelectService.selectCoins(inputs, outputs, sender), + coinSelectService.selectCoins( + inputs, + outputs, + new TxOutput(0, sender.address), + ), ).toThrow('Insufficient funds'); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index 542d933c..ade36a3a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -2,43 +2,47 @@ import coinSelect from 'coinselect'; import type { Recipient } from '../../wallet'; import { TxValidationError } from './exceptions'; -import { SelectionResult } from './selection-result'; import type { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; -import { type IBtcAccount } from './types'; +import { type SelectionResult } from './types'; export class CoinSelectService { - protected readonly feeRate: number; + #feeRate: number; constructor(feeRate: number) { - this.feeRate = Math.round(feeRate); + this.#feeRate = Math.round(feeRate); } selectCoins( inputs: TxInput[], - recipients: Recipient[], - changeAccount: IBtcAccount, + recipients: Recipient[] | TxOutput[], + changeTo: TxOutput, ): SelectionResult { - const result = coinSelect(inputs, recipients, this.feeRate); + const result = coinSelect(inputs, recipients, this.#feeRate); if (!result.inputs || !result.outputs) { throw new TxValidationError('Insufficient funds'); } - const selectedResult = new SelectionResult(); - selectedResult.fee = result.fee; - selectedResult.selectedInputs = result.inputs; + const selectedResult: SelectionResult = { + fee: result.fee, + inputs: result.inputs, + outputs: [], + }; + // restructure outputs to avoid depends on coinselect output format for (const output of result.outputs) { if (output.address) { - selectedResult.selectedOutputs.push( - new TxOutput(output.value, output.address), - ); + if (output instanceof TxOutput) { + selectedResult.outputs.push(output); + } else { + selectedResult.outputs.push( + new TxOutput(output.value, output.address), + ); + } } else { - selectedResult.change = new TxOutput( - output.value, - changeAccount.address, - ); + changeTo.value = output.value; + selectedResult.change = changeTo; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts index 6357706d..70df4e16 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts @@ -1,4 +1,4 @@ -import { Transaction, networks } from 'bitcoinjs-lib'; +import { Psbt, Transaction, networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { hexToBuffer } from '../../utils'; @@ -32,11 +32,14 @@ describe('PsbtService', () => { const receiver1 = await wallet.instance.unlock(1, ScriptType.P2wpkh); const receiver2 = await wallet.instance.unlock(2, ScriptType.P2wpkh); const receivers = [receiver1, receiver2]; - const finalizeSpy = jest.spyOn(service.psbt, 'finalizeAllInputs'); - const inputSpy = jest.spyOn(service.psbt, 'addInput'); - const outputSpy = jest.spyOn(service.psbt, 'addOutput'); - const signSpy = jest.spyOn(service.psbt, 'signAllInputsHDAsync'); - const verifySpy = jest.spyOn(service.psbt, 'validateSignaturesOfAllInputs'); + const finalizeSpy = jest.spyOn(Psbt.prototype, 'finalizeAllInputs'); + const inputSpy = jest.spyOn(Psbt.prototype, 'addInput'); + const outputSpy = jest.spyOn(Psbt.prototype, 'addOutput'); + const signSpy = jest.spyOn(Psbt.prototype, 'signAllInputsHDAsync'); + const verifySpy = jest.spyOn( + Psbt.prototype, + 'validateSignaturesOfAllInputs', + ); const transactionWeightSpy = jest.spyOn(Transaction.prototype, 'weight'); const transactionHexSpy = jest.spyOn(Transaction.prototype, 'toHex'); @@ -59,7 +62,13 @@ describe('PsbtService', () => { service.addOutputs(outputs); - service.addInputs(inputs, sender, rbfOptIn); + service.addInputs( + inputs, + rbfOptIn, + sender.hdPath, + hexToBuffer(sender.pubkey, false), + hexToBuffer(sender.mfp, false), + ); return { service, @@ -83,7 +92,7 @@ describe('PsbtService', () => { const service = new PsbtService(network); - expect(service.psbt.txInputs).toHaveLength(0); + expect(service.psbt).toBeInstanceOf(Psbt); }); it('constructor with an psbt base string', async () => { @@ -92,8 +101,7 @@ describe('PsbtService', () => { const newService = PsbtService.fromBase64(networks.testnet, psbtBase64); - expect(newService.psbt.txInputs).toHaveLength(2); - expect(newService.psbt.txOutputs).toHaveLength(2); + expect(newService.psbt).toBeInstanceOf(Psbt); }); }); @@ -111,7 +119,7 @@ describe('PsbtService', () => { hash: inputs[i].txHash, index: inputs[i].index, witnessUtxo: { - script: inputs[i].scriptBuf, + script: inputs[i].script, value: inputs[i].value, }, bip32Derivation: [ @@ -134,7 +142,7 @@ describe('PsbtService', () => { hash: inputs[i].txHash, index: inputs[i].index, witnessUtxo: { - script: inputs[i].scriptBuf, + script: inputs[i].script, value: inputs[i].value, }, bip32Derivation: [ @@ -156,7 +164,13 @@ describe('PsbtService', () => { }); expect(() => { - service.addInputs(inputs, sender, false); + service.addInputs( + inputs, + false, + sender.hdPath, + hexToBuffer(sender.pubkey, false), + hexToBuffer(sender.mfp), + ); }).toThrow('Failed to add inputs in PSBT'); }); }); @@ -274,4 +288,44 @@ describe('PsbtService', () => { expect(() => service.finalize()).toThrow(PsbtServiceError); }); }); + + describe('signDummy', () => { + it('clones a psbt, then sign it and returns an PsbtService object', async () => { + const { service, sender } = await preparePsbt(); + + const signedService = await service.signDummy(sender.signer); + + expect(signedService).toBeInstanceOf(PsbtService); + }); + + it('throws `Failed to sign dummy in PSBT` error if signDummy is failed', async () => { + const { service, sender, finalizeSpy } = await preparePsbt(); + + finalizeSpy.mockImplementation(() => { + throw new Error('error'); + }); + + await expect(service.signDummy(sender.signer)).rejects.toThrow( + `Failed to sign dummy in PSBT`, + ); + }); + }); + + describe('getFee', () => { + it('extracts fee from the psbt', async () => { + const { service, sender } = await preparePsbt(); + + const signedService = await service.signDummy(sender.signer); + + const fee = signedService.getFee(); + + expect(fee).toBeGreaterThan(0); + }); + + it('throws `Failed to get fee from PSBT` error if getFee is failed', async () => { + const { service } = await preparePsbt(); + + expect(() => service.getFee()).toThrow(`Failed to get fee from PSBT`); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index 0b7ab632..d1bae8c2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -5,31 +5,31 @@ import type { Buffer } from 'buffer'; import ECPairFactory from 'ecpair'; import { logger } from '../../libs/logger/logger'; -import { compactError, hexToBuffer } from '../../utils'; +import { compactError } from '../../utils'; import type { IAccountSigner } from '../../wallet'; import { MaxStandardTxWeight } from '../constants'; import { PsbtServiceError, TxValidationError } from './exceptions'; import type { TxInput } from './transaction-input'; import type { TxOutput } from './transaction-output'; -import type { IBtcAccount } from './types'; const ECPair = ECPairFactory(ecc); export class PsbtService { - protected readonly network: Network; + #psbt: Psbt; - protected readonly _psbt: Psbt; + #network: Network; get psbt() { - return this._psbt; + return this.#psbt; } constructor(network: Network, psbt?: Psbt) { if (psbt === undefined) { - this._psbt = new Psbt({ network }); + this.#psbt = new Psbt({ network }); } else { - this._psbt = psbt; + this.#psbt = psbt; } + this.#network = network; } static fromBase64(network: Network, base64Psbt: string): PsbtService { @@ -38,43 +38,62 @@ export class PsbtService { return service; } + addInput( + input: TxInput, + replaceable: boolean, + changeAddressHdPath: string, + changeAddressPubkey: Buffer, + changeAddressMfp: Buffer, + ) { + try { + this.#psbt.addInput({ + hash: input.txHash, + index: input.index, + witnessUtxo: { + script: input.script, + value: input.value, + }, + // This is useful because as long as you store the masterFingerprint on + // the PSBT Creator's server, you can have the PSBT Creator do the heavy + // lifting with derivation from your m/84'/0'/0' xpub, (deriving only 0/0 ) + // and your signer just needs to pass in an HDSigner interface (ie. bip32 library) + bip32Derivation: [ + { + masterFingerprint: changeAddressMfp, + path: changeAddressHdPath, + pubkey: changeAddressPubkey, + }, + ], + + // reference : https://en.bitcoin.it/wiki/BIP_0125 + // A transaction is considered to have opted in to allowing replacement of itself if any of its inputs have an nSequence number less than (0xffffffff - 1). + // we use max sequence number - 2 to void conflicting with other possible uses of nSequence + sequence: replaceable + ? Transaction.DEFAULT_SEQUENCE - 2 + : Transaction.DEFAULT_SEQUENCE, + }); + } catch (error) { + logger.error('Failed to add input', error); + throw new PsbtServiceError('Failed to add input in PSBT'); + } + } + addInputs( inputs: TxInput[], - changeAccount: IBtcAccount, replaceable: boolean, + changeAddressHdPath: string, + changeAddressPubkey: Buffer, + changeAddressMfp: Buffer, ) { try { - const changeAddressHdPath = changeAccount.hdPath; - const changeAddressPubkey = hexToBuffer(changeAccount.pubkey, false); - const changeAddressMfp = hexToBuffer(changeAccount.mfp, false); - for (const input of inputs) { - this._psbt.addInput({ - hash: input.txHash, - index: input.index, - witnessUtxo: { - script: input.scriptBuf, - value: input.value, - }, - // This is useful because as long as you store the masterFingerprint on - // the PSBT Creator's server, you can have the PSBT Creator do the heavy - // lifting with derivation from your m/84'/0'/0' xpub, (deriving only 0/0 ) - // and your signer just needs to pass in an HDSigner interface (ie. bip32 library) - bip32Derivation: [ - { - masterFingerprint: changeAddressMfp, - path: changeAddressHdPath, - pubkey: changeAddressPubkey, - }, - ], - - // reference : https://en.bitcoin.it/wiki/BIP_0125 - // A transaction is considered to have opted in to allowing replacement of itself if any of its inputs have an nSequence number less than (0xffffffff - 1). - // we use max sequence number - 2 to void conflicting with other possible uses of nSequence - sequence: replaceable - ? Transaction.DEFAULT_SEQUENCE - 2 - : Transaction.DEFAULT_SEQUENCE, - }); + this.addInput( + input, + replaceable, + changeAddressHdPath, + changeAddressPubkey, + changeAddressMfp, + ); } } catch (error) { logger.error('Failed to add inputs', error); @@ -82,13 +101,22 @@ export class PsbtService { } } + addOutput(output: TxOutput) { + try { + this.#psbt.addOutput({ + address: output.address, + value: output.value, + }); + } catch (error) { + logger.error('Failed to add output', error); + throw new PsbtServiceError('Failed to add output in PSBT'); + } + } + addOutputs(outputs: TxOutput[]) { try { for (const output of outputs) { - this._psbt.addOutput({ - address: output.address, - value: output.value, - }); + this.addOutput(output); } } catch (error) { logger.error('Failed to add outputs', error); @@ -96,9 +124,30 @@ export class PsbtService { } } + getFee(): number { + try { + return this.#psbt.getFee(); + } catch (error) { + logger.error('Failed to get fee', error); + throw new PsbtServiceError('Failed to get fee from PSBT'); + } + } + + async signDummy(signer: IAccountSigner): Promise { + try { + const psbt = this.#psbt.clone(); + await psbt.signAllInputsHDAsync(signer); + psbt.finalizeAllInputs(); + return new PsbtService(this.#network, psbt); + } catch (error) { + logger.error('Failed to sign dummy', error); + throw new PsbtServiceError('Failed to sign dummy in PSBT'); + } + } + toBase64(): string { try { - return this._psbt.toBase64(); + return this.#psbt.toBase64(); } catch (error) { logger.error('Failed to convert to base64', error); throw new PsbtServiceError('Failed to output PSBT string'); @@ -110,10 +159,10 @@ export class PsbtService { // This function signAllInputsHDAsync is used to sign all inputs with the signer. // When using the method signAllInputsHDAsync, it is important to note that the signer must derive from the root node as well as the finderprint. // For further reference, please see the getHdSigner method in BtcWallet. - await this._psbt.signAllInputsHDAsync(signer); + await this.#psbt.signAllInputsHDAsync(signer); if ( - !this._psbt.validateSignaturesOfAllInputs( + !this.#psbt.validateSignaturesOfAllInputs( (pubkey: Buffer, msghash: Buffer, signature: Buffer) => this.validateInputs(pubkey, msghash, signature), ) @@ -129,11 +178,11 @@ export class PsbtService { finalize(): string { try { - this._psbt.finalizeAllInputs(); + this.#psbt.finalizeAllInputs(); - const txHex = this._psbt.extractTransaction().toHex(); + const txHex = this.#psbt.extractTransaction().toHex(); - const weight = this._psbt.extractTransaction().weight(); + const weight = this.#psbt.extractTransaction().weight(); if (weight > MaxStandardTxWeight) { throw new TxValidationError('Transaction is too large'); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/selection-result.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/selection-result.ts deleted file mode 100644 index a9549bc5..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/selection-result.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { TxInput } from './transaction-input'; -import type { TxOutput } from './transaction-output'; - -export class SelectionResult { - selectedInputs: TxInput[]; - - selectedOutputs: TxOutput[]; - - change: TxOutput; - - fee: number; - - constructor() { - this.selectedInputs = []; - this.selectedOutputs = []; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts index d1a48dd7..4c553e10 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts @@ -3,6 +3,7 @@ import { networks } from 'bitcoinjs-lib'; import { generateAccounts } from '../../../test/utils'; import { getCaip2ChainId, getExplorerUrl, satsToBtc } from '../utils'; import { BtcAddress } from './address'; +import { BtcAmount } from './amount'; import { BtcTxInfo } from './transaction-info'; import { TxOutput } from './transaction-output'; @@ -13,20 +14,21 @@ describe('BtcTxInfo', () => { const accounts = generateAccounts(5); const addresses = accounts.map((account) => account.address); const fee = 10000; + const feeRate = 100; let total = fee; const outputs: TxOutput[] = []; + const sender = new BtcAddress(addresses[0]); + + const info = new BtcTxInfo(sender, feeRate, network); + info.fee = fee; for (let i = 1; i < addresses.length; i++) { total += 100000; - outputs.push(new TxOutput(100000, addresses[i])); + const output = new TxOutput(100000, addresses[i]); + outputs.push(output); } - const info = new BtcTxInfo( - new BtcAddress(addresses[0]), - outputs, - 10000, - 100, - network, - ); + + info.addRecipients(outputs); info.change = new TxOutput(500, addresses[0]); total += 500; @@ -53,9 +55,20 @@ describe('BtcTxInfo', () => { }, ]; + expect(info.total).toBeInstanceOf(BtcAmount); + expect(info.total.value).toStrictEqual(total); + expect(info.sender).toStrictEqual(sender); + expect(info.recipients).toHaveLength(expectedRecipients.length); + expect(info.change).toBeDefined(); + expect(info.fee).toStrictEqual(fee); + expect(info.txFee).toBeInstanceOf(BtcAmount); + expect(info.txFee.value).toStrictEqual(fee); + expect(info.feeRate).toBeInstanceOf(BtcAmount); + expect(info.feeRate.value).toStrictEqual(feeRate); + expect(info.toJson()).toStrictEqual({ - feeRate: `${satsToBtc(100)} BTC`, - txFee: `${satsToBtc(10000)} BTC`, + feeRate: `${satsToBtc(feeRate)} BTC`, + txFee: `${satsToBtc(fee)} BTC`, sender: addresses[0], recipients: expectedRecipients, changes: expectedChange, diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts index 05d0c1ba..fbb685d5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts @@ -8,15 +8,15 @@ import { BtcAmount } from './amount'; import type { TxOutput } from './transaction-output'; export class BtcTxInfo implements ITxInfo { - #recipients: TxOutput[]; + readonly sender: BtcAddress; - #feeRate: BtcAmount; + readonly txFee: BtcAmount; change?: TxOutput; - #sender: BtcAddress; + #recipients: TxOutput[]; - #txFee: BtcAmount; + #feeRate: BtcAmount; #outputTotal: BtcAmount; @@ -24,21 +24,14 @@ export class BtcTxInfo implements ITxInfo { #network: Network; - constructor( - sender: BtcAddress, - outputs: TxOutput[], - fee: number, - feeRate: number, - network: Network, - ) { + constructor(sender: BtcAddress, feeRate: number, network: Network) { this.#recipients = []; - this.#outputTotal = new BtcAmount(0); this.#serializedRecipients = []; + this.#outputTotal = new BtcAmount(0); this.#feeRate = new BtcAmount(feeRate); - this.#txFee = new BtcAmount(fee); + this.txFee = new BtcAmount(0); this.#network = network; - this.#sender = sender; - this.addRecipients(outputs); + this.sender = sender; } protected changeToJson(): Json { @@ -56,40 +49,56 @@ export class BtcTxInfo implements ITxInfo { : []; } - protected addRecipients(outputs: TxOutput[]): void { + addRecipients(outputs: TxOutput[]): void { for (const output of outputs) { - this.#outputTotal.value += output.value; - - this.#recipients.push(output); - - this.#serializedRecipients.push({ - address: output.destination.toString(true), - value: output.amount.toString(true), - explorerUrl: getExplorerUrl( - output.destination.value, - getCaip2ChainId(this.#network), - ), - }); + this.addRecipient(output); } } - bumpFee(val: number): void { - this.#txFee.value += val; + addRecipient(output: TxOutput): void { + this.#outputTotal.value += output.value; + + this.#recipients.push(output); + + this.#serializedRecipients.push({ + address: output.destination.toString(true), + value: output.amount.toString(true), + explorerUrl: getExplorerUrl( + output.destination.value, + getCaip2ChainId(this.#network), + ), + }); } get total(): BtcAmount { return new BtcAmount( this.#outputTotal.value + (this.change ? this.change.value : 0) + - this.#txFee.value, + this.txFee.value, ); } + get feeRate(): BtcAmount { + return this.#feeRate; + } + + get recipients(): TxOutput[] { + return this.#recipients; + } + + get fee(): number { + return this.txFee.value; + } + + set fee(val: number) { + this.txFee.value = val; + } + toJson>(): InfoJson { return { feeRate: this.#feeRate.toString(true), - txFee: this.#txFee.toString(true), - sender: this.#sender.toString(), + txFee: this.txFee.toString(true), + sender: this.sender.toString(), recipients: this.#serializedRecipients, changes: this.changeToJson(), total: this.total.toString(true), diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts index 2b0760f0..5dbe7061 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts @@ -1,7 +1,7 @@ import { networks } from 'bitcoinjs-lib'; +import type { Buffer } from 'buffer'; import { generateFormatedUtxos } from '../../../test/utils'; -import { hexToBuffer } from '../../utils'; import { ScriptType } from '../constants'; import { BtcAccountBip32Deriver } from './deriver'; import { TxInput } from './transaction-input'; @@ -23,13 +23,12 @@ describe('TxInput', () => { it('return correct property', async () => { const wallet = createMockWallet(networks.testnet); const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); - const script = account.payment.output?.toString('hex') as unknown as string; - const scriptBuf = hexToBuffer(script, false); + const script = account.payment.output as unknown as Buffer; + const utxo = generateFormatedUtxos(account.address, 1)[0]; - const input = new TxInput(utxo, scriptBuf); + const input = new TxInput(utxo, script); - expect(input.scriptBuf).toStrictEqual(scriptBuf); expect(input.script).toStrictEqual(script); expect(input.value).toStrictEqual(utxo.value); expect(input.txHash).toStrictEqual(utxo.txHash); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts index 4f0d6d63..c8da508d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts @@ -1,28 +1,23 @@ import type { Buffer } from 'buffer'; -import { bufferToString } from '../../utils'; import type { IAmount } from '../../wallet'; import { BtcAmount } from './amount'; import type { Utxo } from './types'; export class TxInput { - scriptBuf: Buffer; + // consume by coinselect + readonly script: Buffer; - amount: IAmount; + readonly amount: IAmount; - utxo: Utxo; + readonly utxo: Utxo; constructor(utxo: Utxo, script: Buffer) { - this.scriptBuf = script; + this.script = script; this.utxo = utxo; this.amount = new BtcAmount(utxo.value); } - // consume by coinselect - get script(): string { - return bufferToString(this.scriptBuf, 'hex'); - } - // consume by coinselect get value(): number { return this.amount.value; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts index 93b3ad5c..772a9ff6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts @@ -1,21 +1,32 @@ +import type { Buffer } from 'buffer'; + import type { IAddress, IAmount } from '../../wallet'; import { BtcAddress } from './address'; import { BtcAmount } from './amount'; export class TxOutput { - amount: IAmount; + readonly amount: IAmount; + + // consume by conselect + readonly script?: Buffer; - destination: IAddress; + readonly destination: IAddress; - constructor(value: number, address: string) { + constructor(value: number, address: string, script?: Buffer) { this.amount = new BtcAmount(value); this.destination = new BtcAddress(address); + this.script = script; } + // consume by conselect get value(): number { return this.amount.value; } + set value(value: number) { + this.amount.value = value; + } + get address(): string { return this.destination.value; } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts index 6ef5432a..549caf2d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts @@ -3,6 +3,8 @@ import type { Network, Payment } from 'bitcoinjs-lib'; import type { IAccount, IAccountSigner } from '../../wallet'; import type { ScriptType } from '../constants'; +import type { TxInput } from './transaction-input'; +import type { TxOutput } from './transaction-output'; export type IBtcAccountDeriver = { getRoot(path: string[]): Promise; @@ -46,3 +48,10 @@ export type Utxo = { index: number; value: number; }; + +export type SelectionResult = { + change?: TxOutput; + fee: number; + inputs: TxInput[]; + outputs: TxOutput[]; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index c4628933..057d9fd6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -1,18 +1,16 @@ -import { networks } from 'bitcoinjs-lib'; +import type { Json } from '@metamask/snaps-sdk'; +import { address as addressUtils, networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { DustLimit, ScriptType } from '../constants'; -import type { BtcAccount } from './account'; import { P2SHP2WPKHAccount, P2WPKHAccount } from './account'; -import { BtcAmount } from './amount'; import { CoinSelectService } from './coin-select'; import { BtcAccountBip32Deriver } from './deriver'; import { WalletError } from './exceptions'; -import { PsbtService } from './psbt'; -import { SelectionResult } from './selection-result'; import { BtcTxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; +import type { SelectionResult } from './types'; import { BtcWallet } from './wallet'; jest.mock('../../libs/snap/helpers'); @@ -99,23 +97,60 @@ describe('BtcWallet', () => { }); describe('createTransaction', () => { - it('creates an transaction', async () => { + it('creates an transaction with changes', async () => { const network = networks.testnet; const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(account.address, 2, 10000, 10000); + const utxos = generateFormatedUtxos(account.address, 200, 100000, 100000); const result = await wallet.createTransaction( account, - createMockTxIndent(account.address, 15000), + createMockTxIndent(account.address, 132000), { utxos, - fee: 1, + fee: 56, subtractFeeFrom: [], replaceable: false, }, ); + + const json = result.txInfo.toJson(); + const recipients = json.recipients as unknown as Json[]; + const changes = json.changes as unknown as Json[]; + + expect(recipients).toHaveLength(1); + expect(changes).toHaveLength(1); + expect(result).toStrictEqual({ + tx: expect.any(String), + txInfo: expect.any(BtcTxInfo), + }); + }); + + it('creates an transaction without changes', async () => { + const network = networks.testnet; + const { instance } = createMockDeriver(network); + const wallet = new BtcWallet(instance, network); + const account = await wallet.unlock(0, ScriptType.P2wpkh); + const utxos = generateFormatedUtxos(account.address, 200, 10000, 10000); + + const result = await wallet.createTransaction( + account, + createMockTxIndent(account.address, 100000), + { + utxos, + fee: 50, + subtractFeeFrom: [], + replaceable: false, + }, + ); + + const json = result.txInfo.toJson(); + const recipients = json.recipients as unknown as Json[]; + const changes = json.changes as unknown as Json[]; + + expect(recipients).toHaveLength(1); + expect(changes).toHaveLength(0); expect(result).toStrictEqual({ tx: expect.any(String), txInfo: expect.any(BtcTxInfo), @@ -147,7 +182,7 @@ describe('BtcWallet', () => { expect(coinSelectServiceSpy).toHaveBeenCalledWith( expect.any(Array), expect.any(Array), - account, + expect.any(TxOutput), ); for (const input of coinSelectServiceSpy.mock.calls[0][0]) { @@ -155,10 +190,7 @@ describe('BtcWallet', () => { } for (const output of coinSelectServiceSpy.mock.calls[0][1]) { - expect(output).toStrictEqual({ - address: account.address, - value: DustLimit[account.scriptType] + 1, - }); + expect(output).toBeInstanceOf(TxOutput); } }); @@ -166,34 +198,32 @@ describe('BtcWallet', () => { const network = networks.testnet; const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); - const chgAccount = (await wallet.unlock( - 0, - ScriptType.P2wpkh, - )) as unknown as BtcAccount; + const chgAccount = await wallet.unlock(0, ScriptType.P2wpkh); const recipient = await wallet.unlock(1, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(chgAccount.address, 2); + const utxos = generateFormatedUtxos(chgAccount.address, 2, 10000, 10000); const coinSelectServiceSpy = jest.spyOn( CoinSelectService.prototype, 'selectCoins', ); - const psbtServiceSpy = jest - .spyOn(PsbtService.prototype, 'addOutputs') - .mockReturnThis(); - // to avoid modifiy the original object when we needed to test the output - psbtServiceSpy.mockReturnThis(); - - const selectionResult = new SelectionResult(); - selectionResult.selectedInputs = utxos.map( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (utxo) => new TxInput(utxo, chgAccount.payment.output!), - ); - selectionResult.selectedOutputs = [new TxOutput(500, recipient.address)]; - selectionResult.fee = 100; - selectionResult.change = new TxOutput( - DustLimit[chgAccount.scriptType] - 1, - chgAccount.address, - ); + const selectionResult: SelectionResult = { + change: new TxOutput( + DustLimit[chgAccount.scriptType] - 1, + chgAccount.address, + ), + fee: 100, + inputs: utxos.map( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (utxo) => new TxInput(utxo, chgAccount.payment.output!), + ), + outputs: [ + new TxOutput( + 500, + recipient.address, + addressUtils.toOutputScript(recipient.address, network), + ), + ], + }; coinSelectServiceSpy.mockReturnValue(selectionResult); @@ -210,18 +240,8 @@ describe('BtcWallet', () => { const info: BtcTxInfo = result.txInfo as unknown as BtcTxInfo; - expect(psbtServiceSpy).toHaveBeenCalledWith( - selectionResult.selectedOutputs, - ); - - const jsonInfo = info.toJson(); - - expect(jsonInfo).toHaveProperty( - 'txFee', - new BtcAmount(100 + DustLimit[chgAccount.scriptType] - 1).toString( - true, - ), - ); + expect(info.fee).toBe(19500); + expect(info.change).toBeUndefined(); }); it('throws `Transaction amount too small` error the transaction output is too small', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 3c617535..06b5e627 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -1,8 +1,8 @@ import type { BIP32Interface } from 'bip32'; -import { type Network } from 'bitcoinjs-lib'; +import { type Network, address } from 'bitcoinjs-lib'; import { logger } from '../../libs/logger/logger'; -import { bufferToString, compactError } from '../../utils'; +import { bufferToString, compactError, hexToBuffer } from '../../utils'; import type { IAccountSigner, IWallet, @@ -19,6 +19,7 @@ import { PsbtService } from './psbt'; import { AccountSigner } from './signer'; import { BtcTxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; +import { TxOutput } from './transaction-output'; import type { IStaticBtcAccount, IBtcAccountDeriver, @@ -85,59 +86,71 @@ export class BtcWallet implements IWallet { throw new WalletError('Fail to get account script hash'); } + const inputs = options.utxos.map((utxo) => new TxInput(utxo, scriptOutput)); + const outputs = recipients.map( + (recipient) => + new TxOutput( + recipient.value, + recipient.address, + address.toOutputScript(recipient.address, this.network), + ), + ); + // as fee rate can be 0, we need to ensure it is at least 1 // TODO: The min fee rate should be setting from parameter const feeRate = Math.max(1, options.fee); - const coinSelectService = new CoinSelectService(feeRate); - - const inputs = options.utxos.map((utxo) => new TxInput(utxo, scriptOutput)); - + const change = new TxOutput(0, account.address); const selectionResult = coinSelectService.selectCoins( inputs, - recipients, - account, + outputs, + change, ); - // TODO: add support of subtractFeeFrom, and throw error if output is too small after subtraction - for (const output of selectionResult.selectedOutputs) { - if (isDust(output.value, scriptType)) { - throw new TxValidationError('Transaction amount too small'); - } - } - - const psbtOutputs = selectionResult.selectedOutputs; - const psbtInputs = selectionResult.selectedInputs; + logger.info(JSON.stringify(selectionResult, null, 2)); - const info = new BtcTxInfo( + const txInfo = new BtcTxInfo( new BtcAddress(account.address), - selectionResult.selectedOutputs, - selectionResult.fee, feeRate, this.network, ); - if (selectionResult.change && selectionResult.change.value > 0) { - if (isDust(selectionResult.change.value, scriptType)) { + const psbtService = new PsbtService(this.network); + psbtService.addInputs( + selectionResult.inputs, + options.replaceable, + account.hdPath, + hexToBuffer(account.pubkey, false), + hexToBuffer(account.mfp, false), + ); + + // TODO: add support of subtractFeeFrom, and throw error if output is too small after subtraction + for (const output of selectionResult.outputs) { + if (isDust(output.value, scriptType)) { + throw new TxValidationError('Transaction amount too small'); + } + psbtService.addOutput(output); + txInfo.addRecipient(output); + } + + if (selectionResult.change) { + if (isDust(change.value, scriptType)) { logger.warn( '[BtcWallet.createTransaction] Change is too small, adding to fees', ); - info.bumpFee(selectionResult.change.value); } else { - info.change = selectionResult.change; - psbtOutputs.push(selectionResult.change); + psbtService.addOutput(selectionResult.change); + txInfo.change = selectionResult.change; } } - const psbtService = new PsbtService(this.network); - - psbtService.addInputs(psbtInputs, account, options.replaceable); - - psbtService.addOutputs(psbtOutputs); + // Sign dummy transaction to extract the fee which is more accurate + const signedService = await psbtService.signDummy(account.signer); + txInfo.fee = signedService.getFee(); return { tx: psbtService.toBase64(), - txInfo: info, + txInfo, }; } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 436927f4..b8780d01 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -1,3 +1,4 @@ +import type { Json } from '@metamask/snaps-sdk'; import { InvalidParamsError, UserRejectedRequestError, @@ -16,14 +17,11 @@ import { BtcAccountBip32Deriver, BtcWallet, BtcAmount, - BtcTxInfo, - BtcAddress, } from '../bitcoin/wallet'; -import { TxOutput } from '../bitcoin/wallet/transaction-output'; import { FeeRatio } from '../chain'; import { Factory } from '../factory'; import { SnapHelper } from '../libs/snap'; -import type { IAccount } from '../wallet'; +import type { IAccount, ITxInfo } from '../wallet'; import { SendManyHandler } from './sendmany'; import type { SendManyParams } from './sendmany'; @@ -230,17 +228,34 @@ describe('SendManyHandler', () => { const calls = snapHelperSpy.mock.calls[0][0]; - expect(calls[calls.length - 4]).toStrictEqual({ - type: 'row', - label: 'Comment', - value: { type: 'text', value: 'test comment' }, + const lastPanel = calls[calls.length - 1]; + + expect(lastPanel).toStrictEqual({ + type: 'panel', + children: [ + { + type: 'row', + label: 'Comment', + value: { markdown: false, type: 'text', value: 'test comment' }, + }, + { + type: 'row', + label: 'Network fee', + value: { markdown: false, type: 'text', value: expect.any(String) }, + }, + { + type: 'row', + label: 'Total', + value: { markdown: false, type: 'text', value: expect.any(String) }, + }, + ], }); }); it('display `Recipient` as label in dialog if there is only 1 recipient', async () => { const network = networks.testnet; const caip2ChainId = Network.Testnet; - const { keyringAccount, recipients, snapHelperSpy } = + const { keyringAccount, recipients, snapHelperSpy, sender } = await prepareSendMany(network, caip2ChainId); const walletCreateTxSpy = jest.spyOn( BtcWallet.prototype, @@ -251,13 +266,24 @@ describe('SendManyHandler', () => { 'signTransaction', ); - const info = new BtcTxInfo( - new BtcAddress(recipients[0].address), - [new TxOutput(100000, recipients[0].address)], - 1, - 1, - network, - ); + const info: ITxInfo = { + toJson>() { + return { + feeRate: `0.00000001 BTC`, + txFee: `0.00000001 BTC`, + sender: sender.address, + recipients: [ + { + address: recipients[0].address, + value: `0.000010 BTC`, + explorerUrl: `https://blockchair.com/bitcoin/transaction/transactionId`, + }, + ], + changes: [], + total: `0.000010 BTC`, + } as unknown as TxInfoJson; + }, + }; walletCreateTxSpy.mockResolvedValue({ tx: 'transaction', @@ -274,7 +300,26 @@ describe('SendManyHandler', () => { const calls = snapHelperSpy.mock.calls[0][0]; - expect(calls[3]).toHaveProperty('label', 'Recipient'); + const recipientsPanel = calls[2]; + + expect(recipientsPanel).toStrictEqual({ + type: 'panel', + children: [ + { + type: 'row', + label: 'Recipient', + value: { + type: 'text', + value: `[${recipients[0].address}](https://blockchair.com/bitcoin/transaction/transactionId)`, + }, + }, + { + type: 'row', + label: 'Amount', + value: { markdown: false, type: 'text', value: '0.000010 BTC' }, + }, + ], + }); }); it('throws `Request params is invalid` error when request parameter is not correct', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index e7e4ec0c..046c1046 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -6,6 +6,7 @@ import { text, heading, row, + panel, } from '@metamask/snaps-sdk'; import { object, @@ -179,15 +180,27 @@ export class SendManyHandler comment: string, ): Promise { const header = `Send Request`; - const intro = `Review the request by [portfolio.metamask.io](https://portfolio.metamask.io/) before proceeding. Once the transaction is made, it's irreversible.`; + const intro = `Review the request before proceeding. Once the transaction is made, it's irreversible.`; const recipientsLabel = `Recipient`; const amountLabel = `Amount`; const commentLabel = `Comment`; - const networkFeeRateLabel = `Network fee rate`; + // const networkFeeRateLabel = `Network fee rate`; const networkFeeLabel = `Network fee`; const totalLabel = `Total`; + const requestedByLable = `Requested by`; + + const components: Component[] = [ + panel([ + heading(header), + text(intro), + row( + requestedByLable, + text(`[portfolio.metamask.io](https://portfolio.metamask.io/)`), + ), + ]), + divider(), + ]; - const components: Component[] = [heading(header), text(intro), divider()]; const info = txInfo.toJson(); const isMoreThanOneRecipient = @@ -200,7 +213,8 @@ export class SendManyHandler explorerUrl: string; value: string; }) => { - components.push( + const recipientsPanel: Component[] = []; + recipientsPanel.push( row( isMoreThanOneRecipient ? `${recipientsLabel} ${i + 1}` @@ -208,23 +222,27 @@ export class SendManyHandler text(`[${data.address}](${data.explorerUrl})`), ), ); - components.push(row(amountLabel, text(data.value, false))); - components.push(divider()); + recipientsPanel.push(row(amountLabel, text(data.value, false))); i += 1; + components.push(panel(recipientsPanel)); + components.push(divider()); }; info.recipients.forEach(addReciptentsToComponents); info.changes.forEach(addReciptentsToComponents); + const bottomPanel: Component[] = []; if (comment.trim().length > 0) { - components.push(row(commentLabel, text(comment.trim()))); + bottomPanel.push(row(commentLabel, text(comment.trim(), false))); } - components.push(row(networkFeeLabel, text(`${info.txFee}`, false))); + bottomPanel.push(row(networkFeeLabel, text(`${info.txFee}`, false))); + + // bottomPanel.push(row(networkFeeRateLabel, text(`${info.feeRate}`, false))); - components.push(row(networkFeeRateLabel, text(`${info.feeRate}`, false))); + bottomPanel.push(row(totalLabel, text(`${info.total}`, false))); - components.push(row(totalLabel, text(`${info.total}`, false))); + components.push(panel(bottomPanel)); return (await SnapHelper.confirmDialog(components)) as boolean; } From cd1e2fa13c6d77e20729f9f1d7b118ebda075421 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 4 Jun 2024 18:54:06 +0800 Subject: [PATCH 054/362] chore: update protected property --- .../src/bitcoin/chain/service.ts | 24 +++++------ .../bitcoin/data-client/clients/blockchair.ts | 10 ++--- .../data-client/clients/blockstream.ts | 16 +++---- .../src/bitcoin/wallet/coin-select.ts | 6 +-- .../src/bitcoin/wallet/deriver.ts | 16 ++++--- .../src/bitcoin/wallet/factory.test.ts | 2 +- .../src/bitcoin/wallet/psbt.ts | 34 +++++++-------- .../src/bitcoin/wallet/signer.ts | 14 +++---- .../src/bitcoin/wallet/transaction-info.ts | 40 +++++++++--------- .../src/bitcoin/wallet/wallet.ts | 22 +++++----- .../src/keyring/keyring.ts | 42 +++++++++---------- 11 files changed, 115 insertions(+), 111 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index 62ac91e5..08b908a8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -19,24 +19,24 @@ import { BtcOnChainServiceError } from './exceptions'; import type { BtcOnChainServiceOptions } from './types'; export class BtcOnChainService implements IOnChainService { - protected readonly readClient: IReadDataClient; + protected readonly _readClient: IReadDataClient; - protected readonly writeClient: IWriteDataClient; + protected readonly _writeClient: IWriteDataClient; - protected readonly options: BtcOnChainServiceOptions; + protected readonly _options: BtcOnChainServiceOptions; constructor( readClient: IReadDataClient, writeClient: IWriteDataClient, options: BtcOnChainServiceOptions, ) { - this.readClient = readClient; - this.writeClient = writeClient; - this.options = options; + this._readClient = readClient; + this._writeClient = writeClient; + this._options = options; } get network(): Network { - return this.options.network; + return this._options.network; } async getBalances( @@ -58,7 +58,7 @@ export class BtcOnChainService implements IOnChainService { throw new BtcOnChainServiceError('Invalid asset'); } - const balance: Balances = await this.readClient.getBalances(addresses); + const balance: Balances = await this._readClient.getBalances(addresses); return addresses.reduce( (acc: AssetBalances, address: string) => { @@ -78,7 +78,7 @@ export class BtcOnChainService implements IOnChainService { async getFeeRates(): Promise { try { - const result = await this.readClient.getFeeRates(); + const result = await this._readClient.getFeeRates(); return { fees: Object.entries(result).map( @@ -95,7 +95,7 @@ export class BtcOnChainService implements IOnChainService { async getTransactionStatus(txHash: string) { try { - return await this.readClient.getTransactionStatus(txHash); + return await this._readClient.getTransactionStatus(txHash); } catch (error) { throw new BtcOnChainServiceError(error); } @@ -107,7 +107,7 @@ export class BtcOnChainService implements IOnChainService { transactionIntent?: TransactionIntent, ): Promise { try { - const data = await this.readClient.getUtxos(address); + const data = await this._readClient.getUtxos(address); return { data: { utxos: data, @@ -122,7 +122,7 @@ export class BtcOnChainService implements IOnChainService { signedTransaction: string, ): Promise { try { - const transactionId = await this.writeClient.sendTransaction( + const transactionId = await this._writeClient.sendTransaction( signedTransaction, ); return { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts index 7eb9a348..df958fac 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts @@ -205,14 +205,14 @@ export type PostTransactionResponse = { /* eslint-disable */ export class BlockChairClient implements IReadDataClient, IWriteDataClient { - options: BlockChairClientOptions; + protected readonly _options: BlockChairClientOptions; constructor(options: BlockChairClientOptions) { - this.options = options; + this._options = options; } get baseUrl(): string { - switch (this.options.network) { + switch (this._options.network) { case networks.bitcoin: return 'https://api.blockchair.com/bitcoin'; case networks.testnet: @@ -225,8 +225,8 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { protected getApiUrl(endpoint: string): string { const url = new URL(`${this.baseUrl}${endpoint}`); // TODO: Update to proxy - if (this.options.apiKey) { - url.searchParams.append('key', this.options.apiKey); + if (this._options.apiKey) { + url.searchParams.append('key', this._options.apiKey); } return url.toString(); } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts index 227c6cb9..4b8b155b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts @@ -71,13 +71,13 @@ export type GetBlocksResponse = { /* eslint-enable */ export class BlockStreamClient implements IReadDataClient { - protected readonly options: BlockStreamClientOptions; + protected readonly _options: BlockStreamClientOptions; - protected readonly feeRateRatioMap: Record; + protected readonly _feeRateRatioMap: Record; constructor(options: BlockStreamClientOptions) { - this.options = options; - this.feeRateRatioMap = { + this._options = options; + this._feeRateRatioMap = { [FeeRatio.Fast]: '1', [FeeRatio.Medium]: '144', [FeeRatio.Slow]: '144', @@ -85,7 +85,7 @@ export class BlockStreamClient implements IReadDataClient { } get baseUrl(): string { - switch (this.options.network) { + switch (this._options.network) { case networks.bitcoin: return 'https://blockstream.info/api'; case networks.testnet: @@ -184,13 +184,13 @@ export class BlockStreamClient implements IReadDataClient { ); return { [FeeRatio.Fast]: Math.round( - response[this.feeRateRatioMap[FeeRatio.Fast]], + response[this._feeRateRatioMap[FeeRatio.Fast]], ), [FeeRatio.Medium]: Math.round( - response[this.feeRateRatioMap[FeeRatio.Medium]], + response[this._feeRateRatioMap[FeeRatio.Medium]], ), [FeeRatio.Slow]: Math.round( - response[this.feeRateRatioMap[FeeRatio.Slow]], + response[this._feeRateRatioMap[FeeRatio.Slow]], ), }; } catch (error) { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index ade36a3a..c23a3f64 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -7,10 +7,10 @@ import { TxOutput } from './transaction-output'; import { type SelectionResult } from './types'; export class CoinSelectService { - #feeRate: number; + protected _feeRate: number; constructor(feeRate: number) { - this.#feeRate = Math.round(feeRate); + this._feeRate = Math.round(feeRate); } selectCoins( @@ -18,7 +18,7 @@ export class CoinSelectService { recipients: Recipient[] | TxOutput[], changeTo: TxOutput, ): SelectionResult { - const result = coinSelect(inputs, recipients, this.#feeRate); + const result = coinSelect(inputs, recipients, this._feeRate); if (!result.inputs || !result.outputs) { throw new TxValidationError('Insufficient funds'); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts index cd80ad4f..bd245801 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts @@ -10,20 +10,20 @@ import { DeriverError } from './exceptions'; import type { IBtcAccountDeriver } from './types'; export abstract class BtcAccountDeriver implements IBtcAccountDeriver { - protected readonly network: Network; + protected readonly _network: Network; - protected readonly bip32Api: BIP32API; + protected readonly _bip32Api: BIP32API; constructor(network: Network) { - this.bip32Api = BIP32Factory(ecc); - this.network = network; + this._bip32Api = BIP32Factory(ecc); + this._network = network; } abstract getRoot(path: string[]): Promise; createBip32FromSeed(seed: Buffer): BIP32Interface { try { - return this.bip32Api.fromSeed(seed, this.network); + return this._bip32Api.fromSeed(seed, this._network); } catch (error) { throw new DeriverError('Unable to construct BIP32 node from seed'); } @@ -34,7 +34,11 @@ export abstract class BtcAccountDeriver implements IBtcAccountDeriver { chainNode: Buffer, ): BIP32Interface { try { - return this.bip32Api.fromPrivateKey(privateKey, chainNode, this.network); + return this._bip32Api.fromPrivateKey( + privateKey, + chainNode, + this._network, + ); } catch (error) { throw new DeriverError('Unable to construct BIP32 node from private key'); } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts index 50f0a2e5..43f76ce9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts @@ -10,7 +10,7 @@ import * as manager from './wallet'; describe('BtcWalletFactory', () => { class MockBtcWallet extends manager.BtcWallet { getDeriver() { - return this.deriver; + return this._deriver; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index d1bae8c2..67fe764e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -15,21 +15,21 @@ import type { TxOutput } from './transaction-output'; const ECPair = ECPairFactory(ecc); export class PsbtService { - #psbt: Psbt; + protected _psbt: Psbt; - #network: Network; + protected _network: Network; get psbt() { - return this.#psbt; + return this._psbt; } constructor(network: Network, psbt?: Psbt) { if (psbt === undefined) { - this.#psbt = new Psbt({ network }); + this._psbt = new Psbt({ network }); } else { - this.#psbt = psbt; + this._psbt = psbt; } - this.#network = network; + this._network = network; } static fromBase64(network: Network, base64Psbt: string): PsbtService { @@ -46,7 +46,7 @@ export class PsbtService { changeAddressMfp: Buffer, ) { try { - this.#psbt.addInput({ + this._psbt.addInput({ hash: input.txHash, index: input.index, witnessUtxo: { @@ -103,7 +103,7 @@ export class PsbtService { addOutput(output: TxOutput) { try { - this.#psbt.addOutput({ + this._psbt.addOutput({ address: output.address, value: output.value, }); @@ -126,7 +126,7 @@ export class PsbtService { getFee(): number { try { - return this.#psbt.getFee(); + return this._psbt.getFee(); } catch (error) { logger.error('Failed to get fee', error); throw new PsbtServiceError('Failed to get fee from PSBT'); @@ -135,10 +135,10 @@ export class PsbtService { async signDummy(signer: IAccountSigner): Promise { try { - const psbt = this.#psbt.clone(); + const psbt = this._psbt.clone(); await psbt.signAllInputsHDAsync(signer); psbt.finalizeAllInputs(); - return new PsbtService(this.#network, psbt); + return new PsbtService(this._network, psbt); } catch (error) { logger.error('Failed to sign dummy', error); throw new PsbtServiceError('Failed to sign dummy in PSBT'); @@ -147,7 +147,7 @@ export class PsbtService { toBase64(): string { try { - return this.#psbt.toBase64(); + return this._psbt.toBase64(); } catch (error) { logger.error('Failed to convert to base64', error); throw new PsbtServiceError('Failed to output PSBT string'); @@ -159,10 +159,10 @@ export class PsbtService { // This function signAllInputsHDAsync is used to sign all inputs with the signer. // When using the method signAllInputsHDAsync, it is important to note that the signer must derive from the root node as well as the finderprint. // For further reference, please see the getHdSigner method in BtcWallet. - await this.#psbt.signAllInputsHDAsync(signer); + await this._psbt.signAllInputsHDAsync(signer); if ( - !this.#psbt.validateSignaturesOfAllInputs( + !this._psbt.validateSignaturesOfAllInputs( (pubkey: Buffer, msghash: Buffer, signature: Buffer) => this.validateInputs(pubkey, msghash, signature), ) @@ -178,11 +178,11 @@ export class PsbtService { finalize(): string { try { - this.#psbt.finalizeAllInputs(); + this._psbt.finalizeAllInputs(); - const txHex = this.#psbt.extractTransaction().toHex(); + const txHex = this._psbt.extractTransaction().toHex(); - const weight = this.#psbt.extractTransaction().weight(); + const weight = this._psbt.extractTransaction().weight(); if (weight > MaxStandardTxWeight) { throw new TxValidationError('Transaction is too large'); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts index 72bb4e8e..53ee12d5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts @@ -9,12 +9,12 @@ export class AccountSigner implements HDSignerAsync, IAccountSigner { readonly fingerprint: Buffer; - protected readonly node: BIP32Interface; + protected readonly _node: BIP32Interface; constructor(accountNode: BIP32Interface, mfp?: Buffer) { - this.node = accountNode; - this.publicKey = this.node.publicKey; - this.fingerprint = mfp ?? this.node.fingerprint; + this._node = accountNode; + this.publicKey = this._node.publicKey; + this.fingerprint = mfp ?? this._node.fingerprint; } derivePath(path: string): IAccountSigner { @@ -31,7 +31,7 @@ export class AccountSigner implements HDSignerAsync, IAccountSigner { } index = parseInt(indexStr, 10); return prevHd.derive(index); - }, this.node); + }, this._node); return new AccountSigner(childNode, this.fingerprint); } catch (error) { throw new Error('Unable to derive path'); @@ -39,10 +39,10 @@ export class AccountSigner implements HDSignerAsync, IAccountSigner { } async sign(hash: Buffer): Promise { - return this.node.sign(hash); + return this._node.sign(hash); } verify(hash: Buffer, signature: Buffer): boolean { - return this.node.verify(hash, signature); + return this._node.verify(hash, signature); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts index fbb685d5..b1a7d91f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts @@ -14,23 +14,23 @@ export class BtcTxInfo implements ITxInfo { change?: TxOutput; - #recipients: TxOutput[]; + protected _recipients: TxOutput[]; - #feeRate: BtcAmount; + protected _feeRate: BtcAmount; - #outputTotal: BtcAmount; + protected _outputTotal: BtcAmount; - #serializedRecipients: Json[]; + protected _serializedRecipients: Json[]; - #network: Network; + protected _network: Network; constructor(sender: BtcAddress, feeRate: number, network: Network) { - this.#recipients = []; - this.#serializedRecipients = []; - this.#outputTotal = new BtcAmount(0); - this.#feeRate = new BtcAmount(feeRate); + this._recipients = []; + this._serializedRecipients = []; + this._outputTotal = new BtcAmount(0); + this._feeRate = new BtcAmount(feeRate); this.txFee = new BtcAmount(0); - this.#network = network; + this._network = network; this.sender = sender; } @@ -42,7 +42,7 @@ export class BtcTxInfo implements ITxInfo { value: this.change.amount.toString(true), explorerUrl: getExplorerUrl( this.change.destination.value, - getCaip2ChainId(this.#network), + getCaip2ChainId(this._network), ), }, ] @@ -56,34 +56,34 @@ export class BtcTxInfo implements ITxInfo { } addRecipient(output: TxOutput): void { - this.#outputTotal.value += output.value; + this._outputTotal.value += output.value; - this.#recipients.push(output); + this._recipients.push(output); - this.#serializedRecipients.push({ + this._serializedRecipients.push({ address: output.destination.toString(true), value: output.amount.toString(true), explorerUrl: getExplorerUrl( output.destination.value, - getCaip2ChainId(this.#network), + getCaip2ChainId(this._network), ), }); } get total(): BtcAmount { return new BtcAmount( - this.#outputTotal.value + + this._outputTotal.value + (this.change ? this.change.value : 0) + this.txFee.value, ); } get feeRate(): BtcAmount { - return this.#feeRate; + return this._feeRate; } get recipients(): TxOutput[] { - return this.#recipients; + return this._recipients; } get fee(): number { @@ -96,10 +96,10 @@ export class BtcTxInfo implements ITxInfo { toJson>(): InfoJson { return { - feeRate: this.#feeRate.toString(true), + feeRate: this._feeRate.toString(true), txFee: this.txFee.toString(true), sender: this.sender.toString(), - recipients: this.#serializedRecipients, + recipients: this._serializedRecipients, changes: this.changeToJson(), total: this.total.toString(true), } as unknown as InfoJson; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 06b5e627..75157aa5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -28,13 +28,13 @@ import type { } from './types'; export class BtcWallet implements IWallet { - protected readonly deriver: IBtcAccountDeriver; + protected readonly _deriver: IBtcAccountDeriver; - protected readonly network: Network; + protected readonly _network: Network; constructor(deriver: IBtcAccountDeriver, network: Network) { - this.deriver = deriver; - this.network = network; + this._deriver = deriver; + this._network = network; } protected getAccountCtor(type: string): IStaticBtcAccount { @@ -55,8 +55,8 @@ export class BtcWallet implements IWallet { async unlock(index: number, type: string): Promise { try { const AccountCtor = this.getAccountCtor(type); - const rootNode = await this.deriver.getRoot(AccountCtor.path); - const childNode = await this.deriver.getChild(rootNode, index); + 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 AccountCtor( @@ -64,7 +64,7 @@ export class BtcWallet implements IWallet { index, hdPath, bufferToString(childNode.publicKey, 'hex'), - this.network, + this._network, AccountCtor.scriptType, `bip122:${AccountCtor.scriptType.toLowerCase()}`, this.getHdSigner(rootNode), @@ -92,7 +92,7 @@ export class BtcWallet implements IWallet { new TxOutput( recipient.value, recipient.address, - address.toOutputScript(recipient.address, this.network), + address.toOutputScript(recipient.address, this._network), ), ); @@ -112,10 +112,10 @@ export class BtcWallet implements IWallet { const txInfo = new BtcTxInfo( new BtcAddress(account.address), feeRate, - this.network, + this._network, ); - const psbtService = new PsbtService(this.network); + const psbtService = new PsbtService(this._network); psbtService.addInputs( selectionResult.inputs, options.replaceable, @@ -155,7 +155,7 @@ export class BtcWallet implements IWallet { } async signTransaction(signer: IAccountSigner, tx: string): Promise { - const psbtService = PsbtService.fromBase64(this.network, tx); + const psbtService = PsbtService.fromBase64(this._network, tx); await psbtService.signNVerify(signer); return psbtService.finalize(); } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts index a9e2a446..a59d337e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts @@ -30,25 +30,25 @@ import { } from './types'; export class BtcKeyring implements Keyring { - protected readonly stateMgr: KeyringStateManager; + protected readonly _stateMgr: KeyringStateManager; - protected readonly options: KeyringOptions; + protected readonly _options: KeyringOptions; - protected readonly keyringMethods: string[]; + protected readonly _keyringMethods: string[]; - protected readonly handlers: ChainRPCHandlers; + protected readonly _handlers: ChainRPCHandlers; constructor(stateMgr: KeyringStateManager, options: KeyringOptions) { - this.stateMgr = stateMgr; - this.options = options; + this._stateMgr = stateMgr; + this._options = options; const mapping = RpcHelper.getKeyringRpcApiHandlers(); - this.keyringMethods = Object.keys(mapping); - this.handlers = mapping; + this._keyringMethods = Object.keys(mapping); + this._handlers = mapping; } async listAccounts(): Promise { try { - return await this.stateMgr.listAccounts(); + return await this._stateMgr.listAccounts(); } catch (error) { throw new BtcKeyringError(error); } @@ -56,7 +56,7 @@ export class BtcKeyring implements Keyring { async getAccount(id: string): Promise { try { - return (await this.stateMgr.getAccount(id)) ?? undefined; + return (await this._stateMgr.getAccount(id)) ?? undefined; } catch (error) { throw new BtcKeyringError(error); } @@ -90,8 +90,8 @@ export class BtcKeyring implements Keyring { )}`, ); - await this.stateMgr.withTransaction(async () => { - await this.stateMgr.addWallet({ + await this._stateMgr.withTransaction(async () => { + await this._stateMgr.addWallet({ account: keyringAccount, hdPath: account.hdPath, index: account.index, @@ -121,8 +121,8 @@ export class BtcKeyring implements Keyring { async updateAccount(account: KeyringAccount): Promise { try { - await this.stateMgr.withTransaction(async () => { - await this.stateMgr.updateAccount(account); + await this._stateMgr.withTransaction(async () => { + await this._stateMgr.updateAccount(account); await this.#emitEvent(KeyringEvent.AccountUpdated, { account }); }); } catch (error) { @@ -134,8 +134,8 @@ export class BtcKeyring implements Keyring { async deleteAccount(id: string): Promise { try { - await this.stateMgr.withTransaction(async () => { - await this.stateMgr.removeAccounts([id]); + await this._stateMgr.withTransaction(async () => { + await this._stateMgr.removeAccounts([id]); await this.#emitEvent(KeyringEvent.AccountDeleted, { id }); }); } catch (error) { @@ -162,7 +162,7 @@ export class BtcKeyring implements Keyring { const { scope, account } = request; const { method, params } = request.request; - if (!Object.prototype.hasOwnProperty.call(this.handlers, method)) { + if (!Object.prototype.hasOwnProperty.call(this._handlers, method)) { throw new MethodNotFoundError() as unknown as Error; } @@ -174,7 +174,7 @@ export class BtcKeyring implements Keyring { ); } - return this.handlers[method].getInstance(walletData).execute({ + return this._handlers[method].getInstance(walletData).execute({ ...params, scope, } as unknown as SnapRpcHandlerRequest); @@ -185,7 +185,7 @@ export class BtcKeyring implements Keyring { data: Record, ): Promise { // TODO: Temp solution to support keyring in snap without extentions support - if (this.options.emitEvents) { + if (this._options.emitEvents) { await emitSnapKeyringEvent(SnapHelper.provider, event, data); } } @@ -201,7 +201,7 @@ export class BtcKeyring implements Keyring { options: { ...options, }, - methods: this.keyringMethods, + methods: this._keyringMethods, } as unknown as KeyringAccount; } @@ -224,7 +224,7 @@ export class BtcKeyring implements Keyring { } protected async getAndVerifyWallet(id: string) { - const walletData = await this.stateMgr.getWallet(id); + const walletData = await this._stateMgr.getWallet(id); if (!walletData) { throw new Error('Account not found'); From 6513327162d0bec42011ee3e116f113ffec9f7d3 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 6 Jun 2024 09:27:14 +0800 Subject: [PATCH 055/362] chore: refinement batch2 (#101) * chore: refinement * chore: update todo text * chore: add getScriptForDestnation to support extract script from address --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/bitcoin/utils/address.test.ts | 21 +++++ .../src/bitcoin/utils/address.ts | 17 ++++ .../src/bitcoin/wallet/account.ts | 6 ++ .../src/bitcoin/wallet/coin-select.test.ts | 39 +++----- .../src/bitcoin/wallet/coin-select.ts | 15 +-- .../src/bitcoin/wallet/psbt.test.ts | 9 +- .../src/bitcoin/wallet/psbt.ts | 6 +- .../bitcoin/wallet/transaction-info.test.ts | 5 +- .../bitcoin/wallet/transaction-input.test.ts | 3 +- .../src/bitcoin/wallet/transaction-input.ts | 22 ++--- .../bitcoin/wallet/transaction-output.test.ts | 4 +- .../src/bitcoin/wallet/transaction-output.ts | 4 +- .../src/bitcoin/wallet/types.ts | 2 + .../src/bitcoin/wallet/wallet.test.ts | 43 +-------- .../src/bitcoin/wallet/wallet.ts | 93 ++++++++++++------- .../bitcoin-wallet-snap/src/factory.ts | 4 +- .../src/keyring/keyring.ts | 2 +- .../bitcoin-wallet-snap/src/keyring/types.ts | 2 +- .../bitcoin-wallet-snap/src/libs/rpc/base.ts | 10 +- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 6 +- .../bitcoin-wallet-snap/src/wallet.ts | 4 +- 22 files changed, 163 insertions(+), 156 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index cb36df1b..b3a96b45 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "k2VftTYTbMrhIZSwAGQb91tdJAF/lprzhf+XXuUuHk4=", + "shasum": "gycY0HZ56I1PRIdcUPm4dYlATHJxoWWivbMHJddftK0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts new file mode 100644 index 00000000..d6d083e6 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts @@ -0,0 +1,21 @@ +import { networks } from 'bitcoinjs-lib'; +import { Buffer } from 'buffer'; + +import { getScriptForDestnation } from './address'; + +describe('address', () => { + const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; + + describe('getScriptForDestnation', () => { + it('returns a script', () => { + const val = getScriptForDestnation(address, networks.testnet); + expect(val).toBeInstanceOf(Buffer); + }); + + it('throws `Destnation address has no matching Script` error if the given address is invalid', () => { + expect(() => + getScriptForDestnation('bad-address', networks.testnet), + ).toThrow(`Destnation address has no matching Script`); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts new file mode 100644 index 00000000..e36946dd --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts @@ -0,0 +1,17 @@ +import { address as addressUtils, type Network } from 'bitcoinjs-lib'; + +/** + * Returns the script for a Bitcoin destination address. + * + * @param address - The Bitcoin destination address. + * @param network - The Bitcoin network. + * @returns The Bitcoin script for the destination address. + * @throws An error if the address does not have a matching script. + */ +export function getScriptForDestnation(address: string, network: Network) { + try { + return addressUtils.toOutputScript(address, network); + } catch (error) { + throw new Error('Destnation address has no matching Script'); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts index dc101f23..8c83a93d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts @@ -1,4 +1,5 @@ import type { Network, Payment } from 'bitcoinjs-lib'; +import type { Buffer } from 'buffer'; import type { StaticImplements } from '../../types/static'; import { hexToBuffer } from '../../utils'; @@ -48,6 +49,11 @@ export class BtcAccount implements IBtcAccount { this.type = type; } + get script(): Buffer { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.payment.output!; + } + get address(): string { if (!this.#address) { if (!this.payment.address) { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts index af4718ef..5f009fa7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts @@ -1,4 +1,4 @@ -import { networks } from 'bitcoinjs-lib'; +import { networks, address as addressUtils } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { ScriptType } from '../constants'; @@ -34,12 +34,15 @@ describe('CoinSelectService', () => { const utxos = generateFormatedUtxos(sender.address, 2, inputMin, inputMax); - const outputs = [new TxOutput(outputVal, receiver1.address)]; + const outputs = [ + new TxOutput( + outputVal, + receiver1.address, + addressUtils.toOutputScript(receiver1.address, network), + ), + ]; - const inputs = utxos.map( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (utxo) => new TxInput(utxo, sender.payment.output!), - ); + const inputs = utxos.map((utxo) => new TxInput(utxo, sender.script)); return { sender, @@ -61,7 +64,7 @@ describe('CoinSelectService', () => { const result = coinSelectService.selectCoins( inputs, outputs, - new TxOutput(0, sender.address), + new TxOutput(0, sender.address, sender.script), ); expect(result.fee).toBeGreaterThan(1); @@ -70,26 +73,6 @@ describe('CoinSelectService', () => { expect(result.outputs.length).toBeGreaterThan(0); }); - it('converts output to TxOutput', async () => { - const network = networks.testnet; - const { inputs, outputs, sender } = await prepareCoinSlect(network); - - const coinSelectService = new CoinSelectService(1); - - const result = coinSelectService.selectCoins( - inputs, - outputs.map((output) => ({ - address: output.address, - value: output.value, - })), - new TxOutput(0, sender.address), - ); - - for (const output of result.outputs) { - expect(output).toBeInstanceOf(TxOutput); - } - }); - it('throws `Insufficient funds` error if the given utxos is not sufficient', async () => { const network = networks.testnet; const { inputs, outputs, sender } = await prepareCoinSlect( @@ -105,7 +88,7 @@ describe('CoinSelectService', () => { coinSelectService.selectCoins( inputs, outputs, - new TxOutput(0, sender.address), + new TxOutput(0, sender.address, sender.script), ), ).toThrow('Insufficient funds'); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index c23a3f64..a6506849 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -1,13 +1,12 @@ import coinSelect from 'coinselect'; -import type { Recipient } from '../../wallet'; import { TxValidationError } from './exceptions'; import type { TxInput } from './transaction-input'; -import { TxOutput } from './transaction-output'; +import type { TxOutput } from './transaction-output'; import { type SelectionResult } from './types'; export class CoinSelectService { - protected _feeRate: number; + protected readonly _feeRate: number; constructor(feeRate: number) { this._feeRate = Math.round(feeRate); @@ -15,7 +14,7 @@ export class CoinSelectService { selectCoins( inputs: TxInput[], - recipients: Recipient[] | TxOutput[], + recipients: TxOutput[], changeTo: TxOutput, ): SelectionResult { const result = coinSelect(inputs, recipients, this._feeRate); @@ -33,13 +32,7 @@ export class CoinSelectService { // restructure outputs to avoid depends on coinselect output format for (const output of result.outputs) { if (output.address) { - if (output instanceof TxOutput) { - selectedResult.outputs.push(output); - } else { - selectedResult.outputs.push( - new TxOutput(output.value, output.address), - ); - } + selectedResult.outputs.push(output); } else { changeTo.value = output.value; selectedResult.change = changeTo; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts index 70df4e16..17444791 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts @@ -47,7 +47,7 @@ describe('PsbtService', () => { const fee = 500; const outputs = receivers.map( - (account) => new TxOutput(outputVal, account.address), + (account) => new TxOutput(outputVal, account.address, account.script), ); const utxos = generateFormatedUtxos( sender.address, @@ -55,10 +55,7 @@ describe('PsbtService', () => { outputVal * outputs.length + fee, outputVal * outputs.length + fee, ); - const inputs = utxos.map( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (utxo) => new TxInput(utxo, sender.payment.output!), - ); + const inputs = utxos.map((utxo) => new TxInput(utxo, sender.script)); service.addOutputs(outputs); @@ -183,7 +180,7 @@ describe('PsbtService', () => { for (let i = 0; i < service.psbt.txOutputs.length; i++) { expect(service.psbt.txOutputs[i]).toHaveProperty( 'script', - receivers[i].payment.output, + receivers[i].script, ); expect(service.psbt.txOutputs[i]).toHaveProperty( 'value', diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index 67fe764e..f4638e39 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -15,9 +15,9 @@ import type { TxOutput } from './transaction-output'; const ECPair = ECPairFactory(ecc); export class PsbtService { - protected _psbt: Psbt; + protected readonly _psbt: Psbt; - protected _network: Network; + protected readonly _network: Network; get psbt() { return this._psbt; @@ -104,7 +104,7 @@ export class PsbtService { addOutput(output: TxOutput) { try { this._psbt.addOutput({ - address: output.address, + script: output.script, value: output.value, }); } catch (error) { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts index 4c553e10..c1b15f32 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts @@ -1,4 +1,5 @@ import { networks } from 'bitcoinjs-lib'; +import { Buffer } from 'buffer'; import { generateAccounts } from '../../../test/utils'; import { getCaip2ChainId, getExplorerUrl, satsToBtc } from '../utils'; @@ -24,13 +25,13 @@ describe('BtcTxInfo', () => { for (let i = 1; i < addresses.length; i++) { total += 100000; - const output = new TxOutput(100000, addresses[i]); + const output = new TxOutput(100000, addresses[i], Buffer.from('dummy')); outputs.push(output); } info.addRecipients(outputs); - info.change = new TxOutput(500, addresses[0]); + info.change = new TxOutput(500, addresses[0], Buffer.from('dummy')); total += 500; const expectedRecipients = outputs.map((recipient) => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts index 5dbe7061..1f0877b6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts @@ -1,5 +1,4 @@ import { networks } from 'bitcoinjs-lib'; -import type { Buffer } from 'buffer'; import { generateFormatedUtxos } from '../../../test/utils'; import { ScriptType } from '../constants'; @@ -23,7 +22,7 @@ describe('TxInput', () => { it('return correct property', async () => { const wallet = createMockWallet(networks.testnet); const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); - const script = account.payment.output as unknown as Buffer; + const { script } = account; const utxo = generateFormatedUtxos(account.address, 1)[0]; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts index c8da508d..504fd251 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts @@ -10,28 +10,22 @@ export class TxInput { readonly amount: IAmount; - readonly utxo: Utxo; + readonly txHash: string; + + readonly index: number; + + readonly block: number; constructor(utxo: Utxo, script: Buffer) { this.script = script; - this.utxo = utxo; this.amount = new BtcAmount(utxo.value); + this.index = utxo.index; + this.txHash = utxo.txHash; + this.block = utxo.block; } // consume by coinselect get value(): number { return this.amount.value; } - - get txHash(): string { - return this.utxo.txHash; - } - - get index(): number { - return this.utxo.index; - } - - get block(): number { - return this.utxo.block; - } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts index 2ae196b7..ea20d13f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts @@ -1,3 +1,5 @@ +import { Buffer } from 'buffer'; + import { generateAccounts } from '../../../test/utils'; import { TxOutput } from './transaction-output'; @@ -5,7 +7,7 @@ describe('TxOutput', () => { it('return correct property', () => { const account = generateAccounts(1)[0]; - const input = new TxOutput(10, account.address); + const input = new TxOutput(10, account.address, Buffer.from('dummy')); expect(input.value).toBe(10); expect(input.address).toStrictEqual(account.address); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts index 772a9ff6..298a7fca 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts @@ -8,11 +8,11 @@ export class TxOutput { readonly amount: IAmount; // consume by conselect - readonly script?: Buffer; + readonly script: Buffer; readonly destination: IAddress; - constructor(value: number, address: string, script?: Buffer) { + constructor(value: number, address: string, script: Buffer) { this.amount = new BtcAmount(value); this.destination = new BtcAddress(address); this.script = script; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts index 549caf2d..4db8fff1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts @@ -1,5 +1,6 @@ import type { BIP32Interface } from 'bip32'; import type { Network, Payment } from 'bitcoinjs-lib'; +import type { Buffer } from 'buffer'; import type { IAccount, IAccountSigner } from '../../wallet'; import type { ScriptType } from '../constants'; @@ -15,6 +16,7 @@ export type IBtcAccount = IAccount & { payment: Payment; scriptType: ScriptType; network: Network; + script: Buffer; }; export type IStaticBtcAccount = { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index 057d9fd6..8303ad3b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -1,5 +1,5 @@ import type { Json } from '@metamask/snaps-sdk'; -import { address as addressUtils, networks } from 'bitcoinjs-lib'; +import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { DustLimit, ScriptType } from '../constants'; @@ -210,19 +210,11 @@ describe('BtcWallet', () => { change: new TxOutput( DustLimit[chgAccount.scriptType] - 1, chgAccount.address, + chgAccount.script, ), fee: 100, - inputs: utxos.map( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (utxo) => new TxInput(utxo, chgAccount.payment.output!), - ), - outputs: [ - new TxOutput( - 500, - recipient.address, - addressUtils.toOutputScript(recipient.address, network), - ), - ], + inputs: utxos.map((utxo) => new TxInput(utxo, chgAccount.script)), + outputs: [new TxOutput(500, recipient.address, recipient.script)], }; coinSelectServiceSpy.mockReturnValue(selectionResult); @@ -244,7 +236,7 @@ describe('BtcWallet', () => { expect(info.change).toBeUndefined(); }); - it('throws `Transaction amount too small` error the transaction output is too small', async () => { + it('throws `Transaction amount too small` error if the transaction output is too small', async () => { const network = networks.testnet; const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); @@ -264,31 +256,6 @@ describe('BtcWallet', () => { ), ).rejects.toThrow('Transaction amount too small'); }); - - it('throws `Fail to get account script hash` error if the account script hash is undefined', async () => { - const network = networks.testnet; - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(account.address, 2); - account.payment.output = undefined; - - await expect( - wallet.createTransaction( - account, - createMockTxIndent( - account.address, - DustLimit[account.scriptType] + 1, - ), - { - utxos, - fee: 1, - subtractFeeFrom: [], - replaceable: false, - }, - ), - ).rejects.toThrow('Fail to get account script hash'); - }); }); describe('signTransaction', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 75157aa5..a2647208 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -1,5 +1,5 @@ import type { BIP32Interface } from 'bip32'; -import { type Network, address } from 'bitcoinjs-lib'; +import { type Network } from 'bitcoinjs-lib'; import { logger } from '../../libs/logger/logger'; import { bufferToString, compactError, hexToBuffer } from '../../utils'; @@ -7,10 +7,11 @@ import type { IAccountSigner, IWallet, Recipient, - TxCreationResult, + Transaction, } from '../../wallet'; import { ScriptType } from '../constants'; import { isDust } from '../utils'; +import { getScriptForDestnation } from '../utils/address'; import { P2WPKHAccount, P2SHP2WPKHAccount } from './account'; import { BtcAddress } from './address'; import { CoinSelectService } from './coin-select'; @@ -37,21 +38,6 @@ export class BtcWallet implements IWallet { this._network = network; } - protected getAccountCtor(type: string): IStaticBtcAccount { - let scriptType = type; - if (type.includes('bip122:')) { - scriptType = type.split(':')[1]; - } - switch (scriptType.toLowerCase()) { - case ScriptType.P2wpkh.toLowerCase(): - return P2WPKHAccount; - case ScriptType.P2shP2wkh.toLowerCase(): - return P2SHP2WPKHAccount; - default: - throw new WalletError('Invalid script type'); - } - } - async unlock(index: number, type: string): Promise { try { const AccountCtor = this.getAccountCtor(type); @@ -78,36 +64,59 @@ export class BtcWallet implements IWallet { account: IBtcAccount, recipients: Recipient[], options: CreateTransactionOptions, - ): Promise { - const scriptOutput = account.payment.output; + ): Promise { + const scriptOutput = account.script; const { scriptType } = account; - if (!scriptOutput) { - throw new WalletError('Fail to get account script hash'); - } + logger.info( + JSON.stringify( + { + recipients, + options, + }, + null, + 2, + ), + ); + // TODO: Supporting getting coins from other address (dynamic address) const inputs = options.utxos.map((utxo) => new TxInput(utxo, scriptOutput)); - const outputs = recipients.map( - (recipient) => - new TxOutput( - recipient.value, - recipient.address, - address.toOutputScript(recipient.address, this._network), - ), - ); + const outputs = recipients.map((recipient) => { + if (isDust(recipient.value, scriptType)) { + throw new TxValidationError('Transaction amount too small'); + } + const destnationScriptOutput = getScriptForDestnation( + recipient.address, + this._network, + ); + return new TxOutput( + recipient.value, + recipient.address, + destnationScriptOutput, + ); + }); - // as fee rate can be 0, we need to ensure it is at least 1 + // Do not ever accept zero fee rate, we need to ensure it is at least 1 // TODO: The min fee rate should be setting from parameter const feeRate = Math.max(1, options.fee); const coinSelectService = new CoinSelectService(feeRate); - const change = new TxOutput(0, account.address); + const change = new TxOutput(0, account.address, scriptOutput); const selectionResult = coinSelectService.selectCoins( inputs, outputs, change, ); - logger.info(JSON.stringify(selectionResult, null, 2)); + logger.info( + JSON.stringify( + { + feeRate, + ...selectionResult, + }, + null, + 2, + ), + ); const txInfo = new BtcTxInfo( new BtcAddress(account.address), @@ -126,9 +135,6 @@ export class BtcWallet implements IWallet { // TODO: add support of subtractFeeFrom, and throw error if output is too small after subtraction for (const output of selectionResult.outputs) { - if (isDust(output.value, scriptType)) { - throw new TxValidationError('Transaction amount too small'); - } psbtService.addOutput(output); txInfo.addRecipient(output); } @@ -163,4 +169,19 @@ export class BtcWallet implements IWallet { protected getHdSigner(rootNode: BIP32Interface) { return new AccountSigner(rootNode, rootNode.fingerprint); } + + protected getAccountCtor(type: string): IStaticBtcAccount { + let scriptType = type; + if (type.includes('bip122:')) { + scriptType = type.split(':')[1]; + } + switch (scriptType.toLowerCase()) { + case ScriptType.P2wpkh.toLowerCase(): + return P2WPKHAccount; + case ScriptType.P2shP2wkh.toLowerCase(): + return P2SHP2WPKHAccount; + default: + throw new WalletError('Invalid script type'); + } + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts index f6974caa..ad536089 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.ts @@ -13,7 +13,7 @@ import { Config } from './config'; import { BtcKeyring, KeyringStateManager } from './keyring'; import type { IWallet } from './wallet'; -// TODO: Temp solution to support keyring in snap without keyring API +// TODO: Remove temp solution to support keyring in snap without keyring API export type CreateBtcKeyringOptions = { emitEvents: boolean; }; @@ -43,7 +43,7 @@ export class Factory { return new BtcKeyring(new KeyringStateManager(), { defaultIndex: config.defaultAccountIndex, multiAccount: config.enableMultiAccounts, - // TODO: Temp solution to support keyring in snap without keyring API + // TODO: Remove temp solution to support keyring in snap without keyring API emitEvents: options.emitEvents, }); } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts index a59d337e..74a1c60c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts @@ -184,7 +184,7 @@ export class BtcKeyring implements Keyring { event: KeyringEvent, data: Record, ): Promise { - // TODO: Temp solution to support keyring in snap without extentions support + // TODO: Remove temp solution to support keyring in snap without extentions support if (this._options.emitEvents) { await emitSnapKeyringEvent(SnapHelper.provider, event, data); } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts index acae60e9..ed9e02c1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts @@ -23,7 +23,7 @@ export type SnapState = { export type KeyringOptions = Record & { defaultIndex: number; multiAccount?: boolean; - // TODO: Temp solutio to support keyring in snap without keyring API + // TODO: Remove temp solution to support keyring in snap without keyring API emitEvents?: boolean; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts index 656bf7fe..22f57026 100644 --- a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts +++ b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts @@ -25,11 +25,11 @@ export abstract class BaseSnapRpcHandler { params: SnapRpcHandlerRequest, ): Promise; - protected get requestStruct(): Struct { + protected get _requestStruct(): Struct { return (this.constructor as typeof BaseSnapRpcHandler).requestStruct; } - protected get responseStruct(): Struct | undefined { + protected get _responseStruct(): Struct | undefined { return (this.constructor as typeof BaseSnapRpcHandler).responseStruct; } @@ -38,7 +38,7 @@ export abstract class BaseSnapRpcHandler { `[SnapRpcHandler.preExecute] Request: ${JSON.stringify(params)}`, ); try { - assert(params, this.requestStruct); + assert(params, this._requestStruct); } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[SnapRpcHandler.preExecute] Error: ${error.message}`); @@ -52,8 +52,8 @@ export abstract class BaseSnapRpcHandler { ); try { - if (this.responseStruct) { - assert(response, this.responseStruct); + if (this._responseStruct) { + assert(response, this._responseStruct); } } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 046c1046..ba68876b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -46,7 +46,11 @@ export const TransactionAmountStuct = refine( for (const val of Object.values(value)) { const parsedVal = parseFloat(val); - if (Number.isNaN(parsedVal) || parsedVal <= 0) { + if ( + Number.isNaN(parsedVal) || + parsedVal <= 0 || + !Number.isFinite(parsedVal) + ) { return 'Invalid amount for send'; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/wallet.ts index 8cc5c0b9..937fc141 100644 --- a/merged-packages/bitcoin-wallet-snap/src/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/wallet.ts @@ -6,7 +6,7 @@ export type Recipient = { value: number; }; -export type TxCreationResult = { +export type Transaction = { tx: string; txInfo: ITxInfo; }; @@ -132,7 +132,7 @@ export type IWallet = { account: IAccount, recipients: Recipient[], options: Record, - ): Promise; + ): Promise; }; /** From b331360460013de5c46d5c72a0a3eeb674e4b58e Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 6 Jun 2024 09:38:56 +0800 Subject: [PATCH 056/362] Update README.md (#102) --- merged-packages/bitcoin-wallet-snap/README.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index 54daf78e..2e75fbeb 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -80,3 +80,25 @@ provider.request({ }, }); ``` + +### API `keyring_getAccountBalances` + +example: +```typescript +provider.request({ + method: 'wallet_invokeKeyring', + params: { + snapId, + request: { + method: 'keyring_getAccountBalances', + params: { + account: 'dc06350a-82db-434b-b113-066135804f63', // the uuid account id of the current account + id: 'da40b782-e054-4260-9a6a-c8717a022f92', // an random uuid for this request + assets: [ + 'bip122:000000000019d6689c085ae165831e93/slip44:0' + ], // Caip-2 BTC Asset + }, + }, + }, +}); +``` From 4b73b0e324653b376d1a20a1dc32f6dfb036119b Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 6 Jun 2024 09:41:12 +0800 Subject: [PATCH 057/362] Update README.md --- merged-packages/bitcoin-wallet-snap/README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index 2e75fbeb..a42e75ad 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -84,6 +84,7 @@ provider.request({ ### API `keyring_getAccountBalances` example: + ```typescript provider.request({ method: 'wallet_invokeKeyring', @@ -94,9 +95,7 @@ provider.request({ params: { account: 'dc06350a-82db-434b-b113-066135804f63', // the uuid account id of the current account id: 'da40b782-e054-4260-9a6a-c8717a022f92', // an random uuid for this request - assets: [ - 'bip122:000000000019d6689c085ae165831e93/slip44:0' - ], // Caip-2 BTC Asset + assets: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], // Caip-2 BTC Asset }, }, }, From 55b6bc8423cdeb54cb11f8e7cf6d432c8ebc62a8 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 12 Jun 2024 09:31:35 +0800 Subject: [PATCH 058/362] chore: refactor with simple version (#108) * chore: update structure * Update wallet.ts * chore: remove un use deriver * Update index.tsx * Update permissions.ts * Update snap.manifest.json * chore: add big.js * chore: add min btc validation in sendmany * chore: add big int support * chore: clean up duplicate code * chore: simplify the code * chore: remove unuse code * chore: remove interface for IBtcAccountDeriver * chore: remove blockstream testing tools * chore: moving utxo to chain interface * Update utils.ts * chore: rename chain id and asset name * chore: moving the logger to utils --- .../bitcoin-wallet-snap/package.json | 1 + .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../clients/blockchair.test.ts | 2 +- .../clients/blockchair.ts | 45 ++- .../types.ts => chain/data-client.ts} | 14 +- .../src/bitcoin/chain/exceptions.ts | 4 +- .../src/bitcoin/chain/index.ts | 1 - .../src/bitcoin/chain/service.test.ts | 83 ++-- .../src/bitcoin/chain/service.ts | 46 +-- .../src/bitcoin/chain/types.ts | 5 - .../src/bitcoin/config/index.ts | 1 - .../src/bitcoin/config/types.ts | 23 -- .../src/bitcoin/constants.ts | 28 +- .../data-client/clients/blockstream.test.ts | 305 --------------- .../data-client/clients/blockstream.ts | 223 ----------- .../src/bitcoin/data-client/exceptions.ts | 3 - .../src/bitcoin/data-client/factory.test.ts | 82 ---- .../src/bitcoin/data-client/factory.ts | 50 --- .../src/bitcoin/data-client/index.ts | 3 - .../src/bitcoin/utils/address.test.ts | 4 +- .../src/bitcoin/utils/address.ts | 2 +- .../src/bitcoin/utils/explorer.test.ts | 27 -- .../src/bitcoin/utils/explorer.ts | 25 -- .../src/bitcoin/utils/index.ts | 4 +- .../src/bitcoin/utils/network.test.ts | 12 +- .../src/bitcoin/utils/network.ts | 18 +- .../src/bitcoin/utils/policy.test.ts | 16 + .../src/bitcoin/utils/policy.ts | 17 + .../src/bitcoin/utils/unit.test.ts | 56 --- .../src/bitcoin/utils/unit.ts | 38 -- .../src/bitcoin/wallet/account.ts | 31 +- .../src/bitcoin/wallet/address.test.ts | 24 -- .../src/bitcoin/wallet/address.ts | 18 - .../src/bitcoin/wallet/amount.test.ts | 23 -- .../src/bitcoin/wallet/amount.ts | 26 -- .../src/bitcoin/wallet/coin-select.test.ts | 9 +- .../src/bitcoin/wallet/coin-select.ts | 8 +- .../src/bitcoin/wallet/deriver.test.ts | 106 +---- .../src/bitcoin/wallet/deriver.ts | 91 ++--- .../src/bitcoin/wallet/exceptions.ts | 2 +- .../src/bitcoin/wallet/factory.test.ts | 63 --- .../src/bitcoin/wallet/factory.ts | 17 - .../src/bitcoin/wallet/index.ts | 8 +- .../src/bitcoin/wallet/psbt.test.ts | 11 +- .../src/bitcoin/wallet/psbt.ts | 3 +- .../src/{types => bitcoin/wallet}/static.ts | 0 .../bitcoin/wallet/transaction-info.test.ts | 54 +-- .../src/bitcoin/wallet/transaction-info.ts | 122 +++--- .../bitcoin/wallet/transaction-input.test.ts | 9 +- .../src/bitcoin/wallet/transaction-input.ts | 24 +- .../bitcoin/wallet/transaction-output.test.ts | 18 + .../src/bitcoin/wallet/transaction-output.ts | 28 +- .../src/bitcoin/wallet/types.ts | 59 --- .../src/bitcoin/wallet/wallet.test.ts | 48 +-- .../src/bitcoin/wallet/wallet.ts | 74 ++-- .../bitcoin-wallet-snap/src/chain.ts | 21 +- .../bitcoin-wallet-snap/src/config.ts | 49 +++ .../bitcoin-wallet-snap/src/config/config.ts | 103 ----- .../bitcoin-wallet-snap/src/config/index.ts | 2 - .../bitcoin-wallet-snap/src/constants.ts | 9 + .../bitcoin-wallet-snap/src/factory.test.ts | 17 +- .../bitcoin-wallet-snap/src/factory.ts | 62 +-- .../bitcoin-wallet-snap/src/index.test.ts | 72 ++-- .../bitcoin-wallet-snap/src/index.ts | 37 +- .../src/{keyring => }/keyring.test.ts | 369 +++++++++--------- .../src/{keyring => }/keyring.ts | 181 +++++---- .../src/keyring/exceptions.ts | 5 - .../bitcoin-wallet-snap/src/keyring/index.ts | 4 - .../bitcoin-wallet-snap/src/keyring/types.ts | 37 -- .../src/libs/exception/exceptions.ts | 23 -- .../src/libs/exception/index.ts | 1 - .../bitcoin-wallet-snap/src/libs/rpc/base.ts | 86 ---- .../src/libs/rpc/exceptions.ts | 4 - .../bitcoin-wallet-snap/src/libs/rpc/index.ts | 3 - .../bitcoin-wallet-snap/src/libs/rpc/types.ts | 56 --- .../src/libs/snap/__mocks__/helpers.ts | 25 -- .../src/libs/snap/exceptions.ts | 3 - .../src/libs/snap/helpers.test.ts | 123 ------ .../src/libs/snap/helpers.ts | 68 ---- .../src/libs/snap/index.ts | 4 - .../src/libs/snap/lock.test.ts | 24 -- .../bitcoin-wallet-snap/src/libs/snap/lock.ts | 12 - .../src/{config => }/permissions.ts | 12 +- .../src/rpcs/create-account.ts | 59 ++- .../src/rpcs/get-balances.test.ts | 324 +++++++-------- .../src/rpcs/get-balances.ts | 169 ++++---- .../src/rpcs/get-transaction-status.test.ts | 97 +++-- .../src/rpcs/get-transaction-status.ts | 92 ++--- .../src/rpcs/helpers.test.ts | 34 -- .../bitcoin-wallet-snap/src/rpcs/helpers.ts | 30 -- .../bitcoin-wallet-snap/src/rpcs/index.ts | 3 +- .../src/rpcs/keyring-rpc.ts | 30 -- .../src/rpcs/sendmany.test.ts | 290 +++++++------- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 366 ++++++++--------- .../state.test.ts => stateManagement.test.ts} | 40 +- .../{keyring/state.ts => stateManagement.ts} | 38 +- .../logger => utils}/__mocks__/logger.ts | 0 .../src/utils/__mocks__/snap.ts | 30 ++ .../bitcoin-wallet-snap/src/utils/error.ts | 24 ++ .../src/utils/explorer.test.ts | 22 ++ .../bitcoin-wallet-snap/src/utils/explorer.ts | 29 ++ .../bitcoin-wallet-snap/src/utils/index.ts | 7 + .../src/utils/lock.test.ts | 21 + .../bitcoin-wallet-snap/src/utils/lock.ts | 16 + .../src/{libs/logger => utils}/logger.test.ts | 0 .../src/{libs/logger => utils}/logger.ts | 0 .../bitcoin-wallet-snap/src/utils/rpc.ts | 35 ++ .../snap-state.test.ts} | 31 +- .../snap/state.ts => utils/snap-state.ts} | 38 +- .../src/utils/snap.test.ts | 105 +++++ .../bitcoin-wallet-snap/src/utils/snap.ts | 82 ++++ .../src/utils/string.test.ts | 8 + .../bitcoin-wallet-snap/src/utils/string.ts | 10 + .../src/utils/superstruct.test.ts | 10 +- .../src/utils/superstruct.ts | 4 +- .../src/utils/unit.test.ts | 53 +++ .../bitcoin-wallet-snap/src/utils/unit.ts | 46 +++ .../bitcoin-wallet-snap/src/wallet.ts | 101 ++--- .../test/fixtures/blockstream.json | 220 ----------- .../bitcoin-wallet-snap/test/utils.ts | 135 +------ 120 files changed, 2158 insertions(+), 3871 deletions(-) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{data-client => chain}/clients/blockchair.test.ts (99%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{data-client => chain}/clients/blockchair.ts (89%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{data-client/types.ts => chain/data-client.ts} (50%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/types.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/config/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/config/types.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/exceptions.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.ts rename merged-packages/bitcoin-wallet-snap/src/{types => bitcoin/wallet}/static.ts (100%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/config.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/config/config.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/config/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/constants.ts rename merged-packages/bitcoin-wallet-snap/src/{keyring => }/keyring.test.ts (69%) rename merged-packages/bitcoin-wallet-snap/src/{keyring => }/keyring.ts (60%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/keyring/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/keyring/types.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/exception/exceptions.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/exception/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/rpc/exceptions.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/rpc/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/rpc/types.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/__mocks__/helpers.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/exceptions.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.ts rename merged-packages/bitcoin-wallet-snap/src/{config => }/permissions.ts (84%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts rename merged-packages/bitcoin-wallet-snap/src/{keyring/state.test.ts => stateManagement.test.ts} (90%) rename merged-packages/bitcoin-wallet-snap/src/{keyring/state.ts => stateManagement.ts} (79%) rename merged-packages/bitcoin-wallet-snap/src/{libs/logger => utils}/__mocks__/logger.ts (100%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/explorer.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/explorer.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/lock.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/lock.ts rename merged-packages/bitcoin-wallet-snap/src/{libs/logger => utils}/logger.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{libs/logger => utils}/logger.ts (100%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/rpc.ts rename merged-packages/bitcoin-wallet-snap/src/{libs/snap/state.test.ts => utils/snap-state.test.ts} (95%) rename merged-packages/bitcoin-wallet-snap/src/{libs/snap/state.ts => utils/snap-state.ts} (82%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/snap.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/unit.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 48d4fdfd..1e492fd6 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -34,6 +34,7 @@ "@metamask/keyring-api": "^6.3.1", "@metamask/snaps-sdk": "^4.0.0", "async-mutex": "^0.3.2", + "big.js": "^6.2.1", "bip32": "^4.0.0", "bitcoinjs-lib": "^6.1.5", "buffer": "^6.0.3", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index b3a96b45..d68d25f5 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "gycY0HZ56I1PRIdcUPm4dYlATHJxoWWivbMHJddftK0=", + "shasum": "MO1uECXQxFuVEjZuXbUov3P6Sx1Gklzx+8cqYxSYj3U=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -18,7 +18,6 @@ } }, "initialConnections": { - "https://metamask.github.io": {}, "http://localhost:8000": {}, "https://portfolio.metamask.io": {}, "https://portfolio-builds.metafi-dev.codefi.network": {}, @@ -31,7 +30,6 @@ }, "endowment:keyring": { "allowedOrigins": [ - "https://metamask.github.io", "http://localhost:8000", "https://portfolio.metamask.io", "https://portfolio-builds.metafi-dev.codefi.network", diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts similarity index 99% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts index c335d064..7a1e501d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts @@ -12,7 +12,7 @@ import { FeeRatio, TransactionStatus } from '../../../chain'; import { DataClientError } from '../exceptions'; import { BlockChairClient } from './blockchair'; -jest.mock('../../../libs/logger/logger'); +jest.mock('../../../utils/logger'); describe('BlockChairClient', () => { const createMockFetch = () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts similarity index 89% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts index df958fac..f7534517 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts @@ -1,17 +1,16 @@ import type { Json } from '@metamask/snaps-sdk'; import { type Network, networks } from 'bitcoinjs-lib'; -import type { TransactionStatusData } from '../../../chain'; -import { type Balances, FeeRatio, TransactionStatus } from '../../../chain'; -import { logger } from '../../../libs/logger/logger'; +import type { TransactionStatusData, Utxo } from '../../../chain'; +import { FeeRatio, TransactionStatus } from '../../../chain'; import { compactError } from '../../../utils'; -import type { Utxo } from '../../wallet'; -import { DataClientError } from '../exceptions'; +import { logger } from '../../../utils/logger'; import type { + GetBalancesResp, GetFeeRatesResp, - IReadDataClient, - IWriteDataClient, -} from '../types'; + IDataClient, +} from '../data-client'; +import { DataClientError } from '../exceptions'; export type BlockChairClientOptions = { network: Network; @@ -24,7 +23,7 @@ export type LargestTransaction = { value_usd: number; }; -export type GetBalanceResponse = { +export type GetBalancesResponse = { data: { [address: string]: number; }; @@ -204,7 +203,7 @@ export type PostTransactionResponse = { }; /* eslint-disable */ -export class BlockChairClient implements IReadDataClient, IWriteDataClient { +export class BlockChairClient implements IDataClient { protected readonly _options: BlockChairClientOptions; constructor(options: BlockChairClientOptions) { @@ -218,7 +217,7 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { case networks.testnet: return 'https://api.blockchair.com/bitcoin/testnet'; default: - throw new DataClientError('Invalid network'); + throw new Error('Invalid network'); } } @@ -237,7 +236,7 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { }); if (!response.ok) { - throw new DataClientError( + throw new Error( `Failed to fetch data from blockchair: ${response.statusText}`, ); } @@ -255,13 +254,13 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { if (response.status == 400) { const res = await response.json(); - throw new DataClientError( + throw new Error( `Failed to post data from blockchair: ${res.context.error}`, ); } if (!response.ok) { - throw new DataClientError( + throw new Error( `Failed to post data from blockchair: ${response.statusText}`, ); } @@ -283,11 +282,14 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { ); return response; } catch (error) { + logger.info( + `[BlockChairClient.getTxDashboardData] error: ${error.message}`, + ); throw compactError(error, DataClientError); } } - async getBalances(addresses: string[]): Promise { + async getBalances(addresses: string[]): Promise { try { logger.info( `[BlockChairClient.getBalance] start: { addresses : ${JSON.stringify( @@ -295,7 +297,7 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { )} }`, ); - const response = await this.get( + const response = await this.get( `/addresses/balances?addresses=${addresses.join(',')}`, ); @@ -303,11 +305,12 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { `[BlockChairClient.getBalance] response: ${JSON.stringify(response)}`, ); - return addresses.reduce((data: Balances, address: string) => { + return addresses.reduce((data: GetBalancesResp, address: string) => { data[address] = response.data[address] ?? 0; return data; }, {}); } catch (error) { + logger.info(`[BlockChairClient.getBalance] error: ${error.message}`); throw compactError(error, DataClientError); } } @@ -336,7 +339,7 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { ); if (!Object.prototype.hasOwnProperty.call(response.data, address)) { - throw new DataClientError(`Data not avaiable`); + throw new Error(`Data not avaiable`); } response.data[address].utxo.forEach((utxo) => { @@ -357,6 +360,7 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { return data; } catch (error) { + logger.info(`[BlockChairClient.getUtxos] error: ${error.message}`); throw compactError(error, DataClientError); } } @@ -372,6 +376,7 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { [FeeRatio.Fast]: response.data.suggested_transaction_fee_per_byte_sat, }; } catch (error) { + logger.info(`[BlockChairClient.getFeeRates] error: ${error.message}`); throw compactError(error, DataClientError); } } @@ -393,6 +398,7 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { return response.data.transaction_hash; } catch (error) { + logger.info(`[BlockChairClient.sendTransaction] error: ${error.message}`); throw compactError(error, DataClientError); } } @@ -418,6 +424,9 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { status: status, }; } catch (error) { + logger.info( + `[BlockChairClient.getTransactionStatus] error: ${error.message}`, + ); throw compactError(error, DataClientError); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts similarity index 50% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/types.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts index f7c18ba5..a510b1a6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts @@ -1,19 +1,15 @@ -import type { Balances, FeeRatio, TransactionStatusData } from '../../chain'; -import type { Utxo } from '../wallet'; +import type { FeeRatio, TransactionStatusData, Utxo } from '../../chain'; + +export type GetBalancesResp = Record; export type GetFeeRatesResp = { [key in FeeRatio]?: number; }; -export type IReadDataClient = { - getBalances(address: string[]): Promise; +export type IDataClient = { + getBalances(address: string[]): Promise; getUtxos(address: string, includeUnconfirmed?: boolean): Promise; getFeeRates(): Promise; getTransactionStatus(txHash: string): Promise; -}; - -export type IWriteDataClient = { sendTransaction(tx: string): Promise; }; - -export type IDataClient = IReadDataClient & IWriteDataClient; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts index 8321d50a..54affb11 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts @@ -1,3 +1,5 @@ -import { CustomError } from '../../libs/exception'; +import { CustomError } from '../../utils'; + +export class DataClientError extends CustomError {} export class BtcOnChainServiceError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts index 5ce37176..cb209e05 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts @@ -1,3 +1,2 @@ export * from './exceptions'; export * from './service'; -export * from './types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts index 8d4745c0..ad3dc7bf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts @@ -7,21 +7,22 @@ import { generateBlockChairGetUtxosResp, } from '../../../test/utils'; import { FeeRatio, TransactionStatus } from '../../chain'; -import { BtcAsset } from '../constants'; -import type { IReadDataClient, IWriteDataClient } from '../data-client'; -import { BtcAmount } from '../wallet'; +import { Caip2Asset } from '../../constants'; +import type { IDataClient } from './data-client'; import { BtcOnChainServiceError } from './exceptions'; import { BtcOnChainService } from './service'; -jest.mock('../../libs/logger/logger'); +jest.mock('../../utils/logger'); describe('BtcOnChainService', () => { - const createMockReadDataClient = () => { + const createMockDataClient = () => { const getBalanceSpy = jest.fn(); const getUtxosSpy = jest.fn(); const getFeeRatesSpy = jest.fn(); const getTransactionStatusSpy = jest.fn(); - class MockReadDataClient implements IReadDataClient { + const sendTransactionSpy = jest.fn(); + + class MockReadDataClient implements IDataClient { getBalances = getBalanceSpy; getUtxos = getUtxosSpy; @@ -29,6 +30,8 @@ describe('BtcOnChainService', () => { getFeeRates = getFeeRatesSpy; getTransactionStatus = getTransactionStatusSpy; + + sendTransaction = sendTransactionSpy; } return { @@ -37,29 +40,16 @@ describe('BtcOnChainService', () => { getUtxosSpy, getFeeRatesSpy, getTransactionStatusSpy, - }; - }; - - const createMockWriteDataClient = () => { - const sendTransactionSpy = jest.fn(); - - class MockWriteDataClient implements IWriteDataClient { - sendTransaction = sendTransactionSpy; - } - return { - instance: new MockWriteDataClient(), sendTransactionSpy, }; }; const createMockBtcService = ( - readDataClient?: IReadDataClient, - writeDataClient?: IWriteDataClient, + dataClient?: IDataClient, network: Network = networks.testnet, ) => { const instance = new BtcOnChainService( - readDataClient ?? createMockReadDataClient().instance, - writeDataClient ?? createMockWriteDataClient().instance, + dataClient ?? createMockDataClient().instance, { network, }, @@ -72,7 +62,7 @@ describe('BtcOnChainService', () => { describe('getBalance', () => { it('calls getBalances with readClient', async () => { - const { instance, getBalanceSpy } = createMockReadDataClient(); + const { instance, getBalanceSpy } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); @@ -83,60 +73,59 @@ describe('BtcOnChainService', () => { }, {}), ); - const result = await txService.getBalances(addresses, [BtcAsset.TBtc]); + const result = await txService.getBalances(addresses, [Caip2Asset.TBtc]); expect(getBalanceSpy).toHaveBeenCalledWith(addresses); Object.values(result.balances).forEach((assetBalances) => { expect(assetBalances).toStrictEqual({ - [BtcAsset.TBtc]: { - amount: expect.any(BtcAmount), + [Caip2Asset.TBtc]: { + amount: BigInt(100), }, }); }); }); it('throws `Only one asset is supported` error if the given asset more than 1', async () => { - const { instance } = createMockReadDataClient(); + const { instance } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); await expect( - txService.getBalances(addresses, [BtcAsset.TBtc, BtcAsset.Btc]), + txService.getBalances(addresses, [Caip2Asset.TBtc, Caip2Asset.Btc]), ).rejects.toThrow('Only one asset is supported'); }); it('throws `Invalid asset` error if the BTC asset is given and current network is testnet network', async () => { - const { instance } = createMockReadDataClient(); + const { instance } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); await expect( - txService.getBalances(addresses, [BtcAsset.Btc]), + txService.getBalances(addresses, [Caip2Asset.Btc]), ).rejects.toThrow('Invalid asset'); }); it('throws `Invalid asset` error if the TBTC asset is given and current network is bitcoin network', async () => { - const { instance } = createMockReadDataClient(); + const { instance } = createMockDataClient(); const { instance: txService } = createMockBtcService( instance, - undefined, networks.bitcoin, ); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); await expect( - txService.getBalances(addresses, [BtcAsset.TBtc]), + txService.getBalances(addresses, [Caip2Asset.TBtc]), ).rejects.toThrow('Invalid asset'); }); }); describe('getUtxos', () => { it('calls getUtxos with readClient', async () => { - const { instance, getUtxosSpy } = createMockReadDataClient(); + const { instance, getUtxosSpy } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const sender = accounts[0].address; @@ -168,7 +157,7 @@ describe('BtcOnChainService', () => { }); it('throws error if readClient fail', async () => { - const { instance, getUtxosSpy } = createMockReadDataClient(); + const { instance, getUtxosSpy } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const sender = accounts[0].address; @@ -190,11 +179,11 @@ describe('BtcOnChainService', () => { describe('getFeeRates', () => { it('return getFeeRates result', async () => { - const { instance, getFeeRatesSpy } = createMockReadDataClient(); + const { instance, getFeeRatesSpy } = createMockDataClient(); const { instance: txMgr } = createMockBtcService(instance); getFeeRatesSpy.mockResolvedValue({ - [FeeRatio.Fast]: 1.1, - [FeeRatio.Medium]: 1.2, + [FeeRatio.Fast]: 1, + [FeeRatio.Medium]: 2, }); const result = await txMgr.getFeeRates(); @@ -204,20 +193,18 @@ describe('BtcOnChainService', () => { fees: [ { type: FeeRatio.Fast, - rate: expect.any(BtcAmount), + rate: BigInt(1), }, { type: FeeRatio.Medium, - rate: expect.any(BtcAmount), + rate: BigInt(2), }, ], }); - expect(result.fees[0].rate.value).toBe(1.1); - expect(result.fees[1].rate.value).toBe(1.2); }); it('throws BtcOnChainServiceError error if an error catched', async () => { - const { instance, getFeeRatesSpy } = createMockReadDataClient(); + const { instance, getFeeRatesSpy } = createMockDataClient(); const { instance: txMgr } = createMockBtcService(instance); getFeeRatesSpy.mockRejectedValue(new Error('error')); @@ -231,8 +218,8 @@ describe('BtcOnChainService', () => { '02000000000101ec81faa8b57add4c8fb3958dd8f04667f5cd829a7b94199f4400be9e52cda0760000000000ffffffff015802000000000000160014f80b562cbcbbfc97727043484c06cc5579963e8402473044022011ec3f7ea7a7cac7cb891a1ea498d94ca3cd082339b9b2620ba5421ca7cbdf3d022062f34411d6aa5335c2bd7ff4c940adb962e9509133b86a2d97996552fd811f2c012102ceea82614fdb14871ef881498c55c5dbdc24b4633d29b42040dd18b4285540f500000000'; it('calls sendTransaction with writeClient', async () => { - const { instance, sendTransactionSpy } = createMockWriteDataClient(); - const { instance: txService } = createMockBtcService(undefined, instance); + const { instance, sendTransactionSpy } = createMockDataClient(); + const { instance: txService } = createMockBtcService(instance); const resp = generateBlockChairBroadcastTransactionResp(); sendTransactionSpy.mockResolvedValue(resp.data.transaction_hash); @@ -246,8 +233,8 @@ describe('BtcOnChainService', () => { }); it('throws BtcOnChainServiceErrorr if write client execute fail', async () => { - const { instance, sendTransactionSpy } = createMockWriteDataClient(); - const { instance: txService } = createMockBtcService(undefined, instance); + const { instance, sendTransactionSpy } = createMockDataClient(); + const { instance: txService } = createMockBtcService(instance); sendTransactionSpy.mockRejectedValue(new Error('error')); await expect( @@ -261,7 +248,7 @@ describe('BtcOnChainService', () => { '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; it('return getTransactionStatus result', async () => { - const { instance, getTransactionStatusSpy } = createMockReadDataClient(); + const { instance, getTransactionStatusSpy } = createMockDataClient(); const { instance: txMgr } = createMockBtcService(instance); getTransactionStatusSpy.mockResolvedValue({ status: TransactionStatus.Confirmed, @@ -276,7 +263,7 @@ describe('BtcOnChainService', () => { }); it('throws BtcOnChainServiceError error if an error catched', async () => { - const { instance, getTransactionStatusSpy } = createMockReadDataClient(); + const { instance, getTransactionStatusSpy } = createMockDataClient(); const { instance: txMgr } = createMockBtcService(instance); getTransactionStatusSpy.mockRejectedValue(new Error('error')); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index 08b908a8..d22aa65e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -4,34 +4,28 @@ import { networks } from 'bitcoinjs-lib'; import type { FeeRatio, IOnChainService, - Balances, AssetBalances, TransactionIntent, Fees, TransactionData, CommitedTransaction, } from '../../chain'; +import { Caip2Asset } from '../../constants'; import { compactError } from '../../utils'; -import { BtcAsset } from '../constants'; -import type { IWriteDataClient, IReadDataClient } from '../data-client'; -import { BtcAmount } from '../wallet'; +import type { IDataClient } from './data-client'; import { BtcOnChainServiceError } from './exceptions'; -import type { BtcOnChainServiceOptions } from './types'; -export class BtcOnChainService implements IOnChainService { - protected readonly _readClient: IReadDataClient; +export type BtcOnChainServiceOptions = { + network: Network; +}; - protected readonly _writeClient: IWriteDataClient; +export class BtcOnChainService implements IOnChainService { + protected readonly _dataClient: IDataClient; protected readonly _options: BtcOnChainServiceOptions; - constructor( - readClient: IReadDataClient, - writeClient: IWriteDataClient, - options: BtcOnChainServiceOptions, - ) { - this._readClient = readClient; - this._writeClient = writeClient; + constructor(dataClient: IDataClient, options: BtcOnChainServiceOptions) { + this._dataClient = dataClient; this._options = options; } @@ -48,23 +42,23 @@ export class BtcOnChainService implements IOnChainService { throw new BtcOnChainServiceError('Only one asset is supported'); } - const allowedAssets = new Set(Object.values(BtcAsset)); + const allowedAssets = new Set(Object.values(Caip2Asset)); if ( !allowedAssets.has(assets[0]) || - (this.network === networks.testnet && assets[0] !== BtcAsset.TBtc) || - (this.network === networks.bitcoin && assets[0] !== BtcAsset.Btc) + (this.network === networks.testnet && assets[0] !== Caip2Asset.TBtc) || + (this.network === networks.bitcoin && assets[0] !== Caip2Asset.Btc) ) { throw new BtcOnChainServiceError('Invalid asset'); } - const balance: Balances = await this._readClient.getBalances(addresses); + const balance = await this._dataClient.getBalances(addresses); return addresses.reduce( (acc: AssetBalances, address: string) => { acc.balances[address] = { [assets[0]]: { - amount: new BtcAmount(balance[address]), + amount: BigInt(balance[address]), }, }; return acc; @@ -78,24 +72,24 @@ export class BtcOnChainService implements IOnChainService { async getFeeRates(): Promise { try { - const result = await this._readClient.getFeeRates(); + const result = await this._dataClient.getFeeRates(); return { fees: Object.entries(result).map( ([key, value]: [key: FeeRatio, value: number]) => ({ type: key, - rate: new BtcAmount(value), + rate: BigInt(value), }), ), }; } catch (error) { - throw new BtcOnChainServiceError(error); + throw compactError(error, BtcOnChainServiceError); } } async getTransactionStatus(txHash: string) { try { - return await this._readClient.getTransactionStatus(txHash); + return await this._dataClient.getTransactionStatus(txHash); } catch (error) { throw new BtcOnChainServiceError(error); } @@ -107,7 +101,7 @@ export class BtcOnChainService implements IOnChainService { transactionIntent?: TransactionIntent, ): Promise { try { - const data = await this._readClient.getUtxos(address); + const data = await this._dataClient.getUtxos(address); return { data: { utxos: data, @@ -122,7 +116,7 @@ export class BtcOnChainService implements IOnChainService { signedTransaction: string, ): Promise { try { - const transactionId = await this._writeClient.sendTransaction( + const transactionId = await this._dataClient.sendTransaction( signedTransaction, ); return { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/types.ts deleted file mode 100644 index 60ce9afb..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Network } from 'bitcoinjs-lib'; - -export type BtcOnChainServiceOptions = { - network: Network; -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/index.ts deleted file mode 100644 index fcb073fe..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/types.ts deleted file mode 100644 index a732c410..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Json } from '@metamask/snaps-sdk'; - -import type { DataClient } from '../constants'; - -export type BtcOnChainServiceConfig = { - dataClient: { - read: { - type: DataClient; - options?: Record; - }; - write: { - type: DataClient; - options?: Record; - }; - }; -}; - -export type BtcWalletConfig = { - enableMultiAccounts: boolean; - defaultAccountIndex: number; - defaultAccountType: string; - deriver: string; -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts index 58e194e3..0aa18b3d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts @@ -4,27 +4,6 @@ export enum ScriptType { P2wpkh = 'p2wpkh', } -export enum Network { - Mainnet = 'bip122:000000000019d6689c085ae165831e93', - Testnet = 'bip122:000000000933ea01ad0ee984209779ba', -} - -export enum DataClient { - BlockStream = 'BlockStream', - BlockChair = 'BlockChair', -} - -export enum BtcAsset { - Btc = 'bip122:000000000019d6689c085ae165831e93/slip44:0', - TBtc = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0', -} - -export enum BtcUnit { - Btc = 'BTC', - TBtc = 'tBTC', - Sat = 'satoshi', -} - // reference https://help.magiceden.io/en/articles/8665399-navigating-bitcoin-dust-understanding-limits-and-safeguarding-your-transactions-on-magic-eden // "Dust" is defined in terms of dustRelayFee, // which has units satoshis-per-kilobyte. @@ -48,4 +27,11 @@ export const DustLimit = { /* eslint-disable */ }; +// Maximum weight in bytes for a standard transaction export const MaxStandardTxWeight = 400000; + +// Maximum amount of satoshis +export const maxSatoshi = 21 * 1e14; + +// Minimum amount of satoshis +export const minSatoshi = 1; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts deleted file mode 100644 index ae127a5b..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { networks } from 'bitcoinjs-lib'; - -import { - generateAccounts, - generateBlockStreamAccountStats, - generateBlockStreamGetUtxosResp, - generateBlockStreamEstFeeResp, - generateBlockStreamTransactionStatusResp, -} from '../../../../test/utils'; -import { FeeRatio, TransactionStatus } from '../../../chain'; -import * as asyncUtils from '../../../utils/async'; -import { DataClientError } from '../exceptions'; -import { BlockStreamClient } from './blockstream'; - -jest.mock('../../../libs/logger/logger'); - -describe('BlockStreamClient', () => { - const createMockFetch = () => { - // 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, - }; - }; - - 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('getBalances', () => { - it('returns balances', async () => { - const { fetchSpy } = createMockFetch(); - const accounts = generateAccounts(60); - const addresses = accounts.map((account) => account.address); - const mockResponse = generateBlockStreamAccountStats(addresses); - const expectedResult = {}; - mockResponse.forEach((data) => { - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(data), - }); - expectedResult[data.address] = - data.chain_stats.funded_txo_sum - data.chain_stats.spent_txo_sum; - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getBalances(addresses); - - expect(result).toStrictEqual(expectedResult); - }); - - it('assigns balance to 0 if it failed to fetch', async () => { - const { fetchSpy } = createMockFetch(); - fetchSpy.mockResolvedValue({ - ok: false, - statusText: '503', - }); - const accounts = generateAccounts(1); - const addresses = accounts.map((account) => account.address); - - const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getBalances(addresses); - - expect(result).toStrictEqual({ [addresses[0]]: 0 }); - }); - - it('throws DataClientError error if an non DataClientError catched', async () => { - const asyncHelperSpy = jest.spyOn(asyncUtils, 'processBatch'); - asyncHelperSpy.mockRejectedValue(new Error('error')); - const accounts = generateAccounts(1); - const addresses = accounts.map((account) => account.address); - - const instance = new BlockStreamClient({ network: networks.testnet }); - - await expect(instance.getBalances(addresses)).rejects.toThrow( - DataClientError, - ); - }); - }); - - describe('getFeeRates', () => { - it('returns fee rate', async () => { - const { fetchSpy } = createMockFetch(); - const mockResponse = generateBlockStreamEstFeeResp(); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getFeeRates(); - - expect(result).toStrictEqual({ - [FeeRatio.Fast]: Math.round(mockResponse['1']), - [FeeRatio.Medium]: Math.round(mockResponse['25']), - [FeeRatio.Slow]: Math.round(mockResponse['144']), - }); - }); - - it('throws DataClientError error if an non DataClientError catched', async () => { - const { fetchSpy } = createMockFetch(); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockRejectedValue(new Error('error')), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - - await expect(instance.getFeeRates()).rejects.toThrow(DataClientError); - }); - - it('throws DataClientError error if an DataClientError catched', async () => { - const { fetchSpy } = createMockFetch(); - - fetchSpy.mockResolvedValueOnce({ - ok: false, - json: jest.fn().mockResolvedValue(null), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - - await expect(instance.getFeeRates()).rejects.toThrow(DataClientError); - }); - }); - - describe('getUtxos', () => { - it('returns utxos', async () => { - const { fetchSpy } = createMockFetch(); - const accounts = generateAccounts(1); - const { address } = accounts[0]; - const mockResponse = generateBlockStreamGetUtxosResp(100); - const expectedResult = mockResponse.map((utxo) => ({ - block: utxo.status.block_height, - txHash: utxo.txid, - index: utxo.vout, - value: utxo.value, - })); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getUtxos(address); - - expect(result).toStrictEqual(expectedResult); - }); - - it('includes unconfirmed if includeUnconfirmed is true', async () => { - const { fetchSpy } = createMockFetch(); - const accounts = generateAccounts(1); - const { address } = accounts[0]; - const mockConfirmedResponse = generateBlockStreamGetUtxosResp(100); - const mockUnconfirmedResponse = generateBlockStreamGetUtxosResp( - 100, - false, - ); - const combinedResponse = mockConfirmedResponse.concat( - mockUnconfirmedResponse, - ); - const expectedResult = combinedResponse.map((utxo) => ({ - block: utxo.status.block_height, - txHash: utxo.txid, - index: utxo.vout, - value: utxo.value, - })); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(combinedResponse), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getUtxos(address, true); - - expect(result).toStrictEqual(expectedResult); - }); - - it('filters unconfirmed utxos if includeUnconfirmed is true', async () => { - const { fetchSpy } = createMockFetch(); - const accounts = generateAccounts(1); - const { address } = accounts[0]; - const mockConfirmedResponse = generateBlockStreamGetUtxosResp(100); - const mockUnconfirmedResponse = generateBlockStreamGetUtxosResp( - 100, - false, - ); - const combinedResponse = mockConfirmedResponse.concat( - mockUnconfirmedResponse, - ); - const expectedResult = mockConfirmedResponse.map((utxo) => ({ - block: utxo.status.block_height, - txHash: utxo.txid, - index: utxo.vout, - value: utxo.value, - })); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(combinedResponse), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getUtxos(address, false); - - expect(result).toStrictEqual(expectedResult); - }); - - it('throws DataClientError error if an non DataClientError catched', async () => { - const { fetchSpy } = createMockFetch(); - fetchSpy.mockRejectedValue(new Error('error')); - const accounts = generateAccounts(1); - const { address } = accounts[0]; - - const instance = new BlockStreamClient({ network: networks.testnet }); - - await expect(instance.getUtxos(address)).rejects.toThrow(DataClientError); - }); - }); - - describe('getTransactionStatus', () => { - const txHash = - '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; - - it('returns correct result for confirmed transaction', async () => { - const { fetchSpy } = createMockFetch(); - const mockTxStatusResponse = generateBlockStreamTransactionStatusResp( - 200000, - true, - ); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockTxStatusResponse), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getTransactionStatus(txHash); - - expect(result).toStrictEqual({ - status: TransactionStatus.Confirmed, - }); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - - it('returns correct result for pending transaction', async () => { - const { fetchSpy } = createMockFetch(); - const mockTxStatusResponse = generateBlockStreamTransactionStatusResp( - 200000, - false, - ); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockTxStatusResponse), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getTransactionStatus(txHash); - - expect(result).toStrictEqual({ - status: TransactionStatus.Pending, - }); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - - it('throws DataClientError error if an DataClientError catched', async () => { - const { fetchSpy } = createMockFetch(); - - fetchSpy.mockResolvedValueOnce({ - ok: false, - json: jest.fn().mockResolvedValue(null), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - - await expect(instance.getTransactionStatus(txHash)).rejects.toThrow( - DataClientError, - ); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts deleted file mode 100644 index 4b8b155b..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { type Network, networks } from 'bitcoinjs-lib'; - -import type { TransactionStatusData } from '../../../chain'; -import { type Balances, FeeRatio, TransactionStatus } from '../../../chain'; -import { logger } from '../../../libs/logger/logger'; -import { compactError, processBatch } from '../../../utils'; -import type { Utxo } from '../../wallet'; -import { DataClientError } from '../exceptions'; -import type { GetFeeRatesResp, IReadDataClient } from '../types'; - -export type BlockStreamClientOptions = { - network: Network; -}; - -/* eslint-disable */ -export type GetFeeEstimateResponse = Record; - -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; - }; -}; - -export type GetTransactionStatusResponse = { - confirmed: boolean; - block_height: number; - block_hash: string; - block_time: number; -}; - -export type GetUtxosResponse = { - txid: string; - vout: number; - status: { - confirmed: boolean; - block_height: number; - block_hash: string; - block_time: number; - }; - value: number; -}[]; - -export type GetBlocksResponse = { - id: string; - height: number; - version: number; - timestamp: number; - tx_count: number; - size: number; - weight: number; - merkle_root: string; - previousblockhash: string; - mediantime: number; - nonce: number; - bits: number; - difficulty: number; -}[]; - -/* eslint-enable */ - -export class BlockStreamClient implements IReadDataClient { - protected readonly _options: BlockStreamClientOptions; - - protected readonly _feeRateRatioMap: Record; - - constructor(options: BlockStreamClientOptions) { - this._options = options; - this._feeRateRatioMap = { - [FeeRatio.Fast]: '1', - [FeeRatio.Medium]: '144', - [FeeRatio.Slow]: '144', - }; - } - - 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'); - } - } - - 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 unknown as Resp; - } - - async getBalances(addresses: string[]): Promise { - try { - const responses: Balances = {}; - - await processBatch(addresses, async (address: string) => { - logger.info(`[BlockStreamClient.getBalance] address: ${address}`); - let balance = 0; - try { - const response = await this.get( - `/address/${address}`, - ); - logger.info( - `[BlockStreamClient.getBalance] response: ${JSON.stringify( - response, - )}`, - ); - balance = - response.chain_stats.funded_txo_sum - - response.chain_stats.spent_txo_sum; - } catch (error) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.info(`[BlockStreamClient.getBalance] error: ${error.message}`); - } finally { - responses[address] = balance; - } - }); - - return responses; - } catch (error) { - throw compactError(error, DataClientError); - } - } - - async getUtxos( - address: string, - includeUnconfirmed?: boolean, - ): Promise { - try { - const response = await this.get( - `/address/${address}/utxo`, - ); - - logger.info( - `[BlockStreamClient.getUtxos] response: ${JSON.stringify(response)}`, - ); - - const data: Utxo[] = []; - - for (const utxo of response) { - if (!includeUnconfirmed && !utxo.status.confirmed) { - continue; - } - data.push({ - block: utxo.status.block_height, - txHash: utxo.txid, - index: utxo.vout, - value: utxo.value, - }); - } - - return data; - } catch (error) { - throw compactError(error, DataClientError); - } - } - - async getFeeRates(): Promise { - try { - logger.info(`[BlockStreamClient.getFeeRates] start:`); - const response = await this.get(`/fee-estimates`); - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.info( - `[BlockStreamClient.getFeeRates] response: ${JSON.stringify(response)}`, - ); - return { - [FeeRatio.Fast]: Math.round( - response[this._feeRateRatioMap[FeeRatio.Fast]], - ), - [FeeRatio.Medium]: Math.round( - response[this._feeRateRatioMap[FeeRatio.Medium]], - ), - [FeeRatio.Slow]: Math.round( - response[this._feeRateRatioMap[FeeRatio.Slow]], - ), - }; - } catch (error) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.info(`[BlockStreamClient.getFeeRates] error: ${error.message}`); - if (error instanceof DataClientError) { - throw error; - } - throw new DataClientError(error); - } - } - - async getTransactionStatus(txHash: string): Promise { - try { - const txStatusResp = await this.get( - `/tx/${txHash}/status`, - ); - - const status = txStatusResp.confirmed - ? TransactionStatus.Confirmed - : TransactionStatus.Pending; - - return { - status, - }; - } catch (error) { - throw compactError(error, DataClientError); - } - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/exceptions.ts deleted file mode 100644 index d46bde48..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/exceptions.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { CustomError } from '../../libs/exception'; - -export class DataClientError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.test.ts deleted file mode 100644 index 0c9aa8a1..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { networks } from 'bitcoinjs-lib'; - -import { DataClient } from '../constants'; -import { BlockChairClient } from './clients/blockchair'; -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 }, - write: { type: DataClient.BlockChair }, - }, - }, - networks.testnet, - ); - - expect(instance).toBeInstanceOf(BlockStreamClient); - }); - - it('creates BlockChairClient', () => { - const instance = DataClientFactory.createReadClient( - { - dataClient: { - read: { type: DataClient.BlockChair }, - write: { type: DataClient.BlockChair }, - }, - }, - networks.testnet, - ); - - expect(instance).toBeInstanceOf(BlockChairClient); - }); - - it('throws `Unsupported client type` if the given client is not support', () => { - expect(() => - DataClientFactory.createReadClient( - { - dataClient: { - read: { type: 'SomeClient' as unknown as DataClient }, - write: { type: DataClient.BlockChair }, - }, - }, - networks.testnet, - ), - ).toThrow('Unsupported client type: SomeClient'); - }); - }); - - describe('createWriteClient', () => { - it('creates BlockChairClient', () => { - const instance = DataClientFactory.createWriteClient( - { - dataClient: { - read: { type: DataClient.BlockChair }, - write: { type: DataClient.BlockChair }, - }, - }, - networks.testnet, - ); - - expect(instance).toBeInstanceOf(BlockChairClient); - }); - - it('throws `Unsupported client type` if the given client is not support', () => { - expect(() => - DataClientFactory.createWriteClient( - { - dataClient: { - read: { type: DataClient.BlockChair }, - write: { type: 'SomeClient' as unknown as DataClient }, - }, - }, - networks.testnet, - ), - ).toThrow('Unsupported client type: SomeClient'); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.ts deleted file mode 100644 index f2d1b90f..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { type Network } from 'bitcoinjs-lib'; - -import { type BtcOnChainServiceConfig } from '../config'; -import { DataClient } from '../constants'; -import { BlockChairClient } from './clients/blockchair'; -import { BlockStreamClient } from './clients/blockstream'; -import { DataClientError } from './exceptions'; -import type { IReadDataClient, IWriteDataClient } from './types'; - -export class DataClientFactory { - static createReadClient( - config: BtcOnChainServiceConfig, - network: Network, - ): IReadDataClient { - const { type, options } = config.dataClient.read; - switch (type) { - case DataClient.BlockStream: - return new BlockStreamClient({ network }); - case DataClient.BlockChair: - return new BlockChairClient({ - network, - apiKey: options?.apiKey?.toString(), - }); - default: - throw new DataClientError( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Unsupported client type: ${config.dataClient.read.type}`, - ); - } - } - - static createWriteClient( - config: BtcOnChainServiceConfig, - network: Network, - ): IWriteDataClient { - const { type, options } = config.dataClient.write; - switch (type) { - case DataClient.BlockChair: - return new BlockChairClient({ - network, - apiKey: options?.apiKey?.toString(), - }); - default: - throw new DataClientError( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Unsupported client type: ${config.dataClient.write.type}`, - ); - } - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/index.ts deleted file mode 100644 index 8decdbf9..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './exceptions'; -export * from './types'; -export * from './clients/blockstream'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts index d6d083e6..e12050c8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts @@ -12,10 +12,10 @@ describe('address', () => { expect(val).toBeInstanceOf(Buffer); }); - it('throws `Destnation address has no matching Script` error if the given address is invalid', () => { + it('throws `Destination address has no matching Script` error if the given address is invalid', () => { expect(() => getScriptForDestnation('bad-address', networks.testnet), - ).toThrow(`Destnation address has no matching Script`); + ).toThrow(`Destination address has no matching Script`); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts index e36946dd..c4736482 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts @@ -12,6 +12,6 @@ export function getScriptForDestnation(address: string, network: Network) { try { return addressUtils.toOutputScript(address, network); } catch (error) { - throw new Error('Destnation address has no matching Script'); + throw new Error('Destination address has no matching Script'); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.test.ts deleted file mode 100644 index a7487cab..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Chain, Config } from '../../config'; -import { Network } from '../constants'; -import { getExplorerUrl } from './explorer'; - -describe('getExplorerUrl', () => { - const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; - - it('returns a bitcoin testnet explorer url', () => { - const result = getExplorerUrl(address, Network.Testnet); - expect(result).toBe( - `${Config.explorer[Chain.Bitcoin][Network.Testnet]}/address/${address}`, - ); - }); - - it('returns a bitcoin mainnet explorer url', () => { - const result = getExplorerUrl(address, Network.Mainnet); - expect(result).toBe( - `${Config.explorer[Chain.Bitcoin][Network.Mainnet]}/address/${address}`, - ); - }); - - it('throws `Invalid network` error if the given network is not support', () => { - expect(() => getExplorerUrl(address, 'some invalid network')).toThrow( - 'Invalid network', - ); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts deleted file mode 100644 index ed7c152f..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Chain, Config } from '../../config'; -import { Network } from '../constants'; - -/** - * Gets the explorer URL for a given bitcoin address and CAIP-2 Chain ID. - * - * @param address - The bitcoin address to get the explorer URL for. - * @param network - The CAIP-2 Chain ID. - * @returns The explorer URL as a string. - * @throws An error if an invalid network is provided. - */ -export function getExplorerUrl(address: string, network: string): string { - switch (network) { - case Network.Mainnet: - return `${ - Config.explorer[Chain.Bitcoin][Network.Mainnet] - }/address/${address}`; - case Network.Testnet: - return `${ - Config.explorer[Chain.Bitcoin][Network.Testnet] - }/address/${address}`; - default: - throw new Error('Invalid network'); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts index fb79250f..a57139bb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts @@ -1,4 +1,4 @@ export * from './payment'; export * from './network'; -export * from './unit'; -export * from './explorer'; +export * from './policy'; +export * from './address'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts index 29e66ac3..279d4297 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts @@ -1,22 +1,22 @@ import { networks } from 'bitcoinjs-lib'; -import { Network } from '../constants'; +import { Caip2ChainId } from '../../constants'; import { getBtcNetwork, getCaip2ChainId } from './network'; describe('getBtcNetwork', () => { it('returns bitcoin testnet network', () => { - const result = getBtcNetwork(Network.Testnet); + const result = getBtcNetwork(Caip2ChainId.Testnet); expect(result).toStrictEqual(networks.testnet); }); it('returns bitcoin mainnet network', () => { - const result = getBtcNetwork(Network.Mainnet); + const result = getBtcNetwork(Caip2ChainId.Mainnet); expect(result).toStrictEqual(networks.bitcoin); }); it('throws `Invalid network` error if the given network is not support', () => { expect(() => - getBtcNetwork('someInvalidNetwork' as unknown as Network), + getBtcNetwork('someInvalidNetwork' as unknown as Caip2ChainId), ).toThrow('Invalid network'); }); }); @@ -24,12 +24,12 @@ describe('getBtcNetwork', () => { describe('getCaip2ChainId', () => { it('returns CAIP-2 testnet chain ID of bitcoin', () => { const result = getCaip2ChainId(networks.testnet); - expect(result).toStrictEqual(Network.Testnet); + expect(result).toStrictEqual(Caip2ChainId.Testnet); }); it('returns CAIP-2 mainnet chain ID of bitcoin', () => { const result = getCaip2ChainId(networks.bitcoin); - expect(result).toStrictEqual(Network.Mainnet); + expect(result).toStrictEqual(Caip2ChainId.Mainnet); }); it('throws `Invalid network` error if the given network is not support', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts index b6078c15..9900e71e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts @@ -1,20 +1,20 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; -import { Network as NetworkEnum } from '../constants'; +import { Caip2ChainId } from '../../constants'; /** * Gets a bitcoinjs-lib network instance based on a given CAIP-2 Chain ID. * - * @param network - The CAIP-2 Chain ID. + * @param caip2ChainId - The CAIP-2 Chain ID. * @returns The instance of bitcoinjs-lib network. * @throws An error if an invalid network is provided. */ -export function getBtcNetwork(network: string) { - switch (network) { - case NetworkEnum.Mainnet: +export function getBtcNetwork(caip2ChainId: string) { + switch (caip2ChainId) { + case Caip2ChainId.Mainnet: return networks.bitcoin; - case NetworkEnum.Testnet: + case Caip2ChainId.Testnet: return networks.testnet; default: throw new Error('Invalid network'); @@ -28,12 +28,12 @@ export function getBtcNetwork(network: string) { * @returns The CAIP-2 Chain ID. * @throws An error if an invalid network is provided. */ -export function getCaip2ChainId(network: Network): NetworkEnum { +export function getCaip2ChainId(network: Network): Caip2ChainId { switch (network) { case networks.bitcoin: - return NetworkEnum.Mainnet; + return Caip2ChainId.Mainnet; case networks.testnet: - return NetworkEnum.Testnet; + return Caip2ChainId.Testnet; default: throw new Error('Invalid network'); } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts new file mode 100644 index 00000000..db35d81e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts @@ -0,0 +1,16 @@ +import { DustLimit, ScriptType } from '../constants'; +import { isDust } from './policy'; + +describe('isDust', () => { + it('returns result', () => { + expect(isDust(DustLimit[ScriptType.P2wpkh] + 1, ScriptType.P2wpkh)).toBe( + false, + ); + expect( + isDust(BigInt(DustLimit[ScriptType.P2wpkh] + 1), ScriptType.P2wpkh), + ).toBe(false); + expect( + isDust(BigInt(DustLimit[ScriptType.P2wpkh] - 1), ScriptType.P2wpkh), + ).toBe(true); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts new file mode 100644 index 00000000..905ec2ec --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts @@ -0,0 +1,17 @@ +import type { ScriptType } from '../constants'; +import { DustLimit } from '../constants'; + +/** + * Determines if a given amount is considered dust based on the hardcoded dust limit for a given script type. + * + * @param amt - The amount to compare. + * @param scriptType - The script type for which to calculate the dust limit. + * @returns A boolean indicating whether the amount is considered dust. + */ +export function isDust(amt: bigint | number, scriptType: ScriptType): boolean { + // TODO: Calculate dust threshold by network fee rate, rather than hardcoding it. + if (typeof amt === 'number') { + return amt < DustLimit[scriptType]; + } + return amt < BigInt(DustLimit[scriptType]); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.test.ts deleted file mode 100644 index fcef376e..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { DustLimit, ScriptType } from '../constants'; -import { satsToBtc, btcToSats, isDust } from './unit'; - -describe('satsToBtc', () => { - it('returns Btc unit', () => { - const sats = 1234567899; - expect(satsToBtc(sats)).toBe('12.34567899'); - }); - - it('returns Btc unit with max Satoshis', () => { - const maxSats = 21 * Math.pow(10, 15); - - expect(satsToBtc(maxSats)).toBe('210000000.00000000'); - }); - - it('returns Btc unit with min Satoshis', () => { - const minSats = 1; - expect(satsToBtc(minSats)).toBe('0.00000001'); - }); - - it('throw an error if then given Satoshis in float', () => { - const sats = 1.1; - expect(() => satsToBtc(sats)).toThrow(Error); - }); -}); - -describe('btcToSats', () => { - it('returns Btc unit', () => { - const sats = 1234567899; - const btc = satsToBtc(sats); - expect(btcToSats(parseFloat(btc))).toBe('1234567899'); - }); - - it('returns Btc unit with max Satoshis', () => { - const maxSats = 21 * Math.pow(10, 15); - const btc = satsToBtc(maxSats); - expect(btcToSats(parseFloat(btc))).toBe('21000000000000000'); - }); - - it('returns Btc unit with min Satoshis', () => { - const minSats = 1; - const btc = satsToBtc(minSats); - expect(btcToSats(parseFloat(btc))).toBe('1'); - }); -}); - -describe('isDust', () => { - it('returns result', () => { - expect(isDust(DustLimit[ScriptType.P2wpkh] + 1, ScriptType.P2wpkh)).toBe( - false, - ); - expect(isDust(DustLimit[ScriptType.P2wpkh] - 1, ScriptType.P2wpkh)).toBe( - true, - ); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts deleted file mode 100644 index 301c08a3..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { ScriptType } from '../constants'; -import { DustLimit } from '../constants'; - -/** - * Converts a number of satoshis to a string representing the equivalent amount of BTC. - * - * @param sats - The number of satoshis to convert. - * @returns The equivalent amount of BTC as a string, fixed to 8 decimal places. - * @throws A TypeError if sats is not an integer. - */ -export function satsToBtc(sats: number): string { - if (!Number.isInteger(sats)) { - throw new TypeError('satsToBtc must be called on an integer number'); - } - return (sats / 1e8).toFixed(8); -} - -/** - * Converts a number of BTC to a string representing the equivalent amount of satoshis. - * - * @param btc - The amount of BTC to convert. - * @returns The equivalent amount of satoshis as a string, rounded to the nearest integer. - */ -export function btcToSats(btc: number): string { - return (btc * 1e8).toFixed(0); -} - -/** - * Determines if a given amount is considered dust based on the hardcoded dust limit for a given script type. - * - * @param amt - The amount to compare. - * @param scriptType - The script type for which to calculate the dust limit. - * @returns A boolean indicating whether the amount is considered dust. - */ -export function isDust(amt: number, scriptType: ScriptType): boolean { - // TODO: Calculate dust threshold by network fee rate, rather than hardcoding it. - return amt < DustLimit[scriptType]; -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts index 8c83a93d..09c33648 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts @@ -1,14 +1,35 @@ import type { Network, Payment } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; -import type { StaticImplements } from '../../types/static'; import { hexToBuffer } from '../../utils'; -import type { IAccountSigner } from '../../wallet'; +import type { IAccount, IAccountSigner } from '../../wallet'; import { ScriptType } from '../constants'; -import { getBtcPaymentInst } from '../utils/payment'; -import type { IBtcAccount, IStaticBtcAccount } from './types'; +import { getBtcPaymentInst } from '../utils'; +import type { StaticImplements } from './static'; + +export type IBtcAccount = IAccount & { + payment: Payment; + scriptType: ScriptType; + network: Network; + script: Buffer; +}; + +export type IStaticBtcAccount = { + path: string[]; + scriptType: ScriptType; + new ( + mfp: string, + index: number, + hdPath: string, + pubkey: string, + network: Network, + scriptType: ScriptType, + type: string, + signer: IAccountSigner, + ): IBtcAccount; +}; -export class BtcAccount implements IBtcAccount { +export abstract class BtcAccount implements IBtcAccount { #address: string; #payment: Payment; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts deleted file mode 100644 index b41d07c2..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BtcAddress } from './address'; - -describe('BtcAddress', () => { - const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; - - describe('toString', () => { - it('returns a address', async () => { - const val = new BtcAddress(address); - expect(val.toString()).toStrictEqual(address); - }); - - it('returns a shortern address', async () => { - const val = new BtcAddress(address); - expect(val.toString(true)).toBe('tb1qt...aeu'); - }); - }); - - describe('valueOf', () => { - it('returns a value', async () => { - const val = new BtcAddress(address); - expect(val.valueOf()).toStrictEqual(val.value); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts deleted file mode 100644 index 71bf7abf..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { replaceMiddleChar } from '../../utils'; -import type { IAddress } from '../../wallet'; - -export class BtcAddress implements IAddress { - value: string; - - constructor(address: string) { - this.value = address; - } - - valueOf(): string { - return this.value; - } - - toString(isShortern = false): string { - return isShortern ? replaceMiddleChar(this.value, 5, 3) : this.value; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts deleted file mode 100644 index 82985c15..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { satsToBtc } from '../utils'; -import { BtcAmount } from './amount'; - -describe('BtcAmount', () => { - describe('toString', () => { - it('returns a satsToBtc string with unit', async () => { - const val = new BtcAmount(1); - expect(val.toString(true)).toBe(`${satsToBtc(val.value)} BTC`); - }); - - it('returns a satsToBtc string without unit', async () => { - const val = new BtcAmount(1); - expect(val.toString(false)).toStrictEqual(satsToBtc(val.value)); - }); - }); - - describe('valueOf', () => { - it('returns a value', async () => { - const val = new BtcAmount(1); - expect(val.valueOf()).toStrictEqual(val.value); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts deleted file mode 100644 index 0b272924..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Chain, Config } from '../../config'; -import type { IAmount } from '../../wallet'; -import { satsToBtc } from '../utils'; - -export class BtcAmount implements IAmount { - value: number; - - constructor(value: number) { - this.value = value; - } - - get unit(): string { - return Config.unit[Chain.Bitcoin]; - } - - valueOf(): number { - return this.value; - } - - toString(withUnit = false): string { - if (!withUnit) { - return `${satsToBtc(this.value)}`; - } - return `${satsToBtc(this.value)} ${this.unit}`; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts index 5f009fa7..0e123f68 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts @@ -3,19 +3,16 @@ import { networks, address as addressUtils } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { ScriptType } from '../constants'; import { CoinSelectService } from './coin-select'; -import { BtcAccountBip32Deriver } from './deriver'; +import { BtcAccountDeriver } from './deriver'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; import { BtcWallet } from './wallet'; -jest.mock('../../libs/snap/helpers'); +jest.mock('../../utils/snap'); describe('CoinSelectService', () => { const createMockWallet = (network) => { - const instance = new BtcWallet( - new BtcAccountBip32Deriver(network), - network, - ); + const instance = new BtcWallet(new BtcAccountDeriver(network), network); return { instance, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index a6506849..2514e825 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -3,7 +3,13 @@ import coinSelect from 'coinselect'; import { TxValidationError } from './exceptions'; import type { TxInput } from './transaction-input'; import type { TxOutput } from './transaction-output'; -import { type SelectionResult } from './types'; + +export type SelectionResult = { + change?: TxOutput; + fee: number; + inputs: TxInput[]; + outputs: TxOutput[]; +}; export class CoinSelectService { protected readonly _feeRate: number; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts index b673d8b4..09970973 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts @@ -1,23 +1,18 @@ -import ecc from '@bitcoinerlab/secp256k1'; -import { - type BIP44AddressKeyDeriver, - type SLIP10NodeInterface, -} from '@metamask/key-tree'; +import { type SLIP10NodeInterface } from '@metamask/key-tree'; import { networks } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; -import ECPairFactory from 'ecpair'; -import { SnapHelper } from '../../libs/snap'; +import * as snapUtils from '../../utils/snap'; import * as strUtils from '../../utils/string'; import { P2WPKHAccount } from './account'; -import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; +import { BtcAccountDeriver } from './deriver'; -jest.mock('../../libs/snap/helpers'); +jest.mock('../../utils/snap'); -describe('BtcAccountBip32Deriver', () => { +describe('BtcAccountDeriver', () => { const prepareBip32Deriver = async (network) => { - const deriver = new BtcAccountBip32Deriver(network); - const bip32Deriver = await SnapHelper.getBip32Deriver( + const deriver = new BtcAccountDeriver(network); + const bip32Deriver = await snapUtils.getBip32Deriver( P2WPKHAccount.path, deriver.curve, ); @@ -32,37 +27,6 @@ describe('BtcAccountBip32Deriver', () => { }; }; - describe('createBip32FromSeed', () => { - it('returns an BIP32Interface', async () => { - const network = networks.testnet; - const { deriver, pkBuffer } = await prepareBip32Deriver(network); - - const result = deriver.createBip32FromSeed(pkBuffer); - - expect(result.chainCode).toBeDefined(); - expect(result.chainCode).not.toBeNull(); - expect(result.privateKey).toBeDefined(); - expect(result.privateKey).not.toBeNull(); - expect(result.publicKey).toBeDefined(); - expect(result.publicKey).not.toBeNull(); - expect(result.depth).toBeDefined(); - expect(result.depth).not.toBeNull(); - expect(result.index).toBeDefined(); - expect(result.index).not.toBeNull(); - }); - - it('throws `Unable to construct BIP32 node from seed` if an error catched', () => { - const network = networks.testnet; - const seed = Buffer.from('', 'hex'); - - const deriver = new BtcAccountBip32Deriver(network); - - expect(() => deriver.createBip32FromSeed(seed)).toThrow( - 'Unable to construct BIP32 node from seed', - ); - }); - }); - describe('createBip32FromPrivateKey', () => { it('returns an BIP32Interface', async () => { const network = networks.testnet; @@ -86,7 +50,7 @@ describe('BtcAccountBip32Deriver', () => { it('throws `Unable to construct BIP32 node from private key` if an error catched', async () => { const network = networks.testnet; - const deriver = new BtcAccountBip32Deriver(network); + const deriver = new BtcAccountDeriver(network); const pkBuffer = Buffer.from(''); const ccBuffer = Buffer.from(''); @@ -165,10 +129,10 @@ describe('BtcAccountBip32Deriver', () => { it('throws DeriverError if private key is missing', async () => { const network = networks.testnet; - const deriver = new BtcAccountBip32Deriver(network); + const deriver = new BtcAccountDeriver(network); jest - .spyOn(SnapHelper, 'getBip32Deriver') + .spyOn(snapUtils, 'getBip32Deriver') .mockResolvedValue({} as unknown as SLIP10NodeInterface); await expect(deriver.getRoot(P2WPKHAccount.path)).rejects.toThrow( @@ -177,53 +141,3 @@ describe('BtcAccountBip32Deriver', () => { }); }); }); - -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 } = P2WPKHAccount; - const ecpair = ECPairFactory(ecc); - const privateKey = ecpair.makeRandom().privateKey?.toString('hex'); - const { getBip44DeriverSpy, deriverSpy } = createMockBip44Entropy(); - - deriverSpy.mockResolvedValue({ - privateKey, - }); - - const deriver = new BtcAccountBip44Deriver(network); - await deriver.getRoot(path); - - expect(getBip44DeriverSpy).toHaveBeenCalledWith(0); - }); - - it('throws `Deriver private key is missing` error if the private key is missing', async () => { - const network = networks.testnet; - const { path } = P2WPKHAccount; - const { deriverSpy } = createMockBip44Entropy(); - deriverSpy.mockResolvedValue({ - privateKey: undefined, - }); - - const deriver = new BtcAccountBip44Deriver(network); - - await expect(deriver.getRoot(path)).rejects.toThrow( - 'Deriver private key is missing', - ); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts index bd245801..1e5507e5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts @@ -4,28 +4,48 @@ import { BIP32Factory } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; -import { SnapHelper } from '../../libs/snap/helpers'; -import { compactError, hexToBuffer } from '../../utils'; +import { compactError, hexToBuffer, getBip32Deriver } from '../../utils'; import { DeriverError } from './exceptions'; -import type { IBtcAccountDeriver } from './types'; -export abstract class BtcAccountDeriver implements IBtcAccountDeriver { +export class BtcAccountDeriver { protected readonly _network: Network; protected readonly _bip32Api: BIP32API; + readonly curve: 'secp256k1' | 'ed25519' = 'secp256k1'; + constructor(network: Network) { this._bip32Api = BIP32Factory(ecc); this._network = network; } - abstract getRoot(path: string[]): Promise; - - createBip32FromSeed(seed: Buffer): BIP32Interface { + async getRoot(path: string[]) { try { - return this._bip32Api.fromSeed(seed, this._network); + const deriver = await getBip32Deriver(path, this.curve); + + if (!deriver.privateKey) { + throw new DeriverError('Deriver private key is missing'); + } + + const privateKeyBuffer = this.pkToBuf(deriver.privateKey); + const chainCodeBuffer = this.chainCodeToBuf(deriver.chainCode); + + const root = this.createBip32FromPrivateKey( + 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) { - throw new DeriverError('Unable to construct BIP32 node from seed'); + throw compactError(error, DeriverError); } } @@ -64,56 +84,3 @@ export abstract class BtcAccountDeriver implements IBtcAccountDeriver { } } } - -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 = this.pkToBuf(deriverNode.privateKey); - const root = this.createBip32FromSeed(privateKeyBuffer); - return root - .deriveHardened(parseInt(path[1].slice(0, -1), 10)) - .deriveHardened(0); - } catch (error) { - throw compactError(error, DeriverError); - } - } -} - -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 = this.pkToBuf(deriver.privateKey); - const chainCodeBuffer = this.chainCodeToBuf(deriver.chainCode); - - const root = this.createBip32FromPrivateKey( - 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) { - throw compactError(error, DeriverError); - } - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts index 8dcb3463..e8a19270 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts @@ -1,4 +1,4 @@ -import { CustomError } from '../../libs/exception'; +import { CustomError } from '../../utils'; export class DeriverError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts deleted file mode 100644 index 43f76ce9..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { Network } from 'bitcoinjs-lib'; -import { networks } from 'bitcoinjs-lib'; - -import { ScriptType } from '../constants'; -import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; -import { BtcWalletFactory } from './factory'; -import type { IBtcAccountDeriver } from './types'; -import * as manager from './wallet'; - -describe('BtcWalletFactory', () => { - class MockBtcWallet extends manager.BtcWallet { - getDeriver() { - return this._deriver; - } - } - - const createMockBtcWallet = () => { - const spy = jest - .spyOn(manager, 'BtcWallet') - .mockImplementation((deriver: IBtcAccountDeriver, network: Network) => { - return new MockBtcWallet(deriver, network); - }); - return { - spy, - }; - }; - - describe('create', () => { - it('creates BtcWallet instance with `BtcAccountBip32Deriver`', () => { - const { spy } = createMockBtcWallet(); - - const instance = BtcWalletFactory.create( - { - defaultAccountIndex: 0, - defaultAccountType: ScriptType.P2wpkh, - deriver: 'BIP32', - enableMultiAccounts: false, - }, - networks.testnet, - ) as unknown as MockBtcWallet; - - expect(spy).toHaveBeenCalled(); - expect(instance.getDeriver()).toBeInstanceOf(BtcAccountBip32Deriver); - }); - - it('creates BtcWallet instance with `BtcAccountBip44Deriver`', () => { - const { spy } = createMockBtcWallet(); - - const instance = BtcWalletFactory.create( - { - defaultAccountIndex: 0, - defaultAccountType: ScriptType.P2wpkh, - deriver: 'BIP44', - enableMultiAccounts: false, - }, - networks.testnet, - ) as unknown as MockBtcWallet; - - expect(spy).toHaveBeenCalled(); - expect(instance.getDeriver()).toBeInstanceOf(BtcAccountBip44Deriver); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.ts deleted file mode 100644 index e3c7992d..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { type Network } from 'bitcoinjs-lib'; - -import type { IWallet } from '../../wallet'; -import { type BtcWalletConfig } from '../config'; -import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; -import { BtcWallet } from './wallet'; - -export class BtcWalletFactory { - static create(config: BtcWalletConfig, network: Network): IWallet { - return new BtcWallet( - config.deriver === 'BIP44' - ? new BtcAccountBip44Deriver(network) - : new BtcAccountBip32Deriver(network), - network, - ); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts index c025f6ea..22617161 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts @@ -1,10 +1,10 @@ export * from './exceptions'; export * from './account'; -export * from './factory'; -export * from './types'; export * from './signer'; export * from './deriver'; export * from './wallet'; -export * from './address'; export * from './transaction-info'; -export * from './amount'; +export * from './coin-select'; +export * from './psbt'; +export * from './transaction-input'; +export * from './transaction-output'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts index 17444791..3ecf5539 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts @@ -3,22 +3,19 @@ import { Psbt, Transaction, networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { hexToBuffer } from '../../utils'; import { MaxStandardTxWeight, ScriptType } from '../constants'; -import { BtcAccountBip32Deriver } from './deriver'; +import { BtcAccountDeriver } from './deriver'; import { PsbtServiceError } from './exceptions'; import { PsbtService } from './psbt'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; import { BtcWallet } from './wallet'; -jest.mock('../../libs/logger/logger'); -jest.mock('../../libs/snap/helpers'); +jest.mock('../../utils/logger'); +jest.mock('../../utils/snap'); describe('PsbtService', () => { const createMockWallet = (network) => { - const instance = new BtcWallet( - new BtcAccountBip32Deriver(network), - network, - ); + const instance = new BtcWallet(new BtcAccountDeriver(network), network); return { instance, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index f4638e39..eb92a8b2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -4,8 +4,7 @@ import { Psbt, Transaction } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; import ECPairFactory from 'ecpair'; -import { logger } from '../../libs/logger/logger'; -import { compactError } from '../../utils'; +import { compactError, logger } from '../../utils'; import type { IAccountSigner } from '../../wallet'; import { MaxStandardTxWeight } from '../constants'; import { PsbtServiceError, TxValidationError } from './exceptions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/types/static.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/static.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/types/static.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/static.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts index c1b15f32..01a21469 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts @@ -1,27 +1,22 @@ -import { networks } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; import { generateAccounts } from '../../../test/utils'; -import { getCaip2ChainId, getExplorerUrl, satsToBtc } from '../utils'; -import { BtcAddress } from './address'; -import { BtcAmount } from './amount'; import { BtcTxInfo } from './transaction-info'; import { TxOutput } from './transaction-output'; describe('BtcTxInfo', () => { describe('toJson', () => { it('returns a json', async () => { - const network = networks.testnet; const accounts = generateAccounts(5); const addresses = accounts.map((account) => account.address); const fee = 10000; const feeRate = 100; let total = fee; const outputs: TxOutput[] = []; - const sender = new BtcAddress(addresses[0]); + const sender = addresses[0]; - const info = new BtcTxInfo(sender, feeRate, network); - info.fee = fee; + const info = new BtcTxInfo(sender, feeRate); + info.txFee = fee; for (let i = 1; i < addresses.length; i++) { total += 100000; @@ -29,52 +24,25 @@ describe('BtcTxInfo', () => { outputs.push(output); } - info.addRecipients(outputs); + const change = new TxOutput(500, addresses[0], Buffer.from('dummy')); - info.change = new TxOutput(500, addresses[0], Buffer.from('dummy')); + info.addRecipients(outputs); + info.addChange(change); total += 500; const expectedRecipients = outputs.map((recipient) => { return { - address: recipient.destination.toString(true), - value: recipient.amount.toString(true), - explorerUrl: getExplorerUrl( - recipient.destination.value, - getCaip2ChainId(network), - ), + address: recipient.address, + value: recipient.bigIntValue, }; }); - const expectedChange = [ - { - address: info.change.destination.toString(true), - value: info.change.amount.toString(true), - explorerUrl: getExplorerUrl( - info.change.destination.value, - getCaip2ChainId(network), - ), - }, - ]; - - expect(info.total).toBeInstanceOf(BtcAmount); - expect(info.total.value).toStrictEqual(total); + expect(info.total).toBe(BigInt(total)); expect(info.sender).toStrictEqual(sender); expect(info.recipients).toHaveLength(expectedRecipients.length); expect(info.change).toBeDefined(); - expect(info.fee).toStrictEqual(fee); - expect(info.txFee).toBeInstanceOf(BtcAmount); - expect(info.txFee.value).toStrictEqual(fee); - expect(info.feeRate).toBeInstanceOf(BtcAmount); - expect(info.feeRate.value).toStrictEqual(feeRate); - - expect(info.toJson()).toStrictEqual({ - feeRate: `${satsToBtc(feeRate)} BTC`, - txFee: `${satsToBtc(fee)} BTC`, - sender: addresses[0], - recipients: expectedRecipients, - changes: expectedChange, - total: `${satsToBtc(total)} BTC`, - }); + expect(info.txFee).toBe(BigInt(fee)); + expect(info.feeRate).toBe(BigInt(feeRate)); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts index b1a7d91f..e24ef6fd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts @@ -1,52 +1,26 @@ -import type { Json } from '@metamask/snaps-sdk'; -import type { Network } from 'bitcoinjs-lib'; - -import type { ITxInfo } from '../../wallet'; -import { getCaip2ChainId, getExplorerUrl } from '../utils'; -import type { BtcAddress } from './address'; -import { BtcAmount } from './amount'; +import type { ITxInfo, Recipient } from '../../wallet'; import type { TxOutput } from './transaction-output'; export class BtcTxInfo implements ITxInfo { - readonly sender: BtcAddress; - - readonly txFee: BtcAmount; - - change?: TxOutput; + readonly sender: string; - protected _recipients: TxOutput[]; + protected _change?: Recipient; - protected _feeRate: BtcAmount; + protected _recipients: Recipient[]; - protected _outputTotal: BtcAmount; + protected _outputTotal: bigint; - protected _serializedRecipients: Json[]; + protected _txFee: bigint; - protected _network: Network; + protected _feeRate: bigint; - constructor(sender: BtcAddress, feeRate: number, network: Network) { - this._recipients = []; - this._serializedRecipients = []; - this._outputTotal = new BtcAmount(0); - this._feeRate = new BtcAmount(feeRate); - this.txFee = new BtcAmount(0); - this._network = network; + constructor(sender: string, feeRate: number) { + this.feeRate = feeRate; + this.txFee = 0; this.sender = sender; - } - protected changeToJson(): Json { - return this.change - ? [ - { - address: this.change.destination.toString(true), - value: this.change.amount.toString(true), - explorerUrl: getExplorerUrl( - this.change.destination.value, - getCaip2ChainId(this._network), - ), - }, - ] - : []; + this._recipients = []; + this._outputTotal = BigInt(0); } addRecipients(outputs: TxOutput[]): void { @@ -56,52 +30,58 @@ export class BtcTxInfo implements ITxInfo { } addRecipient(output: TxOutput): void { - this._outputTotal.value += output.value; - - this._recipients.push(output); + this._outputTotal += output.bigIntValue; - this._serializedRecipients.push({ - address: output.destination.toString(true), - value: output.amount.toString(true), - explorerUrl: getExplorerUrl( - output.destination.value, - getCaip2ChainId(this._network), - ), + this._recipients.push({ + address: output.address, + value: output.bigIntValue, }); } - get total(): BtcAmount { - return new BtcAmount( - this._outputTotal.value + - (this.change ? this.change.value : 0) + - this.txFee.value, - ); + addChange(change: TxOutput): void { + this._change = { + address: change.address, + value: change.bigIntValue, + }; } - get feeRate(): BtcAmount { - return this._feeRate; + set txFee(fee: bigint | number) { + if (typeof fee === 'number') { + this._txFee = BigInt(fee); + } else { + this._txFee = fee; + } } - get recipients(): TxOutput[] { - return this._recipients; + get txFee(): bigint { + return this._txFee; } - get fee(): number { - return this.txFee.value; + set feeRate(fee: bigint | number) { + if (typeof fee === 'number') { + this._feeRate = BigInt(fee); + } else { + this._feeRate = fee; + } + } + + get feeRate(): bigint { + return this._feeRate; } - set fee(val: number) { - this.txFee.value = val; + get total(): bigint { + return ( + this._outputTotal + + (this.change ? BigInt(this.change.value) : BigInt(0)) + + this.txFee + ); + } + + get recipients(): Recipient[] { + return this._recipients; } - toJson>(): InfoJson { - return { - feeRate: this._feeRate.toString(true), - txFee: this.txFee.toString(true), - sender: this.sender.toString(), - recipients: this._serializedRecipients, - changes: this.changeToJson(), - total: this.total.toString(true), - } as unknown as InfoJson; + get change(): Recipient | undefined { + return this._change; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts index 1f0877b6..aac055c3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts @@ -2,18 +2,15 @@ import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { ScriptType } from '../constants'; -import { BtcAccountBip32Deriver } from './deriver'; +import { BtcAccountDeriver } from './deriver'; import { TxInput } from './transaction-input'; import { BtcWallet } from './wallet'; -jest.mock('../../libs/snap/helpers'); +jest.mock('../../utils/snap'); describe('TxInput', () => { const createMockWallet = (network) => { - const instance = new BtcWallet( - new BtcAccountBip32Deriver(network), - network, - ); + const instance = new BtcWallet(new BtcAccountDeriver(network), network); return { instance, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts index 504fd251..06134591 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts @@ -1,15 +1,13 @@ import type { Buffer } from 'buffer'; -import type { IAmount } from '../../wallet'; -import { BtcAmount } from './amount'; -import type { Utxo } from './types'; +import type { Utxo } from '../../chain'; export class TxInput { + protected _value: bigint; + // consume by coinselect readonly script: Buffer; - readonly amount: IAmount; - readonly txHash: string; readonly index: number; @@ -18,7 +16,7 @@ export class TxInput { constructor(utxo: Utxo, script: Buffer) { this.script = script; - this.amount = new BtcAmount(utxo.value); + this.value = utxo.value; this.index = utxo.index; this.txHash = utxo.txHash; this.block = utxo.block; @@ -26,6 +24,18 @@ export class TxInput { // consume by coinselect get value(): number { - return this.amount.value; + return Number(this._value); + } + + set value(value: bigint | number) { + if (typeof value === 'number') { + this._value = BigInt(value); + } else { + this._value = value; + } + } + + get bigIntValue(): bigint { + return this._value; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts index ea20d13f..262f3d2e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts @@ -13,4 +13,22 @@ describe('TxOutput', () => { expect(input.address).toStrictEqual(account.address); expect(input.value).toBe(10); }); + + it('sets output value with a number', () => { + const account = generateAccounts(1)[0]; + + const input = new TxOutput(10, account.address, Buffer.from('dummy')); + input.value = 20; + + expect(input.value).toBe(20); + }); + + it('sets output value with a bigint', () => { + const account = generateAccounts(1)[0]; + + const input = new TxOutput(10, account.address, Buffer.from('dummy')); + input.value = BigInt(20); + + expect(input.value).toBe(20); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts index 298a7fca..b6758cec 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts @@ -1,33 +1,33 @@ import type { Buffer } from 'buffer'; -import type { IAddress, IAmount } from '../../wallet'; -import { BtcAddress } from './address'; -import { BtcAmount } from './amount'; - export class TxOutput { - readonly amount: IAmount; + protected _value: bigint; // consume by conselect readonly script: Buffer; - readonly destination: IAddress; + readonly address: string; - constructor(value: number, address: string, script: Buffer) { - this.amount = new BtcAmount(value); - this.destination = new BtcAddress(address); + constructor(value: bigint | number, address: string, script: Buffer) { + this.value = value; + this.address = address; this.script = script; } // consume by conselect get value(): number { - return this.amount.value; + return Number(this._value); } - set value(value: number) { - this.amount.value = value; + set value(value: bigint | number) { + if (typeof value === 'number') { + this._value = BigInt(value); + } else { + this._value = value; + } } - get address(): string { - return this.destination.value; + get bigIntValue(): bigint { + return this._value; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts deleted file mode 100644 index 4db8fff1..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { BIP32Interface } from 'bip32'; -import type { Network, Payment } from 'bitcoinjs-lib'; -import type { Buffer } from 'buffer'; - -import type { IAccount, IAccountSigner } from '../../wallet'; -import type { ScriptType } from '../constants'; -import type { TxInput } from './transaction-input'; -import type { TxOutput } from './transaction-output'; - -export type IBtcAccountDeriver = { - getRoot(path: string[]): Promise; - getChild(root: BIP32Interface, idx: number): Promise; -}; - -export type IBtcAccount = IAccount & { - payment: Payment; - scriptType: ScriptType; - network: Network; - script: Buffer; -}; - -export type IStaticBtcAccount = { - path: string[]; - scriptType: ScriptType; - new ( - mfp: string, - index: number, - hdPath: string, - pubkey: string, - network: Network, - scriptType: ScriptType, - type: string, - signer: IAccountSigner, - ): IBtcAccount; -}; - -export type CreateTransactionOptions = { - utxos: Utxo[]; - fee: number; - subtractFeeFrom: string[]; - // - // BIP125 opt-in RBF flag, - // - replaceable: boolean; -}; - -export type Utxo = { - block: number; - txHash: string; - index: number; - value: number; -}; - -export type SelectionResult = { - change?: TxOutput; - fee: number; - inputs: TxInput[]; - outputs: TxOutput[]; -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index 8303ad3b..269fd8f6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -1,28 +1,26 @@ -import type { Json } from '@metamask/snaps-sdk'; import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; -import { DustLimit, ScriptType } from '../constants'; +import { DustLimit, ScriptType, maxSatoshi } from '../constants'; import { P2SHP2WPKHAccount, P2WPKHAccount } from './account'; import { CoinSelectService } from './coin-select'; -import { BtcAccountBip32Deriver } from './deriver'; +import { BtcAccountDeriver } from './deriver'; import { WalletError } from './exceptions'; import { BtcTxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; -import type { SelectionResult } from './types'; import { BtcWallet } from './wallet'; -jest.mock('../../libs/snap/helpers'); -jest.mock('../../libs/logger/logger'); +jest.mock('../../utils/snap'); +jest.mock('../../utils/logger'); describe('BtcWallet', () => { const createMockDeriver = (network) => { - const rootSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getRoot'); - const childSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getChild'); + const rootSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getRoot'); + const childSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getChild'); return { - instance: new BtcAccountBip32Deriver(network), + instance: new BtcAccountDeriver(network), rootSpy, childSpy, }; @@ -43,7 +41,7 @@ describe('BtcWallet', () => { return [ { address, - value: amount, + value: BigInt(amount), }, ]; }; @@ -102,11 +100,17 @@ describe('BtcWallet', () => { const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(account.address, 200, 100000, 100000); + + const utxos = generateFormatedUtxos( + account.address, + 200, + maxSatoshi, + maxSatoshi, + ); const result = await wallet.createTransaction( account, - createMockTxIndent(account.address, 132000), + createMockTxIndent(account.address, maxSatoshi), { utxos, fee: 56, @@ -115,12 +119,12 @@ describe('BtcWallet', () => { }, ); - const json = result.txInfo.toJson(); - const recipients = json.recipients as unknown as Json[]; - const changes = json.changes as unknown as Json[]; + const info = result.txInfo; + const { recipients } = info; + const { change } = info; expect(recipients).toHaveLength(1); - expect(changes).toHaveLength(1); + expect(change).toBeDefined(); expect(result).toStrictEqual({ tx: expect.any(String), txInfo: expect.any(BtcTxInfo), @@ -145,12 +149,12 @@ describe('BtcWallet', () => { }, ); - const json = result.txInfo.toJson(); - const recipients = json.recipients as unknown as Json[]; - const changes = json.changes as unknown as Json[]; + const info = result.txInfo; + const { recipients } = info; + const { change } = info; expect(recipients).toHaveLength(1); - expect(changes).toHaveLength(0); + expect(change).toBeUndefined(); expect(result).toStrictEqual({ tx: expect.any(String), txInfo: expect.any(BtcTxInfo), @@ -206,7 +210,7 @@ describe('BtcWallet', () => { 'selectCoins', ); - const selectionResult: SelectionResult = { + const selectionResult = { change: new TxOutput( DustLimit[chgAccount.scriptType] - 1, chgAccount.address, @@ -232,7 +236,7 @@ describe('BtcWallet', () => { const info: BtcTxInfo = result.txInfo as unknown as BtcTxInfo; - expect(info.fee).toBe(19500); + expect(info.txFee).toBe(BigInt(19500)); expect(info.change).toBeUndefined(); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index a2647208..0dadb1c0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -1,8 +1,8 @@ import type { BIP32Interface } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; -import { logger } from '../../libs/logger/logger'; -import { bufferToString, compactError, hexToBuffer } from '../../utils'; +import type { Utxo } from '../../chain'; +import { bufferToString, compactError, hexToBuffer, logger } from '../../utils'; import type { IAccountSigner, IWallet, @@ -10,37 +10,45 @@ import type { Transaction, } from '../../wallet'; import { ScriptType } from '../constants'; -import { isDust } from '../utils'; -import { getScriptForDestnation } from '../utils/address'; -import { P2WPKHAccount, P2SHP2WPKHAccount } from './account'; -import { BtcAddress } from './address'; +import { isDust, getScriptForDestnation } from '../utils'; +import { + P2WPKHAccount, + P2SHP2WPKHAccount, + type IStaticBtcAccount, + type IBtcAccount, +} from './account'; import { CoinSelectService } from './coin-select'; +import type { BtcAccountDeriver } from './deriver'; import { WalletError, TxValidationError } from './exceptions'; import { PsbtService } from './psbt'; import { AccountSigner } from './signer'; import { BtcTxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; -import type { - IStaticBtcAccount, - IBtcAccountDeriver, - IBtcAccount, - CreateTransactionOptions, -} from './types'; + +export type CreateTransactionOptions = { + utxos: Utxo[]; + fee: number; + subtractFeeFrom: string[]; + // + // BIP125 opt-in RBF flag, + // + replaceable: boolean; +}; export class BtcWallet implements IWallet { - protected readonly _deriver: IBtcAccountDeriver; + protected readonly _deriver: BtcAccountDeriver; protected readonly _network: Network; - constructor(deriver: IBtcAccountDeriver, network: Network) { + constructor(deriver: BtcAccountDeriver, network: Network) { this._deriver = deriver; this._network = network; } - async unlock(index: number, type: string): Promise { + async unlock(index: number, type?: string): Promise { try { - const AccountCtor = this.getAccountCtor(type); + const AccountCtor = this.getAccountCtor(type ?? ScriptType.P2wpkh); const rootNode = await this._deriver.getRoot(AccountCtor.path); const childNode = await this._deriver.getChild(rootNode, index); const hdPath = [`m`, `0'`, `0`, `${index}`].join('/'); @@ -68,17 +76,6 @@ export class BtcWallet implements IWallet { const scriptOutput = account.script; const { scriptType } = account; - logger.info( - JSON.stringify( - { - recipients, - options, - }, - null, - 2, - ), - ); - // TODO: Supporting getting coins from other address (dynamic address) const inputs = options.utxos.map((utxo) => new TxInput(utxo, scriptOutput)); const outputs = recipients.map((recipient) => { @@ -107,23 +104,6 @@ export class BtcWallet implements IWallet { change, ); - logger.info( - JSON.stringify( - { - feeRate, - ...selectionResult, - }, - null, - 2, - ), - ); - - const txInfo = new BtcTxInfo( - new BtcAddress(account.address), - feeRate, - this._network, - ); - const psbtService = new PsbtService(this._network); psbtService.addInputs( selectionResult.inputs, @@ -133,6 +113,8 @@ export class BtcWallet implements IWallet { hexToBuffer(account.mfp, false), ); + const txInfo = new BtcTxInfo(account.address, feeRate); + // TODO: add support of subtractFeeFrom, and throw error if output is too small after subtraction for (const output of selectionResult.outputs) { psbtService.addOutput(output); @@ -146,13 +128,13 @@ export class BtcWallet implements IWallet { ); } else { psbtService.addOutput(selectionResult.change); - txInfo.change = selectionResult.change; + txInfo.addChange(selectionResult.change); } } // Sign dummy transaction to extract the fee which is more accurate const signedService = await psbtService.signDummy(account.signer); - txInfo.fee = signedService.getFee(); + txInfo.txFee = signedService.getFee(); return { tx: psbtService.toBase64(), diff --git a/merged-packages/bitcoin-wallet-snap/src/chain.ts b/merged-packages/bitcoin-wallet-snap/src/chain.ts index 45f640ff..a0a57418 100644 --- a/merged-packages/bitcoin-wallet-snap/src/chain.ts +++ b/merged-packages/bitcoin-wallet-snap/src/chain.ts @@ -1,7 +1,3 @@ -import type { Json } from '@metamask/snaps-sdk'; - -import type { IAmount } from './wallet'; - export enum FeeRatio { Fast = 'fast', Medium = 'medium', @@ -18,10 +14,8 @@ export type TransactionStatusData = { status: TransactionStatus; }; -export type Balances = Record; - export type Balance = { - amount: IAmount; + amount: bigint; }; export type AssetBalances = { @@ -34,7 +28,7 @@ export type AssetBalances = { export type Fee = { type: FeeRatio; - rate: IAmount; + rate: bigint; }; export type Fees = { @@ -48,13 +42,22 @@ export type TransactionIntent = { }; export type TransactionData = { - data: Record; + data: { + utxos: Utxo[]; + }; }; export type CommitedTransaction = { transactionId: string; }; +export type Utxo = { + block: number; + txHash: string; + index: number; + value: number; +}; + /** * An interface that defines methods for interacting with a blockchain network. */ diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts new file mode 100644 index 00000000..3f17567d --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -0,0 +1,49 @@ +import type { Json } from '@metamask/snaps-sdk'; + +import { Caip2ChainId, Caip2Asset } from './constants'; + +export type SnapConfig = { + onChainService: { + dataClient: { + options?: Record; + }; + }; + wallet: { + defaultAccountIndex: number; + defaultAccountType: string; + }; + avaliableNetworks: string[]; + avaliableAssets: string[]; + unit: string; + explorer: { + [network in string]: string; + }; + logLevel: string; +}; + +export const Config: SnapConfig = { + onChainService: { + dataClient: { + options: { + // eslint-disable-next-line no-restricted-globals + apiKey: process.env.BLOCKCHAIR_API_KEY, + }, + }, + }, + wallet: { + defaultAccountIndex: 0, + defaultAccountType: 'bip122:p2wpkh', + }, + avaliableNetworks: Object.values(Caip2ChainId), + avaliableAssets: Object.values(Caip2Asset), + unit: 'BTC', + explorer: { + // eslint-disable-next-line no-template-curly-in-string + [Caip2ChainId.Mainnet]: 'https://blockstream.info/address/${address}', + [Caip2ChainId.Testnet]: + // eslint-disable-next-line no-template-curly-in-string + 'https://blockstream.info/testnet/address/${address}', + }, + // eslint-disable-next-line no-restricted-globals + logLevel: process.env.LOG_LEVEL ?? '6', +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/config/config.ts b/merged-packages/bitcoin-wallet-snap/src/config/config.ts deleted file mode 100644 index ca1b99fd..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/config/config.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { - BtcWalletConfig, - BtcOnChainServiceConfig, -} from '../bitcoin/config'; -import { - Network as BtcNetwork, - DataClient, - BtcAsset, - BtcUnit, -} from '../bitcoin/constants'; - -export enum Chain { - Bitcoin = 'Bitcoin', -} - -export type NetworkConfig = { - [key in string]: string; -}; - -export type OnChainServiceConfig = { - [Chain.Bitcoin]: BtcOnChainServiceConfig; -}; - -export type WalletConfig = { - [Chain.Bitcoin]: BtcWalletConfig; -}; - -export type SnapConfig = { - onChainService: OnChainServiceConfig; - wallet: WalletConfig; - avaliableNetworks: { - [key in Chain]: string[]; - }; - avaliableAssets: { - [key in Chain]: string[]; - }; - unit: { - [key in Chain]: string; - }; - explorer: { - [key in Chain]: { - [network in string]: string; - }; - }; - chain: Chain; - logLevel: string; -}; - -export const Config: SnapConfig = { - onChainService: { - [Chain.Bitcoin]: { - dataClient: { - read: { - type: - // eslint-disable-next-line no-restricted-globals - (process.env.DATA_CLIENT_READ_TYPE as DataClient) ?? - DataClient.BlockChair, - options: { - // eslint-disable-next-line no-restricted-globals - apiKey: process.env.BLOCKCHAIR_API_KEY, - }, - }, - - write: { - type: - // eslint-disable-next-line no-restricted-globals - (process.env.DATA_CLIENT_WRITE_TYPE as DataClient) ?? - DataClient.BlockChair, - options: { - // eslint-disable-next-line no-restricted-globals - apiKey: process.env.BLOCKCHAIR_API_KEY, - }, - }, - }, - }, - }, - wallet: { - [Chain.Bitcoin]: { - enableMultiAccounts: false, - defaultAccountIndex: 0, - defaultAccountType: 'bip122:p2wpkh', - deriver: 'BIP32', - }, - }, - avaliableNetworks: { - [Chain.Bitcoin]: Object.values(BtcNetwork), - }, - avaliableAssets: { - [Chain.Bitcoin]: Object.values(BtcAsset), - }, - unit: { - [Chain.Bitcoin]: BtcUnit.Btc, - }, - explorer: { - [Chain.Bitcoin]: { - [BtcNetwork.Mainnet]: 'https://blockstream.info', - [BtcNetwork.Testnet]: 'https://blockstream.info/testnet', - }, - }, - chain: Chain.Bitcoin, - // eslint-disable-next-line no-restricted-globals - logLevel: process.env.LOG_LEVEL ?? '6', -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/config/index.ts b/merged-packages/bitcoin-wallet-snap/src/config/index.ts deleted file mode 100644 index ae8d958f..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/config/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './config'; -export * from './permissions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/constants.ts b/merged-packages/bitcoin-wallet-snap/src/constants.ts new file mode 100644 index 00000000..308df1e7 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/constants.ts @@ -0,0 +1,9 @@ +export enum Caip2ChainId { + Mainnet = 'bip122:000000000019d6689c085ae165831e93', + Testnet = 'bip122:000000000933ea01ad0ee984209779ba', +} + +export enum Caip2Asset { + Btc = 'bip122:000000000019d6689c085ae165831e93/slip44:0', + TBtc = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0', +} diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/factory.test.ts index c9835078..c714732a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.test.ts @@ -1,13 +1,14 @@ import { BtcOnChainService } from './bitcoin/chain'; -import { Network } from './bitcoin/constants'; import { BtcWallet } from './bitcoin/wallet'; +import { Caip2ChainId } from './constants'; import { Factory } from './factory'; -import { BtcKeyring } from './keyring'; describe('Factory', () => { describe('createOnChainServiceProvider', () => { it('creates BtcOnChainService instance', () => { - const instance = Factory.createOnChainServiceProvider(Network.Testnet); + const instance = Factory.createOnChainServiceProvider( + Caip2ChainId.Testnet, + ); expect(instance).toBeInstanceOf(BtcOnChainService); }); @@ -15,17 +16,9 @@ describe('Factory', () => { describe('createWallet', () => { it('creates BtcWallet instance', () => { - const instance = Factory.createWallet(Network.Testnet); + const instance = Factory.createWallet(Caip2ChainId.Testnet); expect(instance).toBeInstanceOf(BtcWallet); }); }); - - describe('createKeyring', () => { - it('creates BtcKeyring instance', () => { - const instance = Factory.createKeyring(); - - expect(instance).toBeInstanceOf(BtcKeyring); - }); - }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts index ad536089..efc3087a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.ts @@ -1,67 +1,27 @@ -import { type Keyring } from '@metamask/keyring-api'; - import { BtcOnChainService } from './bitcoin/chain'; -import { - type BtcWalletConfig, - type BtcOnChainServiceConfig, -} from './bitcoin/config'; -import { DataClientFactory } from './bitcoin/data-client/factory'; +import { BlockChairClient } from './bitcoin/chain/clients/blockchair'; import { getBtcNetwork } from './bitcoin/utils'; -import { BtcWalletFactory } from './bitcoin/wallet'; +import { BtcAccountDeriver, BtcWallet } from './bitcoin/wallet'; import type { IOnChainService } from './chain'; import { Config } from './config'; -import { BtcKeyring, KeyringStateManager } from './keyring'; import type { IWallet } from './wallet'; -// TODO: Remove temp solution to support keyring in snap without keyring API -export type CreateBtcKeyringOptions = { - emitEvents: boolean; -}; - export class Factory { - static createBtcOnChainServiceProvider( - config: BtcOnChainServiceConfig, - network: string, - ) { - const btcNetwork = getBtcNetwork(network); - const readClient = DataClientFactory.createReadClient(config, btcNetwork); - const writeClient = DataClientFactory.createWriteClient(config, btcNetwork); - return new BtcOnChainService(readClient, writeClient, { + static createOnChainServiceProvider(scope: string): IOnChainService { + const btcNetwork = getBtcNetwork(scope); + + const client = new BlockChairClient({ network: btcNetwork, + apiKey: Config.onChainService.dataClient.options?.apiKey?.toString(), }); - } - - static createBtcWallet(config: BtcWalletConfig, network: string) { - const btcNetwork = getBtcNetwork(network); - return BtcWalletFactory.create(config, btcNetwork); - } - static createBtcKeyring( - config: BtcWalletConfig, - options: CreateBtcKeyringOptions, - ): BtcKeyring { - return new BtcKeyring(new KeyringStateManager(), { - defaultIndex: config.defaultAccountIndex, - multiAccount: config.enableMultiAccounts, - // TODO: Remove temp solution to support keyring in snap without keyring API - emitEvents: options.emitEvents, + return new BtcOnChainService(client, { + network: btcNetwork, }); } - static createOnChainServiceProvider(scope: string): IOnChainService { - return Factory.createBtcOnChainServiceProvider( - Config.onChainService[Config.chain], - scope, - ); - } - static createWallet(scope: string): IWallet { - return Factory.createBtcWallet(Config.wallet[Config.chain], scope); - } - - static createKeyring(): Keyring { - return Factory.createBtcKeyring(Config.wallet[Config.chain], { - emitEvents: true, - }); + const btcNetwork = getBtcNetwork(scope); + return new BtcWallet(new BtcAccountDeriver(btcNetwork), btcNetwork); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts index 9c10a9f8..ca926944 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.ts @@ -1,19 +1,19 @@ import * as keyringApi from '@metamask/keyring-api'; import { - type Json, type JsonRpcRequest, SnapError, MethodNotFoundError, } from '@metamask/snaps-sdk'; import { onRpcRequest, validateOrigin, onKeyringRequest } from '.'; -import { Config, originPermissions } from './config'; +import * as entry from '.'; +import { TransactionStatus } from './chain'; +import { Config } from './config'; import { BtcKeyring } from './keyring'; -import { BaseSnapRpcHandler, type IStaticSnapRpcHandler } from './libs/rpc'; -import { RpcHelper } from './rpcs'; -import type { StaticImplements } from './types/static'; +import { originPermissions } from './permissions'; +import * as getTxStatusRpc from './rpcs/get-transaction-status'; -jest.mock('./libs/logger/logger'); +jest.mock('./utils/logger'); jest.mock('@metamask/keyring-api', () => ({ ...jest.requireActual('@metamask/keyring-api'), @@ -50,59 +50,47 @@ describe('validateOrigin', () => { }); describe('onRpcRequest', () => { - const createMockChainApiHandler = () => { - const handleRequestSpy = jest.fn(); - class MockChainApiHandler - extends BaseSnapRpcHandler - implements - StaticImplements - { - handleRequest = handleRequestSpy; - } - return { handler: MockChainApiHandler, handleRequestSpy }; - }; + const executeRequest = async (method = 'chain_getTransactionStatus') => { + jest.spyOn(entry, 'validateOrigin').mockReturnThis(); - const executeRequest = async () => { return onRpcRequest({ origin: 'http://localhost:8000', request: { - method: 'chain_getTransactionStatus', + method, params: { - scope: Config.avaliableNetworks[Config.chain][0], + scope: Config.avaliableNetworks[0], }, } as unknown as JsonRpcRequest, }); }; it('executes the rpc request', async () => { - const { handler, handleRequestSpy } = createMockChainApiHandler(); - jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({ - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_getTransactionStatus: handler, - }); - handleRequestSpy.mockResolvedValueOnce({ - data: 1, - } as Json); + jest.spyOn(getTxStatusRpc, 'getTransactionStatus').mockResolvedValue({ + status: TransactionStatus.Confirmed, + } as unknown as getTxStatusRpc.GetTransactionStatusResponse); const resposne = await executeRequest(); - expect(resposne).toStrictEqual({ data: 1 }); + expect(resposne).toStrictEqual({ status: TransactionStatus.Confirmed }); }); - it('throws SnapError if an error catched', async () => { - jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({}); - - await expect(executeRequest()).rejects.toThrow(MethodNotFoundError); + it('throws MethodNotFoundError if an method not found', async () => { + await expect(executeRequest('some-not')).rejects.toThrow( + MethodNotFoundError, + ); }); - it('throws SnapError if an SnapError catched', async () => { - const { handler, handleRequestSpy } = createMockChainApiHandler(); - jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({ - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_getTransactionStatus: handler, - }); - handleRequestSpy.mockRejectedValue(new SnapError('error')); + it('throws SnapError if the request is failed to execute', async () => { + jest + .spyOn(getTxStatusRpc, 'getTransactionStatus') + .mockRejectedValue(new SnapError('error')); + await expect(executeRequest()).rejects.toThrow(SnapError); + }); + it('throws SnapError if the error is not an instance of SnapError', async () => { + jest + .spyOn(getTxStatusRpc, 'getTransactionStatus') + .mockRejectedValue(new Error('error')); await expect(executeRequest()).rejects.toThrow(SnapError); }); }); @@ -122,7 +110,7 @@ describe('onKeyringRequest', () => { request: { method: keyringApi.KeyringRpcMethod.ListAccounts, params: { - scope: Config.avaliableNetworks[Config.chain][0], + scope: Config.avaliableNetworks[0], }, } as unknown as JsonRpcRequest, }); @@ -136,7 +124,7 @@ describe('onKeyringRequest', () => { expect(handler).toHaveBeenCalledWith(expect.any(BtcKeyring), { method: keyringApi.KeyringRpcMethod.ListAccounts, params: { - scope: Config.avaliableNetworks[Config.chain][0], + scope: Config.avaliableNetworks[0], }, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 6aa711cd..89782ea8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -9,12 +9,12 @@ import { } from '@metamask/snaps-sdk'; import { Config } from './config'; -import { originPermissions } from './config/permissions'; -import { Factory } from './factory'; -import { logger } from './libs/logger/logger'; -import type { SnapRpcHandlerRequest } from './libs/rpc'; -import { RpcHelper } from './rpcs/helpers'; -import { isSnapRpcError } from './utils'; +import { BtcKeyring } from './keyring'; +import { InternalRpcMethod, originPermissions } from './permissions'; +import type { CreateAccountParams, GetTransactionStatusParams } from './rpcs'; +import { createAccount, getTransactionStatus } from './rpcs'; +import { KeyringStateManager } from './stateManagement'; +import { isSnapRpcError, logger } from './utils'; export const validateOrigin = (origin: string, method: string): void => { if (!origin) { @@ -35,17 +35,19 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ try { const { method } = request; - validateOrigin(origin, method); - const methodHanlders = RpcHelper.getChainRpcApiHandlers(); + validateOrigin(origin, method); - if (!Object.prototype.hasOwnProperty.call(methodHanlders, method)) { - throw new MethodNotFoundError() as unknown as Error; + switch (method) { + case InternalRpcMethod.CreateAccount: + return await createAccount(request.params as CreateAccountParams); + case InternalRpcMethod.GetTransactionStatus: + return await getTransactionStatus( + request.params as GetTransactionStatusParams, + ); + default: + throw new MethodNotFoundError(method) as unknown as Error; } - - return await methodHanlders[method] - .getInstance() - .execute(request.params as SnapRpcHandlerRequest); } catch (error) { let snapError = error; @@ -68,7 +70,12 @@ export const onKeyringRequest: OnKeyringRequestHandler = async ({ try { validateOrigin(origin, request.method); - const keyring = Factory.createKeyring(); + const keyring = new BtcKeyring(new KeyringStateManager(), { + defaultIndex: Config.wallet.defaultAccountIndex, + // TODO: Remove temp solution to support keyring in snap without keyring API + emitEvents: true, + }); + return (await handleKeyringRequest( keyring, request, diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts similarity index 69% rename from merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts rename to merged-packages/bitcoin-wallet-snap/src/keyring.test.ts index c2008861..2079fd2a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts @@ -1,20 +1,21 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; import { MethodNotFoundError } from '@metamask/snaps-sdk'; -import { unknown } from 'superstruct'; - -import { generateAccounts } from '../../test/utils'; -import { BtcAsset, Network } from '../bitcoin/constants'; -import { Chain, Config } from '../config'; -import { Factory } from '../factory'; -import { type IStaticSnapRpcHandler, BaseSnapRpcHandler } from '../libs/rpc'; -import { GetBalancesHandler } from '../rpcs'; -import { RpcHelper } from '../rpcs/helpers'; -import type { StaticImplements } from '../types/static'; -import type { IWallet } from '../wallet'; -import { BtcKeyringError } from './exceptions'; +import { v4 as uuidv4 } from 'uuid'; + +import { generateAccounts } from '../test/utils'; +import { ScriptType } from './bitcoin/constants'; +import { BtcAccount } from './bitcoin/wallet'; +import { Config } from './config'; +import { Caip2Asset, Caip2ChainId } from './constants'; +import { Factory } from './factory'; import { BtcKeyring } from './keyring'; -import { KeyringStateManager } from './state'; +import * as getBalanceRpc from './rpcs/get-balances'; +import * as sendManyRpc from './rpcs/sendmany'; +import { KeyringStateManager } from './stateManagement'; +import type { IWallet } from './wallet'; -jest.mock('../libs/logger/logger'); +jest.mock('./utils/logger'); +jest.mock('./utils/snap'); jest.mock('@metamask/keyring-api', () => ({ ...jest.requireActual('@metamask/keyring-api'), @@ -71,40 +72,16 @@ describe('BtcKeyring', () => { }; }; - const createMockChainRPCHandler = () => { - const handleRequestSpy = jest.fn(); - class MockChainRpcHandler - extends BaseSnapRpcHandler - implements - StaticImplements - { - static override get requestStruct() { - return unknown(); - } - - handleRequest = handleRequestSpy; - } - return { - instance: MockChainRpcHandler, - handleRequestSpy, - }; - }; - const createMockKeyring = (stateMgr: KeyringStateManager) => { - const { instance: RpcHandler, handleRequestSpy } = - createMockChainRPCHandler(); - - jest.spyOn(RpcHelper, 'getKeyringRpcApiHandlers').mockReturnValue({ - // eslint-disable-next-line @typescript-eslint/naming-convention - btc_sendmany: RpcHandler, - }); - + const sendManySpy = jest.spyOn(sendManyRpc, 'sendMany'); + const getBalanceRpcSpy = jest.spyOn(getBalanceRpc, 'getBalances'); return { instance: new BtcKeyring(stateMgr, { defaultIndex: 0, multiAccount: false, }), - handleRequestSpy, + sendManySpy, + getBalanceRpcSpy, }; }; @@ -112,12 +89,32 @@ describe('BtcKeyring', () => { return [`m`, `0'`, `0`, `${index}`].join('/'); }; + const createSender = async (caip2ChainId: string) => { + const wallet = Factory.createWallet(caip2ChainId); + const sender = await wallet.unlock(0, ScriptType.P2wpkh); + + const keyringAccount = { + type: sender.type, + id: uuidv4(), + address: sender.address, + options: { + scope: caip2ChainId, + index: sender.index, + }, + methods: ['btc_sendmany'], + }; + return { + sender, + keyringAccount, + }; + }; + describe('createAccount', () => { it('creates account', async () => { const { unlockSpy } = createMockWallet(); const { instance: stateMgr, addWalletSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); - const scope = Network.Testnet; + const scope = Caip2ChainId.Testnet; const account = generateAccounts(1)[0]; unlockSpy.mockResolvedValue({ @@ -132,8 +129,8 @@ describe('BtcKeyring', () => { }); expect(unlockSpy).toHaveBeenCalledWith( - Config.wallet[Chain.Bitcoin].defaultAccountIndex, - Config.wallet[Chain.Bitcoin].defaultAccountType, + Config.wallet.defaultAccountIndex, + Config.wallet.defaultAccountType, ); expect(addWalletSpy).toHaveBeenCalledWith({ account: { @@ -152,18 +149,18 @@ describe('BtcKeyring', () => { }); }); - it('throws BtcKeyringError if an error catched', async () => { + it('throws Error if an error catched', async () => { const { unlockSpy } = createMockWallet(); const { instance: stateMgr } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); unlockSpy.mockRejectedValue(new Error('error')); - const scope = Network.Testnet; + const scope = Caip2ChainId.Testnet; await expect( keyring.createAccount({ scope, }), - ).rejects.toThrow(BtcKeyringError); + ).rejects.toThrow(Error); }); it('throws `Invalid params to create an account` if the create options is invalid', async () => { @@ -185,108 +182,11 @@ describe('BtcKeyring', () => { const account = generateAccounts(1)[0]; await expect( - keyring.filterAccountChains(account.id, [Network.Testnet]), + keyring.filterAccountChains(account.id, [Caip2ChainId.Testnet]), ).rejects.toThrow('Method not implemented.'); }); }); - describe('submitRequest', () => { - it('calls SnapRpcHandler if the method support', async () => { - const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const { instance: keyring, handleRequestSpy } = - createMockKeyring(stateMgr); - const account = generateAccounts(1)[0]; - const params = { - scope: 'bip122:000000000933ea01ad0ee984209779ba', - amounts: { - bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah: '0.01', - bc1qf5n2h6mgelkls4497pkpemew55xpew90td2qae: '0.01', - }, - comment: 'testing', - subtractFeeFrom: ['bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah'], - replaceable: false, - }; - getWalletSpy.mockResolvedValue({ - account, - index: account.options.index, - scope: account.options.scope, - hdPath: getHdPath(account.options.index), - }); - - await keyring.submitRequest({ - id: account.id, - scope: Network.Testnet, - account: account.address, - request: { - method: 'btc_sendmany', - params, - }, - }); - - expect(handleRequestSpy).toHaveBeenCalledWith(params); - }); - - it('throws `Account not found` error if the account address not found', async () => { - const { instance: stateMgr } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const account = generateAccounts(1)[0]; - - await expect( - keyring.submitRequest({ - id: account.id, - scope: Network.Testnet, - account: account.address, - request: { - method: 'btc_sendmany', - }, - }), - ).rejects.toThrow('Account not found'); - }); - - it("throws `Account's scope does not match with the request's scope` error if given scope is not match with the account", async () => { - const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const account = generateAccounts(1)[0]; - - getWalletSpy.mockResolvedValue({ - account, - index: account.options.index, - scope: account.options.scope, - hdPath: getHdPath(account.options.index), - }); - - await expect( - keyring.submitRequest({ - id: account.id, - scope: Network.Mainnet, - account: account.address, - request: { - method: 'btc_sendmany', - }, - }), - ).rejects.toThrow( - "Account's scope does not match with the request's scope", - ); - }); - - it('throws MethodNotFoundError if the method not support', async () => { - const { instance: stateMgr } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const account = generateAccounts(1)[0]; - - await expect( - keyring.submitRequest({ - id: account.id, - scope: Network.Testnet, - account: account.address, - request: { - method: 'btc_doesNotExist', - }, - }), - ).rejects.toThrow(MethodNotFoundError); - }); - }); - describe('listAccounts', () => { it('returns result', async () => { const { instance: stateMgr, listAccountsSpy } = createMockStateMgr(); @@ -300,12 +200,12 @@ describe('BtcKeyring', () => { expect(listAccountsSpy).toHaveBeenCalledTimes(1); }); - it('throws BtcKeyringError if an error catched', async () => { + it('throws Error if an error catched', async () => { const { instance: stateMgr, listAccountsSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); listAccountsSpy.mockRejectedValue(new Error('error')); - await expect(keyring.listAccounts()).rejects.toThrow(BtcKeyringError); + await expect(keyring.listAccounts()).rejects.toThrow(Error); }); }); @@ -334,15 +234,13 @@ describe('BtcKeyring', () => { expect(getAccountSpy).toHaveBeenCalledTimes(1); }); - it('throws BtcKeyringError if an error catched', async () => { + it('throws Error if an error catched', async () => { const { instance: stateMgr, getAccountSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); getAccountSpy.mockRejectedValue(new Error('error')); const accounts = generateAccounts(1); - await expect(keyring.getAccount(accounts[0].id)).rejects.toThrow( - BtcKeyringError, - ); + await expect(keyring.getAccount(accounts[0].id)).rejects.toThrow(Error); }); }); @@ -358,15 +256,13 @@ describe('BtcKeyring', () => { expect(updateAccountSpy).toHaveBeenCalledWith(account); }); - it('throws BtcKeyringError if an error catched', async () => { + it('throws Error if an error catched', async () => { const { instance: stateMgr, updateAccountSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); updateAccountSpy.mockRejectedValue(new Error('error')); const account = generateAccounts(1)[0]; - await expect(keyring.updateAccount(account)).rejects.toThrow( - BtcKeyringError, - ); + await expect(keyring.updateAccount(account)).rejects.toThrow(Error); }); }); @@ -382,61 +278,166 @@ describe('BtcKeyring', () => { expect(removeAccountsSpy).toHaveBeenCalledWith([account.id]); }); - it('throws BtcKeyringError if an error catched', async () => { + it('throws Error if an error catched', async () => { const { instance: stateMgr, removeAccountsSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); removeAccountsSpy.mockRejectedValue(new Error('error')); const account = generateAccounts(1)[0]; - await expect(keyring.deleteAccount(account.id)).rejects.toThrow( - BtcKeyringError, - ); + await expect(keyring.deleteAccount(account.id)).rejects.toThrow(Error); }); }); - describe('getAccountBalances', () => { - it('executes `GetBalancesHandler` with correct parameter', async () => { + describe('submitRequest', () => { + it('calls SnapRpcHandler if the method support', async () => { + const caip2ChainId = Caip2ChainId.Testnet; const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const { instance: keyring, sendManySpy } = createMockKeyring(stateMgr); + const { sender, keyringAccount } = await createSender(caip2ChainId); + getWalletSpy.mockResolvedValue({ + account: keyringAccount as unknown as KeyringAccount, + index: sender.index, + scope: keyringAccount.options.scope, + hdPath: sender.hdPath, + }); + sendManySpy.mockResolvedValue({ + txId: 'txid', + }); + + const params = { + scope: caip2ChainId, + amounts: { + bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah: '0.01', + bc1qf5n2h6mgelkls4497pkpemew55xpew90td2qae: '0.01', + }, + comment: 'testing', + subtractFeeFrom: ['bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah'], + replaceable: false, + }; + + await keyring.submitRequest({ + id: keyringAccount.id, + scope: Caip2ChainId.Testnet, + account: keyringAccount.address, + request: { + method: 'btc_sendmany', + params, + }, + }); + + expect(sendManySpy).toHaveBeenCalledWith(expect.any(BtcAccount), params); + }); + + it('throws `Account not found` error if the account address not found', async () => { + const { instance: stateMgr } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); const account = generateAccounts(1)[0]; - const assets = [BtcAsset.TBtc]; - const getBalancesHandlerSpy = jest.spyOn( - GetBalancesHandler.prototype, - 'execute', - ); - getBalancesHandlerSpy.mockResolvedValue( - assets.reduce((acc, asset) => { - acc[asset] = { - amount: '1', - unit: Config.unit[Chain.Bitcoin], - }; - return acc; + + await expect( + keyring.submitRequest({ + id: account.id, + scope: Caip2ChainId.Testnet, + account: account.address, + request: { + method: 'btc_sendmany', + }, }), + ).rejects.toThrow('Account not found'); + }); + + it("throws `Account's scope does not match with the request's scope` error if given scope is not match with the account", async () => { + const caip2ChainId = Caip2ChainId.Testnet; + const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + const { sender, keyringAccount } = await createSender(caip2ChainId); + getWalletSpy.mockResolvedValue({ + account: keyringAccount as unknown as KeyringAccount, + index: sender.index, + scope: keyringAccount.options.scope, + hdPath: sender.hdPath, + }); + + await expect( + keyring.submitRequest({ + id: keyringAccount.id, + scope: Caip2ChainId.Mainnet, + account: keyringAccount.address, + request: { + method: 'btc_sendmany', + }, + }), + ).rejects.toThrow( + "Account's scope does not match with the request's scope", ); + }); + + it('throws MethodNotFoundError if the method not support', async () => { + const caip2ChainId = Caip2ChainId.Testnet; + const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + const { sender, keyringAccount } = await createSender(caip2ChainId); getWalletSpy.mockResolvedValue({ - account, - index: account.options.index, - scope: account.options.scope, - hdPath: getHdPath(account.options.index), + account: keyringAccount as unknown as KeyringAccount, + index: sender.index, + scope: keyringAccount.options.scope, + hdPath: sender.hdPath, + }); + + await expect( + keyring.submitRequest({ + id: keyringAccount.id, + scope: caip2ChainId, + account: keyringAccount.address, + request: { + method: 'btc_doesNotExist', + }, + }), + ).rejects.toThrow(MethodNotFoundError); + }); + }); + + describe('getAccountBalances', () => { + it('executes `GetBalancesHandler` with correct parameter', async () => { + const caip2ChainId = Caip2ChainId.Testnet; + const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const { instance: keyring, getBalanceRpcSpy } = + createMockKeyring(stateMgr); + const { sender, keyringAccount } = await createSender(caip2ChainId); + getWalletSpy.mockResolvedValue({ + account: keyringAccount as unknown as KeyringAccount, + index: sender.index, + scope: keyringAccount.options.scope, + hdPath: sender.hdPath, }); - await keyring.getAccountBalances(account.id, [BtcAsset.TBtc]); + const assets = [Caip2Asset.TBtc]; + const expectedResp = assets.reduce((acc, asset) => { + acc[asset] = { + amount: '1', + unit: Config.unit, + }; + return acc; + }, {}); + + getBalanceRpcSpy.mockResolvedValue(expectedResp); + + await keyring.getAccountBalances(keyringAccount.id, [Caip2Asset.TBtc]); - expect(getBalancesHandlerSpy).toHaveBeenCalledWith({ - scope: account.options.scope, + expect(getBalanceRpcSpy).toHaveBeenCalledWith(expect.any(BtcAccount), { + scope: caip2ChainId, assets, }); }); - it('throws BtcKeyringError if an error catched', async () => { + it('throws Error if an error catched', async () => { const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); getWalletSpy.mockRejectedValue(new Error('error')); const account = generateAccounts(1)[0]; await expect( - keyring.getAccountBalances(account.id, [BtcAsset.TBtc]), - ).rejects.toThrow(BtcKeyringError); + keyring.getAccountBalances(account.id, [Caip2Asset.TBtc]), + ).rejects.toThrow(Error); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts similarity index 60% rename from merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts rename to merged-packages/bitcoin-wallet-snap/src/keyring.ts index 74a1c60c..6ba16239 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -9,48 +9,48 @@ import { type CaipAssetType, } from '@metamask/keyring-api'; import { MethodNotFoundError, type Json } from '@metamask/snaps-sdk'; -import { assert, StructError } from 'superstruct'; +import type { Infer } from 'superstruct'; +import { assert, object, StructError } from 'superstruct'; import { v4 as uuidv4 } from 'uuid'; -import { Config } from '../config'; -import { Factory } from '../factory'; -import { logger } from '../libs/logger/logger'; -import type { SnapRpcHandlerRequest } from '../libs/rpc'; -import { SnapHelper } from '../libs/snap'; -import { GetBalancesHandler } from '../rpcs'; -import { RpcHelper } from '../rpcs/helpers'; -import type { IAccount, IWallet } from '../wallet'; -import { BtcKeyringError } from './exceptions'; -import type { KeyringStateManager } from './state'; -import { - CreateAccountOptionsStruct, - type ChainRPCHandlers, - type CreateAccountOptions, - type KeyringOptions, -} from './types'; +import { Config } from './config'; +import { Factory } from './factory'; +import { getBalances, type SendManyParams, sendMany } from './rpcs'; +import type { KeyringStateManager, Wallet } from './stateManagement'; +import { getProvider, scopeStruct, logger } from './utils'; +import type { IAccount, IWallet } from './wallet'; + +export type KeyringOptions = Record & { + defaultIndex: number; + multiAccount?: boolean; + // TODO: Remove temp solution to support keyring in snap without keyring API + emitEvents?: boolean; +}; + +export const CreateAccountOptionsStruct = object({ + scope: scopeStruct, +}); + +export type CreateAccountOptions = Record & + Infer; export class BtcKeyring implements Keyring { protected readonly _stateMgr: KeyringStateManager; protected readonly _options: KeyringOptions; - protected readonly _keyringMethods: string[]; - - protected readonly _handlers: ChainRPCHandlers; + protected readonly _methods = ['btc_sendmany']; constructor(stateMgr: KeyringStateManager, options: KeyringOptions) { this._stateMgr = stateMgr; this._options = options; - const mapping = RpcHelper.getKeyringRpcApiHandlers(); - this._keyringMethods = Object.keys(mapping); - this._handlers = mapping; } async listAccounts(): Promise { try { return await this._stateMgr.listAccounts(); } catch (error) { - throw new BtcKeyringError(error); + throw new Error(error); } } @@ -58,7 +58,7 @@ export class BtcKeyring implements Keyring { try { return (await this._stateMgr.getAccount(id)) ?? undefined; } catch (error) { - throw new BtcKeyringError(error); + throw new Error(error); } } @@ -66,16 +66,16 @@ export class BtcKeyring implements Keyring { try { assert(options, CreateAccountOptionsStruct); - const wallet: IWallet = Factory.createWallet(options.scope); + const wallet = this.getBtcWallet(options.scope); // TODO: Create account with index 0 for now for phase 1 scope, update to use increment index later - const index = Config.wallet[Config.chain].defaultAccountIndex; - const account = await wallet.unlock( - index, - Config.wallet[Config.chain].defaultAccountType, - ); + const index = this._options.defaultIndex; + const type = Config.wallet.defaultAccountType; + + const account = await this.discoverAccount(wallet, index, type); logger.info( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `[BtcKeyring.createAccount] Account unlocked: ${account.address}`, ); @@ -108,15 +108,15 @@ export class BtcKeyring implements Keyring { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[BtcKeyring.createAccount] Error: ${error.message}`); if (error instanceof StructError) { - throw new BtcKeyringError('Invalid params to create an account'); + throw new Error('Invalid params to create an account'); } - throw new BtcKeyringError(error); + throw new Error(error); } } // eslint-disable-next-line @typescript-eslint/no-unused-vars async filterAccountChains(id: string, chains: string[]): Promise { - throw new BtcKeyringError('Method not implemented.'); + throw new Error('Method not implemented.'); } async updateAccount(account: KeyringAccount): Promise { @@ -128,7 +128,7 @@ export class BtcKeyring implements Keyring { } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[BtcKeyring.updateAccount] Error: ${error.message}`); - throw new BtcKeyringError(error); + throw new Error(error); } } @@ -141,17 +141,11 @@ export class BtcKeyring implements Keyring { } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[BtcKeyring.deleteAccount] Error: ${error.message}`); - throw new BtcKeyringError(error); + throw new Error(error); } } async submitRequest(request: KeyringRequest): Promise { - return this.syncSubmitRequest(request); - } - - protected async syncSubmitRequest( - request: KeyringRequest, - ): Promise { return { pending: false, result: await this.handleSubmitRequest(request), @@ -159,14 +153,10 @@ export class BtcKeyring implements Keyring { } protected async handleSubmitRequest(request: KeyringRequest): Promise { - const { scope, account } = request; + const { scope, account: id } = request; const { method, params } = request.request; - if (!Object.prototype.hasOwnProperty.call(this._handlers, method)) { - throw new MethodNotFoundError() as unknown as Error; - } - - const walletData = await this.getAndVerifyWallet(account); + const walletData = await this.getWalletData(id); if (walletData.scope !== scope) { throw new Error( @@ -174,10 +164,24 @@ export class BtcKeyring implements Keyring { ); } - return this._handlers[method].getInstance(walletData).execute({ - ...params, - scope, - } as unknown as SnapRpcHandlerRequest); + const wallet = this.getBtcWallet(walletData.scope); + const account = await this.discoverAccount( + wallet, + walletData.index, + walletData.account.type, + ); + + this.verifyIfAccountValid(account, walletData.account); + + switch (method) { + case 'btc_sendmany': + return (await sendMany(account, { + ...params, + scope: walletData.scope, + } as unknown as SendManyParams)) as unknown as Json; + default: + throw new MethodNotFoundError(method) as unknown as Error; + } } async #emitEvent( @@ -186,44 +190,37 @@ export class BtcKeyring implements Keyring { ): Promise { // TODO: Remove temp solution to support keyring in snap without extentions support if (this._options.emitEvents) { - await emitSnapKeyringEvent(SnapHelper.provider, event, data); + await emitSnapKeyringEvent(getProvider(), event, data); } } - protected newKeyringAccount( - account: IAccount, - options?: CreateAccountOptions, - ): KeyringAccount { - return { - type: account.type, - id: uuidv4(), - address: account.address, - options: { - ...options, - }, - methods: this._keyringMethods, - } as unknown as KeyringAccount; - } - async getAccountBalances( id: string, assets: CaipAssetType[], ): Promise> { try { - const walletData = await this.getAndVerifyWallet(id); + const walletData = await this.getWalletData(id); + const wallet = this.getBtcWallet(walletData.scope); + const account = await this.discoverAccount( + wallet, + walletData.index, + walletData.account.type, + ); - return (await GetBalancesHandler.getInstance(walletData).execute({ - scope: walletData.scope, + this.verifyIfAccountValid(account, walletData.account); + + return await getBalances(account, { assets, - })) as unknown as Record; + scope: walletData.scope, + }); } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[BtcKeyring.getAccountBalances] Error: ${error.message}`); - throw new BtcKeyringError(error); + throw new Error(error); } } - protected async getAndVerifyWallet(id: string) { + protected async getWalletData(id: string): Promise { const walletData = await this._stateMgr.getWallet(id); if (!walletData) { @@ -232,4 +229,40 @@ export class BtcKeyring implements Keyring { return walletData; } + + protected getBtcWallet(scope: string): IWallet { + return Factory.createWallet(scope); + } + + protected async discoverAccount( + wallet: IWallet, + index: number, + type: string, + ): Promise { + return await wallet.unlock(index, type); + } + + protected verifyIfAccountValid( + account: IAccount, + keyringAccount: KeyringAccount, + ): void { + if (!account || account.address !== keyringAccount.address) { + throw new Error('Account not found'); + } + } + + protected newKeyringAccount( + account: IAccount, + options?: CreateAccountOptions, + ): KeyringAccount { + return { + type: account.type, + id: uuidv4(), + address: account.address, + options: { + ...options, + }, + methods: this._methods, + } as unknown as KeyringAccount; + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts deleted file mode 100644 index 27acf74c..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { CustomError } from '../libs/exception'; - -export class BtcKeyringError extends CustomError {} - -export class BtcKeyringStateError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/index.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/index.ts deleted file mode 100644 index 53d3dc06..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './keyring'; -export * from './state'; -export * from './exceptions'; -export * from './types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts deleted file mode 100644 index ed9e02c1..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; -import { type Json } from '@metamask/snaps-sdk'; -import type { Infer } from 'superstruct'; -import { object } from 'superstruct'; - -import type { IStaticSnapRpcHandler } from '../libs/rpc'; -import { scopeStruct } from '../utils'; - -export type Wallet = { - account: KeyringAccount; - hdPath: string; - index: number; - scope: string; -}; - -export type Wallets = Record; - -export type SnapState = { - walletIds: string[]; - wallets: Wallets; -}; - -export type KeyringOptions = Record & { - defaultIndex: number; - multiAccount?: boolean; - // TODO: Remove temp solution to support keyring in snap without keyring API - emitEvents?: boolean; -}; - -export type ChainRPCHandlers = Record; - -export const CreateAccountOptionsStruct = object({ - scope: scopeStruct, -}); - -export type CreateAccountOptions = Record & - Infer; diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/exception/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/libs/exception/exceptions.ts deleted file mode 100644 index 438d3c89..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/exception/exceptions.ts +++ /dev/null @@ -1,23 +0,0 @@ -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/libs/exception/index.ts b/merged-packages/bitcoin-wallet-snap/src/libs/exception/index.ts deleted file mode 100644 index 28386125..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/exception/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './exceptions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts deleted file mode 100644 index 22f57026..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { InvalidParamsError } from '@metamask/snaps-sdk'; -import { type Struct, assert } from 'superstruct'; - -import { logger } from '../logger/logger'; -import { InvalidSnapRpcResponseError } from './exceptions'; -import { - type SnapRpcHandlerOptions, - type ISnapRpcHandler, - type IStaticSnapRpcHandler, - type SnapRpcHandlerResponse, - type SnapRpcHandlerRequest, - SnapRpcHandlerRequestStruct, -} from './types'; - -export abstract class BaseSnapRpcHandler { - static instance: ISnapRpcHandler | null = null; - - static readonly requestStruct: Struct = SnapRpcHandlerRequestStruct; - - static readonly responseStruct?: Struct; - - protected isThrowValidationError = false; - - abstract handleRequest( - params: SnapRpcHandlerRequest, - ): Promise; - - protected get _requestStruct(): Struct { - return (this.constructor as typeof BaseSnapRpcHandler).requestStruct; - } - - protected get _responseStruct(): Struct | undefined { - return (this.constructor as typeof BaseSnapRpcHandler).responseStruct; - } - - protected async preExecute(params: SnapRpcHandlerRequest): Promise { - logger.info( - `[SnapRpcHandler.preExecute] Request: ${JSON.stringify(params)}`, - ); - try { - assert(params, this._requestStruct); - } catch (error) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.info(`[SnapRpcHandler.preExecute] Error: ${error.message}`); - this.throwValidationError(error.message); - } - } - - protected async postExecute(response: SnapRpcHandlerResponse): Promise { - logger.info( - `[SnapRpcHandler.postExecute] Response: ${JSON.stringify(response)}`, - ); - - try { - if (this._responseStruct) { - assert(response, this._responseStruct); - } - } catch (error) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.info(`[SnapRpcHandler.postExecute] Error: ${error.message}`); - throw new InvalidSnapRpcResponseError('Invalid Response'); - } - } - - async execute( - params: SnapRpcHandlerRequest, - ): Promise { - await this.preExecute(params); - const result = await this.handleRequest(params); - await this.postExecute(result); - return result; - } - - static getInstance( - this: IStaticSnapRpcHandler, - options?: SnapRpcHandlerOptions, - ): ISnapRpcHandler { - return new this(options); - } - - protected throwValidationError(message: string): void { - throw new InvalidParamsError( - this.isThrowValidationError ? message : undefined, - ) as unknown as Error; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/exceptions.ts deleted file mode 100644 index 50b6a304..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/exceptions.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { CustomError } from '../exception'; - -export class SnapRpcError extends CustomError {} -export class InvalidSnapRpcResponseError extends SnapRpcError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/index.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/index.ts deleted file mode 100644 index 0f4d2283..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './types'; -export * from './base'; -export * from './exceptions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/types.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/types.ts deleted file mode 100644 index a326f49e..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { Json } from '@metamask/snaps-sdk'; -import { object, type Struct, type Infer } from 'superstruct'; - -import { scopeStruct } from '../../utils'; - -export const SnapRpcHandlerRequestStruct = object({ - scope: scopeStruct, -}); - -export type SnapRpcHandlerRequest = Json & - Infer; - -export type SnapRpcHandlerResponse = Json; - -export type SnapRpcHandlerOptions = Record | null; - -export type IStaticSnapRpcHandler = { - /** - * Superstruct for the request. - */ - requestStruct: Struct; - /** - * Superstruct for the response. - */ - reponseStruct?: Struct; - - /** - * A method to create a new instance of the rpc handler. - * - * @param options - An optional parameter to create the instance. - * @returns An handler object. - */ - new (options?: SnapRpcHandlerOptions): ISnapRpcHandler; - - /** - * A method to return the instance object of the rpc handler. - * - * @param this - The static instance of the handler class. - * @param options - An optional parameter to create the instance. - * @returns An handler object. - */ - getInstance( - this: IStaticSnapRpcHandler, - options?: SnapRpcHandlerOptions, - ): ISnapRpcHandler; -}; - -export type ISnapRpcHandler = { - /** - * A method to execute the rpc method. - * - * @param params - An struct contains the require parameter for the request. - * @returns A promise that resolves to an json. - */ - execute(params: SnapRpcHandlerRequest): Promise; -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/__mocks__/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/__mocks__/helpers.ts deleted file mode 100644 index b8b5abff..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/__mocks__/helpers.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { type SLIP10NodeInterface } from '@metamask/key-tree'; -import { networks } from 'bitcoinjs-lib'; - -import { createRandomBip32Data } from '../../../../test/utils'; - -export class SnapHelper { - static getBip44Deriver = jest.fn(); - - static async getBip32Deriver( - path: string[], - curve: 'secp256k1' | 'ed25519', - ): Promise { - const { data } = createRandomBip32Data(networks.bitcoin, path, curve); - return { - ...data, - toJSON: jest.fn().mockReturnValue(data), - } as SLIP10NodeInterface; - } - - static confirmDialog = jest.fn(); - - static getStateData = jest.fn(); - - static setStateData = jest.fn(); -} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/exceptions.ts deleted file mode 100644 index 5999cf38..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/exceptions.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { CustomError } from '../exception'; - -export class StateError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts deleted file mode 100644 index 430d1c04..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { expect } from '@jest/globals'; -import type { Component } from '@metamask/snaps-sdk'; -import { heading, panel, text, divider, row } 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.provider, '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.provider, '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.provider, 'request'); - const components: Component[] = [ - heading('header'), - text('subHeader'), - divider(), - row('Label1', text('Value1')), - text('**Label2**:'), - row('SubLabel1', text('SubValue1')), - ]; - - await SnapHelper.confirmDialog(components); - - expect(spy).toHaveBeenCalledWith({ - method: 'snap_dialog', - params: { - type: 'confirmation', - content: panel(components), - }, - }); - }); - }); - - describe('getStateData', () => { - it('gets state data', async () => { - const spy = jest.spyOn(SnapHelper.provider, 'request'); - const testcase = { - state: { - transaction: [ - { - txHash: '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.provider, 'request'); - const testcase = { - state: { - transaction: [ - { - txHash: '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/libs/snap/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.ts deleted file mode 100644 index e22c3bad..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - getBIP44AddressKeyDeriver, - type BIP44AddressKeyDeriver, - type SLIP10NodeInterface, -} from '@metamask/key-tree'; -import type { Component, DialogResult, Json } from '@metamask/snaps-sdk'; -import { panel, type SnapsProvider } from '@metamask/snaps-sdk'; - -declare const snap: SnapsProvider; - -export class SnapHelper { - static provider: SnapsProvider = snap; - - static async getBip44Deriver( - coinType: number, - ): Promise { - const bip44Node = await SnapHelper.provider.request({ - method: 'snap_getBip44Entropy', - params: { - coinType, - }, - }); - return getBIP44AddressKeyDeriver(bip44Node); - } - - static async getBip32Deriver( - path: string[], - curve: 'secp256k1' | 'ed25519', - ): Promise { - const node = await SnapHelper.provider.request({ - method: 'snap_getBip32Entropy', - params: { - path, - curve, - }, - }); - return node as SLIP10NodeInterface; - } - - static async confirmDialog(components: Component[]): Promise { - return SnapHelper.provider.request({ - method: 'snap_dialog', - params: { - type: 'confirmation', - content: panel(components), - }, - }); - } - - static async getStateData(): Promise { - return (await SnapHelper.provider.request({ - method: 'snap_manageState', - params: { - operation: 'get', - }, - })) as unknown as State; - } - - static async setStateData(data: State) { - await SnapHelper.provider.request({ - method: 'snap_manageState', - params: { - operation: 'update', - newState: data as unknown as Record, - }, - }); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/index.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/index.ts deleted file mode 100644 index 6e76333f..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './helpers'; -export * from './state'; -export * from './lock'; -export * from './exceptions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.test.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.test.ts deleted file mode 100644 index 66e667ed..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -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/libs/snap/lock.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.ts deleted file mode 100644 index b4b0e4fb..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.ts +++ /dev/null @@ -1,12 +0,0 @@ -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/config/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/permissions.ts similarity index 84% rename from merged-packages/bitcoin-wallet-snap/src/config/permissions.ts rename to merged-packages/bitcoin-wallet-snap/src/permissions.ts index c84b4144..e8af8a24 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/permissions.ts @@ -1,5 +1,10 @@ import { KeyringRpcMethod } from '@metamask/keyring-api'; +export enum InternalRpcMethod { + GetTransactionStatus = 'chain_getTransactionStatus', + CreateAccount = 'chain_createAccount', +} + const dappPermissions = new Set([ // Keyring methods KeyringRpcMethod.ListAccounts, @@ -14,7 +19,7 @@ const dappPermissions = new Set([ KeyringRpcMethod.RejectRequest, KeyringRpcMethod.SubmitRequest, // Chain API methods - 'chain_getTransactionStatus', + InternalRpcMethod.GetTransactionStatus, ]); const metamaskPermissions = new Set([ @@ -32,11 +37,10 @@ const metamaskPermissions = new Set([ KeyringRpcMethod.SubmitRequest, KeyringRpcMethod.GetAccountBalances, // Chain API methods - 'chain_getTransactionStatus', + InternalRpcMethod.GetTransactionStatus, ]); const allowedOrigins = [ - 'https://metamask.github.io', 'https://portfolio.metamask.io', 'https://portfolio-builds.metafi-dev.codefi.network', 'https://dev.portfolio.metamask.io', @@ -53,5 +57,5 @@ for (const origin of allowedOrigins) { originPermissions.set(metamask, metamaskPermissions); originPermissions.set( local, - new Set([...dappPermissions, 'chain_createAccount']), + new Set([...dappPermissions, InternalRpcMethod.CreateAccount]), ); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts index 166de9bb..b8b1ce06 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts @@ -1,43 +1,36 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import type { Infer } from 'superstruct'; +import { object, type Infer } from 'superstruct'; import { Config } from '../config'; -import { BtcKeyring, KeyringStateManager } from '../keyring'; -import { SnapRpcHandlerRequestStruct, BaseSnapRpcHandler } from '../libs/rpc'; -import type { - IStaticSnapRpcHandler, - SnapRpcHandlerResponse, -} from '../libs/rpc'; -import type { StaticImplements } from '../types/static'; +import { BtcKeyring } from '../keyring'; +import { KeyringStateManager } from '../stateManagement'; +import { scopeStruct } from '../utils'; -export type CreateAccountParams = Infer< - typeof CreateAccountHandler.requestStruct ->; +export const CreateAccountParamsStruct = object({ + scope: scopeStruct, +}); -export type CreateAccountResponse = SnapRpcHandlerResponse & KeyringAccount; +export type CreateAccountParams = Infer; -export class CreateAccountHandler - extends BaseSnapRpcHandler - implements - StaticImplements -{ - static override get requestStruct() { - return SnapRpcHandlerRequestStruct; - } +export type CreateAccountResponse = KeyringAccount; - async handleRequest( - params: CreateAccountParams, - ): Promise { - const keyring = new BtcKeyring(new KeyringStateManager(), { - defaultIndex: Config.wallet[Config.chain].defaultAccountIndex, - multiAccount: Config.wallet[Config.chain].enableMultiAccounts, - emitEvents: false, - }); +/** + * Creates a new account with the specified parameters. + * + * @param params - The parameters for creating the account. + * @returns A Promise that resolves to the new account. + */ +export async function createAccount( + params: CreateAccountParams, +): Promise { + const keyring = new BtcKeyring(new KeyringStateManager(), { + defaultIndex: Config.wallet.defaultAccountIndex, + emitEvents: false, + }); - const account = await keyring.createAccount({ - scope: params.scope, - }); + const account = await keyring.createAccount({ + scope: params.scope, + }); - return account; - } + return account; } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts index 7786d8fd..3001f5b2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts @@ -4,186 +4,186 @@ import { networks } from 'bitcoinjs-lib'; import { v4 as uuidv4 } from 'uuid'; import { generateAccounts } from '../../test/utils'; -import { Network, ScriptType } from '../bitcoin/constants'; -import { - BtcAccountBip32Deriver, - BtcWallet, - BtcAmount, -} from '../bitcoin/wallet'; +import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; import { Config } from '../config'; +import { Caip2ChainId } from '../constants'; import { Factory } from '../factory'; -import { GetBalancesHandler } from './get-balances'; - -jest.mock('../libs/logger/logger'); -jest.mock('../libs/snap/helpers'); - -describe('GetBalancesHandler', () => { - const asset = Config.avaliableAssets[Config.chain][0]; - - describe('handleRequest', () => { - const createMockChainApiFactory = () => { - const getBalancesSpy = jest.fn(); - - jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ - getFeeRates: jest.fn(), - getBalances: getBalancesSpy, - broadcastTransaction: jest.fn(), - listTransactions: jest.fn(), - getTransactionStatus: jest.fn(), - getDataForTransaction: jest.fn(), - }); - return { - getBalancesSpy, - }; - }; +import { getBalances } from './get-balances'; + +jest.mock('../utils/logger'); +jest.mock('../utils/snap'); - const createMockDeriver = (network) => { - const rootSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getRoot'); - const childSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getChild'); +describe('getBalances', () => { + const asset = Config.avaliableAssets[0]; - return { - instance: new BtcAccountBip32Deriver(network), - rootSpy, - childSpy, - }; + const createMockChainApiFactory = () => { + const getBalancesSpy = jest.fn(); + + jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ + getFeeRates: jest.fn(), + getBalances: getBalancesSpy, + broadcastTransaction: jest.fn(), + listTransactions: jest.fn(), + getTransactionStatus: jest.fn(), + getDataForTransaction: jest.fn(), + }); + return { + getBalancesSpy, }; + }; - const createMockAccount = async (network, caip2ChainId) => { - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const sender = await wallet.unlock(0, ScriptType.P2wpkh); - const keyringAccount = { - type: sender.type, - id: uuidv4(), - address: sender.address, - options: { - scope: caip2ChainId, - index: sender.index, - }, - methods: ['btc_sendmany'], - }; - - const walletData = { - account: keyringAccount as unknown as KeyringAccount, - hdPath: sender.hdPath, - index: sender.index, + const createMockDeriver = (network) => { + const rootSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getRoot'); + const childSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getChild'); + + return { + instance: new BtcAccountDeriver(network), + rootSpy, + childSpy, + }; + }; + + const createMockAccount = async (network, caip2ChainId) => { + const { instance } = createMockDeriver(network); + const wallet = new BtcWallet(instance, network); + const sender = await wallet.unlock(0, Config.wallet.defaultAccountType); + const keyringAccount = { + type: sender.type, + id: uuidv4(), + address: sender.address, + options: { scope: caip2ChainId, - }; + index: sender.index, + }, + methods: ['btc_sendmany'], + }; - return { - keyringAccount, - walletData, - sender, - }; + const walletData = { + account: keyringAccount as unknown as KeyringAccount, + hdPath: sender.hdPath, + index: sender.index, + scope: caip2ChainId, }; - it('gets balances', async () => { - const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); - - const { walletData } = await createMockAccount(network, caip2ChainId); - - const addresses = [walletData.account.address]; - const mockResp = { - balances: addresses.reduce((acc, address) => { - acc[address] = { - [asset]: { - amount: new BtcAmount(100), - }, - }; - return acc; - }, {}), - }; - - const expected = { - [asset]: { - amount: '0.00000100', - unit: Config.unit[Config.chain], - }, - }; - - getBalancesSpy.mockResolvedValue(mockResp); - - const result = await GetBalancesHandler.getInstance(walletData).execute({ - scope: walletData.scope, - assets: [asset], - }); + return { + keyringAccount, + walletData, + sender, + }; + }; + + it('gets balances', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { getBalancesSpy } = createMockChainApiFactory(); + + const { walletData, sender } = await createMockAccount( + network, + caip2ChainId, + ); + + const addresses = [walletData.account.address]; + const mockResp = { + balances: addresses.reduce((acc, address) => { + acc[address] = { + [asset]: { + amount: BigInt(100), + }, + }; + return acc; + }, {}), + }; - expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [asset]); - expect(result).toStrictEqual(expected); - }); + const expected = { + [asset]: { + amount: '0.00000100', + unit: Config.unit, + }, + }; - it('gets balances of the request account only', async () => { - const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); - const accounts = generateAccounts(10); - const { walletData } = await createMockAccount(network, caip2ChainId); - - const addresses = [walletData.account.address]; - const mockResp = { - balances: [ - ...addresses, - ...accounts.map((account) => account.address), - ].reduce((acc, address) => { - acc[address] = { - [asset]: { - amount: new BtcAmount(100), - }, - 'some-asset': { - amount: new BtcAmount(100), - }, - }; - return acc; - }, {}), - }; - - const expected = { - [asset]: { - amount: '0.00000100', - unit: Config.unit[Config.chain], - }, - }; - - getBalancesSpy.mockResolvedValue(mockResp); - - const result = await GetBalancesHandler.getInstance(walletData).execute({ - scope: Network.Testnet, - assets: [asset], - }); + getBalancesSpy.mockResolvedValue(mockResp); - expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [asset]); - expect(result).toStrictEqual(expected); + const result = await getBalances(sender, { + scope: walletData.scope, + assets: [asset], }); - it('throws `Fail to get the balances` when transaction status fetch failed', async () => { - const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); - const { walletData } = await createMockAccount(network, caip2ChainId); + expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [asset]); + expect(result).toStrictEqual(expected); + }); - getBalancesSpy.mockRejectedValue(new Error('error')); + it('gets balances of the request account only', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { getBalancesSpy } = createMockChainApiFactory(); + const accounts = generateAccounts(10); + const { walletData, sender } = await createMockAccount( + network, + caip2ChainId, + ); + + const addresses = [walletData.account.address]; + const mockResp = { + balances: [ + ...addresses, + ...accounts.map((account) => account.address), + ].reduce((acc, address) => { + acc[address] = { + [asset]: { + amount: BigInt(100), + }, + 'some-asset': { + amount: BigInt(100), + }, + }; + return acc; + }, {}), + }; - await expect( - GetBalancesHandler.getInstance(walletData).execute({ - scope: Network.Testnet, - assets: [asset], - }), - ).rejects.toThrow(`Fail to get the balances`); - }); + const expected = { + [asset]: { + amount: '0.00000100', + unit: Config.unit, + }, + }; + + getBalancesSpy.mockResolvedValue(mockResp); - it('throws `Request params is invalid` when request parameter is not correct', async () => { - const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { walletData } = await createMockAccount(network, caip2ChainId); - - await expect( - GetBalancesHandler.getInstance(walletData).execute({ - scope: Network.Testnet, - assets: ['some-asset'], - }), - ).rejects.toThrow(InvalidParamsError); + const result = await getBalances(sender, { + scope: Caip2ChainId.Testnet, + assets: [asset], }); + + expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [asset]); + expect(result).toStrictEqual(expected); + }); + + it('throws `Fail to get the balances` when transaction status fetch failed', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { getBalancesSpy } = createMockChainApiFactory(); + const { sender } = await createMockAccount(network, caip2ChainId); + + getBalancesSpy.mockRejectedValue(new Error('error')); + + await expect( + getBalances(sender, { + scope: Caip2ChainId.Testnet, + assets: [asset], + }), + ).rejects.toThrow(`Fail to get the balances`); + }); + + it('throws `Request params is invalid` when request parameter is not correct', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { sender } = await createMockAccount(network, caip2ChainId); + + await expect( + getBalances(sender, { + scope: Caip2ChainId.Testnet, + assets: ['some-asset'], + }), + ).rejects.toThrow(InvalidParamsError); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts index 60c698f7..412a8238 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts @@ -1,99 +1,108 @@ import type { Infer } from 'superstruct'; -import { object, assign, array, record, enums } from 'superstruct'; +import { object, array, record, enums, assert } from 'superstruct'; import { Config } from '../config'; import { Factory } from '../factory'; -import { type Wallet as WalletData } from '../keyring'; -import { SnapRpcError, SnapRpcHandlerRequestStruct } from '../libs/rpc'; -import type { - IStaticSnapRpcHandler, - SnapRpcHandlerResponse, -} from '../libs/rpc'; -import type { StaticImplements } from '../types/static'; -import { assetsStruct, positiveStringStruct } from '../utils/superstruct'; -import type { IAmount } from '../wallet'; -import { KeyringRpcHandler } from './keyring-rpc'; - -export type GetBalancesParams = Infer; - -export type GetBalancesResponse = SnapRpcHandlerResponse & - Infer; - -export class GetBalancesHandler - extends KeyringRpcHandler - implements StaticImplements -{ - protected override isThrowValidationError = true; - - static override get requestStruct() { - return assign( - object({ - assets: array(assetsStruct), - }), - SnapRpcHandlerRequestStruct, - ); - } +import { + isSnapRpcError, + validateRequest, + validateResponse, + logger, + satsToBtc, +} from '../utils'; +import { + assetsStruct, + positiveStringStruct, + scopeStruct, +} from '../utils/superstruct'; +import type { IAccount } from '../wallet'; - static override get responseStruct() { - const unit = Config.unit[Config.chain]; - return record( - assetsStruct, - object({ - amount: positiveStringStruct, - unit: enums([unit]), - }), - ); - } +export const getBalancesRequestStruct = object({ + assets: array(assetsStruct), + scope: scopeStruct, +}); - constructor(walletData: WalletData) { - super(); - this.walletData = walletData; - } +export const getBalancesResponseStruct = object({ + assets: record( + assetsStruct, + object({ + amount: positiveStringStruct, + unit: enums([Config.unit]), + }), + ), +}); + +export type GetBalancesParams = Infer; + +export type GetBalancesResponse = Infer; + +/** + * Get Balances by a given account. + * + * @param account - The account to get the balances. + * @param params - The parameters for get the account. + * @returns A Promise that resolves to an GetBalancesResponse object. + */ +export async function getBalances( + account: IAccount, + params: GetBalancesParams, +) { + try { + validateRequest(params, getBalancesRequestStruct); + + assert(params, getBalancesRequestStruct); - async handleRequest(params: GetBalancesParams): Promise { - try { - const { scope, assets } = params; + const { assets, scope } = params; - const chainApi = Factory.createOnChainServiceProvider(scope); - const addresses = [this.walletAccount.address]; - const addressesSet = new Set(addresses); - const assetsSet = new Set(assets); + const chainApi = Factory.createOnChainServiceProvider(scope); + const addresses = [account.address]; + const addressesSet = new Set(addresses); + const assetsSet = new Set(assets); - const balances = await chainApi.getBalances(addresses, assets); + const balances = await chainApi.getBalances(addresses, assets); - const balancesVals = Object.entries(balances.balances); - const balancesMap = new Map(); + const balancesVals = Object.entries(balances.balances); + const balancesMap = new Map(); - for (const [address, assetBalances] of balancesVals) { - if (!addressesSet.has(address)) { + for (const [address, assetBalances] of balancesVals) { + if (!addressesSet.has(address)) { + continue; + } + for (const asset in assetBalances) { + if (!assetsSet.has(asset)) { continue; } - for (const asset in assetBalances) { - if (!assetsSet.has(asset)) { - continue; - } - - const { amount } = assetBalances[asset]; - const currentAmount = balancesMap.get(asset); - if (currentAmount) { - currentAmount.value += amount.value; - } - - balancesMap.set(asset, currentAmount ?? amount); + + const { amount } = assetBalances[asset]; + let currentAmount = balancesMap.get(asset); + if (currentAmount) { + currentAmount += amount; } + + balancesMap.set(asset, currentAmount ?? amount); } + } - return Object.fromEntries( - [...balancesMap.entries()].map(([asset, amount]) => [ - asset, - { - amount: amount.toString(), - unit: amount.unit, - }, - ]), - ); - } catch (error) { - throw new SnapRpcError('Fail to get the balances'); + const resp = Object.fromEntries( + [...balancesMap.entries()].map(([asset, amount]) => [ + asset, + { + amount: satsToBtc(amount), + unit: Config.unit, + }, + ]), + ); + + validateResponse(params, getBalancesRequestStruct); + + return resp; + } catch (error) { + logger.error('Failed to get balances', error); + + if (isSnapRpcError(error)) { + throw error as unknown as Error; } + + throw new Error('Fail to get the balances'); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts index 36017ab5..8271c47c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts @@ -1,72 +1,71 @@ import { InvalidParamsError } from '@metamask/snaps-sdk'; -import { Network } from '../bitcoin/constants'; import { TransactionStatus } from '../chain'; +import { Caip2ChainId } from '../constants'; import { Factory } from '../factory'; -import { GetTransactionStatusHandler } from './get-transaction-status'; +import { getTransactionStatus } from './get-transaction-status'; -jest.mock('../libs/logger/logger'); +jest.mock('../utils/logger'); -describe('GetBalancesHandler', () => { +describe('getTransactionStatus', () => { const txHash = '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; - describe('handleRequest', () => { - const createMockChainApiFactory = () => { - const getTransactionStatusSpy = jest.fn(); + const createMockChainApiFactory = () => { + const getTransactionStatusSpy = jest.fn(); - jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ - getFeeRates: jest.fn(), - getBalances: jest.fn(), - broadcastTransaction: jest.fn(), - listTransactions: jest.fn(), - getTransactionStatus: getTransactionStatusSpy, - getDataForTransaction: jest.fn(), - }); - return { - getTransactionStatusSpy, - }; + jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ + getFeeRates: jest.fn(), + getBalances: jest.fn(), + broadcastTransaction: jest.fn(), + listTransactions: jest.fn(), + getTransactionStatus: getTransactionStatusSpy, + getDataForTransaction: jest.fn(), + }); + return { + getTransactionStatusSpy, }; + }; - it('gets status', async () => { - const { getTransactionStatusSpy } = createMockChainApiFactory(); + it('gets status', async () => { + const { getTransactionStatusSpy } = createMockChainApiFactory(); - const mockResp = { - status: TransactionStatus.Confirmed, - }; + const mockResp = { + status: TransactionStatus.Confirmed, + }; - getTransactionStatusSpy.mockResolvedValue(mockResp); + getTransactionStatusSpy.mockResolvedValue(mockResp); - const result = await GetTransactionStatusHandler.getInstance().execute({ - scope: Network.Testnet, - transactionId: txHash, - }); + const result = await getTransactionStatus({ + scope: Caip2ChainId.Testnet, + transactionId: txHash, + }); - expect(getTransactionStatusSpy).toHaveBeenCalledWith(txHash); - expect(result).toStrictEqual({ - status: TransactionStatus.Confirmed, - }); + expect(getTransactionStatusSpy).toHaveBeenCalledWith(txHash); + expect(result).toStrictEqual({ + status: TransactionStatus.Confirmed, }); + }); - it('throws `Fail to get the transaction status` when transaction status fetch failed', async () => { - const { getTransactionStatusSpy } = createMockChainApiFactory(); + it('throws `Fail to get the transaction status` when transaction status fetch failed', async () => { + const { getTransactionStatusSpy } = createMockChainApiFactory(); - getTransactionStatusSpy.mockRejectedValue(new Error('error')); + getTransactionStatusSpy.mockRejectedValue(new Error('error')); - await expect( - GetTransactionStatusHandler.getInstance().execute({ - scope: Network.Testnet, - transactionId: txHash, - }), - ).rejects.toThrow(`Fail to get the transaction status`); - }); + await expect( + getTransactionStatus({ + scope: Caip2ChainId.Testnet, + transactionId: txHash, + }), + ).rejects.toThrow(`Fail to get the transaction status`); + }); - it('throws `Request params is invalid` when request parameter is not correct', async () => { - await expect( - GetTransactionStatusHandler.getInstance().execute({ - scope: Network.Testnet, - }), - ).rejects.toThrow(InvalidParamsError); - }); + it('throws `Request params is invalid` when request parameter is not correct', async () => { + await expect( + getTransactionStatus({ + scope: 'some-scope', + transactionId: '', + }), + ).rejects.toThrow(InvalidParamsError); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts index 43474a33..5b7e89c8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts @@ -1,61 +1,65 @@ import type { Infer } from 'superstruct'; -import { object, string, assign, enums } from 'superstruct'; +import { object, string, enums } from 'superstruct'; import { TransactionStatus } from '../chain'; import { Factory } from '../factory'; import { - SnapRpcHandlerRequestStruct, - BaseSnapRpcHandler, - SnapRpcError, -} from '../libs/rpc'; -import type { - IStaticSnapRpcHandler, - SnapRpcHandlerResponse, -} from '../libs/rpc'; -import type { StaticImplements } from '../types/static'; + isSnapRpcError, + scopeStruct, + validateRequest, + validateResponse, + logger, +} from '../utils'; + +export const getTransactionStatusParamsRequestStruct = object({ + transactionId: string(), + scope: scopeStruct, +}); + +export const getTransactionStatusParamsResponseStruct = object({ + status: enums(Object.values(TransactionStatus)), +}); export type GetTransactionStatusParams = Infer< - typeof GetTransactionStatusHandler.requestStruct + typeof getTransactionStatusParamsRequestStruct >; -export type GetTransactionStatusResponse = SnapRpcHandlerResponse & - Infer; - -export class GetTransactionStatusHandler - extends BaseSnapRpcHandler - implements - StaticImplements -{ - static override get requestStruct() { - return assign( - object({ - transactionId: string(), - }), - SnapRpcHandlerRequestStruct, - ); - } +export type GetTransactionStatusResponse = Infer< + typeof getTransactionStatusParamsResponseStruct +>; - static override get responseStruct() { - return object({ - status: enums(Object.values(TransactionStatus)), - }); - } +/** + * Get Transaction Status by a given transaction id. + * + * @param params - The parameters for get the transaction status. + * @returns A Promise that resolves to an GetTransactionStatusResponse object. + */ +export async function getTransactionStatus( + params: GetTransactionStatusParams, +): Promise { + try { + validateRequest(params, getTransactionStatusParamsRequestStruct); - async handleRequest( - params: GetTransactionStatusParams, - ): Promise { - try { - const { scope, transactionId } = params; + const { scope, transactionId } = params; - const chainApi = Factory.createOnChainServiceProvider(scope); + const chainApi = Factory.createOnChainServiceProvider(scope); - const resp = await chainApi.getTransactionStatus(transactionId); + const txStatusResp = await chainApi.getTransactionStatus(transactionId); - return { - status: resp.status, - }; - } catch (error) { - throw new SnapRpcError('Fail to get the transaction status'); + const resp = { + status: txStatusResp.status, + }; + + validateResponse(resp, getTransactionStatusParamsResponseStruct); + + return resp; + } catch (error) { + logger.error('Failed to get transaction status', error); + + if (isSnapRpcError(error)) { + throw error as unknown as Error; } + + throw new Error('Fail to get the transaction status'); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts deleted file mode 100644 index 15329e6f..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { CreateAccountHandler } from './create-account'; -import { GetTransactionStatusHandler } from './get-transaction-status'; -import { RpcHelper } from './helpers'; -import { SendManyHandler } from './sendmany'; - -describe('RpcHelper', () => { - describe('getChainRpcApiHandlers', () => { - it('returns handler', () => { - expect(RpcHelper.getChainRpcApiHandlers()).toStrictEqual({ - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_createAccount: CreateAccountHandler, - // // eslint-disable-next-line @typescript-eslint/naming-convention - // chain_getBalances: GetBalancesHandler, - // // eslint-disable-next-line @typescript-eslint/naming-convention - // chain_broadcastTransaction: BroadcastTransactionHandler, - // // eslint-disable-next-line @typescript-eslint/naming-convention - // chain_getDataForTransaction: GetTransactionDataHandler, - // // eslint-disable-next-line @typescript-eslint/naming-convention - // chain_estimateFees: EstimateFeesHandler, - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_getTransactionStatus: GetTransactionStatusHandler, - }); - }); - }); - - describe('getKeyringRpcApiHandlers', () => { - it('returns handler', () => { - expect(RpcHelper.getKeyringRpcApiHandlers()).toStrictEqual({ - // eslint-disable-next-line @typescript-eslint/naming-convention - btc_sendmany: SendManyHandler, - }); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts deleted file mode 100644 index e567e284..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { CreateAccountHandler } from '.'; -import type { IStaticSnapRpcHandler } from '../libs/rpc'; -import { GetTransactionStatusHandler } from './get-transaction-status'; -import { SendManyHandler } from './sendmany'; - -export class RpcHelper { - static getChainRpcApiHandlers(): Record { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_createAccount: CreateAccountHandler, - // // eslint-disable-next-line @typescript-eslint/naming-convention - // chain_getBalances: GetBalancesHandler, - // // eslint-disable-next-line @typescript-eslint/naming-convention - // chain_broadcastTransaction: BroadcastTransactionHandler, - // // eslint-disable-next-line @typescript-eslint/naming-convention - // chain_getDataForTransaction: GetTransactionDataHandler, - // // eslint-disable-next-line @typescript-eslint/naming-convention - // chain_estimateFees: EstimateFeesHandler, - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_getTransactionStatus: GetTransactionStatusHandler, - }; - } - - static getKeyringRpcApiHandlers(): Record { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - btc_sendmany: SendManyHandler, - }; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts index 53521af2..5dd3f86e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts @@ -1,3 +1,4 @@ export * from './create-account'; export * from './get-balances'; -export * from './helpers'; +export * from './get-transaction-status'; +export * from './sendmany'; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts deleted file mode 100644 index 50d653c6..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Factory } from '../factory'; -import { type Wallet as WalletData } from '../keyring'; -import { BaseSnapRpcHandler } from '../libs/rpc'; -import type { SnapRpcHandlerRequest } from '../libs/rpc'; -import type { IAccount, IWallet } from '../wallet'; - -export abstract class KeyringRpcHandler extends BaseSnapRpcHandler { - walletData: WalletData; - - wallet: IWallet; - - walletAccount: IAccount; - - protected override async preExecute( - params: SnapRpcHandlerRequest, - ): Promise { - await super.preExecute(params); - - const { scope, index, account } = this.walletData; - const wallet = Factory.createWallet(scope); - const unlocked = await wallet.unlock(index, account.type); - - if (!unlocked || unlocked.address !== account.address) { - throw new Error('Account not found'); - } - - this.walletAccount = unlocked; - this.wallet = wallet; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index b8780d01..741fa218 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -1,4 +1,3 @@ -import type { Json } from '@metamask/snaps-sdk'; import { InvalidParamsError, UserRejectedRequestError, @@ -10,26 +9,22 @@ import { generateBlockChairBroadcastTransactionResp, generateBlockChairGetUtxosResp, } from '../../test/utils'; -import { DustLimit, Network, ScriptType } from '../bitcoin/constants'; -import { satsToBtc } from '../bitcoin/utils/unit'; -import type { IBtcAccount } from '../bitcoin/wallet'; -import { - BtcAccountBip32Deriver, - BtcWallet, - BtcAmount, -} from '../bitcoin/wallet'; +import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; import { FeeRatio } from '../chain'; +import { Config } from '../config'; +import { Caip2ChainId } from '../constants'; import { Factory } from '../factory'; -import { SnapHelper } from '../libs/snap'; +import { getExplorerUrl, shortenAddress } from '../utils'; +import * as snapUtils from '../utils/snap'; +import { satsToBtc } from '../utils/unit'; import type { IAccount, ITxInfo } from '../wallet'; -import { SendManyHandler } from './sendmany'; -import type { SendManyParams } from './sendmany'; +import { type SendManyParams, sendMany } from './sendmany'; -jest.mock('../libs/logger/logger'); -jest.mock('../libs/snap/helpers'); +jest.mock('../utils/logger'); +jest.mock('../utils/snap'); describe('SendManyHandler', () => { - describe('handleRequest', () => { + describe('sendMany', () => { const createMockChainApiFactory = () => { const getDataForTransactionSpy = jest.fn(); const getFeeRatesSpy = jest.fn(); @@ -52,7 +47,7 @@ describe('SendManyHandler', () => { const createMockDeriver = (network) => { return { - instance: new BtcAccountBip32Deriver(network), + instance: new BtcAccountDeriver(network), }; }; @@ -63,7 +58,7 @@ describe('SendManyHandler', () => { ) => { const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); - const sender = await wallet.unlock(0, ScriptType.P2wpkh); + const sender = await wallet.unlock(0, Config.wallet.defaultAccountType); const keyringAccount = { type: sender.type, @@ -77,7 +72,9 @@ describe('SendManyHandler', () => { }; const recipients: IAccount[] = []; for (let i = 1; i < recipientCnt + 1; i++) { - recipients.push(await wallet.unlock(i, ScriptType.P2wpkh)); + recipients.push( + await wallet.unlock(i, Config.wallet.defaultAccountType), + ); } return { @@ -94,10 +91,8 @@ describe('SendManyHandler', () => { comment = '', ): SendManyParams => { return { - amounts: recipients.reduce((acc, recipient: IBtcAccount) => { - acc[recipient.address] = satsToBtc( - DustLimit[recipient.scriptType] + 1, - ); + amounts: recipients.reduce((acc, recipient) => { + acc[recipient.address] = satsToBtc(500); return acc; }, {}), comment, @@ -136,7 +131,7 @@ describe('SendManyHandler', () => { getFeeRatesSpy, broadcastTransactionSpy, } = createMockChainApiFactory(); - const snapHelperSpy = jest.spyOn(SnapHelper, 'confirmDialog'); + const snapHelperSpy = jest.spyOn(snapUtils, 'confirmDialog'); const { sender, keyringAccount, recipients } = await createSenderNRecipients(network, caip2ChainId, 2); @@ -152,7 +147,7 @@ describe('SendManyHandler', () => { fees: [ { type: FeeRatio.Fast, - rate: new BtcAmount(1), + rate: BigInt(1), }, ], }); @@ -175,9 +170,9 @@ describe('SendManyHandler', () => { it('returns correct result', async () => { const network = networks.testnet; - const caip2ChainId = Network.Testnet; + const caip2ChainId = Caip2ChainId.Testnet; const { - keyringAccount, + sender, recipients, broadcastResp, getDataForTransactionSpy, @@ -185,11 +180,10 @@ describe('SendManyHandler', () => { broadcastTransactionSpy, } = await prepareSendMany(network, caip2ChainId); - const result = await SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2ChainId, false)); + const result = await sendMany( + sender, + createSendManyParams(recipients, caip2ChainId, false), + ); expect(result).toStrictEqual({ txId: broadcastResp }); expect(getFeeRatesSpy).toHaveBeenCalledTimes(1); @@ -199,30 +193,28 @@ describe('SendManyHandler', () => { it('does not broadcast transaction if in dryrun mode', async () => { const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { keyringAccount, recipients, broadcastTransactionSpy } = + const caip2ChainId = Caip2ChainId.Testnet; + const { recipients, sender, broadcastTransactionSpy } = await prepareSendMany(network, caip2ChainId); - await SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2ChainId, true)); + await sendMany( + sender, + createSendManyParams(recipients, caip2ChainId, true), + ); expect(broadcastTransactionSpy).toHaveBeenCalledTimes(0); }); it('does create comment component in dialog if consumer has provide the comment', async () => { const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { keyringAccount, recipients, snapHelperSpy } = - await prepareSendMany(network, caip2ChainId); + const caip2ChainId = Caip2ChainId.Testnet; + const { sender, recipients, snapHelperSpy } = await prepareSendMany( + network, + caip2ChainId, + ); - await SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute( + await sendMany( + sender, createSendManyParams(recipients, caip2ChainId, true, 'test comment'), ); @@ -254,9 +246,11 @@ describe('SendManyHandler', () => { it('display `Recipient` as label in dialog if there is only 1 recipient', async () => { const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { keyringAccount, recipients, snapHelperSpy, sender } = - await prepareSendMany(network, caip2ChainId); + const caip2ChainId = Caip2ChainId.Testnet; + const { recipients, snapHelperSpy, sender } = await prepareSendMany( + network, + caip2ChainId, + ); const walletCreateTxSpy = jest.spyOn( BtcWallet.prototype, 'createTransaction', @@ -267,22 +261,16 @@ describe('SendManyHandler', () => { ); const info: ITxInfo = { - toJson>() { - return { - feeRate: `0.00000001 BTC`, - txFee: `0.00000001 BTC`, - sender: sender.address, - recipients: [ - { - address: recipients[0].address, - value: `0.000010 BTC`, - explorerUrl: `https://blockchair.com/bitcoin/transaction/transactionId`, - }, - ], - changes: [], - total: `0.000010 BTC`, - } as unknown as TxInfoJson; - }, + feeRate: BigInt('1'), + txFee: BigInt('1'), + sender: sender.address, + recipients: [ + { + address: recipients[0].address, + value: BigInt('1000'), + }, + ], + total: BigInt('1000'), }; walletCreateTxSpy.mockResolvedValue({ @@ -292,11 +280,10 @@ describe('SendManyHandler', () => { walletSignTxSpy.mockResolvedValue('txId'); - await SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute(createSendManyParams([recipients[0]], caip2ChainId, true)); + await sendMany( + sender, + createSendManyParams([recipients[0]], caip2ChainId, true), + ); const calls = snapHelperSpy.mock.calls[0][0]; @@ -310,69 +297,103 @@ describe('SendManyHandler', () => { label: 'Recipient', value: { type: 'text', - value: `[${recipients[0].address}](https://blockchair.com/bitcoin/transaction/transactionId)`, + value: `[${shortenAddress( + recipients[0].address, + )}](${getExplorerUrl(recipients[0].address, caip2ChainId)})`, }, }, { type: 'row', label: 'Amount', - value: { markdown: false, type: 'text', value: '0.000010 BTC' }, + value: { markdown: false, type: 'text', value: '0.00001000 BTC' }, }, ], }); }); - it('throws `Request params is invalid` error when request parameter is not correct', async () => { - createMockChainApiFactory(); + it('throws InvalidParamsError when request parameter is not correct', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { sender } = await prepareSendMany(network, caip2ChainId); await expect( - SendManyHandler.getInstance().execute({ - scope: Network.Testnet, - }), + sendMany(sender, { + amounts: { + 'some-address': '1', + }, + } as unknown as SendManyParams), ).rejects.toThrow(InvalidParamsError); }); - it('throws `Account not found` error when given address not match', async () => { + it('throws `Transaction must have at least one recipient` error if no recipient provided', async () => { createMockChainApiFactory(); const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { keyringAccount, recipients } = await createSenderNRecipients( + const caip2ChainId = Caip2ChainId.Testnet; + const { recipients, sender } = await createSenderNRecipients( network, caip2ChainId, - 2, + 0, ); await expect( - SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 20, - account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2ChainId, false)), - ).rejects.toThrow('Account not found'); + sendMany(sender, createSendManyParams(recipients, caip2ChainId, false)), + ).rejects.toThrow('Transaction must have at least one recipient'); }); - it('throws `Transaction must have at least one recipient` error if no recipient provided', async () => { + it('throws `Invalid amount for send` error if receive amount is not valid', async () => { createMockChainApiFactory(); const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { keyringAccount, recipients } = await createSenderNRecipients( + const caip2ChainId = Caip2ChainId.Testnet; + const { recipients, sender } = await createSenderNRecipients( network, caip2ChainId, - 0, + 2, ); await expect( - SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2ChainId, false)), - ).rejects.toThrow('Transaction must have at least one recipient'); + sendMany(sender, { + ...createSendManyParams(recipients, caip2ChainId, false), + amounts: { + [recipients[0].address]: 'invalid', + [recipients[1].address]: '0.1', + }, + }), + ).rejects.toThrow('Invalid amount for send'); + + await expect( + sendMany(sender, { + ...createSendManyParams(recipients, caip2ChainId, false), + amounts: { + [recipients[0].address]: '0', + [recipients[1].address]: '0.1', + }, + }), + ).rejects.toThrow('Invalid amount for send'); + + await expect( + sendMany(sender, { + ...createSendManyParams(recipients, caip2ChainId, false), + amounts: { + [recipients[0].address]: 'invalid', + [recipients[1].address]: '0.000000019', + }, + }), + ).rejects.toThrow('Invalid amount for send'); + + await expect( + sendMany(sender, { + ...createSendManyParams(recipients, caip2ChainId, false), + amounts: { + [recipients[0].address]: '1', + [recipients[1].address]: '999999999.99999999', + }, + }), + ).rejects.toThrow('Invalid amount for send'); }); it('throws `Failed to send the transaction` error if no fee rate returns from chain service', async () => { const { getFeeRatesSpy } = createMockChainApiFactory(); const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { keyringAccount, recipients } = await createSenderNRecipients( + const caip2ChainId = Caip2ChainId.Testnet; + const { sender, recipients } = await createSenderNRecipients( network, caip2ChainId, 10, @@ -382,43 +403,14 @@ describe('SendManyHandler', () => { }); await expect( - SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2ChainId, false)), + sendMany(sender, createSendManyParams(recipients, caip2ChainId, false)), ).rejects.toThrow('Failed to send the transaction'); }); - it('throws `Invalid amount for send` error if sending amount is <= 0', async () => { - const network = networks.testnet; - const caip2ChainId = Network.Testnet; - createMockChainApiFactory(); - const { keyringAccount, recipients } = await createSenderNRecipients( - network, - caip2ChainId, - 2, - ); - - await expect( - SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute({ - ...createSendManyParams(recipients, caip2ChainId, false), - amounts: { - [recipients[0].address]: satsToBtc(500), - [recipients[1].address]: satsToBtc(0), - }, - }), - ).rejects.toThrow('Invalid amount for send'); - }); - it('throws `Invalid response` error if the response is unexpected', async () => { const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { keyringAccount, recipients, broadcastTransactionSpy } = + const caip2ChainId = Caip2ChainId.Testnet; + const { sender, recipients, broadcastTransactionSpy } = await prepareSendMany(network, caip2ChainId); broadcastTransactionSpy.mockResolvedValue({ @@ -427,43 +419,39 @@ describe('SendManyHandler', () => { }, }); await expect( - SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2ChainId, false)), + sendMany(sender, { + ...createSendManyParams(recipients, caip2ChainId, false), + }), ).rejects.toThrow('Invalid Response'); }); it('throws UserRejectedRequestError error if user denied the transaction', async () => { const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { snapHelperSpy, keyringAccount, recipients } = - await prepareSendMany(network, caip2ChainId); + const caip2ChainId = Caip2ChainId.Testnet; + const { snapHelperSpy, sender, recipients } = await prepareSendMany( + network, + caip2ChainId, + ); snapHelperSpy.mockResolvedValue(false); await expect( - SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2ChainId, false)), + sendMany(sender, { + ...createSendManyParams(recipients, caip2ChainId, false), + }), ).rejects.toThrow(UserRejectedRequestError); }); it('throws `Failed to send the transaction` error if the transaction is fail to commit', async () => { const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { broadcastTransactionSpy, keyringAccount, recipients } = + const caip2ChainId = Caip2ChainId.Testnet; + const { broadcastTransactionSpy, sender, recipients } = await prepareSendMany(network, caip2ChainId); broadcastTransactionSpy.mockRejectedValue(new Error('error')); await expect( - SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2ChainId, false)), + sendMany(sender, { + ...createSendManyParams(recipients, caip2ChainId, false), + }), ).rejects.toThrow('Failed to send the transaction'); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index ba68876b..5cc3ca58 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -11,7 +11,6 @@ import { import { object, string, - assign, type Infer, record, array, @@ -20,21 +19,21 @@ import { optional, } from 'superstruct'; -import { btcToSats } from '../bitcoin/utils'; import { TxValidationError } from '../bitcoin/wallet'; import { Factory } from '../factory'; -import { type Wallet as WalletData } from '../keyring'; -import { logger } from '../libs/logger/logger'; -import { SnapRpcHandlerRequestStruct } from '../libs/rpc'; -import type { IStaticSnapRpcHandler } from '../libs/rpc'; -import { SnapHelper } from '../libs/snap'; -import type { StaticImplements } from '../types/static'; -import type { ITxInfo } from '../wallet'; -import { KeyringRpcHandler } from './keyring-rpc'; - -export type SendManyParams = Infer; - -export type SendManyResponse = Infer; +import { + scopeStruct, + confirmDialog, + isSnapRpcError, + shortenAddress, + getExplorerUrl, + btcToSats, + satsToBtc, + validateRequest, + validateResponse, + logger, +} from '../utils'; +import type { IAccount, ITxInfo } from '../wallet'; export const TransactionAmountStuct = refine( record(BtcP2wpkhAddressStruct, string()), @@ -53,201 +52,206 @@ export const TransactionAmountStuct = refine( ) { return 'Invalid amount for send'; } + + try { + btcToSats(val); + } catch (error) { + return 'Invalid amount for send'; + } } return true; }, ); -export type TxJson = { - feeRate: string; - txFee: string; - total: string; - sender: string; - recipients: { - address: string; - explorerUrl: string; - value: string; - }[]; - changes: { - address: string; - value: string; - explorerUrl: string; - }[]; -}; - -export class SendManyHandler - extends KeyringRpcHandler - implements StaticImplements -{ - protected override isThrowValidationError = true; - - constructor(walletData: WalletData) { - super(); - this.walletData = walletData; - } +export const sendManyParamsStruct = object({ + amounts: TransactionAmountStuct, + comment: string(), + subtractFeeFrom: array(BtcP2wpkhAddressStruct), + replaceable: boolean(), + dryrun: optional(boolean()), + scope: scopeStruct, +}); + +export const sendManyResponseStruct = object({ + txId: string(), + txHash: optional(string()), +}); + +export type SendManyParams = Infer; + +export type SendManyResponse = Infer; + +/** + * Send BTC to multiple account. + * + * @param account - The account to send the transaction. + * @param params - The parameters for send the transaction. + * @returns A Promise that resolves to an SendManyResponse object. + */ +export async function sendMany(account: IAccount, params: SendManyParams) { + try { + validateRequest(params, sendManyParamsStruct); + + const { dryrun, scope } = params; + const chainApi = Factory.createOnChainServiceProvider(scope); + const wallet = Factory.createWallet(scope); + + const feesResp = await chainApi.getFeeRates(); + + if (feesResp.fees.length === 0) { + throw new Error('No fee rates available'); + } - static override get requestStruct() { - return assign( - object({ - amounts: TransactionAmountStuct, - comment: string(), - subtractFeeFrom: array(BtcP2wpkhAddressStruct), - replaceable: boolean(), - dryrun: boolean(), + const fee = Math.max( + Number(feesResp.fees[feesResp.fees.length - 1].rate), + 1, + ); + + const recipients = Object.entries(params.amounts).map( + ([address, value]) => ({ + address, + value: btcToSats(value), }), - SnapRpcHandlerRequestStruct, ); - } - static override get responseStruct() { - return object({ - txId: string(), - txHash: optional(string()), + const metadata = await chainApi.getDataForTransaction(account.address); + + const { tx, txInfo } = await wallet.createTransaction(account, recipients, { + utxos: metadata.data.utxos, + fee, + subtractFeeFrom: params.subtractFeeFrom, + replaceable: params.replaceable, }); - } - async handleRequest(params: SendManyParams): Promise { - try { - const { scope } = this.walletData; - const { dryrun } = params; - const chainApi = Factory.createOnChainServiceProvider(scope); + if (!(await getTxConsensus(txInfo, params.comment, scope))) { + throw new UserRejectedRequestError() as unknown as Error; + } - const feesResp = await chainApi.getFeeRates(); + const txHash = await wallet.signTransaction(account.signer, tx); - if (feesResp.fees.length === 0) { - throw new Error('No fee rates available'); - } + if (dryrun) { + return { + txId: '', + txHash, + }; + } - const fee = Math.max( - feesResp.fees[feesResp.fees.length - 1].rate.value, - 1, - ); - - const recipients = Object.entries(params.amounts).map( - ([address, value]) => ({ - address, - value: parseInt(btcToSats(parseFloat(value)), 10), - }), - ); - - const metadata = await chainApi.getDataForTransaction( - this.walletAccount.address, - ); - - const { tx, txInfo } = await this.wallet.createTransaction( - this.walletAccount, - recipients, - { - utxos: metadata.data.utxos, - fee, - subtractFeeFrom: params.subtractFeeFrom, - replaceable: params.replaceable, - }, - ); - - if (!(await this.getTxConsensus(txInfo, params.comment))) { - throw new UserRejectedRequestError() as unknown as Error; - } + const result = await chainApi.broadcastTransaction(txHash); - const txHash = await this.wallet.signTransaction( - this.walletAccount.signer, - tx, - ); + const resp = { + txId: result.transactionId, + }; - if (dryrun) { - return { - txId: '', - txHash, - }; - } + validateResponse(resp, sendManyResponseStruct); - const result = await chainApi.broadcastTransaction(txHash); + return resp; + } catch (error) { + logger.error('Failed to send the transaction', error); - return { - txId: result.transactionId, - }; - } catch (error) { - logger.error('Failed to send the transaction', error); - if ( - error instanceof TxValidationError || - error instanceof UserRejectedRequestError - ) { - throw error as unknown as Error; - } - throw new Error('Failed to send the transaction'); + if (isSnapRpcError(error)) { + throw error as unknown as Error; + } + + if ( + error instanceof TxValidationError || + error instanceof UserRejectedRequestError + ) { + throw error as unknown as Error; } + + throw new Error('Failed to send the transaction'); } +} - protected async getTxConsensus( - txInfo: ITxInfo, - comment: string, - ): Promise { - const header = `Send Request`; - const intro = `Review the request before proceeding. Once the transaction is made, it's irreversible.`; - const recipientsLabel = `Recipient`; - const amountLabel = `Amount`; - const commentLabel = `Comment`; - // const networkFeeRateLabel = `Network fee rate`; - const networkFeeLabel = `Network fee`; - const totalLabel = `Total`; - const requestedByLable = `Requested by`; - - const components: Component[] = [ - panel([ - heading(header), - text(intro), - row( - requestedByLable, - text(`[portfolio.metamask.io](https://portfolio.metamask.io/)`), - ), - ]), - divider(), - ]; - - const info = txInfo.toJson(); - - const isMoreThanOneRecipient = - info.recipients.length + info.changes.length > 1; - - let i = 0; - - const addReciptentsToComponents = (data: { - address: string; - explorerUrl: string; - value: string; - }) => { - const recipientsPanel: Component[] = []; - recipientsPanel.push( - row( - isMoreThanOneRecipient - ? `${recipientsLabel} ${i + 1}` - : recipientsLabel, - text(`[${data.address}](${data.explorerUrl})`), +/** + * Display an confirmation dialog to confirm an transaction. + * + * @param info - The transaction data object contains the transaction information. + * @param comment - The comment text to display. + * @param scope - The CAIP-2 Chain ID. + * @returns A Promise that resolves to the response of the confirmation dialog. + */ +export async function getTxConsensus( + info: ITxInfo, + comment: string, + scope: string, +): Promise { + const header = `Send Request`; + const intro = `Review the request before proceeding. Once the transaction is made, it's irreversible.`; + const recipientsLabel = `Recipient`; + const amountLabel = `Amount`; + const commentLabel = `Comment`; + // const networkFeeRateLabel = `Network fee rate`; + const networkFeeLabel = `Network fee`; + const totalLabel = `Total`; + const requestedByLable = `Requested by`; + + const components: Component[] = [ + panel([ + heading(header), + text(intro), + row( + requestedByLable, + text(`[portfolio.metamask.io](https://portfolio.metamask.io/)`), + ), + ]), + divider(), + ]; + + const isMoreThanOneRecipient = + info.recipients.length + (info.change ? 1 : 0) > 1; + + let i = 0; + + const addReciptentsToComponents = (data: { + address: string; + value: bigint; + }) => { + const recipientsPanel: Component[] = []; + recipientsPanel.push( + row( + isMoreThanOneRecipient + ? `${recipientsLabel} ${i + 1}` + : recipientsLabel, + text( + `[${shortenAddress(data.address)}](${getExplorerUrl( + data.address, + scope, + )})`, ), - ); - recipientsPanel.push(row(amountLabel, text(data.value, false))); - i += 1; - components.push(panel(recipientsPanel)); - components.push(divider()); - }; + ), + ); + recipientsPanel.push( + row(amountLabel, text(satsToBtc(data.value, true), false)), + ); + i += 1; + components.push(panel(recipientsPanel)); + components.push(divider()); + }; - info.recipients.forEach(addReciptentsToComponents); - info.changes.forEach(addReciptentsToComponents); + info.recipients.forEach(addReciptentsToComponents); - const bottomPanel: Component[] = []; - if (comment.trim().length > 0) { - bottomPanel.push(row(commentLabel, text(comment.trim(), false))); - } + if (info.change) { + [info.change].forEach(addReciptentsToComponents); + } - bottomPanel.push(row(networkFeeLabel, text(`${info.txFee}`, false))); + const bottomPanel: Component[] = []; + if (comment.trim().length > 0) { + bottomPanel.push(row(commentLabel, text(comment.trim(), false))); + } - // bottomPanel.push(row(networkFeeRateLabel, text(`${info.feeRate}`, false))); + bottomPanel.push( + row(networkFeeLabel, text(`${satsToBtc(info.txFee, true)}`, false)), + ); - bottomPanel.push(row(totalLabel, text(`${info.total}`, false))); + // bottomPanel.push(row(networkFeeRateLabel, text(`${info.feeRate}`, false))); - components.push(panel(bottomPanel)); + bottomPanel.push( + row(totalLabel, text(`${satsToBtc(info.total, true)}`, false)), + ); - return (await SnapHelper.confirmDialog(components)) as boolean; - } + components.push(panel(bottomPanel)); + + return (await confirmDialog(components)) as boolean; } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts b/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts similarity index 90% rename from merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts rename to merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts index eeddcc04..883c0045 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts @@ -1,12 +1,12 @@ -import { generateAccounts } from '../../test/utils'; -import { Network } from '../bitcoin/constants'; -import { SnapHelper, StateError } from '../libs/snap'; -import { KeyringStateManager } from './state'; +import { generateAccounts } from '../test/utils'; +import { Caip2ChainId } from './constants'; +import { KeyringStateManager } from './stateManagement'; +import * as snapUtil from './utils/snap'; describe('KeyringStateManager', () => { const createMockStateManager = () => { - const getDataSpy = jest.spyOn(SnapHelper, 'getStateData'); - const setDataSpy = jest.spyOn(SnapHelper, 'setStateData'); + const getDataSpy = jest.spyOn(snapUtil, 'getStateData'); + const setDataSpy = jest.spyOn(snapUtil, 'setStateData'); return { instance: new KeyringStateManager(), getDataSpy, @@ -18,7 +18,7 @@ describe('KeyringStateManager', () => { return [`m`, `0'`, `0`, `${index}`].join('/'); }; - const createInitState = (cnt = 1, scope = Network.Testnet) => { + const createInitState = (cnt = 1, scope = Caip2ChainId.Testnet) => { const generatedAccounts = generateAccounts(cnt); return { walletIds: generatedAccounts.map((accounts) => accounts.id), @@ -82,11 +82,11 @@ describe('KeyringStateManager', () => { expect(result).toStrictEqual([]); }); - it('throw StateError if an error catched', async () => { + it('throw Error if an error catched', async () => { const { instance, getDataSpy } = createMockStateManager(); getDataSpy.mockRejectedValue(new Error('error')); - await expect(instance.listAccounts()).rejects.toThrow(StateError); + await expect(instance.listAccounts()).rejects.toThrow(Error); }); }); @@ -140,7 +140,7 @@ describe('KeyringStateManager', () => { }); }); - it('throw StateError if the given account id exist', async () => { + it('throw Error if the given account id exist', async () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); getDataSpy.mockResolvedValue(state); @@ -153,10 +153,10 @@ describe('KeyringStateManager', () => { index: accountToSave.index, scope: accountToSave.scope, }), - ).rejects.toThrow(StateError); + ).rejects.toThrow(Error); }); - it('throw StateError if the given account address exist', async () => { + it('throw Error if the given account address exist', async () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); getDataSpy.mockResolvedValue(state); @@ -194,7 +194,7 @@ describe('KeyringStateManager', () => { expect(state.wallets).not.toContain(testInput[1]); }); - it('throw StateError if the account does not exist', async () => { + it('throw Error if the account does not exist', async () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); const nonExistAcc = generateAccounts(1, 'notexist')[0]; @@ -208,13 +208,13 @@ describe('KeyringStateManager', () => { ); }); - it('throw StateError if an error catched', async () => { + it('throw Error if an error catched', async () => { const { instance, getDataSpy } = createMockStateManager(); getDataSpy.mockRejectedValue(new Error('error')); const state = createInitState(1); await expect(instance.removeAccounts(state.walletIds)).rejects.toThrow( - StateError, + Error, ); }); }); @@ -244,12 +244,12 @@ describe('KeyringStateManager', () => { expect(result).toBeNull(); }); - it('throw StateError if an error catched', async () => { + it('throw Error 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); + await expect(instance.getAccount(id)).rejects.toThrow(Error); }); }); @@ -278,7 +278,7 @@ describe('KeyringStateManager', () => { ); }); - it('throw StateError if the account does not exist', async () => { + it('throw Error if the account does not exist', async () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); const accToUpdate = generateAccounts(1, 'notexist')[0]; @@ -346,13 +346,13 @@ describe('KeyringStateManager', () => { expect(result).toBeNull(); }); - it('throw StateError if an error catched', async () => { + it('throw Error if an error catched', async () => { const { instance, getDataSpy } = createMockStateManager(); getDataSpy.mockRejectedValue(new Error('error')); const state = createInitState(20); const { id } = state.wallets[state.walletIds[0]].account; - await expect(instance.getWallet(id)).rejects.toThrow(StateError); + await expect(instance.getWallet(id)).rejects.toThrow(Error); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/state.ts b/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts similarity index 79% rename from merged-packages/bitcoin-wallet-snap/src/keyring/state.ts rename to merged-packages/bitcoin-wallet-snap/src/stateManagement.ts index 7a0ec24a..4292895b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts @@ -1,8 +1,20 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import { SnapStateManager, StateError } from '../libs/snap'; -import { compactError } from '../utils'; -import type { Wallet, SnapState } from './types'; +import { compactError, SnapStateManager } from './utils'; + +export type Wallet = { + account: KeyringAccount; + hdPath: string; + index: number; + scope: string; +}; + +export type Wallets = Record; + +export type SnapState = { + walletIds: string[]; + wallets: Wallets; +}; export class KeyringStateManager extends SnapStateManager { protected override async get(): Promise { @@ -32,7 +44,7 @@ export class KeyringStateManager extends SnapStateManager { const state = await this.get(); return state.walletIds.map((id) => state.wallets[id].account); } catch (error) { - throw compactError(error, StateError); + throw compactError(error, Error); } } @@ -44,14 +56,14 @@ export class KeyringStateManager extends SnapStateManager { this.isAccountExist(state, id) || this.getAccountByAddress(state, address) ) { - throw new StateError(`Account address ${address} already exists`); + throw new Error(`Account address ${address} already exists`); } state.wallets[id] = wallet; state.walletIds.push(id); }); } catch (error) { - throw compactError(error, StateError); + throw compactError(error, Error); } } @@ -59,7 +71,7 @@ export class KeyringStateManager extends SnapStateManager { try { await this.update(async (state: SnapState) => { if (!this.isAccountExist(state, account.id)) { - throw new StateError(`Account id ${account.id} does not exist`); + throw new Error(`Account id ${account.id} does not exist`); } const wallet = state.wallets[account.id]; @@ -70,13 +82,13 @@ export class KeyringStateManager extends SnapStateManager { account.address.toLowerCase() || accountInState.type !== account.type ) { - throw new StateError(`Account address or type is immutable`); + throw new Error(`Account address or type is immutable`); } state.wallets[account.id].account = account; }); } catch (error) { - throw compactError(error, StateError); + throw compactError(error, Error); } } @@ -87,7 +99,7 @@ export class KeyringStateManager extends SnapStateManager { for (const id of ids) { if (!this.isAccountExist(state, id)) { - throw new StateError(`Account id ${id} does not exist`); + throw new Error(`Account id ${id} does not exist`); } removeIds.add(id); } @@ -96,7 +108,7 @@ export class KeyringStateManager extends SnapStateManager { state.walletIds = state.walletIds.filter((id) => !removeIds.has(id)); }); } catch (error) { - throw compactError(error, StateError); + throw compactError(error, Error); } } @@ -105,7 +117,7 @@ export class KeyringStateManager extends SnapStateManager { const state = await this.get(); return state.wallets[id]?.account ?? null; } catch (error) { - throw compactError(error, StateError); + throw compactError(error, Error); } } @@ -114,7 +126,7 @@ export class KeyringStateManager extends SnapStateManager { const state = await this.get(); return state.wallets[id] ?? null; } catch (error) { - throw compactError(error, StateError); + throw compactError(error, Error); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/logger/__mocks__/logger.ts b/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/logger.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/libs/logger/__mocks__/logger.ts rename to merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/logger.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts new file mode 100644 index 00000000..1dd67d68 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts @@ -0,0 +1,30 @@ +import { type SLIP10NodeInterface } from '@metamask/key-tree'; +import { networks } from 'bitcoinjs-lib'; + +import { createRandomBip32Data } from '../../../test/utils'; + +/** + * Retrieves a SLIP10NodeInterface object for the specified path and curve. + * + * @param path - The BIP32 derivation path for which to retrieve a SLIP10NodeInterface. + * @param curve - The elliptic curve to use for key derivation. + * @returns A Promise that resolves to a SLIP10NodeInterface object. + */ +export async function getBip32Deriver( + path: string[], + curve: 'secp256k1' | 'ed25519', +): Promise { + const { data } = createRandomBip32Data(networks.bitcoin, path, curve); + return { + ...data, + toJSON: jest.fn().mockReturnValue(data), + } as SLIP10NodeInterface; +} + +export const getBip44Deriver = jest.fn(); + +export const confirmDialog = jest.fn(); + +export const getStateData = jest.fn(); + +export const setStateData = jest.fn(); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/error.ts b/merged-packages/bitcoin-wallet-snap/src/utils/error.ts index 9c20c537..c4b7081d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/error.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/error.ts @@ -18,6 +18,30 @@ import { SnapError, } from '@metamask/snaps-sdk'; +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); + } +} + /** * Compacts an error to a specific error instance. * diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/explorer.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/explorer.test.ts new file mode 100644 index 00000000..557545f4 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/explorer.test.ts @@ -0,0 +1,22 @@ +import { Caip2ChainId } from '../constants'; +import { getExplorerUrl } from './explorer'; + +describe('getExplorerUrl', () => { + const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; + + it('returns a testnet explorer url', () => { + const result = getExplorerUrl(address, Caip2ChainId.Testnet); + expect(result).toBe(`https://blockstream.info/testnet/address/${address}`); + }); + + it('returns a mainnet explorer url', () => { + const result = getExplorerUrl(address, Caip2ChainId.Mainnet); + expect(result).toBe(`https://blockstream.info/address/${address}`); + }); + + it('throws `Invalid Chain ID` error if the given Chain ID is not support', () => { + expect(() => getExplorerUrl(address, 'some Chain ID')).toThrow( + 'Invalid Chain ID', + ); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/explorer.ts b/merged-packages/bitcoin-wallet-snap/src/utils/explorer.ts new file mode 100644 index 00000000..04f1aecb --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/explorer.ts @@ -0,0 +1,29 @@ +import { Config } from '../config'; +import { Caip2ChainId } from '../constants'; + +/** + * Gets the explorer URL for a given bitcoin address and CAIP-2 Chain ID. + * + * @param address - The bitcoin address to get the explorer URL for. + * @param caip2ChainId - The CAIP-2 Chain ID. + * @returns The explorer URL as a string. + * @throws An error if an invalid scope is provided. + */ +export function getExplorerUrl(address: string, caip2ChainId: string): string { + switch (caip2ChainId) { + case Caip2ChainId.Mainnet: + return Config.explorer[Caip2ChainId.Mainnet].replace( + // eslint-disable-next-line no-template-curly-in-string + '${address}', + address, + ); + case Caip2ChainId.Testnet: + return Config.explorer[Caip2ChainId.Testnet].replace( + // eslint-disable-next-line no-template-curly-in-string + '${address}', + address, + ); + default: + throw new Error('Invalid Chain ID'); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/utils/index.ts index d022966a..3aa58ed8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/index.ts @@ -2,3 +2,10 @@ export * from './error'; export * from './string'; export * from './async'; export * from './superstruct'; +export * from './lock'; +export * from './snap'; +export * from './snap-state'; +export * from './rpc'; +export * from './explorer'; +export * from './unit'; +export * from './logger'; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/lock.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/lock.test.ts new file mode 100644 index 00000000..ca96498e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/lock.test.ts @@ -0,0 +1,21 @@ +import { Mutex } from 'async-mutex'; + +import { acquireLock } from './lock'; + +jest.mock('async-mutex', () => { + return { + Mutex: jest.fn(), + }; +}); + +describe('acquireLock', () => { + it('acquires lock', () => { + acquireLock(); + expect(Mutex).toHaveBeenCalledTimes(0); + }); + + it('acquires new lock if parameter `create` is true', () => { + acquireLock(true); + expect(Mutex).toHaveBeenCalledTimes(1); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/lock.ts b/merged-packages/bitcoin-wallet-snap/src/utils/lock.ts new file mode 100644 index 00000000..73d0fd06 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/lock.ts @@ -0,0 +1,16 @@ +import { Mutex } from 'async-mutex'; + +const saveMutex = new Mutex(); + +/** + * Acquires or retrieves a lock. + * + * @param create - Whether to create a new lock or retrieve an existing one. + * @returns A Mutex object representing the lock. + */ +export function acquireLock(create = false) { + if (create) { + return new Mutex(); + } + return saveMutex; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/logger/logger.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/logger.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/libs/logger/logger.test.ts rename to merged-packages/bitcoin-wallet-snap/src/utils/logger.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/logger/logger.ts b/merged-packages/bitcoin-wallet-snap/src/utils/logger.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/libs/logger/logger.ts rename to merged-packages/bitcoin-wallet-snap/src/utils/logger.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/rpc.ts b/merged-packages/bitcoin-wallet-snap/src/utils/rpc.ts new file mode 100644 index 00000000..4e384a3c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/rpc.ts @@ -0,0 +1,35 @@ +import { InvalidParamsError, SnapError } from '@metamask/snaps-sdk'; +import type { Struct } from 'superstruct'; +import { assert } from 'superstruct'; + +/** + * Validates that the request parameters conform to the expected structure defined by the provided struct. + * + * @template Params - The expected structure of the request parameters. + * @param requestParams - The request parameters to validate. + * @param struct - The expected structure of the request parameters. + * @throws {InvalidParamsError} If the request parameters do not conform to the expected structure. + */ +export function validateRequest(requestParams: Params, struct: Struct) { + try { + assert(requestParams, struct); + } catch (error) { + throw new InvalidParamsError(error.message) as unknown as Error; + } +} + +/** + * Validates that the response conforms to the expected structure defined by the provided struct. + * + * @template Params - The expected structure of the response. + * @param response - The response to validate. + * @param struct - The expected structure of the response. + * @throws {SnapError} If the response does not conform to the expected structure. + */ +export function validateResponse(response: Params, struct: Struct) { + try { + assert(response, struct); + } catch (error) { + throw new SnapError('Invalid Response') as unknown as Error; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts similarity index 95% rename from merged-packages/bitcoin-wallet-snap/src/libs/snap/state.test.ts rename to merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts index 956542fc..18c9ea3d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts @@ -1,11 +1,10 @@ import { expect } from '@jest/globals'; -import { StateError } from './exceptions'; -import { SnapHelper } from './helpers'; -import { MutexLock } from './lock'; -import { SnapStateManager } from './state'; +import * as lockUtil from './lock'; +import * as snapUtil from './snap'; +import { SnapStateManager } from './snap-state'; -jest.mock('../logger/logger'); +jest.mock('../utils/logger'); type MockTransactionDetail = { txHash: string; @@ -125,7 +124,7 @@ describe('SnapStateManager', () => { }; const getStateDataSpy = jest - .spyOn(SnapHelper, 'getStateData') + .spyOn(snapUtil, 'getStateData') .mockImplementation(async () => { return { transaction: [...initState.transaction], @@ -147,7 +146,7 @@ describe('SnapStateManager', () => { }); const setStateDataSpy = jest - .spyOn(SnapHelper, 'setStateData') + .spyOn(snapUtil, 'setStateData') .mockImplementation(setStateDataFn); return { @@ -160,14 +159,14 @@ describe('SnapStateManager', () => { describe('constructor', () => { it('sends `false` to Lock.Acquire if parameter `createLock` is `undefined`', async () => { - const spy = jest.spyOn(MutexLock, 'acquire'); + const spy = jest.spyOn(lockUtil, 'acquireLock'); createMockStateManager(); expect(spy).toHaveBeenCalledWith(false); }); it('sends `true` to Lock.Acquire if parameter `createLock` is `true`', async () => { - const spy = jest.spyOn(MutexLock, 'acquire'); + const spy = jest.spyOn(lockUtil, 'acquireLock'); createMockStateManager(true); expect(spy).toHaveBeenCalledWith(true); @@ -186,7 +185,7 @@ describe('SnapStateManager', () => { ], }; const readSpy = jest - .spyOn(SnapHelper, 'getStateData') + .spyOn(snapUtil, 'getStateData') .mockResolvedValue(state); const result = await instance.getData(); @@ -213,9 +212,9 @@ describe('SnapStateManager', () => { }, }; const readSpy = jest - .spyOn(SnapHelper, 'getStateData') + .spyOn(snapUtil, 'getStateData') .mockResolvedValue(testcase.state); - const writeSpy = jest.spyOn(SnapHelper, 'setStateData'); + const writeSpy = jest.spyOn(snapUtil, 'setStateData'); updateDataSpy.mockImplementation((state, data) => { state.transaction.push(data); }); @@ -384,7 +383,7 @@ describe('SnapStateManager', () => { } catch (error) { expectedError = error; } finally { - expect(expectedError).toBeInstanceOf(StateError); + expect(expectedError).toBeInstanceOf(Error); expect(initState.transaction).toStrictEqual(['id']); expect(setStateDataSpy).toHaveBeenCalledTimes(0); expect(initState.trasansactionDetails).toStrictEqual({ @@ -563,7 +562,7 @@ describe('SnapStateManager', () => { ).rejects.toThrow('Failed to begin transaction'); }); - it('throws StateError error, if an StateError catched', async () => { + it('throws Error error, if an Error catched', async () => { const initState = { transaction: [], trasansactionDetails: {}, @@ -582,7 +581,7 @@ describe('SnapStateManager', () => { // firsy mockImplementation is to mock the set data actions setStateDataSpy .mockImplementationOnce(async () => { - throw new StateError('setStateDataSpy'); + throw new Error('setStateDataSpy'); // second mockImplementation is to mock the rollback actions }) .mockImplementationOnce(setStateDataFn); @@ -596,7 +595,7 @@ describe('SnapStateManager', () => { }, 30, ), - ).rejects.toThrow(StateError); + ).rejects.toThrow(Error); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts similarity index 82% rename from merged-packages/bitcoin-wallet-snap/src/libs/snap/state.ts rename to merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts index 509b37fb..46991b87 100644 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts @@ -1,11 +1,9 @@ import { type MutexInterface } from 'async-mutex'; import { v4 as uuidv4 } from 'uuid'; -import { compactError } from '../../utils'; -import { logger } from '../logger/logger'; -import { StateError } from './exceptions'; -import { SnapHelper } from './helpers'; -import { MutexLock } from './lock'; +import { acquireLock } from './lock'; +import { logger } from './logger'; +import { getStateData, setStateData } from './snap'; export type Transaction = { id?: string; @@ -21,7 +19,7 @@ export abstract class SnapStateManager { #transaction: Transaction; constructor(createLock = false) { - this.mtx = MutexLock.acquire(createLock); + this.mtx = acquireLock(createLock); this.#transaction = { id: undefined, orgState: undefined, @@ -32,11 +30,11 @@ export abstract class SnapStateManager { } protected async get(): Promise { - return SnapHelper.getStateData(); + return getStateData(); } protected async set(state: State): Promise { - return SnapHelper.setStateData(state); + return setStateData(state); } protected async update( @@ -79,7 +77,7 @@ export abstract class SnapStateManager { !this.#transaction.orgState || !this.#transaction.id ) { - throw new StateError('Failed to begin transaction'); + throw new Error('Failed to begin transaction'); } logger.info( @@ -97,11 +95,10 @@ export abstract class SnapStateManager { this.#transactionId }]: error : ${JSON.stringify(error.message)}`, ); - if (this.#transaction.hasCommited) { - // we only need to rollback if the transaction is committed - await this.#rollback(); - } - throw compactError(error, StateError); + + await this.#rollback(); + + throw error; } finally { this.#cleanUpTransaction(); } @@ -110,7 +107,7 @@ export abstract class SnapStateManager { async commit() { if (!this.#transaction.current || !this.#transaction.orgState) { - throw new StateError('Failed to commit transaction'); + throw new Error('Failed to commit transaction'); } this.#transaction.hasCommited = true; await this.set(this.#transaction.current); @@ -128,7 +125,12 @@ export abstract class SnapStateManager { async #rollback(): Promise { try { - if (!this.#transaction.isRollingBack && this.#transaction.orgState) { + // we only need to rollback if the transaction is committed + if ( + this.#transaction.hasCommited && + !this.#transaction.isRollingBack && + this.#transaction.orgState + ) { logger.info( `SnapStateManager.rollback [${ this.#transactionId @@ -136,7 +138,6 @@ export abstract class SnapStateManager { ); this.#transaction.isRollingBack = true; await this.set(this.#transaction.orgState); - this.#cleanUpTransaction(); } } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions @@ -145,8 +146,7 @@ export abstract class SnapStateManager { this.#transactionId }]: error : ${JSON.stringify(error)}`, ); - this.#cleanUpTransaction(); - throw new StateError('Failed to rollback state'); + throw new Error('Failed to rollback state'); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts new file mode 100644 index 00000000..780cd74f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts @@ -0,0 +1,105 @@ +import { expect } from '@jest/globals'; +import type { Component } from '@metamask/snaps-sdk'; +import { heading, panel, text, divider, row } from '@metamask/snaps-sdk'; + +import * as snapUtil from './snap'; + +jest.mock('@metamask/key-tree', () => ({ + getBIP44AddressKeyDeriver: jest.fn(), +})); + +describe('getBip32Deriver', () => { + it('gets bip32 deriver', async () => { + const spy = jest.spyOn(snapUtil.getProvider(), 'request'); + const path = ['m', "84'", "0'"]; + const curve = 'secp256k1'; + + await snapUtil.getBip32Deriver(path, curve); + + expect(spy).toHaveBeenCalledWith({ + method: 'snap_getBip32Entropy', + params: { + path, + curve, + }, + }); + }); +}); + +describe('confirmDialog', () => { + it('calls snap_dialog', async () => { + const spy = jest.spyOn(snapUtil.getProvider(), 'request'); + const components: Component[] = [ + heading('header'), + text('subHeader'), + divider(), + row('Label1', text('Value1')), + text('**Label2**:'), + row('SubLabel1', text('SubValue1')), + ]; + + await snapUtil.confirmDialog(components); + + expect(spy).toHaveBeenCalledWith({ + method: 'snap_dialog', + params: { + type: 'confirmation', + content: panel(components), + }, + }); + }); +}); + +describe('getStateData', () => { + it('gets state data', async () => { + const spy = jest.spyOn(snapUtil.getProvider(), 'request'); + const testcase = { + state: { + transaction: [ + { + txHash: 'hash', + chainId: 'chainId', + }, + ], + }, + }; + + spy.mockResolvedValue(testcase.state); + const result = await snapUtil.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(snapUtil.getProvider(), 'request'); + const testcase = { + state: { + transaction: [ + { + txHash: 'hash', + chainId: 'chainId', + }, + ], + }, + }; + + await snapUtil.setStateData(testcase.state); + + expect(spy).toHaveBeenCalledWith({ + method: 'snap_manageState', + params: { + operation: 'update', + newState: testcase.state, + }, + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts new file mode 100644 index 00000000..e6b162ef --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts @@ -0,0 +1,82 @@ +import { type SLIP10NodeInterface } from '@metamask/key-tree'; +import type { Component, DialogResult, Json } from '@metamask/snaps-sdk'; +import { panel, type SnapsProvider } from '@metamask/snaps-sdk'; + +declare const snap: SnapsProvider; + +/** + * Retrieves the current SnapsProvider. + * + * @returns The current SnapsProvider. + */ +export function getProvider(): SnapsProvider { + return snap; +} + +/** + * Retrieves a SLIP10NodeInterface object for the specified path and curve. + * + * @param path - The BIP32 derivation path for which to retrieve a SLIP10NodeInterface. + * @param curve - The elliptic curve to use for key derivation. + * @returns A Promise that resolves to a SLIP10NodeInterface object. + */ +export async function getBip32Deriver( + path: string[], + curve: 'secp256k1' | 'ed25519', +): Promise { + const node = await snap.request({ + method: 'snap_getBip32Entropy', + params: { + path, + curve, + }, + }); + return node as SLIP10NodeInterface; +} + +/** + * Displays a confirmation dialog with the specified components. + * + * @param components - An array of components to display in the dialog. + * @returns A Promise that resolves to the result of the dialog. + */ +export async function confirmDialog( + components: Component[], +): Promise { + return snap.request({ + method: 'snap_dialog', + params: { + type: 'confirmation', + content: panel(components), + }, + }); +} + +/** + * Retrieves the current state data. + * + * @returns A Promise that resolves to the current state data. + */ +export async function getStateData(): Promise { + return (await snap.request({ + method: 'snap_manageState', + params: { + operation: 'get', + }, + })) as unknown as State; +} + +/** + * Sets the current state data to the specified data. + * + * @param data - The new state data to set. + */ +export async function setStateData(data: State) { + await snap.request({ + method: 'snap_manageState', + params: { + operation: 'update', + newState: data as unknown as Record, + }, + }); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts index f0b3e8c4..b4c14a31 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts @@ -5,6 +5,7 @@ import { hexToBuffer, bufferToString, replaceMiddleChar, + shortenAddress, } from './string'; describe('trimHexPrefix', () => { @@ -68,3 +69,10 @@ describe('replaceMiddleChar', () => { expect(replaceMiddleChar('', 5, 3)).toBe(''); }); }); + +describe('shortenAddress', () => { + const str = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; + it('shorten an address', () => { + expect(shortenAddress(str)).toBe('tb1qt...aeu'); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts index acf96baa..8dc990be 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts @@ -64,3 +64,13 @@ export function replaceMiddleChar( str.length - tailLength, )}`; } + +/** + * Format the address in shorten string. + * + * @param address - The address to format. + * @returns The formatted address. + */ +export function shortenAddress(address: string) { + return replaceMiddleChar(address, 5, 3); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts index 06495791..f9440c9f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts @@ -45,10 +45,7 @@ describe('superstruct', () => { describe('scopeStruct', () => { it('validates correctly', () => { expect(() => - assert( - Config.avaliableNetworks[Config.chain][0], - superstruct.scopeStruct, - ), + assert(Config.avaliableNetworks[0], superstruct.scopeStruct), ).not.toThrow(); expect(() => assert('custom scope', superstruct.scopeStruct)).toThrow( Error, @@ -59,10 +56,7 @@ describe('superstruct', () => { describe('assetsStruct', () => { it('validates correctly', () => { expect(() => - assert( - Config.avaliableAssets[Config.chain][0], - superstruct.assetsStruct, - ), + assert(Config.avaliableAssets[0], superstruct.assetsStruct), ).not.toThrow(); expect(() => assert('custom scope', superstruct.assetsStruct)).toThrow( Error, diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts index a7c15dda..411cb8a0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts @@ -2,9 +2,9 @@ import { enums, string, pattern } from 'superstruct'; import { Config } from '../config'; -export const assetsStruct = enums(Config.avaliableAssets[Config.chain]); +export const assetsStruct = enums(Config.avaliableAssets); -export const scopeStruct = enums(Config.avaliableNetworks[Config.chain]); +export const scopeStruct = enums(Config.avaliableNetworks); export const positiveStringStruct = pattern( string(), diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts new file mode 100644 index 00000000..c038e30f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts @@ -0,0 +1,53 @@ +import { maxSatoshi, minSatoshi } from '../bitcoin/constants'; +import { satsToBtc, btcToSats } from './unit'; + +describe('satsToBtc', () => { + it('returns Btc unit', () => { + expect(satsToBtc(2099999999999999n)).toBe('20999999.99999999'); + }); + + it('returns Btc unit with max Satoshis', () => { + expect(satsToBtc(maxSatoshi)).toBe('21000000.00000000'); + }); + + it('returns Btc unit with min Satoshis', () => { + expect(satsToBtc(minSatoshi)).toBe('0.00000001'); + }); + + it('returns Btc unit with unit', () => { + expect(satsToBtc(minSatoshi, true)).toBe('0.00000001 BTC'); + }); + + it('throw an error if then given Satoshis in float', () => { + const sats = 1.1; + expect(() => satsToBtc(sats)).toThrow(Error); + }); +}); + +describe('btcToSats', () => { + it('returns Btc unit', () => { + expect(btcToSats('20999999.99999999')).toBe(2099999999999999n); + }); + + it('returns Btc unit with max Satoshis', () => { + expect(btcToSats('21000000')).toBe(2100000000000000n); + }); + + it('returns Btc unit with 0 Satoshis', () => { + expect(btcToSats('0')).toBe(0n); + }); + + it('returns Btc unit with min Satoshis', () => { + expect(btcToSats('0.00000001')).toBe(1n); + }); + + it('throws an error if the given BTC is out of range', () => { + expect(() => btcToSats('0.9999999999999')).toThrow( + 'BTC amount is out of range', + ); + expect(() => btcToSats('21000000.999999999"')).toThrow( + 'BTC amount is out of range', + ); + expect(() => btcToSats('22000000')).toThrow('BTC amount is out of range'); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts new file mode 100644 index 00000000..f27b1fb8 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts @@ -0,0 +1,46 @@ +import Big from 'big.js'; + +import { maxSatoshi } from '../bitcoin/constants'; +import { Config } from '../config'; + +/** + * Converts a satoshis to a string representing the equivalent amount of BTC. + * + * @param sats - The number of satoshis to convert. + * @param withUnit - A boolean indicating whether to include the unit in the string representation. Default is false. + * @returns The equivalent amount of BTC as a string, fixed to 8 decimal places. + * @throws A Error if sats is not an integer. + */ +export function satsToBtc(sats: number | bigint, withUnit = false): string { + if (typeof sats === 'number' && !Number.isInteger(sats)) { + throw new Error('satsToBtc must be called on an integer number'); + } + const bigIntSat = new Big(sats); + const val = bigIntSat.div(100000000).toFixed(8); + + if (withUnit) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + return `${val} ${Config.unit}`; + } + return val; +} + +/** + * Converts a BTC to a bigint representing the equivalent amount of satoshis. + * + * @param btc - The amount of BTC to convert. + * @returns The equivalent amount of satoshis as a string, rounded to the nearest integer. + * @throws A Error if the BTC > max amount of satoshis (21 * 1e14) or the BTC < 0 or the BTC has more than 8 decimals. + */ +export function btcToSats(btc: string): bigint { + const stringVals = btc.split('.'); + if (stringVals.length > 1 && stringVals[1].length > 8) { + throw new Error('BTC amount is out of range'); + } + const bigIntBtc = new Big(btc); + const sats = bigIntBtc.times(100000000); + if (sats.lt(0) || sats.gt(maxSatoshi)) { + throw new Error('BTC amount is out of range'); + } + return BigInt(sats.toFixed(0)); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/wallet.ts index 937fc141..9dbc86b9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/wallet.ts @@ -3,7 +3,7 @@ import type { Buffer } from 'buffer'; export type Recipient = { address: string; - value: number; + value: bigint; }; export type Transaction = { @@ -15,53 +15,49 @@ export type Transaction = { * An interface that defines a `toJson` method for getting a JSON representation of a transaction info object. */ export type ITxInfo = { - /** - * Returns a JSON representation of the transaction info object. - * - * @returns The JSON representation of the transaction info object. - */ - toJson>(): TxInfoJson; + sender: string; + change?: Recipient; + recipients: Recipient[]; + total: bigint; + txFee: bigint; + feeRate: bigint; }; /** - * An interface that defines methods and properties for working with blockchain addresses. + * An interface that defines methods for unlocking accounts, signing transactions, and creating transactions. */ -export type IAddress = { - /** - * The string value of the address. - */ - value: string; - +export type IWallet = { /** - * Returns the string representation of the address. + * Unlocks an account by index and script type. * - * @param isShorten - A boolean indicating whether the address should be shortened. - * @returns The string representation of the address. - */ - toString(isShorten?: boolean): string; -}; - -/** - * An interface that defines properties for working with amounts of cryptocurrency. - */ -export type IAmount = { - /** - * The numeric value of the amount. + * @param index - The index to derive from the node. + * @param type - The script type of the unlocked account, e.g. `bip122:p2pkh`. + * @returns A promise that resolves to an `IAccount` object. */ - value: number; + unlock(index: number, type?: string): Promise; /** - * The unit of the amount, e.g. "BTC" or "ETH". + * Signs a transaction by the given encoded transaction string. + * + * @param signer - The `IAccountSigner` object to sign the transaction. + * @param transaction - The encoded transaction string to convert back to a transaction. + * @returns A promise that resolves to a string of the signed transaction. */ - unit: string; + signTransaction(signer: IAccountSigner, transaction: string): Promise; /** - * Returns the string representation of the amount, with or without the unit. + * Creates a transaction using the given account, transaction intent, and options. * - * @param withUnit - A boolean indicating whether to include the unit in the string representation. - * @returns The string representation of the amount. + * @param account - The `IAccount` object to create the transaction. + * @param recipients - The transaction recipients. + * @param options - The options to use when creating the transaction. + * @returns A promise that resolves to an object containing the transaction hash and transaction info. */ - toString(withUnit?: boolean): string; + createTransaction( + account: IAccount, + recipients: Recipient[], + options: Record, + ): Promise; }; /** @@ -98,43 +94,6 @@ export type IAccount = { signer: IAccountSigner; }; -/** - * An interface that defines methods for unlocking accounts, signing transactions, and creating transactions. - */ -export type IWallet = { - /** - * Unlocks an account by index and script type. - * - * @param index - The index to derive from the node. - * @param type - The script type of the unlocked account, e.g. `bip122:p2pkh`. - * @returns A promise that resolves to an `IAccount` object. - */ - unlock(index: number, type: string): Promise; - - /** - * Signs a transaction by the given encoded transaction string. - * - * @param signer - The `IAccountSigner` object to sign the transaction. - * @param transaction - The encoded transaction string to convert back to a transaction. - * @returns A promise that resolves to a string of the signed transaction. - */ - signTransaction(signer: IAccountSigner, transaction: string): Promise; - - /** - * Creates a transaction using the given account, transaction intent, and options. - * - * @param account - The `IAccount` object to create the transaction. - * @param recipients - The transaction recipients. - * @param options - The options to use when creating the transaction. - * @returns A promise that resolves to an object containing the transaction hash and transaction info. - */ - createTransaction( - account: IAccount, - recipients: Recipient[], - options: Record, - ): Promise; -}; - /** * An interface that defines methods and properties for signing transactions and verifying signatures. */ diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json deleted file mode 100644 index 4e5e1ce2..00000000 --- a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json +++ /dev/null @@ -1,220 +0,0 @@ -{ - "getAccountStatsResp": { - "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 - } - }, - "getUtxoResp": [ - { - "txid": "bf9955de6df6d2b35aa301142ddeb85259f62dbdb5a5382ac7199b91bf6aa165", - "vout": 1, - "status": { - "confirmed": true, - "block_height": 2581760, - "block_hash": "000000000000000eba4e476fd2e7985221a2ae28ea2696c97bde455a4d40ec81", - "block_time": 1710329183 - }, - "value": 28019 - } - ], - "feeEstimateResp": { - "22": 52.373999999999995, - "6": 52.373999999999995, - "144": 46.793, - "504": 46.793, - "15": 52.373999999999995, - "19": 52.373999999999995, - "7": 52.373999999999995, - "21": 52.373999999999995, - "10": 55.343, - "3": 46.793, - "11": 55.343, - "24": 52.373999999999995, - "17": 52.373999999999995, - "5": 52.373999999999995, - "4": 52.375, - "1": 46.793, - "1008": 46.793, - "9": 52.373999999999995, - "12": 52.373999999999995, - "16": 52.373999999999995, - "18": 52.373999999999995, - "20": 52.373999999999995, - "2": 46.793, - "14": 52.373999999999995, - "13": 52.373999999999995, - "23": 52.373999999999995, - "25": 52.373999999999995, - "8": 52.373999999999995 - }, - "getLast10BlockResp": [ - { - "id": "000000000000000774b68e8353359959bb7243630a7c8994fbf7b8c53312b985", - "height": 2818504, - "version": 570884096, - "timestamp": 1716968525, - "tx_count": 6138, - "size": 2237729, - "weight": 3993176, - "merkle_root": "14cf491c608a3415b692570e286fb876936d7448a122d5fd64753d82f2a1cd4b", - "previousblockhash": "000000000000000953d3bd05c2692444ba9e9d8f81113c772ea1d2b2b82bafd9", - "mediantime": 1716968525, - "nonce": 4148296018, - "bits": 420164126, - "difficulty": 383618246.78880125 - }, - { - "id": "000000000000000953d3bd05c2692444ba9e9d8f81113c772ea1d2b2b82bafd9", - "height": 2818503, - "version": 641843200, - "timestamp": 1716968525, - "tx_count": 6296, - "size": 1931479, - "weight": 3992977, - "merkle_root": "89f7f7f2b096d04dc59182b02db2f43e5a4d2060947e3bb968d510cc25d66c1e", - "previousblockhash": "00000000000000094888e858eca8a2a2aac1624108a45bd4dceded927422e9d6", - "mediantime": 1716968524, - "nonce": 3737463343, - "bits": 420164126, - "difficulty": 383618246.78880125 - }, - { - "id": "00000000000000094888e858eca8a2a2aac1624108a45bd4dceded927422e9d6", - "height": 2818502, - "version": 677756928, - "timestamp": 1716968524, - "tx_count": 6191, - "size": 2033489, - "weight": 3992897, - "merkle_root": "5868743f6708e2102eadabceb85cb9ad31e1ed730dbd14c2f2b9771839e1abfa", - "previousblockhash": "000000004db90149f43d3a0c249e17953c7171a01c77868a2bf0781516142dc6", - "mediantime": 1716968524, - "nonce": 3766185333, - "bits": 420164126, - "difficulty": 383618246.78880125 - }, - { - "id": "000000004db90149f43d3a0c249e17953c7171a01c77868a2bf0781516142dc6", - "height": 2818501, - "version": 536870912, - "timestamp": 1716970927, - "tx_count": 9, - "size": 1871, - "weight": 5369, - "merkle_root": "3440666441c17245218a6c4b6c853505a94473e8955b3cca9945c6d74a9e5fba", - "previousblockhash": "00000000e5f036d8f93121377e83d4c6a7fcf1c0091c9e35da4f120149e3f857", - "mediantime": 1716968523, - "nonce": 1023899438, - "bits": 486604799, - "difficulty": 1.0 - }, - { - "id": "00000000e5f036d8f93121377e83d4c6a7fcf1c0091c9e35da4f120149e3f857", - "height": 2818500, - "version": 536870912, - "timestamp": 1716969726, - "tx_count": 2, - "size": 455, - "weight": 1508, - "merkle_root": "2d71a7cae3d8e76dc1601314f6c8cae0138fefd58d19d324048459c8181751c8", - "previousblockhash": "0000000040628f64b9310ac950b055234b75392944e7f143bdcf2c77c4a3399c", - "mediantime": 1716967324, - "nonce": 1017180888, - "bits": 486604799, - "difficulty": 1.0 - }, - { - "id": "0000000040628f64b9310ac950b055234b75392944e7f143bdcf2c77c4a3399c", - "height": 2818499, - "version": 536870912, - "timestamp": 1716968525, - "tx_count": 1, - "size": 250, - "weight": 892, - "merkle_root": "0ac6dc718de6c68eb0dae9b19a88494112e7e5c549dc94e0ea365b81d1517f41", - "previousblockhash": "000000009649cfb6a61dede6b96e40a664a0ceb99cda5ded6cf62a0c50b3f248", - "mediantime": 1716967323, - "nonce": 390285632, - "bits": 486604799, - "difficulty": 1.0 - }, - { - "id": "000000009649cfb6a61dede6b96e40a664a0ceb99cda5ded6cf62a0c50b3f248", - "height": 2818498, - "version": 536870912, - "timestamp": 1716967324, - "tx_count": 3, - "size": 660, - "weight": 2124, - "merkle_root": "1b7fe58a1b1be6c0bc3cf145da5f593962d1765dc114010e597dace157684cc8", - "previousblockhash": "0000000000000001e5f68dbe40f3364ebddf4f230ec9e49dd5eccd1813c8b257", - "mediantime": 1716967322, - "nonce": 4221530826, - "bits": 486604799, - "difficulty": 1.0 - }, - { - "id": "0000000000000001e5f68dbe40f3364ebddf4f230ec9e49dd5eccd1813c8b257", - "height": 2818497, - "version": 633970688, - "timestamp": 1716966123, - "tx_count": 6196, - "size": 2099705, - "weight": 3992492, - "merkle_root": "bb8c150aa19ee1e5580cc1925d06f18878a26f69fa8224f550dae07378c727a3", - "previousblockhash": "00000000caddc4212fe7617f933d4c954aa830d17343f57676fbd072eca68b9b", - "mediantime": 1716966123, - "nonce": 1829804593, - "bits": 420164126, - "difficulty": 383618246.78880125 - }, - { - "id": "00000000caddc4212fe7617f933d4c954aa830d17343f57676fbd072eca68b9b", - "height": 2818496, - "version": 536870912, - "timestamp": 1716969725, - "tx_count": 2, - "size": 473, - "weight": 1454, - "merkle_root": "2327409528477e7de65184b33f1e28b3b061c07b15bcd6cc40f03f969348afb5", - "previousblockhash": "00000000dd4d4b64c55d54edbdb3bc9634de0434f0d23292a5fa11511a8e6e90", - "mediantime": 1716966122, - "nonce": 4028091752, - "bits": 486604799, - "difficulty": 1.0 - }, - { - "id": "00000000dd4d4b64c55d54edbdb3bc9634de0434f0d23292a5fa11511a8e6e90", - "height": 2818495, - "version": 536870912, - "timestamp": 1716968524, - "tx_count": 1, - "size": 250, - "weight": 892, - "merkle_root": "70bc5770e60c093fa682dd860c2c4437aadf1276ed924d15036e7d6326f8189a", - "previousblockhash": "00000000aa597bac4c12b00b38ce4aec6547e028ad27811a7d1f4ac539051fa8", - "mediantime": 1716966121, - "nonce": 1607636480, - "bits": 486604799, - "difficulty": 1.0 - } - ], - "getTransactionStatus": { - "confirmed": true, - "block_height": 2817600, - "block_hash": "0000000000000008859508cb0ec5596e8d05363b0df010b9b02b7b4cd4dc261e", - "block_time": 1716623274 - } -} diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index 5152e881..c5c27fba 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -5,9 +5,10 @@ import { Buffer } from 'buffer'; import ECPairFactory from 'ecpair'; import { v4 as uuidv4 } from 'uuid'; -import { Network as NetworkEnum } from '../src/bitcoin/constants'; +import { Caip2ChainId } from '../src/constants'; import blockChairData from './fixtures/blockchair.json'; -import blockStreamData from './fixtures/blockstream.json'; + +/* eslint-disable */ /** * Method to generate testing account. @@ -31,7 +32,7 @@ export function generateAccounts(cnt = 1, addressPrefix = '') { baseAddress.slice(0, baseAddress.length - i.toString().length) + i.toString(), options: { - scope: NetworkEnum.Testnet, + scope: Caip2ChainId.Testnet, index: i, }, methods: ['btc_sendmany'], @@ -200,39 +201,6 @@ export function createMockBip32Instance( } const randomNum = (max) => Math.floor(Math.random() * max); -/** - * Method to generate blockstream account stats resp by addresses. - * - * @param addresses - Array of address in string. - * @returns An array of blocksteam stats response. - */ -export function generateBlockStreamAccountStats(addresses: string[]) { - const template = blockStreamData.getAccountStatsResp; - const resp: (typeof template)[] = []; - for (const address of addresses) { - /* eslint-disable */ - resp.push({ - ...template, - address, - chain_stats: { - funded_txo_count: randomNum(100), - funded_txo_sum: Math.max(randomNum(1000000), 10000), - spent_txo_count: randomNum(100), - spent_txo_sum: randomNum(10000), - tx_count: randomNum(100), - }, - mempool_stats: { - funded_txo_count: randomNum(100), - funded_txo_sum: Math.max(randomNum(1000000), 10000), - spent_txo_count: randomNum(100), - spent_txo_sum: randomNum(10000), - tx_count: randomNum(100), - }, - }); - /* eslint-disable */ - } - return resp; -} /** * Method to generate blockchair getBalance resp by addresses. @@ -254,13 +222,15 @@ export function generateBlockChairGetBalanceResp(addresses: string[]) { * * @param address - address in string. * @param utxosCount - utxos count. + * @param minAmount - min amount of each utxo value. + * @param maxAmount - max amount of each utxo value. * @returns An array of blockchair getUtxos response. */ export function generateBlockChairGetUtxosResp( address: string, utxosCount: number, - minAmount: number = 0, - maxAmount: number = 1000000, + minAmount = 0, + maxAmount = 1000000, ) { const template = blockChairData.getUtxoResp; const data = { ...template.data.tb1qlq94vt9uh07fwunsgdyycpkv24uev05ywjua0r }; @@ -306,54 +276,7 @@ export function generateBlockChairGetStatsResp() { resp.data[key] = randomNum(value); } }); - resp.data['suggested_transaction_fee_per_byte_sat'] = randomNum(20); - return resp; -} - -/** - * Method to generate blockstream getUtxos resp. - * - * @param utxosCount - utxos count. - * @returns An array of blockstream getUtxos response. - */ -export function generateBlockStreamGetUtxosResp( - utxosCount: number, - confirmed: boolean = true, -) { - const template = blockStreamData.getUtxoResp; - let idx = -1; - const resp = Array.from({ length: utxosCount }, () => { - idx += 1; - return { - txid: randomNum(1000000) - .toString(16) - .padStart(template[0].txid.length, '0'), - vout: idx, - status: { - confirmed: confirmed, - block_height: randomNum(1000000), - block_hash: randomNum(1000000) - .toString(16) - .padStart(template[0].status.block_hash.length, '0'), - block_time: 1710329183, - }, - value: randomNum(1000000), - }; - }); - return resp; -} - -/** - * Method to generate blockstream estimate fee resp. - * - * @returns A blockstream estimate fee resp. - */ -export function generateBlockStreamEstFeeResp() { - const template = blockStreamData.feeEstimateResp; - const resp: typeof template = { ...template }; - Object.keys(template).forEach((key) => { - resp[key] = Math.min(0.1, randomNum(40)); - }); + resp.data.suggested_transaction_fee_per_byte_sat = randomNum(20); return resp; } @@ -368,45 +291,13 @@ export function generateBlockChairBroadcastTransactionResp() { return resp; } -/** - * Method to generate blockstream last 10 blocks resp. - * - * @param blockHeight - A start number for the block height of the resp. - * @returns A blockstream last 10 blocks resp. - */ -export function generateBlockStreamLast10BlockResp(blockHeight: number) { - const template = blockStreamData.getLast10BlockResp; - const resp: typeof template = { ...template }; - for (let i = 0; i < template.length; i++) { - resp[i].height = blockHeight - i; - } - return resp; -} - -/** - * Method to generate blockstream transaction status resp. - * - * @param blockHeight - Block height of the transaction. - * @param confirmed - Confirm status of the transaction. - * @returns A blockstream transaction status resp. - */ -export function generateBlockStreamTransactionStatusResp( - blockHeight: number, - confirmed: boolean, -) { - const template = blockStreamData.getTransactionStatus; - const resp: typeof template = { ...template }; - resp.block_height = blockHeight; - resp.confirmed = confirmed; - return resp; -} - /** * Method to generate blockchair transaction dashboards resp. * * @param txHash - Transaction hash of the transaction. * @param txnBlockHeight - Block height of the transaction. * @param txnBlockHeight - Block height of the last block. + * @param lastBlockHeight - Block height of the last block. * @param confirmed - Confirm status of the transaction. * @returns A blockchair transaction dashboards resp. */ @@ -441,8 +332,8 @@ export function generateBlockChairTransactionDashboard( * * @param address - the utxos owner address. * @param utxoCnt - count of the utox to be generated. - * @param minAmount - min amount of each utxo value. - * @param maxAmount - max amount of each utxo value. + * @param minAmt - min amount of the utxo array. + * @param maxAmt - max amount of the utxo array. * @returns An formatted utxo array. */ export function generateFormatedUtxos( @@ -465,3 +356,5 @@ export function generateFormatedUtxos( })); return formattedUtxos; } + +/* eslint-disable */ From 78949cdcb11492e9e5b3682788857c02dbd16fd4 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 12 Jun 2024 15:52:02 +0800 Subject: [PATCH 059/362] chore: simplify code structure batch 2 (#110) * chore: remove chain interface * chore: remove wallet interface and add docs * chore: add unit test coverage * chore: refine psbt error handling * chore: moving static implement to root folder * Update account.ts --- .../bitcoin-wallet-snap/jest.config.js | 2 +- .../bitcoin/chain/clients/blockchair.test.ts | 2 +- .../src/bitcoin/chain/clients/blockchair.ts | 41 +++--- .../src/bitcoin/chain/constants.ts | 11 ++ .../src/bitcoin/chain/data-client.ts | 63 ++++++-- .../src/bitcoin/chain/index.ts | 1 + .../src/bitcoin/chain/service.test.ts | 28 +--- .../src/bitcoin/chain/service.ts | 95 +++++++++--- .../src/bitcoin/wallet/account.test.ts | 8 +- .../src/bitcoin/wallet/account.ts | 66 ++++++--- .../src/bitcoin/wallet/coin-select.test.ts | 2 +- .../src/bitcoin/wallet/coin-select.ts | 13 +- .../src/bitcoin/{ => wallet}/constants.ts | 6 - .../src/bitcoin/wallet/deriver.test.ts | 8 +- .../src/bitcoin/wallet/deriver.ts | 48 ++++--- .../src/bitcoin/wallet/index.ts | 4 +- .../src/bitcoin/wallet/psbt.test.ts | 10 +- .../src/bitcoin/wallet/psbt.ts | 113 +++++++++++---- .../src/bitcoin/wallet/signer.ts | 34 ++++- .../bitcoin/wallet/transaction-info.test.ts | 129 +++++++++++------ .../src/bitcoin/wallet/transaction-info.ts | 4 +- .../bitcoin/wallet/transaction-input.test.ts | 14 +- .../src/bitcoin/wallet/transaction-input.ts | 12 +- .../{ => wallet}/utils/address.test.ts | 0 .../src/bitcoin/{ => wallet}/utils/address.ts | 0 .../src/bitcoin/{ => wallet}/utils/index.ts | 0 .../{ => wallet}/utils/network.test.ts | 2 +- .../src/bitcoin/{ => wallet}/utils/network.ts | 2 +- .../{ => wallet}/utils/payment.test.ts | 0 .../src/bitcoin/{ => wallet}/utils/payment.ts | 0 .../bitcoin/{ => wallet}/utils/policy.test.ts | 0 .../src/bitcoin/{ => wallet}/utils/policy.ts | 0 .../src/bitcoin/wallet/wallet.test.ts | 35 +++-- .../src/bitcoin/wallet/wallet.ts | 67 ++++++--- .../bitcoin-wallet-snap/src/chain.ts | 111 -------------- .../bitcoin-wallet-snap/src/factory.ts | 9 +- .../bitcoin-wallet-snap/src/index.test.ts | 2 +- .../bitcoin-wallet-snap/src/keyring.test.ts | 60 +++----- .../bitcoin-wallet-snap/src/keyring.ts | 12 +- .../src/rpcs/get-balances.test.ts | 23 ++- .../src/rpcs/get-balances.ts | 4 +- .../src/rpcs/get-transaction-status.test.ts | 23 ++- .../src/rpcs/get-transaction-status.ts | 2 +- .../src/rpcs/sendmany.test.ts | 100 +++++++------ .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 17 +-- .../src/{bitcoin/wallet => utils}/static.ts | 0 .../src/utils/unit.test.ts | 3 +- .../bitcoin-wallet-snap/src/utils/unit.ts | 7 +- .../bitcoin-wallet-snap/src/wallet.ts | 135 ------------------ 49 files changed, 704 insertions(+), 624 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/constants.ts (90%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/address.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/address.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/index.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/network.test.ts (96%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/network.ts (95%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/payment.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/payment.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/policy.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/policy.ts (100%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/chain.ts rename merged-packages/bitcoin-wallet-snap/src/{bitcoin/wallet => utils}/static.ts (100%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/wallet.ts diff --git a/merged-packages/bitcoin-wallet-snap/jest.config.js b/merged-packages/bitcoin-wallet-snap/jest.config.js index 3bc03249..491cb4eb 100644 --- a/merged-packages/bitcoin-wallet-snap/jest.config.js +++ b/merged-packages/bitcoin-wallet-snap/jest.config.js @@ -7,7 +7,7 @@ module.exports = { restoreMocks: true, resetMocks: true, verbose: true, - testPathIgnorePatterns: ['/node_modules/', '/__BAK__/', '/__mocks__/'], + testPathIgnorePatterns: ['/node_modules/', '/__mocks__/'], 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 diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts index 7a1e501d..6b81a97d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts @@ -8,7 +8,7 @@ import { generateBlockChairGetStatsResp, generateBlockChairTransactionDashboard, } from '../../../../test/utils'; -import { FeeRatio, TransactionStatus } from '../../../chain'; +import { FeeRatio, TransactionStatus } from '../constants'; import { DataClientError } from '../exceptions'; import { BlockChairClient } from './blockchair'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts index f7534517..4a0965e9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts @@ -1,14 +1,15 @@ import type { Json } from '@metamask/snaps-sdk'; import { type Network, networks } from 'bitcoinjs-lib'; -import type { TransactionStatusData, Utxo } from '../../../chain'; -import { FeeRatio, TransactionStatus } from '../../../chain'; -import { compactError } from '../../../utils'; -import { logger } from '../../../utils/logger'; +import { compactError, logger } from '../../../utils'; +import { FeeRatio, TransactionStatus } from '../constants'; import type { - GetBalancesResp, - GetFeeRatesResp, IDataClient, + DataClientGetBalancesResp, + DataClientGetTxStatusResp, + DataClientGetUtxosResp, + DataClientSendTxResp, + DataClientGetFeeRatesResp, } from '../data-client'; import { DataClientError } from '../exceptions'; @@ -289,7 +290,7 @@ export class BlockChairClient implements IDataClient { } } - async getBalances(addresses: string[]): Promise { + async getBalances(addresses: string[]): Promise { try { logger.info( `[BlockChairClient.getBalance] start: { addresses : ${JSON.stringify( @@ -305,26 +306,28 @@ export class BlockChairClient implements IDataClient { `[BlockChairClient.getBalance] response: ${JSON.stringify(response)}`, ); - return addresses.reduce((data: GetBalancesResp, address: string) => { - data[address] = response.data[address] ?? 0; - return data; - }, {}); + return addresses.reduce( + (data: DataClientGetBalancesResp, address: string) => { + data[address] = response.data[address] ?? 0; + return data; + }, + {}, + ); } catch (error) { logger.info(`[BlockChairClient.getBalance] error: ${error.message}`); throw compactError(error, DataClientError); } } - // TODO: Get UTXOs that sufficiently cover the amount and fee, to reduce the number of requests async getUtxos( address: string, includeUnconfirmed?: boolean, - ): Promise { + ): Promise { try { let process = true; let offset = 0; const limit = 1000; - const data: Utxo[] = []; + const data: DataClientGetUtxosResp = []; while (process) { let url = `/dashboards/address/${address}?limit=0,${limit}&offset=0,${offset}`; @@ -365,7 +368,7 @@ export class BlockChairClient implements IDataClient { } } - async getFeeRates(): Promise { + async getFeeRates(): Promise { try { logger.info(`[BlockChairClient.getFeeRates] start:`); const response = await this.get(`/stats`); @@ -381,7 +384,9 @@ export class BlockChairClient implements IDataClient { } } - async sendTransaction(signedTransaction: string): Promise { + async sendTransaction( + signedTransaction: string, + ): Promise { try { const response = await this.post( `/push/transaction`, @@ -403,7 +408,9 @@ export class BlockChairClient implements IDataClient { } } - async getTransactionStatus(txHash: string): Promise { + async getTransactionStatus( + txHash: string, + ): Promise { try { const response = await this.getTxDashboardData(txHash); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts new file mode 100644 index 00000000..3a0720f6 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts @@ -0,0 +1,11 @@ +export enum FeeRatio { + Fast = 'fast', + Medium = 'medium', + Slow = 'slow', +} + +export enum TransactionStatus { + Confirmed = 'confirmed', + Pending = 'pending', + Failed = 'failed', +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts index a510b1a6..3e38e9cb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts @@ -1,15 +1,62 @@ -import type { FeeRatio, TransactionStatusData, Utxo } from '../../chain'; +import type { FeeRatio } from './constants'; +import type { TransactionStatusData, Utxo } from './service'; -export type GetBalancesResp = Record; +export type DataClientGetBalancesResp = Record; -export type GetFeeRatesResp = { +export type DataClientGetUtxosResp = Utxo[]; + +export type DataClientGetFeeRatesResp = { [key in FeeRatio]?: number; }; +export type DataClientGetTxStatusResp = TransactionStatusData; + +export type DataClientSendTxResp = string; + +/** + * This interface defines the methods available on a data client for interacting with the Bitcoin blockchain. + */ export type IDataClient = { - getBalances(address: string[]): Promise; - getUtxos(address: string, includeUnconfirmed?: boolean): Promise; - getFeeRates(): Promise; - getTransactionStatus(txHash: string): Promise; - sendTransaction(tx: string): Promise; + /** + * Gets the balances for a set of Bitcoin addresses. + * + * @param {string[]} address - An array of Bitcoin addresses to query. + * @returns {Promise} A promise that resolves to a record of addresses and their corresponding balances. + */ + getBalances(address: string[]): Promise; + + /** + * Gets the UTXOs for a Bitcoin address. + * + * @param {string} address - The Bitcoin address to query. + * @param {boolean} [includeUnconfirmed] - Whether or not to include unconfirmed UTXOs in the response. Defaults to false. + * @returns {Promise} A promise that resolves to an array of UTXOs. + */ + getUtxos( + address: string, + includeUnconfirmed?: boolean, + ): Promise; + + /** + * Gets the fee rates for the Bitcoin network. + * + * @returns {Promise} A promise that resolves to an object containing fee rates for different ratios. + */ + getFeeRates(): Promise; + + /** + * Gets the status of a transaction given its hash. + * + * @param {string} txHash - The hash of the transaction to query. + * @returns {Promise} A promise that resolves to the transaction status data. + */ + getTransactionStatus(txHash: string): Promise; + + /** + * Sends a transaction to the Bitcoin network. + * + * @param {string} tx - The transaction to send. + * @returns {Promise} A promise that resolves to the transaction hash. + */ + sendTransaction(tx: string): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts index cb209e05..651c3bdb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts @@ -1,2 +1,3 @@ export * from './exceptions'; export * from './service'; +export * from './constants'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts index ad3dc7bf..68e63b56 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts @@ -6,8 +6,8 @@ import { generateBlockChairBroadcastTransactionResp, generateBlockChairGetUtxosResp, } from '../../../test/utils'; -import { FeeRatio, TransactionStatus } from '../../chain'; import { Caip2Asset } from '../../constants'; +import { FeeRatio, TransactionStatus } from './constants'; import type { IDataClient } from './data-client'; import { BtcOnChainServiceError } from './exceptions'; import { BtcOnChainService } from './service'; @@ -127,9 +127,8 @@ describe('BtcOnChainService', () => { it('calls getUtxos with readClient', async () => { const { instance, getUtxosSpy } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); - const accounts = generateAccounts(2); + const accounts = generateAccounts(1); const sender = accounts[0].address; - const receiver = accounts[1].address; const mockResponse = generateBlockChairGetUtxosResp(sender, 10); const utxos = mockResponse.data[sender].utxo.map((utxo) => ({ block: utxo.block_id, @@ -140,13 +139,7 @@ describe('BtcOnChainService', () => { getUtxosSpy.mockResolvedValue(utxos); - const result = await txService.getDataForTransaction(sender, { - amounts: { - [receiver]: 100, - }, - subtractFeeFrom: [], - replaceable: true, - }); + const result = await txService.getDataForTransaction(sender); expect(getUtxosSpy).toHaveBeenCalledWith(sender); expect(result).toStrictEqual({ @@ -159,21 +152,14 @@ describe('BtcOnChainService', () => { it('throws error if readClient fail', async () => { const { instance, getUtxosSpy } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); - const accounts = generateAccounts(2); + const accounts = generateAccounts(1); const sender = accounts[0].address; - const receiver = accounts[1].address; getUtxosSpy.mockRejectedValue(new Error('error')); - await expect( - txService.getDataForTransaction(sender, { - amounts: { - [receiver]: 100, - }, - subtractFeeFrom: [], - replaceable: true, - }), - ).rejects.toThrow(BtcOnChainServiceError); + await expect(txService.getDataForTransaction(sender)).rejects.toThrow( + BtcOnChainServiceError, + ); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index d22aa65e..4817481c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -1,25 +1,62 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; -import type { - FeeRatio, - IOnChainService, - AssetBalances, - TransactionIntent, - Fees, - TransactionData, - CommitedTransaction, -} from '../../chain'; import { Caip2Asset } from '../../constants'; import { compactError } from '../../utils'; +import type { FeeRatio, TransactionStatus } from './constants'; import type { IDataClient } from './data-client'; import { BtcOnChainServiceError } from './exceptions'; +export type TransactionStatusData = { + status: TransactionStatus; +}; + +export type Balance = { + amount: bigint; +}; + +export type AssetBalances = { + balances: { + [address: string]: { + [asset: string]: Balance; + }; + }; +}; + +export type Fee = { + type: FeeRatio; + rate: bigint; +}; + +export type Fees = { + fees: { + type: FeeRatio; + rate: bigint; + }[]; +}; + +export type Utxo = { + block: number; + txHash: string; + index: number; + value: number; +}; + +export type TransactionData = { + data: { + utxos: Utxo[]; + }; +}; + +export type CommitedTransaction = { + transactionId: string; +}; + export type BtcOnChainServiceOptions = { network: Network; }; -export class BtcOnChainService implements IOnChainService { +export class BtcOnChainService { protected readonly _dataClient: IDataClient; protected readonly _options: BtcOnChainServiceOptions; @@ -33,6 +70,13 @@ export class BtcOnChainService implements IOnChainService { return this._options.network; } + /** + * Gets the balances for multiple addresses and multiple assets. + * + * @param addresses - An array of addresses to fetch the balances for. + * @param assets - An array of assets to fetch the balances of. + * @returns A promise that resolves to an `AssetBalances` object. + */ async getBalances( addresses: string[], assets: string[], @@ -70,6 +114,11 @@ export class BtcOnChainService implements IOnChainService { } } + /** + * Gets the fee rates of the network. + * + * @returns A promise that resolves to a `Fees` object. + */ async getFeeRates(): Promise { try { const result = await this._dataClient.getFeeRates(); @@ -87,7 +136,13 @@ export class BtcOnChainService implements IOnChainService { } } - async getTransactionStatus(txHash: string) { + /** + * Gets the status of a transaction with the given transaction hash. + * + * @param txHash - The transaction hash of the transaction to get the status of. + * @returns A promise that resolves to a `TransactionStatusData` object. + */ + async getTransactionStatus(txHash: string): Promise { try { return await this._dataClient.getTransactionStatus(txHash); } catch (error) { @@ -95,11 +150,13 @@ export class BtcOnChainService implements IOnChainService { } } - async getDataForTransaction( - address: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - transactionIntent?: TransactionIntent, - ): Promise { + /** + * Gets the required metadata to build a transaction for the given address and transaction intent. + * + * @param address - The address to build the transaction for. + * @returns A promise that resolves to a `TransactionData` object. + */ + async getDataForTransaction(address: string): Promise { try { const data = await this._dataClient.getUtxos(address); return { @@ -112,6 +169,12 @@ export class BtcOnChainService implements IOnChainService { } } + /** + * Broadcasts a signed transaction on the blockchain network. + * + * @param signedTransaction - A signed transaction string. + * @returns A promise that resolves to a `CommitedTransaction` object. + */ async broadcastTransaction( signedTransaction: string, ): Promise { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts index e49eb86e..3cb4dcf3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts @@ -2,10 +2,10 @@ import type { Network, Payment } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; -import type { IAccountSigner } from '../../wallet'; -import { ScriptType } from '../constants'; -import * as utils from '../utils/payment'; import { P2WPKHAccount } from './account'; +import { ScriptType } from './constants'; +import type { AccountSigner } from './signer'; +import * as utils from './utils/payment'; describe('BtcAccount', () => { const createMockPaymentInstance = (address: string | undefined) => { @@ -31,7 +31,7 @@ describe('BtcAccount', () => { network, P2WPKHAccount.scriptType, `bip122:${P2WPKHAccount.scriptType.toLowerCase()}`, - { sign: signerSpy } as unknown as IAccountSigner, + { sign: signerSpy } as unknown as AccountSigner, ); return { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts index 09c33648..fbde1ef8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts @@ -2,17 +2,10 @@ import type { Network, Payment } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; import { hexToBuffer } from '../../utils'; -import type { IAccount, IAccountSigner } from '../../wallet'; -import { ScriptType } from '../constants'; -import { getBtcPaymentInst } from '../utils'; -import type { StaticImplements } from './static'; - -export type IBtcAccount = IAccount & { - payment: Payment; - scriptType: ScriptType; - network: Network; - script: Buffer; -}; +import type { StaticImplements } from '../../utils/static'; +import { ScriptType } from './constants'; +import type { AccountSigner } from './signer'; +import { getBtcPaymentInst } from './utils'; export type IStaticBtcAccount = { path: string[]; @@ -25,30 +18,48 @@ export type IStaticBtcAccount = { network: Network, scriptType: ScriptType, type: string, - signer: IAccountSigner, - ): IBtcAccount; + signer: AccountSigner, + ): BtcAccount; }; -export abstract class BtcAccount implements IBtcAccount { +export abstract class BtcAccount { #address: string; #payment: Payment; + readonly network: Network; + + readonly scriptType: ScriptType; + + /** + * The master fingerprint of the derived node, as a string. + */ readonly mfp: string; + /** + * The index of the derived node, as a number. + */ readonly index: number; + /** + * The HD path of the account, as a string. + */ readonly hdPath: string; + /** + * The public key of the account, as a string. + */ readonly pubkey: string; - readonly network: Network; - - readonly scriptType: ScriptType; - + /** + * The type of the account, e.g. `bip122:p2pwh`, as a string. + */ readonly type: string; - readonly signer: IAccountSigner; + /** + * The `IAccountSigner` object derived from the root node. + */ + readonly signer: AccountSigner; constructor( mfp: string, @@ -58,7 +69,7 @@ export abstract class BtcAccount implements IBtcAccount { network: Network, scriptType: ScriptType, type: string, - signer: IAccountSigner, + signer: AccountSigner, ) { this.mfp = mfp; this.index = index; @@ -70,11 +81,21 @@ export abstract class BtcAccount implements IBtcAccount { this.type = type; } + /** + * A getter function to return the correcsponing account type's output script. + * + * @returns The correcsponing account type's output script. + */ get script(): Buffer { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return this.payment.output!; } + /** + * A getter function to return the correcsponing account type's address. + * + * @returns The correcsponing account type's address. + */ get address(): string { if (!this.#address) { if (!this.payment.address) { @@ -85,6 +106,11 @@ export abstract class BtcAccount implements IBtcAccount { return this.#address; } + /** + * A getter function to return the correcsponing account type's payment instance. + * + * @returns The correcsponing account type's payment instance. + */ get payment(): Payment { if (!this.#payment) { this.#payment = getBtcPaymentInst( diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts index 0e123f68..c821fbf4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts @@ -1,8 +1,8 @@ import { networks, address as addressUtils } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; -import { ScriptType } from '../constants'; import { CoinSelectService } from './coin-select'; +import { ScriptType } from './constants'; import { BtcAccountDeriver } from './deriver'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index 2514e825..bcfe0d97 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -18,12 +18,21 @@ export class CoinSelectService { this._feeRate = Math.round(feeRate); } + /** + * This function selects the UTXOs that will be used to pay for a transaction and its associated gas fee. + * + * @param inputs - An array of input objects. + * @param outputs - An array of output objects. + * @param changeTo - The change output object. + * @returns A SelectionResult object that includes the calculated transaction fee, selected inputs, outputs, and change (if any). + * @throws {TxValidationError} Throws a TxValidationError if there are insufficient funds to complete the transaction. + */ selectCoins( inputs: TxInput[], - recipients: TxOutput[], + outputs: TxOutput[], changeTo: TxOutput, ): SelectionResult { - const result = coinSelect(inputs, recipients, this._feeRate); + const result = coinSelect(inputs, outputs, this._feeRate); if (!result.inputs || !result.outputs) { throw new TxValidationError('Insufficient funds'); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/constants.ts similarity index 90% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/constants.ts index 0aa18b3d..7fb64ebb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/constants.ts @@ -29,9 +29,3 @@ export const DustLimit = { // Maximum weight in bytes for a standard transaction export const MaxStandardTxWeight = 400000; - -// Maximum amount of satoshis -export const maxSatoshi = 21 * 1e14; - -// Minimum amount of satoshis -export const minSatoshi = 1; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts index 09970973..75f9692f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts @@ -99,7 +99,7 @@ describe('BtcAccountDeriver', () => { expect(result.privateKey).toStrictEqual(pkBuffer); }); - it('throws `Private key is invalid` if private key is invalid', async () => { + it('throws error if private key is invalid', async () => { const network = networks.testnet; const spy = jest.spyOn(strUtils, 'hexToBuffer'); spy.mockImplementation(() => { @@ -108,11 +108,11 @@ describe('BtcAccountDeriver', () => { const { deriver } = await prepareBip32Deriver(network); await expect(deriver.getRoot(P2WPKHAccount.path)).rejects.toThrow( - 'Private key is invalid', + 'error', ); }); - it('throws `Chain code is invalid` if chain code is invalid', async () => { + it('throws error if chain code is invalid', async () => { const network = networks.testnet; const spy = jest.spyOn(strUtils, 'hexToBuffer'); spy @@ -123,7 +123,7 @@ describe('BtcAccountDeriver', () => { const { deriver } = await prepareBip32Deriver(network); await expect(deriver.getRoot(P2WPKHAccount.path)).rejects.toThrow( - 'Chain code is invalid', + 'error', ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts index 1e5507e5..18809e37 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts @@ -7,11 +7,17 @@ import type { Buffer } from 'buffer'; import { compactError, hexToBuffer, getBip32Deriver } from '../../utils'; import { DeriverError } from './exceptions'; +/** + * This class provides a mechanism for deriving Bitcoin accounts using BIP32. + */ export class BtcAccountDeriver { protected readonly _network: Network; protected readonly _bip32Api: BIP32API; + /** + * The curve to use for account derivation. Defaults to 'secp256k1'. + */ readonly curve: 'secp256k1' | 'ed25519' = 'secp256k1'; constructor(network: Network) { @@ -19,6 +25,13 @@ export class BtcAccountDeriver { this._network = network; } + /** + * Gets the root node of a BIP32 account given a path. + * + * @param path - The BIP32 path to use for derivation. + * @returns The root node of the BIP32 account. + * @throws {DeriverError} Throws a DeriverError if the private key is missing or if the BIP32 node cannot be constructed from the private key. + */ async getRoot(path: string[]) { try { const deriver = await getBip32Deriver(path, this.curve); @@ -27,8 +40,8 @@ export class BtcAccountDeriver { throw new DeriverError('Deriver private key is missing'); } - const privateKeyBuffer = this.pkToBuf(deriver.privateKey); - const chainCodeBuffer = this.chainCodeToBuf(deriver.chainCode); + const privateKeyBuffer = hexToBuffer(deriver.privateKey); + const chainCodeBuffer = hexToBuffer(deriver.chainCode); const root = this.createBip32FromPrivateKey( privateKeyBuffer, @@ -49,6 +62,14 @@ export class BtcAccountDeriver { } } + /** + * Creates a BIP32 node from a private key and chain node. + * + * @param privateKey - The private key buffer. + * @param chainNode - The chain node buffer. + * @returns The BIP32 node. + * @throws {DeriverError} Throws a DeriverError if the BIP32 node cannot be constructed from the private key. + */ createBip32FromPrivateKey( privateKey: Buffer, chainNode: Buffer, @@ -64,23 +85,14 @@ export class BtcAccountDeriver { } } + /** + * Gets a child node of a BIP32 account given an index. + * + * @param root - The root node of the BIP32 account. + * @param idx - The index of the child node to get. + * @returns A promise that resolves to the child node. + */ async getChild(root: BIP32Interface, idx: number): Promise { return Promise.resolve(root.deriveHardened(0).derive(0).derive(idx)); } - - protected pkToBuf(pk: string): Buffer { - try { - return hexToBuffer(pk); - } catch (error) { - throw new DeriverError('Private key is invalid'); - } - } - - protected chainCodeToBuf(chainCode: string): Buffer { - try { - return hexToBuffer(chainCode); - } catch (error) { - throw new DeriverError('Chain code is invalid'); - } - } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts index 22617161..206909fa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts @@ -3,8 +3,10 @@ export * from './account'; export * from './signer'; export * from './deriver'; export * from './wallet'; -export * from './transaction-info'; export * from './coin-select'; export * from './psbt'; +export * from './transaction-info'; export * from './transaction-input'; export * from './transaction-output'; +export * from './utils'; +export * from './constants'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts index 3ecf5539..e77b195b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts @@ -2,7 +2,7 @@ import { Psbt, Transaction, networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { hexToBuffer } from '../../utils'; -import { MaxStandardTxWeight, ScriptType } from '../constants'; +import { MaxStandardTxWeight, ScriptType } from './constants'; import { BtcAccountDeriver } from './deriver'; import { PsbtServiceError } from './exceptions'; import { PsbtService } from './psbt'; @@ -151,7 +151,7 @@ describe('PsbtService', () => { } }); - it('throws `Failed to add inputs in PSBT` error if adding inputs fails', async () => { + it('throws `Failed to add input in PSBT` error if adding inputs fails', async () => { const { service, inputSpy, sender, inputs } = await preparePsbt(); inputSpy.mockImplementation(() => { throw new Error('error'); @@ -165,7 +165,7 @@ describe('PsbtService', () => { hexToBuffer(sender.pubkey, false), hexToBuffer(sender.mfp), ); - }).toThrow('Failed to add inputs in PSBT'); + }).toThrow('Failed to add input in PSBT'); }); }); @@ -190,14 +190,14 @@ describe('PsbtService', () => { } }); - it('throws `Failed to add outputs in PSBT` error if adding outputs fails', async () => { + it('throws `Failed to add output in PSBT` error if adding outputs fails', async () => { const { service, outputSpy, outputs } = await preparePsbt(); outputSpy.mockImplementation(() => { throw new Error('error'); }); expect(() => service.addOutputs(outputs)).toThrow( - 'Failed to add outputs in PSBT', + 'Failed to add output in PSBT', ); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index eb92a8b2..e3fd9b05 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -1,13 +1,12 @@ import ecc from '@bitcoinerlab/secp256k1'; -import type { Network } from 'bitcoinjs-lib'; +import type { HDSignerAsync, Network } from 'bitcoinjs-lib'; import { Psbt, Transaction } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; import ECPairFactory from 'ecpair'; import { compactError, logger } from '../../utils'; -import type { IAccountSigner } from '../../wallet'; -import { MaxStandardTxWeight } from '../constants'; -import { PsbtServiceError, TxValidationError } from './exceptions'; +import { MaxStandardTxWeight } from './constants'; +import { PsbtServiceError } from './exceptions'; import type { TxInput } from './transaction-input'; import type { TxOutput } from './transaction-output'; @@ -31,12 +30,29 @@ export class PsbtService { this._network = network; } + /** + * Creates a new instance of the `PsbtService` class from a base64-encoded PSBT string and a network. + * + * @param network - The network to use for the PSBT. + * @param base64Psbt - The base64-encoded PSBT string. + * @returns A new instance of the `PsbtService` class. + */ static fromBase64(network: Network, base64Psbt: string): PsbtService { const psbt = Psbt.fromBase64(base64Psbt, { network }); const service = new PsbtService(network, psbt); return service; } + /** + * Adds an input to the PSBT. + * + * @param input - The transaction input to add. + * @param replaceable - Whether or not the transaction is replaceable. + * @param changeAddressHdPath - The HD path of the change address. + * @param changeAddressPubkey - The public key of the change address. + * @param changeAddressMfp - The master fingerprint of the change address. + * @throws {PsbtServiceError} If there was an error adding the input to the PSBT. + */ addInput( input: TxInput, replaceable: boolean, @@ -77,6 +93,15 @@ export class PsbtService { } } + /** + * Adds multiple inputs to the PSBT. + * + * @param inputs - An array of transaction inputs to add. + * @param replaceable - Whether or not the transactions are replaceable. + * @param changeAddressHdPath - The HD path of the change address. + * @param changeAddressPubkey - The public key of the change address. + * @param changeAddressMfp - The master fingerprint of the change address. + */ addInputs( inputs: TxInput[], replaceable: boolean, @@ -84,22 +109,23 @@ export class PsbtService { changeAddressPubkey: Buffer, changeAddressMfp: Buffer, ) { - try { - for (const input of inputs) { - this.addInput( - input, - replaceable, - changeAddressHdPath, - changeAddressPubkey, - changeAddressMfp, - ); - } - } catch (error) { - logger.error('Failed to add inputs', error); - throw new PsbtServiceError('Failed to add inputs in PSBT'); + for (const input of inputs) { + this.addInput( + input, + replaceable, + changeAddressHdPath, + changeAddressPubkey, + changeAddressMfp, + ); } } + /** + * Adds an output to the PSBT. + * + * @param output - The transaction output to add. + * @throws {PsbtServiceError} If there was an error adding the output to the PSBT. + */ addOutput(output: TxOutput) { try { this._psbt.addOutput({ @@ -112,17 +138,23 @@ export class PsbtService { } } + /** + * Adds multiple outputs to the PSBT. + * + * @param outputs - An array of transaction outputs to add. + */ addOutputs(outputs: TxOutput[]) { - try { - for (const output of outputs) { - this.addOutput(output); - } - } catch (error) { - logger.error('Failed to add outputs', error); - throw new PsbtServiceError('Failed to add outputs in PSBT'); + for (const output of outputs) { + this.addOutput(output); } } + /** + * Gets the fee for the PSBT. + * + * @returns The fee for the PSBT. + * @throws {PsbtServiceError} If there was an error getting the fee from the PSBT. + */ getFee(): number { try { return this._psbt.getFee(); @@ -132,7 +164,14 @@ export class PsbtService { } } - async signDummy(signer: IAccountSigner): Promise { + /** + * Signs all inputs in the PSBT with a dummy signature using an asynchronous signer. + * + * @param signer - The asynchronous signer to use. + * @returns A promise that resolves with a new `PsbtService` instance with the signed inputs. + * @throws {PsbtServiceError} If there was an error signing the inputs in the PSBT. + */ + async signDummy(signer: HDSignerAsync): Promise { try { const psbt = this._psbt.clone(); await psbt.signAllInputsHDAsync(signer); @@ -144,6 +183,12 @@ export class PsbtService { } } + /** + * Converts the PSBT to a base64-encoded string. + * + * @returns The base64-encoded string representation of the PSBT. + * @throws {PsbtServiceError} If there was an error converting the PSBT to a base64-encoded string. + */ toBase64(): string { try { return this._psbt.toBase64(); @@ -153,7 +198,13 @@ export class PsbtService { } } - async signNVerify(signer: IAccountSigner) { + /** + * Signs all inputs in the PSBT and verifies that the signatures are valid using an asynchronous signer. + * + * @param signer - The asynchronous signer to use. + * @throws {PsbtServiceError} If there was an error signing or verifying the PSBT's inputs. + */ + async signNVerify(signer: HDSignerAsync) { try { // This function signAllInputsHDAsync is used to sign all inputs with the signer. // When using the method signAllInputsHDAsync, it is important to note that the signer must derive from the root node as well as the finderprint. @@ -166,7 +217,7 @@ export class PsbtService { this.validateInputs(pubkey, msghash, signature), ) ) { - throw new TxValidationError( + throw new PsbtServiceError( "Invalid signature to sign the PSBT's inputs", ); } @@ -175,6 +226,12 @@ export class PsbtService { } } + /** + * Finalizes the PSBT and returns the resulting transaction in hexadecimal format. + * + * @returns The transaction in hexadecimal format. + * @throws {PsbtServiceError} If there was an error finalizing the PSBT. + */ finalize(): string { try { this._psbt.finalizeAllInputs(); @@ -184,7 +241,7 @@ export class PsbtService { const weight = this._psbt.extractTransaction().weight(); if (weight > MaxStandardTxWeight) { - throw new TxValidationError('Transaction is too large'); + throw new PsbtServiceError('Transaction is too large'); } return txHex; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts index 53ee12d5..4d59f57e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts @@ -2,11 +2,18 @@ import type { BIP32Interface } from 'bip32'; import type { HDSignerAsync } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; -import type { IAccountSigner } from '../../wallet'; - -export class AccountSigner implements HDSignerAsync, IAccountSigner { +/** + * An Signer object that defines methods and properties for signing transactions and verifying signatures in PSBT sign process. + */ +export class AccountSigner implements HDSignerAsync { + /** + * The public key of the current derived node used for verifying signatures, as a Buffer. + */ readonly publicKey: Buffer; + /** + * The fingerprint of the current derived node used for verifying signatures, as a Buffer. + */ readonly fingerprint: Buffer; protected readonly _node: BIP32Interface; @@ -17,7 +24,13 @@ export class AccountSigner implements HDSignerAsync, IAccountSigner { this.fingerprint = mfp ?? this._node.fingerprint; } - derivePath(path: string): IAccountSigner { + /** + * Derives a new `IAccountSigner` object using an HD path. + * + * @param path - The HD path in string format, e.g. `m'\0'\0`. + * @returns A new `IAccountSigner` object derived by the given path. + */ + derivePath(path: string): AccountSigner { try { let splitPath = path.split('/'); if (splitPath[0] === 'm') { @@ -38,10 +51,23 @@ export class AccountSigner implements HDSignerAsync, IAccountSigner { } } + /** + * Signs a transaction hash. + * + * @param hash - The buffer containing the transaction hash to sign. + * @returns A promise that resolves to the signed result as a Buffer. + */ async sign(hash: Buffer): Promise { return this._node.sign(hash); } + /** + * Verifies a signature using the derived node of an `IAccountSigner` object. + * + * @param hash - The buffer containing the transaction hash. + * @param signature - The buffer containing the signature to verify. + * @returns A boolean indicating whether the signature is valid. + */ verify(hash: Buffer, signature: Buffer): boolean { return this._node.verify(hash, signature); } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts index 01a21469..0358cfab 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts @@ -1,48 +1,97 @@ import { Buffer } from 'buffer'; import { generateAccounts } from '../../../test/utils'; -import { BtcTxInfo } from './transaction-info'; +import { TxInfo } from './transaction-info'; import { TxOutput } from './transaction-output'; -describe('BtcTxInfo', () => { - describe('toJson', () => { - it('returns a json', async () => { - const accounts = generateAccounts(5); - const addresses = accounts.map((account) => account.address); - const fee = 10000; - const feeRate = 100; - let total = fee; - const outputs: TxOutput[] = []; - const sender = addresses[0]; - - const info = new BtcTxInfo(sender, feeRate); - info.txFee = fee; - - for (let i = 1; i < addresses.length; i++) { - total += 100000; - const output = new TxOutput(100000, addresses[i], Buffer.from('dummy')); - outputs.push(output); - } - - const change = new TxOutput(500, addresses[0], Buffer.from('dummy')); - - info.addRecipients(outputs); - info.addChange(change); - total += 500; - - const expectedRecipients = outputs.map((recipient) => { - return { - address: recipient.address, - value: recipient.bigIntValue, - }; - }); - - expect(info.total).toBe(BigInt(total)); - expect(info.sender).toStrictEqual(sender); - expect(info.recipients).toHaveLength(expectedRecipients.length); - expect(info.change).toBeDefined(); - expect(info.txFee).toBe(BigInt(fee)); - expect(info.feeRate).toBe(BigInt(feeRate)); +describe('TxInfo', () => { + it('accumulated total and add `TxOutput[]` as `Recipient[]`', async () => { + const accounts = generateAccounts(5); + const addresses = accounts.map((account) => account.address); + const fee = 10000; + const feeRate = 100; + let total = fee; + const outputs: TxOutput[] = []; + const sender = addresses[0]; + + const info = new TxInfo(sender, feeRate); + info.txFee = fee; + + for (let i = 1; i < addresses.length; i++) { + total += 100000; + const output = new TxOutput(100000, addresses[i], Buffer.from('dummy')); + outputs.push(output); + } + + info.addRecipients(outputs); + + const expectedRecipients = outputs.map((recipient) => { + return { + address: recipient.address, + value: recipient.bigIntValue, + }; }); + + expect(info.total).toBe(BigInt(total)); + expect(info.sender).toStrictEqual(sender); + expect(info.recipients).toHaveLength(expectedRecipients.length); + }); + + it('accumulated total with change', async () => { + const accounts = generateAccounts(5); + const addresses = accounts.map((account) => account.address); + const fee = 10000; + const feeRate = 100; + let total = fee; + const outputs: TxOutput[] = []; + const sender = addresses[0]; + + const info = new TxInfo(sender, feeRate); + info.txFee = fee; + + for (let i = 1; i < addresses.length; i++) { + total += 100000; + const output = new TxOutput(100000, addresses[i], Buffer.from('dummy')); + outputs.push(output); + } + + const change = new TxOutput(500, addresses[0], Buffer.from('dummy')); + + info.addRecipients(outputs); + info.addChange(change); + total += 500; + + expect(info.total).toBe(BigInt(total)); + expect(info.change).toBeDefined(); + }); + + it('converts number input to bigint', async () => { + const accounts = generateAccounts(1); + const addresses = accounts.map((account) => account.address); + const fee = 10000; + const feeRate = 100; + const sender = addresses[0]; + + const info = new TxInfo(sender, feeRate); + info.txFee = fee; + info.feeRate = feeRate; + + expect(info.txFee).toBe(BigInt(fee)); + expect(info.feeRate).toBe(BigInt(feeRate)); + }); + + it('does not convert bigint input to bigint', async () => { + const accounts = generateAccounts(1); + const addresses = accounts.map((account) => account.address); + const fee = 10000; + const feeRate = 100; + const sender = addresses[0]; + + const info = new TxInfo(sender, feeRate); + info.txFee = BigInt(fee); + info.feeRate = BigInt(feeRate); + + expect(info.txFee).toBe(BigInt(fee)); + expect(info.feeRate).toBe(BigInt(feeRate)); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts index e24ef6fd..1f74c61e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts @@ -1,7 +1,7 @@ -import type { ITxInfo, Recipient } from '../../wallet'; import type { TxOutput } from './transaction-output'; +import type { ITxInfo, Recipient } from './wallet'; -export class BtcTxInfo implements ITxInfo { +export class TxInfo implements ITxInfo { readonly sender: string; protected _change?: Recipient; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts index aac055c3..99713d89 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts @@ -1,7 +1,7 @@ import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; -import { ScriptType } from '../constants'; +import { ScriptType } from './constants'; import { BtcAccountDeriver } from './deriver'; import { TxInput } from './transaction-input'; import { BtcWallet } from './wallet'; @@ -31,4 +31,16 @@ describe('TxInput', () => { expect(input.index).toStrictEqual(utxo.index); expect(input.block).toStrictEqual(utxo.block); }); + + it('return bigint val', async () => { + const wallet = createMockWallet(networks.testnet); + const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); + const { script } = account; + + const utxo = generateFormatedUtxos(account.address, 1)[0]; + + const input = new TxInput(utxo, script); + + expect(input.bigIntValue).toStrictEqual(BigInt(utxo.value)); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts index 06134591..e6b0bd1c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts @@ -1,6 +1,6 @@ import type { Buffer } from 'buffer'; -import type { Utxo } from '../../chain'; +import type { Utxo } from '../chain'; export class TxInput { protected _value: bigint; @@ -16,7 +16,7 @@ export class TxInput { constructor(utxo: Utxo, script: Buffer) { this.script = script; - this.value = utxo.value; + this._value = BigInt(utxo.value); this.index = utxo.index; this.txHash = utxo.txHash; this.block = utxo.block; @@ -27,14 +27,6 @@ export class TxInput { return Number(this._value); } - set value(value: bigint | number) { - if (typeof value === 'number') { - this._value = BigInt(value); - } else { - this._value = value; - } - } - get bigIntValue(): bigint { return this._value; } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/index.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.test.ts similarity index 96% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.test.ts index 279d4297..e60ddc65 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.test.ts @@ -1,6 +1,6 @@ import { networks } from 'bitcoinjs-lib'; -import { Caip2ChainId } from '../../constants'; +import { Caip2ChainId } from '../../../constants'; import { getBtcNetwork, getCaip2ChainId } from './network'; describe('getBtcNetwork', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.ts similarity index 95% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.ts index 9900e71e..7b8b4f25 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.ts @@ -1,7 +1,7 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; -import { Caip2ChainId } from '../../constants'; +import { Caip2ChainId } from '../../../constants'; /** * Gets a bitcoinjs-lib network instance based on a given CAIP-2 Chain ID. diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index 269fd8f6..397c7444 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -1,12 +1,12 @@ import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; -import { DustLimit, ScriptType, maxSatoshi } from '../constants'; import { P2SHP2WPKHAccount, P2WPKHAccount } from './account'; import { CoinSelectService } from './coin-select'; +import { DustLimit, ScriptType } from './constants'; import { BtcAccountDeriver } from './deriver'; import { WalletError } from './exceptions'; -import { BtcTxInfo } from './transaction-info'; +import { TxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; import { BtcWallet } from './wallet'; @@ -47,7 +47,19 @@ describe('BtcWallet', () => { }; describe('unlock', () => { - it('returns an `Account` objec with type bip122:p2wpkh', async () => { + it('returns an `Account` object with defualt type', async () => { + const network = networks.testnet; + const { rootSpy, childSpy, instance } = createMockWallet(network); + const idx = 0; + + const result = await instance.unlock(idx); + + expect(result).toBeInstanceOf(P2WPKHAccount); + expect(rootSpy).toHaveBeenCalledWith(P2WPKHAccount.path); + expect(childSpy).toHaveBeenCalledWith(expect.any(Object), idx); + }); + + it('returns an `Account` object with type bip122:p2wpkh', async () => { const network = networks.testnet; const { rootSpy, childSpy, instance } = createMockWallet(network); const idx = 0; @@ -59,7 +71,7 @@ describe('BtcWallet', () => { expect(childSpy).toHaveBeenCalledWith(expect.any(Object), idx); }); - it('returns an `Account` objec with type `p2wpkh`', async () => { + it('returns an `Account` object with type `p2wpkh`', async () => { const network = networks.testnet; const { rootSpy, childSpy, instance } = createMockWallet(network); const idx = 0; @@ -101,16 +113,11 @@ describe('BtcWallet', () => { const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos( - account.address, - 200, - maxSatoshi, - maxSatoshi, - ); + const utxos = generateFormatedUtxos(account.address, 200, 100000, 100000); const result = await wallet.createTransaction( account, - createMockTxIndent(account.address, maxSatoshi), + createMockTxIndent(account.address, 100000), { utxos, fee: 56, @@ -127,7 +134,7 @@ describe('BtcWallet', () => { expect(change).toBeDefined(); expect(result).toStrictEqual({ tx: expect.any(String), - txInfo: expect.any(BtcTxInfo), + txInfo: expect.any(TxInfo), }); }); @@ -157,7 +164,7 @@ describe('BtcWallet', () => { expect(change).toBeUndefined(); expect(result).toStrictEqual({ tx: expect.any(String), - txInfo: expect.any(BtcTxInfo), + txInfo: expect.any(TxInfo), }); }); @@ -234,7 +241,7 @@ describe('BtcWallet', () => { }, ); - const info: BtcTxInfo = result.txInfo as unknown as BtcTxInfo; + const info: TxInfo = result.txInfo as unknown as TxInfo; expect(info.txFee).toBe(BigInt(19500)); expect(info.change).toBeUndefined(); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 0dadb1c0..24c2023f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -1,30 +1,43 @@ import type { BIP32Interface } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; -import type { Utxo } from '../../chain'; import { bufferToString, compactError, hexToBuffer, logger } from '../../utils'; -import type { - IAccountSigner, - IWallet, - Recipient, - Transaction, -} from '../../wallet'; -import { ScriptType } from '../constants'; -import { isDust, getScriptForDestnation } from '../utils'; +import type { Utxo } from '../chain'; +import type { BtcAccount } from './account'; import { P2WPKHAccount, P2SHP2WPKHAccount, type IStaticBtcAccount, - type IBtcAccount, } from './account'; import { CoinSelectService } from './coin-select'; +import { ScriptType } from './constants'; import type { BtcAccountDeriver } from './deriver'; import { WalletError, TxValidationError } from './exceptions'; import { PsbtService } from './psbt'; import { AccountSigner } from './signer'; -import { BtcTxInfo } from './transaction-info'; +import { TxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; +import { isDust, getScriptForDestnation } from './utils'; + +export type Recipient = { + address: string; + value: bigint; +}; + +export type Transaction = { + tx: string; + txInfo: ITxInfo; +}; + +export type ITxInfo = { + sender: string; + change?: Recipient; + recipients: Recipient[]; + total: bigint; + txFee: bigint; + feeRate: bigint; +}; export type CreateTransactionOptions = { utxos: Utxo[]; @@ -36,7 +49,7 @@ export type CreateTransactionOptions = { replaceable: boolean; }; -export class BtcWallet implements IWallet { +export class BtcWallet { protected readonly _deriver: BtcAccountDeriver; protected readonly _network: Network; @@ -46,7 +59,14 @@ export class BtcWallet implements IWallet { this._network = network; } - async unlock(index: number, type?: string): Promise { + /** + * Unlocks an account by index and script type. + * + * @param index - The index to derive from the node. + * @param type - The script type of the unlocked account, e.g. `bip122:p2pkh`. + * @returns A promise that resolves to an `IAccount` object. + */ + async unlock(index: number, type?: string): Promise { try { const AccountCtor = this.getAccountCtor(type ?? ScriptType.P2wpkh); const rootNode = await this._deriver.getRoot(AccountCtor.path); @@ -68,8 +88,16 @@ export class BtcWallet implements IWallet { } } + /** + * Creates a transaction using the given account, transaction intent, and options. + * + * @param account - The `IAccount` object to create the transaction. + * @param recipients - The transaction recipients. + * @param options - The options to use when creating the transaction. + * @returns A promise that resolves to an object containing the transaction hash and transaction info. + */ async createTransaction( - account: IBtcAccount, + account: BtcAccount, recipients: Recipient[], options: CreateTransactionOptions, ): Promise { @@ -113,7 +141,7 @@ export class BtcWallet implements IWallet { hexToBuffer(account.mfp, false), ); - const txInfo = new BtcTxInfo(account.address, feeRate); + const txInfo = new TxInfo(account.address, feeRate); // TODO: add support of subtractFeeFrom, and throw error if output is too small after subtraction for (const output of selectionResult.outputs) { @@ -142,7 +170,14 @@ export class BtcWallet implements IWallet { }; } - async signTransaction(signer: IAccountSigner, tx: string): Promise { + /** + * Signs a transaction by the given encoded transaction string. + * + * @param signer - The `AccountSigner` object to sign the transaction. + * @param tx - The encoded transaction string to convert back to a transaction. + * @returns A promise that resolves to a string of the signed transaction. + */ + async signTransaction(signer: AccountSigner, tx: string): Promise { const psbtService = PsbtService.fromBase64(this._network, tx); await psbtService.signNVerify(signer); return psbtService.finalize(); diff --git a/merged-packages/bitcoin-wallet-snap/src/chain.ts b/merged-packages/bitcoin-wallet-snap/src/chain.ts deleted file mode 100644 index a0a57418..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/chain.ts +++ /dev/null @@ -1,111 +0,0 @@ -export enum FeeRatio { - Fast = 'fast', - Medium = 'medium', - Slow = 'slow', -} - -export enum TransactionStatus { - Confirmed = 'confirmed', - Pending = 'pending', - Failed = 'failed', -} - -export type TransactionStatusData = { - status: TransactionStatus; -}; - -export type Balance = { - amount: bigint; -}; - -export type AssetBalances = { - balances: { - [address: string]: { - [asset: string]: Balance; - }; - }; -}; - -export type Fee = { - type: FeeRatio; - rate: bigint; -}; - -export type Fees = { - fees: Fee[]; -}; - -export type TransactionIntent = { - amounts: Record; - subtractFeeFrom: string[]; - replaceable: boolean; -}; - -export type TransactionData = { - data: { - utxos: Utxo[]; - }; -}; - -export type CommitedTransaction = { - transactionId: string; -}; - -export type Utxo = { - block: number; - txHash: string; - index: number; - value: number; -}; - -/** - * An interface that defines methods for interacting with a blockchain network. - */ -export type IOnChainService = { - /** - * Gets the balances for multiple addresses and multiple assets. - * - * @param addresses - An array of addresses to fetch the balances for. - * @param assets - An array of assets to fetch the balances of. - * @returns A promise that resolves to an `AssetBalances` object. - */ - getBalances(addresses: string[], assets: string[]): Promise; - - /** - * Gets the fee rates of the network. - * - * @returns A promise that resolves to a `Fees` object. - */ - getFeeRates(): Promise; - - /** - * Broadcasts a signed transaction on the blockchain network. - * - * @param signedTransaction - A signed transaction string. - * @returns A promise that resolves to a `CommitedTransaction` object. - */ - broadcastTransaction(signedTransaction: string): Promise; - - /** - * Gets the status of a transaction with the given transaction hash. - * - * @param txHash - The transaction hash of the transaction to get the status of. - * @returns A promise that resolves to a `TransactionStatusData` object. - */ - getTransactionStatus(txHash: string): Promise; - - /** - * Gets the required metadata to build a transaction for the given address and transaction intent. - * - * @param address - The address to build the transaction for. - * @param transactionIntent - The transaction intent object containing the transaction inputs and outputs. - * @returns A promise that resolves to a `TransactionData` object. - */ - getDataForTransaction( - address: string, - transactionIntent?: TransactionIntent, - ): Promise; - - // TODO: Implement listTransactions in next phase - listTransactions(); -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts index efc3087a..1ea11fb8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.ts @@ -1,13 +1,10 @@ import { BtcOnChainService } from './bitcoin/chain'; import { BlockChairClient } from './bitcoin/chain/clients/blockchair'; -import { getBtcNetwork } from './bitcoin/utils'; -import { BtcAccountDeriver, BtcWallet } from './bitcoin/wallet'; -import type { IOnChainService } from './chain'; +import { BtcAccountDeriver, BtcWallet, getBtcNetwork } from './bitcoin/wallet'; import { Config } from './config'; -import type { IWallet } from './wallet'; export class Factory { - static createOnChainServiceProvider(scope: string): IOnChainService { + static createOnChainServiceProvider(scope: string): BtcOnChainService { const btcNetwork = getBtcNetwork(scope); const client = new BlockChairClient({ @@ -20,7 +17,7 @@ export class Factory { }); } - static createWallet(scope: string): IWallet { + static createWallet(scope: string): BtcWallet { const btcNetwork = getBtcNetwork(scope); return new BtcWallet(new BtcAccountDeriver(btcNetwork), btcNetwork); } diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts index ca926944..34620274 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.ts @@ -7,7 +7,7 @@ import { import { onRpcRequest, validateOrigin, onKeyringRequest } from '.'; import * as entry from '.'; -import { TransactionStatus } from './chain'; +import { TransactionStatus } from './bitcoin/chain'; import { Config } from './config'; import { BtcKeyring } from './keyring'; import { originPermissions } from './permissions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts index 2079fd2a..73e842ea 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts @@ -3,8 +3,7 @@ import { MethodNotFoundError } from '@metamask/snaps-sdk'; import { v4 as uuidv4 } from 'uuid'; import { generateAccounts } from '../test/utils'; -import { ScriptType } from './bitcoin/constants'; -import { BtcAccount } from './bitcoin/wallet'; +import { BtcAccount, BtcWallet, ScriptType } from './bitcoin/wallet'; import { Config } from './config'; import { Caip2Asset, Caip2ChainId } from './constants'; import { Factory } from './factory'; @@ -12,7 +11,6 @@ import { BtcKeyring } from './keyring'; import * as getBalanceRpc from './rpcs/get-balances'; import * as sendManyRpc from './rpcs/sendmany'; import { KeyringStateManager } from './stateManagement'; -import type { IWallet } from './wallet'; jest.mock('./utils/logger'); jest.mock('./utils/snap'); @@ -24,20 +22,17 @@ jest.mock('@metamask/keyring-api', () => ({ describe('BtcKeyring', () => { const createMockWallet = () => { - const unlockSpy = jest.fn(); - class Wallet implements IWallet { - unlock = unlockSpy; - - signTransaction = jest.fn(); - - createTransaction = jest.fn(); - } - jest - .spyOn(Factory, 'createWallet') - .mockImplementation() - .mockReturnValue(new Wallet()); + const unlockSpy = jest.spyOn(BtcWallet.prototype, 'unlock'); + const signTransaction = jest.spyOn(BtcWallet.prototype, 'signTransaction'); + const createTransaction = jest.spyOn( + BtcWallet.prototype, + 'createTransaction', + ); + return { unlockSpy, + signTransaction, + createTransaction, }; }; @@ -85,10 +80,6 @@ describe('BtcKeyring', () => { }; }; - const getHdPath = (index: number) => { - return [`m`, `0'`, `0`, `${index}`].join('/'); - }; - const createSender = async (caip2ChainId: string) => { const wallet = Factory.createWallet(caip2ChainId); const sender = await wallet.unlock(0, ScriptType.P2wpkh); @@ -114,38 +105,29 @@ describe('BtcKeyring', () => { const { unlockSpy } = createMockWallet(); const { instance: stateMgr, addWalletSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); - const scope = Caip2ChainId.Testnet; - const account = generateAccounts(1)[0]; - - unlockSpy.mockResolvedValue({ - address: account.address, - hdPath: getHdPath(account.options.index), - index: account.options.index, - type: account.type, - }); + const caip2ChainId = Caip2ChainId.Testnet; + const { sender, keyringAccount } = await createSender(caip2ChainId); await keyring.createAccount({ - scope, + scope: caip2ChainId, }); expect(unlockSpy).toHaveBeenCalledWith( Config.wallet.defaultAccountIndex, Config.wallet.defaultAccountType, ); + expect(addWalletSpy).toHaveBeenCalledWith({ account: { - type: account.type, + type: keyringAccount.type, id: expect.any(String), - address: account.address, - options: { - scope, - index: account.options.index, - }, - methods: account.methods, + address: keyringAccount.address, + options: keyringAccount.options, + methods: keyringAccount.methods, }, - hdPath: getHdPath(account.options.index), - index: account.options.index, - scope, + hdPath: sender.hdPath, + index: sender.index, + scope: caip2ChainId, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index 6ba16239..7939b997 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -13,12 +13,12 @@ import type { Infer } from 'superstruct'; import { assert, object, StructError } from 'superstruct'; import { v4 as uuidv4 } from 'uuid'; +import type { BtcAccount, BtcWallet } from './bitcoin/wallet'; import { Config } from './config'; import { Factory } from './factory'; import { getBalances, type SendManyParams, sendMany } from './rpcs'; import type { KeyringStateManager, Wallet } from './stateManagement'; import { getProvider, scopeStruct, logger } from './utils'; -import type { IAccount, IWallet } from './wallet'; export type KeyringOptions = Record & { defaultIndex: number; @@ -230,20 +230,20 @@ export class BtcKeyring implements Keyring { return walletData; } - protected getBtcWallet(scope: string): IWallet { + protected getBtcWallet(scope: string): BtcWallet { return Factory.createWallet(scope); } protected async discoverAccount( - wallet: IWallet, + wallet: BtcWallet, index: number, type: string, - ): Promise { + ): Promise { return await wallet.unlock(index, type); } protected verifyIfAccountValid( - account: IAccount, + account: BtcAccount, keyringAccount: KeyringAccount, ): void { if (!account || account.address !== keyringAccount.address) { @@ -252,7 +252,7 @@ export class BtcKeyring implements Keyring { } protected newKeyringAccount( - account: IAccount, + account: BtcAccount, options?: CreateAccountOptions, ): KeyringAccount { return { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts index 3001f5b2..29d2c1a7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts @@ -4,10 +4,10 @@ import { networks } from 'bitcoinjs-lib'; import { v4 as uuidv4 } from 'uuid'; import { generateAccounts } from '../../test/utils'; +import { BtcOnChainService } from '../bitcoin/chain'; import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; import { Config } from '../config'; import { Caip2ChainId } from '../constants'; -import { Factory } from '../factory'; import { getBalances } from './get-balances'; jest.mock('../utils/logger'); @@ -16,17 +16,12 @@ jest.mock('../utils/snap'); describe('getBalances', () => { const asset = Config.avaliableAssets[0]; - const createMockChainApiFactory = () => { - const getBalancesSpy = jest.fn(); + const createMockChainService = () => { + const getBalancesSpy = jest.spyOn( + BtcOnChainService.prototype, + 'getBalances', + ); - jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ - getFeeRates: jest.fn(), - getBalances: getBalancesSpy, - broadcastTransaction: jest.fn(), - listTransactions: jest.fn(), - getTransactionStatus: jest.fn(), - getDataForTransaction: jest.fn(), - }); return { getBalancesSpy, }; @@ -75,7 +70,7 @@ describe('getBalances', () => { it('gets balances', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); + const { getBalancesSpy } = createMockChainService(); const { walletData, sender } = await createMockAccount( network, @@ -115,7 +110,7 @@ describe('getBalances', () => { it('gets balances of the request account only', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); + const { getBalancesSpy } = createMockChainService(); const accounts = generateAccounts(10); const { walletData, sender } = await createMockAccount( network, @@ -161,7 +156,7 @@ describe('getBalances', () => { it('throws `Fail to get the balances` when transaction status fetch failed', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); + const { getBalancesSpy } = createMockChainService(); const { sender } = await createMockAccount(network, caip2ChainId); getBalancesSpy.mockRejectedValue(new Error('error')); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts index 412a8238..d9cc8eb5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts @@ -1,6 +1,7 @@ import type { Infer } from 'superstruct'; import { object, array, record, enums, assert } from 'superstruct'; +import type { BtcAccount } from '../bitcoin/wallet'; import { Config } from '../config'; import { Factory } from '../factory'; import { @@ -15,7 +16,6 @@ import { positiveStringStruct, scopeStruct, } from '../utils/superstruct'; -import type { IAccount } from '../wallet'; export const getBalancesRequestStruct = object({ assets: array(assetsStruct), @@ -44,7 +44,7 @@ export type GetBalancesResponse = Infer; * @returns A Promise that resolves to an GetBalancesResponse object. */ export async function getBalances( - account: IAccount, + account: BtcAccount, params: GetBalancesParams, ) { try { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts index 8271c47c..138c76ce 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts @@ -1,8 +1,7 @@ import { InvalidParamsError } from '@metamask/snaps-sdk'; -import { TransactionStatus } from '../chain'; +import { BtcOnChainService, TransactionStatus } from '../bitcoin/chain'; import { Caip2ChainId } from '../constants'; -import { Factory } from '../factory'; import { getTransactionStatus } from './get-transaction-status'; jest.mock('../utils/logger'); @@ -11,24 +10,18 @@ describe('getTransactionStatus', () => { const txHash = '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; - const createMockChainApiFactory = () => { - const getTransactionStatusSpy = jest.fn(); - - jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ - getFeeRates: jest.fn(), - getBalances: jest.fn(), - broadcastTransaction: jest.fn(), - listTransactions: jest.fn(), - getTransactionStatus: getTransactionStatusSpy, - getDataForTransaction: jest.fn(), - }); + const createMockChainService = () => { + const getTransactionStatusSpy = jest.spyOn( + BtcOnChainService.prototype, + 'getTransactionStatus', + ); return { getTransactionStatusSpy, }; }; it('gets status', async () => { - const { getTransactionStatusSpy } = createMockChainApiFactory(); + const { getTransactionStatusSpy } = createMockChainService(); const mockResp = { status: TransactionStatus.Confirmed, @@ -48,7 +41,7 @@ describe('getTransactionStatus', () => { }); it('throws `Fail to get the transaction status` when transaction status fetch failed', async () => { - const { getTransactionStatusSpy } = createMockChainApiFactory(); + const { getTransactionStatusSpy } = createMockChainService(); getTransactionStatusSpy.mockRejectedValue(new Error('error')); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts index 5b7e89c8..1518575a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts @@ -1,7 +1,7 @@ import type { Infer } from 'superstruct'; import { object, string, enums } from 'superstruct'; -import { TransactionStatus } from '../chain'; +import { TransactionStatus } from '../bitcoin/chain'; import { Factory } from '../factory'; import { isSnapRpcError, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 741fa218..887a7f32 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -9,15 +9,18 @@ import { generateBlockChairBroadcastTransactionResp, generateBlockChairGetUtxosResp, } from '../../test/utils'; -import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; -import { FeeRatio } from '../chain'; +import { BtcOnChainService, FeeRatio } from '../bitcoin/chain'; +import type { BtcAccount } from '../bitcoin/wallet'; +import { + BtcAccountDeriver, + BtcWallet, + type ITxInfo, + TxValidationError, +} from '../bitcoin/wallet'; import { Config } from '../config'; import { Caip2ChainId } from '../constants'; -import { Factory } from '../factory'; -import { getExplorerUrl, shortenAddress } from '../utils'; +import { getExplorerUrl, shortenAddress, satsToBtc } from '../utils'; import * as snapUtils from '../utils/snap'; -import { satsToBtc } from '../utils/unit'; -import type { IAccount, ITxInfo } from '../wallet'; import { type SendManyParams, sendMany } from './sendmany'; jest.mock('../utils/logger'); @@ -26,18 +29,19 @@ jest.mock('../utils/snap'); describe('SendManyHandler', () => { describe('sendMany', () => { const createMockChainApiFactory = () => { - const getDataForTransactionSpy = jest.fn(); - const getFeeRatesSpy = jest.fn(); - const broadcastTransactionSpy = jest.fn(); - - jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ - getFeeRates: getFeeRatesSpy, - getBalances: jest.fn(), - broadcastTransaction: broadcastTransactionSpy, - listTransactions: jest.fn(), - getTransactionStatus: jest.fn(), - getDataForTransaction: getDataForTransactionSpy, - }); + const getFeeRatesSpy = jest.spyOn( + BtcOnChainService.prototype, + 'getFeeRates', + ); + const broadcastTransactionSpy = jest.spyOn( + BtcOnChainService.prototype, + 'broadcastTransaction', + ); + const getDataForTransactionSpy = jest.spyOn( + BtcOnChainService.prototype, + 'getDataForTransaction', + ); + return { getDataForTransactionSpy, getFeeRatesSpy, @@ -70,7 +74,7 @@ describe('SendManyHandler', () => { }, methods: ['btc_sendmany'], }; - const recipients: IAccount[] = []; + const recipients: BtcAccount[] = []; for (let i = 1; i < recipientCnt + 1; i++) { recipients.push( await wallet.unlock(i, Config.wallet.defaultAccountType), @@ -85,7 +89,7 @@ describe('SendManyHandler', () => { }; const createSendManyParams = ( - recipients: IAccount[], + recipients: BtcAccount[], caip2ChainId: string, dryrun: boolean, comment = '', @@ -389,24 +393,6 @@ describe('SendManyHandler', () => { ).rejects.toThrow('Invalid amount for send'); }); - it('throws `Failed to send the transaction` error if no fee rate returns from chain service', async () => { - const { getFeeRatesSpy } = createMockChainApiFactory(); - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients } = await createSenderNRecipients( - network, - caip2ChainId, - 10, - ); - getFeeRatesSpy.mockResolvedValue({ - fees: [], - }); - - await expect( - sendMany(sender, createSendManyParams(recipients, caip2ChainId, false)), - ).rejects.toThrow('Failed to send the transaction'); - }); - it('throws `Invalid response` error if the response is unexpected', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; @@ -414,9 +400,7 @@ describe('SendManyHandler', () => { await prepareSendMany(network, caip2ChainId); broadcastTransactionSpy.mockResolvedValue({ - transactionId: { - txId: 'invalid', - }, + transactionId: '', }); await expect( sendMany(sender, { @@ -441,6 +425,24 @@ describe('SendManyHandler', () => { ).rejects.toThrow(UserRejectedRequestError); }); + it('throws `Failed to send the transaction` error if no fee rate returns from chain service', async () => { + const { getFeeRatesSpy } = createMockChainApiFactory(); + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { sender, recipients } = await createSenderNRecipients( + network, + caip2ChainId, + 10, + ); + getFeeRatesSpy.mockResolvedValue({ + fees: [], + }); + + await expect( + sendMany(sender, createSendManyParams(recipients, caip2ChainId, false)), + ).rejects.toThrow('Failed to send the transaction'); + }); + it('throws `Failed to send the transaction` error if the transaction is fail to commit', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; @@ -454,5 +456,21 @@ describe('SendManyHandler', () => { }), ).rejects.toThrow('Failed to send the transaction'); }); + + it('throws DisplayableError error meesage if the DisplayableError throwed', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { broadcastTransactionSpy, sender, recipients } = + await prepareSendMany(network, caip2ChainId); + broadcastTransactionSpy.mockRejectedValue( + new TxValidationError('some tx error'), + ); + + await expect( + sendMany(sender, { + ...createSendManyParams(recipients, caip2ChainId, false), + }), + ).rejects.toThrow('some tx error'); + }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 5cc3ca58..8ce5d6ea 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -17,9 +17,14 @@ import { boolean, refine, optional, + nonempty, } from 'superstruct'; -import { TxValidationError } from '../bitcoin/wallet'; +import { + type BtcAccount, + type ITxInfo, + TxValidationError, +} from '../bitcoin/wallet'; import { Factory } from '../factory'; import { scopeStruct, @@ -33,7 +38,6 @@ import { validateResponse, logger, } from '../utils'; -import type { IAccount, ITxInfo } from '../wallet'; export const TransactionAmountStuct = refine( record(BtcP2wpkhAddressStruct, string()), @@ -74,7 +78,7 @@ export const sendManyParamsStruct = object({ }); export const sendManyResponseStruct = object({ - txId: string(), + txId: nonempty(string()), txHash: optional(string()), }); @@ -89,7 +93,7 @@ export type SendManyResponse = Infer; * @param params - The parameters for send the transaction. * @returns A Promise that resolves to an SendManyResponse object. */ -export async function sendMany(account: IAccount, params: SendManyParams) { +export async function sendMany(account: BtcAccount, params: SendManyParams) { try { validateRequest(params, sendManyParamsStruct); @@ -153,10 +157,7 @@ export async function sendMany(account: IAccount, params: SendManyParams) { throw error as unknown as Error; } - if ( - error instanceof TxValidationError || - error instanceof UserRejectedRequestError - ) { + if (error instanceof TxValidationError) { throw error as unknown as Error; } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/static.ts b/merged-packages/bitcoin-wallet-snap/src/utils/static.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/static.ts rename to merged-packages/bitcoin-wallet-snap/src/utils/static.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts index c038e30f..a0dbbcdb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts @@ -1,5 +1,4 @@ -import { maxSatoshi, minSatoshi } from '../bitcoin/constants'; -import { satsToBtc, btcToSats } from './unit'; +import { satsToBtc, btcToSats, maxSatoshi, minSatoshi } from './unit'; describe('satsToBtc', () => { it('returns Btc unit', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts index f27b1fb8..79fd4a1b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts @@ -1,8 +1,13 @@ import Big from 'big.js'; -import { maxSatoshi } from '../bitcoin/constants'; import { Config } from '../config'; +// Maximum amount of satoshis +export const maxSatoshi = 21 * 1e14; + +// Minimum amount of satoshis +export const minSatoshi = 1; + /** * Converts a satoshis to a string representing the equivalent amount of BTC. * diff --git a/merged-packages/bitcoin-wallet-snap/src/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/wallet.ts deleted file mode 100644 index 9dbc86b9..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/wallet.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { Json } from '@metamask/snaps-sdk'; -import type { Buffer } from 'buffer'; - -export type Recipient = { - address: string; - value: bigint; -}; - -export type Transaction = { - tx: string; - txInfo: ITxInfo; -}; - -/** - * An interface that defines a `toJson` method for getting a JSON representation of a transaction info object. - */ -export type ITxInfo = { - sender: string; - change?: Recipient; - recipients: Recipient[]; - total: bigint; - txFee: bigint; - feeRate: bigint; -}; - -/** - * An interface that defines methods for unlocking accounts, signing transactions, and creating transactions. - */ -export type IWallet = { - /** - * Unlocks an account by index and script type. - * - * @param index - The index to derive from the node. - * @param type - The script type of the unlocked account, e.g. `bip122:p2pkh`. - * @returns A promise that resolves to an `IAccount` object. - */ - unlock(index: number, type?: string): Promise; - - /** - * Signs a transaction by the given encoded transaction string. - * - * @param signer - The `IAccountSigner` object to sign the transaction. - * @param transaction - The encoded transaction string to convert back to a transaction. - * @returns A promise that resolves to a string of the signed transaction. - */ - signTransaction(signer: IAccountSigner, transaction: string): Promise; - - /** - * Creates a transaction using the given account, transaction intent, and options. - * - * @param account - The `IAccount` object to create the transaction. - * @param recipients - The transaction recipients. - * @param options - The options to use when creating the transaction. - * @returns A promise that resolves to an object containing the transaction hash and transaction info. - */ - createTransaction( - account: IAccount, - recipients: Recipient[], - options: Record, - ): Promise; -}; - -/** - * An interface that defines properties for an account, including its address, HD path, public key, and signer object. - */ -export type IAccount = { - /** - * The master fingerprint of the derived node, as a string. - */ - mfp: string; - /** - * The index of the derived node, as a number. - */ - index: number; - /** - * The address of the account, as a string. - */ - address: string; - /** - * The HD path of the account, as a string. - */ - hdPath: string; - /** - * The public key of the account, as a string. - */ - pubkey: string; - /** - * The type of the account, e.g. `bip122:p2pwh`, as a string. - */ - type: string; - /** - * The `IAccountSigner` object derived from the root node. - */ - signer: IAccountSigner; -}; - -/** - * An interface that defines methods and properties for signing transactions and verifying signatures. - */ -export type IAccountSigner = { - /** - * Signs a transaction hash. - * - * @param hash - The buffer containing the transaction hash to sign. - * @returns A promise that resolves to the signed result as a Buffer. - */ - sign(hash: Buffer): Promise; - - /** - * Derives a new `IAccountSigner` object using an HD path. - * - * @param path - The HD path in string format, e.g. `m'\0'\0`. - * @returns A new `IAccountSigner` object derived by the given path. - */ - derivePath(path: string): IAccountSigner; - - /** - * Verifies a signature using the derived node of an `IAccountSigner` object. - * - * @param hash - The buffer containing the transaction hash. - * @param signature - The buffer containing the signature to verify. - * @returns A boolean indicating whether the signature is valid. - */ - verify(hash: Buffer, signature: Buffer): boolean; - - /** - * The public key of the current derived node used for verifying signatures, as a Buffer. - */ - publicKey: Buffer; - - /** - * The fingerprint of the current derived node used for verifying signatures, as a Buffer. - */ - fingerprint: Buffer; -}; From 94bb758936923d012c90dc02d6effb776be8be1f Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 13 Jun 2024 15:57:58 +0800 Subject: [PATCH 060/362] chore: update readme and env example (#111) * chore: update readme and env example * Update README.md --- .../bitcoin-wallet-snap/.env.example | 10 --- merged-packages/bitcoin-wallet-snap/README.md | 75 ++++++++++--------- 2 files changed, 40 insertions(+), 45 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/.env.example b/merged-packages/bitcoin-wallet-snap/.env.example index 6ba8529b..f2c971bf 100644 --- a/merged-packages/bitcoin-wallet-snap/.env.example +++ b/merged-packages/bitcoin-wallet-snap/.env.example @@ -1,13 +1,3 @@ -# Description: Environment variables for read data client -# Possible Options: BlockStream | BlockChair -# Default: BlockChair -# Required: false -DATA_CLIENT_READ_TYPE=BlockStream -# Description: Environment variables for write data client -# Possible Options: BlockChair -# Default: BlockChair -# Required: false -DATA_CLIENT_WRITE_TYPE=BlockChair # Description: Environment variables for Log Level, 0 does not log anything, 6 logs everything # Possible Options: 0-6 # Default: 0 diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index a42e75ad..df6450a7 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -6,20 +6,6 @@ Rename `.env.example` to `.env` Configurations are setup though `.env`, ```bash -# Description: Environment variables for read data client -# Possible Options: BlockStream | BlockChair -# Default: BlockChair -# Required: false -DATA_CLIENT_READ_TYPE=BlockStream -# Description: Environment variables for write data client -# Possible Options: BlockChair -# Default: BlockChair -# Required: false -DATA_CLIENT_WRITE_TYPE=BlockChair -# Description: Environment variables for Log Level, 0 does not log anything, 6 logs everything -# Possible Options: 0-6 -# Default: 0 -# Required: false LOG_LEVEL=6 # Description: Environment variables for API key of BlockChair # Required: false @@ -28,27 +14,26 @@ BLOCKCHAIR_API_KEY= ## Rpcs: -### API `chain_getTransactionStatus` +### API `keyring_createAccount` example: ```typescript provider.request({ - method: 'wallet_invokeSnap', + method: 'wallet_invokeKeyring', params: { snapId, request: { - method: 'chain_getTransactionStatus', + method: 'keyring_createAccount', params: { scope: 'bip122:000000000933ea01ad0ee984209779ba', // the CAIP-2 chain ID of bitcoin - transactionId: '5639078d-742e-4901-8993-bc25a5ef6161', // the txn id of an bitcoin transaction }, }, }, }); ``` -### API `btc_sendmany` +### API `keyring_getAccountBalances` example: @@ -58,30 +43,38 @@ provider.request({ params: { snapId, request: { - method: 'keyring_submitRequest', + method: 'keyring_getAccountBalances', params: { account: 'dc06350a-82db-434b-b113-066135804f63', // the uuid account id of the current account id: 'da40b782-e054-4260-9a6a-c8717a022f92', // an random uuid for this request + assets: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], // Caip-2 BTC Asset + }, + }, + }, +}); +``` + +### API `chain_getTransactionStatus` + +example: + +```typescript +provider.request({ + method: 'wallet_invokeSnap', + params: { + snapId, + request: { + method: 'chain_getTransactionStatus', + params: { scope: 'bip122:000000000933ea01ad0ee984209779ba', // the CAIP-2 chain ID of bitcoin - request: { - method: 'btc_sendmany', - params: { - amounts: { - ['tb1qlhkuysju47s642834n7f3tyk67mvnt2cfd9r7y']: '0.00000500', - }, // the recipient struct to indicate how many BTC to be received for each recipient - comment: 'some comment', - subtractFeeFrom: [], // not support yet - replaceable: false, // an flag to opt-in RBF - dryrun: true, // an flag to enable similation of the transaction, without broadcast to network - }, - }, + transactionId: '5639078d-742e-4901-8993-bc25a5ef6161', // the txn id of an bitcoin transaction }, }, }, }); ``` -### API `keyring_getAccountBalances` +### API `btc_sendmany` example: @@ -91,11 +84,23 @@ provider.request({ params: { snapId, request: { - method: 'keyring_getAccountBalances', + method: 'keyring_submitRequest', params: { account: 'dc06350a-82db-434b-b113-066135804f63', // the uuid account id of the current account id: 'da40b782-e054-4260-9a6a-c8717a022f92', // an random uuid for this request - assets: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], // Caip-2 BTC Asset + scope: 'bip122:000000000933ea01ad0ee984209779ba', // the CAIP-2 chain ID of bitcoin + request: { + method: 'btc_sendmany', + params: { + amounts: { + ['tb1qlhkuysju47s642834n7f3tyk67mvnt2cfd9r7y']: '0.00000500', + }, // the recipient struct to indicate how many BTC to be received for each recipient + comment: 'some comment', + subtractFeeFrom: [], // not support yet + replaceable: false, // an flag to opt-in RBF + dryrun: true, // an flag to enable similation of the transaction, without broadcast to network + }, + }, }, }, }, From 82f02922d0a58accc2cddca3bb00832eac151689 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 4 Jun 2024 18:39:51 +0800 Subject: [PATCH 061/362] chore: update send many (#97) * chore: update send many * chore: update sendmany dialog --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/bitcoin/wallet/address.ts | 4 - .../src/bitcoin/wallet/coin-select.test.ts | 37 ++++- .../src/bitcoin/wallet/coin-select.ts | 38 +++-- .../src/bitcoin/wallet/psbt.test.ts | 80 ++++++++-- .../src/bitcoin/wallet/psbt.ts | 145 ++++++++++++------ .../src/bitcoin/wallet/selection-result.ts | 17 -- .../bitcoin/wallet/transaction-info.test.ts | 33 ++-- .../src/bitcoin/wallet/transaction-info.ts | 75 +++++---- .../bitcoin/wallet/transaction-input.test.ts | 9 +- .../src/bitcoin/wallet/transaction-input.ts | 15 +- .../src/bitcoin/wallet/transaction-output.ts | 17 +- .../src/bitcoin/wallet/types.ts | 9 ++ .../src/bitcoin/wallet/wallet.test.ts | 116 ++++++++------ .../src/bitcoin/wallet/wallet.ts | 75 +++++---- .../src/rpcs/sendmany.test.ts | 79 ++++++++-- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 38 +++-- 17 files changed, 518 insertions(+), 271 deletions(-) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/selection-result.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 5cc1fcf5..cb36df1b 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "Nz/fHikjqUlxBPopJv1lTeWP6FaObv0z7jw4pMzpZns=", + "shasum": "k2VftTYTbMrhIZSwAGQb91tdJAF/lprzhf+XXuUuHk4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts index 271bee60..71bf7abf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts @@ -1,13 +1,9 @@ -import type { Network } from 'bitcoinjs-lib'; - import { replaceMiddleChar } from '../../utils'; import type { IAddress } from '../../wallet'; export class BtcAddress implements IAddress { value: string; - network?: Network; - constructor(address: string) { this.value = address; } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts index e01e03e4..af4718ef 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts @@ -4,7 +4,6 @@ import { generateFormatedUtxos } from '../../../test/utils'; import { ScriptType } from '../constants'; import { CoinSelectService } from './coin-select'; import { BtcAccountBip32Deriver } from './deriver'; -import { SelectionResult } from './selection-result'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; import { BtcWallet } from './wallet'; @@ -59,10 +58,36 @@ describe('CoinSelectService', () => { const coinSelectService = new CoinSelectService(1); - const result = coinSelectService.selectCoins(inputs, outputs, sender); + const result = coinSelectService.selectCoins( + inputs, + outputs, + new TxOutput(0, sender.address), + ); - expect(result).toBeInstanceOf(SelectionResult); expect(result.fee).toBeGreaterThan(1); + expect(result.change).toBeDefined(); + expect(result.inputs.length).toBeGreaterThan(0); + expect(result.outputs.length).toBeGreaterThan(0); + }); + + it('converts output to TxOutput', async () => { + const network = networks.testnet; + const { inputs, outputs, sender } = await prepareCoinSlect(network); + + const coinSelectService = new CoinSelectService(1); + + const result = coinSelectService.selectCoins( + inputs, + outputs.map((output) => ({ + address: output.address, + value: output.value, + })), + new TxOutput(0, sender.address), + ); + + for (const output of result.outputs) { + expect(output).toBeInstanceOf(TxOutput); + } }); it('throws `Insufficient funds` error if the given utxos is not sufficient', async () => { @@ -77,7 +102,11 @@ describe('CoinSelectService', () => { const coinSelectService = new CoinSelectService(100); expect(() => - coinSelectService.selectCoins(inputs, outputs, sender), + coinSelectService.selectCoins( + inputs, + outputs, + new TxOutput(0, sender.address), + ), ).toThrow('Insufficient funds'); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index 542d933c..ade36a3a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -2,43 +2,47 @@ import coinSelect from 'coinselect'; import type { Recipient } from '../../wallet'; import { TxValidationError } from './exceptions'; -import { SelectionResult } from './selection-result'; import type { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; -import { type IBtcAccount } from './types'; +import { type SelectionResult } from './types'; export class CoinSelectService { - protected readonly feeRate: number; + #feeRate: number; constructor(feeRate: number) { - this.feeRate = Math.round(feeRate); + this.#feeRate = Math.round(feeRate); } selectCoins( inputs: TxInput[], - recipients: Recipient[], - changeAccount: IBtcAccount, + recipients: Recipient[] | TxOutput[], + changeTo: TxOutput, ): SelectionResult { - const result = coinSelect(inputs, recipients, this.feeRate); + const result = coinSelect(inputs, recipients, this.#feeRate); if (!result.inputs || !result.outputs) { throw new TxValidationError('Insufficient funds'); } - const selectedResult = new SelectionResult(); - selectedResult.fee = result.fee; - selectedResult.selectedInputs = result.inputs; + const selectedResult: SelectionResult = { + fee: result.fee, + inputs: result.inputs, + outputs: [], + }; + // restructure outputs to avoid depends on coinselect output format for (const output of result.outputs) { if (output.address) { - selectedResult.selectedOutputs.push( - new TxOutput(output.value, output.address), - ); + if (output instanceof TxOutput) { + selectedResult.outputs.push(output); + } else { + selectedResult.outputs.push( + new TxOutput(output.value, output.address), + ); + } } else { - selectedResult.change = new TxOutput( - output.value, - changeAccount.address, - ); + changeTo.value = output.value; + selectedResult.change = changeTo; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts index 6357706d..70df4e16 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts @@ -1,4 +1,4 @@ -import { Transaction, networks } from 'bitcoinjs-lib'; +import { Psbt, Transaction, networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { hexToBuffer } from '../../utils'; @@ -32,11 +32,14 @@ describe('PsbtService', () => { const receiver1 = await wallet.instance.unlock(1, ScriptType.P2wpkh); const receiver2 = await wallet.instance.unlock(2, ScriptType.P2wpkh); const receivers = [receiver1, receiver2]; - const finalizeSpy = jest.spyOn(service.psbt, 'finalizeAllInputs'); - const inputSpy = jest.spyOn(service.psbt, 'addInput'); - const outputSpy = jest.spyOn(service.psbt, 'addOutput'); - const signSpy = jest.spyOn(service.psbt, 'signAllInputsHDAsync'); - const verifySpy = jest.spyOn(service.psbt, 'validateSignaturesOfAllInputs'); + const finalizeSpy = jest.spyOn(Psbt.prototype, 'finalizeAllInputs'); + const inputSpy = jest.spyOn(Psbt.prototype, 'addInput'); + const outputSpy = jest.spyOn(Psbt.prototype, 'addOutput'); + const signSpy = jest.spyOn(Psbt.prototype, 'signAllInputsHDAsync'); + const verifySpy = jest.spyOn( + Psbt.prototype, + 'validateSignaturesOfAllInputs', + ); const transactionWeightSpy = jest.spyOn(Transaction.prototype, 'weight'); const transactionHexSpy = jest.spyOn(Transaction.prototype, 'toHex'); @@ -59,7 +62,13 @@ describe('PsbtService', () => { service.addOutputs(outputs); - service.addInputs(inputs, sender, rbfOptIn); + service.addInputs( + inputs, + rbfOptIn, + sender.hdPath, + hexToBuffer(sender.pubkey, false), + hexToBuffer(sender.mfp, false), + ); return { service, @@ -83,7 +92,7 @@ describe('PsbtService', () => { const service = new PsbtService(network); - expect(service.psbt.txInputs).toHaveLength(0); + expect(service.psbt).toBeInstanceOf(Psbt); }); it('constructor with an psbt base string', async () => { @@ -92,8 +101,7 @@ describe('PsbtService', () => { const newService = PsbtService.fromBase64(networks.testnet, psbtBase64); - expect(newService.psbt.txInputs).toHaveLength(2); - expect(newService.psbt.txOutputs).toHaveLength(2); + expect(newService.psbt).toBeInstanceOf(Psbt); }); }); @@ -111,7 +119,7 @@ describe('PsbtService', () => { hash: inputs[i].txHash, index: inputs[i].index, witnessUtxo: { - script: inputs[i].scriptBuf, + script: inputs[i].script, value: inputs[i].value, }, bip32Derivation: [ @@ -134,7 +142,7 @@ describe('PsbtService', () => { hash: inputs[i].txHash, index: inputs[i].index, witnessUtxo: { - script: inputs[i].scriptBuf, + script: inputs[i].script, value: inputs[i].value, }, bip32Derivation: [ @@ -156,7 +164,13 @@ describe('PsbtService', () => { }); expect(() => { - service.addInputs(inputs, sender, false); + service.addInputs( + inputs, + false, + sender.hdPath, + hexToBuffer(sender.pubkey, false), + hexToBuffer(sender.mfp), + ); }).toThrow('Failed to add inputs in PSBT'); }); }); @@ -274,4 +288,44 @@ describe('PsbtService', () => { expect(() => service.finalize()).toThrow(PsbtServiceError); }); }); + + describe('signDummy', () => { + it('clones a psbt, then sign it and returns an PsbtService object', async () => { + const { service, sender } = await preparePsbt(); + + const signedService = await service.signDummy(sender.signer); + + expect(signedService).toBeInstanceOf(PsbtService); + }); + + it('throws `Failed to sign dummy in PSBT` error if signDummy is failed', async () => { + const { service, sender, finalizeSpy } = await preparePsbt(); + + finalizeSpy.mockImplementation(() => { + throw new Error('error'); + }); + + await expect(service.signDummy(sender.signer)).rejects.toThrow( + `Failed to sign dummy in PSBT`, + ); + }); + }); + + describe('getFee', () => { + it('extracts fee from the psbt', async () => { + const { service, sender } = await preparePsbt(); + + const signedService = await service.signDummy(sender.signer); + + const fee = signedService.getFee(); + + expect(fee).toBeGreaterThan(0); + }); + + it('throws `Failed to get fee from PSBT` error if getFee is failed', async () => { + const { service } = await preparePsbt(); + + expect(() => service.getFee()).toThrow(`Failed to get fee from PSBT`); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index 0b7ab632..d1bae8c2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -5,31 +5,31 @@ import type { Buffer } from 'buffer'; import ECPairFactory from 'ecpair'; import { logger } from '../../libs/logger/logger'; -import { compactError, hexToBuffer } from '../../utils'; +import { compactError } from '../../utils'; import type { IAccountSigner } from '../../wallet'; import { MaxStandardTxWeight } from '../constants'; import { PsbtServiceError, TxValidationError } from './exceptions'; import type { TxInput } from './transaction-input'; import type { TxOutput } from './transaction-output'; -import type { IBtcAccount } from './types'; const ECPair = ECPairFactory(ecc); export class PsbtService { - protected readonly network: Network; + #psbt: Psbt; - protected readonly _psbt: Psbt; + #network: Network; get psbt() { - return this._psbt; + return this.#psbt; } constructor(network: Network, psbt?: Psbt) { if (psbt === undefined) { - this._psbt = new Psbt({ network }); + this.#psbt = new Psbt({ network }); } else { - this._psbt = psbt; + this.#psbt = psbt; } + this.#network = network; } static fromBase64(network: Network, base64Psbt: string): PsbtService { @@ -38,43 +38,62 @@ export class PsbtService { return service; } + addInput( + input: TxInput, + replaceable: boolean, + changeAddressHdPath: string, + changeAddressPubkey: Buffer, + changeAddressMfp: Buffer, + ) { + try { + this.#psbt.addInput({ + hash: input.txHash, + index: input.index, + witnessUtxo: { + script: input.script, + value: input.value, + }, + // This is useful because as long as you store the masterFingerprint on + // the PSBT Creator's server, you can have the PSBT Creator do the heavy + // lifting with derivation from your m/84'/0'/0' xpub, (deriving only 0/0 ) + // and your signer just needs to pass in an HDSigner interface (ie. bip32 library) + bip32Derivation: [ + { + masterFingerprint: changeAddressMfp, + path: changeAddressHdPath, + pubkey: changeAddressPubkey, + }, + ], + + // reference : https://en.bitcoin.it/wiki/BIP_0125 + // A transaction is considered to have opted in to allowing replacement of itself if any of its inputs have an nSequence number less than (0xffffffff - 1). + // we use max sequence number - 2 to void conflicting with other possible uses of nSequence + sequence: replaceable + ? Transaction.DEFAULT_SEQUENCE - 2 + : Transaction.DEFAULT_SEQUENCE, + }); + } catch (error) { + logger.error('Failed to add input', error); + throw new PsbtServiceError('Failed to add input in PSBT'); + } + } + addInputs( inputs: TxInput[], - changeAccount: IBtcAccount, replaceable: boolean, + changeAddressHdPath: string, + changeAddressPubkey: Buffer, + changeAddressMfp: Buffer, ) { try { - const changeAddressHdPath = changeAccount.hdPath; - const changeAddressPubkey = hexToBuffer(changeAccount.pubkey, false); - const changeAddressMfp = hexToBuffer(changeAccount.mfp, false); - for (const input of inputs) { - this._psbt.addInput({ - hash: input.txHash, - index: input.index, - witnessUtxo: { - script: input.scriptBuf, - value: input.value, - }, - // This is useful because as long as you store the masterFingerprint on - // the PSBT Creator's server, you can have the PSBT Creator do the heavy - // lifting with derivation from your m/84'/0'/0' xpub, (deriving only 0/0 ) - // and your signer just needs to pass in an HDSigner interface (ie. bip32 library) - bip32Derivation: [ - { - masterFingerprint: changeAddressMfp, - path: changeAddressHdPath, - pubkey: changeAddressPubkey, - }, - ], - - // reference : https://en.bitcoin.it/wiki/BIP_0125 - // A transaction is considered to have opted in to allowing replacement of itself if any of its inputs have an nSequence number less than (0xffffffff - 1). - // we use max sequence number - 2 to void conflicting with other possible uses of nSequence - sequence: replaceable - ? Transaction.DEFAULT_SEQUENCE - 2 - : Transaction.DEFAULT_SEQUENCE, - }); + this.addInput( + input, + replaceable, + changeAddressHdPath, + changeAddressPubkey, + changeAddressMfp, + ); } } catch (error) { logger.error('Failed to add inputs', error); @@ -82,13 +101,22 @@ export class PsbtService { } } + addOutput(output: TxOutput) { + try { + this.#psbt.addOutput({ + address: output.address, + value: output.value, + }); + } catch (error) { + logger.error('Failed to add output', error); + throw new PsbtServiceError('Failed to add output in PSBT'); + } + } + addOutputs(outputs: TxOutput[]) { try { for (const output of outputs) { - this._psbt.addOutput({ - address: output.address, - value: output.value, - }); + this.addOutput(output); } } catch (error) { logger.error('Failed to add outputs', error); @@ -96,9 +124,30 @@ export class PsbtService { } } + getFee(): number { + try { + return this.#psbt.getFee(); + } catch (error) { + logger.error('Failed to get fee', error); + throw new PsbtServiceError('Failed to get fee from PSBT'); + } + } + + async signDummy(signer: IAccountSigner): Promise { + try { + const psbt = this.#psbt.clone(); + await psbt.signAllInputsHDAsync(signer); + psbt.finalizeAllInputs(); + return new PsbtService(this.#network, psbt); + } catch (error) { + logger.error('Failed to sign dummy', error); + throw new PsbtServiceError('Failed to sign dummy in PSBT'); + } + } + toBase64(): string { try { - return this._psbt.toBase64(); + return this.#psbt.toBase64(); } catch (error) { logger.error('Failed to convert to base64', error); throw new PsbtServiceError('Failed to output PSBT string'); @@ -110,10 +159,10 @@ export class PsbtService { // This function signAllInputsHDAsync is used to sign all inputs with the signer. // When using the method signAllInputsHDAsync, it is important to note that the signer must derive from the root node as well as the finderprint. // For further reference, please see the getHdSigner method in BtcWallet. - await this._psbt.signAllInputsHDAsync(signer); + await this.#psbt.signAllInputsHDAsync(signer); if ( - !this._psbt.validateSignaturesOfAllInputs( + !this.#psbt.validateSignaturesOfAllInputs( (pubkey: Buffer, msghash: Buffer, signature: Buffer) => this.validateInputs(pubkey, msghash, signature), ) @@ -129,11 +178,11 @@ export class PsbtService { finalize(): string { try { - this._psbt.finalizeAllInputs(); + this.#psbt.finalizeAllInputs(); - const txHex = this._psbt.extractTransaction().toHex(); + const txHex = this.#psbt.extractTransaction().toHex(); - const weight = this._psbt.extractTransaction().weight(); + const weight = this.#psbt.extractTransaction().weight(); if (weight > MaxStandardTxWeight) { throw new TxValidationError('Transaction is too large'); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/selection-result.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/selection-result.ts deleted file mode 100644 index a9549bc5..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/selection-result.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { TxInput } from './transaction-input'; -import type { TxOutput } from './transaction-output'; - -export class SelectionResult { - selectedInputs: TxInput[]; - - selectedOutputs: TxOutput[]; - - change: TxOutput; - - fee: number; - - constructor() { - this.selectedInputs = []; - this.selectedOutputs = []; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts index d1a48dd7..4c553e10 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts @@ -3,6 +3,7 @@ import { networks } from 'bitcoinjs-lib'; import { generateAccounts } from '../../../test/utils'; import { getCaip2ChainId, getExplorerUrl, satsToBtc } from '../utils'; import { BtcAddress } from './address'; +import { BtcAmount } from './amount'; import { BtcTxInfo } from './transaction-info'; import { TxOutput } from './transaction-output'; @@ -13,20 +14,21 @@ describe('BtcTxInfo', () => { const accounts = generateAccounts(5); const addresses = accounts.map((account) => account.address); const fee = 10000; + const feeRate = 100; let total = fee; const outputs: TxOutput[] = []; + const sender = new BtcAddress(addresses[0]); + + const info = new BtcTxInfo(sender, feeRate, network); + info.fee = fee; for (let i = 1; i < addresses.length; i++) { total += 100000; - outputs.push(new TxOutput(100000, addresses[i])); + const output = new TxOutput(100000, addresses[i]); + outputs.push(output); } - const info = new BtcTxInfo( - new BtcAddress(addresses[0]), - outputs, - 10000, - 100, - network, - ); + + info.addRecipients(outputs); info.change = new TxOutput(500, addresses[0]); total += 500; @@ -53,9 +55,20 @@ describe('BtcTxInfo', () => { }, ]; + expect(info.total).toBeInstanceOf(BtcAmount); + expect(info.total.value).toStrictEqual(total); + expect(info.sender).toStrictEqual(sender); + expect(info.recipients).toHaveLength(expectedRecipients.length); + expect(info.change).toBeDefined(); + expect(info.fee).toStrictEqual(fee); + expect(info.txFee).toBeInstanceOf(BtcAmount); + expect(info.txFee.value).toStrictEqual(fee); + expect(info.feeRate).toBeInstanceOf(BtcAmount); + expect(info.feeRate.value).toStrictEqual(feeRate); + expect(info.toJson()).toStrictEqual({ - feeRate: `${satsToBtc(100)} BTC`, - txFee: `${satsToBtc(10000)} BTC`, + feeRate: `${satsToBtc(feeRate)} BTC`, + txFee: `${satsToBtc(fee)} BTC`, sender: addresses[0], recipients: expectedRecipients, changes: expectedChange, diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts index 05d0c1ba..fbb685d5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts @@ -8,15 +8,15 @@ import { BtcAmount } from './amount'; import type { TxOutput } from './transaction-output'; export class BtcTxInfo implements ITxInfo { - #recipients: TxOutput[]; + readonly sender: BtcAddress; - #feeRate: BtcAmount; + readonly txFee: BtcAmount; change?: TxOutput; - #sender: BtcAddress; + #recipients: TxOutput[]; - #txFee: BtcAmount; + #feeRate: BtcAmount; #outputTotal: BtcAmount; @@ -24,21 +24,14 @@ export class BtcTxInfo implements ITxInfo { #network: Network; - constructor( - sender: BtcAddress, - outputs: TxOutput[], - fee: number, - feeRate: number, - network: Network, - ) { + constructor(sender: BtcAddress, feeRate: number, network: Network) { this.#recipients = []; - this.#outputTotal = new BtcAmount(0); this.#serializedRecipients = []; + this.#outputTotal = new BtcAmount(0); this.#feeRate = new BtcAmount(feeRate); - this.#txFee = new BtcAmount(fee); + this.txFee = new BtcAmount(0); this.#network = network; - this.#sender = sender; - this.addRecipients(outputs); + this.sender = sender; } protected changeToJson(): Json { @@ -56,40 +49,56 @@ export class BtcTxInfo implements ITxInfo { : []; } - protected addRecipients(outputs: TxOutput[]): void { + addRecipients(outputs: TxOutput[]): void { for (const output of outputs) { - this.#outputTotal.value += output.value; - - this.#recipients.push(output); - - this.#serializedRecipients.push({ - address: output.destination.toString(true), - value: output.amount.toString(true), - explorerUrl: getExplorerUrl( - output.destination.value, - getCaip2ChainId(this.#network), - ), - }); + this.addRecipient(output); } } - bumpFee(val: number): void { - this.#txFee.value += val; + addRecipient(output: TxOutput): void { + this.#outputTotal.value += output.value; + + this.#recipients.push(output); + + this.#serializedRecipients.push({ + address: output.destination.toString(true), + value: output.amount.toString(true), + explorerUrl: getExplorerUrl( + output.destination.value, + getCaip2ChainId(this.#network), + ), + }); } get total(): BtcAmount { return new BtcAmount( this.#outputTotal.value + (this.change ? this.change.value : 0) + - this.#txFee.value, + this.txFee.value, ); } + get feeRate(): BtcAmount { + return this.#feeRate; + } + + get recipients(): TxOutput[] { + return this.#recipients; + } + + get fee(): number { + return this.txFee.value; + } + + set fee(val: number) { + this.txFee.value = val; + } + toJson>(): InfoJson { return { feeRate: this.#feeRate.toString(true), - txFee: this.#txFee.toString(true), - sender: this.#sender.toString(), + txFee: this.txFee.toString(true), + sender: this.sender.toString(), recipients: this.#serializedRecipients, changes: this.changeToJson(), total: this.total.toString(true), diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts index 2b0760f0..5dbe7061 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts @@ -1,7 +1,7 @@ import { networks } from 'bitcoinjs-lib'; +import type { Buffer } from 'buffer'; import { generateFormatedUtxos } from '../../../test/utils'; -import { hexToBuffer } from '../../utils'; import { ScriptType } from '../constants'; import { BtcAccountBip32Deriver } from './deriver'; import { TxInput } from './transaction-input'; @@ -23,13 +23,12 @@ describe('TxInput', () => { it('return correct property', async () => { const wallet = createMockWallet(networks.testnet); const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); - const script = account.payment.output?.toString('hex') as unknown as string; - const scriptBuf = hexToBuffer(script, false); + const script = account.payment.output as unknown as Buffer; + const utxo = generateFormatedUtxos(account.address, 1)[0]; - const input = new TxInput(utxo, scriptBuf); + const input = new TxInput(utxo, script); - expect(input.scriptBuf).toStrictEqual(scriptBuf); expect(input.script).toStrictEqual(script); expect(input.value).toStrictEqual(utxo.value); expect(input.txHash).toStrictEqual(utxo.txHash); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts index 4f0d6d63..c8da508d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts @@ -1,28 +1,23 @@ import type { Buffer } from 'buffer'; -import { bufferToString } from '../../utils'; import type { IAmount } from '../../wallet'; import { BtcAmount } from './amount'; import type { Utxo } from './types'; export class TxInput { - scriptBuf: Buffer; + // consume by coinselect + readonly script: Buffer; - amount: IAmount; + readonly amount: IAmount; - utxo: Utxo; + readonly utxo: Utxo; constructor(utxo: Utxo, script: Buffer) { - this.scriptBuf = script; + this.script = script; this.utxo = utxo; this.amount = new BtcAmount(utxo.value); } - // consume by coinselect - get script(): string { - return bufferToString(this.scriptBuf, 'hex'); - } - // consume by coinselect get value(): number { return this.amount.value; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts index 93b3ad5c..772a9ff6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts @@ -1,21 +1,32 @@ +import type { Buffer } from 'buffer'; + import type { IAddress, IAmount } from '../../wallet'; import { BtcAddress } from './address'; import { BtcAmount } from './amount'; export class TxOutput { - amount: IAmount; + readonly amount: IAmount; + + // consume by conselect + readonly script?: Buffer; - destination: IAddress; + readonly destination: IAddress; - constructor(value: number, address: string) { + constructor(value: number, address: string, script?: Buffer) { this.amount = new BtcAmount(value); this.destination = new BtcAddress(address); + this.script = script; } + // consume by conselect get value(): number { return this.amount.value; } + set value(value: number) { + this.amount.value = value; + } + get address(): string { return this.destination.value; } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts index 6ef5432a..549caf2d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts @@ -3,6 +3,8 @@ import type { Network, Payment } from 'bitcoinjs-lib'; import type { IAccount, IAccountSigner } from '../../wallet'; import type { ScriptType } from '../constants'; +import type { TxInput } from './transaction-input'; +import type { TxOutput } from './transaction-output'; export type IBtcAccountDeriver = { getRoot(path: string[]): Promise; @@ -46,3 +48,10 @@ export type Utxo = { index: number; value: number; }; + +export type SelectionResult = { + change?: TxOutput; + fee: number; + inputs: TxInput[]; + outputs: TxOutput[]; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index c4628933..057d9fd6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -1,18 +1,16 @@ -import { networks } from 'bitcoinjs-lib'; +import type { Json } from '@metamask/snaps-sdk'; +import { address as addressUtils, networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { DustLimit, ScriptType } from '../constants'; -import type { BtcAccount } from './account'; import { P2SHP2WPKHAccount, P2WPKHAccount } from './account'; -import { BtcAmount } from './amount'; import { CoinSelectService } from './coin-select'; import { BtcAccountBip32Deriver } from './deriver'; import { WalletError } from './exceptions'; -import { PsbtService } from './psbt'; -import { SelectionResult } from './selection-result'; import { BtcTxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; +import type { SelectionResult } from './types'; import { BtcWallet } from './wallet'; jest.mock('../../libs/snap/helpers'); @@ -99,23 +97,60 @@ describe('BtcWallet', () => { }); describe('createTransaction', () => { - it('creates an transaction', async () => { + it('creates an transaction with changes', async () => { const network = networks.testnet; const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(account.address, 2, 10000, 10000); + const utxos = generateFormatedUtxos(account.address, 200, 100000, 100000); const result = await wallet.createTransaction( account, - createMockTxIndent(account.address, 15000), + createMockTxIndent(account.address, 132000), { utxos, - fee: 1, + fee: 56, subtractFeeFrom: [], replaceable: false, }, ); + + const json = result.txInfo.toJson(); + const recipients = json.recipients as unknown as Json[]; + const changes = json.changes as unknown as Json[]; + + expect(recipients).toHaveLength(1); + expect(changes).toHaveLength(1); + expect(result).toStrictEqual({ + tx: expect.any(String), + txInfo: expect.any(BtcTxInfo), + }); + }); + + it('creates an transaction without changes', async () => { + const network = networks.testnet; + const { instance } = createMockDeriver(network); + const wallet = new BtcWallet(instance, network); + const account = await wallet.unlock(0, ScriptType.P2wpkh); + const utxos = generateFormatedUtxos(account.address, 200, 10000, 10000); + + const result = await wallet.createTransaction( + account, + createMockTxIndent(account.address, 100000), + { + utxos, + fee: 50, + subtractFeeFrom: [], + replaceable: false, + }, + ); + + const json = result.txInfo.toJson(); + const recipients = json.recipients as unknown as Json[]; + const changes = json.changes as unknown as Json[]; + + expect(recipients).toHaveLength(1); + expect(changes).toHaveLength(0); expect(result).toStrictEqual({ tx: expect.any(String), txInfo: expect.any(BtcTxInfo), @@ -147,7 +182,7 @@ describe('BtcWallet', () => { expect(coinSelectServiceSpy).toHaveBeenCalledWith( expect.any(Array), expect.any(Array), - account, + expect.any(TxOutput), ); for (const input of coinSelectServiceSpy.mock.calls[0][0]) { @@ -155,10 +190,7 @@ describe('BtcWallet', () => { } for (const output of coinSelectServiceSpy.mock.calls[0][1]) { - expect(output).toStrictEqual({ - address: account.address, - value: DustLimit[account.scriptType] + 1, - }); + expect(output).toBeInstanceOf(TxOutput); } }); @@ -166,34 +198,32 @@ describe('BtcWallet', () => { const network = networks.testnet; const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); - const chgAccount = (await wallet.unlock( - 0, - ScriptType.P2wpkh, - )) as unknown as BtcAccount; + const chgAccount = await wallet.unlock(0, ScriptType.P2wpkh); const recipient = await wallet.unlock(1, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(chgAccount.address, 2); + const utxos = generateFormatedUtxos(chgAccount.address, 2, 10000, 10000); const coinSelectServiceSpy = jest.spyOn( CoinSelectService.prototype, 'selectCoins', ); - const psbtServiceSpy = jest - .spyOn(PsbtService.prototype, 'addOutputs') - .mockReturnThis(); - // to avoid modifiy the original object when we needed to test the output - psbtServiceSpy.mockReturnThis(); - - const selectionResult = new SelectionResult(); - selectionResult.selectedInputs = utxos.map( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (utxo) => new TxInput(utxo, chgAccount.payment.output!), - ); - selectionResult.selectedOutputs = [new TxOutput(500, recipient.address)]; - selectionResult.fee = 100; - selectionResult.change = new TxOutput( - DustLimit[chgAccount.scriptType] - 1, - chgAccount.address, - ); + const selectionResult: SelectionResult = { + change: new TxOutput( + DustLimit[chgAccount.scriptType] - 1, + chgAccount.address, + ), + fee: 100, + inputs: utxos.map( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (utxo) => new TxInput(utxo, chgAccount.payment.output!), + ), + outputs: [ + new TxOutput( + 500, + recipient.address, + addressUtils.toOutputScript(recipient.address, network), + ), + ], + }; coinSelectServiceSpy.mockReturnValue(selectionResult); @@ -210,18 +240,8 @@ describe('BtcWallet', () => { const info: BtcTxInfo = result.txInfo as unknown as BtcTxInfo; - expect(psbtServiceSpy).toHaveBeenCalledWith( - selectionResult.selectedOutputs, - ); - - const jsonInfo = info.toJson(); - - expect(jsonInfo).toHaveProperty( - 'txFee', - new BtcAmount(100 + DustLimit[chgAccount.scriptType] - 1).toString( - true, - ), - ); + expect(info.fee).toBe(19500); + expect(info.change).toBeUndefined(); }); it('throws `Transaction amount too small` error the transaction output is too small', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 3c617535..06b5e627 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -1,8 +1,8 @@ import type { BIP32Interface } from 'bip32'; -import { type Network } from 'bitcoinjs-lib'; +import { type Network, address } from 'bitcoinjs-lib'; import { logger } from '../../libs/logger/logger'; -import { bufferToString, compactError } from '../../utils'; +import { bufferToString, compactError, hexToBuffer } from '../../utils'; import type { IAccountSigner, IWallet, @@ -19,6 +19,7 @@ import { PsbtService } from './psbt'; import { AccountSigner } from './signer'; import { BtcTxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; +import { TxOutput } from './transaction-output'; import type { IStaticBtcAccount, IBtcAccountDeriver, @@ -85,59 +86,71 @@ export class BtcWallet implements IWallet { throw new WalletError('Fail to get account script hash'); } + const inputs = options.utxos.map((utxo) => new TxInput(utxo, scriptOutput)); + const outputs = recipients.map( + (recipient) => + new TxOutput( + recipient.value, + recipient.address, + address.toOutputScript(recipient.address, this.network), + ), + ); + // as fee rate can be 0, we need to ensure it is at least 1 // TODO: The min fee rate should be setting from parameter const feeRate = Math.max(1, options.fee); - const coinSelectService = new CoinSelectService(feeRate); - - const inputs = options.utxos.map((utxo) => new TxInput(utxo, scriptOutput)); - + const change = new TxOutput(0, account.address); const selectionResult = coinSelectService.selectCoins( inputs, - recipients, - account, + outputs, + change, ); - // TODO: add support of subtractFeeFrom, and throw error if output is too small after subtraction - for (const output of selectionResult.selectedOutputs) { - if (isDust(output.value, scriptType)) { - throw new TxValidationError('Transaction amount too small'); - } - } - - const psbtOutputs = selectionResult.selectedOutputs; - const psbtInputs = selectionResult.selectedInputs; + logger.info(JSON.stringify(selectionResult, null, 2)); - const info = new BtcTxInfo( + const txInfo = new BtcTxInfo( new BtcAddress(account.address), - selectionResult.selectedOutputs, - selectionResult.fee, feeRate, this.network, ); - if (selectionResult.change && selectionResult.change.value > 0) { - if (isDust(selectionResult.change.value, scriptType)) { + const psbtService = new PsbtService(this.network); + psbtService.addInputs( + selectionResult.inputs, + options.replaceable, + account.hdPath, + hexToBuffer(account.pubkey, false), + hexToBuffer(account.mfp, false), + ); + + // TODO: add support of subtractFeeFrom, and throw error if output is too small after subtraction + for (const output of selectionResult.outputs) { + if (isDust(output.value, scriptType)) { + throw new TxValidationError('Transaction amount too small'); + } + psbtService.addOutput(output); + txInfo.addRecipient(output); + } + + if (selectionResult.change) { + if (isDust(change.value, scriptType)) { logger.warn( '[BtcWallet.createTransaction] Change is too small, adding to fees', ); - info.bumpFee(selectionResult.change.value); } else { - info.change = selectionResult.change; - psbtOutputs.push(selectionResult.change); + psbtService.addOutput(selectionResult.change); + txInfo.change = selectionResult.change; } } - const psbtService = new PsbtService(this.network); - - psbtService.addInputs(psbtInputs, account, options.replaceable); - - psbtService.addOutputs(psbtOutputs); + // Sign dummy transaction to extract the fee which is more accurate + const signedService = await psbtService.signDummy(account.signer); + txInfo.fee = signedService.getFee(); return { tx: psbtService.toBase64(), - txInfo: info, + txInfo, }; } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 436927f4..b8780d01 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -1,3 +1,4 @@ +import type { Json } from '@metamask/snaps-sdk'; import { InvalidParamsError, UserRejectedRequestError, @@ -16,14 +17,11 @@ import { BtcAccountBip32Deriver, BtcWallet, BtcAmount, - BtcTxInfo, - BtcAddress, } from '../bitcoin/wallet'; -import { TxOutput } from '../bitcoin/wallet/transaction-output'; import { FeeRatio } from '../chain'; import { Factory } from '../factory'; import { SnapHelper } from '../libs/snap'; -import type { IAccount } from '../wallet'; +import type { IAccount, ITxInfo } from '../wallet'; import { SendManyHandler } from './sendmany'; import type { SendManyParams } from './sendmany'; @@ -230,17 +228,34 @@ describe('SendManyHandler', () => { const calls = snapHelperSpy.mock.calls[0][0]; - expect(calls[calls.length - 4]).toStrictEqual({ - type: 'row', - label: 'Comment', - value: { type: 'text', value: 'test comment' }, + const lastPanel = calls[calls.length - 1]; + + expect(lastPanel).toStrictEqual({ + type: 'panel', + children: [ + { + type: 'row', + label: 'Comment', + value: { markdown: false, type: 'text', value: 'test comment' }, + }, + { + type: 'row', + label: 'Network fee', + value: { markdown: false, type: 'text', value: expect.any(String) }, + }, + { + type: 'row', + label: 'Total', + value: { markdown: false, type: 'text', value: expect.any(String) }, + }, + ], }); }); it('display `Recipient` as label in dialog if there is only 1 recipient', async () => { const network = networks.testnet; const caip2ChainId = Network.Testnet; - const { keyringAccount, recipients, snapHelperSpy } = + const { keyringAccount, recipients, snapHelperSpy, sender } = await prepareSendMany(network, caip2ChainId); const walletCreateTxSpy = jest.spyOn( BtcWallet.prototype, @@ -251,13 +266,24 @@ describe('SendManyHandler', () => { 'signTransaction', ); - const info = new BtcTxInfo( - new BtcAddress(recipients[0].address), - [new TxOutput(100000, recipients[0].address)], - 1, - 1, - network, - ); + const info: ITxInfo = { + toJson>() { + return { + feeRate: `0.00000001 BTC`, + txFee: `0.00000001 BTC`, + sender: sender.address, + recipients: [ + { + address: recipients[0].address, + value: `0.000010 BTC`, + explorerUrl: `https://blockchair.com/bitcoin/transaction/transactionId`, + }, + ], + changes: [], + total: `0.000010 BTC`, + } as unknown as TxInfoJson; + }, + }; walletCreateTxSpy.mockResolvedValue({ tx: 'transaction', @@ -274,7 +300,26 @@ describe('SendManyHandler', () => { const calls = snapHelperSpy.mock.calls[0][0]; - expect(calls[3]).toHaveProperty('label', 'Recipient'); + const recipientsPanel = calls[2]; + + expect(recipientsPanel).toStrictEqual({ + type: 'panel', + children: [ + { + type: 'row', + label: 'Recipient', + value: { + type: 'text', + value: `[${recipients[0].address}](https://blockchair.com/bitcoin/transaction/transactionId)`, + }, + }, + { + type: 'row', + label: 'Amount', + value: { markdown: false, type: 'text', value: '0.000010 BTC' }, + }, + ], + }); }); it('throws `Request params is invalid` error when request parameter is not correct', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index e7e4ec0c..046c1046 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -6,6 +6,7 @@ import { text, heading, row, + panel, } from '@metamask/snaps-sdk'; import { object, @@ -179,15 +180,27 @@ export class SendManyHandler comment: string, ): Promise { const header = `Send Request`; - const intro = `Review the request by [portfolio.metamask.io](https://portfolio.metamask.io/) before proceeding. Once the transaction is made, it's irreversible.`; + const intro = `Review the request before proceeding. Once the transaction is made, it's irreversible.`; const recipientsLabel = `Recipient`; const amountLabel = `Amount`; const commentLabel = `Comment`; - const networkFeeRateLabel = `Network fee rate`; + // const networkFeeRateLabel = `Network fee rate`; const networkFeeLabel = `Network fee`; const totalLabel = `Total`; + const requestedByLable = `Requested by`; + + const components: Component[] = [ + panel([ + heading(header), + text(intro), + row( + requestedByLable, + text(`[portfolio.metamask.io](https://portfolio.metamask.io/)`), + ), + ]), + divider(), + ]; - const components: Component[] = [heading(header), text(intro), divider()]; const info = txInfo.toJson(); const isMoreThanOneRecipient = @@ -200,7 +213,8 @@ export class SendManyHandler explorerUrl: string; value: string; }) => { - components.push( + const recipientsPanel: Component[] = []; + recipientsPanel.push( row( isMoreThanOneRecipient ? `${recipientsLabel} ${i + 1}` @@ -208,23 +222,27 @@ export class SendManyHandler text(`[${data.address}](${data.explorerUrl})`), ), ); - components.push(row(amountLabel, text(data.value, false))); - components.push(divider()); + recipientsPanel.push(row(amountLabel, text(data.value, false))); i += 1; + components.push(panel(recipientsPanel)); + components.push(divider()); }; info.recipients.forEach(addReciptentsToComponents); info.changes.forEach(addReciptentsToComponents); + const bottomPanel: Component[] = []; if (comment.trim().length > 0) { - components.push(row(commentLabel, text(comment.trim()))); + bottomPanel.push(row(commentLabel, text(comment.trim(), false))); } - components.push(row(networkFeeLabel, text(`${info.txFee}`, false))); + bottomPanel.push(row(networkFeeLabel, text(`${info.txFee}`, false))); + + // bottomPanel.push(row(networkFeeRateLabel, text(`${info.feeRate}`, false))); - components.push(row(networkFeeRateLabel, text(`${info.feeRate}`, false))); + bottomPanel.push(row(totalLabel, text(`${info.total}`, false))); - components.push(row(totalLabel, text(`${info.total}`, false))); + components.push(panel(bottomPanel)); return (await SnapHelper.confirmDialog(components)) as boolean; } From 086a3e0b1fb3dff3371d3b1cbe33b70822ddf61f Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 4 Jun 2024 18:54:06 +0800 Subject: [PATCH 062/362] chore: update protected property --- .../src/bitcoin/chain/service.ts | 24 +++++------ .../bitcoin/data-client/clients/blockchair.ts | 10 ++--- .../data-client/clients/blockstream.ts | 16 +++---- .../src/bitcoin/wallet/coin-select.ts | 6 +-- .../src/bitcoin/wallet/deriver.ts | 16 ++++--- .../src/bitcoin/wallet/factory.test.ts | 2 +- .../src/bitcoin/wallet/psbt.ts | 34 +++++++-------- .../src/bitcoin/wallet/signer.ts | 14 +++---- .../src/bitcoin/wallet/transaction-info.ts | 40 +++++++++--------- .../src/bitcoin/wallet/wallet.ts | 22 +++++----- .../src/keyring/keyring.ts | 42 +++++++++---------- 11 files changed, 115 insertions(+), 111 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index 62ac91e5..08b908a8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -19,24 +19,24 @@ import { BtcOnChainServiceError } from './exceptions'; import type { BtcOnChainServiceOptions } from './types'; export class BtcOnChainService implements IOnChainService { - protected readonly readClient: IReadDataClient; + protected readonly _readClient: IReadDataClient; - protected readonly writeClient: IWriteDataClient; + protected readonly _writeClient: IWriteDataClient; - protected readonly options: BtcOnChainServiceOptions; + protected readonly _options: BtcOnChainServiceOptions; constructor( readClient: IReadDataClient, writeClient: IWriteDataClient, options: BtcOnChainServiceOptions, ) { - this.readClient = readClient; - this.writeClient = writeClient; - this.options = options; + this._readClient = readClient; + this._writeClient = writeClient; + this._options = options; } get network(): Network { - return this.options.network; + return this._options.network; } async getBalances( @@ -58,7 +58,7 @@ export class BtcOnChainService implements IOnChainService { throw new BtcOnChainServiceError('Invalid asset'); } - const balance: Balances = await this.readClient.getBalances(addresses); + const balance: Balances = await this._readClient.getBalances(addresses); return addresses.reduce( (acc: AssetBalances, address: string) => { @@ -78,7 +78,7 @@ export class BtcOnChainService implements IOnChainService { async getFeeRates(): Promise { try { - const result = await this.readClient.getFeeRates(); + const result = await this._readClient.getFeeRates(); return { fees: Object.entries(result).map( @@ -95,7 +95,7 @@ export class BtcOnChainService implements IOnChainService { async getTransactionStatus(txHash: string) { try { - return await this.readClient.getTransactionStatus(txHash); + return await this._readClient.getTransactionStatus(txHash); } catch (error) { throw new BtcOnChainServiceError(error); } @@ -107,7 +107,7 @@ export class BtcOnChainService implements IOnChainService { transactionIntent?: TransactionIntent, ): Promise { try { - const data = await this.readClient.getUtxos(address); + const data = await this._readClient.getUtxos(address); return { data: { utxos: data, @@ -122,7 +122,7 @@ export class BtcOnChainService implements IOnChainService { signedTransaction: string, ): Promise { try { - const transactionId = await this.writeClient.sendTransaction( + const transactionId = await this._writeClient.sendTransaction( signedTransaction, ); return { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts index 7eb9a348..df958fac 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts @@ -205,14 +205,14 @@ export type PostTransactionResponse = { /* eslint-disable */ export class BlockChairClient implements IReadDataClient, IWriteDataClient { - options: BlockChairClientOptions; + protected readonly _options: BlockChairClientOptions; constructor(options: BlockChairClientOptions) { - this.options = options; + this._options = options; } get baseUrl(): string { - switch (this.options.network) { + switch (this._options.network) { case networks.bitcoin: return 'https://api.blockchair.com/bitcoin'; case networks.testnet: @@ -225,8 +225,8 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { protected getApiUrl(endpoint: string): string { const url = new URL(`${this.baseUrl}${endpoint}`); // TODO: Update to proxy - if (this.options.apiKey) { - url.searchParams.append('key', this.options.apiKey); + if (this._options.apiKey) { + url.searchParams.append('key', this._options.apiKey); } return url.toString(); } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts index 227c6cb9..4b8b155b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts @@ -71,13 +71,13 @@ export type GetBlocksResponse = { /* eslint-enable */ export class BlockStreamClient implements IReadDataClient { - protected readonly options: BlockStreamClientOptions; + protected readonly _options: BlockStreamClientOptions; - protected readonly feeRateRatioMap: Record; + protected readonly _feeRateRatioMap: Record; constructor(options: BlockStreamClientOptions) { - this.options = options; - this.feeRateRatioMap = { + this._options = options; + this._feeRateRatioMap = { [FeeRatio.Fast]: '1', [FeeRatio.Medium]: '144', [FeeRatio.Slow]: '144', @@ -85,7 +85,7 @@ export class BlockStreamClient implements IReadDataClient { } get baseUrl(): string { - switch (this.options.network) { + switch (this._options.network) { case networks.bitcoin: return 'https://blockstream.info/api'; case networks.testnet: @@ -184,13 +184,13 @@ export class BlockStreamClient implements IReadDataClient { ); return { [FeeRatio.Fast]: Math.round( - response[this.feeRateRatioMap[FeeRatio.Fast]], + response[this._feeRateRatioMap[FeeRatio.Fast]], ), [FeeRatio.Medium]: Math.round( - response[this.feeRateRatioMap[FeeRatio.Medium]], + response[this._feeRateRatioMap[FeeRatio.Medium]], ), [FeeRatio.Slow]: Math.round( - response[this.feeRateRatioMap[FeeRatio.Slow]], + response[this._feeRateRatioMap[FeeRatio.Slow]], ), }; } catch (error) { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index ade36a3a..c23a3f64 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -7,10 +7,10 @@ import { TxOutput } from './transaction-output'; import { type SelectionResult } from './types'; export class CoinSelectService { - #feeRate: number; + protected _feeRate: number; constructor(feeRate: number) { - this.#feeRate = Math.round(feeRate); + this._feeRate = Math.round(feeRate); } selectCoins( @@ -18,7 +18,7 @@ export class CoinSelectService { recipients: Recipient[] | TxOutput[], changeTo: TxOutput, ): SelectionResult { - const result = coinSelect(inputs, recipients, this.#feeRate); + const result = coinSelect(inputs, recipients, this._feeRate); if (!result.inputs || !result.outputs) { throw new TxValidationError('Insufficient funds'); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts index cd80ad4f..bd245801 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts @@ -10,20 +10,20 @@ import { DeriverError } from './exceptions'; import type { IBtcAccountDeriver } from './types'; export abstract class BtcAccountDeriver implements IBtcAccountDeriver { - protected readonly network: Network; + protected readonly _network: Network; - protected readonly bip32Api: BIP32API; + protected readonly _bip32Api: BIP32API; constructor(network: Network) { - this.bip32Api = BIP32Factory(ecc); - this.network = network; + this._bip32Api = BIP32Factory(ecc); + this._network = network; } abstract getRoot(path: string[]): Promise; createBip32FromSeed(seed: Buffer): BIP32Interface { try { - return this.bip32Api.fromSeed(seed, this.network); + return this._bip32Api.fromSeed(seed, this._network); } catch (error) { throw new DeriverError('Unable to construct BIP32 node from seed'); } @@ -34,7 +34,11 @@ export abstract class BtcAccountDeriver implements IBtcAccountDeriver { chainNode: Buffer, ): BIP32Interface { try { - return this.bip32Api.fromPrivateKey(privateKey, chainNode, this.network); + return this._bip32Api.fromPrivateKey( + privateKey, + chainNode, + this._network, + ); } catch (error) { throw new DeriverError('Unable to construct BIP32 node from private key'); } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts index 50f0a2e5..43f76ce9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts @@ -10,7 +10,7 @@ import * as manager from './wallet'; describe('BtcWalletFactory', () => { class MockBtcWallet extends manager.BtcWallet { getDeriver() { - return this.deriver; + return this._deriver; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index d1bae8c2..67fe764e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -15,21 +15,21 @@ import type { TxOutput } from './transaction-output'; const ECPair = ECPairFactory(ecc); export class PsbtService { - #psbt: Psbt; + protected _psbt: Psbt; - #network: Network; + protected _network: Network; get psbt() { - return this.#psbt; + return this._psbt; } constructor(network: Network, psbt?: Psbt) { if (psbt === undefined) { - this.#psbt = new Psbt({ network }); + this._psbt = new Psbt({ network }); } else { - this.#psbt = psbt; + this._psbt = psbt; } - this.#network = network; + this._network = network; } static fromBase64(network: Network, base64Psbt: string): PsbtService { @@ -46,7 +46,7 @@ export class PsbtService { changeAddressMfp: Buffer, ) { try { - this.#psbt.addInput({ + this._psbt.addInput({ hash: input.txHash, index: input.index, witnessUtxo: { @@ -103,7 +103,7 @@ export class PsbtService { addOutput(output: TxOutput) { try { - this.#psbt.addOutput({ + this._psbt.addOutput({ address: output.address, value: output.value, }); @@ -126,7 +126,7 @@ export class PsbtService { getFee(): number { try { - return this.#psbt.getFee(); + return this._psbt.getFee(); } catch (error) { logger.error('Failed to get fee', error); throw new PsbtServiceError('Failed to get fee from PSBT'); @@ -135,10 +135,10 @@ export class PsbtService { async signDummy(signer: IAccountSigner): Promise { try { - const psbt = this.#psbt.clone(); + const psbt = this._psbt.clone(); await psbt.signAllInputsHDAsync(signer); psbt.finalizeAllInputs(); - return new PsbtService(this.#network, psbt); + return new PsbtService(this._network, psbt); } catch (error) { logger.error('Failed to sign dummy', error); throw new PsbtServiceError('Failed to sign dummy in PSBT'); @@ -147,7 +147,7 @@ export class PsbtService { toBase64(): string { try { - return this.#psbt.toBase64(); + return this._psbt.toBase64(); } catch (error) { logger.error('Failed to convert to base64', error); throw new PsbtServiceError('Failed to output PSBT string'); @@ -159,10 +159,10 @@ export class PsbtService { // This function signAllInputsHDAsync is used to sign all inputs with the signer. // When using the method signAllInputsHDAsync, it is important to note that the signer must derive from the root node as well as the finderprint. // For further reference, please see the getHdSigner method in BtcWallet. - await this.#psbt.signAllInputsHDAsync(signer); + await this._psbt.signAllInputsHDAsync(signer); if ( - !this.#psbt.validateSignaturesOfAllInputs( + !this._psbt.validateSignaturesOfAllInputs( (pubkey: Buffer, msghash: Buffer, signature: Buffer) => this.validateInputs(pubkey, msghash, signature), ) @@ -178,11 +178,11 @@ export class PsbtService { finalize(): string { try { - this.#psbt.finalizeAllInputs(); + this._psbt.finalizeAllInputs(); - const txHex = this.#psbt.extractTransaction().toHex(); + const txHex = this._psbt.extractTransaction().toHex(); - const weight = this.#psbt.extractTransaction().weight(); + const weight = this._psbt.extractTransaction().weight(); if (weight > MaxStandardTxWeight) { throw new TxValidationError('Transaction is too large'); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts index 72bb4e8e..53ee12d5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts @@ -9,12 +9,12 @@ export class AccountSigner implements HDSignerAsync, IAccountSigner { readonly fingerprint: Buffer; - protected readonly node: BIP32Interface; + protected readonly _node: BIP32Interface; constructor(accountNode: BIP32Interface, mfp?: Buffer) { - this.node = accountNode; - this.publicKey = this.node.publicKey; - this.fingerprint = mfp ?? this.node.fingerprint; + this._node = accountNode; + this.publicKey = this._node.publicKey; + this.fingerprint = mfp ?? this._node.fingerprint; } derivePath(path: string): IAccountSigner { @@ -31,7 +31,7 @@ export class AccountSigner implements HDSignerAsync, IAccountSigner { } index = parseInt(indexStr, 10); return prevHd.derive(index); - }, this.node); + }, this._node); return new AccountSigner(childNode, this.fingerprint); } catch (error) { throw new Error('Unable to derive path'); @@ -39,10 +39,10 @@ export class AccountSigner implements HDSignerAsync, IAccountSigner { } async sign(hash: Buffer): Promise { - return this.node.sign(hash); + return this._node.sign(hash); } verify(hash: Buffer, signature: Buffer): boolean { - return this.node.verify(hash, signature); + return this._node.verify(hash, signature); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts index fbb685d5..b1a7d91f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts @@ -14,23 +14,23 @@ export class BtcTxInfo implements ITxInfo { change?: TxOutput; - #recipients: TxOutput[]; + protected _recipients: TxOutput[]; - #feeRate: BtcAmount; + protected _feeRate: BtcAmount; - #outputTotal: BtcAmount; + protected _outputTotal: BtcAmount; - #serializedRecipients: Json[]; + protected _serializedRecipients: Json[]; - #network: Network; + protected _network: Network; constructor(sender: BtcAddress, feeRate: number, network: Network) { - this.#recipients = []; - this.#serializedRecipients = []; - this.#outputTotal = new BtcAmount(0); - this.#feeRate = new BtcAmount(feeRate); + this._recipients = []; + this._serializedRecipients = []; + this._outputTotal = new BtcAmount(0); + this._feeRate = new BtcAmount(feeRate); this.txFee = new BtcAmount(0); - this.#network = network; + this._network = network; this.sender = sender; } @@ -42,7 +42,7 @@ export class BtcTxInfo implements ITxInfo { value: this.change.amount.toString(true), explorerUrl: getExplorerUrl( this.change.destination.value, - getCaip2ChainId(this.#network), + getCaip2ChainId(this._network), ), }, ] @@ -56,34 +56,34 @@ export class BtcTxInfo implements ITxInfo { } addRecipient(output: TxOutput): void { - this.#outputTotal.value += output.value; + this._outputTotal.value += output.value; - this.#recipients.push(output); + this._recipients.push(output); - this.#serializedRecipients.push({ + this._serializedRecipients.push({ address: output.destination.toString(true), value: output.amount.toString(true), explorerUrl: getExplorerUrl( output.destination.value, - getCaip2ChainId(this.#network), + getCaip2ChainId(this._network), ), }); } get total(): BtcAmount { return new BtcAmount( - this.#outputTotal.value + + this._outputTotal.value + (this.change ? this.change.value : 0) + this.txFee.value, ); } get feeRate(): BtcAmount { - return this.#feeRate; + return this._feeRate; } get recipients(): TxOutput[] { - return this.#recipients; + return this._recipients; } get fee(): number { @@ -96,10 +96,10 @@ export class BtcTxInfo implements ITxInfo { toJson>(): InfoJson { return { - feeRate: this.#feeRate.toString(true), + feeRate: this._feeRate.toString(true), txFee: this.txFee.toString(true), sender: this.sender.toString(), - recipients: this.#serializedRecipients, + recipients: this._serializedRecipients, changes: this.changeToJson(), total: this.total.toString(true), } as unknown as InfoJson; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 06b5e627..75157aa5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -28,13 +28,13 @@ import type { } from './types'; export class BtcWallet implements IWallet { - protected readonly deriver: IBtcAccountDeriver; + protected readonly _deriver: IBtcAccountDeriver; - protected readonly network: Network; + protected readonly _network: Network; constructor(deriver: IBtcAccountDeriver, network: Network) { - this.deriver = deriver; - this.network = network; + this._deriver = deriver; + this._network = network; } protected getAccountCtor(type: string): IStaticBtcAccount { @@ -55,8 +55,8 @@ export class BtcWallet implements IWallet { async unlock(index: number, type: string): Promise { try { const AccountCtor = this.getAccountCtor(type); - const rootNode = await this.deriver.getRoot(AccountCtor.path); - const childNode = await this.deriver.getChild(rootNode, index); + 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 AccountCtor( @@ -64,7 +64,7 @@ export class BtcWallet implements IWallet { index, hdPath, bufferToString(childNode.publicKey, 'hex'), - this.network, + this._network, AccountCtor.scriptType, `bip122:${AccountCtor.scriptType.toLowerCase()}`, this.getHdSigner(rootNode), @@ -92,7 +92,7 @@ export class BtcWallet implements IWallet { new TxOutput( recipient.value, recipient.address, - address.toOutputScript(recipient.address, this.network), + address.toOutputScript(recipient.address, this._network), ), ); @@ -112,10 +112,10 @@ export class BtcWallet implements IWallet { const txInfo = new BtcTxInfo( new BtcAddress(account.address), feeRate, - this.network, + this._network, ); - const psbtService = new PsbtService(this.network); + const psbtService = new PsbtService(this._network); psbtService.addInputs( selectionResult.inputs, options.replaceable, @@ -155,7 +155,7 @@ export class BtcWallet implements IWallet { } async signTransaction(signer: IAccountSigner, tx: string): Promise { - const psbtService = PsbtService.fromBase64(this.network, tx); + const psbtService = PsbtService.fromBase64(this._network, tx); await psbtService.signNVerify(signer); return psbtService.finalize(); } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts index a9e2a446..a59d337e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts @@ -30,25 +30,25 @@ import { } from './types'; export class BtcKeyring implements Keyring { - protected readonly stateMgr: KeyringStateManager; + protected readonly _stateMgr: KeyringStateManager; - protected readonly options: KeyringOptions; + protected readonly _options: KeyringOptions; - protected readonly keyringMethods: string[]; + protected readonly _keyringMethods: string[]; - protected readonly handlers: ChainRPCHandlers; + protected readonly _handlers: ChainRPCHandlers; constructor(stateMgr: KeyringStateManager, options: KeyringOptions) { - this.stateMgr = stateMgr; - this.options = options; + this._stateMgr = stateMgr; + this._options = options; const mapping = RpcHelper.getKeyringRpcApiHandlers(); - this.keyringMethods = Object.keys(mapping); - this.handlers = mapping; + this._keyringMethods = Object.keys(mapping); + this._handlers = mapping; } async listAccounts(): Promise { try { - return await this.stateMgr.listAccounts(); + return await this._stateMgr.listAccounts(); } catch (error) { throw new BtcKeyringError(error); } @@ -56,7 +56,7 @@ export class BtcKeyring implements Keyring { async getAccount(id: string): Promise { try { - return (await this.stateMgr.getAccount(id)) ?? undefined; + return (await this._stateMgr.getAccount(id)) ?? undefined; } catch (error) { throw new BtcKeyringError(error); } @@ -90,8 +90,8 @@ export class BtcKeyring implements Keyring { )}`, ); - await this.stateMgr.withTransaction(async () => { - await this.stateMgr.addWallet({ + await this._stateMgr.withTransaction(async () => { + await this._stateMgr.addWallet({ account: keyringAccount, hdPath: account.hdPath, index: account.index, @@ -121,8 +121,8 @@ export class BtcKeyring implements Keyring { async updateAccount(account: KeyringAccount): Promise { try { - await this.stateMgr.withTransaction(async () => { - await this.stateMgr.updateAccount(account); + await this._stateMgr.withTransaction(async () => { + await this._stateMgr.updateAccount(account); await this.#emitEvent(KeyringEvent.AccountUpdated, { account }); }); } catch (error) { @@ -134,8 +134,8 @@ export class BtcKeyring implements Keyring { async deleteAccount(id: string): Promise { try { - await this.stateMgr.withTransaction(async () => { - await this.stateMgr.removeAccounts([id]); + await this._stateMgr.withTransaction(async () => { + await this._stateMgr.removeAccounts([id]); await this.#emitEvent(KeyringEvent.AccountDeleted, { id }); }); } catch (error) { @@ -162,7 +162,7 @@ export class BtcKeyring implements Keyring { const { scope, account } = request; const { method, params } = request.request; - if (!Object.prototype.hasOwnProperty.call(this.handlers, method)) { + if (!Object.prototype.hasOwnProperty.call(this._handlers, method)) { throw new MethodNotFoundError() as unknown as Error; } @@ -174,7 +174,7 @@ export class BtcKeyring implements Keyring { ); } - return this.handlers[method].getInstance(walletData).execute({ + return this._handlers[method].getInstance(walletData).execute({ ...params, scope, } as unknown as SnapRpcHandlerRequest); @@ -185,7 +185,7 @@ export class BtcKeyring implements Keyring { data: Record, ): Promise { // TODO: Temp solution to support keyring in snap without extentions support - if (this.options.emitEvents) { + if (this._options.emitEvents) { await emitSnapKeyringEvent(SnapHelper.provider, event, data); } } @@ -201,7 +201,7 @@ export class BtcKeyring implements Keyring { options: { ...options, }, - methods: this.keyringMethods, + methods: this._keyringMethods, } as unknown as KeyringAccount; } @@ -224,7 +224,7 @@ export class BtcKeyring implements Keyring { } protected async getAndVerifyWallet(id: string) { - const walletData = await this.stateMgr.getWallet(id); + const walletData = await this._stateMgr.getWallet(id); if (!walletData) { throw new Error('Account not found'); From c81bc84a7d55c543d898d0f204d544d83c3360aa Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 6 Jun 2024 09:27:14 +0800 Subject: [PATCH 063/362] chore: refinement batch2 (#101) * chore: refinement * chore: update todo text * chore: add getScriptForDestnation to support extract script from address --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/bitcoin/utils/address.test.ts | 21 +++++ .../src/bitcoin/utils/address.ts | 17 ++++ .../src/bitcoin/wallet/account.ts | 6 ++ .../src/bitcoin/wallet/coin-select.test.ts | 39 +++----- .../src/bitcoin/wallet/coin-select.ts | 15 +-- .../src/bitcoin/wallet/psbt.test.ts | 9 +- .../src/bitcoin/wallet/psbt.ts | 6 +- .../bitcoin/wallet/transaction-info.test.ts | 5 +- .../bitcoin/wallet/transaction-input.test.ts | 3 +- .../src/bitcoin/wallet/transaction-input.ts | 22 ++--- .../bitcoin/wallet/transaction-output.test.ts | 4 +- .../src/bitcoin/wallet/transaction-output.ts | 4 +- .../src/bitcoin/wallet/types.ts | 2 + .../src/bitcoin/wallet/wallet.test.ts | 43 +-------- .../src/bitcoin/wallet/wallet.ts | 93 ++++++++++++------- .../bitcoin-wallet-snap/src/factory.ts | 4 +- .../src/keyring/keyring.ts | 2 +- .../bitcoin-wallet-snap/src/keyring/types.ts | 2 +- .../bitcoin-wallet-snap/src/libs/rpc/base.ts | 10 +- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 6 +- .../bitcoin-wallet-snap/src/wallet.ts | 4 +- 22 files changed, 163 insertions(+), 156 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index cb36df1b..b3a96b45 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "k2VftTYTbMrhIZSwAGQb91tdJAF/lprzhf+XXuUuHk4=", + "shasum": "gycY0HZ56I1PRIdcUPm4dYlATHJxoWWivbMHJddftK0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts new file mode 100644 index 00000000..d6d083e6 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts @@ -0,0 +1,21 @@ +import { networks } from 'bitcoinjs-lib'; +import { Buffer } from 'buffer'; + +import { getScriptForDestnation } from './address'; + +describe('address', () => { + const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; + + describe('getScriptForDestnation', () => { + it('returns a script', () => { + const val = getScriptForDestnation(address, networks.testnet); + expect(val).toBeInstanceOf(Buffer); + }); + + it('throws `Destnation address has no matching Script` error if the given address is invalid', () => { + expect(() => + getScriptForDestnation('bad-address', networks.testnet), + ).toThrow(`Destnation address has no matching Script`); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts new file mode 100644 index 00000000..e36946dd --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts @@ -0,0 +1,17 @@ +import { address as addressUtils, type Network } from 'bitcoinjs-lib'; + +/** + * Returns the script for a Bitcoin destination address. + * + * @param address - The Bitcoin destination address. + * @param network - The Bitcoin network. + * @returns The Bitcoin script for the destination address. + * @throws An error if the address does not have a matching script. + */ +export function getScriptForDestnation(address: string, network: Network) { + try { + return addressUtils.toOutputScript(address, network); + } catch (error) { + throw new Error('Destnation address has no matching Script'); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts index dc101f23..8c83a93d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts @@ -1,4 +1,5 @@ import type { Network, Payment } from 'bitcoinjs-lib'; +import type { Buffer } from 'buffer'; import type { StaticImplements } from '../../types/static'; import { hexToBuffer } from '../../utils'; @@ -48,6 +49,11 @@ export class BtcAccount implements IBtcAccount { this.type = type; } + get script(): Buffer { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.payment.output!; + } + get address(): string { if (!this.#address) { if (!this.payment.address) { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts index af4718ef..5f009fa7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts @@ -1,4 +1,4 @@ -import { networks } from 'bitcoinjs-lib'; +import { networks, address as addressUtils } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { ScriptType } from '../constants'; @@ -34,12 +34,15 @@ describe('CoinSelectService', () => { const utxos = generateFormatedUtxos(sender.address, 2, inputMin, inputMax); - const outputs = [new TxOutput(outputVal, receiver1.address)]; + const outputs = [ + new TxOutput( + outputVal, + receiver1.address, + addressUtils.toOutputScript(receiver1.address, network), + ), + ]; - const inputs = utxos.map( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (utxo) => new TxInput(utxo, sender.payment.output!), - ); + const inputs = utxos.map((utxo) => new TxInput(utxo, sender.script)); return { sender, @@ -61,7 +64,7 @@ describe('CoinSelectService', () => { const result = coinSelectService.selectCoins( inputs, outputs, - new TxOutput(0, sender.address), + new TxOutput(0, sender.address, sender.script), ); expect(result.fee).toBeGreaterThan(1); @@ -70,26 +73,6 @@ describe('CoinSelectService', () => { expect(result.outputs.length).toBeGreaterThan(0); }); - it('converts output to TxOutput', async () => { - const network = networks.testnet; - const { inputs, outputs, sender } = await prepareCoinSlect(network); - - const coinSelectService = new CoinSelectService(1); - - const result = coinSelectService.selectCoins( - inputs, - outputs.map((output) => ({ - address: output.address, - value: output.value, - })), - new TxOutput(0, sender.address), - ); - - for (const output of result.outputs) { - expect(output).toBeInstanceOf(TxOutput); - } - }); - it('throws `Insufficient funds` error if the given utxos is not sufficient', async () => { const network = networks.testnet; const { inputs, outputs, sender } = await prepareCoinSlect( @@ -105,7 +88,7 @@ describe('CoinSelectService', () => { coinSelectService.selectCoins( inputs, outputs, - new TxOutput(0, sender.address), + new TxOutput(0, sender.address, sender.script), ), ).toThrow('Insufficient funds'); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index c23a3f64..a6506849 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -1,13 +1,12 @@ import coinSelect from 'coinselect'; -import type { Recipient } from '../../wallet'; import { TxValidationError } from './exceptions'; import type { TxInput } from './transaction-input'; -import { TxOutput } from './transaction-output'; +import type { TxOutput } from './transaction-output'; import { type SelectionResult } from './types'; export class CoinSelectService { - protected _feeRate: number; + protected readonly _feeRate: number; constructor(feeRate: number) { this._feeRate = Math.round(feeRate); @@ -15,7 +14,7 @@ export class CoinSelectService { selectCoins( inputs: TxInput[], - recipients: Recipient[] | TxOutput[], + recipients: TxOutput[], changeTo: TxOutput, ): SelectionResult { const result = coinSelect(inputs, recipients, this._feeRate); @@ -33,13 +32,7 @@ export class CoinSelectService { // restructure outputs to avoid depends on coinselect output format for (const output of result.outputs) { if (output.address) { - if (output instanceof TxOutput) { - selectedResult.outputs.push(output); - } else { - selectedResult.outputs.push( - new TxOutput(output.value, output.address), - ); - } + selectedResult.outputs.push(output); } else { changeTo.value = output.value; selectedResult.change = changeTo; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts index 70df4e16..17444791 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts @@ -47,7 +47,7 @@ describe('PsbtService', () => { const fee = 500; const outputs = receivers.map( - (account) => new TxOutput(outputVal, account.address), + (account) => new TxOutput(outputVal, account.address, account.script), ); const utxos = generateFormatedUtxos( sender.address, @@ -55,10 +55,7 @@ describe('PsbtService', () => { outputVal * outputs.length + fee, outputVal * outputs.length + fee, ); - const inputs = utxos.map( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (utxo) => new TxInput(utxo, sender.payment.output!), - ); + const inputs = utxos.map((utxo) => new TxInput(utxo, sender.script)); service.addOutputs(outputs); @@ -183,7 +180,7 @@ describe('PsbtService', () => { for (let i = 0; i < service.psbt.txOutputs.length; i++) { expect(service.psbt.txOutputs[i]).toHaveProperty( 'script', - receivers[i].payment.output, + receivers[i].script, ); expect(service.psbt.txOutputs[i]).toHaveProperty( 'value', diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index 67fe764e..f4638e39 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -15,9 +15,9 @@ import type { TxOutput } from './transaction-output'; const ECPair = ECPairFactory(ecc); export class PsbtService { - protected _psbt: Psbt; + protected readonly _psbt: Psbt; - protected _network: Network; + protected readonly _network: Network; get psbt() { return this._psbt; @@ -104,7 +104,7 @@ export class PsbtService { addOutput(output: TxOutput) { try { this._psbt.addOutput({ - address: output.address, + script: output.script, value: output.value, }); } catch (error) { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts index 4c553e10..c1b15f32 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts @@ -1,4 +1,5 @@ import { networks } from 'bitcoinjs-lib'; +import { Buffer } from 'buffer'; import { generateAccounts } from '../../../test/utils'; import { getCaip2ChainId, getExplorerUrl, satsToBtc } from '../utils'; @@ -24,13 +25,13 @@ describe('BtcTxInfo', () => { for (let i = 1; i < addresses.length; i++) { total += 100000; - const output = new TxOutput(100000, addresses[i]); + const output = new TxOutput(100000, addresses[i], Buffer.from('dummy')); outputs.push(output); } info.addRecipients(outputs); - info.change = new TxOutput(500, addresses[0]); + info.change = new TxOutput(500, addresses[0], Buffer.from('dummy')); total += 500; const expectedRecipients = outputs.map((recipient) => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts index 5dbe7061..1f0877b6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts @@ -1,5 +1,4 @@ import { networks } from 'bitcoinjs-lib'; -import type { Buffer } from 'buffer'; import { generateFormatedUtxos } from '../../../test/utils'; import { ScriptType } from '../constants'; @@ -23,7 +22,7 @@ describe('TxInput', () => { it('return correct property', async () => { const wallet = createMockWallet(networks.testnet); const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); - const script = account.payment.output as unknown as Buffer; + const { script } = account; const utxo = generateFormatedUtxos(account.address, 1)[0]; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts index c8da508d..504fd251 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts @@ -10,28 +10,22 @@ export class TxInput { readonly amount: IAmount; - readonly utxo: Utxo; + readonly txHash: string; + + readonly index: number; + + readonly block: number; constructor(utxo: Utxo, script: Buffer) { this.script = script; - this.utxo = utxo; this.amount = new BtcAmount(utxo.value); + this.index = utxo.index; + this.txHash = utxo.txHash; + this.block = utxo.block; } // consume by coinselect get value(): number { return this.amount.value; } - - get txHash(): string { - return this.utxo.txHash; - } - - get index(): number { - return this.utxo.index; - } - - get block(): number { - return this.utxo.block; - } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts index 2ae196b7..ea20d13f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts @@ -1,3 +1,5 @@ +import { Buffer } from 'buffer'; + import { generateAccounts } from '../../../test/utils'; import { TxOutput } from './transaction-output'; @@ -5,7 +7,7 @@ describe('TxOutput', () => { it('return correct property', () => { const account = generateAccounts(1)[0]; - const input = new TxOutput(10, account.address); + const input = new TxOutput(10, account.address, Buffer.from('dummy')); expect(input.value).toBe(10); expect(input.address).toStrictEqual(account.address); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts index 772a9ff6..298a7fca 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts @@ -8,11 +8,11 @@ export class TxOutput { readonly amount: IAmount; // consume by conselect - readonly script?: Buffer; + readonly script: Buffer; readonly destination: IAddress; - constructor(value: number, address: string, script?: Buffer) { + constructor(value: number, address: string, script: Buffer) { this.amount = new BtcAmount(value); this.destination = new BtcAddress(address); this.script = script; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts index 549caf2d..4db8fff1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts @@ -1,5 +1,6 @@ import type { BIP32Interface } from 'bip32'; import type { Network, Payment } from 'bitcoinjs-lib'; +import type { Buffer } from 'buffer'; import type { IAccount, IAccountSigner } from '../../wallet'; import type { ScriptType } from '../constants'; @@ -15,6 +16,7 @@ export type IBtcAccount = IAccount & { payment: Payment; scriptType: ScriptType; network: Network; + script: Buffer; }; export type IStaticBtcAccount = { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index 057d9fd6..8303ad3b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -1,5 +1,5 @@ import type { Json } from '@metamask/snaps-sdk'; -import { address as addressUtils, networks } from 'bitcoinjs-lib'; +import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { DustLimit, ScriptType } from '../constants'; @@ -210,19 +210,11 @@ describe('BtcWallet', () => { change: new TxOutput( DustLimit[chgAccount.scriptType] - 1, chgAccount.address, + chgAccount.script, ), fee: 100, - inputs: utxos.map( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (utxo) => new TxInput(utxo, chgAccount.payment.output!), - ), - outputs: [ - new TxOutput( - 500, - recipient.address, - addressUtils.toOutputScript(recipient.address, network), - ), - ], + inputs: utxos.map((utxo) => new TxInput(utxo, chgAccount.script)), + outputs: [new TxOutput(500, recipient.address, recipient.script)], }; coinSelectServiceSpy.mockReturnValue(selectionResult); @@ -244,7 +236,7 @@ describe('BtcWallet', () => { expect(info.change).toBeUndefined(); }); - it('throws `Transaction amount too small` error the transaction output is too small', async () => { + it('throws `Transaction amount too small` error if the transaction output is too small', async () => { const network = networks.testnet; const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); @@ -264,31 +256,6 @@ describe('BtcWallet', () => { ), ).rejects.toThrow('Transaction amount too small'); }); - - it('throws `Fail to get account script hash` error if the account script hash is undefined', async () => { - const network = networks.testnet; - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(account.address, 2); - account.payment.output = undefined; - - await expect( - wallet.createTransaction( - account, - createMockTxIndent( - account.address, - DustLimit[account.scriptType] + 1, - ), - { - utxos, - fee: 1, - subtractFeeFrom: [], - replaceable: false, - }, - ), - ).rejects.toThrow('Fail to get account script hash'); - }); }); describe('signTransaction', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 75157aa5..a2647208 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -1,5 +1,5 @@ import type { BIP32Interface } from 'bip32'; -import { type Network, address } from 'bitcoinjs-lib'; +import { type Network } from 'bitcoinjs-lib'; import { logger } from '../../libs/logger/logger'; import { bufferToString, compactError, hexToBuffer } from '../../utils'; @@ -7,10 +7,11 @@ import type { IAccountSigner, IWallet, Recipient, - TxCreationResult, + Transaction, } from '../../wallet'; import { ScriptType } from '../constants'; import { isDust } from '../utils'; +import { getScriptForDestnation } from '../utils/address'; import { P2WPKHAccount, P2SHP2WPKHAccount } from './account'; import { BtcAddress } from './address'; import { CoinSelectService } from './coin-select'; @@ -37,21 +38,6 @@ export class BtcWallet implements IWallet { this._network = network; } - protected getAccountCtor(type: string): IStaticBtcAccount { - let scriptType = type; - if (type.includes('bip122:')) { - scriptType = type.split(':')[1]; - } - switch (scriptType.toLowerCase()) { - case ScriptType.P2wpkh.toLowerCase(): - return P2WPKHAccount; - case ScriptType.P2shP2wkh.toLowerCase(): - return P2SHP2WPKHAccount; - default: - throw new WalletError('Invalid script type'); - } - } - async unlock(index: number, type: string): Promise { try { const AccountCtor = this.getAccountCtor(type); @@ -78,36 +64,59 @@ export class BtcWallet implements IWallet { account: IBtcAccount, recipients: Recipient[], options: CreateTransactionOptions, - ): Promise { - const scriptOutput = account.payment.output; + ): Promise { + const scriptOutput = account.script; const { scriptType } = account; - if (!scriptOutput) { - throw new WalletError('Fail to get account script hash'); - } + logger.info( + JSON.stringify( + { + recipients, + options, + }, + null, + 2, + ), + ); + // TODO: Supporting getting coins from other address (dynamic address) const inputs = options.utxos.map((utxo) => new TxInput(utxo, scriptOutput)); - const outputs = recipients.map( - (recipient) => - new TxOutput( - recipient.value, - recipient.address, - address.toOutputScript(recipient.address, this._network), - ), - ); + const outputs = recipients.map((recipient) => { + if (isDust(recipient.value, scriptType)) { + throw new TxValidationError('Transaction amount too small'); + } + const destnationScriptOutput = getScriptForDestnation( + recipient.address, + this._network, + ); + return new TxOutput( + recipient.value, + recipient.address, + destnationScriptOutput, + ); + }); - // as fee rate can be 0, we need to ensure it is at least 1 + // Do not ever accept zero fee rate, we need to ensure it is at least 1 // TODO: The min fee rate should be setting from parameter const feeRate = Math.max(1, options.fee); const coinSelectService = new CoinSelectService(feeRate); - const change = new TxOutput(0, account.address); + const change = new TxOutput(0, account.address, scriptOutput); const selectionResult = coinSelectService.selectCoins( inputs, outputs, change, ); - logger.info(JSON.stringify(selectionResult, null, 2)); + logger.info( + JSON.stringify( + { + feeRate, + ...selectionResult, + }, + null, + 2, + ), + ); const txInfo = new BtcTxInfo( new BtcAddress(account.address), @@ -126,9 +135,6 @@ export class BtcWallet implements IWallet { // TODO: add support of subtractFeeFrom, and throw error if output is too small after subtraction for (const output of selectionResult.outputs) { - if (isDust(output.value, scriptType)) { - throw new TxValidationError('Transaction amount too small'); - } psbtService.addOutput(output); txInfo.addRecipient(output); } @@ -163,4 +169,19 @@ export class BtcWallet implements IWallet { protected getHdSigner(rootNode: BIP32Interface) { return new AccountSigner(rootNode, rootNode.fingerprint); } + + protected getAccountCtor(type: string): IStaticBtcAccount { + let scriptType = type; + if (type.includes('bip122:')) { + scriptType = type.split(':')[1]; + } + switch (scriptType.toLowerCase()) { + case ScriptType.P2wpkh.toLowerCase(): + return P2WPKHAccount; + case ScriptType.P2shP2wkh.toLowerCase(): + return P2SHP2WPKHAccount; + default: + throw new WalletError('Invalid script type'); + } + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts index f6974caa..ad536089 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.ts @@ -13,7 +13,7 @@ import { Config } from './config'; import { BtcKeyring, KeyringStateManager } from './keyring'; import type { IWallet } from './wallet'; -// TODO: Temp solution to support keyring in snap without keyring API +// TODO: Remove temp solution to support keyring in snap without keyring API export type CreateBtcKeyringOptions = { emitEvents: boolean; }; @@ -43,7 +43,7 @@ export class Factory { return new BtcKeyring(new KeyringStateManager(), { defaultIndex: config.defaultAccountIndex, multiAccount: config.enableMultiAccounts, - // TODO: Temp solution to support keyring in snap without keyring API + // TODO: Remove temp solution to support keyring in snap without keyring API emitEvents: options.emitEvents, }); } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts index a59d337e..74a1c60c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts @@ -184,7 +184,7 @@ export class BtcKeyring implements Keyring { event: KeyringEvent, data: Record, ): Promise { - // TODO: Temp solution to support keyring in snap without extentions support + // TODO: Remove temp solution to support keyring in snap without extentions support if (this._options.emitEvents) { await emitSnapKeyringEvent(SnapHelper.provider, event, data); } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts index acae60e9..ed9e02c1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts @@ -23,7 +23,7 @@ export type SnapState = { export type KeyringOptions = Record & { defaultIndex: number; multiAccount?: boolean; - // TODO: Temp solutio to support keyring in snap without keyring API + // TODO: Remove temp solution to support keyring in snap without keyring API emitEvents?: boolean; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts index 656bf7fe..22f57026 100644 --- a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts +++ b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts @@ -25,11 +25,11 @@ export abstract class BaseSnapRpcHandler { params: SnapRpcHandlerRequest, ): Promise; - protected get requestStruct(): Struct { + protected get _requestStruct(): Struct { return (this.constructor as typeof BaseSnapRpcHandler).requestStruct; } - protected get responseStruct(): Struct | undefined { + protected get _responseStruct(): Struct | undefined { return (this.constructor as typeof BaseSnapRpcHandler).responseStruct; } @@ -38,7 +38,7 @@ export abstract class BaseSnapRpcHandler { `[SnapRpcHandler.preExecute] Request: ${JSON.stringify(params)}`, ); try { - assert(params, this.requestStruct); + assert(params, this._requestStruct); } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[SnapRpcHandler.preExecute] Error: ${error.message}`); @@ -52,8 +52,8 @@ export abstract class BaseSnapRpcHandler { ); try { - if (this.responseStruct) { - assert(response, this.responseStruct); + if (this._responseStruct) { + assert(response, this._responseStruct); } } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 046c1046..ba68876b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -46,7 +46,11 @@ export const TransactionAmountStuct = refine( for (const val of Object.values(value)) { const parsedVal = parseFloat(val); - if (Number.isNaN(parsedVal) || parsedVal <= 0) { + if ( + Number.isNaN(parsedVal) || + parsedVal <= 0 || + !Number.isFinite(parsedVal) + ) { return 'Invalid amount for send'; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/wallet.ts index 8cc5c0b9..937fc141 100644 --- a/merged-packages/bitcoin-wallet-snap/src/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/wallet.ts @@ -6,7 +6,7 @@ export type Recipient = { value: number; }; -export type TxCreationResult = { +export type Transaction = { tx: string; txInfo: ITxInfo; }; @@ -132,7 +132,7 @@ export type IWallet = { account: IAccount, recipients: Recipient[], options: Record, - ): Promise; + ): Promise; }; /** From 09e1acf4f445efd6f17d085236c46f63aeaf63f3 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 6 Jun 2024 09:38:56 +0800 Subject: [PATCH 064/362] Update README.md (#102) --- merged-packages/bitcoin-wallet-snap/README.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index 54daf78e..2e75fbeb 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -80,3 +80,25 @@ provider.request({ }, }); ``` + +### API `keyring_getAccountBalances` + +example: +```typescript +provider.request({ + method: 'wallet_invokeKeyring', + params: { + snapId, + request: { + method: 'keyring_getAccountBalances', + params: { + account: 'dc06350a-82db-434b-b113-066135804f63', // the uuid account id of the current account + id: 'da40b782-e054-4260-9a6a-c8717a022f92', // an random uuid for this request + assets: [ + 'bip122:000000000019d6689c085ae165831e93/slip44:0' + ], // Caip-2 BTC Asset + }, + }, + }, +}); +``` From d29d0f19f95c740a498a84353e0eb968f591d2c7 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 6 Jun 2024 09:41:12 +0800 Subject: [PATCH 065/362] Update README.md --- merged-packages/bitcoin-wallet-snap/README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index 2e75fbeb..a42e75ad 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -84,6 +84,7 @@ provider.request({ ### API `keyring_getAccountBalances` example: + ```typescript provider.request({ method: 'wallet_invokeKeyring', @@ -94,9 +95,7 @@ provider.request({ params: { account: 'dc06350a-82db-434b-b113-066135804f63', // the uuid account id of the current account id: 'da40b782-e054-4260-9a6a-c8717a022f92', // an random uuid for this request - assets: [ - 'bip122:000000000019d6689c085ae165831e93/slip44:0' - ], // Caip-2 BTC Asset + assets: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], // Caip-2 BTC Asset }, }, }, From cddeee5453c5e8286d818333404148ebc6adee9b Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 12 Jun 2024 09:31:35 +0800 Subject: [PATCH 066/362] chore: refactor with simple version (#108) * chore: update structure * Update wallet.ts * chore: remove un use deriver * Update index.tsx * Update permissions.ts * Update snap.manifest.json * chore: add big.js * chore: add min btc validation in sendmany * chore: add big int support * chore: clean up duplicate code * chore: simplify the code * chore: remove unuse code * chore: remove interface for IBtcAccountDeriver * chore: remove blockstream testing tools * chore: moving utxo to chain interface * Update utils.ts * chore: rename chain id and asset name * chore: moving the logger to utils --- .../bitcoin-wallet-snap/package.json | 1 + .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../clients/blockchair.test.ts | 2 +- .../clients/blockchair.ts | 45 ++- .../types.ts => chain/data-client.ts} | 14 +- .../src/bitcoin/chain/exceptions.ts | 4 +- .../src/bitcoin/chain/index.ts | 1 - .../src/bitcoin/chain/service.test.ts | 83 ++-- .../src/bitcoin/chain/service.ts | 46 +-- .../src/bitcoin/chain/types.ts | 5 - .../src/bitcoin/config/index.ts | 1 - .../src/bitcoin/config/types.ts | 23 -- .../src/bitcoin/constants.ts | 28 +- .../data-client/clients/blockstream.test.ts | 305 --------------- .../data-client/clients/blockstream.ts | 223 ----------- .../src/bitcoin/data-client/exceptions.ts | 3 - .../src/bitcoin/data-client/factory.test.ts | 82 ---- .../src/bitcoin/data-client/factory.ts | 50 --- .../src/bitcoin/data-client/index.ts | 3 - .../src/bitcoin/utils/address.test.ts | 4 +- .../src/bitcoin/utils/address.ts | 2 +- .../src/bitcoin/utils/explorer.test.ts | 27 -- .../src/bitcoin/utils/explorer.ts | 25 -- .../src/bitcoin/utils/index.ts | 4 +- .../src/bitcoin/utils/network.test.ts | 12 +- .../src/bitcoin/utils/network.ts | 18 +- .../src/bitcoin/utils/policy.test.ts | 16 + .../src/bitcoin/utils/policy.ts | 17 + .../src/bitcoin/utils/unit.test.ts | 56 --- .../src/bitcoin/utils/unit.ts | 38 -- .../src/bitcoin/wallet/account.ts | 31 +- .../src/bitcoin/wallet/address.test.ts | 24 -- .../src/bitcoin/wallet/address.ts | 18 - .../src/bitcoin/wallet/amount.test.ts | 23 -- .../src/bitcoin/wallet/amount.ts | 26 -- .../src/bitcoin/wallet/coin-select.test.ts | 9 +- .../src/bitcoin/wallet/coin-select.ts | 8 +- .../src/bitcoin/wallet/deriver.test.ts | 106 +---- .../src/bitcoin/wallet/deriver.ts | 91 ++--- .../src/bitcoin/wallet/exceptions.ts | 2 +- .../src/bitcoin/wallet/factory.test.ts | 63 --- .../src/bitcoin/wallet/factory.ts | 17 - .../src/bitcoin/wallet/index.ts | 8 +- .../src/bitcoin/wallet/psbt.test.ts | 11 +- .../src/bitcoin/wallet/psbt.ts | 3 +- .../src/{types => bitcoin/wallet}/static.ts | 0 .../bitcoin/wallet/transaction-info.test.ts | 54 +-- .../src/bitcoin/wallet/transaction-info.ts | 122 +++--- .../bitcoin/wallet/transaction-input.test.ts | 9 +- .../src/bitcoin/wallet/transaction-input.ts | 24 +- .../bitcoin/wallet/transaction-output.test.ts | 18 + .../src/bitcoin/wallet/transaction-output.ts | 28 +- .../src/bitcoin/wallet/types.ts | 59 --- .../src/bitcoin/wallet/wallet.test.ts | 48 +-- .../src/bitcoin/wallet/wallet.ts | 74 ++-- .../bitcoin-wallet-snap/src/chain.ts | 21 +- .../bitcoin-wallet-snap/src/config.ts | 49 +++ .../bitcoin-wallet-snap/src/config/config.ts | 103 ----- .../bitcoin-wallet-snap/src/config/index.ts | 2 - .../bitcoin-wallet-snap/src/constants.ts | 9 + .../bitcoin-wallet-snap/src/factory.test.ts | 17 +- .../bitcoin-wallet-snap/src/factory.ts | 62 +-- .../bitcoin-wallet-snap/src/index.test.ts | 72 ++-- .../bitcoin-wallet-snap/src/index.ts | 37 +- .../src/{keyring => }/keyring.test.ts | 369 +++++++++--------- .../src/{keyring => }/keyring.ts | 181 +++++---- .../src/keyring/exceptions.ts | 5 - .../bitcoin-wallet-snap/src/keyring/index.ts | 4 - .../bitcoin-wallet-snap/src/keyring/types.ts | 37 -- .../src/libs/exception/exceptions.ts | 23 -- .../src/libs/exception/index.ts | 1 - .../bitcoin-wallet-snap/src/libs/rpc/base.ts | 86 ---- .../src/libs/rpc/exceptions.ts | 4 - .../bitcoin-wallet-snap/src/libs/rpc/index.ts | 3 - .../bitcoin-wallet-snap/src/libs/rpc/types.ts | 56 --- .../src/libs/snap/__mocks__/helpers.ts | 25 -- .../src/libs/snap/exceptions.ts | 3 - .../src/libs/snap/helpers.test.ts | 123 ------ .../src/libs/snap/helpers.ts | 68 ---- .../src/libs/snap/index.ts | 4 - .../src/libs/snap/lock.test.ts | 24 -- .../bitcoin-wallet-snap/src/libs/snap/lock.ts | 12 - .../src/{config => }/permissions.ts | 12 +- .../src/rpcs/create-account.ts | 59 ++- .../src/rpcs/get-balances.test.ts | 324 +++++++-------- .../src/rpcs/get-balances.ts | 169 ++++---- .../src/rpcs/get-transaction-status.test.ts | 97 +++-- .../src/rpcs/get-transaction-status.ts | 92 ++--- .../src/rpcs/helpers.test.ts | 34 -- .../bitcoin-wallet-snap/src/rpcs/helpers.ts | 30 -- .../bitcoin-wallet-snap/src/rpcs/index.ts | 3 +- .../src/rpcs/keyring-rpc.ts | 30 -- .../src/rpcs/sendmany.test.ts | 290 +++++++------- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 366 ++++++++--------- .../state.test.ts => stateManagement.test.ts} | 40 +- .../{keyring/state.ts => stateManagement.ts} | 38 +- .../logger => utils}/__mocks__/logger.ts | 0 .../src/utils/__mocks__/snap.ts | 30 ++ .../bitcoin-wallet-snap/src/utils/error.ts | 24 ++ .../src/utils/explorer.test.ts | 22 ++ .../bitcoin-wallet-snap/src/utils/explorer.ts | 29 ++ .../bitcoin-wallet-snap/src/utils/index.ts | 7 + .../src/utils/lock.test.ts | 21 + .../bitcoin-wallet-snap/src/utils/lock.ts | 16 + .../src/{libs/logger => utils}/logger.test.ts | 0 .../src/{libs/logger => utils}/logger.ts | 0 .../bitcoin-wallet-snap/src/utils/rpc.ts | 35 ++ .../snap-state.test.ts} | 31 +- .../snap/state.ts => utils/snap-state.ts} | 38 +- .../src/utils/snap.test.ts | 105 +++++ .../bitcoin-wallet-snap/src/utils/snap.ts | 82 ++++ .../src/utils/string.test.ts | 8 + .../bitcoin-wallet-snap/src/utils/string.ts | 10 + .../src/utils/superstruct.test.ts | 10 +- .../src/utils/superstruct.ts | 4 +- .../src/utils/unit.test.ts | 53 +++ .../bitcoin-wallet-snap/src/utils/unit.ts | 46 +++ .../bitcoin-wallet-snap/src/wallet.ts | 101 ++--- .../test/fixtures/blockstream.json | 220 ----------- .../bitcoin-wallet-snap/test/utils.ts | 135 +------ 120 files changed, 2158 insertions(+), 3871 deletions(-) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{data-client => chain}/clients/blockchair.test.ts (99%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{data-client => chain}/clients/blockchair.ts (89%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{data-client/types.ts => chain/data-client.ts} (50%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/types.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/config/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/config/types.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/exceptions.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.ts rename merged-packages/bitcoin-wallet-snap/src/{types => bitcoin/wallet}/static.ts (100%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/config.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/config/config.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/config/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/constants.ts rename merged-packages/bitcoin-wallet-snap/src/{keyring => }/keyring.test.ts (69%) rename merged-packages/bitcoin-wallet-snap/src/{keyring => }/keyring.ts (60%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/keyring/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/keyring/types.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/exception/exceptions.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/exception/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/rpc/exceptions.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/rpc/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/rpc/types.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/__mocks__/helpers.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/exceptions.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.ts rename merged-packages/bitcoin-wallet-snap/src/{config => }/permissions.ts (84%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts rename merged-packages/bitcoin-wallet-snap/src/{keyring/state.test.ts => stateManagement.test.ts} (90%) rename merged-packages/bitcoin-wallet-snap/src/{keyring/state.ts => stateManagement.ts} (79%) rename merged-packages/bitcoin-wallet-snap/src/{libs/logger => utils}/__mocks__/logger.ts (100%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/explorer.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/explorer.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/lock.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/lock.ts rename merged-packages/bitcoin-wallet-snap/src/{libs/logger => utils}/logger.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{libs/logger => utils}/logger.ts (100%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/rpc.ts rename merged-packages/bitcoin-wallet-snap/src/{libs/snap/state.test.ts => utils/snap-state.test.ts} (95%) rename merged-packages/bitcoin-wallet-snap/src/{libs/snap/state.ts => utils/snap-state.ts} (82%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/snap.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/unit.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 48d4fdfd..1e492fd6 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -34,6 +34,7 @@ "@metamask/keyring-api": "^6.3.1", "@metamask/snaps-sdk": "^4.0.0", "async-mutex": "^0.3.2", + "big.js": "^6.2.1", "bip32": "^4.0.0", "bitcoinjs-lib": "^6.1.5", "buffer": "^6.0.3", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index b3a96b45..d68d25f5 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "gycY0HZ56I1PRIdcUPm4dYlATHJxoWWivbMHJddftK0=", + "shasum": "MO1uECXQxFuVEjZuXbUov3P6Sx1Gklzx+8cqYxSYj3U=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -18,7 +18,6 @@ } }, "initialConnections": { - "https://metamask.github.io": {}, "http://localhost:8000": {}, "https://portfolio.metamask.io": {}, "https://portfolio-builds.metafi-dev.codefi.network": {}, @@ -31,7 +30,6 @@ }, "endowment:keyring": { "allowedOrigins": [ - "https://metamask.github.io", "http://localhost:8000", "https://portfolio.metamask.io", "https://portfolio-builds.metafi-dev.codefi.network", diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts similarity index 99% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts index c335d064..7a1e501d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts @@ -12,7 +12,7 @@ import { FeeRatio, TransactionStatus } from '../../../chain'; import { DataClientError } from '../exceptions'; import { BlockChairClient } from './blockchair'; -jest.mock('../../../libs/logger/logger'); +jest.mock('../../../utils/logger'); describe('BlockChairClient', () => { const createMockFetch = () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts similarity index 89% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts index df958fac..f7534517 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts @@ -1,17 +1,16 @@ import type { Json } from '@metamask/snaps-sdk'; import { type Network, networks } from 'bitcoinjs-lib'; -import type { TransactionStatusData } from '../../../chain'; -import { type Balances, FeeRatio, TransactionStatus } from '../../../chain'; -import { logger } from '../../../libs/logger/logger'; +import type { TransactionStatusData, Utxo } from '../../../chain'; +import { FeeRatio, TransactionStatus } from '../../../chain'; import { compactError } from '../../../utils'; -import type { Utxo } from '../../wallet'; -import { DataClientError } from '../exceptions'; +import { logger } from '../../../utils/logger'; import type { + GetBalancesResp, GetFeeRatesResp, - IReadDataClient, - IWriteDataClient, -} from '../types'; + IDataClient, +} from '../data-client'; +import { DataClientError } from '../exceptions'; export type BlockChairClientOptions = { network: Network; @@ -24,7 +23,7 @@ export type LargestTransaction = { value_usd: number; }; -export type GetBalanceResponse = { +export type GetBalancesResponse = { data: { [address: string]: number; }; @@ -204,7 +203,7 @@ export type PostTransactionResponse = { }; /* eslint-disable */ -export class BlockChairClient implements IReadDataClient, IWriteDataClient { +export class BlockChairClient implements IDataClient { protected readonly _options: BlockChairClientOptions; constructor(options: BlockChairClientOptions) { @@ -218,7 +217,7 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { case networks.testnet: return 'https://api.blockchair.com/bitcoin/testnet'; default: - throw new DataClientError('Invalid network'); + throw new Error('Invalid network'); } } @@ -237,7 +236,7 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { }); if (!response.ok) { - throw new DataClientError( + throw new Error( `Failed to fetch data from blockchair: ${response.statusText}`, ); } @@ -255,13 +254,13 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { if (response.status == 400) { const res = await response.json(); - throw new DataClientError( + throw new Error( `Failed to post data from blockchair: ${res.context.error}`, ); } if (!response.ok) { - throw new DataClientError( + throw new Error( `Failed to post data from blockchair: ${response.statusText}`, ); } @@ -283,11 +282,14 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { ); return response; } catch (error) { + logger.info( + `[BlockChairClient.getTxDashboardData] error: ${error.message}`, + ); throw compactError(error, DataClientError); } } - async getBalances(addresses: string[]): Promise { + async getBalances(addresses: string[]): Promise { try { logger.info( `[BlockChairClient.getBalance] start: { addresses : ${JSON.stringify( @@ -295,7 +297,7 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { )} }`, ); - const response = await this.get( + const response = await this.get( `/addresses/balances?addresses=${addresses.join(',')}`, ); @@ -303,11 +305,12 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { `[BlockChairClient.getBalance] response: ${JSON.stringify(response)}`, ); - return addresses.reduce((data: Balances, address: string) => { + return addresses.reduce((data: GetBalancesResp, address: string) => { data[address] = response.data[address] ?? 0; return data; }, {}); } catch (error) { + logger.info(`[BlockChairClient.getBalance] error: ${error.message}`); throw compactError(error, DataClientError); } } @@ -336,7 +339,7 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { ); if (!Object.prototype.hasOwnProperty.call(response.data, address)) { - throw new DataClientError(`Data not avaiable`); + throw new Error(`Data not avaiable`); } response.data[address].utxo.forEach((utxo) => { @@ -357,6 +360,7 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { return data; } catch (error) { + logger.info(`[BlockChairClient.getUtxos] error: ${error.message}`); throw compactError(error, DataClientError); } } @@ -372,6 +376,7 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { [FeeRatio.Fast]: response.data.suggested_transaction_fee_per_byte_sat, }; } catch (error) { + logger.info(`[BlockChairClient.getFeeRates] error: ${error.message}`); throw compactError(error, DataClientError); } } @@ -393,6 +398,7 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { return response.data.transaction_hash; } catch (error) { + logger.info(`[BlockChairClient.sendTransaction] error: ${error.message}`); throw compactError(error, DataClientError); } } @@ -418,6 +424,9 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { status: status, }; } catch (error) { + logger.info( + `[BlockChairClient.getTransactionStatus] error: ${error.message}`, + ); throw compactError(error, DataClientError); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts similarity index 50% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/types.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts index f7c18ba5..a510b1a6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts @@ -1,19 +1,15 @@ -import type { Balances, FeeRatio, TransactionStatusData } from '../../chain'; -import type { Utxo } from '../wallet'; +import type { FeeRatio, TransactionStatusData, Utxo } from '../../chain'; + +export type GetBalancesResp = Record; export type GetFeeRatesResp = { [key in FeeRatio]?: number; }; -export type IReadDataClient = { - getBalances(address: string[]): Promise; +export type IDataClient = { + getBalances(address: string[]): Promise; getUtxos(address: string, includeUnconfirmed?: boolean): Promise; getFeeRates(): Promise; getTransactionStatus(txHash: string): Promise; -}; - -export type IWriteDataClient = { sendTransaction(tx: string): Promise; }; - -export type IDataClient = IReadDataClient & IWriteDataClient; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts index 8321d50a..54affb11 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts @@ -1,3 +1,5 @@ -import { CustomError } from '../../libs/exception'; +import { CustomError } from '../../utils'; + +export class DataClientError extends CustomError {} export class BtcOnChainServiceError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts index 5ce37176..cb209e05 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts @@ -1,3 +1,2 @@ export * from './exceptions'; export * from './service'; -export * from './types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts index 8d4745c0..ad3dc7bf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts @@ -7,21 +7,22 @@ import { generateBlockChairGetUtxosResp, } from '../../../test/utils'; import { FeeRatio, TransactionStatus } from '../../chain'; -import { BtcAsset } from '../constants'; -import type { IReadDataClient, IWriteDataClient } from '../data-client'; -import { BtcAmount } from '../wallet'; +import { Caip2Asset } from '../../constants'; +import type { IDataClient } from './data-client'; import { BtcOnChainServiceError } from './exceptions'; import { BtcOnChainService } from './service'; -jest.mock('../../libs/logger/logger'); +jest.mock('../../utils/logger'); describe('BtcOnChainService', () => { - const createMockReadDataClient = () => { + const createMockDataClient = () => { const getBalanceSpy = jest.fn(); const getUtxosSpy = jest.fn(); const getFeeRatesSpy = jest.fn(); const getTransactionStatusSpy = jest.fn(); - class MockReadDataClient implements IReadDataClient { + const sendTransactionSpy = jest.fn(); + + class MockReadDataClient implements IDataClient { getBalances = getBalanceSpy; getUtxos = getUtxosSpy; @@ -29,6 +30,8 @@ describe('BtcOnChainService', () => { getFeeRates = getFeeRatesSpy; getTransactionStatus = getTransactionStatusSpy; + + sendTransaction = sendTransactionSpy; } return { @@ -37,29 +40,16 @@ describe('BtcOnChainService', () => { getUtxosSpy, getFeeRatesSpy, getTransactionStatusSpy, - }; - }; - - const createMockWriteDataClient = () => { - const sendTransactionSpy = jest.fn(); - - class MockWriteDataClient implements IWriteDataClient { - sendTransaction = sendTransactionSpy; - } - return { - instance: new MockWriteDataClient(), sendTransactionSpy, }; }; const createMockBtcService = ( - readDataClient?: IReadDataClient, - writeDataClient?: IWriteDataClient, + dataClient?: IDataClient, network: Network = networks.testnet, ) => { const instance = new BtcOnChainService( - readDataClient ?? createMockReadDataClient().instance, - writeDataClient ?? createMockWriteDataClient().instance, + dataClient ?? createMockDataClient().instance, { network, }, @@ -72,7 +62,7 @@ describe('BtcOnChainService', () => { describe('getBalance', () => { it('calls getBalances with readClient', async () => { - const { instance, getBalanceSpy } = createMockReadDataClient(); + const { instance, getBalanceSpy } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); @@ -83,60 +73,59 @@ describe('BtcOnChainService', () => { }, {}), ); - const result = await txService.getBalances(addresses, [BtcAsset.TBtc]); + const result = await txService.getBalances(addresses, [Caip2Asset.TBtc]); expect(getBalanceSpy).toHaveBeenCalledWith(addresses); Object.values(result.balances).forEach((assetBalances) => { expect(assetBalances).toStrictEqual({ - [BtcAsset.TBtc]: { - amount: expect.any(BtcAmount), + [Caip2Asset.TBtc]: { + amount: BigInt(100), }, }); }); }); it('throws `Only one asset is supported` error if the given asset more than 1', async () => { - const { instance } = createMockReadDataClient(); + const { instance } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); await expect( - txService.getBalances(addresses, [BtcAsset.TBtc, BtcAsset.Btc]), + txService.getBalances(addresses, [Caip2Asset.TBtc, Caip2Asset.Btc]), ).rejects.toThrow('Only one asset is supported'); }); it('throws `Invalid asset` error if the BTC asset is given and current network is testnet network', async () => { - const { instance } = createMockReadDataClient(); + const { instance } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); await expect( - txService.getBalances(addresses, [BtcAsset.Btc]), + txService.getBalances(addresses, [Caip2Asset.Btc]), ).rejects.toThrow('Invalid asset'); }); it('throws `Invalid asset` error if the TBTC asset is given and current network is bitcoin network', async () => { - const { instance } = createMockReadDataClient(); + const { instance } = createMockDataClient(); const { instance: txService } = createMockBtcService( instance, - undefined, networks.bitcoin, ); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); await expect( - txService.getBalances(addresses, [BtcAsset.TBtc]), + txService.getBalances(addresses, [Caip2Asset.TBtc]), ).rejects.toThrow('Invalid asset'); }); }); describe('getUtxos', () => { it('calls getUtxos with readClient', async () => { - const { instance, getUtxosSpy } = createMockReadDataClient(); + const { instance, getUtxosSpy } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const sender = accounts[0].address; @@ -168,7 +157,7 @@ describe('BtcOnChainService', () => { }); it('throws error if readClient fail', async () => { - const { instance, getUtxosSpy } = createMockReadDataClient(); + const { instance, getUtxosSpy } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const sender = accounts[0].address; @@ -190,11 +179,11 @@ describe('BtcOnChainService', () => { describe('getFeeRates', () => { it('return getFeeRates result', async () => { - const { instance, getFeeRatesSpy } = createMockReadDataClient(); + const { instance, getFeeRatesSpy } = createMockDataClient(); const { instance: txMgr } = createMockBtcService(instance); getFeeRatesSpy.mockResolvedValue({ - [FeeRatio.Fast]: 1.1, - [FeeRatio.Medium]: 1.2, + [FeeRatio.Fast]: 1, + [FeeRatio.Medium]: 2, }); const result = await txMgr.getFeeRates(); @@ -204,20 +193,18 @@ describe('BtcOnChainService', () => { fees: [ { type: FeeRatio.Fast, - rate: expect.any(BtcAmount), + rate: BigInt(1), }, { type: FeeRatio.Medium, - rate: expect.any(BtcAmount), + rate: BigInt(2), }, ], }); - expect(result.fees[0].rate.value).toBe(1.1); - expect(result.fees[1].rate.value).toBe(1.2); }); it('throws BtcOnChainServiceError error if an error catched', async () => { - const { instance, getFeeRatesSpy } = createMockReadDataClient(); + const { instance, getFeeRatesSpy } = createMockDataClient(); const { instance: txMgr } = createMockBtcService(instance); getFeeRatesSpy.mockRejectedValue(new Error('error')); @@ -231,8 +218,8 @@ describe('BtcOnChainService', () => { '02000000000101ec81faa8b57add4c8fb3958dd8f04667f5cd829a7b94199f4400be9e52cda0760000000000ffffffff015802000000000000160014f80b562cbcbbfc97727043484c06cc5579963e8402473044022011ec3f7ea7a7cac7cb891a1ea498d94ca3cd082339b9b2620ba5421ca7cbdf3d022062f34411d6aa5335c2bd7ff4c940adb962e9509133b86a2d97996552fd811f2c012102ceea82614fdb14871ef881498c55c5dbdc24b4633d29b42040dd18b4285540f500000000'; it('calls sendTransaction with writeClient', async () => { - const { instance, sendTransactionSpy } = createMockWriteDataClient(); - const { instance: txService } = createMockBtcService(undefined, instance); + const { instance, sendTransactionSpy } = createMockDataClient(); + const { instance: txService } = createMockBtcService(instance); const resp = generateBlockChairBroadcastTransactionResp(); sendTransactionSpy.mockResolvedValue(resp.data.transaction_hash); @@ -246,8 +233,8 @@ describe('BtcOnChainService', () => { }); it('throws BtcOnChainServiceErrorr if write client execute fail', async () => { - const { instance, sendTransactionSpy } = createMockWriteDataClient(); - const { instance: txService } = createMockBtcService(undefined, instance); + const { instance, sendTransactionSpy } = createMockDataClient(); + const { instance: txService } = createMockBtcService(instance); sendTransactionSpy.mockRejectedValue(new Error('error')); await expect( @@ -261,7 +248,7 @@ describe('BtcOnChainService', () => { '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; it('return getTransactionStatus result', async () => { - const { instance, getTransactionStatusSpy } = createMockReadDataClient(); + const { instance, getTransactionStatusSpy } = createMockDataClient(); const { instance: txMgr } = createMockBtcService(instance); getTransactionStatusSpy.mockResolvedValue({ status: TransactionStatus.Confirmed, @@ -276,7 +263,7 @@ describe('BtcOnChainService', () => { }); it('throws BtcOnChainServiceError error if an error catched', async () => { - const { instance, getTransactionStatusSpy } = createMockReadDataClient(); + const { instance, getTransactionStatusSpy } = createMockDataClient(); const { instance: txMgr } = createMockBtcService(instance); getTransactionStatusSpy.mockRejectedValue(new Error('error')); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index 08b908a8..d22aa65e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -4,34 +4,28 @@ import { networks } from 'bitcoinjs-lib'; import type { FeeRatio, IOnChainService, - Balances, AssetBalances, TransactionIntent, Fees, TransactionData, CommitedTransaction, } from '../../chain'; +import { Caip2Asset } from '../../constants'; import { compactError } from '../../utils'; -import { BtcAsset } from '../constants'; -import type { IWriteDataClient, IReadDataClient } from '../data-client'; -import { BtcAmount } from '../wallet'; +import type { IDataClient } from './data-client'; import { BtcOnChainServiceError } from './exceptions'; -import type { BtcOnChainServiceOptions } from './types'; -export class BtcOnChainService implements IOnChainService { - protected readonly _readClient: IReadDataClient; +export type BtcOnChainServiceOptions = { + network: Network; +}; - protected readonly _writeClient: IWriteDataClient; +export class BtcOnChainService implements IOnChainService { + protected readonly _dataClient: IDataClient; protected readonly _options: BtcOnChainServiceOptions; - constructor( - readClient: IReadDataClient, - writeClient: IWriteDataClient, - options: BtcOnChainServiceOptions, - ) { - this._readClient = readClient; - this._writeClient = writeClient; + constructor(dataClient: IDataClient, options: BtcOnChainServiceOptions) { + this._dataClient = dataClient; this._options = options; } @@ -48,23 +42,23 @@ export class BtcOnChainService implements IOnChainService { throw new BtcOnChainServiceError('Only one asset is supported'); } - const allowedAssets = new Set(Object.values(BtcAsset)); + const allowedAssets = new Set(Object.values(Caip2Asset)); if ( !allowedAssets.has(assets[0]) || - (this.network === networks.testnet && assets[0] !== BtcAsset.TBtc) || - (this.network === networks.bitcoin && assets[0] !== BtcAsset.Btc) + (this.network === networks.testnet && assets[0] !== Caip2Asset.TBtc) || + (this.network === networks.bitcoin && assets[0] !== Caip2Asset.Btc) ) { throw new BtcOnChainServiceError('Invalid asset'); } - const balance: Balances = await this._readClient.getBalances(addresses); + const balance = await this._dataClient.getBalances(addresses); return addresses.reduce( (acc: AssetBalances, address: string) => { acc.balances[address] = { [assets[0]]: { - amount: new BtcAmount(balance[address]), + amount: BigInt(balance[address]), }, }; return acc; @@ -78,24 +72,24 @@ export class BtcOnChainService implements IOnChainService { async getFeeRates(): Promise { try { - const result = await this._readClient.getFeeRates(); + const result = await this._dataClient.getFeeRates(); return { fees: Object.entries(result).map( ([key, value]: [key: FeeRatio, value: number]) => ({ type: key, - rate: new BtcAmount(value), + rate: BigInt(value), }), ), }; } catch (error) { - throw new BtcOnChainServiceError(error); + throw compactError(error, BtcOnChainServiceError); } } async getTransactionStatus(txHash: string) { try { - return await this._readClient.getTransactionStatus(txHash); + return await this._dataClient.getTransactionStatus(txHash); } catch (error) { throw new BtcOnChainServiceError(error); } @@ -107,7 +101,7 @@ export class BtcOnChainService implements IOnChainService { transactionIntent?: TransactionIntent, ): Promise { try { - const data = await this._readClient.getUtxos(address); + const data = await this._dataClient.getUtxos(address); return { data: { utxos: data, @@ -122,7 +116,7 @@ export class BtcOnChainService implements IOnChainService { signedTransaction: string, ): Promise { try { - const transactionId = await this._writeClient.sendTransaction( + const transactionId = await this._dataClient.sendTransaction( signedTransaction, ); return { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/types.ts deleted file mode 100644 index 60ce9afb..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Network } from 'bitcoinjs-lib'; - -export type BtcOnChainServiceOptions = { - network: Network; -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/index.ts deleted file mode 100644 index fcb073fe..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/types.ts deleted file mode 100644 index a732c410..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Json } from '@metamask/snaps-sdk'; - -import type { DataClient } from '../constants'; - -export type BtcOnChainServiceConfig = { - dataClient: { - read: { - type: DataClient; - options?: Record; - }; - write: { - type: DataClient; - options?: Record; - }; - }; -}; - -export type BtcWalletConfig = { - enableMultiAccounts: boolean; - defaultAccountIndex: number; - defaultAccountType: string; - deriver: string; -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts index 58e194e3..0aa18b3d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts @@ -4,27 +4,6 @@ export enum ScriptType { P2wpkh = 'p2wpkh', } -export enum Network { - Mainnet = 'bip122:000000000019d6689c085ae165831e93', - Testnet = 'bip122:000000000933ea01ad0ee984209779ba', -} - -export enum DataClient { - BlockStream = 'BlockStream', - BlockChair = 'BlockChair', -} - -export enum BtcAsset { - Btc = 'bip122:000000000019d6689c085ae165831e93/slip44:0', - TBtc = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0', -} - -export enum BtcUnit { - Btc = 'BTC', - TBtc = 'tBTC', - Sat = 'satoshi', -} - // reference https://help.magiceden.io/en/articles/8665399-navigating-bitcoin-dust-understanding-limits-and-safeguarding-your-transactions-on-magic-eden // "Dust" is defined in terms of dustRelayFee, // which has units satoshis-per-kilobyte. @@ -48,4 +27,11 @@ export const DustLimit = { /* eslint-disable */ }; +// Maximum weight in bytes for a standard transaction export const MaxStandardTxWeight = 400000; + +// Maximum amount of satoshis +export const maxSatoshi = 21 * 1e14; + +// Minimum amount of satoshis +export const minSatoshi = 1; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts deleted file mode 100644 index ae127a5b..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { networks } from 'bitcoinjs-lib'; - -import { - generateAccounts, - generateBlockStreamAccountStats, - generateBlockStreamGetUtxosResp, - generateBlockStreamEstFeeResp, - generateBlockStreamTransactionStatusResp, -} from '../../../../test/utils'; -import { FeeRatio, TransactionStatus } from '../../../chain'; -import * as asyncUtils from '../../../utils/async'; -import { DataClientError } from '../exceptions'; -import { BlockStreamClient } from './blockstream'; - -jest.mock('../../../libs/logger/logger'); - -describe('BlockStreamClient', () => { - const createMockFetch = () => { - // 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, - }; - }; - - 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('getBalances', () => { - it('returns balances', async () => { - const { fetchSpy } = createMockFetch(); - const accounts = generateAccounts(60); - const addresses = accounts.map((account) => account.address); - const mockResponse = generateBlockStreamAccountStats(addresses); - const expectedResult = {}; - mockResponse.forEach((data) => { - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(data), - }); - expectedResult[data.address] = - data.chain_stats.funded_txo_sum - data.chain_stats.spent_txo_sum; - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getBalances(addresses); - - expect(result).toStrictEqual(expectedResult); - }); - - it('assigns balance to 0 if it failed to fetch', async () => { - const { fetchSpy } = createMockFetch(); - fetchSpy.mockResolvedValue({ - ok: false, - statusText: '503', - }); - const accounts = generateAccounts(1); - const addresses = accounts.map((account) => account.address); - - const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getBalances(addresses); - - expect(result).toStrictEqual({ [addresses[0]]: 0 }); - }); - - it('throws DataClientError error if an non DataClientError catched', async () => { - const asyncHelperSpy = jest.spyOn(asyncUtils, 'processBatch'); - asyncHelperSpy.mockRejectedValue(new Error('error')); - const accounts = generateAccounts(1); - const addresses = accounts.map((account) => account.address); - - const instance = new BlockStreamClient({ network: networks.testnet }); - - await expect(instance.getBalances(addresses)).rejects.toThrow( - DataClientError, - ); - }); - }); - - describe('getFeeRates', () => { - it('returns fee rate', async () => { - const { fetchSpy } = createMockFetch(); - const mockResponse = generateBlockStreamEstFeeResp(); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getFeeRates(); - - expect(result).toStrictEqual({ - [FeeRatio.Fast]: Math.round(mockResponse['1']), - [FeeRatio.Medium]: Math.round(mockResponse['25']), - [FeeRatio.Slow]: Math.round(mockResponse['144']), - }); - }); - - it('throws DataClientError error if an non DataClientError catched', async () => { - const { fetchSpy } = createMockFetch(); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockRejectedValue(new Error('error')), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - - await expect(instance.getFeeRates()).rejects.toThrow(DataClientError); - }); - - it('throws DataClientError error if an DataClientError catched', async () => { - const { fetchSpy } = createMockFetch(); - - fetchSpy.mockResolvedValueOnce({ - ok: false, - json: jest.fn().mockResolvedValue(null), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - - await expect(instance.getFeeRates()).rejects.toThrow(DataClientError); - }); - }); - - describe('getUtxos', () => { - it('returns utxos', async () => { - const { fetchSpy } = createMockFetch(); - const accounts = generateAccounts(1); - const { address } = accounts[0]; - const mockResponse = generateBlockStreamGetUtxosResp(100); - const expectedResult = mockResponse.map((utxo) => ({ - block: utxo.status.block_height, - txHash: utxo.txid, - index: utxo.vout, - value: utxo.value, - })); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getUtxos(address); - - expect(result).toStrictEqual(expectedResult); - }); - - it('includes unconfirmed if includeUnconfirmed is true', async () => { - const { fetchSpy } = createMockFetch(); - const accounts = generateAccounts(1); - const { address } = accounts[0]; - const mockConfirmedResponse = generateBlockStreamGetUtxosResp(100); - const mockUnconfirmedResponse = generateBlockStreamGetUtxosResp( - 100, - false, - ); - const combinedResponse = mockConfirmedResponse.concat( - mockUnconfirmedResponse, - ); - const expectedResult = combinedResponse.map((utxo) => ({ - block: utxo.status.block_height, - txHash: utxo.txid, - index: utxo.vout, - value: utxo.value, - })); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(combinedResponse), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getUtxos(address, true); - - expect(result).toStrictEqual(expectedResult); - }); - - it('filters unconfirmed utxos if includeUnconfirmed is true', async () => { - const { fetchSpy } = createMockFetch(); - const accounts = generateAccounts(1); - const { address } = accounts[0]; - const mockConfirmedResponse = generateBlockStreamGetUtxosResp(100); - const mockUnconfirmedResponse = generateBlockStreamGetUtxosResp( - 100, - false, - ); - const combinedResponse = mockConfirmedResponse.concat( - mockUnconfirmedResponse, - ); - const expectedResult = mockConfirmedResponse.map((utxo) => ({ - block: utxo.status.block_height, - txHash: utxo.txid, - index: utxo.vout, - value: utxo.value, - })); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(combinedResponse), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getUtxos(address, false); - - expect(result).toStrictEqual(expectedResult); - }); - - it('throws DataClientError error if an non DataClientError catched', async () => { - const { fetchSpy } = createMockFetch(); - fetchSpy.mockRejectedValue(new Error('error')); - const accounts = generateAccounts(1); - const { address } = accounts[0]; - - const instance = new BlockStreamClient({ network: networks.testnet }); - - await expect(instance.getUtxos(address)).rejects.toThrow(DataClientError); - }); - }); - - describe('getTransactionStatus', () => { - const txHash = - '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; - - it('returns correct result for confirmed transaction', async () => { - const { fetchSpy } = createMockFetch(); - const mockTxStatusResponse = generateBlockStreamTransactionStatusResp( - 200000, - true, - ); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockTxStatusResponse), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getTransactionStatus(txHash); - - expect(result).toStrictEqual({ - status: TransactionStatus.Confirmed, - }); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - - it('returns correct result for pending transaction', async () => { - const { fetchSpy } = createMockFetch(); - const mockTxStatusResponse = generateBlockStreamTransactionStatusResp( - 200000, - false, - ); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockTxStatusResponse), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - const result = await instance.getTransactionStatus(txHash); - - expect(result).toStrictEqual({ - status: TransactionStatus.Pending, - }); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - - it('throws DataClientError error if an DataClientError catched', async () => { - const { fetchSpy } = createMockFetch(); - - fetchSpy.mockResolvedValueOnce({ - ok: false, - json: jest.fn().mockResolvedValue(null), - }); - - const instance = new BlockStreamClient({ network: networks.testnet }); - - await expect(instance.getTransactionStatus(txHash)).rejects.toThrow( - DataClientError, - ); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts deleted file mode 100644 index 4b8b155b..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { type Network, networks } from 'bitcoinjs-lib'; - -import type { TransactionStatusData } from '../../../chain'; -import { type Balances, FeeRatio, TransactionStatus } from '../../../chain'; -import { logger } from '../../../libs/logger/logger'; -import { compactError, processBatch } from '../../../utils'; -import type { Utxo } from '../../wallet'; -import { DataClientError } from '../exceptions'; -import type { GetFeeRatesResp, IReadDataClient } from '../types'; - -export type BlockStreamClientOptions = { - network: Network; -}; - -/* eslint-disable */ -export type GetFeeEstimateResponse = Record; - -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; - }; -}; - -export type GetTransactionStatusResponse = { - confirmed: boolean; - block_height: number; - block_hash: string; - block_time: number; -}; - -export type GetUtxosResponse = { - txid: string; - vout: number; - status: { - confirmed: boolean; - block_height: number; - block_hash: string; - block_time: number; - }; - value: number; -}[]; - -export type GetBlocksResponse = { - id: string; - height: number; - version: number; - timestamp: number; - tx_count: number; - size: number; - weight: number; - merkle_root: string; - previousblockhash: string; - mediantime: number; - nonce: number; - bits: number; - difficulty: number; -}[]; - -/* eslint-enable */ - -export class BlockStreamClient implements IReadDataClient { - protected readonly _options: BlockStreamClientOptions; - - protected readonly _feeRateRatioMap: Record; - - constructor(options: BlockStreamClientOptions) { - this._options = options; - this._feeRateRatioMap = { - [FeeRatio.Fast]: '1', - [FeeRatio.Medium]: '144', - [FeeRatio.Slow]: '144', - }; - } - - 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'); - } - } - - 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 unknown as Resp; - } - - async getBalances(addresses: string[]): Promise { - try { - const responses: Balances = {}; - - await processBatch(addresses, async (address: string) => { - logger.info(`[BlockStreamClient.getBalance] address: ${address}`); - let balance = 0; - try { - const response = await this.get( - `/address/${address}`, - ); - logger.info( - `[BlockStreamClient.getBalance] response: ${JSON.stringify( - response, - )}`, - ); - balance = - response.chain_stats.funded_txo_sum - - response.chain_stats.spent_txo_sum; - } catch (error) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.info(`[BlockStreamClient.getBalance] error: ${error.message}`); - } finally { - responses[address] = balance; - } - }); - - return responses; - } catch (error) { - throw compactError(error, DataClientError); - } - } - - async getUtxos( - address: string, - includeUnconfirmed?: boolean, - ): Promise { - try { - const response = await this.get( - `/address/${address}/utxo`, - ); - - logger.info( - `[BlockStreamClient.getUtxos] response: ${JSON.stringify(response)}`, - ); - - const data: Utxo[] = []; - - for (const utxo of response) { - if (!includeUnconfirmed && !utxo.status.confirmed) { - continue; - } - data.push({ - block: utxo.status.block_height, - txHash: utxo.txid, - index: utxo.vout, - value: utxo.value, - }); - } - - return data; - } catch (error) { - throw compactError(error, DataClientError); - } - } - - async getFeeRates(): Promise { - try { - logger.info(`[BlockStreamClient.getFeeRates] start:`); - const response = await this.get(`/fee-estimates`); - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.info( - `[BlockStreamClient.getFeeRates] response: ${JSON.stringify(response)}`, - ); - return { - [FeeRatio.Fast]: Math.round( - response[this._feeRateRatioMap[FeeRatio.Fast]], - ), - [FeeRatio.Medium]: Math.round( - response[this._feeRateRatioMap[FeeRatio.Medium]], - ), - [FeeRatio.Slow]: Math.round( - response[this._feeRateRatioMap[FeeRatio.Slow]], - ), - }; - } catch (error) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.info(`[BlockStreamClient.getFeeRates] error: ${error.message}`); - if (error instanceof DataClientError) { - throw error; - } - throw new DataClientError(error); - } - } - - async getTransactionStatus(txHash: string): Promise { - try { - const txStatusResp = await this.get( - `/tx/${txHash}/status`, - ); - - const status = txStatusResp.confirmed - ? TransactionStatus.Confirmed - : TransactionStatus.Pending; - - return { - status, - }; - } catch (error) { - throw compactError(error, DataClientError); - } - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/exceptions.ts deleted file mode 100644 index d46bde48..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/exceptions.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { CustomError } from '../../libs/exception'; - -export class DataClientError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.test.ts deleted file mode 100644 index 0c9aa8a1..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { networks } from 'bitcoinjs-lib'; - -import { DataClient } from '../constants'; -import { BlockChairClient } from './clients/blockchair'; -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 }, - write: { type: DataClient.BlockChair }, - }, - }, - networks.testnet, - ); - - expect(instance).toBeInstanceOf(BlockStreamClient); - }); - - it('creates BlockChairClient', () => { - const instance = DataClientFactory.createReadClient( - { - dataClient: { - read: { type: DataClient.BlockChair }, - write: { type: DataClient.BlockChair }, - }, - }, - networks.testnet, - ); - - expect(instance).toBeInstanceOf(BlockChairClient); - }); - - it('throws `Unsupported client type` if the given client is not support', () => { - expect(() => - DataClientFactory.createReadClient( - { - dataClient: { - read: { type: 'SomeClient' as unknown as DataClient }, - write: { type: DataClient.BlockChair }, - }, - }, - networks.testnet, - ), - ).toThrow('Unsupported client type: SomeClient'); - }); - }); - - describe('createWriteClient', () => { - it('creates BlockChairClient', () => { - const instance = DataClientFactory.createWriteClient( - { - dataClient: { - read: { type: DataClient.BlockChair }, - write: { type: DataClient.BlockChair }, - }, - }, - networks.testnet, - ); - - expect(instance).toBeInstanceOf(BlockChairClient); - }); - - it('throws `Unsupported client type` if the given client is not support', () => { - expect(() => - DataClientFactory.createWriteClient( - { - dataClient: { - read: { type: DataClient.BlockChair }, - write: { type: 'SomeClient' as unknown as DataClient }, - }, - }, - networks.testnet, - ), - ).toThrow('Unsupported client type: SomeClient'); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.ts deleted file mode 100644 index f2d1b90f..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { type Network } from 'bitcoinjs-lib'; - -import { type BtcOnChainServiceConfig } from '../config'; -import { DataClient } from '../constants'; -import { BlockChairClient } from './clients/blockchair'; -import { BlockStreamClient } from './clients/blockstream'; -import { DataClientError } from './exceptions'; -import type { IReadDataClient, IWriteDataClient } from './types'; - -export class DataClientFactory { - static createReadClient( - config: BtcOnChainServiceConfig, - network: Network, - ): IReadDataClient { - const { type, options } = config.dataClient.read; - switch (type) { - case DataClient.BlockStream: - return new BlockStreamClient({ network }); - case DataClient.BlockChair: - return new BlockChairClient({ - network, - apiKey: options?.apiKey?.toString(), - }); - default: - throw new DataClientError( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Unsupported client type: ${config.dataClient.read.type}`, - ); - } - } - - static createWriteClient( - config: BtcOnChainServiceConfig, - network: Network, - ): IWriteDataClient { - const { type, options } = config.dataClient.write; - switch (type) { - case DataClient.BlockChair: - return new BlockChairClient({ - network, - apiKey: options?.apiKey?.toString(), - }); - default: - throw new DataClientError( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Unsupported client type: ${config.dataClient.write.type}`, - ); - } - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/index.ts deleted file mode 100644 index 8decdbf9..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './exceptions'; -export * from './types'; -export * from './clients/blockstream'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts index d6d083e6..e12050c8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts @@ -12,10 +12,10 @@ describe('address', () => { expect(val).toBeInstanceOf(Buffer); }); - it('throws `Destnation address has no matching Script` error if the given address is invalid', () => { + it('throws `Destination address has no matching Script` error if the given address is invalid', () => { expect(() => getScriptForDestnation('bad-address', networks.testnet), - ).toThrow(`Destnation address has no matching Script`); + ).toThrow(`Destination address has no matching Script`); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts index e36946dd..c4736482 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts @@ -12,6 +12,6 @@ export function getScriptForDestnation(address: string, network: Network) { try { return addressUtils.toOutputScript(address, network); } catch (error) { - throw new Error('Destnation address has no matching Script'); + throw new Error('Destination address has no matching Script'); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.test.ts deleted file mode 100644 index a7487cab..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Chain, Config } from '../../config'; -import { Network } from '../constants'; -import { getExplorerUrl } from './explorer'; - -describe('getExplorerUrl', () => { - const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; - - it('returns a bitcoin testnet explorer url', () => { - const result = getExplorerUrl(address, Network.Testnet); - expect(result).toBe( - `${Config.explorer[Chain.Bitcoin][Network.Testnet]}/address/${address}`, - ); - }); - - it('returns a bitcoin mainnet explorer url', () => { - const result = getExplorerUrl(address, Network.Mainnet); - expect(result).toBe( - `${Config.explorer[Chain.Bitcoin][Network.Mainnet]}/address/${address}`, - ); - }); - - it('throws `Invalid network` error if the given network is not support', () => { - expect(() => getExplorerUrl(address, 'some invalid network')).toThrow( - 'Invalid network', - ); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts deleted file mode 100644 index ed7c152f..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Chain, Config } from '../../config'; -import { Network } from '../constants'; - -/** - * Gets the explorer URL for a given bitcoin address and CAIP-2 Chain ID. - * - * @param address - The bitcoin address to get the explorer URL for. - * @param network - The CAIP-2 Chain ID. - * @returns The explorer URL as a string. - * @throws An error if an invalid network is provided. - */ -export function getExplorerUrl(address: string, network: string): string { - switch (network) { - case Network.Mainnet: - return `${ - Config.explorer[Chain.Bitcoin][Network.Mainnet] - }/address/${address}`; - case Network.Testnet: - return `${ - Config.explorer[Chain.Bitcoin][Network.Testnet] - }/address/${address}`; - default: - throw new Error('Invalid network'); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts index fb79250f..a57139bb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts @@ -1,4 +1,4 @@ export * from './payment'; export * from './network'; -export * from './unit'; -export * from './explorer'; +export * from './policy'; +export * from './address'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts index 29e66ac3..279d4297 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts @@ -1,22 +1,22 @@ import { networks } from 'bitcoinjs-lib'; -import { Network } from '../constants'; +import { Caip2ChainId } from '../../constants'; import { getBtcNetwork, getCaip2ChainId } from './network'; describe('getBtcNetwork', () => { it('returns bitcoin testnet network', () => { - const result = getBtcNetwork(Network.Testnet); + const result = getBtcNetwork(Caip2ChainId.Testnet); expect(result).toStrictEqual(networks.testnet); }); it('returns bitcoin mainnet network', () => { - const result = getBtcNetwork(Network.Mainnet); + const result = getBtcNetwork(Caip2ChainId.Mainnet); expect(result).toStrictEqual(networks.bitcoin); }); it('throws `Invalid network` error if the given network is not support', () => { expect(() => - getBtcNetwork('someInvalidNetwork' as unknown as Network), + getBtcNetwork('someInvalidNetwork' as unknown as Caip2ChainId), ).toThrow('Invalid network'); }); }); @@ -24,12 +24,12 @@ describe('getBtcNetwork', () => { describe('getCaip2ChainId', () => { it('returns CAIP-2 testnet chain ID of bitcoin', () => { const result = getCaip2ChainId(networks.testnet); - expect(result).toStrictEqual(Network.Testnet); + expect(result).toStrictEqual(Caip2ChainId.Testnet); }); it('returns CAIP-2 mainnet chain ID of bitcoin', () => { const result = getCaip2ChainId(networks.bitcoin); - expect(result).toStrictEqual(Network.Mainnet); + expect(result).toStrictEqual(Caip2ChainId.Mainnet); }); it('throws `Invalid network` error if the given network is not support', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts index b6078c15..9900e71e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts @@ -1,20 +1,20 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; -import { Network as NetworkEnum } from '../constants'; +import { Caip2ChainId } from '../../constants'; /** * Gets a bitcoinjs-lib network instance based on a given CAIP-2 Chain ID. * - * @param network - The CAIP-2 Chain ID. + * @param caip2ChainId - The CAIP-2 Chain ID. * @returns The instance of bitcoinjs-lib network. * @throws An error if an invalid network is provided. */ -export function getBtcNetwork(network: string) { - switch (network) { - case NetworkEnum.Mainnet: +export function getBtcNetwork(caip2ChainId: string) { + switch (caip2ChainId) { + case Caip2ChainId.Mainnet: return networks.bitcoin; - case NetworkEnum.Testnet: + case Caip2ChainId.Testnet: return networks.testnet; default: throw new Error('Invalid network'); @@ -28,12 +28,12 @@ export function getBtcNetwork(network: string) { * @returns The CAIP-2 Chain ID. * @throws An error if an invalid network is provided. */ -export function getCaip2ChainId(network: Network): NetworkEnum { +export function getCaip2ChainId(network: Network): Caip2ChainId { switch (network) { case networks.bitcoin: - return NetworkEnum.Mainnet; + return Caip2ChainId.Mainnet; case networks.testnet: - return NetworkEnum.Testnet; + return Caip2ChainId.Testnet; default: throw new Error('Invalid network'); } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts new file mode 100644 index 00000000..db35d81e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts @@ -0,0 +1,16 @@ +import { DustLimit, ScriptType } from '../constants'; +import { isDust } from './policy'; + +describe('isDust', () => { + it('returns result', () => { + expect(isDust(DustLimit[ScriptType.P2wpkh] + 1, ScriptType.P2wpkh)).toBe( + false, + ); + expect( + isDust(BigInt(DustLimit[ScriptType.P2wpkh] + 1), ScriptType.P2wpkh), + ).toBe(false); + expect( + isDust(BigInt(DustLimit[ScriptType.P2wpkh] - 1), ScriptType.P2wpkh), + ).toBe(true); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts new file mode 100644 index 00000000..905ec2ec --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts @@ -0,0 +1,17 @@ +import type { ScriptType } from '../constants'; +import { DustLimit } from '../constants'; + +/** + * Determines if a given amount is considered dust based on the hardcoded dust limit for a given script type. + * + * @param amt - The amount to compare. + * @param scriptType - The script type for which to calculate the dust limit. + * @returns A boolean indicating whether the amount is considered dust. + */ +export function isDust(amt: bigint | number, scriptType: ScriptType): boolean { + // TODO: Calculate dust threshold by network fee rate, rather than hardcoding it. + if (typeof amt === 'number') { + return amt < DustLimit[scriptType]; + } + return amt < BigInt(DustLimit[scriptType]); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.test.ts deleted file mode 100644 index fcef376e..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { DustLimit, ScriptType } from '../constants'; -import { satsToBtc, btcToSats, isDust } from './unit'; - -describe('satsToBtc', () => { - it('returns Btc unit', () => { - const sats = 1234567899; - expect(satsToBtc(sats)).toBe('12.34567899'); - }); - - it('returns Btc unit with max Satoshis', () => { - const maxSats = 21 * Math.pow(10, 15); - - expect(satsToBtc(maxSats)).toBe('210000000.00000000'); - }); - - it('returns Btc unit with min Satoshis', () => { - const minSats = 1; - expect(satsToBtc(minSats)).toBe('0.00000001'); - }); - - it('throw an error if then given Satoshis in float', () => { - const sats = 1.1; - expect(() => satsToBtc(sats)).toThrow(Error); - }); -}); - -describe('btcToSats', () => { - it('returns Btc unit', () => { - const sats = 1234567899; - const btc = satsToBtc(sats); - expect(btcToSats(parseFloat(btc))).toBe('1234567899'); - }); - - it('returns Btc unit with max Satoshis', () => { - const maxSats = 21 * Math.pow(10, 15); - const btc = satsToBtc(maxSats); - expect(btcToSats(parseFloat(btc))).toBe('21000000000000000'); - }); - - it('returns Btc unit with min Satoshis', () => { - const minSats = 1; - const btc = satsToBtc(minSats); - expect(btcToSats(parseFloat(btc))).toBe('1'); - }); -}); - -describe('isDust', () => { - it('returns result', () => { - expect(isDust(DustLimit[ScriptType.P2wpkh] + 1, ScriptType.P2wpkh)).toBe( - false, - ); - expect(isDust(DustLimit[ScriptType.P2wpkh] - 1, ScriptType.P2wpkh)).toBe( - true, - ); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts deleted file mode 100644 index 301c08a3..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { ScriptType } from '../constants'; -import { DustLimit } from '../constants'; - -/** - * Converts a number of satoshis to a string representing the equivalent amount of BTC. - * - * @param sats - The number of satoshis to convert. - * @returns The equivalent amount of BTC as a string, fixed to 8 decimal places. - * @throws A TypeError if sats is not an integer. - */ -export function satsToBtc(sats: number): string { - if (!Number.isInteger(sats)) { - throw new TypeError('satsToBtc must be called on an integer number'); - } - return (sats / 1e8).toFixed(8); -} - -/** - * Converts a number of BTC to a string representing the equivalent amount of satoshis. - * - * @param btc - The amount of BTC to convert. - * @returns The equivalent amount of satoshis as a string, rounded to the nearest integer. - */ -export function btcToSats(btc: number): string { - return (btc * 1e8).toFixed(0); -} - -/** - * Determines if a given amount is considered dust based on the hardcoded dust limit for a given script type. - * - * @param amt - The amount to compare. - * @param scriptType - The script type for which to calculate the dust limit. - * @returns A boolean indicating whether the amount is considered dust. - */ -export function isDust(amt: number, scriptType: ScriptType): boolean { - // TODO: Calculate dust threshold by network fee rate, rather than hardcoding it. - return amt < DustLimit[scriptType]; -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts index 8c83a93d..09c33648 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts @@ -1,14 +1,35 @@ import type { Network, Payment } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; -import type { StaticImplements } from '../../types/static'; import { hexToBuffer } from '../../utils'; -import type { IAccountSigner } from '../../wallet'; +import type { IAccount, IAccountSigner } from '../../wallet'; import { ScriptType } from '../constants'; -import { getBtcPaymentInst } from '../utils/payment'; -import type { IBtcAccount, IStaticBtcAccount } from './types'; +import { getBtcPaymentInst } from '../utils'; +import type { StaticImplements } from './static'; + +export type IBtcAccount = IAccount & { + payment: Payment; + scriptType: ScriptType; + network: Network; + script: Buffer; +}; + +export type IStaticBtcAccount = { + path: string[]; + scriptType: ScriptType; + new ( + mfp: string, + index: number, + hdPath: string, + pubkey: string, + network: Network, + scriptType: ScriptType, + type: string, + signer: IAccountSigner, + ): IBtcAccount; +}; -export class BtcAccount implements IBtcAccount { +export abstract class BtcAccount implements IBtcAccount { #address: string; #payment: Payment; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts deleted file mode 100644 index b41d07c2..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { BtcAddress } from './address'; - -describe('BtcAddress', () => { - const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; - - describe('toString', () => { - it('returns a address', async () => { - const val = new BtcAddress(address); - expect(val.toString()).toStrictEqual(address); - }); - - it('returns a shortern address', async () => { - const val = new BtcAddress(address); - expect(val.toString(true)).toBe('tb1qt...aeu'); - }); - }); - - describe('valueOf', () => { - it('returns a value', async () => { - const val = new BtcAddress(address); - expect(val.valueOf()).toStrictEqual(val.value); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts deleted file mode 100644 index 71bf7abf..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { replaceMiddleChar } from '../../utils'; -import type { IAddress } from '../../wallet'; - -export class BtcAddress implements IAddress { - value: string; - - constructor(address: string) { - this.value = address; - } - - valueOf(): string { - return this.value; - } - - toString(isShortern = false): string { - return isShortern ? replaceMiddleChar(this.value, 5, 3) : this.value; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts deleted file mode 100644 index 82985c15..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { satsToBtc } from '../utils'; -import { BtcAmount } from './amount'; - -describe('BtcAmount', () => { - describe('toString', () => { - it('returns a satsToBtc string with unit', async () => { - const val = new BtcAmount(1); - expect(val.toString(true)).toBe(`${satsToBtc(val.value)} BTC`); - }); - - it('returns a satsToBtc string without unit', async () => { - const val = new BtcAmount(1); - expect(val.toString(false)).toStrictEqual(satsToBtc(val.value)); - }); - }); - - describe('valueOf', () => { - it('returns a value', async () => { - const val = new BtcAmount(1); - expect(val.valueOf()).toStrictEqual(val.value); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts deleted file mode 100644 index 0b272924..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Chain, Config } from '../../config'; -import type { IAmount } from '../../wallet'; -import { satsToBtc } from '../utils'; - -export class BtcAmount implements IAmount { - value: number; - - constructor(value: number) { - this.value = value; - } - - get unit(): string { - return Config.unit[Chain.Bitcoin]; - } - - valueOf(): number { - return this.value; - } - - toString(withUnit = false): string { - if (!withUnit) { - return `${satsToBtc(this.value)}`; - } - return `${satsToBtc(this.value)} ${this.unit}`; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts index 5f009fa7..0e123f68 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts @@ -3,19 +3,16 @@ import { networks, address as addressUtils } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { ScriptType } from '../constants'; import { CoinSelectService } from './coin-select'; -import { BtcAccountBip32Deriver } from './deriver'; +import { BtcAccountDeriver } from './deriver'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; import { BtcWallet } from './wallet'; -jest.mock('../../libs/snap/helpers'); +jest.mock('../../utils/snap'); describe('CoinSelectService', () => { const createMockWallet = (network) => { - const instance = new BtcWallet( - new BtcAccountBip32Deriver(network), - network, - ); + const instance = new BtcWallet(new BtcAccountDeriver(network), network); return { instance, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index a6506849..2514e825 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -3,7 +3,13 @@ import coinSelect from 'coinselect'; import { TxValidationError } from './exceptions'; import type { TxInput } from './transaction-input'; import type { TxOutput } from './transaction-output'; -import { type SelectionResult } from './types'; + +export type SelectionResult = { + change?: TxOutput; + fee: number; + inputs: TxInput[]; + outputs: TxOutput[]; +}; export class CoinSelectService { protected readonly _feeRate: number; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts index b673d8b4..09970973 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts @@ -1,23 +1,18 @@ -import ecc from '@bitcoinerlab/secp256k1'; -import { - type BIP44AddressKeyDeriver, - type SLIP10NodeInterface, -} from '@metamask/key-tree'; +import { type SLIP10NodeInterface } from '@metamask/key-tree'; import { networks } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; -import ECPairFactory from 'ecpair'; -import { SnapHelper } from '../../libs/snap'; +import * as snapUtils from '../../utils/snap'; import * as strUtils from '../../utils/string'; import { P2WPKHAccount } from './account'; -import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; +import { BtcAccountDeriver } from './deriver'; -jest.mock('../../libs/snap/helpers'); +jest.mock('../../utils/snap'); -describe('BtcAccountBip32Deriver', () => { +describe('BtcAccountDeriver', () => { const prepareBip32Deriver = async (network) => { - const deriver = new BtcAccountBip32Deriver(network); - const bip32Deriver = await SnapHelper.getBip32Deriver( + const deriver = new BtcAccountDeriver(network); + const bip32Deriver = await snapUtils.getBip32Deriver( P2WPKHAccount.path, deriver.curve, ); @@ -32,37 +27,6 @@ describe('BtcAccountBip32Deriver', () => { }; }; - describe('createBip32FromSeed', () => { - it('returns an BIP32Interface', async () => { - const network = networks.testnet; - const { deriver, pkBuffer } = await prepareBip32Deriver(network); - - const result = deriver.createBip32FromSeed(pkBuffer); - - expect(result.chainCode).toBeDefined(); - expect(result.chainCode).not.toBeNull(); - expect(result.privateKey).toBeDefined(); - expect(result.privateKey).not.toBeNull(); - expect(result.publicKey).toBeDefined(); - expect(result.publicKey).not.toBeNull(); - expect(result.depth).toBeDefined(); - expect(result.depth).not.toBeNull(); - expect(result.index).toBeDefined(); - expect(result.index).not.toBeNull(); - }); - - it('throws `Unable to construct BIP32 node from seed` if an error catched', () => { - const network = networks.testnet; - const seed = Buffer.from('', 'hex'); - - const deriver = new BtcAccountBip32Deriver(network); - - expect(() => deriver.createBip32FromSeed(seed)).toThrow( - 'Unable to construct BIP32 node from seed', - ); - }); - }); - describe('createBip32FromPrivateKey', () => { it('returns an BIP32Interface', async () => { const network = networks.testnet; @@ -86,7 +50,7 @@ describe('BtcAccountBip32Deriver', () => { it('throws `Unable to construct BIP32 node from private key` if an error catched', async () => { const network = networks.testnet; - const deriver = new BtcAccountBip32Deriver(network); + const deriver = new BtcAccountDeriver(network); const pkBuffer = Buffer.from(''); const ccBuffer = Buffer.from(''); @@ -165,10 +129,10 @@ describe('BtcAccountBip32Deriver', () => { it('throws DeriverError if private key is missing', async () => { const network = networks.testnet; - const deriver = new BtcAccountBip32Deriver(network); + const deriver = new BtcAccountDeriver(network); jest - .spyOn(SnapHelper, 'getBip32Deriver') + .spyOn(snapUtils, 'getBip32Deriver') .mockResolvedValue({} as unknown as SLIP10NodeInterface); await expect(deriver.getRoot(P2WPKHAccount.path)).rejects.toThrow( @@ -177,53 +141,3 @@ describe('BtcAccountBip32Deriver', () => { }); }); }); - -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 } = P2WPKHAccount; - const ecpair = ECPairFactory(ecc); - const privateKey = ecpair.makeRandom().privateKey?.toString('hex'); - const { getBip44DeriverSpy, deriverSpy } = createMockBip44Entropy(); - - deriverSpy.mockResolvedValue({ - privateKey, - }); - - const deriver = new BtcAccountBip44Deriver(network); - await deriver.getRoot(path); - - expect(getBip44DeriverSpy).toHaveBeenCalledWith(0); - }); - - it('throws `Deriver private key is missing` error if the private key is missing', async () => { - const network = networks.testnet; - const { path } = P2WPKHAccount; - const { deriverSpy } = createMockBip44Entropy(); - deriverSpy.mockResolvedValue({ - privateKey: undefined, - }); - - const deriver = new BtcAccountBip44Deriver(network); - - await expect(deriver.getRoot(path)).rejects.toThrow( - 'Deriver private key is missing', - ); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts index bd245801..1e5507e5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts @@ -4,28 +4,48 @@ import { BIP32Factory } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; -import { SnapHelper } from '../../libs/snap/helpers'; -import { compactError, hexToBuffer } from '../../utils'; +import { compactError, hexToBuffer, getBip32Deriver } from '../../utils'; import { DeriverError } from './exceptions'; -import type { IBtcAccountDeriver } from './types'; -export abstract class BtcAccountDeriver implements IBtcAccountDeriver { +export class BtcAccountDeriver { protected readonly _network: Network; protected readonly _bip32Api: BIP32API; + readonly curve: 'secp256k1' | 'ed25519' = 'secp256k1'; + constructor(network: Network) { this._bip32Api = BIP32Factory(ecc); this._network = network; } - abstract getRoot(path: string[]): Promise; - - createBip32FromSeed(seed: Buffer): BIP32Interface { + async getRoot(path: string[]) { try { - return this._bip32Api.fromSeed(seed, this._network); + const deriver = await getBip32Deriver(path, this.curve); + + if (!deriver.privateKey) { + throw new DeriverError('Deriver private key is missing'); + } + + const privateKeyBuffer = this.pkToBuf(deriver.privateKey); + const chainCodeBuffer = this.chainCodeToBuf(deriver.chainCode); + + const root = this.createBip32FromPrivateKey( + 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) { - throw new DeriverError('Unable to construct BIP32 node from seed'); + throw compactError(error, DeriverError); } } @@ -64,56 +84,3 @@ export abstract class BtcAccountDeriver implements IBtcAccountDeriver { } } } - -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 = this.pkToBuf(deriverNode.privateKey); - const root = this.createBip32FromSeed(privateKeyBuffer); - return root - .deriveHardened(parseInt(path[1].slice(0, -1), 10)) - .deriveHardened(0); - } catch (error) { - throw compactError(error, DeriverError); - } - } -} - -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 = this.pkToBuf(deriver.privateKey); - const chainCodeBuffer = this.chainCodeToBuf(deriver.chainCode); - - const root = this.createBip32FromPrivateKey( - 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) { - throw compactError(error, DeriverError); - } - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts index 8dcb3463..e8a19270 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts @@ -1,4 +1,4 @@ -import { CustomError } from '../../libs/exception'; +import { CustomError } from '../../utils'; export class DeriverError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts deleted file mode 100644 index 43f76ce9..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { Network } from 'bitcoinjs-lib'; -import { networks } from 'bitcoinjs-lib'; - -import { ScriptType } from '../constants'; -import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; -import { BtcWalletFactory } from './factory'; -import type { IBtcAccountDeriver } from './types'; -import * as manager from './wallet'; - -describe('BtcWalletFactory', () => { - class MockBtcWallet extends manager.BtcWallet { - getDeriver() { - return this._deriver; - } - } - - const createMockBtcWallet = () => { - const spy = jest - .spyOn(manager, 'BtcWallet') - .mockImplementation((deriver: IBtcAccountDeriver, network: Network) => { - return new MockBtcWallet(deriver, network); - }); - return { - spy, - }; - }; - - describe('create', () => { - it('creates BtcWallet instance with `BtcAccountBip32Deriver`', () => { - const { spy } = createMockBtcWallet(); - - const instance = BtcWalletFactory.create( - { - defaultAccountIndex: 0, - defaultAccountType: ScriptType.P2wpkh, - deriver: 'BIP32', - enableMultiAccounts: false, - }, - networks.testnet, - ) as unknown as MockBtcWallet; - - expect(spy).toHaveBeenCalled(); - expect(instance.getDeriver()).toBeInstanceOf(BtcAccountBip32Deriver); - }); - - it('creates BtcWallet instance with `BtcAccountBip44Deriver`', () => { - const { spy } = createMockBtcWallet(); - - const instance = BtcWalletFactory.create( - { - defaultAccountIndex: 0, - defaultAccountType: ScriptType.P2wpkh, - deriver: 'BIP44', - enableMultiAccounts: false, - }, - networks.testnet, - ) as unknown as MockBtcWallet; - - expect(spy).toHaveBeenCalled(); - expect(instance.getDeriver()).toBeInstanceOf(BtcAccountBip44Deriver); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.ts deleted file mode 100644 index e3c7992d..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { type Network } from 'bitcoinjs-lib'; - -import type { IWallet } from '../../wallet'; -import { type BtcWalletConfig } from '../config'; -import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; -import { BtcWallet } from './wallet'; - -export class BtcWalletFactory { - static create(config: BtcWalletConfig, network: Network): IWallet { - return new BtcWallet( - config.deriver === 'BIP44' - ? new BtcAccountBip44Deriver(network) - : new BtcAccountBip32Deriver(network), - network, - ); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts index c025f6ea..22617161 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts @@ -1,10 +1,10 @@ export * from './exceptions'; export * from './account'; -export * from './factory'; -export * from './types'; export * from './signer'; export * from './deriver'; export * from './wallet'; -export * from './address'; export * from './transaction-info'; -export * from './amount'; +export * from './coin-select'; +export * from './psbt'; +export * from './transaction-input'; +export * from './transaction-output'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts index 17444791..3ecf5539 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts @@ -3,22 +3,19 @@ import { Psbt, Transaction, networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { hexToBuffer } from '../../utils'; import { MaxStandardTxWeight, ScriptType } from '../constants'; -import { BtcAccountBip32Deriver } from './deriver'; +import { BtcAccountDeriver } from './deriver'; import { PsbtServiceError } from './exceptions'; import { PsbtService } from './psbt'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; import { BtcWallet } from './wallet'; -jest.mock('../../libs/logger/logger'); -jest.mock('../../libs/snap/helpers'); +jest.mock('../../utils/logger'); +jest.mock('../../utils/snap'); describe('PsbtService', () => { const createMockWallet = (network) => { - const instance = new BtcWallet( - new BtcAccountBip32Deriver(network), - network, - ); + const instance = new BtcWallet(new BtcAccountDeriver(network), network); return { instance, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index f4638e39..eb92a8b2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -4,8 +4,7 @@ import { Psbt, Transaction } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; import ECPairFactory from 'ecpair'; -import { logger } from '../../libs/logger/logger'; -import { compactError } from '../../utils'; +import { compactError, logger } from '../../utils'; import type { IAccountSigner } from '../../wallet'; import { MaxStandardTxWeight } from '../constants'; import { PsbtServiceError, TxValidationError } from './exceptions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/types/static.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/static.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/types/static.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/static.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts index c1b15f32..01a21469 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts @@ -1,27 +1,22 @@ -import { networks } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; import { generateAccounts } from '../../../test/utils'; -import { getCaip2ChainId, getExplorerUrl, satsToBtc } from '../utils'; -import { BtcAddress } from './address'; -import { BtcAmount } from './amount'; import { BtcTxInfo } from './transaction-info'; import { TxOutput } from './transaction-output'; describe('BtcTxInfo', () => { describe('toJson', () => { it('returns a json', async () => { - const network = networks.testnet; const accounts = generateAccounts(5); const addresses = accounts.map((account) => account.address); const fee = 10000; const feeRate = 100; let total = fee; const outputs: TxOutput[] = []; - const sender = new BtcAddress(addresses[0]); + const sender = addresses[0]; - const info = new BtcTxInfo(sender, feeRate, network); - info.fee = fee; + const info = new BtcTxInfo(sender, feeRate); + info.txFee = fee; for (let i = 1; i < addresses.length; i++) { total += 100000; @@ -29,52 +24,25 @@ describe('BtcTxInfo', () => { outputs.push(output); } - info.addRecipients(outputs); + const change = new TxOutput(500, addresses[0], Buffer.from('dummy')); - info.change = new TxOutput(500, addresses[0], Buffer.from('dummy')); + info.addRecipients(outputs); + info.addChange(change); total += 500; const expectedRecipients = outputs.map((recipient) => { return { - address: recipient.destination.toString(true), - value: recipient.amount.toString(true), - explorerUrl: getExplorerUrl( - recipient.destination.value, - getCaip2ChainId(network), - ), + address: recipient.address, + value: recipient.bigIntValue, }; }); - const expectedChange = [ - { - address: info.change.destination.toString(true), - value: info.change.amount.toString(true), - explorerUrl: getExplorerUrl( - info.change.destination.value, - getCaip2ChainId(network), - ), - }, - ]; - - expect(info.total).toBeInstanceOf(BtcAmount); - expect(info.total.value).toStrictEqual(total); + expect(info.total).toBe(BigInt(total)); expect(info.sender).toStrictEqual(sender); expect(info.recipients).toHaveLength(expectedRecipients.length); expect(info.change).toBeDefined(); - expect(info.fee).toStrictEqual(fee); - expect(info.txFee).toBeInstanceOf(BtcAmount); - expect(info.txFee.value).toStrictEqual(fee); - expect(info.feeRate).toBeInstanceOf(BtcAmount); - expect(info.feeRate.value).toStrictEqual(feeRate); - - expect(info.toJson()).toStrictEqual({ - feeRate: `${satsToBtc(feeRate)} BTC`, - txFee: `${satsToBtc(fee)} BTC`, - sender: addresses[0], - recipients: expectedRecipients, - changes: expectedChange, - total: `${satsToBtc(total)} BTC`, - }); + expect(info.txFee).toBe(BigInt(fee)); + expect(info.feeRate).toBe(BigInt(feeRate)); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts index b1a7d91f..e24ef6fd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts @@ -1,52 +1,26 @@ -import type { Json } from '@metamask/snaps-sdk'; -import type { Network } from 'bitcoinjs-lib'; - -import type { ITxInfo } from '../../wallet'; -import { getCaip2ChainId, getExplorerUrl } from '../utils'; -import type { BtcAddress } from './address'; -import { BtcAmount } from './amount'; +import type { ITxInfo, Recipient } from '../../wallet'; import type { TxOutput } from './transaction-output'; export class BtcTxInfo implements ITxInfo { - readonly sender: BtcAddress; - - readonly txFee: BtcAmount; - - change?: TxOutput; + readonly sender: string; - protected _recipients: TxOutput[]; + protected _change?: Recipient; - protected _feeRate: BtcAmount; + protected _recipients: Recipient[]; - protected _outputTotal: BtcAmount; + protected _outputTotal: bigint; - protected _serializedRecipients: Json[]; + protected _txFee: bigint; - protected _network: Network; + protected _feeRate: bigint; - constructor(sender: BtcAddress, feeRate: number, network: Network) { - this._recipients = []; - this._serializedRecipients = []; - this._outputTotal = new BtcAmount(0); - this._feeRate = new BtcAmount(feeRate); - this.txFee = new BtcAmount(0); - this._network = network; + constructor(sender: string, feeRate: number) { + this.feeRate = feeRate; + this.txFee = 0; this.sender = sender; - } - protected changeToJson(): Json { - return this.change - ? [ - { - address: this.change.destination.toString(true), - value: this.change.amount.toString(true), - explorerUrl: getExplorerUrl( - this.change.destination.value, - getCaip2ChainId(this._network), - ), - }, - ] - : []; + this._recipients = []; + this._outputTotal = BigInt(0); } addRecipients(outputs: TxOutput[]): void { @@ -56,52 +30,58 @@ export class BtcTxInfo implements ITxInfo { } addRecipient(output: TxOutput): void { - this._outputTotal.value += output.value; - - this._recipients.push(output); + this._outputTotal += output.bigIntValue; - this._serializedRecipients.push({ - address: output.destination.toString(true), - value: output.amount.toString(true), - explorerUrl: getExplorerUrl( - output.destination.value, - getCaip2ChainId(this._network), - ), + this._recipients.push({ + address: output.address, + value: output.bigIntValue, }); } - get total(): BtcAmount { - return new BtcAmount( - this._outputTotal.value + - (this.change ? this.change.value : 0) + - this.txFee.value, - ); + addChange(change: TxOutput): void { + this._change = { + address: change.address, + value: change.bigIntValue, + }; } - get feeRate(): BtcAmount { - return this._feeRate; + set txFee(fee: bigint | number) { + if (typeof fee === 'number') { + this._txFee = BigInt(fee); + } else { + this._txFee = fee; + } } - get recipients(): TxOutput[] { - return this._recipients; + get txFee(): bigint { + return this._txFee; } - get fee(): number { - return this.txFee.value; + set feeRate(fee: bigint | number) { + if (typeof fee === 'number') { + this._feeRate = BigInt(fee); + } else { + this._feeRate = fee; + } + } + + get feeRate(): bigint { + return this._feeRate; } - set fee(val: number) { - this.txFee.value = val; + get total(): bigint { + return ( + this._outputTotal + + (this.change ? BigInt(this.change.value) : BigInt(0)) + + this.txFee + ); + } + + get recipients(): Recipient[] { + return this._recipients; } - toJson>(): InfoJson { - return { - feeRate: this._feeRate.toString(true), - txFee: this.txFee.toString(true), - sender: this.sender.toString(), - recipients: this._serializedRecipients, - changes: this.changeToJson(), - total: this.total.toString(true), - } as unknown as InfoJson; + get change(): Recipient | undefined { + return this._change; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts index 1f0877b6..aac055c3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts @@ -2,18 +2,15 @@ import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { ScriptType } from '../constants'; -import { BtcAccountBip32Deriver } from './deriver'; +import { BtcAccountDeriver } from './deriver'; import { TxInput } from './transaction-input'; import { BtcWallet } from './wallet'; -jest.mock('../../libs/snap/helpers'); +jest.mock('../../utils/snap'); describe('TxInput', () => { const createMockWallet = (network) => { - const instance = new BtcWallet( - new BtcAccountBip32Deriver(network), - network, - ); + const instance = new BtcWallet(new BtcAccountDeriver(network), network); return { instance, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts index 504fd251..06134591 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts @@ -1,15 +1,13 @@ import type { Buffer } from 'buffer'; -import type { IAmount } from '../../wallet'; -import { BtcAmount } from './amount'; -import type { Utxo } from './types'; +import type { Utxo } from '../../chain'; export class TxInput { + protected _value: bigint; + // consume by coinselect readonly script: Buffer; - readonly amount: IAmount; - readonly txHash: string; readonly index: number; @@ -18,7 +16,7 @@ export class TxInput { constructor(utxo: Utxo, script: Buffer) { this.script = script; - this.amount = new BtcAmount(utxo.value); + this.value = utxo.value; this.index = utxo.index; this.txHash = utxo.txHash; this.block = utxo.block; @@ -26,6 +24,18 @@ export class TxInput { // consume by coinselect get value(): number { - return this.amount.value; + return Number(this._value); + } + + set value(value: bigint | number) { + if (typeof value === 'number') { + this._value = BigInt(value); + } else { + this._value = value; + } + } + + get bigIntValue(): bigint { + return this._value; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts index ea20d13f..262f3d2e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts @@ -13,4 +13,22 @@ describe('TxOutput', () => { expect(input.address).toStrictEqual(account.address); expect(input.value).toBe(10); }); + + it('sets output value with a number', () => { + const account = generateAccounts(1)[0]; + + const input = new TxOutput(10, account.address, Buffer.from('dummy')); + input.value = 20; + + expect(input.value).toBe(20); + }); + + it('sets output value with a bigint', () => { + const account = generateAccounts(1)[0]; + + const input = new TxOutput(10, account.address, Buffer.from('dummy')); + input.value = BigInt(20); + + expect(input.value).toBe(20); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts index 298a7fca..b6758cec 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts @@ -1,33 +1,33 @@ import type { Buffer } from 'buffer'; -import type { IAddress, IAmount } from '../../wallet'; -import { BtcAddress } from './address'; -import { BtcAmount } from './amount'; - export class TxOutput { - readonly amount: IAmount; + protected _value: bigint; // consume by conselect readonly script: Buffer; - readonly destination: IAddress; + readonly address: string; - constructor(value: number, address: string, script: Buffer) { - this.amount = new BtcAmount(value); - this.destination = new BtcAddress(address); + constructor(value: bigint | number, address: string, script: Buffer) { + this.value = value; + this.address = address; this.script = script; } // consume by conselect get value(): number { - return this.amount.value; + return Number(this._value); } - set value(value: number) { - this.amount.value = value; + set value(value: bigint | number) { + if (typeof value === 'number') { + this._value = BigInt(value); + } else { + this._value = value; + } } - get address(): string { - return this.destination.value; + get bigIntValue(): bigint { + return this._value; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts deleted file mode 100644 index 4db8fff1..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { BIP32Interface } from 'bip32'; -import type { Network, Payment } from 'bitcoinjs-lib'; -import type { Buffer } from 'buffer'; - -import type { IAccount, IAccountSigner } from '../../wallet'; -import type { ScriptType } from '../constants'; -import type { TxInput } from './transaction-input'; -import type { TxOutput } from './transaction-output'; - -export type IBtcAccountDeriver = { - getRoot(path: string[]): Promise; - getChild(root: BIP32Interface, idx: number): Promise; -}; - -export type IBtcAccount = IAccount & { - payment: Payment; - scriptType: ScriptType; - network: Network; - script: Buffer; -}; - -export type IStaticBtcAccount = { - path: string[]; - scriptType: ScriptType; - new ( - mfp: string, - index: number, - hdPath: string, - pubkey: string, - network: Network, - scriptType: ScriptType, - type: string, - signer: IAccountSigner, - ): IBtcAccount; -}; - -export type CreateTransactionOptions = { - utxos: Utxo[]; - fee: number; - subtractFeeFrom: string[]; - // - // BIP125 opt-in RBF flag, - // - replaceable: boolean; -}; - -export type Utxo = { - block: number; - txHash: string; - index: number; - value: number; -}; - -export type SelectionResult = { - change?: TxOutput; - fee: number; - inputs: TxInput[]; - outputs: TxOutput[]; -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index 8303ad3b..269fd8f6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -1,28 +1,26 @@ -import type { Json } from '@metamask/snaps-sdk'; import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; -import { DustLimit, ScriptType } from '../constants'; +import { DustLimit, ScriptType, maxSatoshi } from '../constants'; import { P2SHP2WPKHAccount, P2WPKHAccount } from './account'; import { CoinSelectService } from './coin-select'; -import { BtcAccountBip32Deriver } from './deriver'; +import { BtcAccountDeriver } from './deriver'; import { WalletError } from './exceptions'; import { BtcTxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; -import type { SelectionResult } from './types'; import { BtcWallet } from './wallet'; -jest.mock('../../libs/snap/helpers'); -jest.mock('../../libs/logger/logger'); +jest.mock('../../utils/snap'); +jest.mock('../../utils/logger'); describe('BtcWallet', () => { const createMockDeriver = (network) => { - const rootSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getRoot'); - const childSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getChild'); + const rootSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getRoot'); + const childSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getChild'); return { - instance: new BtcAccountBip32Deriver(network), + instance: new BtcAccountDeriver(network), rootSpy, childSpy, }; @@ -43,7 +41,7 @@ describe('BtcWallet', () => { return [ { address, - value: amount, + value: BigInt(amount), }, ]; }; @@ -102,11 +100,17 @@ describe('BtcWallet', () => { const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(account.address, 200, 100000, 100000); + + const utxos = generateFormatedUtxos( + account.address, + 200, + maxSatoshi, + maxSatoshi, + ); const result = await wallet.createTransaction( account, - createMockTxIndent(account.address, 132000), + createMockTxIndent(account.address, maxSatoshi), { utxos, fee: 56, @@ -115,12 +119,12 @@ describe('BtcWallet', () => { }, ); - const json = result.txInfo.toJson(); - const recipients = json.recipients as unknown as Json[]; - const changes = json.changes as unknown as Json[]; + const info = result.txInfo; + const { recipients } = info; + const { change } = info; expect(recipients).toHaveLength(1); - expect(changes).toHaveLength(1); + expect(change).toBeDefined(); expect(result).toStrictEqual({ tx: expect.any(String), txInfo: expect.any(BtcTxInfo), @@ -145,12 +149,12 @@ describe('BtcWallet', () => { }, ); - const json = result.txInfo.toJson(); - const recipients = json.recipients as unknown as Json[]; - const changes = json.changes as unknown as Json[]; + const info = result.txInfo; + const { recipients } = info; + const { change } = info; expect(recipients).toHaveLength(1); - expect(changes).toHaveLength(0); + expect(change).toBeUndefined(); expect(result).toStrictEqual({ tx: expect.any(String), txInfo: expect.any(BtcTxInfo), @@ -206,7 +210,7 @@ describe('BtcWallet', () => { 'selectCoins', ); - const selectionResult: SelectionResult = { + const selectionResult = { change: new TxOutput( DustLimit[chgAccount.scriptType] - 1, chgAccount.address, @@ -232,7 +236,7 @@ describe('BtcWallet', () => { const info: BtcTxInfo = result.txInfo as unknown as BtcTxInfo; - expect(info.fee).toBe(19500); + expect(info.txFee).toBe(BigInt(19500)); expect(info.change).toBeUndefined(); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index a2647208..0dadb1c0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -1,8 +1,8 @@ import type { BIP32Interface } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; -import { logger } from '../../libs/logger/logger'; -import { bufferToString, compactError, hexToBuffer } from '../../utils'; +import type { Utxo } from '../../chain'; +import { bufferToString, compactError, hexToBuffer, logger } from '../../utils'; import type { IAccountSigner, IWallet, @@ -10,37 +10,45 @@ import type { Transaction, } from '../../wallet'; import { ScriptType } from '../constants'; -import { isDust } from '../utils'; -import { getScriptForDestnation } from '../utils/address'; -import { P2WPKHAccount, P2SHP2WPKHAccount } from './account'; -import { BtcAddress } from './address'; +import { isDust, getScriptForDestnation } from '../utils'; +import { + P2WPKHAccount, + P2SHP2WPKHAccount, + type IStaticBtcAccount, + type IBtcAccount, +} from './account'; import { CoinSelectService } from './coin-select'; +import type { BtcAccountDeriver } from './deriver'; import { WalletError, TxValidationError } from './exceptions'; import { PsbtService } from './psbt'; import { AccountSigner } from './signer'; import { BtcTxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; -import type { - IStaticBtcAccount, - IBtcAccountDeriver, - IBtcAccount, - CreateTransactionOptions, -} from './types'; + +export type CreateTransactionOptions = { + utxos: Utxo[]; + fee: number; + subtractFeeFrom: string[]; + // + // BIP125 opt-in RBF flag, + // + replaceable: boolean; +}; export class BtcWallet implements IWallet { - protected readonly _deriver: IBtcAccountDeriver; + protected readonly _deriver: BtcAccountDeriver; protected readonly _network: Network; - constructor(deriver: IBtcAccountDeriver, network: Network) { + constructor(deriver: BtcAccountDeriver, network: Network) { this._deriver = deriver; this._network = network; } - async unlock(index: number, type: string): Promise { + async unlock(index: number, type?: string): Promise { try { - const AccountCtor = this.getAccountCtor(type); + const AccountCtor = this.getAccountCtor(type ?? ScriptType.P2wpkh); const rootNode = await this._deriver.getRoot(AccountCtor.path); const childNode = await this._deriver.getChild(rootNode, index); const hdPath = [`m`, `0'`, `0`, `${index}`].join('/'); @@ -68,17 +76,6 @@ export class BtcWallet implements IWallet { const scriptOutput = account.script; const { scriptType } = account; - logger.info( - JSON.stringify( - { - recipients, - options, - }, - null, - 2, - ), - ); - // TODO: Supporting getting coins from other address (dynamic address) const inputs = options.utxos.map((utxo) => new TxInput(utxo, scriptOutput)); const outputs = recipients.map((recipient) => { @@ -107,23 +104,6 @@ export class BtcWallet implements IWallet { change, ); - logger.info( - JSON.stringify( - { - feeRate, - ...selectionResult, - }, - null, - 2, - ), - ); - - const txInfo = new BtcTxInfo( - new BtcAddress(account.address), - feeRate, - this._network, - ); - const psbtService = new PsbtService(this._network); psbtService.addInputs( selectionResult.inputs, @@ -133,6 +113,8 @@ export class BtcWallet implements IWallet { hexToBuffer(account.mfp, false), ); + const txInfo = new BtcTxInfo(account.address, feeRate); + // TODO: add support of subtractFeeFrom, and throw error if output is too small after subtraction for (const output of selectionResult.outputs) { psbtService.addOutput(output); @@ -146,13 +128,13 @@ export class BtcWallet implements IWallet { ); } else { psbtService.addOutput(selectionResult.change); - txInfo.change = selectionResult.change; + txInfo.addChange(selectionResult.change); } } // Sign dummy transaction to extract the fee which is more accurate const signedService = await psbtService.signDummy(account.signer); - txInfo.fee = signedService.getFee(); + txInfo.txFee = signedService.getFee(); return { tx: psbtService.toBase64(), diff --git a/merged-packages/bitcoin-wallet-snap/src/chain.ts b/merged-packages/bitcoin-wallet-snap/src/chain.ts index 45f640ff..a0a57418 100644 --- a/merged-packages/bitcoin-wallet-snap/src/chain.ts +++ b/merged-packages/bitcoin-wallet-snap/src/chain.ts @@ -1,7 +1,3 @@ -import type { Json } from '@metamask/snaps-sdk'; - -import type { IAmount } from './wallet'; - export enum FeeRatio { Fast = 'fast', Medium = 'medium', @@ -18,10 +14,8 @@ export type TransactionStatusData = { status: TransactionStatus; }; -export type Balances = Record; - export type Balance = { - amount: IAmount; + amount: bigint; }; export type AssetBalances = { @@ -34,7 +28,7 @@ export type AssetBalances = { export type Fee = { type: FeeRatio; - rate: IAmount; + rate: bigint; }; export type Fees = { @@ -48,13 +42,22 @@ export type TransactionIntent = { }; export type TransactionData = { - data: Record; + data: { + utxos: Utxo[]; + }; }; export type CommitedTransaction = { transactionId: string; }; +export type Utxo = { + block: number; + txHash: string; + index: number; + value: number; +}; + /** * An interface that defines methods for interacting with a blockchain network. */ diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts new file mode 100644 index 00000000..3f17567d --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -0,0 +1,49 @@ +import type { Json } from '@metamask/snaps-sdk'; + +import { Caip2ChainId, Caip2Asset } from './constants'; + +export type SnapConfig = { + onChainService: { + dataClient: { + options?: Record; + }; + }; + wallet: { + defaultAccountIndex: number; + defaultAccountType: string; + }; + avaliableNetworks: string[]; + avaliableAssets: string[]; + unit: string; + explorer: { + [network in string]: string; + }; + logLevel: string; +}; + +export const Config: SnapConfig = { + onChainService: { + dataClient: { + options: { + // eslint-disable-next-line no-restricted-globals + apiKey: process.env.BLOCKCHAIR_API_KEY, + }, + }, + }, + wallet: { + defaultAccountIndex: 0, + defaultAccountType: 'bip122:p2wpkh', + }, + avaliableNetworks: Object.values(Caip2ChainId), + avaliableAssets: Object.values(Caip2Asset), + unit: 'BTC', + explorer: { + // eslint-disable-next-line no-template-curly-in-string + [Caip2ChainId.Mainnet]: 'https://blockstream.info/address/${address}', + [Caip2ChainId.Testnet]: + // eslint-disable-next-line no-template-curly-in-string + 'https://blockstream.info/testnet/address/${address}', + }, + // eslint-disable-next-line no-restricted-globals + logLevel: process.env.LOG_LEVEL ?? '6', +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/config/config.ts b/merged-packages/bitcoin-wallet-snap/src/config/config.ts deleted file mode 100644 index ca1b99fd..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/config/config.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { - BtcWalletConfig, - BtcOnChainServiceConfig, -} from '../bitcoin/config'; -import { - Network as BtcNetwork, - DataClient, - BtcAsset, - BtcUnit, -} from '../bitcoin/constants'; - -export enum Chain { - Bitcoin = 'Bitcoin', -} - -export type NetworkConfig = { - [key in string]: string; -}; - -export type OnChainServiceConfig = { - [Chain.Bitcoin]: BtcOnChainServiceConfig; -}; - -export type WalletConfig = { - [Chain.Bitcoin]: BtcWalletConfig; -}; - -export type SnapConfig = { - onChainService: OnChainServiceConfig; - wallet: WalletConfig; - avaliableNetworks: { - [key in Chain]: string[]; - }; - avaliableAssets: { - [key in Chain]: string[]; - }; - unit: { - [key in Chain]: string; - }; - explorer: { - [key in Chain]: { - [network in string]: string; - }; - }; - chain: Chain; - logLevel: string; -}; - -export const Config: SnapConfig = { - onChainService: { - [Chain.Bitcoin]: { - dataClient: { - read: { - type: - // eslint-disable-next-line no-restricted-globals - (process.env.DATA_CLIENT_READ_TYPE as DataClient) ?? - DataClient.BlockChair, - options: { - // eslint-disable-next-line no-restricted-globals - apiKey: process.env.BLOCKCHAIR_API_KEY, - }, - }, - - write: { - type: - // eslint-disable-next-line no-restricted-globals - (process.env.DATA_CLIENT_WRITE_TYPE as DataClient) ?? - DataClient.BlockChair, - options: { - // eslint-disable-next-line no-restricted-globals - apiKey: process.env.BLOCKCHAIR_API_KEY, - }, - }, - }, - }, - }, - wallet: { - [Chain.Bitcoin]: { - enableMultiAccounts: false, - defaultAccountIndex: 0, - defaultAccountType: 'bip122:p2wpkh', - deriver: 'BIP32', - }, - }, - avaliableNetworks: { - [Chain.Bitcoin]: Object.values(BtcNetwork), - }, - avaliableAssets: { - [Chain.Bitcoin]: Object.values(BtcAsset), - }, - unit: { - [Chain.Bitcoin]: BtcUnit.Btc, - }, - explorer: { - [Chain.Bitcoin]: { - [BtcNetwork.Mainnet]: 'https://blockstream.info', - [BtcNetwork.Testnet]: 'https://blockstream.info/testnet', - }, - }, - chain: Chain.Bitcoin, - // eslint-disable-next-line no-restricted-globals - logLevel: process.env.LOG_LEVEL ?? '6', -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/config/index.ts b/merged-packages/bitcoin-wallet-snap/src/config/index.ts deleted file mode 100644 index ae8d958f..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/config/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './config'; -export * from './permissions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/constants.ts b/merged-packages/bitcoin-wallet-snap/src/constants.ts new file mode 100644 index 00000000..308df1e7 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/constants.ts @@ -0,0 +1,9 @@ +export enum Caip2ChainId { + Mainnet = 'bip122:000000000019d6689c085ae165831e93', + Testnet = 'bip122:000000000933ea01ad0ee984209779ba', +} + +export enum Caip2Asset { + Btc = 'bip122:000000000019d6689c085ae165831e93/slip44:0', + TBtc = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0', +} diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/factory.test.ts index c9835078..c714732a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.test.ts @@ -1,13 +1,14 @@ import { BtcOnChainService } from './bitcoin/chain'; -import { Network } from './bitcoin/constants'; import { BtcWallet } from './bitcoin/wallet'; +import { Caip2ChainId } from './constants'; import { Factory } from './factory'; -import { BtcKeyring } from './keyring'; describe('Factory', () => { describe('createOnChainServiceProvider', () => { it('creates BtcOnChainService instance', () => { - const instance = Factory.createOnChainServiceProvider(Network.Testnet); + const instance = Factory.createOnChainServiceProvider( + Caip2ChainId.Testnet, + ); expect(instance).toBeInstanceOf(BtcOnChainService); }); @@ -15,17 +16,9 @@ describe('Factory', () => { describe('createWallet', () => { it('creates BtcWallet instance', () => { - const instance = Factory.createWallet(Network.Testnet); + const instance = Factory.createWallet(Caip2ChainId.Testnet); expect(instance).toBeInstanceOf(BtcWallet); }); }); - - describe('createKeyring', () => { - it('creates BtcKeyring instance', () => { - const instance = Factory.createKeyring(); - - expect(instance).toBeInstanceOf(BtcKeyring); - }); - }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts index ad536089..efc3087a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.ts @@ -1,67 +1,27 @@ -import { type Keyring } from '@metamask/keyring-api'; - import { BtcOnChainService } from './bitcoin/chain'; -import { - type BtcWalletConfig, - type BtcOnChainServiceConfig, -} from './bitcoin/config'; -import { DataClientFactory } from './bitcoin/data-client/factory'; +import { BlockChairClient } from './bitcoin/chain/clients/blockchair'; import { getBtcNetwork } from './bitcoin/utils'; -import { BtcWalletFactory } from './bitcoin/wallet'; +import { BtcAccountDeriver, BtcWallet } from './bitcoin/wallet'; import type { IOnChainService } from './chain'; import { Config } from './config'; -import { BtcKeyring, KeyringStateManager } from './keyring'; import type { IWallet } from './wallet'; -// TODO: Remove temp solution to support keyring in snap without keyring API -export type CreateBtcKeyringOptions = { - emitEvents: boolean; -}; - export class Factory { - static createBtcOnChainServiceProvider( - config: BtcOnChainServiceConfig, - network: string, - ) { - const btcNetwork = getBtcNetwork(network); - const readClient = DataClientFactory.createReadClient(config, btcNetwork); - const writeClient = DataClientFactory.createWriteClient(config, btcNetwork); - return new BtcOnChainService(readClient, writeClient, { + static createOnChainServiceProvider(scope: string): IOnChainService { + const btcNetwork = getBtcNetwork(scope); + + const client = new BlockChairClient({ network: btcNetwork, + apiKey: Config.onChainService.dataClient.options?.apiKey?.toString(), }); - } - - static createBtcWallet(config: BtcWalletConfig, network: string) { - const btcNetwork = getBtcNetwork(network); - return BtcWalletFactory.create(config, btcNetwork); - } - static createBtcKeyring( - config: BtcWalletConfig, - options: CreateBtcKeyringOptions, - ): BtcKeyring { - return new BtcKeyring(new KeyringStateManager(), { - defaultIndex: config.defaultAccountIndex, - multiAccount: config.enableMultiAccounts, - // TODO: Remove temp solution to support keyring in snap without keyring API - emitEvents: options.emitEvents, + return new BtcOnChainService(client, { + network: btcNetwork, }); } - static createOnChainServiceProvider(scope: string): IOnChainService { - return Factory.createBtcOnChainServiceProvider( - Config.onChainService[Config.chain], - scope, - ); - } - static createWallet(scope: string): IWallet { - return Factory.createBtcWallet(Config.wallet[Config.chain], scope); - } - - static createKeyring(): Keyring { - return Factory.createBtcKeyring(Config.wallet[Config.chain], { - emitEvents: true, - }); + const btcNetwork = getBtcNetwork(scope); + return new BtcWallet(new BtcAccountDeriver(btcNetwork), btcNetwork); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts index 9c10a9f8..ca926944 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.ts @@ -1,19 +1,19 @@ import * as keyringApi from '@metamask/keyring-api'; import { - type Json, type JsonRpcRequest, SnapError, MethodNotFoundError, } from '@metamask/snaps-sdk'; import { onRpcRequest, validateOrigin, onKeyringRequest } from '.'; -import { Config, originPermissions } from './config'; +import * as entry from '.'; +import { TransactionStatus } from './chain'; +import { Config } from './config'; import { BtcKeyring } from './keyring'; -import { BaseSnapRpcHandler, type IStaticSnapRpcHandler } from './libs/rpc'; -import { RpcHelper } from './rpcs'; -import type { StaticImplements } from './types/static'; +import { originPermissions } from './permissions'; +import * as getTxStatusRpc from './rpcs/get-transaction-status'; -jest.mock('./libs/logger/logger'); +jest.mock('./utils/logger'); jest.mock('@metamask/keyring-api', () => ({ ...jest.requireActual('@metamask/keyring-api'), @@ -50,59 +50,47 @@ describe('validateOrigin', () => { }); describe('onRpcRequest', () => { - const createMockChainApiHandler = () => { - const handleRequestSpy = jest.fn(); - class MockChainApiHandler - extends BaseSnapRpcHandler - implements - StaticImplements - { - handleRequest = handleRequestSpy; - } - return { handler: MockChainApiHandler, handleRequestSpy }; - }; + const executeRequest = async (method = 'chain_getTransactionStatus') => { + jest.spyOn(entry, 'validateOrigin').mockReturnThis(); - const executeRequest = async () => { return onRpcRequest({ origin: 'http://localhost:8000', request: { - method: 'chain_getTransactionStatus', + method, params: { - scope: Config.avaliableNetworks[Config.chain][0], + scope: Config.avaliableNetworks[0], }, } as unknown as JsonRpcRequest, }); }; it('executes the rpc request', async () => { - const { handler, handleRequestSpy } = createMockChainApiHandler(); - jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({ - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_getTransactionStatus: handler, - }); - handleRequestSpy.mockResolvedValueOnce({ - data: 1, - } as Json); + jest.spyOn(getTxStatusRpc, 'getTransactionStatus').mockResolvedValue({ + status: TransactionStatus.Confirmed, + } as unknown as getTxStatusRpc.GetTransactionStatusResponse); const resposne = await executeRequest(); - expect(resposne).toStrictEqual({ data: 1 }); + expect(resposne).toStrictEqual({ status: TransactionStatus.Confirmed }); }); - it('throws SnapError if an error catched', async () => { - jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({}); - - await expect(executeRequest()).rejects.toThrow(MethodNotFoundError); + it('throws MethodNotFoundError if an method not found', async () => { + await expect(executeRequest('some-not')).rejects.toThrow( + MethodNotFoundError, + ); }); - it('throws SnapError if an SnapError catched', async () => { - const { handler, handleRequestSpy } = createMockChainApiHandler(); - jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({ - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_getTransactionStatus: handler, - }); - handleRequestSpy.mockRejectedValue(new SnapError('error')); + it('throws SnapError if the request is failed to execute', async () => { + jest + .spyOn(getTxStatusRpc, 'getTransactionStatus') + .mockRejectedValue(new SnapError('error')); + await expect(executeRequest()).rejects.toThrow(SnapError); + }); + it('throws SnapError if the error is not an instance of SnapError', async () => { + jest + .spyOn(getTxStatusRpc, 'getTransactionStatus') + .mockRejectedValue(new Error('error')); await expect(executeRequest()).rejects.toThrow(SnapError); }); }); @@ -122,7 +110,7 @@ describe('onKeyringRequest', () => { request: { method: keyringApi.KeyringRpcMethod.ListAccounts, params: { - scope: Config.avaliableNetworks[Config.chain][0], + scope: Config.avaliableNetworks[0], }, } as unknown as JsonRpcRequest, }); @@ -136,7 +124,7 @@ describe('onKeyringRequest', () => { expect(handler).toHaveBeenCalledWith(expect.any(BtcKeyring), { method: keyringApi.KeyringRpcMethod.ListAccounts, params: { - scope: Config.avaliableNetworks[Config.chain][0], + scope: Config.avaliableNetworks[0], }, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 6aa711cd..89782ea8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -9,12 +9,12 @@ import { } from '@metamask/snaps-sdk'; import { Config } from './config'; -import { originPermissions } from './config/permissions'; -import { Factory } from './factory'; -import { logger } from './libs/logger/logger'; -import type { SnapRpcHandlerRequest } from './libs/rpc'; -import { RpcHelper } from './rpcs/helpers'; -import { isSnapRpcError } from './utils'; +import { BtcKeyring } from './keyring'; +import { InternalRpcMethod, originPermissions } from './permissions'; +import type { CreateAccountParams, GetTransactionStatusParams } from './rpcs'; +import { createAccount, getTransactionStatus } from './rpcs'; +import { KeyringStateManager } from './stateManagement'; +import { isSnapRpcError, logger } from './utils'; export const validateOrigin = (origin: string, method: string): void => { if (!origin) { @@ -35,17 +35,19 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ try { const { method } = request; - validateOrigin(origin, method); - const methodHanlders = RpcHelper.getChainRpcApiHandlers(); + validateOrigin(origin, method); - if (!Object.prototype.hasOwnProperty.call(methodHanlders, method)) { - throw new MethodNotFoundError() as unknown as Error; + switch (method) { + case InternalRpcMethod.CreateAccount: + return await createAccount(request.params as CreateAccountParams); + case InternalRpcMethod.GetTransactionStatus: + return await getTransactionStatus( + request.params as GetTransactionStatusParams, + ); + default: + throw new MethodNotFoundError(method) as unknown as Error; } - - return await methodHanlders[method] - .getInstance() - .execute(request.params as SnapRpcHandlerRequest); } catch (error) { let snapError = error; @@ -68,7 +70,12 @@ export const onKeyringRequest: OnKeyringRequestHandler = async ({ try { validateOrigin(origin, request.method); - const keyring = Factory.createKeyring(); + const keyring = new BtcKeyring(new KeyringStateManager(), { + defaultIndex: Config.wallet.defaultAccountIndex, + // TODO: Remove temp solution to support keyring in snap without keyring API + emitEvents: true, + }); + return (await handleKeyringRequest( keyring, request, diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts similarity index 69% rename from merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts rename to merged-packages/bitcoin-wallet-snap/src/keyring.test.ts index c2008861..2079fd2a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts @@ -1,20 +1,21 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; import { MethodNotFoundError } from '@metamask/snaps-sdk'; -import { unknown } from 'superstruct'; - -import { generateAccounts } from '../../test/utils'; -import { BtcAsset, Network } from '../bitcoin/constants'; -import { Chain, Config } from '../config'; -import { Factory } from '../factory'; -import { type IStaticSnapRpcHandler, BaseSnapRpcHandler } from '../libs/rpc'; -import { GetBalancesHandler } from '../rpcs'; -import { RpcHelper } from '../rpcs/helpers'; -import type { StaticImplements } from '../types/static'; -import type { IWallet } from '../wallet'; -import { BtcKeyringError } from './exceptions'; +import { v4 as uuidv4 } from 'uuid'; + +import { generateAccounts } from '../test/utils'; +import { ScriptType } from './bitcoin/constants'; +import { BtcAccount } from './bitcoin/wallet'; +import { Config } from './config'; +import { Caip2Asset, Caip2ChainId } from './constants'; +import { Factory } from './factory'; import { BtcKeyring } from './keyring'; -import { KeyringStateManager } from './state'; +import * as getBalanceRpc from './rpcs/get-balances'; +import * as sendManyRpc from './rpcs/sendmany'; +import { KeyringStateManager } from './stateManagement'; +import type { IWallet } from './wallet'; -jest.mock('../libs/logger/logger'); +jest.mock('./utils/logger'); +jest.mock('./utils/snap'); jest.mock('@metamask/keyring-api', () => ({ ...jest.requireActual('@metamask/keyring-api'), @@ -71,40 +72,16 @@ describe('BtcKeyring', () => { }; }; - const createMockChainRPCHandler = () => { - const handleRequestSpy = jest.fn(); - class MockChainRpcHandler - extends BaseSnapRpcHandler - implements - StaticImplements - { - static override get requestStruct() { - return unknown(); - } - - handleRequest = handleRequestSpy; - } - return { - instance: MockChainRpcHandler, - handleRequestSpy, - }; - }; - const createMockKeyring = (stateMgr: KeyringStateManager) => { - const { instance: RpcHandler, handleRequestSpy } = - createMockChainRPCHandler(); - - jest.spyOn(RpcHelper, 'getKeyringRpcApiHandlers').mockReturnValue({ - // eslint-disable-next-line @typescript-eslint/naming-convention - btc_sendmany: RpcHandler, - }); - + const sendManySpy = jest.spyOn(sendManyRpc, 'sendMany'); + const getBalanceRpcSpy = jest.spyOn(getBalanceRpc, 'getBalances'); return { instance: new BtcKeyring(stateMgr, { defaultIndex: 0, multiAccount: false, }), - handleRequestSpy, + sendManySpy, + getBalanceRpcSpy, }; }; @@ -112,12 +89,32 @@ describe('BtcKeyring', () => { return [`m`, `0'`, `0`, `${index}`].join('/'); }; + const createSender = async (caip2ChainId: string) => { + const wallet = Factory.createWallet(caip2ChainId); + const sender = await wallet.unlock(0, ScriptType.P2wpkh); + + const keyringAccount = { + type: sender.type, + id: uuidv4(), + address: sender.address, + options: { + scope: caip2ChainId, + index: sender.index, + }, + methods: ['btc_sendmany'], + }; + return { + sender, + keyringAccount, + }; + }; + describe('createAccount', () => { it('creates account', async () => { const { unlockSpy } = createMockWallet(); const { instance: stateMgr, addWalletSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); - const scope = Network.Testnet; + const scope = Caip2ChainId.Testnet; const account = generateAccounts(1)[0]; unlockSpy.mockResolvedValue({ @@ -132,8 +129,8 @@ describe('BtcKeyring', () => { }); expect(unlockSpy).toHaveBeenCalledWith( - Config.wallet[Chain.Bitcoin].defaultAccountIndex, - Config.wallet[Chain.Bitcoin].defaultAccountType, + Config.wallet.defaultAccountIndex, + Config.wallet.defaultAccountType, ); expect(addWalletSpy).toHaveBeenCalledWith({ account: { @@ -152,18 +149,18 @@ describe('BtcKeyring', () => { }); }); - it('throws BtcKeyringError if an error catched', async () => { + it('throws Error if an error catched', async () => { const { unlockSpy } = createMockWallet(); const { instance: stateMgr } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); unlockSpy.mockRejectedValue(new Error('error')); - const scope = Network.Testnet; + const scope = Caip2ChainId.Testnet; await expect( keyring.createAccount({ scope, }), - ).rejects.toThrow(BtcKeyringError); + ).rejects.toThrow(Error); }); it('throws `Invalid params to create an account` if the create options is invalid', async () => { @@ -185,108 +182,11 @@ describe('BtcKeyring', () => { const account = generateAccounts(1)[0]; await expect( - keyring.filterAccountChains(account.id, [Network.Testnet]), + keyring.filterAccountChains(account.id, [Caip2ChainId.Testnet]), ).rejects.toThrow('Method not implemented.'); }); }); - describe('submitRequest', () => { - it('calls SnapRpcHandler if the method support', async () => { - const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const { instance: keyring, handleRequestSpy } = - createMockKeyring(stateMgr); - const account = generateAccounts(1)[0]; - const params = { - scope: 'bip122:000000000933ea01ad0ee984209779ba', - amounts: { - bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah: '0.01', - bc1qf5n2h6mgelkls4497pkpemew55xpew90td2qae: '0.01', - }, - comment: 'testing', - subtractFeeFrom: ['bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah'], - replaceable: false, - }; - getWalletSpy.mockResolvedValue({ - account, - index: account.options.index, - scope: account.options.scope, - hdPath: getHdPath(account.options.index), - }); - - await keyring.submitRequest({ - id: account.id, - scope: Network.Testnet, - account: account.address, - request: { - method: 'btc_sendmany', - params, - }, - }); - - expect(handleRequestSpy).toHaveBeenCalledWith(params); - }); - - it('throws `Account not found` error if the account address not found', async () => { - const { instance: stateMgr } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const account = generateAccounts(1)[0]; - - await expect( - keyring.submitRequest({ - id: account.id, - scope: Network.Testnet, - account: account.address, - request: { - method: 'btc_sendmany', - }, - }), - ).rejects.toThrow('Account not found'); - }); - - it("throws `Account's scope does not match with the request's scope` error if given scope is not match with the account", async () => { - const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const account = generateAccounts(1)[0]; - - getWalletSpy.mockResolvedValue({ - account, - index: account.options.index, - scope: account.options.scope, - hdPath: getHdPath(account.options.index), - }); - - await expect( - keyring.submitRequest({ - id: account.id, - scope: Network.Mainnet, - account: account.address, - request: { - method: 'btc_sendmany', - }, - }), - ).rejects.toThrow( - "Account's scope does not match with the request's scope", - ); - }); - - it('throws MethodNotFoundError if the method not support', async () => { - const { instance: stateMgr } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const account = generateAccounts(1)[0]; - - await expect( - keyring.submitRequest({ - id: account.id, - scope: Network.Testnet, - account: account.address, - request: { - method: 'btc_doesNotExist', - }, - }), - ).rejects.toThrow(MethodNotFoundError); - }); - }); - describe('listAccounts', () => { it('returns result', async () => { const { instance: stateMgr, listAccountsSpy } = createMockStateMgr(); @@ -300,12 +200,12 @@ describe('BtcKeyring', () => { expect(listAccountsSpy).toHaveBeenCalledTimes(1); }); - it('throws BtcKeyringError if an error catched', async () => { + it('throws Error if an error catched', async () => { const { instance: stateMgr, listAccountsSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); listAccountsSpy.mockRejectedValue(new Error('error')); - await expect(keyring.listAccounts()).rejects.toThrow(BtcKeyringError); + await expect(keyring.listAccounts()).rejects.toThrow(Error); }); }); @@ -334,15 +234,13 @@ describe('BtcKeyring', () => { expect(getAccountSpy).toHaveBeenCalledTimes(1); }); - it('throws BtcKeyringError if an error catched', async () => { + it('throws Error if an error catched', async () => { const { instance: stateMgr, getAccountSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); getAccountSpy.mockRejectedValue(new Error('error')); const accounts = generateAccounts(1); - await expect(keyring.getAccount(accounts[0].id)).rejects.toThrow( - BtcKeyringError, - ); + await expect(keyring.getAccount(accounts[0].id)).rejects.toThrow(Error); }); }); @@ -358,15 +256,13 @@ describe('BtcKeyring', () => { expect(updateAccountSpy).toHaveBeenCalledWith(account); }); - it('throws BtcKeyringError if an error catched', async () => { + it('throws Error if an error catched', async () => { const { instance: stateMgr, updateAccountSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); updateAccountSpy.mockRejectedValue(new Error('error')); const account = generateAccounts(1)[0]; - await expect(keyring.updateAccount(account)).rejects.toThrow( - BtcKeyringError, - ); + await expect(keyring.updateAccount(account)).rejects.toThrow(Error); }); }); @@ -382,61 +278,166 @@ describe('BtcKeyring', () => { expect(removeAccountsSpy).toHaveBeenCalledWith([account.id]); }); - it('throws BtcKeyringError if an error catched', async () => { + it('throws Error if an error catched', async () => { const { instance: stateMgr, removeAccountsSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); removeAccountsSpy.mockRejectedValue(new Error('error')); const account = generateAccounts(1)[0]; - await expect(keyring.deleteAccount(account.id)).rejects.toThrow( - BtcKeyringError, - ); + await expect(keyring.deleteAccount(account.id)).rejects.toThrow(Error); }); }); - describe('getAccountBalances', () => { - it('executes `GetBalancesHandler` with correct parameter', async () => { + describe('submitRequest', () => { + it('calls SnapRpcHandler if the method support', async () => { + const caip2ChainId = Caip2ChainId.Testnet; const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const { instance: keyring, sendManySpy } = createMockKeyring(stateMgr); + const { sender, keyringAccount } = await createSender(caip2ChainId); + getWalletSpy.mockResolvedValue({ + account: keyringAccount as unknown as KeyringAccount, + index: sender.index, + scope: keyringAccount.options.scope, + hdPath: sender.hdPath, + }); + sendManySpy.mockResolvedValue({ + txId: 'txid', + }); + + const params = { + scope: caip2ChainId, + amounts: { + bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah: '0.01', + bc1qf5n2h6mgelkls4497pkpemew55xpew90td2qae: '0.01', + }, + comment: 'testing', + subtractFeeFrom: ['bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah'], + replaceable: false, + }; + + await keyring.submitRequest({ + id: keyringAccount.id, + scope: Caip2ChainId.Testnet, + account: keyringAccount.address, + request: { + method: 'btc_sendmany', + params, + }, + }); + + expect(sendManySpy).toHaveBeenCalledWith(expect.any(BtcAccount), params); + }); + + it('throws `Account not found` error if the account address not found', async () => { + const { instance: stateMgr } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); const account = generateAccounts(1)[0]; - const assets = [BtcAsset.TBtc]; - const getBalancesHandlerSpy = jest.spyOn( - GetBalancesHandler.prototype, - 'execute', - ); - getBalancesHandlerSpy.mockResolvedValue( - assets.reduce((acc, asset) => { - acc[asset] = { - amount: '1', - unit: Config.unit[Chain.Bitcoin], - }; - return acc; + + await expect( + keyring.submitRequest({ + id: account.id, + scope: Caip2ChainId.Testnet, + account: account.address, + request: { + method: 'btc_sendmany', + }, }), + ).rejects.toThrow('Account not found'); + }); + + it("throws `Account's scope does not match with the request's scope` error if given scope is not match with the account", async () => { + const caip2ChainId = Caip2ChainId.Testnet; + const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + const { sender, keyringAccount } = await createSender(caip2ChainId); + getWalletSpy.mockResolvedValue({ + account: keyringAccount as unknown as KeyringAccount, + index: sender.index, + scope: keyringAccount.options.scope, + hdPath: sender.hdPath, + }); + + await expect( + keyring.submitRequest({ + id: keyringAccount.id, + scope: Caip2ChainId.Mainnet, + account: keyringAccount.address, + request: { + method: 'btc_sendmany', + }, + }), + ).rejects.toThrow( + "Account's scope does not match with the request's scope", ); + }); + + it('throws MethodNotFoundError if the method not support', async () => { + const caip2ChainId = Caip2ChainId.Testnet; + const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + const { sender, keyringAccount } = await createSender(caip2ChainId); getWalletSpy.mockResolvedValue({ - account, - index: account.options.index, - scope: account.options.scope, - hdPath: getHdPath(account.options.index), + account: keyringAccount as unknown as KeyringAccount, + index: sender.index, + scope: keyringAccount.options.scope, + hdPath: sender.hdPath, + }); + + await expect( + keyring.submitRequest({ + id: keyringAccount.id, + scope: caip2ChainId, + account: keyringAccount.address, + request: { + method: 'btc_doesNotExist', + }, + }), + ).rejects.toThrow(MethodNotFoundError); + }); + }); + + describe('getAccountBalances', () => { + it('executes `GetBalancesHandler` with correct parameter', async () => { + const caip2ChainId = Caip2ChainId.Testnet; + const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const { instance: keyring, getBalanceRpcSpy } = + createMockKeyring(stateMgr); + const { sender, keyringAccount } = await createSender(caip2ChainId); + getWalletSpy.mockResolvedValue({ + account: keyringAccount as unknown as KeyringAccount, + index: sender.index, + scope: keyringAccount.options.scope, + hdPath: sender.hdPath, }); - await keyring.getAccountBalances(account.id, [BtcAsset.TBtc]); + const assets = [Caip2Asset.TBtc]; + const expectedResp = assets.reduce((acc, asset) => { + acc[asset] = { + amount: '1', + unit: Config.unit, + }; + return acc; + }, {}); + + getBalanceRpcSpy.mockResolvedValue(expectedResp); + + await keyring.getAccountBalances(keyringAccount.id, [Caip2Asset.TBtc]); - expect(getBalancesHandlerSpy).toHaveBeenCalledWith({ - scope: account.options.scope, + expect(getBalanceRpcSpy).toHaveBeenCalledWith(expect.any(BtcAccount), { + scope: caip2ChainId, assets, }); }); - it('throws BtcKeyringError if an error catched', async () => { + it('throws Error if an error catched', async () => { const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); getWalletSpy.mockRejectedValue(new Error('error')); const account = generateAccounts(1)[0]; await expect( - keyring.getAccountBalances(account.id, [BtcAsset.TBtc]), - ).rejects.toThrow(BtcKeyringError); + keyring.getAccountBalances(account.id, [Caip2Asset.TBtc]), + ).rejects.toThrow(Error); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts similarity index 60% rename from merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts rename to merged-packages/bitcoin-wallet-snap/src/keyring.ts index 74a1c60c..6ba16239 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -9,48 +9,48 @@ import { type CaipAssetType, } from '@metamask/keyring-api'; import { MethodNotFoundError, type Json } from '@metamask/snaps-sdk'; -import { assert, StructError } from 'superstruct'; +import type { Infer } from 'superstruct'; +import { assert, object, StructError } from 'superstruct'; import { v4 as uuidv4 } from 'uuid'; -import { Config } from '../config'; -import { Factory } from '../factory'; -import { logger } from '../libs/logger/logger'; -import type { SnapRpcHandlerRequest } from '../libs/rpc'; -import { SnapHelper } from '../libs/snap'; -import { GetBalancesHandler } from '../rpcs'; -import { RpcHelper } from '../rpcs/helpers'; -import type { IAccount, IWallet } from '../wallet'; -import { BtcKeyringError } from './exceptions'; -import type { KeyringStateManager } from './state'; -import { - CreateAccountOptionsStruct, - type ChainRPCHandlers, - type CreateAccountOptions, - type KeyringOptions, -} from './types'; +import { Config } from './config'; +import { Factory } from './factory'; +import { getBalances, type SendManyParams, sendMany } from './rpcs'; +import type { KeyringStateManager, Wallet } from './stateManagement'; +import { getProvider, scopeStruct, logger } from './utils'; +import type { IAccount, IWallet } from './wallet'; + +export type KeyringOptions = Record & { + defaultIndex: number; + multiAccount?: boolean; + // TODO: Remove temp solution to support keyring in snap without keyring API + emitEvents?: boolean; +}; + +export const CreateAccountOptionsStruct = object({ + scope: scopeStruct, +}); + +export type CreateAccountOptions = Record & + Infer; export class BtcKeyring implements Keyring { protected readonly _stateMgr: KeyringStateManager; protected readonly _options: KeyringOptions; - protected readonly _keyringMethods: string[]; - - protected readonly _handlers: ChainRPCHandlers; + protected readonly _methods = ['btc_sendmany']; constructor(stateMgr: KeyringStateManager, options: KeyringOptions) { this._stateMgr = stateMgr; this._options = options; - const mapping = RpcHelper.getKeyringRpcApiHandlers(); - this._keyringMethods = Object.keys(mapping); - this._handlers = mapping; } async listAccounts(): Promise { try { return await this._stateMgr.listAccounts(); } catch (error) { - throw new BtcKeyringError(error); + throw new Error(error); } } @@ -58,7 +58,7 @@ export class BtcKeyring implements Keyring { try { return (await this._stateMgr.getAccount(id)) ?? undefined; } catch (error) { - throw new BtcKeyringError(error); + throw new Error(error); } } @@ -66,16 +66,16 @@ export class BtcKeyring implements Keyring { try { assert(options, CreateAccountOptionsStruct); - const wallet: IWallet = Factory.createWallet(options.scope); + const wallet = this.getBtcWallet(options.scope); // TODO: Create account with index 0 for now for phase 1 scope, update to use increment index later - const index = Config.wallet[Config.chain].defaultAccountIndex; - const account = await wallet.unlock( - index, - Config.wallet[Config.chain].defaultAccountType, - ); + const index = this._options.defaultIndex; + const type = Config.wallet.defaultAccountType; + + const account = await this.discoverAccount(wallet, index, type); logger.info( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `[BtcKeyring.createAccount] Account unlocked: ${account.address}`, ); @@ -108,15 +108,15 @@ export class BtcKeyring implements Keyring { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[BtcKeyring.createAccount] Error: ${error.message}`); if (error instanceof StructError) { - throw new BtcKeyringError('Invalid params to create an account'); + throw new Error('Invalid params to create an account'); } - throw new BtcKeyringError(error); + throw new Error(error); } } // eslint-disable-next-line @typescript-eslint/no-unused-vars async filterAccountChains(id: string, chains: string[]): Promise { - throw new BtcKeyringError('Method not implemented.'); + throw new Error('Method not implemented.'); } async updateAccount(account: KeyringAccount): Promise { @@ -128,7 +128,7 @@ export class BtcKeyring implements Keyring { } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[BtcKeyring.updateAccount] Error: ${error.message}`); - throw new BtcKeyringError(error); + throw new Error(error); } } @@ -141,17 +141,11 @@ export class BtcKeyring implements Keyring { } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[BtcKeyring.deleteAccount] Error: ${error.message}`); - throw new BtcKeyringError(error); + throw new Error(error); } } async submitRequest(request: KeyringRequest): Promise { - return this.syncSubmitRequest(request); - } - - protected async syncSubmitRequest( - request: KeyringRequest, - ): Promise { return { pending: false, result: await this.handleSubmitRequest(request), @@ -159,14 +153,10 @@ export class BtcKeyring implements Keyring { } protected async handleSubmitRequest(request: KeyringRequest): Promise { - const { scope, account } = request; + const { scope, account: id } = request; const { method, params } = request.request; - if (!Object.prototype.hasOwnProperty.call(this._handlers, method)) { - throw new MethodNotFoundError() as unknown as Error; - } - - const walletData = await this.getAndVerifyWallet(account); + const walletData = await this.getWalletData(id); if (walletData.scope !== scope) { throw new Error( @@ -174,10 +164,24 @@ export class BtcKeyring implements Keyring { ); } - return this._handlers[method].getInstance(walletData).execute({ - ...params, - scope, - } as unknown as SnapRpcHandlerRequest); + const wallet = this.getBtcWallet(walletData.scope); + const account = await this.discoverAccount( + wallet, + walletData.index, + walletData.account.type, + ); + + this.verifyIfAccountValid(account, walletData.account); + + switch (method) { + case 'btc_sendmany': + return (await sendMany(account, { + ...params, + scope: walletData.scope, + } as unknown as SendManyParams)) as unknown as Json; + default: + throw new MethodNotFoundError(method) as unknown as Error; + } } async #emitEvent( @@ -186,44 +190,37 @@ export class BtcKeyring implements Keyring { ): Promise { // TODO: Remove temp solution to support keyring in snap without extentions support if (this._options.emitEvents) { - await emitSnapKeyringEvent(SnapHelper.provider, event, data); + await emitSnapKeyringEvent(getProvider(), event, data); } } - protected newKeyringAccount( - account: IAccount, - options?: CreateAccountOptions, - ): KeyringAccount { - return { - type: account.type, - id: uuidv4(), - address: account.address, - options: { - ...options, - }, - methods: this._keyringMethods, - } as unknown as KeyringAccount; - } - async getAccountBalances( id: string, assets: CaipAssetType[], ): Promise> { try { - const walletData = await this.getAndVerifyWallet(id); + const walletData = await this.getWalletData(id); + const wallet = this.getBtcWallet(walletData.scope); + const account = await this.discoverAccount( + wallet, + walletData.index, + walletData.account.type, + ); - return (await GetBalancesHandler.getInstance(walletData).execute({ - scope: walletData.scope, + this.verifyIfAccountValid(account, walletData.account); + + return await getBalances(account, { assets, - })) as unknown as Record; + scope: walletData.scope, + }); } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[BtcKeyring.getAccountBalances] Error: ${error.message}`); - throw new BtcKeyringError(error); + throw new Error(error); } } - protected async getAndVerifyWallet(id: string) { + protected async getWalletData(id: string): Promise { const walletData = await this._stateMgr.getWallet(id); if (!walletData) { @@ -232,4 +229,40 @@ export class BtcKeyring implements Keyring { return walletData; } + + protected getBtcWallet(scope: string): IWallet { + return Factory.createWallet(scope); + } + + protected async discoverAccount( + wallet: IWallet, + index: number, + type: string, + ): Promise { + return await wallet.unlock(index, type); + } + + protected verifyIfAccountValid( + account: IAccount, + keyringAccount: KeyringAccount, + ): void { + if (!account || account.address !== keyringAccount.address) { + throw new Error('Account not found'); + } + } + + protected newKeyringAccount( + account: IAccount, + options?: CreateAccountOptions, + ): KeyringAccount { + return { + type: account.type, + id: uuidv4(), + address: account.address, + options: { + ...options, + }, + methods: this._methods, + } as unknown as KeyringAccount; + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts deleted file mode 100644 index 27acf74c..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { CustomError } from '../libs/exception'; - -export class BtcKeyringError extends CustomError {} - -export class BtcKeyringStateError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/index.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/index.ts deleted file mode 100644 index 53d3dc06..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './keyring'; -export * from './state'; -export * from './exceptions'; -export * from './types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts deleted file mode 100644 index ed9e02c1..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; -import { type Json } from '@metamask/snaps-sdk'; -import type { Infer } from 'superstruct'; -import { object } from 'superstruct'; - -import type { IStaticSnapRpcHandler } from '../libs/rpc'; -import { scopeStruct } from '../utils'; - -export type Wallet = { - account: KeyringAccount; - hdPath: string; - index: number; - scope: string; -}; - -export type Wallets = Record; - -export type SnapState = { - walletIds: string[]; - wallets: Wallets; -}; - -export type KeyringOptions = Record & { - defaultIndex: number; - multiAccount?: boolean; - // TODO: Remove temp solution to support keyring in snap without keyring API - emitEvents?: boolean; -}; - -export type ChainRPCHandlers = Record; - -export const CreateAccountOptionsStruct = object({ - scope: scopeStruct, -}); - -export type CreateAccountOptions = Record & - Infer; diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/exception/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/libs/exception/exceptions.ts deleted file mode 100644 index 438d3c89..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/exception/exceptions.ts +++ /dev/null @@ -1,23 +0,0 @@ -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/libs/exception/index.ts b/merged-packages/bitcoin-wallet-snap/src/libs/exception/index.ts deleted file mode 100644 index 28386125..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/exception/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './exceptions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts deleted file mode 100644 index 22f57026..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { InvalidParamsError } from '@metamask/snaps-sdk'; -import { type Struct, assert } from 'superstruct'; - -import { logger } from '../logger/logger'; -import { InvalidSnapRpcResponseError } from './exceptions'; -import { - type SnapRpcHandlerOptions, - type ISnapRpcHandler, - type IStaticSnapRpcHandler, - type SnapRpcHandlerResponse, - type SnapRpcHandlerRequest, - SnapRpcHandlerRequestStruct, -} from './types'; - -export abstract class BaseSnapRpcHandler { - static instance: ISnapRpcHandler | null = null; - - static readonly requestStruct: Struct = SnapRpcHandlerRequestStruct; - - static readonly responseStruct?: Struct; - - protected isThrowValidationError = false; - - abstract handleRequest( - params: SnapRpcHandlerRequest, - ): Promise; - - protected get _requestStruct(): Struct { - return (this.constructor as typeof BaseSnapRpcHandler).requestStruct; - } - - protected get _responseStruct(): Struct | undefined { - return (this.constructor as typeof BaseSnapRpcHandler).responseStruct; - } - - protected async preExecute(params: SnapRpcHandlerRequest): Promise { - logger.info( - `[SnapRpcHandler.preExecute] Request: ${JSON.stringify(params)}`, - ); - try { - assert(params, this._requestStruct); - } catch (error) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.info(`[SnapRpcHandler.preExecute] Error: ${error.message}`); - this.throwValidationError(error.message); - } - } - - protected async postExecute(response: SnapRpcHandlerResponse): Promise { - logger.info( - `[SnapRpcHandler.postExecute] Response: ${JSON.stringify(response)}`, - ); - - try { - if (this._responseStruct) { - assert(response, this._responseStruct); - } - } catch (error) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.info(`[SnapRpcHandler.postExecute] Error: ${error.message}`); - throw new InvalidSnapRpcResponseError('Invalid Response'); - } - } - - async execute( - params: SnapRpcHandlerRequest, - ): Promise { - await this.preExecute(params); - const result = await this.handleRequest(params); - await this.postExecute(result); - return result; - } - - static getInstance( - this: IStaticSnapRpcHandler, - options?: SnapRpcHandlerOptions, - ): ISnapRpcHandler { - return new this(options); - } - - protected throwValidationError(message: string): void { - throw new InvalidParamsError( - this.isThrowValidationError ? message : undefined, - ) as unknown as Error; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/exceptions.ts deleted file mode 100644 index 50b6a304..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/exceptions.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { CustomError } from '../exception'; - -export class SnapRpcError extends CustomError {} -export class InvalidSnapRpcResponseError extends SnapRpcError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/index.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/index.ts deleted file mode 100644 index 0f4d2283..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './types'; -export * from './base'; -export * from './exceptions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/types.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/types.ts deleted file mode 100644 index a326f49e..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { Json } from '@metamask/snaps-sdk'; -import { object, type Struct, type Infer } from 'superstruct'; - -import { scopeStruct } from '../../utils'; - -export const SnapRpcHandlerRequestStruct = object({ - scope: scopeStruct, -}); - -export type SnapRpcHandlerRequest = Json & - Infer; - -export type SnapRpcHandlerResponse = Json; - -export type SnapRpcHandlerOptions = Record | null; - -export type IStaticSnapRpcHandler = { - /** - * Superstruct for the request. - */ - requestStruct: Struct; - /** - * Superstruct for the response. - */ - reponseStruct?: Struct; - - /** - * A method to create a new instance of the rpc handler. - * - * @param options - An optional parameter to create the instance. - * @returns An handler object. - */ - new (options?: SnapRpcHandlerOptions): ISnapRpcHandler; - - /** - * A method to return the instance object of the rpc handler. - * - * @param this - The static instance of the handler class. - * @param options - An optional parameter to create the instance. - * @returns An handler object. - */ - getInstance( - this: IStaticSnapRpcHandler, - options?: SnapRpcHandlerOptions, - ): ISnapRpcHandler; -}; - -export type ISnapRpcHandler = { - /** - * A method to execute the rpc method. - * - * @param params - An struct contains the require parameter for the request. - * @returns A promise that resolves to an json. - */ - execute(params: SnapRpcHandlerRequest): Promise; -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/__mocks__/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/__mocks__/helpers.ts deleted file mode 100644 index b8b5abff..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/__mocks__/helpers.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { type SLIP10NodeInterface } from '@metamask/key-tree'; -import { networks } from 'bitcoinjs-lib'; - -import { createRandomBip32Data } from '../../../../test/utils'; - -export class SnapHelper { - static getBip44Deriver = jest.fn(); - - static async getBip32Deriver( - path: string[], - curve: 'secp256k1' | 'ed25519', - ): Promise { - const { data } = createRandomBip32Data(networks.bitcoin, path, curve); - return { - ...data, - toJSON: jest.fn().mockReturnValue(data), - } as SLIP10NodeInterface; - } - - static confirmDialog = jest.fn(); - - static getStateData = jest.fn(); - - static setStateData = jest.fn(); -} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/exceptions.ts deleted file mode 100644 index 5999cf38..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/exceptions.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { CustomError } from '../exception'; - -export class StateError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts deleted file mode 100644 index 430d1c04..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { expect } from '@jest/globals'; -import type { Component } from '@metamask/snaps-sdk'; -import { heading, panel, text, divider, row } 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.provider, '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.provider, '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.provider, 'request'); - const components: Component[] = [ - heading('header'), - text('subHeader'), - divider(), - row('Label1', text('Value1')), - text('**Label2**:'), - row('SubLabel1', text('SubValue1')), - ]; - - await SnapHelper.confirmDialog(components); - - expect(spy).toHaveBeenCalledWith({ - method: 'snap_dialog', - params: { - type: 'confirmation', - content: panel(components), - }, - }); - }); - }); - - describe('getStateData', () => { - it('gets state data', async () => { - const spy = jest.spyOn(SnapHelper.provider, 'request'); - const testcase = { - state: { - transaction: [ - { - txHash: '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.provider, 'request'); - const testcase = { - state: { - transaction: [ - { - txHash: '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/libs/snap/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.ts deleted file mode 100644 index e22c3bad..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - getBIP44AddressKeyDeriver, - type BIP44AddressKeyDeriver, - type SLIP10NodeInterface, -} from '@metamask/key-tree'; -import type { Component, DialogResult, Json } from '@metamask/snaps-sdk'; -import { panel, type SnapsProvider } from '@metamask/snaps-sdk'; - -declare const snap: SnapsProvider; - -export class SnapHelper { - static provider: SnapsProvider = snap; - - static async getBip44Deriver( - coinType: number, - ): Promise { - const bip44Node = await SnapHelper.provider.request({ - method: 'snap_getBip44Entropy', - params: { - coinType, - }, - }); - return getBIP44AddressKeyDeriver(bip44Node); - } - - static async getBip32Deriver( - path: string[], - curve: 'secp256k1' | 'ed25519', - ): Promise { - const node = await SnapHelper.provider.request({ - method: 'snap_getBip32Entropy', - params: { - path, - curve, - }, - }); - return node as SLIP10NodeInterface; - } - - static async confirmDialog(components: Component[]): Promise { - return SnapHelper.provider.request({ - method: 'snap_dialog', - params: { - type: 'confirmation', - content: panel(components), - }, - }); - } - - static async getStateData(): Promise { - return (await SnapHelper.provider.request({ - method: 'snap_manageState', - params: { - operation: 'get', - }, - })) as unknown as State; - } - - static async setStateData(data: State) { - await SnapHelper.provider.request({ - method: 'snap_manageState', - params: { - operation: 'update', - newState: data as unknown as Record, - }, - }); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/index.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/index.ts deleted file mode 100644 index 6e76333f..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './helpers'; -export * from './state'; -export * from './lock'; -export * from './exceptions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.test.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.test.ts deleted file mode 100644 index 66e667ed..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -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/libs/snap/lock.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.ts deleted file mode 100644 index b4b0e4fb..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.ts +++ /dev/null @@ -1,12 +0,0 @@ -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/config/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/permissions.ts similarity index 84% rename from merged-packages/bitcoin-wallet-snap/src/config/permissions.ts rename to merged-packages/bitcoin-wallet-snap/src/permissions.ts index c84b4144..e8af8a24 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/permissions.ts @@ -1,5 +1,10 @@ import { KeyringRpcMethod } from '@metamask/keyring-api'; +export enum InternalRpcMethod { + GetTransactionStatus = 'chain_getTransactionStatus', + CreateAccount = 'chain_createAccount', +} + const dappPermissions = new Set([ // Keyring methods KeyringRpcMethod.ListAccounts, @@ -14,7 +19,7 @@ const dappPermissions = new Set([ KeyringRpcMethod.RejectRequest, KeyringRpcMethod.SubmitRequest, // Chain API methods - 'chain_getTransactionStatus', + InternalRpcMethod.GetTransactionStatus, ]); const metamaskPermissions = new Set([ @@ -32,11 +37,10 @@ const metamaskPermissions = new Set([ KeyringRpcMethod.SubmitRequest, KeyringRpcMethod.GetAccountBalances, // Chain API methods - 'chain_getTransactionStatus', + InternalRpcMethod.GetTransactionStatus, ]); const allowedOrigins = [ - 'https://metamask.github.io', 'https://portfolio.metamask.io', 'https://portfolio-builds.metafi-dev.codefi.network', 'https://dev.portfolio.metamask.io', @@ -53,5 +57,5 @@ for (const origin of allowedOrigins) { originPermissions.set(metamask, metamaskPermissions); originPermissions.set( local, - new Set([...dappPermissions, 'chain_createAccount']), + new Set([...dappPermissions, InternalRpcMethod.CreateAccount]), ); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts index 166de9bb..b8b1ce06 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts @@ -1,43 +1,36 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import type { Infer } from 'superstruct'; +import { object, type Infer } from 'superstruct'; import { Config } from '../config'; -import { BtcKeyring, KeyringStateManager } from '../keyring'; -import { SnapRpcHandlerRequestStruct, BaseSnapRpcHandler } from '../libs/rpc'; -import type { - IStaticSnapRpcHandler, - SnapRpcHandlerResponse, -} from '../libs/rpc'; -import type { StaticImplements } from '../types/static'; +import { BtcKeyring } from '../keyring'; +import { KeyringStateManager } from '../stateManagement'; +import { scopeStruct } from '../utils'; -export type CreateAccountParams = Infer< - typeof CreateAccountHandler.requestStruct ->; +export const CreateAccountParamsStruct = object({ + scope: scopeStruct, +}); -export type CreateAccountResponse = SnapRpcHandlerResponse & KeyringAccount; +export type CreateAccountParams = Infer; -export class CreateAccountHandler - extends BaseSnapRpcHandler - implements - StaticImplements -{ - static override get requestStruct() { - return SnapRpcHandlerRequestStruct; - } +export type CreateAccountResponse = KeyringAccount; - async handleRequest( - params: CreateAccountParams, - ): Promise { - const keyring = new BtcKeyring(new KeyringStateManager(), { - defaultIndex: Config.wallet[Config.chain].defaultAccountIndex, - multiAccount: Config.wallet[Config.chain].enableMultiAccounts, - emitEvents: false, - }); +/** + * Creates a new account with the specified parameters. + * + * @param params - The parameters for creating the account. + * @returns A Promise that resolves to the new account. + */ +export async function createAccount( + params: CreateAccountParams, +): Promise { + const keyring = new BtcKeyring(new KeyringStateManager(), { + defaultIndex: Config.wallet.defaultAccountIndex, + emitEvents: false, + }); - const account = await keyring.createAccount({ - scope: params.scope, - }); + const account = await keyring.createAccount({ + scope: params.scope, + }); - return account; - } + return account; } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts index 7786d8fd..3001f5b2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts @@ -4,186 +4,186 @@ import { networks } from 'bitcoinjs-lib'; import { v4 as uuidv4 } from 'uuid'; import { generateAccounts } from '../../test/utils'; -import { Network, ScriptType } from '../bitcoin/constants'; -import { - BtcAccountBip32Deriver, - BtcWallet, - BtcAmount, -} from '../bitcoin/wallet'; +import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; import { Config } from '../config'; +import { Caip2ChainId } from '../constants'; import { Factory } from '../factory'; -import { GetBalancesHandler } from './get-balances'; - -jest.mock('../libs/logger/logger'); -jest.mock('../libs/snap/helpers'); - -describe('GetBalancesHandler', () => { - const asset = Config.avaliableAssets[Config.chain][0]; - - describe('handleRequest', () => { - const createMockChainApiFactory = () => { - const getBalancesSpy = jest.fn(); - - jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ - getFeeRates: jest.fn(), - getBalances: getBalancesSpy, - broadcastTransaction: jest.fn(), - listTransactions: jest.fn(), - getTransactionStatus: jest.fn(), - getDataForTransaction: jest.fn(), - }); - return { - getBalancesSpy, - }; - }; +import { getBalances } from './get-balances'; + +jest.mock('../utils/logger'); +jest.mock('../utils/snap'); - const createMockDeriver = (network) => { - const rootSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getRoot'); - const childSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getChild'); +describe('getBalances', () => { + const asset = Config.avaliableAssets[0]; - return { - instance: new BtcAccountBip32Deriver(network), - rootSpy, - childSpy, - }; + const createMockChainApiFactory = () => { + const getBalancesSpy = jest.fn(); + + jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ + getFeeRates: jest.fn(), + getBalances: getBalancesSpy, + broadcastTransaction: jest.fn(), + listTransactions: jest.fn(), + getTransactionStatus: jest.fn(), + getDataForTransaction: jest.fn(), + }); + return { + getBalancesSpy, }; + }; - const createMockAccount = async (network, caip2ChainId) => { - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const sender = await wallet.unlock(0, ScriptType.P2wpkh); - const keyringAccount = { - type: sender.type, - id: uuidv4(), - address: sender.address, - options: { - scope: caip2ChainId, - index: sender.index, - }, - methods: ['btc_sendmany'], - }; - - const walletData = { - account: keyringAccount as unknown as KeyringAccount, - hdPath: sender.hdPath, - index: sender.index, + const createMockDeriver = (network) => { + const rootSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getRoot'); + const childSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getChild'); + + return { + instance: new BtcAccountDeriver(network), + rootSpy, + childSpy, + }; + }; + + const createMockAccount = async (network, caip2ChainId) => { + const { instance } = createMockDeriver(network); + const wallet = new BtcWallet(instance, network); + const sender = await wallet.unlock(0, Config.wallet.defaultAccountType); + const keyringAccount = { + type: sender.type, + id: uuidv4(), + address: sender.address, + options: { scope: caip2ChainId, - }; + index: sender.index, + }, + methods: ['btc_sendmany'], + }; - return { - keyringAccount, - walletData, - sender, - }; + const walletData = { + account: keyringAccount as unknown as KeyringAccount, + hdPath: sender.hdPath, + index: sender.index, + scope: caip2ChainId, }; - it('gets balances', async () => { - const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); - - const { walletData } = await createMockAccount(network, caip2ChainId); - - const addresses = [walletData.account.address]; - const mockResp = { - balances: addresses.reduce((acc, address) => { - acc[address] = { - [asset]: { - amount: new BtcAmount(100), - }, - }; - return acc; - }, {}), - }; - - const expected = { - [asset]: { - amount: '0.00000100', - unit: Config.unit[Config.chain], - }, - }; - - getBalancesSpy.mockResolvedValue(mockResp); - - const result = await GetBalancesHandler.getInstance(walletData).execute({ - scope: walletData.scope, - assets: [asset], - }); + return { + keyringAccount, + walletData, + sender, + }; + }; + + it('gets balances', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { getBalancesSpy } = createMockChainApiFactory(); + + const { walletData, sender } = await createMockAccount( + network, + caip2ChainId, + ); + + const addresses = [walletData.account.address]; + const mockResp = { + balances: addresses.reduce((acc, address) => { + acc[address] = { + [asset]: { + amount: BigInt(100), + }, + }; + return acc; + }, {}), + }; - expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [asset]); - expect(result).toStrictEqual(expected); - }); + const expected = { + [asset]: { + amount: '0.00000100', + unit: Config.unit, + }, + }; - it('gets balances of the request account only', async () => { - const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); - const accounts = generateAccounts(10); - const { walletData } = await createMockAccount(network, caip2ChainId); - - const addresses = [walletData.account.address]; - const mockResp = { - balances: [ - ...addresses, - ...accounts.map((account) => account.address), - ].reduce((acc, address) => { - acc[address] = { - [asset]: { - amount: new BtcAmount(100), - }, - 'some-asset': { - amount: new BtcAmount(100), - }, - }; - return acc; - }, {}), - }; - - const expected = { - [asset]: { - amount: '0.00000100', - unit: Config.unit[Config.chain], - }, - }; - - getBalancesSpy.mockResolvedValue(mockResp); - - const result = await GetBalancesHandler.getInstance(walletData).execute({ - scope: Network.Testnet, - assets: [asset], - }); + getBalancesSpy.mockResolvedValue(mockResp); - expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [asset]); - expect(result).toStrictEqual(expected); + const result = await getBalances(sender, { + scope: walletData.scope, + assets: [asset], }); - it('throws `Fail to get the balances` when transaction status fetch failed', async () => { - const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); - const { walletData } = await createMockAccount(network, caip2ChainId); + expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [asset]); + expect(result).toStrictEqual(expected); + }); - getBalancesSpy.mockRejectedValue(new Error('error')); + it('gets balances of the request account only', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { getBalancesSpy } = createMockChainApiFactory(); + const accounts = generateAccounts(10); + const { walletData, sender } = await createMockAccount( + network, + caip2ChainId, + ); + + const addresses = [walletData.account.address]; + const mockResp = { + balances: [ + ...addresses, + ...accounts.map((account) => account.address), + ].reduce((acc, address) => { + acc[address] = { + [asset]: { + amount: BigInt(100), + }, + 'some-asset': { + amount: BigInt(100), + }, + }; + return acc; + }, {}), + }; - await expect( - GetBalancesHandler.getInstance(walletData).execute({ - scope: Network.Testnet, - assets: [asset], - }), - ).rejects.toThrow(`Fail to get the balances`); - }); + const expected = { + [asset]: { + amount: '0.00000100', + unit: Config.unit, + }, + }; + + getBalancesSpy.mockResolvedValue(mockResp); - it('throws `Request params is invalid` when request parameter is not correct', async () => { - const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { walletData } = await createMockAccount(network, caip2ChainId); - - await expect( - GetBalancesHandler.getInstance(walletData).execute({ - scope: Network.Testnet, - assets: ['some-asset'], - }), - ).rejects.toThrow(InvalidParamsError); + const result = await getBalances(sender, { + scope: Caip2ChainId.Testnet, + assets: [asset], }); + + expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [asset]); + expect(result).toStrictEqual(expected); + }); + + it('throws `Fail to get the balances` when transaction status fetch failed', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { getBalancesSpy } = createMockChainApiFactory(); + const { sender } = await createMockAccount(network, caip2ChainId); + + getBalancesSpy.mockRejectedValue(new Error('error')); + + await expect( + getBalances(sender, { + scope: Caip2ChainId.Testnet, + assets: [asset], + }), + ).rejects.toThrow(`Fail to get the balances`); + }); + + it('throws `Request params is invalid` when request parameter is not correct', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { sender } = await createMockAccount(network, caip2ChainId); + + await expect( + getBalances(sender, { + scope: Caip2ChainId.Testnet, + assets: ['some-asset'], + }), + ).rejects.toThrow(InvalidParamsError); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts index 60c698f7..412a8238 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts @@ -1,99 +1,108 @@ import type { Infer } from 'superstruct'; -import { object, assign, array, record, enums } from 'superstruct'; +import { object, array, record, enums, assert } from 'superstruct'; import { Config } from '../config'; import { Factory } from '../factory'; -import { type Wallet as WalletData } from '../keyring'; -import { SnapRpcError, SnapRpcHandlerRequestStruct } from '../libs/rpc'; -import type { - IStaticSnapRpcHandler, - SnapRpcHandlerResponse, -} from '../libs/rpc'; -import type { StaticImplements } from '../types/static'; -import { assetsStruct, positiveStringStruct } from '../utils/superstruct'; -import type { IAmount } from '../wallet'; -import { KeyringRpcHandler } from './keyring-rpc'; - -export type GetBalancesParams = Infer; - -export type GetBalancesResponse = SnapRpcHandlerResponse & - Infer; - -export class GetBalancesHandler - extends KeyringRpcHandler - implements StaticImplements -{ - protected override isThrowValidationError = true; - - static override get requestStruct() { - return assign( - object({ - assets: array(assetsStruct), - }), - SnapRpcHandlerRequestStruct, - ); - } +import { + isSnapRpcError, + validateRequest, + validateResponse, + logger, + satsToBtc, +} from '../utils'; +import { + assetsStruct, + positiveStringStruct, + scopeStruct, +} from '../utils/superstruct'; +import type { IAccount } from '../wallet'; - static override get responseStruct() { - const unit = Config.unit[Config.chain]; - return record( - assetsStruct, - object({ - amount: positiveStringStruct, - unit: enums([unit]), - }), - ); - } +export const getBalancesRequestStruct = object({ + assets: array(assetsStruct), + scope: scopeStruct, +}); - constructor(walletData: WalletData) { - super(); - this.walletData = walletData; - } +export const getBalancesResponseStruct = object({ + assets: record( + assetsStruct, + object({ + amount: positiveStringStruct, + unit: enums([Config.unit]), + }), + ), +}); + +export type GetBalancesParams = Infer; + +export type GetBalancesResponse = Infer; + +/** + * Get Balances by a given account. + * + * @param account - The account to get the balances. + * @param params - The parameters for get the account. + * @returns A Promise that resolves to an GetBalancesResponse object. + */ +export async function getBalances( + account: IAccount, + params: GetBalancesParams, +) { + try { + validateRequest(params, getBalancesRequestStruct); + + assert(params, getBalancesRequestStruct); - async handleRequest(params: GetBalancesParams): Promise { - try { - const { scope, assets } = params; + const { assets, scope } = params; - const chainApi = Factory.createOnChainServiceProvider(scope); - const addresses = [this.walletAccount.address]; - const addressesSet = new Set(addresses); - const assetsSet = new Set(assets); + const chainApi = Factory.createOnChainServiceProvider(scope); + const addresses = [account.address]; + const addressesSet = new Set(addresses); + const assetsSet = new Set(assets); - const balances = await chainApi.getBalances(addresses, assets); + const balances = await chainApi.getBalances(addresses, assets); - const balancesVals = Object.entries(balances.balances); - const balancesMap = new Map(); + const balancesVals = Object.entries(balances.balances); + const balancesMap = new Map(); - for (const [address, assetBalances] of balancesVals) { - if (!addressesSet.has(address)) { + for (const [address, assetBalances] of balancesVals) { + if (!addressesSet.has(address)) { + continue; + } + for (const asset in assetBalances) { + if (!assetsSet.has(asset)) { continue; } - for (const asset in assetBalances) { - if (!assetsSet.has(asset)) { - continue; - } - - const { amount } = assetBalances[asset]; - const currentAmount = balancesMap.get(asset); - if (currentAmount) { - currentAmount.value += amount.value; - } - - balancesMap.set(asset, currentAmount ?? amount); + + const { amount } = assetBalances[asset]; + let currentAmount = balancesMap.get(asset); + if (currentAmount) { + currentAmount += amount; } + + balancesMap.set(asset, currentAmount ?? amount); } + } - return Object.fromEntries( - [...balancesMap.entries()].map(([asset, amount]) => [ - asset, - { - amount: amount.toString(), - unit: amount.unit, - }, - ]), - ); - } catch (error) { - throw new SnapRpcError('Fail to get the balances'); + const resp = Object.fromEntries( + [...balancesMap.entries()].map(([asset, amount]) => [ + asset, + { + amount: satsToBtc(amount), + unit: Config.unit, + }, + ]), + ); + + validateResponse(params, getBalancesRequestStruct); + + return resp; + } catch (error) { + logger.error('Failed to get balances', error); + + if (isSnapRpcError(error)) { + throw error as unknown as Error; } + + throw new Error('Fail to get the balances'); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts index 36017ab5..8271c47c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts @@ -1,72 +1,71 @@ import { InvalidParamsError } from '@metamask/snaps-sdk'; -import { Network } from '../bitcoin/constants'; import { TransactionStatus } from '../chain'; +import { Caip2ChainId } from '../constants'; import { Factory } from '../factory'; -import { GetTransactionStatusHandler } from './get-transaction-status'; +import { getTransactionStatus } from './get-transaction-status'; -jest.mock('../libs/logger/logger'); +jest.mock('../utils/logger'); -describe('GetBalancesHandler', () => { +describe('getTransactionStatus', () => { const txHash = '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; - describe('handleRequest', () => { - const createMockChainApiFactory = () => { - const getTransactionStatusSpy = jest.fn(); + const createMockChainApiFactory = () => { + const getTransactionStatusSpy = jest.fn(); - jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ - getFeeRates: jest.fn(), - getBalances: jest.fn(), - broadcastTransaction: jest.fn(), - listTransactions: jest.fn(), - getTransactionStatus: getTransactionStatusSpy, - getDataForTransaction: jest.fn(), - }); - return { - getTransactionStatusSpy, - }; + jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ + getFeeRates: jest.fn(), + getBalances: jest.fn(), + broadcastTransaction: jest.fn(), + listTransactions: jest.fn(), + getTransactionStatus: getTransactionStatusSpy, + getDataForTransaction: jest.fn(), + }); + return { + getTransactionStatusSpy, }; + }; - it('gets status', async () => { - const { getTransactionStatusSpy } = createMockChainApiFactory(); + it('gets status', async () => { + const { getTransactionStatusSpy } = createMockChainApiFactory(); - const mockResp = { - status: TransactionStatus.Confirmed, - }; + const mockResp = { + status: TransactionStatus.Confirmed, + }; - getTransactionStatusSpy.mockResolvedValue(mockResp); + getTransactionStatusSpy.mockResolvedValue(mockResp); - const result = await GetTransactionStatusHandler.getInstance().execute({ - scope: Network.Testnet, - transactionId: txHash, - }); + const result = await getTransactionStatus({ + scope: Caip2ChainId.Testnet, + transactionId: txHash, + }); - expect(getTransactionStatusSpy).toHaveBeenCalledWith(txHash); - expect(result).toStrictEqual({ - status: TransactionStatus.Confirmed, - }); + expect(getTransactionStatusSpy).toHaveBeenCalledWith(txHash); + expect(result).toStrictEqual({ + status: TransactionStatus.Confirmed, }); + }); - it('throws `Fail to get the transaction status` when transaction status fetch failed', async () => { - const { getTransactionStatusSpy } = createMockChainApiFactory(); + it('throws `Fail to get the transaction status` when transaction status fetch failed', async () => { + const { getTransactionStatusSpy } = createMockChainApiFactory(); - getTransactionStatusSpy.mockRejectedValue(new Error('error')); + getTransactionStatusSpy.mockRejectedValue(new Error('error')); - await expect( - GetTransactionStatusHandler.getInstance().execute({ - scope: Network.Testnet, - transactionId: txHash, - }), - ).rejects.toThrow(`Fail to get the transaction status`); - }); + await expect( + getTransactionStatus({ + scope: Caip2ChainId.Testnet, + transactionId: txHash, + }), + ).rejects.toThrow(`Fail to get the transaction status`); + }); - it('throws `Request params is invalid` when request parameter is not correct', async () => { - await expect( - GetTransactionStatusHandler.getInstance().execute({ - scope: Network.Testnet, - }), - ).rejects.toThrow(InvalidParamsError); - }); + it('throws `Request params is invalid` when request parameter is not correct', async () => { + await expect( + getTransactionStatus({ + scope: 'some-scope', + transactionId: '', + }), + ).rejects.toThrow(InvalidParamsError); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts index 43474a33..5b7e89c8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts @@ -1,61 +1,65 @@ import type { Infer } from 'superstruct'; -import { object, string, assign, enums } from 'superstruct'; +import { object, string, enums } from 'superstruct'; import { TransactionStatus } from '../chain'; import { Factory } from '../factory'; import { - SnapRpcHandlerRequestStruct, - BaseSnapRpcHandler, - SnapRpcError, -} from '../libs/rpc'; -import type { - IStaticSnapRpcHandler, - SnapRpcHandlerResponse, -} from '../libs/rpc'; -import type { StaticImplements } from '../types/static'; + isSnapRpcError, + scopeStruct, + validateRequest, + validateResponse, + logger, +} from '../utils'; + +export const getTransactionStatusParamsRequestStruct = object({ + transactionId: string(), + scope: scopeStruct, +}); + +export const getTransactionStatusParamsResponseStruct = object({ + status: enums(Object.values(TransactionStatus)), +}); export type GetTransactionStatusParams = Infer< - typeof GetTransactionStatusHandler.requestStruct + typeof getTransactionStatusParamsRequestStruct >; -export type GetTransactionStatusResponse = SnapRpcHandlerResponse & - Infer; - -export class GetTransactionStatusHandler - extends BaseSnapRpcHandler - implements - StaticImplements -{ - static override get requestStruct() { - return assign( - object({ - transactionId: string(), - }), - SnapRpcHandlerRequestStruct, - ); - } +export type GetTransactionStatusResponse = Infer< + typeof getTransactionStatusParamsResponseStruct +>; - static override get responseStruct() { - return object({ - status: enums(Object.values(TransactionStatus)), - }); - } +/** + * Get Transaction Status by a given transaction id. + * + * @param params - The parameters for get the transaction status. + * @returns A Promise that resolves to an GetTransactionStatusResponse object. + */ +export async function getTransactionStatus( + params: GetTransactionStatusParams, +): Promise { + try { + validateRequest(params, getTransactionStatusParamsRequestStruct); - async handleRequest( - params: GetTransactionStatusParams, - ): Promise { - try { - const { scope, transactionId } = params; + const { scope, transactionId } = params; - const chainApi = Factory.createOnChainServiceProvider(scope); + const chainApi = Factory.createOnChainServiceProvider(scope); - const resp = await chainApi.getTransactionStatus(transactionId); + const txStatusResp = await chainApi.getTransactionStatus(transactionId); - return { - status: resp.status, - }; - } catch (error) { - throw new SnapRpcError('Fail to get the transaction status'); + const resp = { + status: txStatusResp.status, + }; + + validateResponse(resp, getTransactionStatusParamsResponseStruct); + + return resp; + } catch (error) { + logger.error('Failed to get transaction status', error); + + if (isSnapRpcError(error)) { + throw error as unknown as Error; } + + throw new Error('Fail to get the transaction status'); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts deleted file mode 100644 index 15329e6f..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { CreateAccountHandler } from './create-account'; -import { GetTransactionStatusHandler } from './get-transaction-status'; -import { RpcHelper } from './helpers'; -import { SendManyHandler } from './sendmany'; - -describe('RpcHelper', () => { - describe('getChainRpcApiHandlers', () => { - it('returns handler', () => { - expect(RpcHelper.getChainRpcApiHandlers()).toStrictEqual({ - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_createAccount: CreateAccountHandler, - // // eslint-disable-next-line @typescript-eslint/naming-convention - // chain_getBalances: GetBalancesHandler, - // // eslint-disable-next-line @typescript-eslint/naming-convention - // chain_broadcastTransaction: BroadcastTransactionHandler, - // // eslint-disable-next-line @typescript-eslint/naming-convention - // chain_getDataForTransaction: GetTransactionDataHandler, - // // eslint-disable-next-line @typescript-eslint/naming-convention - // chain_estimateFees: EstimateFeesHandler, - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_getTransactionStatus: GetTransactionStatusHandler, - }); - }); - }); - - describe('getKeyringRpcApiHandlers', () => { - it('returns handler', () => { - expect(RpcHelper.getKeyringRpcApiHandlers()).toStrictEqual({ - // eslint-disable-next-line @typescript-eslint/naming-convention - btc_sendmany: SendManyHandler, - }); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts deleted file mode 100644 index e567e284..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { CreateAccountHandler } from '.'; -import type { IStaticSnapRpcHandler } from '../libs/rpc'; -import { GetTransactionStatusHandler } from './get-transaction-status'; -import { SendManyHandler } from './sendmany'; - -export class RpcHelper { - static getChainRpcApiHandlers(): Record { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_createAccount: CreateAccountHandler, - // // eslint-disable-next-line @typescript-eslint/naming-convention - // chain_getBalances: GetBalancesHandler, - // // eslint-disable-next-line @typescript-eslint/naming-convention - // chain_broadcastTransaction: BroadcastTransactionHandler, - // // eslint-disable-next-line @typescript-eslint/naming-convention - // chain_getDataForTransaction: GetTransactionDataHandler, - // // eslint-disable-next-line @typescript-eslint/naming-convention - // chain_estimateFees: EstimateFeesHandler, - // eslint-disable-next-line @typescript-eslint/naming-convention - chain_getTransactionStatus: GetTransactionStatusHandler, - }; - } - - static getKeyringRpcApiHandlers(): Record { - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - btc_sendmany: SendManyHandler, - }; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts index 53521af2..5dd3f86e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts @@ -1,3 +1,4 @@ export * from './create-account'; export * from './get-balances'; -export * from './helpers'; +export * from './get-transaction-status'; +export * from './sendmany'; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts deleted file mode 100644 index 50d653c6..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Factory } from '../factory'; -import { type Wallet as WalletData } from '../keyring'; -import { BaseSnapRpcHandler } from '../libs/rpc'; -import type { SnapRpcHandlerRequest } from '../libs/rpc'; -import type { IAccount, IWallet } from '../wallet'; - -export abstract class KeyringRpcHandler extends BaseSnapRpcHandler { - walletData: WalletData; - - wallet: IWallet; - - walletAccount: IAccount; - - protected override async preExecute( - params: SnapRpcHandlerRequest, - ): Promise { - await super.preExecute(params); - - const { scope, index, account } = this.walletData; - const wallet = Factory.createWallet(scope); - const unlocked = await wallet.unlock(index, account.type); - - if (!unlocked || unlocked.address !== account.address) { - throw new Error('Account not found'); - } - - this.walletAccount = unlocked; - this.wallet = wallet; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index b8780d01..741fa218 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -1,4 +1,3 @@ -import type { Json } from '@metamask/snaps-sdk'; import { InvalidParamsError, UserRejectedRequestError, @@ -10,26 +9,22 @@ import { generateBlockChairBroadcastTransactionResp, generateBlockChairGetUtxosResp, } from '../../test/utils'; -import { DustLimit, Network, ScriptType } from '../bitcoin/constants'; -import { satsToBtc } from '../bitcoin/utils/unit'; -import type { IBtcAccount } from '../bitcoin/wallet'; -import { - BtcAccountBip32Deriver, - BtcWallet, - BtcAmount, -} from '../bitcoin/wallet'; +import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; import { FeeRatio } from '../chain'; +import { Config } from '../config'; +import { Caip2ChainId } from '../constants'; import { Factory } from '../factory'; -import { SnapHelper } from '../libs/snap'; +import { getExplorerUrl, shortenAddress } from '../utils'; +import * as snapUtils from '../utils/snap'; +import { satsToBtc } from '../utils/unit'; import type { IAccount, ITxInfo } from '../wallet'; -import { SendManyHandler } from './sendmany'; -import type { SendManyParams } from './sendmany'; +import { type SendManyParams, sendMany } from './sendmany'; -jest.mock('../libs/logger/logger'); -jest.mock('../libs/snap/helpers'); +jest.mock('../utils/logger'); +jest.mock('../utils/snap'); describe('SendManyHandler', () => { - describe('handleRequest', () => { + describe('sendMany', () => { const createMockChainApiFactory = () => { const getDataForTransactionSpy = jest.fn(); const getFeeRatesSpy = jest.fn(); @@ -52,7 +47,7 @@ describe('SendManyHandler', () => { const createMockDeriver = (network) => { return { - instance: new BtcAccountBip32Deriver(network), + instance: new BtcAccountDeriver(network), }; }; @@ -63,7 +58,7 @@ describe('SendManyHandler', () => { ) => { const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); - const sender = await wallet.unlock(0, ScriptType.P2wpkh); + const sender = await wallet.unlock(0, Config.wallet.defaultAccountType); const keyringAccount = { type: sender.type, @@ -77,7 +72,9 @@ describe('SendManyHandler', () => { }; const recipients: IAccount[] = []; for (let i = 1; i < recipientCnt + 1; i++) { - recipients.push(await wallet.unlock(i, ScriptType.P2wpkh)); + recipients.push( + await wallet.unlock(i, Config.wallet.defaultAccountType), + ); } return { @@ -94,10 +91,8 @@ describe('SendManyHandler', () => { comment = '', ): SendManyParams => { return { - amounts: recipients.reduce((acc, recipient: IBtcAccount) => { - acc[recipient.address] = satsToBtc( - DustLimit[recipient.scriptType] + 1, - ); + amounts: recipients.reduce((acc, recipient) => { + acc[recipient.address] = satsToBtc(500); return acc; }, {}), comment, @@ -136,7 +131,7 @@ describe('SendManyHandler', () => { getFeeRatesSpy, broadcastTransactionSpy, } = createMockChainApiFactory(); - const snapHelperSpy = jest.spyOn(SnapHelper, 'confirmDialog'); + const snapHelperSpy = jest.spyOn(snapUtils, 'confirmDialog'); const { sender, keyringAccount, recipients } = await createSenderNRecipients(network, caip2ChainId, 2); @@ -152,7 +147,7 @@ describe('SendManyHandler', () => { fees: [ { type: FeeRatio.Fast, - rate: new BtcAmount(1), + rate: BigInt(1), }, ], }); @@ -175,9 +170,9 @@ describe('SendManyHandler', () => { it('returns correct result', async () => { const network = networks.testnet; - const caip2ChainId = Network.Testnet; + const caip2ChainId = Caip2ChainId.Testnet; const { - keyringAccount, + sender, recipients, broadcastResp, getDataForTransactionSpy, @@ -185,11 +180,10 @@ describe('SendManyHandler', () => { broadcastTransactionSpy, } = await prepareSendMany(network, caip2ChainId); - const result = await SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2ChainId, false)); + const result = await sendMany( + sender, + createSendManyParams(recipients, caip2ChainId, false), + ); expect(result).toStrictEqual({ txId: broadcastResp }); expect(getFeeRatesSpy).toHaveBeenCalledTimes(1); @@ -199,30 +193,28 @@ describe('SendManyHandler', () => { it('does not broadcast transaction if in dryrun mode', async () => { const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { keyringAccount, recipients, broadcastTransactionSpy } = + const caip2ChainId = Caip2ChainId.Testnet; + const { recipients, sender, broadcastTransactionSpy } = await prepareSendMany(network, caip2ChainId); - await SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2ChainId, true)); + await sendMany( + sender, + createSendManyParams(recipients, caip2ChainId, true), + ); expect(broadcastTransactionSpy).toHaveBeenCalledTimes(0); }); it('does create comment component in dialog if consumer has provide the comment', async () => { const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { keyringAccount, recipients, snapHelperSpy } = - await prepareSendMany(network, caip2ChainId); + const caip2ChainId = Caip2ChainId.Testnet; + const { sender, recipients, snapHelperSpy } = await prepareSendMany( + network, + caip2ChainId, + ); - await SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute( + await sendMany( + sender, createSendManyParams(recipients, caip2ChainId, true, 'test comment'), ); @@ -254,9 +246,11 @@ describe('SendManyHandler', () => { it('display `Recipient` as label in dialog if there is only 1 recipient', async () => { const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { keyringAccount, recipients, snapHelperSpy, sender } = - await prepareSendMany(network, caip2ChainId); + const caip2ChainId = Caip2ChainId.Testnet; + const { recipients, snapHelperSpy, sender } = await prepareSendMany( + network, + caip2ChainId, + ); const walletCreateTxSpy = jest.spyOn( BtcWallet.prototype, 'createTransaction', @@ -267,22 +261,16 @@ describe('SendManyHandler', () => { ); const info: ITxInfo = { - toJson>() { - return { - feeRate: `0.00000001 BTC`, - txFee: `0.00000001 BTC`, - sender: sender.address, - recipients: [ - { - address: recipients[0].address, - value: `0.000010 BTC`, - explorerUrl: `https://blockchair.com/bitcoin/transaction/transactionId`, - }, - ], - changes: [], - total: `0.000010 BTC`, - } as unknown as TxInfoJson; - }, + feeRate: BigInt('1'), + txFee: BigInt('1'), + sender: sender.address, + recipients: [ + { + address: recipients[0].address, + value: BigInt('1000'), + }, + ], + total: BigInt('1000'), }; walletCreateTxSpy.mockResolvedValue({ @@ -292,11 +280,10 @@ describe('SendManyHandler', () => { walletSignTxSpy.mockResolvedValue('txId'); - await SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute(createSendManyParams([recipients[0]], caip2ChainId, true)); + await sendMany( + sender, + createSendManyParams([recipients[0]], caip2ChainId, true), + ); const calls = snapHelperSpy.mock.calls[0][0]; @@ -310,69 +297,103 @@ describe('SendManyHandler', () => { label: 'Recipient', value: { type: 'text', - value: `[${recipients[0].address}](https://blockchair.com/bitcoin/transaction/transactionId)`, + value: `[${shortenAddress( + recipients[0].address, + )}](${getExplorerUrl(recipients[0].address, caip2ChainId)})`, }, }, { type: 'row', label: 'Amount', - value: { markdown: false, type: 'text', value: '0.000010 BTC' }, + value: { markdown: false, type: 'text', value: '0.00001000 BTC' }, }, ], }); }); - it('throws `Request params is invalid` error when request parameter is not correct', async () => { - createMockChainApiFactory(); + it('throws InvalidParamsError when request parameter is not correct', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { sender } = await prepareSendMany(network, caip2ChainId); await expect( - SendManyHandler.getInstance().execute({ - scope: Network.Testnet, - }), + sendMany(sender, { + amounts: { + 'some-address': '1', + }, + } as unknown as SendManyParams), ).rejects.toThrow(InvalidParamsError); }); - it('throws `Account not found` error when given address not match', async () => { + it('throws `Transaction must have at least one recipient` error if no recipient provided', async () => { createMockChainApiFactory(); const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { keyringAccount, recipients } = await createSenderNRecipients( + const caip2ChainId = Caip2ChainId.Testnet; + const { recipients, sender } = await createSenderNRecipients( network, caip2ChainId, - 2, + 0, ); await expect( - SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 20, - account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2ChainId, false)), - ).rejects.toThrow('Account not found'); + sendMany(sender, createSendManyParams(recipients, caip2ChainId, false)), + ).rejects.toThrow('Transaction must have at least one recipient'); }); - it('throws `Transaction must have at least one recipient` error if no recipient provided', async () => { + it('throws `Invalid amount for send` error if receive amount is not valid', async () => { createMockChainApiFactory(); const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { keyringAccount, recipients } = await createSenderNRecipients( + const caip2ChainId = Caip2ChainId.Testnet; + const { recipients, sender } = await createSenderNRecipients( network, caip2ChainId, - 0, + 2, ); await expect( - SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2ChainId, false)), - ).rejects.toThrow('Transaction must have at least one recipient'); + sendMany(sender, { + ...createSendManyParams(recipients, caip2ChainId, false), + amounts: { + [recipients[0].address]: 'invalid', + [recipients[1].address]: '0.1', + }, + }), + ).rejects.toThrow('Invalid amount for send'); + + await expect( + sendMany(sender, { + ...createSendManyParams(recipients, caip2ChainId, false), + amounts: { + [recipients[0].address]: '0', + [recipients[1].address]: '0.1', + }, + }), + ).rejects.toThrow('Invalid amount for send'); + + await expect( + sendMany(sender, { + ...createSendManyParams(recipients, caip2ChainId, false), + amounts: { + [recipients[0].address]: 'invalid', + [recipients[1].address]: '0.000000019', + }, + }), + ).rejects.toThrow('Invalid amount for send'); + + await expect( + sendMany(sender, { + ...createSendManyParams(recipients, caip2ChainId, false), + amounts: { + [recipients[0].address]: '1', + [recipients[1].address]: '999999999.99999999', + }, + }), + ).rejects.toThrow('Invalid amount for send'); }); it('throws `Failed to send the transaction` error if no fee rate returns from chain service', async () => { const { getFeeRatesSpy } = createMockChainApiFactory(); const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { keyringAccount, recipients } = await createSenderNRecipients( + const caip2ChainId = Caip2ChainId.Testnet; + const { sender, recipients } = await createSenderNRecipients( network, caip2ChainId, 10, @@ -382,43 +403,14 @@ describe('SendManyHandler', () => { }); await expect( - SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2ChainId, false)), + sendMany(sender, createSendManyParams(recipients, caip2ChainId, false)), ).rejects.toThrow('Failed to send the transaction'); }); - it('throws `Invalid amount for send` error if sending amount is <= 0', async () => { - const network = networks.testnet; - const caip2ChainId = Network.Testnet; - createMockChainApiFactory(); - const { keyringAccount, recipients } = await createSenderNRecipients( - network, - caip2ChainId, - 2, - ); - - await expect( - SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute({ - ...createSendManyParams(recipients, caip2ChainId, false), - amounts: { - [recipients[0].address]: satsToBtc(500), - [recipients[1].address]: satsToBtc(0), - }, - }), - ).rejects.toThrow('Invalid amount for send'); - }); - it('throws `Invalid response` error if the response is unexpected', async () => { const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { keyringAccount, recipients, broadcastTransactionSpy } = + const caip2ChainId = Caip2ChainId.Testnet; + const { sender, recipients, broadcastTransactionSpy } = await prepareSendMany(network, caip2ChainId); broadcastTransactionSpy.mockResolvedValue({ @@ -427,43 +419,39 @@ describe('SendManyHandler', () => { }, }); await expect( - SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2ChainId, false)), + sendMany(sender, { + ...createSendManyParams(recipients, caip2ChainId, false), + }), ).rejects.toThrow('Invalid Response'); }); it('throws UserRejectedRequestError error if user denied the transaction', async () => { const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { snapHelperSpy, keyringAccount, recipients } = - await prepareSendMany(network, caip2ChainId); + const caip2ChainId = Caip2ChainId.Testnet; + const { snapHelperSpy, sender, recipients } = await prepareSendMany( + network, + caip2ChainId, + ); snapHelperSpy.mockResolvedValue(false); await expect( - SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2ChainId, false)), + sendMany(sender, { + ...createSendManyParams(recipients, caip2ChainId, false), + }), ).rejects.toThrow(UserRejectedRequestError); }); it('throws `Failed to send the transaction` error if the transaction is fail to commit', async () => { const network = networks.testnet; - const caip2ChainId = Network.Testnet; - const { broadcastTransactionSpy, keyringAccount, recipients } = + const caip2ChainId = Caip2ChainId.Testnet; + const { broadcastTransactionSpy, sender, recipients } = await prepareSendMany(network, caip2ChainId); broadcastTransactionSpy.mockRejectedValue(new Error('error')); await expect( - SendManyHandler.getInstance({ - scope: caip2ChainId, - index: 0, - account: keyringAccount, - }).execute(createSendManyParams(recipients, caip2ChainId, false)), + sendMany(sender, { + ...createSendManyParams(recipients, caip2ChainId, false), + }), ).rejects.toThrow('Failed to send the transaction'); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index ba68876b..5cc3ca58 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -11,7 +11,6 @@ import { import { object, string, - assign, type Infer, record, array, @@ -20,21 +19,21 @@ import { optional, } from 'superstruct'; -import { btcToSats } from '../bitcoin/utils'; import { TxValidationError } from '../bitcoin/wallet'; import { Factory } from '../factory'; -import { type Wallet as WalletData } from '../keyring'; -import { logger } from '../libs/logger/logger'; -import { SnapRpcHandlerRequestStruct } from '../libs/rpc'; -import type { IStaticSnapRpcHandler } from '../libs/rpc'; -import { SnapHelper } from '../libs/snap'; -import type { StaticImplements } from '../types/static'; -import type { ITxInfo } from '../wallet'; -import { KeyringRpcHandler } from './keyring-rpc'; - -export type SendManyParams = Infer; - -export type SendManyResponse = Infer; +import { + scopeStruct, + confirmDialog, + isSnapRpcError, + shortenAddress, + getExplorerUrl, + btcToSats, + satsToBtc, + validateRequest, + validateResponse, + logger, +} from '../utils'; +import type { IAccount, ITxInfo } from '../wallet'; export const TransactionAmountStuct = refine( record(BtcP2wpkhAddressStruct, string()), @@ -53,201 +52,206 @@ export const TransactionAmountStuct = refine( ) { return 'Invalid amount for send'; } + + try { + btcToSats(val); + } catch (error) { + return 'Invalid amount for send'; + } } return true; }, ); -export type TxJson = { - feeRate: string; - txFee: string; - total: string; - sender: string; - recipients: { - address: string; - explorerUrl: string; - value: string; - }[]; - changes: { - address: string; - value: string; - explorerUrl: string; - }[]; -}; - -export class SendManyHandler - extends KeyringRpcHandler - implements StaticImplements -{ - protected override isThrowValidationError = true; - - constructor(walletData: WalletData) { - super(); - this.walletData = walletData; - } +export const sendManyParamsStruct = object({ + amounts: TransactionAmountStuct, + comment: string(), + subtractFeeFrom: array(BtcP2wpkhAddressStruct), + replaceable: boolean(), + dryrun: optional(boolean()), + scope: scopeStruct, +}); + +export const sendManyResponseStruct = object({ + txId: string(), + txHash: optional(string()), +}); + +export type SendManyParams = Infer; + +export type SendManyResponse = Infer; + +/** + * Send BTC to multiple account. + * + * @param account - The account to send the transaction. + * @param params - The parameters for send the transaction. + * @returns A Promise that resolves to an SendManyResponse object. + */ +export async function sendMany(account: IAccount, params: SendManyParams) { + try { + validateRequest(params, sendManyParamsStruct); + + const { dryrun, scope } = params; + const chainApi = Factory.createOnChainServiceProvider(scope); + const wallet = Factory.createWallet(scope); + + const feesResp = await chainApi.getFeeRates(); + + if (feesResp.fees.length === 0) { + throw new Error('No fee rates available'); + } - static override get requestStruct() { - return assign( - object({ - amounts: TransactionAmountStuct, - comment: string(), - subtractFeeFrom: array(BtcP2wpkhAddressStruct), - replaceable: boolean(), - dryrun: boolean(), + const fee = Math.max( + Number(feesResp.fees[feesResp.fees.length - 1].rate), + 1, + ); + + const recipients = Object.entries(params.amounts).map( + ([address, value]) => ({ + address, + value: btcToSats(value), }), - SnapRpcHandlerRequestStruct, ); - } - static override get responseStruct() { - return object({ - txId: string(), - txHash: optional(string()), + const metadata = await chainApi.getDataForTransaction(account.address); + + const { tx, txInfo } = await wallet.createTransaction(account, recipients, { + utxos: metadata.data.utxos, + fee, + subtractFeeFrom: params.subtractFeeFrom, + replaceable: params.replaceable, }); - } - async handleRequest(params: SendManyParams): Promise { - try { - const { scope } = this.walletData; - const { dryrun } = params; - const chainApi = Factory.createOnChainServiceProvider(scope); + if (!(await getTxConsensus(txInfo, params.comment, scope))) { + throw new UserRejectedRequestError() as unknown as Error; + } - const feesResp = await chainApi.getFeeRates(); + const txHash = await wallet.signTransaction(account.signer, tx); - if (feesResp.fees.length === 0) { - throw new Error('No fee rates available'); - } + if (dryrun) { + return { + txId: '', + txHash, + }; + } - const fee = Math.max( - feesResp.fees[feesResp.fees.length - 1].rate.value, - 1, - ); - - const recipients = Object.entries(params.amounts).map( - ([address, value]) => ({ - address, - value: parseInt(btcToSats(parseFloat(value)), 10), - }), - ); - - const metadata = await chainApi.getDataForTransaction( - this.walletAccount.address, - ); - - const { tx, txInfo } = await this.wallet.createTransaction( - this.walletAccount, - recipients, - { - utxos: metadata.data.utxos, - fee, - subtractFeeFrom: params.subtractFeeFrom, - replaceable: params.replaceable, - }, - ); - - if (!(await this.getTxConsensus(txInfo, params.comment))) { - throw new UserRejectedRequestError() as unknown as Error; - } + const result = await chainApi.broadcastTransaction(txHash); - const txHash = await this.wallet.signTransaction( - this.walletAccount.signer, - tx, - ); + const resp = { + txId: result.transactionId, + }; - if (dryrun) { - return { - txId: '', - txHash, - }; - } + validateResponse(resp, sendManyResponseStruct); - const result = await chainApi.broadcastTransaction(txHash); + return resp; + } catch (error) { + logger.error('Failed to send the transaction', error); - return { - txId: result.transactionId, - }; - } catch (error) { - logger.error('Failed to send the transaction', error); - if ( - error instanceof TxValidationError || - error instanceof UserRejectedRequestError - ) { - throw error as unknown as Error; - } - throw new Error('Failed to send the transaction'); + if (isSnapRpcError(error)) { + throw error as unknown as Error; + } + + if ( + error instanceof TxValidationError || + error instanceof UserRejectedRequestError + ) { + throw error as unknown as Error; } + + throw new Error('Failed to send the transaction'); } +} - protected async getTxConsensus( - txInfo: ITxInfo, - comment: string, - ): Promise { - const header = `Send Request`; - const intro = `Review the request before proceeding. Once the transaction is made, it's irreversible.`; - const recipientsLabel = `Recipient`; - const amountLabel = `Amount`; - const commentLabel = `Comment`; - // const networkFeeRateLabel = `Network fee rate`; - const networkFeeLabel = `Network fee`; - const totalLabel = `Total`; - const requestedByLable = `Requested by`; - - const components: Component[] = [ - panel([ - heading(header), - text(intro), - row( - requestedByLable, - text(`[portfolio.metamask.io](https://portfolio.metamask.io/)`), - ), - ]), - divider(), - ]; - - const info = txInfo.toJson(); - - const isMoreThanOneRecipient = - info.recipients.length + info.changes.length > 1; - - let i = 0; - - const addReciptentsToComponents = (data: { - address: string; - explorerUrl: string; - value: string; - }) => { - const recipientsPanel: Component[] = []; - recipientsPanel.push( - row( - isMoreThanOneRecipient - ? `${recipientsLabel} ${i + 1}` - : recipientsLabel, - text(`[${data.address}](${data.explorerUrl})`), +/** + * Display an confirmation dialog to confirm an transaction. + * + * @param info - The transaction data object contains the transaction information. + * @param comment - The comment text to display. + * @param scope - The CAIP-2 Chain ID. + * @returns A Promise that resolves to the response of the confirmation dialog. + */ +export async function getTxConsensus( + info: ITxInfo, + comment: string, + scope: string, +): Promise { + const header = `Send Request`; + const intro = `Review the request before proceeding. Once the transaction is made, it's irreversible.`; + const recipientsLabel = `Recipient`; + const amountLabel = `Amount`; + const commentLabel = `Comment`; + // const networkFeeRateLabel = `Network fee rate`; + const networkFeeLabel = `Network fee`; + const totalLabel = `Total`; + const requestedByLable = `Requested by`; + + const components: Component[] = [ + panel([ + heading(header), + text(intro), + row( + requestedByLable, + text(`[portfolio.metamask.io](https://portfolio.metamask.io/)`), + ), + ]), + divider(), + ]; + + const isMoreThanOneRecipient = + info.recipients.length + (info.change ? 1 : 0) > 1; + + let i = 0; + + const addReciptentsToComponents = (data: { + address: string; + value: bigint; + }) => { + const recipientsPanel: Component[] = []; + recipientsPanel.push( + row( + isMoreThanOneRecipient + ? `${recipientsLabel} ${i + 1}` + : recipientsLabel, + text( + `[${shortenAddress(data.address)}](${getExplorerUrl( + data.address, + scope, + )})`, ), - ); - recipientsPanel.push(row(amountLabel, text(data.value, false))); - i += 1; - components.push(panel(recipientsPanel)); - components.push(divider()); - }; + ), + ); + recipientsPanel.push( + row(amountLabel, text(satsToBtc(data.value, true), false)), + ); + i += 1; + components.push(panel(recipientsPanel)); + components.push(divider()); + }; - info.recipients.forEach(addReciptentsToComponents); - info.changes.forEach(addReciptentsToComponents); + info.recipients.forEach(addReciptentsToComponents); - const bottomPanel: Component[] = []; - if (comment.trim().length > 0) { - bottomPanel.push(row(commentLabel, text(comment.trim(), false))); - } + if (info.change) { + [info.change].forEach(addReciptentsToComponents); + } - bottomPanel.push(row(networkFeeLabel, text(`${info.txFee}`, false))); + const bottomPanel: Component[] = []; + if (comment.trim().length > 0) { + bottomPanel.push(row(commentLabel, text(comment.trim(), false))); + } - // bottomPanel.push(row(networkFeeRateLabel, text(`${info.feeRate}`, false))); + bottomPanel.push( + row(networkFeeLabel, text(`${satsToBtc(info.txFee, true)}`, false)), + ); - bottomPanel.push(row(totalLabel, text(`${info.total}`, false))); + // bottomPanel.push(row(networkFeeRateLabel, text(`${info.feeRate}`, false))); - components.push(panel(bottomPanel)); + bottomPanel.push( + row(totalLabel, text(`${satsToBtc(info.total, true)}`, false)), + ); - return (await SnapHelper.confirmDialog(components)) as boolean; - } + components.push(panel(bottomPanel)); + + return (await confirmDialog(components)) as boolean; } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts b/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts similarity index 90% rename from merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts rename to merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts index eeddcc04..883c0045 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts @@ -1,12 +1,12 @@ -import { generateAccounts } from '../../test/utils'; -import { Network } from '../bitcoin/constants'; -import { SnapHelper, StateError } from '../libs/snap'; -import { KeyringStateManager } from './state'; +import { generateAccounts } from '../test/utils'; +import { Caip2ChainId } from './constants'; +import { KeyringStateManager } from './stateManagement'; +import * as snapUtil from './utils/snap'; describe('KeyringStateManager', () => { const createMockStateManager = () => { - const getDataSpy = jest.spyOn(SnapHelper, 'getStateData'); - const setDataSpy = jest.spyOn(SnapHelper, 'setStateData'); + const getDataSpy = jest.spyOn(snapUtil, 'getStateData'); + const setDataSpy = jest.spyOn(snapUtil, 'setStateData'); return { instance: new KeyringStateManager(), getDataSpy, @@ -18,7 +18,7 @@ describe('KeyringStateManager', () => { return [`m`, `0'`, `0`, `${index}`].join('/'); }; - const createInitState = (cnt = 1, scope = Network.Testnet) => { + const createInitState = (cnt = 1, scope = Caip2ChainId.Testnet) => { const generatedAccounts = generateAccounts(cnt); return { walletIds: generatedAccounts.map((accounts) => accounts.id), @@ -82,11 +82,11 @@ describe('KeyringStateManager', () => { expect(result).toStrictEqual([]); }); - it('throw StateError if an error catched', async () => { + it('throw Error if an error catched', async () => { const { instance, getDataSpy } = createMockStateManager(); getDataSpy.mockRejectedValue(new Error('error')); - await expect(instance.listAccounts()).rejects.toThrow(StateError); + await expect(instance.listAccounts()).rejects.toThrow(Error); }); }); @@ -140,7 +140,7 @@ describe('KeyringStateManager', () => { }); }); - it('throw StateError if the given account id exist', async () => { + it('throw Error if the given account id exist', async () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); getDataSpy.mockResolvedValue(state); @@ -153,10 +153,10 @@ describe('KeyringStateManager', () => { index: accountToSave.index, scope: accountToSave.scope, }), - ).rejects.toThrow(StateError); + ).rejects.toThrow(Error); }); - it('throw StateError if the given account address exist', async () => { + it('throw Error if the given account address exist', async () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); getDataSpy.mockResolvedValue(state); @@ -194,7 +194,7 @@ describe('KeyringStateManager', () => { expect(state.wallets).not.toContain(testInput[1]); }); - it('throw StateError if the account does not exist', async () => { + it('throw Error if the account does not exist', async () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); const nonExistAcc = generateAccounts(1, 'notexist')[0]; @@ -208,13 +208,13 @@ describe('KeyringStateManager', () => { ); }); - it('throw StateError if an error catched', async () => { + it('throw Error if an error catched', async () => { const { instance, getDataSpy } = createMockStateManager(); getDataSpy.mockRejectedValue(new Error('error')); const state = createInitState(1); await expect(instance.removeAccounts(state.walletIds)).rejects.toThrow( - StateError, + Error, ); }); }); @@ -244,12 +244,12 @@ describe('KeyringStateManager', () => { expect(result).toBeNull(); }); - it('throw StateError if an error catched', async () => { + it('throw Error 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); + await expect(instance.getAccount(id)).rejects.toThrow(Error); }); }); @@ -278,7 +278,7 @@ describe('KeyringStateManager', () => { ); }); - it('throw StateError if the account does not exist', async () => { + it('throw Error if the account does not exist', async () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); const accToUpdate = generateAccounts(1, 'notexist')[0]; @@ -346,13 +346,13 @@ describe('KeyringStateManager', () => { expect(result).toBeNull(); }); - it('throw StateError if an error catched', async () => { + it('throw Error if an error catched', async () => { const { instance, getDataSpy } = createMockStateManager(); getDataSpy.mockRejectedValue(new Error('error')); const state = createInitState(20); const { id } = state.wallets[state.walletIds[0]].account; - await expect(instance.getWallet(id)).rejects.toThrow(StateError); + await expect(instance.getWallet(id)).rejects.toThrow(Error); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/state.ts b/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts similarity index 79% rename from merged-packages/bitcoin-wallet-snap/src/keyring/state.ts rename to merged-packages/bitcoin-wallet-snap/src/stateManagement.ts index 7a0ec24a..4292895b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts @@ -1,8 +1,20 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import { SnapStateManager, StateError } from '../libs/snap'; -import { compactError } from '../utils'; -import type { Wallet, SnapState } from './types'; +import { compactError, SnapStateManager } from './utils'; + +export type Wallet = { + account: KeyringAccount; + hdPath: string; + index: number; + scope: string; +}; + +export type Wallets = Record; + +export type SnapState = { + walletIds: string[]; + wallets: Wallets; +}; export class KeyringStateManager extends SnapStateManager { protected override async get(): Promise { @@ -32,7 +44,7 @@ export class KeyringStateManager extends SnapStateManager { const state = await this.get(); return state.walletIds.map((id) => state.wallets[id].account); } catch (error) { - throw compactError(error, StateError); + throw compactError(error, Error); } } @@ -44,14 +56,14 @@ export class KeyringStateManager extends SnapStateManager { this.isAccountExist(state, id) || this.getAccountByAddress(state, address) ) { - throw new StateError(`Account address ${address} already exists`); + throw new Error(`Account address ${address} already exists`); } state.wallets[id] = wallet; state.walletIds.push(id); }); } catch (error) { - throw compactError(error, StateError); + throw compactError(error, Error); } } @@ -59,7 +71,7 @@ export class KeyringStateManager extends SnapStateManager { try { await this.update(async (state: SnapState) => { if (!this.isAccountExist(state, account.id)) { - throw new StateError(`Account id ${account.id} does not exist`); + throw new Error(`Account id ${account.id} does not exist`); } const wallet = state.wallets[account.id]; @@ -70,13 +82,13 @@ export class KeyringStateManager extends SnapStateManager { account.address.toLowerCase() || accountInState.type !== account.type ) { - throw new StateError(`Account address or type is immutable`); + throw new Error(`Account address or type is immutable`); } state.wallets[account.id].account = account; }); } catch (error) { - throw compactError(error, StateError); + throw compactError(error, Error); } } @@ -87,7 +99,7 @@ export class KeyringStateManager extends SnapStateManager { for (const id of ids) { if (!this.isAccountExist(state, id)) { - throw new StateError(`Account id ${id} does not exist`); + throw new Error(`Account id ${id} does not exist`); } removeIds.add(id); } @@ -96,7 +108,7 @@ export class KeyringStateManager extends SnapStateManager { state.walletIds = state.walletIds.filter((id) => !removeIds.has(id)); }); } catch (error) { - throw compactError(error, StateError); + throw compactError(error, Error); } } @@ -105,7 +117,7 @@ export class KeyringStateManager extends SnapStateManager { const state = await this.get(); return state.wallets[id]?.account ?? null; } catch (error) { - throw compactError(error, StateError); + throw compactError(error, Error); } } @@ -114,7 +126,7 @@ export class KeyringStateManager extends SnapStateManager { const state = await this.get(); return state.wallets[id] ?? null; } catch (error) { - throw compactError(error, StateError); + throw compactError(error, Error); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/logger/__mocks__/logger.ts b/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/logger.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/libs/logger/__mocks__/logger.ts rename to merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/logger.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts new file mode 100644 index 00000000..1dd67d68 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts @@ -0,0 +1,30 @@ +import { type SLIP10NodeInterface } from '@metamask/key-tree'; +import { networks } from 'bitcoinjs-lib'; + +import { createRandomBip32Data } from '../../../test/utils'; + +/** + * Retrieves a SLIP10NodeInterface object for the specified path and curve. + * + * @param path - The BIP32 derivation path for which to retrieve a SLIP10NodeInterface. + * @param curve - The elliptic curve to use for key derivation. + * @returns A Promise that resolves to a SLIP10NodeInterface object. + */ +export async function getBip32Deriver( + path: string[], + curve: 'secp256k1' | 'ed25519', +): Promise { + const { data } = createRandomBip32Data(networks.bitcoin, path, curve); + return { + ...data, + toJSON: jest.fn().mockReturnValue(data), + } as SLIP10NodeInterface; +} + +export const getBip44Deriver = jest.fn(); + +export const confirmDialog = jest.fn(); + +export const getStateData = jest.fn(); + +export const setStateData = jest.fn(); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/error.ts b/merged-packages/bitcoin-wallet-snap/src/utils/error.ts index 9c20c537..c4b7081d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/error.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/error.ts @@ -18,6 +18,30 @@ import { SnapError, } from '@metamask/snaps-sdk'; +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); + } +} + /** * Compacts an error to a specific error instance. * diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/explorer.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/explorer.test.ts new file mode 100644 index 00000000..557545f4 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/explorer.test.ts @@ -0,0 +1,22 @@ +import { Caip2ChainId } from '../constants'; +import { getExplorerUrl } from './explorer'; + +describe('getExplorerUrl', () => { + const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; + + it('returns a testnet explorer url', () => { + const result = getExplorerUrl(address, Caip2ChainId.Testnet); + expect(result).toBe(`https://blockstream.info/testnet/address/${address}`); + }); + + it('returns a mainnet explorer url', () => { + const result = getExplorerUrl(address, Caip2ChainId.Mainnet); + expect(result).toBe(`https://blockstream.info/address/${address}`); + }); + + it('throws `Invalid Chain ID` error if the given Chain ID is not support', () => { + expect(() => getExplorerUrl(address, 'some Chain ID')).toThrow( + 'Invalid Chain ID', + ); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/explorer.ts b/merged-packages/bitcoin-wallet-snap/src/utils/explorer.ts new file mode 100644 index 00000000..04f1aecb --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/explorer.ts @@ -0,0 +1,29 @@ +import { Config } from '../config'; +import { Caip2ChainId } from '../constants'; + +/** + * Gets the explorer URL for a given bitcoin address and CAIP-2 Chain ID. + * + * @param address - The bitcoin address to get the explorer URL for. + * @param caip2ChainId - The CAIP-2 Chain ID. + * @returns The explorer URL as a string. + * @throws An error if an invalid scope is provided. + */ +export function getExplorerUrl(address: string, caip2ChainId: string): string { + switch (caip2ChainId) { + case Caip2ChainId.Mainnet: + return Config.explorer[Caip2ChainId.Mainnet].replace( + // eslint-disable-next-line no-template-curly-in-string + '${address}', + address, + ); + case Caip2ChainId.Testnet: + return Config.explorer[Caip2ChainId.Testnet].replace( + // eslint-disable-next-line no-template-curly-in-string + '${address}', + address, + ); + default: + throw new Error('Invalid Chain ID'); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/utils/index.ts index d022966a..3aa58ed8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/index.ts @@ -2,3 +2,10 @@ export * from './error'; export * from './string'; export * from './async'; export * from './superstruct'; +export * from './lock'; +export * from './snap'; +export * from './snap-state'; +export * from './rpc'; +export * from './explorer'; +export * from './unit'; +export * from './logger'; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/lock.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/lock.test.ts new file mode 100644 index 00000000..ca96498e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/lock.test.ts @@ -0,0 +1,21 @@ +import { Mutex } from 'async-mutex'; + +import { acquireLock } from './lock'; + +jest.mock('async-mutex', () => { + return { + Mutex: jest.fn(), + }; +}); + +describe('acquireLock', () => { + it('acquires lock', () => { + acquireLock(); + expect(Mutex).toHaveBeenCalledTimes(0); + }); + + it('acquires new lock if parameter `create` is true', () => { + acquireLock(true); + expect(Mutex).toHaveBeenCalledTimes(1); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/lock.ts b/merged-packages/bitcoin-wallet-snap/src/utils/lock.ts new file mode 100644 index 00000000..73d0fd06 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/lock.ts @@ -0,0 +1,16 @@ +import { Mutex } from 'async-mutex'; + +const saveMutex = new Mutex(); + +/** + * Acquires or retrieves a lock. + * + * @param create - Whether to create a new lock or retrieve an existing one. + * @returns A Mutex object representing the lock. + */ +export function acquireLock(create = false) { + if (create) { + return new Mutex(); + } + return saveMutex; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/logger/logger.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/logger.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/libs/logger/logger.test.ts rename to merged-packages/bitcoin-wallet-snap/src/utils/logger.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/logger/logger.ts b/merged-packages/bitcoin-wallet-snap/src/utils/logger.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/libs/logger/logger.ts rename to merged-packages/bitcoin-wallet-snap/src/utils/logger.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/rpc.ts b/merged-packages/bitcoin-wallet-snap/src/utils/rpc.ts new file mode 100644 index 00000000..4e384a3c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/rpc.ts @@ -0,0 +1,35 @@ +import { InvalidParamsError, SnapError } from '@metamask/snaps-sdk'; +import type { Struct } from 'superstruct'; +import { assert } from 'superstruct'; + +/** + * Validates that the request parameters conform to the expected structure defined by the provided struct. + * + * @template Params - The expected structure of the request parameters. + * @param requestParams - The request parameters to validate. + * @param struct - The expected structure of the request parameters. + * @throws {InvalidParamsError} If the request parameters do not conform to the expected structure. + */ +export function validateRequest(requestParams: Params, struct: Struct) { + try { + assert(requestParams, struct); + } catch (error) { + throw new InvalidParamsError(error.message) as unknown as Error; + } +} + +/** + * Validates that the response conforms to the expected structure defined by the provided struct. + * + * @template Params - The expected structure of the response. + * @param response - The response to validate. + * @param struct - The expected structure of the response. + * @throws {SnapError} If the response does not conform to the expected structure. + */ +export function validateResponse(response: Params, struct: Struct) { + try { + assert(response, struct); + } catch (error) { + throw new SnapError('Invalid Response') as unknown as Error; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts similarity index 95% rename from merged-packages/bitcoin-wallet-snap/src/libs/snap/state.test.ts rename to merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts index 956542fc..18c9ea3d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts @@ -1,11 +1,10 @@ import { expect } from '@jest/globals'; -import { StateError } from './exceptions'; -import { SnapHelper } from './helpers'; -import { MutexLock } from './lock'; -import { SnapStateManager } from './state'; +import * as lockUtil from './lock'; +import * as snapUtil from './snap'; +import { SnapStateManager } from './snap-state'; -jest.mock('../logger/logger'); +jest.mock('../utils/logger'); type MockTransactionDetail = { txHash: string; @@ -125,7 +124,7 @@ describe('SnapStateManager', () => { }; const getStateDataSpy = jest - .spyOn(SnapHelper, 'getStateData') + .spyOn(snapUtil, 'getStateData') .mockImplementation(async () => { return { transaction: [...initState.transaction], @@ -147,7 +146,7 @@ describe('SnapStateManager', () => { }); const setStateDataSpy = jest - .spyOn(SnapHelper, 'setStateData') + .spyOn(snapUtil, 'setStateData') .mockImplementation(setStateDataFn); return { @@ -160,14 +159,14 @@ describe('SnapStateManager', () => { describe('constructor', () => { it('sends `false` to Lock.Acquire if parameter `createLock` is `undefined`', async () => { - const spy = jest.spyOn(MutexLock, 'acquire'); + const spy = jest.spyOn(lockUtil, 'acquireLock'); createMockStateManager(); expect(spy).toHaveBeenCalledWith(false); }); it('sends `true` to Lock.Acquire if parameter `createLock` is `true`', async () => { - const spy = jest.spyOn(MutexLock, 'acquire'); + const spy = jest.spyOn(lockUtil, 'acquireLock'); createMockStateManager(true); expect(spy).toHaveBeenCalledWith(true); @@ -186,7 +185,7 @@ describe('SnapStateManager', () => { ], }; const readSpy = jest - .spyOn(SnapHelper, 'getStateData') + .spyOn(snapUtil, 'getStateData') .mockResolvedValue(state); const result = await instance.getData(); @@ -213,9 +212,9 @@ describe('SnapStateManager', () => { }, }; const readSpy = jest - .spyOn(SnapHelper, 'getStateData') + .spyOn(snapUtil, 'getStateData') .mockResolvedValue(testcase.state); - const writeSpy = jest.spyOn(SnapHelper, 'setStateData'); + const writeSpy = jest.spyOn(snapUtil, 'setStateData'); updateDataSpy.mockImplementation((state, data) => { state.transaction.push(data); }); @@ -384,7 +383,7 @@ describe('SnapStateManager', () => { } catch (error) { expectedError = error; } finally { - expect(expectedError).toBeInstanceOf(StateError); + expect(expectedError).toBeInstanceOf(Error); expect(initState.transaction).toStrictEqual(['id']); expect(setStateDataSpy).toHaveBeenCalledTimes(0); expect(initState.trasansactionDetails).toStrictEqual({ @@ -563,7 +562,7 @@ describe('SnapStateManager', () => { ).rejects.toThrow('Failed to begin transaction'); }); - it('throws StateError error, if an StateError catched', async () => { + it('throws Error error, if an Error catched', async () => { const initState = { transaction: [], trasansactionDetails: {}, @@ -582,7 +581,7 @@ describe('SnapStateManager', () => { // firsy mockImplementation is to mock the set data actions setStateDataSpy .mockImplementationOnce(async () => { - throw new StateError('setStateDataSpy'); + throw new Error('setStateDataSpy'); // second mockImplementation is to mock the rollback actions }) .mockImplementationOnce(setStateDataFn); @@ -596,7 +595,7 @@ describe('SnapStateManager', () => { }, 30, ), - ).rejects.toThrow(StateError); + ).rejects.toThrow(Error); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts similarity index 82% rename from merged-packages/bitcoin-wallet-snap/src/libs/snap/state.ts rename to merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts index 509b37fb..46991b87 100644 --- a/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts @@ -1,11 +1,9 @@ import { type MutexInterface } from 'async-mutex'; import { v4 as uuidv4 } from 'uuid'; -import { compactError } from '../../utils'; -import { logger } from '../logger/logger'; -import { StateError } from './exceptions'; -import { SnapHelper } from './helpers'; -import { MutexLock } from './lock'; +import { acquireLock } from './lock'; +import { logger } from './logger'; +import { getStateData, setStateData } from './snap'; export type Transaction = { id?: string; @@ -21,7 +19,7 @@ export abstract class SnapStateManager { #transaction: Transaction; constructor(createLock = false) { - this.mtx = MutexLock.acquire(createLock); + this.mtx = acquireLock(createLock); this.#transaction = { id: undefined, orgState: undefined, @@ -32,11 +30,11 @@ export abstract class SnapStateManager { } protected async get(): Promise { - return SnapHelper.getStateData(); + return getStateData(); } protected async set(state: State): Promise { - return SnapHelper.setStateData(state); + return setStateData(state); } protected async update( @@ -79,7 +77,7 @@ export abstract class SnapStateManager { !this.#transaction.orgState || !this.#transaction.id ) { - throw new StateError('Failed to begin transaction'); + throw new Error('Failed to begin transaction'); } logger.info( @@ -97,11 +95,10 @@ export abstract class SnapStateManager { this.#transactionId }]: error : ${JSON.stringify(error.message)}`, ); - if (this.#transaction.hasCommited) { - // we only need to rollback if the transaction is committed - await this.#rollback(); - } - throw compactError(error, StateError); + + await this.#rollback(); + + throw error; } finally { this.#cleanUpTransaction(); } @@ -110,7 +107,7 @@ export abstract class SnapStateManager { async commit() { if (!this.#transaction.current || !this.#transaction.orgState) { - throw new StateError('Failed to commit transaction'); + throw new Error('Failed to commit transaction'); } this.#transaction.hasCommited = true; await this.set(this.#transaction.current); @@ -128,7 +125,12 @@ export abstract class SnapStateManager { async #rollback(): Promise { try { - if (!this.#transaction.isRollingBack && this.#transaction.orgState) { + // we only need to rollback if the transaction is committed + if ( + this.#transaction.hasCommited && + !this.#transaction.isRollingBack && + this.#transaction.orgState + ) { logger.info( `SnapStateManager.rollback [${ this.#transactionId @@ -136,7 +138,6 @@ export abstract class SnapStateManager { ); this.#transaction.isRollingBack = true; await this.set(this.#transaction.orgState); - this.#cleanUpTransaction(); } } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions @@ -145,8 +146,7 @@ export abstract class SnapStateManager { this.#transactionId }]: error : ${JSON.stringify(error)}`, ); - this.#cleanUpTransaction(); - throw new StateError('Failed to rollback state'); + throw new Error('Failed to rollback state'); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts new file mode 100644 index 00000000..780cd74f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts @@ -0,0 +1,105 @@ +import { expect } from '@jest/globals'; +import type { Component } from '@metamask/snaps-sdk'; +import { heading, panel, text, divider, row } from '@metamask/snaps-sdk'; + +import * as snapUtil from './snap'; + +jest.mock('@metamask/key-tree', () => ({ + getBIP44AddressKeyDeriver: jest.fn(), +})); + +describe('getBip32Deriver', () => { + it('gets bip32 deriver', async () => { + const spy = jest.spyOn(snapUtil.getProvider(), 'request'); + const path = ['m', "84'", "0'"]; + const curve = 'secp256k1'; + + await snapUtil.getBip32Deriver(path, curve); + + expect(spy).toHaveBeenCalledWith({ + method: 'snap_getBip32Entropy', + params: { + path, + curve, + }, + }); + }); +}); + +describe('confirmDialog', () => { + it('calls snap_dialog', async () => { + const spy = jest.spyOn(snapUtil.getProvider(), 'request'); + const components: Component[] = [ + heading('header'), + text('subHeader'), + divider(), + row('Label1', text('Value1')), + text('**Label2**:'), + row('SubLabel1', text('SubValue1')), + ]; + + await snapUtil.confirmDialog(components); + + expect(spy).toHaveBeenCalledWith({ + method: 'snap_dialog', + params: { + type: 'confirmation', + content: panel(components), + }, + }); + }); +}); + +describe('getStateData', () => { + it('gets state data', async () => { + const spy = jest.spyOn(snapUtil.getProvider(), 'request'); + const testcase = { + state: { + transaction: [ + { + txHash: 'hash', + chainId: 'chainId', + }, + ], + }, + }; + + spy.mockResolvedValue(testcase.state); + const result = await snapUtil.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(snapUtil.getProvider(), 'request'); + const testcase = { + state: { + transaction: [ + { + txHash: 'hash', + chainId: 'chainId', + }, + ], + }, + }; + + await snapUtil.setStateData(testcase.state); + + expect(spy).toHaveBeenCalledWith({ + method: 'snap_manageState', + params: { + operation: 'update', + newState: testcase.state, + }, + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts new file mode 100644 index 00000000..e6b162ef --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts @@ -0,0 +1,82 @@ +import { type SLIP10NodeInterface } from '@metamask/key-tree'; +import type { Component, DialogResult, Json } from '@metamask/snaps-sdk'; +import { panel, type SnapsProvider } from '@metamask/snaps-sdk'; + +declare const snap: SnapsProvider; + +/** + * Retrieves the current SnapsProvider. + * + * @returns The current SnapsProvider. + */ +export function getProvider(): SnapsProvider { + return snap; +} + +/** + * Retrieves a SLIP10NodeInterface object for the specified path and curve. + * + * @param path - The BIP32 derivation path for which to retrieve a SLIP10NodeInterface. + * @param curve - The elliptic curve to use for key derivation. + * @returns A Promise that resolves to a SLIP10NodeInterface object. + */ +export async function getBip32Deriver( + path: string[], + curve: 'secp256k1' | 'ed25519', +): Promise { + const node = await snap.request({ + method: 'snap_getBip32Entropy', + params: { + path, + curve, + }, + }); + return node as SLIP10NodeInterface; +} + +/** + * Displays a confirmation dialog with the specified components. + * + * @param components - An array of components to display in the dialog. + * @returns A Promise that resolves to the result of the dialog. + */ +export async function confirmDialog( + components: Component[], +): Promise { + return snap.request({ + method: 'snap_dialog', + params: { + type: 'confirmation', + content: panel(components), + }, + }); +} + +/** + * Retrieves the current state data. + * + * @returns A Promise that resolves to the current state data. + */ +export async function getStateData(): Promise { + return (await snap.request({ + method: 'snap_manageState', + params: { + operation: 'get', + }, + })) as unknown as State; +} + +/** + * Sets the current state data to the specified data. + * + * @param data - The new state data to set. + */ +export async function setStateData(data: State) { + await snap.request({ + method: 'snap_manageState', + params: { + operation: 'update', + newState: data as unknown as Record, + }, + }); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts index f0b3e8c4..b4c14a31 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts @@ -5,6 +5,7 @@ import { hexToBuffer, bufferToString, replaceMiddleChar, + shortenAddress, } from './string'; describe('trimHexPrefix', () => { @@ -68,3 +69,10 @@ describe('replaceMiddleChar', () => { expect(replaceMiddleChar('', 5, 3)).toBe(''); }); }); + +describe('shortenAddress', () => { + const str = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; + it('shorten an address', () => { + expect(shortenAddress(str)).toBe('tb1qt...aeu'); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts index acf96baa..8dc990be 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts @@ -64,3 +64,13 @@ export function replaceMiddleChar( str.length - tailLength, )}`; } + +/** + * Format the address in shorten string. + * + * @param address - The address to format. + * @returns The formatted address. + */ +export function shortenAddress(address: string) { + return replaceMiddleChar(address, 5, 3); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts index 06495791..f9440c9f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts @@ -45,10 +45,7 @@ describe('superstruct', () => { describe('scopeStruct', () => { it('validates correctly', () => { expect(() => - assert( - Config.avaliableNetworks[Config.chain][0], - superstruct.scopeStruct, - ), + assert(Config.avaliableNetworks[0], superstruct.scopeStruct), ).not.toThrow(); expect(() => assert('custom scope', superstruct.scopeStruct)).toThrow( Error, @@ -59,10 +56,7 @@ describe('superstruct', () => { describe('assetsStruct', () => { it('validates correctly', () => { expect(() => - assert( - Config.avaliableAssets[Config.chain][0], - superstruct.assetsStruct, - ), + assert(Config.avaliableAssets[0], superstruct.assetsStruct), ).not.toThrow(); expect(() => assert('custom scope', superstruct.assetsStruct)).toThrow( Error, diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts index a7c15dda..411cb8a0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts @@ -2,9 +2,9 @@ import { enums, string, pattern } from 'superstruct'; import { Config } from '../config'; -export const assetsStruct = enums(Config.avaliableAssets[Config.chain]); +export const assetsStruct = enums(Config.avaliableAssets); -export const scopeStruct = enums(Config.avaliableNetworks[Config.chain]); +export const scopeStruct = enums(Config.avaliableNetworks); export const positiveStringStruct = pattern( string(), diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts new file mode 100644 index 00000000..c038e30f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts @@ -0,0 +1,53 @@ +import { maxSatoshi, minSatoshi } from '../bitcoin/constants'; +import { satsToBtc, btcToSats } from './unit'; + +describe('satsToBtc', () => { + it('returns Btc unit', () => { + expect(satsToBtc(2099999999999999n)).toBe('20999999.99999999'); + }); + + it('returns Btc unit with max Satoshis', () => { + expect(satsToBtc(maxSatoshi)).toBe('21000000.00000000'); + }); + + it('returns Btc unit with min Satoshis', () => { + expect(satsToBtc(minSatoshi)).toBe('0.00000001'); + }); + + it('returns Btc unit with unit', () => { + expect(satsToBtc(minSatoshi, true)).toBe('0.00000001 BTC'); + }); + + it('throw an error if then given Satoshis in float', () => { + const sats = 1.1; + expect(() => satsToBtc(sats)).toThrow(Error); + }); +}); + +describe('btcToSats', () => { + it('returns Btc unit', () => { + expect(btcToSats('20999999.99999999')).toBe(2099999999999999n); + }); + + it('returns Btc unit with max Satoshis', () => { + expect(btcToSats('21000000')).toBe(2100000000000000n); + }); + + it('returns Btc unit with 0 Satoshis', () => { + expect(btcToSats('0')).toBe(0n); + }); + + it('returns Btc unit with min Satoshis', () => { + expect(btcToSats('0.00000001')).toBe(1n); + }); + + it('throws an error if the given BTC is out of range', () => { + expect(() => btcToSats('0.9999999999999')).toThrow( + 'BTC amount is out of range', + ); + expect(() => btcToSats('21000000.999999999"')).toThrow( + 'BTC amount is out of range', + ); + expect(() => btcToSats('22000000')).toThrow('BTC amount is out of range'); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts new file mode 100644 index 00000000..f27b1fb8 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts @@ -0,0 +1,46 @@ +import Big from 'big.js'; + +import { maxSatoshi } from '../bitcoin/constants'; +import { Config } from '../config'; + +/** + * Converts a satoshis to a string representing the equivalent amount of BTC. + * + * @param sats - The number of satoshis to convert. + * @param withUnit - A boolean indicating whether to include the unit in the string representation. Default is false. + * @returns The equivalent amount of BTC as a string, fixed to 8 decimal places. + * @throws A Error if sats is not an integer. + */ +export function satsToBtc(sats: number | bigint, withUnit = false): string { + if (typeof sats === 'number' && !Number.isInteger(sats)) { + throw new Error('satsToBtc must be called on an integer number'); + } + const bigIntSat = new Big(sats); + const val = bigIntSat.div(100000000).toFixed(8); + + if (withUnit) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + return `${val} ${Config.unit}`; + } + return val; +} + +/** + * Converts a BTC to a bigint representing the equivalent amount of satoshis. + * + * @param btc - The amount of BTC to convert. + * @returns The equivalent amount of satoshis as a string, rounded to the nearest integer. + * @throws A Error if the BTC > max amount of satoshis (21 * 1e14) or the BTC < 0 or the BTC has more than 8 decimals. + */ +export function btcToSats(btc: string): bigint { + const stringVals = btc.split('.'); + if (stringVals.length > 1 && stringVals[1].length > 8) { + throw new Error('BTC amount is out of range'); + } + const bigIntBtc = new Big(btc); + const sats = bigIntBtc.times(100000000); + if (sats.lt(0) || sats.gt(maxSatoshi)) { + throw new Error('BTC amount is out of range'); + } + return BigInt(sats.toFixed(0)); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/wallet.ts index 937fc141..9dbc86b9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/wallet.ts @@ -3,7 +3,7 @@ import type { Buffer } from 'buffer'; export type Recipient = { address: string; - value: number; + value: bigint; }; export type Transaction = { @@ -15,53 +15,49 @@ export type Transaction = { * An interface that defines a `toJson` method for getting a JSON representation of a transaction info object. */ export type ITxInfo = { - /** - * Returns a JSON representation of the transaction info object. - * - * @returns The JSON representation of the transaction info object. - */ - toJson>(): TxInfoJson; + sender: string; + change?: Recipient; + recipients: Recipient[]; + total: bigint; + txFee: bigint; + feeRate: bigint; }; /** - * An interface that defines methods and properties for working with blockchain addresses. + * An interface that defines methods for unlocking accounts, signing transactions, and creating transactions. */ -export type IAddress = { - /** - * The string value of the address. - */ - value: string; - +export type IWallet = { /** - * Returns the string representation of the address. + * Unlocks an account by index and script type. * - * @param isShorten - A boolean indicating whether the address should be shortened. - * @returns The string representation of the address. - */ - toString(isShorten?: boolean): string; -}; - -/** - * An interface that defines properties for working with amounts of cryptocurrency. - */ -export type IAmount = { - /** - * The numeric value of the amount. + * @param index - The index to derive from the node. + * @param type - The script type of the unlocked account, e.g. `bip122:p2pkh`. + * @returns A promise that resolves to an `IAccount` object. */ - value: number; + unlock(index: number, type?: string): Promise; /** - * The unit of the amount, e.g. "BTC" or "ETH". + * Signs a transaction by the given encoded transaction string. + * + * @param signer - The `IAccountSigner` object to sign the transaction. + * @param transaction - The encoded transaction string to convert back to a transaction. + * @returns A promise that resolves to a string of the signed transaction. */ - unit: string; + signTransaction(signer: IAccountSigner, transaction: string): Promise; /** - * Returns the string representation of the amount, with or without the unit. + * Creates a transaction using the given account, transaction intent, and options. * - * @param withUnit - A boolean indicating whether to include the unit in the string representation. - * @returns The string representation of the amount. + * @param account - The `IAccount` object to create the transaction. + * @param recipients - The transaction recipients. + * @param options - The options to use when creating the transaction. + * @returns A promise that resolves to an object containing the transaction hash and transaction info. */ - toString(withUnit?: boolean): string; + createTransaction( + account: IAccount, + recipients: Recipient[], + options: Record, + ): Promise; }; /** @@ -98,43 +94,6 @@ export type IAccount = { signer: IAccountSigner; }; -/** - * An interface that defines methods for unlocking accounts, signing transactions, and creating transactions. - */ -export type IWallet = { - /** - * Unlocks an account by index and script type. - * - * @param index - The index to derive from the node. - * @param type - The script type of the unlocked account, e.g. `bip122:p2pkh`. - * @returns A promise that resolves to an `IAccount` object. - */ - unlock(index: number, type: string): Promise; - - /** - * Signs a transaction by the given encoded transaction string. - * - * @param signer - The `IAccountSigner` object to sign the transaction. - * @param transaction - The encoded transaction string to convert back to a transaction. - * @returns A promise that resolves to a string of the signed transaction. - */ - signTransaction(signer: IAccountSigner, transaction: string): Promise; - - /** - * Creates a transaction using the given account, transaction intent, and options. - * - * @param account - The `IAccount` object to create the transaction. - * @param recipients - The transaction recipients. - * @param options - The options to use when creating the transaction. - * @returns A promise that resolves to an object containing the transaction hash and transaction info. - */ - createTransaction( - account: IAccount, - recipients: Recipient[], - options: Record, - ): Promise; -}; - /** * An interface that defines methods and properties for signing transactions and verifying signatures. */ diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json deleted file mode 100644 index 4e5e1ce2..00000000 --- a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json +++ /dev/null @@ -1,220 +0,0 @@ -{ - "getAccountStatsResp": { - "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 - } - }, - "getUtxoResp": [ - { - "txid": "bf9955de6df6d2b35aa301142ddeb85259f62dbdb5a5382ac7199b91bf6aa165", - "vout": 1, - "status": { - "confirmed": true, - "block_height": 2581760, - "block_hash": "000000000000000eba4e476fd2e7985221a2ae28ea2696c97bde455a4d40ec81", - "block_time": 1710329183 - }, - "value": 28019 - } - ], - "feeEstimateResp": { - "22": 52.373999999999995, - "6": 52.373999999999995, - "144": 46.793, - "504": 46.793, - "15": 52.373999999999995, - "19": 52.373999999999995, - "7": 52.373999999999995, - "21": 52.373999999999995, - "10": 55.343, - "3": 46.793, - "11": 55.343, - "24": 52.373999999999995, - "17": 52.373999999999995, - "5": 52.373999999999995, - "4": 52.375, - "1": 46.793, - "1008": 46.793, - "9": 52.373999999999995, - "12": 52.373999999999995, - "16": 52.373999999999995, - "18": 52.373999999999995, - "20": 52.373999999999995, - "2": 46.793, - "14": 52.373999999999995, - "13": 52.373999999999995, - "23": 52.373999999999995, - "25": 52.373999999999995, - "8": 52.373999999999995 - }, - "getLast10BlockResp": [ - { - "id": "000000000000000774b68e8353359959bb7243630a7c8994fbf7b8c53312b985", - "height": 2818504, - "version": 570884096, - "timestamp": 1716968525, - "tx_count": 6138, - "size": 2237729, - "weight": 3993176, - "merkle_root": "14cf491c608a3415b692570e286fb876936d7448a122d5fd64753d82f2a1cd4b", - "previousblockhash": "000000000000000953d3bd05c2692444ba9e9d8f81113c772ea1d2b2b82bafd9", - "mediantime": 1716968525, - "nonce": 4148296018, - "bits": 420164126, - "difficulty": 383618246.78880125 - }, - { - "id": "000000000000000953d3bd05c2692444ba9e9d8f81113c772ea1d2b2b82bafd9", - "height": 2818503, - "version": 641843200, - "timestamp": 1716968525, - "tx_count": 6296, - "size": 1931479, - "weight": 3992977, - "merkle_root": "89f7f7f2b096d04dc59182b02db2f43e5a4d2060947e3bb968d510cc25d66c1e", - "previousblockhash": "00000000000000094888e858eca8a2a2aac1624108a45bd4dceded927422e9d6", - "mediantime": 1716968524, - "nonce": 3737463343, - "bits": 420164126, - "difficulty": 383618246.78880125 - }, - { - "id": "00000000000000094888e858eca8a2a2aac1624108a45bd4dceded927422e9d6", - "height": 2818502, - "version": 677756928, - "timestamp": 1716968524, - "tx_count": 6191, - "size": 2033489, - "weight": 3992897, - "merkle_root": "5868743f6708e2102eadabceb85cb9ad31e1ed730dbd14c2f2b9771839e1abfa", - "previousblockhash": "000000004db90149f43d3a0c249e17953c7171a01c77868a2bf0781516142dc6", - "mediantime": 1716968524, - "nonce": 3766185333, - "bits": 420164126, - "difficulty": 383618246.78880125 - }, - { - "id": "000000004db90149f43d3a0c249e17953c7171a01c77868a2bf0781516142dc6", - "height": 2818501, - "version": 536870912, - "timestamp": 1716970927, - "tx_count": 9, - "size": 1871, - "weight": 5369, - "merkle_root": "3440666441c17245218a6c4b6c853505a94473e8955b3cca9945c6d74a9e5fba", - "previousblockhash": "00000000e5f036d8f93121377e83d4c6a7fcf1c0091c9e35da4f120149e3f857", - "mediantime": 1716968523, - "nonce": 1023899438, - "bits": 486604799, - "difficulty": 1.0 - }, - { - "id": "00000000e5f036d8f93121377e83d4c6a7fcf1c0091c9e35da4f120149e3f857", - "height": 2818500, - "version": 536870912, - "timestamp": 1716969726, - "tx_count": 2, - "size": 455, - "weight": 1508, - "merkle_root": "2d71a7cae3d8e76dc1601314f6c8cae0138fefd58d19d324048459c8181751c8", - "previousblockhash": "0000000040628f64b9310ac950b055234b75392944e7f143bdcf2c77c4a3399c", - "mediantime": 1716967324, - "nonce": 1017180888, - "bits": 486604799, - "difficulty": 1.0 - }, - { - "id": "0000000040628f64b9310ac950b055234b75392944e7f143bdcf2c77c4a3399c", - "height": 2818499, - "version": 536870912, - "timestamp": 1716968525, - "tx_count": 1, - "size": 250, - "weight": 892, - "merkle_root": "0ac6dc718de6c68eb0dae9b19a88494112e7e5c549dc94e0ea365b81d1517f41", - "previousblockhash": "000000009649cfb6a61dede6b96e40a664a0ceb99cda5ded6cf62a0c50b3f248", - "mediantime": 1716967323, - "nonce": 390285632, - "bits": 486604799, - "difficulty": 1.0 - }, - { - "id": "000000009649cfb6a61dede6b96e40a664a0ceb99cda5ded6cf62a0c50b3f248", - "height": 2818498, - "version": 536870912, - "timestamp": 1716967324, - "tx_count": 3, - "size": 660, - "weight": 2124, - "merkle_root": "1b7fe58a1b1be6c0bc3cf145da5f593962d1765dc114010e597dace157684cc8", - "previousblockhash": "0000000000000001e5f68dbe40f3364ebddf4f230ec9e49dd5eccd1813c8b257", - "mediantime": 1716967322, - "nonce": 4221530826, - "bits": 486604799, - "difficulty": 1.0 - }, - { - "id": "0000000000000001e5f68dbe40f3364ebddf4f230ec9e49dd5eccd1813c8b257", - "height": 2818497, - "version": 633970688, - "timestamp": 1716966123, - "tx_count": 6196, - "size": 2099705, - "weight": 3992492, - "merkle_root": "bb8c150aa19ee1e5580cc1925d06f18878a26f69fa8224f550dae07378c727a3", - "previousblockhash": "00000000caddc4212fe7617f933d4c954aa830d17343f57676fbd072eca68b9b", - "mediantime": 1716966123, - "nonce": 1829804593, - "bits": 420164126, - "difficulty": 383618246.78880125 - }, - { - "id": "00000000caddc4212fe7617f933d4c954aa830d17343f57676fbd072eca68b9b", - "height": 2818496, - "version": 536870912, - "timestamp": 1716969725, - "tx_count": 2, - "size": 473, - "weight": 1454, - "merkle_root": "2327409528477e7de65184b33f1e28b3b061c07b15bcd6cc40f03f969348afb5", - "previousblockhash": "00000000dd4d4b64c55d54edbdb3bc9634de0434f0d23292a5fa11511a8e6e90", - "mediantime": 1716966122, - "nonce": 4028091752, - "bits": 486604799, - "difficulty": 1.0 - }, - { - "id": "00000000dd4d4b64c55d54edbdb3bc9634de0434f0d23292a5fa11511a8e6e90", - "height": 2818495, - "version": 536870912, - "timestamp": 1716968524, - "tx_count": 1, - "size": 250, - "weight": 892, - "merkle_root": "70bc5770e60c093fa682dd860c2c4437aadf1276ed924d15036e7d6326f8189a", - "previousblockhash": "00000000aa597bac4c12b00b38ce4aec6547e028ad27811a7d1f4ac539051fa8", - "mediantime": 1716966121, - "nonce": 1607636480, - "bits": 486604799, - "difficulty": 1.0 - } - ], - "getTransactionStatus": { - "confirmed": true, - "block_height": 2817600, - "block_hash": "0000000000000008859508cb0ec5596e8d05363b0df010b9b02b7b4cd4dc261e", - "block_time": 1716623274 - } -} diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index 5152e881..c5c27fba 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -5,9 +5,10 @@ import { Buffer } from 'buffer'; import ECPairFactory from 'ecpair'; import { v4 as uuidv4 } from 'uuid'; -import { Network as NetworkEnum } from '../src/bitcoin/constants'; +import { Caip2ChainId } from '../src/constants'; import blockChairData from './fixtures/blockchair.json'; -import blockStreamData from './fixtures/blockstream.json'; + +/* eslint-disable */ /** * Method to generate testing account. @@ -31,7 +32,7 @@ export function generateAccounts(cnt = 1, addressPrefix = '') { baseAddress.slice(0, baseAddress.length - i.toString().length) + i.toString(), options: { - scope: NetworkEnum.Testnet, + scope: Caip2ChainId.Testnet, index: i, }, methods: ['btc_sendmany'], @@ -200,39 +201,6 @@ export function createMockBip32Instance( } const randomNum = (max) => Math.floor(Math.random() * max); -/** - * Method to generate blockstream account stats resp by addresses. - * - * @param addresses - Array of address in string. - * @returns An array of blocksteam stats response. - */ -export function generateBlockStreamAccountStats(addresses: string[]) { - const template = blockStreamData.getAccountStatsResp; - const resp: (typeof template)[] = []; - for (const address of addresses) { - /* eslint-disable */ - resp.push({ - ...template, - address, - chain_stats: { - funded_txo_count: randomNum(100), - funded_txo_sum: Math.max(randomNum(1000000), 10000), - spent_txo_count: randomNum(100), - spent_txo_sum: randomNum(10000), - tx_count: randomNum(100), - }, - mempool_stats: { - funded_txo_count: randomNum(100), - funded_txo_sum: Math.max(randomNum(1000000), 10000), - spent_txo_count: randomNum(100), - spent_txo_sum: randomNum(10000), - tx_count: randomNum(100), - }, - }); - /* eslint-disable */ - } - return resp; -} /** * Method to generate blockchair getBalance resp by addresses. @@ -254,13 +222,15 @@ export function generateBlockChairGetBalanceResp(addresses: string[]) { * * @param address - address in string. * @param utxosCount - utxos count. + * @param minAmount - min amount of each utxo value. + * @param maxAmount - max amount of each utxo value. * @returns An array of blockchair getUtxos response. */ export function generateBlockChairGetUtxosResp( address: string, utxosCount: number, - minAmount: number = 0, - maxAmount: number = 1000000, + minAmount = 0, + maxAmount = 1000000, ) { const template = blockChairData.getUtxoResp; const data = { ...template.data.tb1qlq94vt9uh07fwunsgdyycpkv24uev05ywjua0r }; @@ -306,54 +276,7 @@ export function generateBlockChairGetStatsResp() { resp.data[key] = randomNum(value); } }); - resp.data['suggested_transaction_fee_per_byte_sat'] = randomNum(20); - return resp; -} - -/** - * Method to generate blockstream getUtxos resp. - * - * @param utxosCount - utxos count. - * @returns An array of blockstream getUtxos response. - */ -export function generateBlockStreamGetUtxosResp( - utxosCount: number, - confirmed: boolean = true, -) { - const template = blockStreamData.getUtxoResp; - let idx = -1; - const resp = Array.from({ length: utxosCount }, () => { - idx += 1; - return { - txid: randomNum(1000000) - .toString(16) - .padStart(template[0].txid.length, '0'), - vout: idx, - status: { - confirmed: confirmed, - block_height: randomNum(1000000), - block_hash: randomNum(1000000) - .toString(16) - .padStart(template[0].status.block_hash.length, '0'), - block_time: 1710329183, - }, - value: randomNum(1000000), - }; - }); - return resp; -} - -/** - * Method to generate blockstream estimate fee resp. - * - * @returns A blockstream estimate fee resp. - */ -export function generateBlockStreamEstFeeResp() { - const template = blockStreamData.feeEstimateResp; - const resp: typeof template = { ...template }; - Object.keys(template).forEach((key) => { - resp[key] = Math.min(0.1, randomNum(40)); - }); + resp.data.suggested_transaction_fee_per_byte_sat = randomNum(20); return resp; } @@ -368,45 +291,13 @@ export function generateBlockChairBroadcastTransactionResp() { return resp; } -/** - * Method to generate blockstream last 10 blocks resp. - * - * @param blockHeight - A start number for the block height of the resp. - * @returns A blockstream last 10 blocks resp. - */ -export function generateBlockStreamLast10BlockResp(blockHeight: number) { - const template = blockStreamData.getLast10BlockResp; - const resp: typeof template = { ...template }; - for (let i = 0; i < template.length; i++) { - resp[i].height = blockHeight - i; - } - return resp; -} - -/** - * Method to generate blockstream transaction status resp. - * - * @param blockHeight - Block height of the transaction. - * @param confirmed - Confirm status of the transaction. - * @returns A blockstream transaction status resp. - */ -export function generateBlockStreamTransactionStatusResp( - blockHeight: number, - confirmed: boolean, -) { - const template = blockStreamData.getTransactionStatus; - const resp: typeof template = { ...template }; - resp.block_height = blockHeight; - resp.confirmed = confirmed; - return resp; -} - /** * Method to generate blockchair transaction dashboards resp. * * @param txHash - Transaction hash of the transaction. * @param txnBlockHeight - Block height of the transaction. * @param txnBlockHeight - Block height of the last block. + * @param lastBlockHeight - Block height of the last block. * @param confirmed - Confirm status of the transaction. * @returns A blockchair transaction dashboards resp. */ @@ -441,8 +332,8 @@ export function generateBlockChairTransactionDashboard( * * @param address - the utxos owner address. * @param utxoCnt - count of the utox to be generated. - * @param minAmount - min amount of each utxo value. - * @param maxAmount - max amount of each utxo value. + * @param minAmt - min amount of the utxo array. + * @param maxAmt - max amount of the utxo array. * @returns An formatted utxo array. */ export function generateFormatedUtxos( @@ -465,3 +356,5 @@ export function generateFormatedUtxos( })); return formattedUtxos; } + +/* eslint-disable */ From 38bf616b32bc66b4a26479764fdfda04ef98bb89 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 12 Jun 2024 15:52:02 +0800 Subject: [PATCH 067/362] chore: simplify code structure batch 2 (#110) * chore: remove chain interface * chore: remove wallet interface and add docs * chore: add unit test coverage * chore: refine psbt error handling * chore: moving static implement to root folder * Update account.ts --- .../bitcoin-wallet-snap/jest.config.js | 2 +- .../bitcoin/chain/clients/blockchair.test.ts | 2 +- .../src/bitcoin/chain/clients/blockchair.ts | 41 +++--- .../src/bitcoin/chain/constants.ts | 11 ++ .../src/bitcoin/chain/data-client.ts | 63 ++++++-- .../src/bitcoin/chain/index.ts | 1 + .../src/bitcoin/chain/service.test.ts | 28 +--- .../src/bitcoin/chain/service.ts | 95 +++++++++--- .../src/bitcoin/wallet/account.test.ts | 8 +- .../src/bitcoin/wallet/account.ts | 66 ++++++--- .../src/bitcoin/wallet/coin-select.test.ts | 2 +- .../src/bitcoin/wallet/coin-select.ts | 13 +- .../src/bitcoin/{ => wallet}/constants.ts | 6 - .../src/bitcoin/wallet/deriver.test.ts | 8 +- .../src/bitcoin/wallet/deriver.ts | 48 ++++--- .../src/bitcoin/wallet/index.ts | 4 +- .../src/bitcoin/wallet/psbt.test.ts | 10 +- .../src/bitcoin/wallet/psbt.ts | 113 +++++++++++---- .../src/bitcoin/wallet/signer.ts | 34 ++++- .../bitcoin/wallet/transaction-info.test.ts | 129 +++++++++++------ .../src/bitcoin/wallet/transaction-info.ts | 4 +- .../bitcoin/wallet/transaction-input.test.ts | 14 +- .../src/bitcoin/wallet/transaction-input.ts | 12 +- .../{ => wallet}/utils/address.test.ts | 0 .../src/bitcoin/{ => wallet}/utils/address.ts | 0 .../src/bitcoin/{ => wallet}/utils/index.ts | 0 .../{ => wallet}/utils/network.test.ts | 2 +- .../src/bitcoin/{ => wallet}/utils/network.ts | 2 +- .../{ => wallet}/utils/payment.test.ts | 0 .../src/bitcoin/{ => wallet}/utils/payment.ts | 0 .../bitcoin/{ => wallet}/utils/policy.test.ts | 0 .../src/bitcoin/{ => wallet}/utils/policy.ts | 0 .../src/bitcoin/wallet/wallet.test.ts | 35 +++-- .../src/bitcoin/wallet/wallet.ts | 67 ++++++--- .../bitcoin-wallet-snap/src/chain.ts | 111 -------------- .../bitcoin-wallet-snap/src/factory.ts | 9 +- .../bitcoin-wallet-snap/src/index.test.ts | 2 +- .../bitcoin-wallet-snap/src/keyring.test.ts | 60 +++----- .../bitcoin-wallet-snap/src/keyring.ts | 12 +- .../src/rpcs/get-balances.test.ts | 23 ++- .../src/rpcs/get-balances.ts | 4 +- .../src/rpcs/get-transaction-status.test.ts | 23 ++- .../src/rpcs/get-transaction-status.ts | 2 +- .../src/rpcs/sendmany.test.ts | 100 +++++++------ .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 17 +-- .../src/{bitcoin/wallet => utils}/static.ts | 0 .../src/utils/unit.test.ts | 3 +- .../bitcoin-wallet-snap/src/utils/unit.ts | 7 +- .../bitcoin-wallet-snap/src/wallet.ts | 135 ------------------ 49 files changed, 704 insertions(+), 624 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/constants.ts (90%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/address.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/address.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/index.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/network.test.ts (96%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/network.ts (95%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/payment.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/payment.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/policy.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{ => wallet}/utils/policy.ts (100%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/chain.ts rename merged-packages/bitcoin-wallet-snap/src/{bitcoin/wallet => utils}/static.ts (100%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/wallet.ts diff --git a/merged-packages/bitcoin-wallet-snap/jest.config.js b/merged-packages/bitcoin-wallet-snap/jest.config.js index 3bc03249..491cb4eb 100644 --- a/merged-packages/bitcoin-wallet-snap/jest.config.js +++ b/merged-packages/bitcoin-wallet-snap/jest.config.js @@ -7,7 +7,7 @@ module.exports = { restoreMocks: true, resetMocks: true, verbose: true, - testPathIgnorePatterns: ['/node_modules/', '/__BAK__/', '/__mocks__/'], + testPathIgnorePatterns: ['/node_modules/', '/__mocks__/'], 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 diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts index 7a1e501d..6b81a97d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts @@ -8,7 +8,7 @@ import { generateBlockChairGetStatsResp, generateBlockChairTransactionDashboard, } from '../../../../test/utils'; -import { FeeRatio, TransactionStatus } from '../../../chain'; +import { FeeRatio, TransactionStatus } from '../constants'; import { DataClientError } from '../exceptions'; import { BlockChairClient } from './blockchair'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts index f7534517..4a0965e9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts @@ -1,14 +1,15 @@ import type { Json } from '@metamask/snaps-sdk'; import { type Network, networks } from 'bitcoinjs-lib'; -import type { TransactionStatusData, Utxo } from '../../../chain'; -import { FeeRatio, TransactionStatus } from '../../../chain'; -import { compactError } from '../../../utils'; -import { logger } from '../../../utils/logger'; +import { compactError, logger } from '../../../utils'; +import { FeeRatio, TransactionStatus } from '../constants'; import type { - GetBalancesResp, - GetFeeRatesResp, IDataClient, + DataClientGetBalancesResp, + DataClientGetTxStatusResp, + DataClientGetUtxosResp, + DataClientSendTxResp, + DataClientGetFeeRatesResp, } from '../data-client'; import { DataClientError } from '../exceptions'; @@ -289,7 +290,7 @@ export class BlockChairClient implements IDataClient { } } - async getBalances(addresses: string[]): Promise { + async getBalances(addresses: string[]): Promise { try { logger.info( `[BlockChairClient.getBalance] start: { addresses : ${JSON.stringify( @@ -305,26 +306,28 @@ export class BlockChairClient implements IDataClient { `[BlockChairClient.getBalance] response: ${JSON.stringify(response)}`, ); - return addresses.reduce((data: GetBalancesResp, address: string) => { - data[address] = response.data[address] ?? 0; - return data; - }, {}); + return addresses.reduce( + (data: DataClientGetBalancesResp, address: string) => { + data[address] = response.data[address] ?? 0; + return data; + }, + {}, + ); } catch (error) { logger.info(`[BlockChairClient.getBalance] error: ${error.message}`); throw compactError(error, DataClientError); } } - // TODO: Get UTXOs that sufficiently cover the amount and fee, to reduce the number of requests async getUtxos( address: string, includeUnconfirmed?: boolean, - ): Promise { + ): Promise { try { let process = true; let offset = 0; const limit = 1000; - const data: Utxo[] = []; + const data: DataClientGetUtxosResp = []; while (process) { let url = `/dashboards/address/${address}?limit=0,${limit}&offset=0,${offset}`; @@ -365,7 +368,7 @@ export class BlockChairClient implements IDataClient { } } - async getFeeRates(): Promise { + async getFeeRates(): Promise { try { logger.info(`[BlockChairClient.getFeeRates] start:`); const response = await this.get(`/stats`); @@ -381,7 +384,9 @@ export class BlockChairClient implements IDataClient { } } - async sendTransaction(signedTransaction: string): Promise { + async sendTransaction( + signedTransaction: string, + ): Promise { try { const response = await this.post( `/push/transaction`, @@ -403,7 +408,9 @@ export class BlockChairClient implements IDataClient { } } - async getTransactionStatus(txHash: string): Promise { + async getTransactionStatus( + txHash: string, + ): Promise { try { const response = await this.getTxDashboardData(txHash); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts new file mode 100644 index 00000000..3a0720f6 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts @@ -0,0 +1,11 @@ +export enum FeeRatio { + Fast = 'fast', + Medium = 'medium', + Slow = 'slow', +} + +export enum TransactionStatus { + Confirmed = 'confirmed', + Pending = 'pending', + Failed = 'failed', +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts index a510b1a6..3e38e9cb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts @@ -1,15 +1,62 @@ -import type { FeeRatio, TransactionStatusData, Utxo } from '../../chain'; +import type { FeeRatio } from './constants'; +import type { TransactionStatusData, Utxo } from './service'; -export type GetBalancesResp = Record; +export type DataClientGetBalancesResp = Record; -export type GetFeeRatesResp = { +export type DataClientGetUtxosResp = Utxo[]; + +export type DataClientGetFeeRatesResp = { [key in FeeRatio]?: number; }; +export type DataClientGetTxStatusResp = TransactionStatusData; + +export type DataClientSendTxResp = string; + +/** + * This interface defines the methods available on a data client for interacting with the Bitcoin blockchain. + */ export type IDataClient = { - getBalances(address: string[]): Promise; - getUtxos(address: string, includeUnconfirmed?: boolean): Promise; - getFeeRates(): Promise; - getTransactionStatus(txHash: string): Promise; - sendTransaction(tx: string): Promise; + /** + * Gets the balances for a set of Bitcoin addresses. + * + * @param {string[]} address - An array of Bitcoin addresses to query. + * @returns {Promise} A promise that resolves to a record of addresses and their corresponding balances. + */ + getBalances(address: string[]): Promise; + + /** + * Gets the UTXOs for a Bitcoin address. + * + * @param {string} address - The Bitcoin address to query. + * @param {boolean} [includeUnconfirmed] - Whether or not to include unconfirmed UTXOs in the response. Defaults to false. + * @returns {Promise} A promise that resolves to an array of UTXOs. + */ + getUtxos( + address: string, + includeUnconfirmed?: boolean, + ): Promise; + + /** + * Gets the fee rates for the Bitcoin network. + * + * @returns {Promise} A promise that resolves to an object containing fee rates for different ratios. + */ + getFeeRates(): Promise; + + /** + * Gets the status of a transaction given its hash. + * + * @param {string} txHash - The hash of the transaction to query. + * @returns {Promise} A promise that resolves to the transaction status data. + */ + getTransactionStatus(txHash: string): Promise; + + /** + * Sends a transaction to the Bitcoin network. + * + * @param {string} tx - The transaction to send. + * @returns {Promise} A promise that resolves to the transaction hash. + */ + sendTransaction(tx: string): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts index cb209e05..651c3bdb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts @@ -1,2 +1,3 @@ export * from './exceptions'; export * from './service'; +export * from './constants'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts index ad3dc7bf..68e63b56 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts @@ -6,8 +6,8 @@ import { generateBlockChairBroadcastTransactionResp, generateBlockChairGetUtxosResp, } from '../../../test/utils'; -import { FeeRatio, TransactionStatus } from '../../chain'; import { Caip2Asset } from '../../constants'; +import { FeeRatio, TransactionStatus } from './constants'; import type { IDataClient } from './data-client'; import { BtcOnChainServiceError } from './exceptions'; import { BtcOnChainService } from './service'; @@ -127,9 +127,8 @@ describe('BtcOnChainService', () => { it('calls getUtxos with readClient', async () => { const { instance, getUtxosSpy } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); - const accounts = generateAccounts(2); + const accounts = generateAccounts(1); const sender = accounts[0].address; - const receiver = accounts[1].address; const mockResponse = generateBlockChairGetUtxosResp(sender, 10); const utxos = mockResponse.data[sender].utxo.map((utxo) => ({ block: utxo.block_id, @@ -140,13 +139,7 @@ describe('BtcOnChainService', () => { getUtxosSpy.mockResolvedValue(utxos); - const result = await txService.getDataForTransaction(sender, { - amounts: { - [receiver]: 100, - }, - subtractFeeFrom: [], - replaceable: true, - }); + const result = await txService.getDataForTransaction(sender); expect(getUtxosSpy).toHaveBeenCalledWith(sender); expect(result).toStrictEqual({ @@ -159,21 +152,14 @@ describe('BtcOnChainService', () => { it('throws error if readClient fail', async () => { const { instance, getUtxosSpy } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); - const accounts = generateAccounts(2); + const accounts = generateAccounts(1); const sender = accounts[0].address; - const receiver = accounts[1].address; getUtxosSpy.mockRejectedValue(new Error('error')); - await expect( - txService.getDataForTransaction(sender, { - amounts: { - [receiver]: 100, - }, - subtractFeeFrom: [], - replaceable: true, - }), - ).rejects.toThrow(BtcOnChainServiceError); + await expect(txService.getDataForTransaction(sender)).rejects.toThrow( + BtcOnChainServiceError, + ); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index d22aa65e..4817481c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -1,25 +1,62 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; -import type { - FeeRatio, - IOnChainService, - AssetBalances, - TransactionIntent, - Fees, - TransactionData, - CommitedTransaction, -} from '../../chain'; import { Caip2Asset } from '../../constants'; import { compactError } from '../../utils'; +import type { FeeRatio, TransactionStatus } from './constants'; import type { IDataClient } from './data-client'; import { BtcOnChainServiceError } from './exceptions'; +export type TransactionStatusData = { + status: TransactionStatus; +}; + +export type Balance = { + amount: bigint; +}; + +export type AssetBalances = { + balances: { + [address: string]: { + [asset: string]: Balance; + }; + }; +}; + +export type Fee = { + type: FeeRatio; + rate: bigint; +}; + +export type Fees = { + fees: { + type: FeeRatio; + rate: bigint; + }[]; +}; + +export type Utxo = { + block: number; + txHash: string; + index: number; + value: number; +}; + +export type TransactionData = { + data: { + utxos: Utxo[]; + }; +}; + +export type CommitedTransaction = { + transactionId: string; +}; + export type BtcOnChainServiceOptions = { network: Network; }; -export class BtcOnChainService implements IOnChainService { +export class BtcOnChainService { protected readonly _dataClient: IDataClient; protected readonly _options: BtcOnChainServiceOptions; @@ -33,6 +70,13 @@ export class BtcOnChainService implements IOnChainService { return this._options.network; } + /** + * Gets the balances for multiple addresses and multiple assets. + * + * @param addresses - An array of addresses to fetch the balances for. + * @param assets - An array of assets to fetch the balances of. + * @returns A promise that resolves to an `AssetBalances` object. + */ async getBalances( addresses: string[], assets: string[], @@ -70,6 +114,11 @@ export class BtcOnChainService implements IOnChainService { } } + /** + * Gets the fee rates of the network. + * + * @returns A promise that resolves to a `Fees` object. + */ async getFeeRates(): Promise { try { const result = await this._dataClient.getFeeRates(); @@ -87,7 +136,13 @@ export class BtcOnChainService implements IOnChainService { } } - async getTransactionStatus(txHash: string) { + /** + * Gets the status of a transaction with the given transaction hash. + * + * @param txHash - The transaction hash of the transaction to get the status of. + * @returns A promise that resolves to a `TransactionStatusData` object. + */ + async getTransactionStatus(txHash: string): Promise { try { return await this._dataClient.getTransactionStatus(txHash); } catch (error) { @@ -95,11 +150,13 @@ export class BtcOnChainService implements IOnChainService { } } - async getDataForTransaction( - address: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - transactionIntent?: TransactionIntent, - ): Promise { + /** + * Gets the required metadata to build a transaction for the given address and transaction intent. + * + * @param address - The address to build the transaction for. + * @returns A promise that resolves to a `TransactionData` object. + */ + async getDataForTransaction(address: string): Promise { try { const data = await this._dataClient.getUtxos(address); return { @@ -112,6 +169,12 @@ export class BtcOnChainService implements IOnChainService { } } + /** + * Broadcasts a signed transaction on the blockchain network. + * + * @param signedTransaction - A signed transaction string. + * @returns A promise that resolves to a `CommitedTransaction` object. + */ async broadcastTransaction( signedTransaction: string, ): Promise { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts index e49eb86e..3cb4dcf3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts @@ -2,10 +2,10 @@ import type { Network, Payment } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; -import type { IAccountSigner } from '../../wallet'; -import { ScriptType } from '../constants'; -import * as utils from '../utils/payment'; import { P2WPKHAccount } from './account'; +import { ScriptType } from './constants'; +import type { AccountSigner } from './signer'; +import * as utils from './utils/payment'; describe('BtcAccount', () => { const createMockPaymentInstance = (address: string | undefined) => { @@ -31,7 +31,7 @@ describe('BtcAccount', () => { network, P2WPKHAccount.scriptType, `bip122:${P2WPKHAccount.scriptType.toLowerCase()}`, - { sign: signerSpy } as unknown as IAccountSigner, + { sign: signerSpy } as unknown as AccountSigner, ); return { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts index 09c33648..fbde1ef8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts @@ -2,17 +2,10 @@ import type { Network, Payment } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; import { hexToBuffer } from '../../utils'; -import type { IAccount, IAccountSigner } from '../../wallet'; -import { ScriptType } from '../constants'; -import { getBtcPaymentInst } from '../utils'; -import type { StaticImplements } from './static'; - -export type IBtcAccount = IAccount & { - payment: Payment; - scriptType: ScriptType; - network: Network; - script: Buffer; -}; +import type { StaticImplements } from '../../utils/static'; +import { ScriptType } from './constants'; +import type { AccountSigner } from './signer'; +import { getBtcPaymentInst } from './utils'; export type IStaticBtcAccount = { path: string[]; @@ -25,30 +18,48 @@ export type IStaticBtcAccount = { network: Network, scriptType: ScriptType, type: string, - signer: IAccountSigner, - ): IBtcAccount; + signer: AccountSigner, + ): BtcAccount; }; -export abstract class BtcAccount implements IBtcAccount { +export abstract class BtcAccount { #address: string; #payment: Payment; + readonly network: Network; + + readonly scriptType: ScriptType; + + /** + * The master fingerprint of the derived node, as a string. + */ readonly mfp: string; + /** + * The index of the derived node, as a number. + */ readonly index: number; + /** + * The HD path of the account, as a string. + */ readonly hdPath: string; + /** + * The public key of the account, as a string. + */ readonly pubkey: string; - readonly network: Network; - - readonly scriptType: ScriptType; - + /** + * The type of the account, e.g. `bip122:p2pwh`, as a string. + */ readonly type: string; - readonly signer: IAccountSigner; + /** + * The `IAccountSigner` object derived from the root node. + */ + readonly signer: AccountSigner; constructor( mfp: string, @@ -58,7 +69,7 @@ export abstract class BtcAccount implements IBtcAccount { network: Network, scriptType: ScriptType, type: string, - signer: IAccountSigner, + signer: AccountSigner, ) { this.mfp = mfp; this.index = index; @@ -70,11 +81,21 @@ export abstract class BtcAccount implements IBtcAccount { this.type = type; } + /** + * A getter function to return the correcsponing account type's output script. + * + * @returns The correcsponing account type's output script. + */ get script(): Buffer { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return this.payment.output!; } + /** + * A getter function to return the correcsponing account type's address. + * + * @returns The correcsponing account type's address. + */ get address(): string { if (!this.#address) { if (!this.payment.address) { @@ -85,6 +106,11 @@ export abstract class BtcAccount implements IBtcAccount { return this.#address; } + /** + * A getter function to return the correcsponing account type's payment instance. + * + * @returns The correcsponing account type's payment instance. + */ get payment(): Payment { if (!this.#payment) { this.#payment = getBtcPaymentInst( diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts index 0e123f68..c821fbf4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts @@ -1,8 +1,8 @@ import { networks, address as addressUtils } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; -import { ScriptType } from '../constants'; import { CoinSelectService } from './coin-select'; +import { ScriptType } from './constants'; import { BtcAccountDeriver } from './deriver'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index 2514e825..bcfe0d97 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -18,12 +18,21 @@ export class CoinSelectService { this._feeRate = Math.round(feeRate); } + /** + * This function selects the UTXOs that will be used to pay for a transaction and its associated gas fee. + * + * @param inputs - An array of input objects. + * @param outputs - An array of output objects. + * @param changeTo - The change output object. + * @returns A SelectionResult object that includes the calculated transaction fee, selected inputs, outputs, and change (if any). + * @throws {TxValidationError} Throws a TxValidationError if there are insufficient funds to complete the transaction. + */ selectCoins( inputs: TxInput[], - recipients: TxOutput[], + outputs: TxOutput[], changeTo: TxOutput, ): SelectionResult { - const result = coinSelect(inputs, recipients, this._feeRate); + const result = coinSelect(inputs, outputs, this._feeRate); if (!result.inputs || !result.outputs) { throw new TxValidationError('Insufficient funds'); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/constants.ts similarity index 90% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/constants.ts index 0aa18b3d..7fb64ebb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/constants.ts @@ -29,9 +29,3 @@ export const DustLimit = { // Maximum weight in bytes for a standard transaction export const MaxStandardTxWeight = 400000; - -// Maximum amount of satoshis -export const maxSatoshi = 21 * 1e14; - -// Minimum amount of satoshis -export const minSatoshi = 1; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts index 09970973..75f9692f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts @@ -99,7 +99,7 @@ describe('BtcAccountDeriver', () => { expect(result.privateKey).toStrictEqual(pkBuffer); }); - it('throws `Private key is invalid` if private key is invalid', async () => { + it('throws error if private key is invalid', async () => { const network = networks.testnet; const spy = jest.spyOn(strUtils, 'hexToBuffer'); spy.mockImplementation(() => { @@ -108,11 +108,11 @@ describe('BtcAccountDeriver', () => { const { deriver } = await prepareBip32Deriver(network); await expect(deriver.getRoot(P2WPKHAccount.path)).rejects.toThrow( - 'Private key is invalid', + 'error', ); }); - it('throws `Chain code is invalid` if chain code is invalid', async () => { + it('throws error if chain code is invalid', async () => { const network = networks.testnet; const spy = jest.spyOn(strUtils, 'hexToBuffer'); spy @@ -123,7 +123,7 @@ describe('BtcAccountDeriver', () => { const { deriver } = await prepareBip32Deriver(network); await expect(deriver.getRoot(P2WPKHAccount.path)).rejects.toThrow( - 'Chain code is invalid', + 'error', ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts index 1e5507e5..18809e37 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts @@ -7,11 +7,17 @@ import type { Buffer } from 'buffer'; import { compactError, hexToBuffer, getBip32Deriver } from '../../utils'; import { DeriverError } from './exceptions'; +/** + * This class provides a mechanism for deriving Bitcoin accounts using BIP32. + */ export class BtcAccountDeriver { protected readonly _network: Network; protected readonly _bip32Api: BIP32API; + /** + * The curve to use for account derivation. Defaults to 'secp256k1'. + */ readonly curve: 'secp256k1' | 'ed25519' = 'secp256k1'; constructor(network: Network) { @@ -19,6 +25,13 @@ export class BtcAccountDeriver { this._network = network; } + /** + * Gets the root node of a BIP32 account given a path. + * + * @param path - The BIP32 path to use for derivation. + * @returns The root node of the BIP32 account. + * @throws {DeriverError} Throws a DeriverError if the private key is missing or if the BIP32 node cannot be constructed from the private key. + */ async getRoot(path: string[]) { try { const deriver = await getBip32Deriver(path, this.curve); @@ -27,8 +40,8 @@ export class BtcAccountDeriver { throw new DeriverError('Deriver private key is missing'); } - const privateKeyBuffer = this.pkToBuf(deriver.privateKey); - const chainCodeBuffer = this.chainCodeToBuf(deriver.chainCode); + const privateKeyBuffer = hexToBuffer(deriver.privateKey); + const chainCodeBuffer = hexToBuffer(deriver.chainCode); const root = this.createBip32FromPrivateKey( privateKeyBuffer, @@ -49,6 +62,14 @@ export class BtcAccountDeriver { } } + /** + * Creates a BIP32 node from a private key and chain node. + * + * @param privateKey - The private key buffer. + * @param chainNode - The chain node buffer. + * @returns The BIP32 node. + * @throws {DeriverError} Throws a DeriverError if the BIP32 node cannot be constructed from the private key. + */ createBip32FromPrivateKey( privateKey: Buffer, chainNode: Buffer, @@ -64,23 +85,14 @@ export class BtcAccountDeriver { } } + /** + * Gets a child node of a BIP32 account given an index. + * + * @param root - The root node of the BIP32 account. + * @param idx - The index of the child node to get. + * @returns A promise that resolves to the child node. + */ async getChild(root: BIP32Interface, idx: number): Promise { return Promise.resolve(root.deriveHardened(0).derive(0).derive(idx)); } - - protected pkToBuf(pk: string): Buffer { - try { - return hexToBuffer(pk); - } catch (error) { - throw new DeriverError('Private key is invalid'); - } - } - - protected chainCodeToBuf(chainCode: string): Buffer { - try { - return hexToBuffer(chainCode); - } catch (error) { - throw new DeriverError('Chain code is invalid'); - } - } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts index 22617161..206909fa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts @@ -3,8 +3,10 @@ export * from './account'; export * from './signer'; export * from './deriver'; export * from './wallet'; -export * from './transaction-info'; export * from './coin-select'; export * from './psbt'; +export * from './transaction-info'; export * from './transaction-input'; export * from './transaction-output'; +export * from './utils'; +export * from './constants'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts index 3ecf5539..e77b195b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts @@ -2,7 +2,7 @@ import { Psbt, Transaction, networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { hexToBuffer } from '../../utils'; -import { MaxStandardTxWeight, ScriptType } from '../constants'; +import { MaxStandardTxWeight, ScriptType } from './constants'; import { BtcAccountDeriver } from './deriver'; import { PsbtServiceError } from './exceptions'; import { PsbtService } from './psbt'; @@ -151,7 +151,7 @@ describe('PsbtService', () => { } }); - it('throws `Failed to add inputs in PSBT` error if adding inputs fails', async () => { + it('throws `Failed to add input in PSBT` error if adding inputs fails', async () => { const { service, inputSpy, sender, inputs } = await preparePsbt(); inputSpy.mockImplementation(() => { throw new Error('error'); @@ -165,7 +165,7 @@ describe('PsbtService', () => { hexToBuffer(sender.pubkey, false), hexToBuffer(sender.mfp), ); - }).toThrow('Failed to add inputs in PSBT'); + }).toThrow('Failed to add input in PSBT'); }); }); @@ -190,14 +190,14 @@ describe('PsbtService', () => { } }); - it('throws `Failed to add outputs in PSBT` error if adding outputs fails', async () => { + it('throws `Failed to add output in PSBT` error if adding outputs fails', async () => { const { service, outputSpy, outputs } = await preparePsbt(); outputSpy.mockImplementation(() => { throw new Error('error'); }); expect(() => service.addOutputs(outputs)).toThrow( - 'Failed to add outputs in PSBT', + 'Failed to add output in PSBT', ); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index eb92a8b2..e3fd9b05 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -1,13 +1,12 @@ import ecc from '@bitcoinerlab/secp256k1'; -import type { Network } from 'bitcoinjs-lib'; +import type { HDSignerAsync, Network } from 'bitcoinjs-lib'; import { Psbt, Transaction } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; import ECPairFactory from 'ecpair'; import { compactError, logger } from '../../utils'; -import type { IAccountSigner } from '../../wallet'; -import { MaxStandardTxWeight } from '../constants'; -import { PsbtServiceError, TxValidationError } from './exceptions'; +import { MaxStandardTxWeight } from './constants'; +import { PsbtServiceError } from './exceptions'; import type { TxInput } from './transaction-input'; import type { TxOutput } from './transaction-output'; @@ -31,12 +30,29 @@ export class PsbtService { this._network = network; } + /** + * Creates a new instance of the `PsbtService` class from a base64-encoded PSBT string and a network. + * + * @param network - The network to use for the PSBT. + * @param base64Psbt - The base64-encoded PSBT string. + * @returns A new instance of the `PsbtService` class. + */ static fromBase64(network: Network, base64Psbt: string): PsbtService { const psbt = Psbt.fromBase64(base64Psbt, { network }); const service = new PsbtService(network, psbt); return service; } + /** + * Adds an input to the PSBT. + * + * @param input - The transaction input to add. + * @param replaceable - Whether or not the transaction is replaceable. + * @param changeAddressHdPath - The HD path of the change address. + * @param changeAddressPubkey - The public key of the change address. + * @param changeAddressMfp - The master fingerprint of the change address. + * @throws {PsbtServiceError} If there was an error adding the input to the PSBT. + */ addInput( input: TxInput, replaceable: boolean, @@ -77,6 +93,15 @@ export class PsbtService { } } + /** + * Adds multiple inputs to the PSBT. + * + * @param inputs - An array of transaction inputs to add. + * @param replaceable - Whether or not the transactions are replaceable. + * @param changeAddressHdPath - The HD path of the change address. + * @param changeAddressPubkey - The public key of the change address. + * @param changeAddressMfp - The master fingerprint of the change address. + */ addInputs( inputs: TxInput[], replaceable: boolean, @@ -84,22 +109,23 @@ export class PsbtService { changeAddressPubkey: Buffer, changeAddressMfp: Buffer, ) { - try { - for (const input of inputs) { - this.addInput( - input, - replaceable, - changeAddressHdPath, - changeAddressPubkey, - changeAddressMfp, - ); - } - } catch (error) { - logger.error('Failed to add inputs', error); - throw new PsbtServiceError('Failed to add inputs in PSBT'); + for (const input of inputs) { + this.addInput( + input, + replaceable, + changeAddressHdPath, + changeAddressPubkey, + changeAddressMfp, + ); } } + /** + * Adds an output to the PSBT. + * + * @param output - The transaction output to add. + * @throws {PsbtServiceError} If there was an error adding the output to the PSBT. + */ addOutput(output: TxOutput) { try { this._psbt.addOutput({ @@ -112,17 +138,23 @@ export class PsbtService { } } + /** + * Adds multiple outputs to the PSBT. + * + * @param outputs - An array of transaction outputs to add. + */ addOutputs(outputs: TxOutput[]) { - try { - for (const output of outputs) { - this.addOutput(output); - } - } catch (error) { - logger.error('Failed to add outputs', error); - throw new PsbtServiceError('Failed to add outputs in PSBT'); + for (const output of outputs) { + this.addOutput(output); } } + /** + * Gets the fee for the PSBT. + * + * @returns The fee for the PSBT. + * @throws {PsbtServiceError} If there was an error getting the fee from the PSBT. + */ getFee(): number { try { return this._psbt.getFee(); @@ -132,7 +164,14 @@ export class PsbtService { } } - async signDummy(signer: IAccountSigner): Promise { + /** + * Signs all inputs in the PSBT with a dummy signature using an asynchronous signer. + * + * @param signer - The asynchronous signer to use. + * @returns A promise that resolves with a new `PsbtService` instance with the signed inputs. + * @throws {PsbtServiceError} If there was an error signing the inputs in the PSBT. + */ + async signDummy(signer: HDSignerAsync): Promise { try { const psbt = this._psbt.clone(); await psbt.signAllInputsHDAsync(signer); @@ -144,6 +183,12 @@ export class PsbtService { } } + /** + * Converts the PSBT to a base64-encoded string. + * + * @returns The base64-encoded string representation of the PSBT. + * @throws {PsbtServiceError} If there was an error converting the PSBT to a base64-encoded string. + */ toBase64(): string { try { return this._psbt.toBase64(); @@ -153,7 +198,13 @@ export class PsbtService { } } - async signNVerify(signer: IAccountSigner) { + /** + * Signs all inputs in the PSBT and verifies that the signatures are valid using an asynchronous signer. + * + * @param signer - The asynchronous signer to use. + * @throws {PsbtServiceError} If there was an error signing or verifying the PSBT's inputs. + */ + async signNVerify(signer: HDSignerAsync) { try { // This function signAllInputsHDAsync is used to sign all inputs with the signer. // When using the method signAllInputsHDAsync, it is important to note that the signer must derive from the root node as well as the finderprint. @@ -166,7 +217,7 @@ export class PsbtService { this.validateInputs(pubkey, msghash, signature), ) ) { - throw new TxValidationError( + throw new PsbtServiceError( "Invalid signature to sign the PSBT's inputs", ); } @@ -175,6 +226,12 @@ export class PsbtService { } } + /** + * Finalizes the PSBT and returns the resulting transaction in hexadecimal format. + * + * @returns The transaction in hexadecimal format. + * @throws {PsbtServiceError} If there was an error finalizing the PSBT. + */ finalize(): string { try { this._psbt.finalizeAllInputs(); @@ -184,7 +241,7 @@ export class PsbtService { const weight = this._psbt.extractTransaction().weight(); if (weight > MaxStandardTxWeight) { - throw new TxValidationError('Transaction is too large'); + throw new PsbtServiceError('Transaction is too large'); } return txHex; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts index 53ee12d5..4d59f57e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts @@ -2,11 +2,18 @@ import type { BIP32Interface } from 'bip32'; import type { HDSignerAsync } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; -import type { IAccountSigner } from '../../wallet'; - -export class AccountSigner implements HDSignerAsync, IAccountSigner { +/** + * An Signer object that defines methods and properties for signing transactions and verifying signatures in PSBT sign process. + */ +export class AccountSigner implements HDSignerAsync { + /** + * The public key of the current derived node used for verifying signatures, as a Buffer. + */ readonly publicKey: Buffer; + /** + * The fingerprint of the current derived node used for verifying signatures, as a Buffer. + */ readonly fingerprint: Buffer; protected readonly _node: BIP32Interface; @@ -17,7 +24,13 @@ export class AccountSigner implements HDSignerAsync, IAccountSigner { this.fingerprint = mfp ?? this._node.fingerprint; } - derivePath(path: string): IAccountSigner { + /** + * Derives a new `IAccountSigner` object using an HD path. + * + * @param path - The HD path in string format, e.g. `m'\0'\0`. + * @returns A new `IAccountSigner` object derived by the given path. + */ + derivePath(path: string): AccountSigner { try { let splitPath = path.split('/'); if (splitPath[0] === 'm') { @@ -38,10 +51,23 @@ export class AccountSigner implements HDSignerAsync, IAccountSigner { } } + /** + * Signs a transaction hash. + * + * @param hash - The buffer containing the transaction hash to sign. + * @returns A promise that resolves to the signed result as a Buffer. + */ async sign(hash: Buffer): Promise { return this._node.sign(hash); } + /** + * Verifies a signature using the derived node of an `IAccountSigner` object. + * + * @param hash - The buffer containing the transaction hash. + * @param signature - The buffer containing the signature to verify. + * @returns A boolean indicating whether the signature is valid. + */ verify(hash: Buffer, signature: Buffer): boolean { return this._node.verify(hash, signature); } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts index 01a21469..0358cfab 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts @@ -1,48 +1,97 @@ import { Buffer } from 'buffer'; import { generateAccounts } from '../../../test/utils'; -import { BtcTxInfo } from './transaction-info'; +import { TxInfo } from './transaction-info'; import { TxOutput } from './transaction-output'; -describe('BtcTxInfo', () => { - describe('toJson', () => { - it('returns a json', async () => { - const accounts = generateAccounts(5); - const addresses = accounts.map((account) => account.address); - const fee = 10000; - const feeRate = 100; - let total = fee; - const outputs: TxOutput[] = []; - const sender = addresses[0]; - - const info = new BtcTxInfo(sender, feeRate); - info.txFee = fee; - - for (let i = 1; i < addresses.length; i++) { - total += 100000; - const output = new TxOutput(100000, addresses[i], Buffer.from('dummy')); - outputs.push(output); - } - - const change = new TxOutput(500, addresses[0], Buffer.from('dummy')); - - info.addRecipients(outputs); - info.addChange(change); - total += 500; - - const expectedRecipients = outputs.map((recipient) => { - return { - address: recipient.address, - value: recipient.bigIntValue, - }; - }); - - expect(info.total).toBe(BigInt(total)); - expect(info.sender).toStrictEqual(sender); - expect(info.recipients).toHaveLength(expectedRecipients.length); - expect(info.change).toBeDefined(); - expect(info.txFee).toBe(BigInt(fee)); - expect(info.feeRate).toBe(BigInt(feeRate)); +describe('TxInfo', () => { + it('accumulated total and add `TxOutput[]` as `Recipient[]`', async () => { + const accounts = generateAccounts(5); + const addresses = accounts.map((account) => account.address); + const fee = 10000; + const feeRate = 100; + let total = fee; + const outputs: TxOutput[] = []; + const sender = addresses[0]; + + const info = new TxInfo(sender, feeRate); + info.txFee = fee; + + for (let i = 1; i < addresses.length; i++) { + total += 100000; + const output = new TxOutput(100000, addresses[i], Buffer.from('dummy')); + outputs.push(output); + } + + info.addRecipients(outputs); + + const expectedRecipients = outputs.map((recipient) => { + return { + address: recipient.address, + value: recipient.bigIntValue, + }; }); + + expect(info.total).toBe(BigInt(total)); + expect(info.sender).toStrictEqual(sender); + expect(info.recipients).toHaveLength(expectedRecipients.length); + }); + + it('accumulated total with change', async () => { + const accounts = generateAccounts(5); + const addresses = accounts.map((account) => account.address); + const fee = 10000; + const feeRate = 100; + let total = fee; + const outputs: TxOutput[] = []; + const sender = addresses[0]; + + const info = new TxInfo(sender, feeRate); + info.txFee = fee; + + for (let i = 1; i < addresses.length; i++) { + total += 100000; + const output = new TxOutput(100000, addresses[i], Buffer.from('dummy')); + outputs.push(output); + } + + const change = new TxOutput(500, addresses[0], Buffer.from('dummy')); + + info.addRecipients(outputs); + info.addChange(change); + total += 500; + + expect(info.total).toBe(BigInt(total)); + expect(info.change).toBeDefined(); + }); + + it('converts number input to bigint', async () => { + const accounts = generateAccounts(1); + const addresses = accounts.map((account) => account.address); + const fee = 10000; + const feeRate = 100; + const sender = addresses[0]; + + const info = new TxInfo(sender, feeRate); + info.txFee = fee; + info.feeRate = feeRate; + + expect(info.txFee).toBe(BigInt(fee)); + expect(info.feeRate).toBe(BigInt(feeRate)); + }); + + it('does not convert bigint input to bigint', async () => { + const accounts = generateAccounts(1); + const addresses = accounts.map((account) => account.address); + const fee = 10000; + const feeRate = 100; + const sender = addresses[0]; + + const info = new TxInfo(sender, feeRate); + info.txFee = BigInt(fee); + info.feeRate = BigInt(feeRate); + + expect(info.txFee).toBe(BigInt(fee)); + expect(info.feeRate).toBe(BigInt(feeRate)); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts index e24ef6fd..1f74c61e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts @@ -1,7 +1,7 @@ -import type { ITxInfo, Recipient } from '../../wallet'; import type { TxOutput } from './transaction-output'; +import type { ITxInfo, Recipient } from './wallet'; -export class BtcTxInfo implements ITxInfo { +export class TxInfo implements ITxInfo { readonly sender: string; protected _change?: Recipient; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts index aac055c3..99713d89 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts @@ -1,7 +1,7 @@ import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; -import { ScriptType } from '../constants'; +import { ScriptType } from './constants'; import { BtcAccountDeriver } from './deriver'; import { TxInput } from './transaction-input'; import { BtcWallet } from './wallet'; @@ -31,4 +31,16 @@ describe('TxInput', () => { expect(input.index).toStrictEqual(utxo.index); expect(input.block).toStrictEqual(utxo.block); }); + + it('return bigint val', async () => { + const wallet = createMockWallet(networks.testnet); + const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); + const { script } = account; + + const utxo = generateFormatedUtxos(account.address, 1)[0]; + + const input = new TxInput(utxo, script); + + expect(input.bigIntValue).toStrictEqual(BigInt(utxo.value)); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts index 06134591..e6b0bd1c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts @@ -1,6 +1,6 @@ import type { Buffer } from 'buffer'; -import type { Utxo } from '../../chain'; +import type { Utxo } from '../chain'; export class TxInput { protected _value: bigint; @@ -16,7 +16,7 @@ export class TxInput { constructor(utxo: Utxo, script: Buffer) { this.script = script; - this.value = utxo.value; + this._value = BigInt(utxo.value); this.index = utxo.index; this.txHash = utxo.txHash; this.block = utxo.block; @@ -27,14 +27,6 @@ export class TxInput { return Number(this._value); } - set value(value: bigint | number) { - if (typeof value === 'number') { - this._value = BigInt(value); - } else { - this._value = value; - } - } - get bigIntValue(): bigint { return this._value; } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/index.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.test.ts similarity index 96% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.test.ts index 279d4297..e60ddc65 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.test.ts @@ -1,6 +1,6 @@ import { networks } from 'bitcoinjs-lib'; -import { Caip2ChainId } from '../../constants'; +import { Caip2ChainId } from '../../../constants'; import { getBtcNetwork, getCaip2ChainId } from './network'; describe('getBtcNetwork', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.ts similarity index 95% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.ts index 9900e71e..7b8b4f25 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.ts @@ -1,7 +1,7 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; -import { Caip2ChainId } from '../../constants'; +import { Caip2ChainId } from '../../../constants'; /** * Gets a bitcoinjs-lib network instance based on a given CAIP-2 Chain ID. diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index 269fd8f6..397c7444 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -1,12 +1,12 @@ import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; -import { DustLimit, ScriptType, maxSatoshi } from '../constants'; import { P2SHP2WPKHAccount, P2WPKHAccount } from './account'; import { CoinSelectService } from './coin-select'; +import { DustLimit, ScriptType } from './constants'; import { BtcAccountDeriver } from './deriver'; import { WalletError } from './exceptions'; -import { BtcTxInfo } from './transaction-info'; +import { TxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; import { BtcWallet } from './wallet'; @@ -47,7 +47,19 @@ describe('BtcWallet', () => { }; describe('unlock', () => { - it('returns an `Account` objec with type bip122:p2wpkh', async () => { + it('returns an `Account` object with defualt type', async () => { + const network = networks.testnet; + const { rootSpy, childSpy, instance } = createMockWallet(network); + const idx = 0; + + const result = await instance.unlock(idx); + + expect(result).toBeInstanceOf(P2WPKHAccount); + expect(rootSpy).toHaveBeenCalledWith(P2WPKHAccount.path); + expect(childSpy).toHaveBeenCalledWith(expect.any(Object), idx); + }); + + it('returns an `Account` object with type bip122:p2wpkh', async () => { const network = networks.testnet; const { rootSpy, childSpy, instance } = createMockWallet(network); const idx = 0; @@ -59,7 +71,7 @@ describe('BtcWallet', () => { expect(childSpy).toHaveBeenCalledWith(expect.any(Object), idx); }); - it('returns an `Account` objec with type `p2wpkh`', async () => { + it('returns an `Account` object with type `p2wpkh`', async () => { const network = networks.testnet; const { rootSpy, childSpy, instance } = createMockWallet(network); const idx = 0; @@ -101,16 +113,11 @@ describe('BtcWallet', () => { const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos( - account.address, - 200, - maxSatoshi, - maxSatoshi, - ); + const utxos = generateFormatedUtxos(account.address, 200, 100000, 100000); const result = await wallet.createTransaction( account, - createMockTxIndent(account.address, maxSatoshi), + createMockTxIndent(account.address, 100000), { utxos, fee: 56, @@ -127,7 +134,7 @@ describe('BtcWallet', () => { expect(change).toBeDefined(); expect(result).toStrictEqual({ tx: expect.any(String), - txInfo: expect.any(BtcTxInfo), + txInfo: expect.any(TxInfo), }); }); @@ -157,7 +164,7 @@ describe('BtcWallet', () => { expect(change).toBeUndefined(); expect(result).toStrictEqual({ tx: expect.any(String), - txInfo: expect.any(BtcTxInfo), + txInfo: expect.any(TxInfo), }); }); @@ -234,7 +241,7 @@ describe('BtcWallet', () => { }, ); - const info: BtcTxInfo = result.txInfo as unknown as BtcTxInfo; + const info: TxInfo = result.txInfo as unknown as TxInfo; expect(info.txFee).toBe(BigInt(19500)); expect(info.change).toBeUndefined(); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 0dadb1c0..24c2023f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -1,30 +1,43 @@ import type { BIP32Interface } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; -import type { Utxo } from '../../chain'; import { bufferToString, compactError, hexToBuffer, logger } from '../../utils'; -import type { - IAccountSigner, - IWallet, - Recipient, - Transaction, -} from '../../wallet'; -import { ScriptType } from '../constants'; -import { isDust, getScriptForDestnation } from '../utils'; +import type { Utxo } from '../chain'; +import type { BtcAccount } from './account'; import { P2WPKHAccount, P2SHP2WPKHAccount, type IStaticBtcAccount, - type IBtcAccount, } from './account'; import { CoinSelectService } from './coin-select'; +import { ScriptType } from './constants'; import type { BtcAccountDeriver } from './deriver'; import { WalletError, TxValidationError } from './exceptions'; import { PsbtService } from './psbt'; import { AccountSigner } from './signer'; -import { BtcTxInfo } from './transaction-info'; +import { TxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; +import { isDust, getScriptForDestnation } from './utils'; + +export type Recipient = { + address: string; + value: bigint; +}; + +export type Transaction = { + tx: string; + txInfo: ITxInfo; +}; + +export type ITxInfo = { + sender: string; + change?: Recipient; + recipients: Recipient[]; + total: bigint; + txFee: bigint; + feeRate: bigint; +}; export type CreateTransactionOptions = { utxos: Utxo[]; @@ -36,7 +49,7 @@ export type CreateTransactionOptions = { replaceable: boolean; }; -export class BtcWallet implements IWallet { +export class BtcWallet { protected readonly _deriver: BtcAccountDeriver; protected readonly _network: Network; @@ -46,7 +59,14 @@ export class BtcWallet implements IWallet { this._network = network; } - async unlock(index: number, type?: string): Promise { + /** + * Unlocks an account by index and script type. + * + * @param index - The index to derive from the node. + * @param type - The script type of the unlocked account, e.g. `bip122:p2pkh`. + * @returns A promise that resolves to an `IAccount` object. + */ + async unlock(index: number, type?: string): Promise { try { const AccountCtor = this.getAccountCtor(type ?? ScriptType.P2wpkh); const rootNode = await this._deriver.getRoot(AccountCtor.path); @@ -68,8 +88,16 @@ export class BtcWallet implements IWallet { } } + /** + * Creates a transaction using the given account, transaction intent, and options. + * + * @param account - The `IAccount` object to create the transaction. + * @param recipients - The transaction recipients. + * @param options - The options to use when creating the transaction. + * @returns A promise that resolves to an object containing the transaction hash and transaction info. + */ async createTransaction( - account: IBtcAccount, + account: BtcAccount, recipients: Recipient[], options: CreateTransactionOptions, ): Promise { @@ -113,7 +141,7 @@ export class BtcWallet implements IWallet { hexToBuffer(account.mfp, false), ); - const txInfo = new BtcTxInfo(account.address, feeRate); + const txInfo = new TxInfo(account.address, feeRate); // TODO: add support of subtractFeeFrom, and throw error if output is too small after subtraction for (const output of selectionResult.outputs) { @@ -142,7 +170,14 @@ export class BtcWallet implements IWallet { }; } - async signTransaction(signer: IAccountSigner, tx: string): Promise { + /** + * Signs a transaction by the given encoded transaction string. + * + * @param signer - The `AccountSigner` object to sign the transaction. + * @param tx - The encoded transaction string to convert back to a transaction. + * @returns A promise that resolves to a string of the signed transaction. + */ + async signTransaction(signer: AccountSigner, tx: string): Promise { const psbtService = PsbtService.fromBase64(this._network, tx); await psbtService.signNVerify(signer); return psbtService.finalize(); diff --git a/merged-packages/bitcoin-wallet-snap/src/chain.ts b/merged-packages/bitcoin-wallet-snap/src/chain.ts deleted file mode 100644 index a0a57418..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/chain.ts +++ /dev/null @@ -1,111 +0,0 @@ -export enum FeeRatio { - Fast = 'fast', - Medium = 'medium', - Slow = 'slow', -} - -export enum TransactionStatus { - Confirmed = 'confirmed', - Pending = 'pending', - Failed = 'failed', -} - -export type TransactionStatusData = { - status: TransactionStatus; -}; - -export type Balance = { - amount: bigint; -}; - -export type AssetBalances = { - balances: { - [address: string]: { - [asset: string]: Balance; - }; - }; -}; - -export type Fee = { - type: FeeRatio; - rate: bigint; -}; - -export type Fees = { - fees: Fee[]; -}; - -export type TransactionIntent = { - amounts: Record; - subtractFeeFrom: string[]; - replaceable: boolean; -}; - -export type TransactionData = { - data: { - utxos: Utxo[]; - }; -}; - -export type CommitedTransaction = { - transactionId: string; -}; - -export type Utxo = { - block: number; - txHash: string; - index: number; - value: number; -}; - -/** - * An interface that defines methods for interacting with a blockchain network. - */ -export type IOnChainService = { - /** - * Gets the balances for multiple addresses and multiple assets. - * - * @param addresses - An array of addresses to fetch the balances for. - * @param assets - An array of assets to fetch the balances of. - * @returns A promise that resolves to an `AssetBalances` object. - */ - getBalances(addresses: string[], assets: string[]): Promise; - - /** - * Gets the fee rates of the network. - * - * @returns A promise that resolves to a `Fees` object. - */ - getFeeRates(): Promise; - - /** - * Broadcasts a signed transaction on the blockchain network. - * - * @param signedTransaction - A signed transaction string. - * @returns A promise that resolves to a `CommitedTransaction` object. - */ - broadcastTransaction(signedTransaction: string): Promise; - - /** - * Gets the status of a transaction with the given transaction hash. - * - * @param txHash - The transaction hash of the transaction to get the status of. - * @returns A promise that resolves to a `TransactionStatusData` object. - */ - getTransactionStatus(txHash: string): Promise; - - /** - * Gets the required metadata to build a transaction for the given address and transaction intent. - * - * @param address - The address to build the transaction for. - * @param transactionIntent - The transaction intent object containing the transaction inputs and outputs. - * @returns A promise that resolves to a `TransactionData` object. - */ - getDataForTransaction( - address: string, - transactionIntent?: TransactionIntent, - ): Promise; - - // TODO: Implement listTransactions in next phase - listTransactions(); -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts index efc3087a..1ea11fb8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.ts @@ -1,13 +1,10 @@ import { BtcOnChainService } from './bitcoin/chain'; import { BlockChairClient } from './bitcoin/chain/clients/blockchair'; -import { getBtcNetwork } from './bitcoin/utils'; -import { BtcAccountDeriver, BtcWallet } from './bitcoin/wallet'; -import type { IOnChainService } from './chain'; +import { BtcAccountDeriver, BtcWallet, getBtcNetwork } from './bitcoin/wallet'; import { Config } from './config'; -import type { IWallet } from './wallet'; export class Factory { - static createOnChainServiceProvider(scope: string): IOnChainService { + static createOnChainServiceProvider(scope: string): BtcOnChainService { const btcNetwork = getBtcNetwork(scope); const client = new BlockChairClient({ @@ -20,7 +17,7 @@ export class Factory { }); } - static createWallet(scope: string): IWallet { + static createWallet(scope: string): BtcWallet { const btcNetwork = getBtcNetwork(scope); return new BtcWallet(new BtcAccountDeriver(btcNetwork), btcNetwork); } diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts index ca926944..34620274 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.ts @@ -7,7 +7,7 @@ import { import { onRpcRequest, validateOrigin, onKeyringRequest } from '.'; import * as entry from '.'; -import { TransactionStatus } from './chain'; +import { TransactionStatus } from './bitcoin/chain'; import { Config } from './config'; import { BtcKeyring } from './keyring'; import { originPermissions } from './permissions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts index 2079fd2a..73e842ea 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts @@ -3,8 +3,7 @@ import { MethodNotFoundError } from '@metamask/snaps-sdk'; import { v4 as uuidv4 } from 'uuid'; import { generateAccounts } from '../test/utils'; -import { ScriptType } from './bitcoin/constants'; -import { BtcAccount } from './bitcoin/wallet'; +import { BtcAccount, BtcWallet, ScriptType } from './bitcoin/wallet'; import { Config } from './config'; import { Caip2Asset, Caip2ChainId } from './constants'; import { Factory } from './factory'; @@ -12,7 +11,6 @@ import { BtcKeyring } from './keyring'; import * as getBalanceRpc from './rpcs/get-balances'; import * as sendManyRpc from './rpcs/sendmany'; import { KeyringStateManager } from './stateManagement'; -import type { IWallet } from './wallet'; jest.mock('./utils/logger'); jest.mock('./utils/snap'); @@ -24,20 +22,17 @@ jest.mock('@metamask/keyring-api', () => ({ describe('BtcKeyring', () => { const createMockWallet = () => { - const unlockSpy = jest.fn(); - class Wallet implements IWallet { - unlock = unlockSpy; - - signTransaction = jest.fn(); - - createTransaction = jest.fn(); - } - jest - .spyOn(Factory, 'createWallet') - .mockImplementation() - .mockReturnValue(new Wallet()); + const unlockSpy = jest.spyOn(BtcWallet.prototype, 'unlock'); + const signTransaction = jest.spyOn(BtcWallet.prototype, 'signTransaction'); + const createTransaction = jest.spyOn( + BtcWallet.prototype, + 'createTransaction', + ); + return { unlockSpy, + signTransaction, + createTransaction, }; }; @@ -85,10 +80,6 @@ describe('BtcKeyring', () => { }; }; - const getHdPath = (index: number) => { - return [`m`, `0'`, `0`, `${index}`].join('/'); - }; - const createSender = async (caip2ChainId: string) => { const wallet = Factory.createWallet(caip2ChainId); const sender = await wallet.unlock(0, ScriptType.P2wpkh); @@ -114,38 +105,29 @@ describe('BtcKeyring', () => { const { unlockSpy } = createMockWallet(); const { instance: stateMgr, addWalletSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); - const scope = Caip2ChainId.Testnet; - const account = generateAccounts(1)[0]; - - unlockSpy.mockResolvedValue({ - address: account.address, - hdPath: getHdPath(account.options.index), - index: account.options.index, - type: account.type, - }); + const caip2ChainId = Caip2ChainId.Testnet; + const { sender, keyringAccount } = await createSender(caip2ChainId); await keyring.createAccount({ - scope, + scope: caip2ChainId, }); expect(unlockSpy).toHaveBeenCalledWith( Config.wallet.defaultAccountIndex, Config.wallet.defaultAccountType, ); + expect(addWalletSpy).toHaveBeenCalledWith({ account: { - type: account.type, + type: keyringAccount.type, id: expect.any(String), - address: account.address, - options: { - scope, - index: account.options.index, - }, - methods: account.methods, + address: keyringAccount.address, + options: keyringAccount.options, + methods: keyringAccount.methods, }, - hdPath: getHdPath(account.options.index), - index: account.options.index, - scope, + hdPath: sender.hdPath, + index: sender.index, + scope: caip2ChainId, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index 6ba16239..7939b997 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -13,12 +13,12 @@ import type { Infer } from 'superstruct'; import { assert, object, StructError } from 'superstruct'; import { v4 as uuidv4 } from 'uuid'; +import type { BtcAccount, BtcWallet } from './bitcoin/wallet'; import { Config } from './config'; import { Factory } from './factory'; import { getBalances, type SendManyParams, sendMany } from './rpcs'; import type { KeyringStateManager, Wallet } from './stateManagement'; import { getProvider, scopeStruct, logger } from './utils'; -import type { IAccount, IWallet } from './wallet'; export type KeyringOptions = Record & { defaultIndex: number; @@ -230,20 +230,20 @@ export class BtcKeyring implements Keyring { return walletData; } - protected getBtcWallet(scope: string): IWallet { + protected getBtcWallet(scope: string): BtcWallet { return Factory.createWallet(scope); } protected async discoverAccount( - wallet: IWallet, + wallet: BtcWallet, index: number, type: string, - ): Promise { + ): Promise { return await wallet.unlock(index, type); } protected verifyIfAccountValid( - account: IAccount, + account: BtcAccount, keyringAccount: KeyringAccount, ): void { if (!account || account.address !== keyringAccount.address) { @@ -252,7 +252,7 @@ export class BtcKeyring implements Keyring { } protected newKeyringAccount( - account: IAccount, + account: BtcAccount, options?: CreateAccountOptions, ): KeyringAccount { return { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts index 3001f5b2..29d2c1a7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts @@ -4,10 +4,10 @@ import { networks } from 'bitcoinjs-lib'; import { v4 as uuidv4 } from 'uuid'; import { generateAccounts } from '../../test/utils'; +import { BtcOnChainService } from '../bitcoin/chain'; import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; import { Config } from '../config'; import { Caip2ChainId } from '../constants'; -import { Factory } from '../factory'; import { getBalances } from './get-balances'; jest.mock('../utils/logger'); @@ -16,17 +16,12 @@ jest.mock('../utils/snap'); describe('getBalances', () => { const asset = Config.avaliableAssets[0]; - const createMockChainApiFactory = () => { - const getBalancesSpy = jest.fn(); + const createMockChainService = () => { + const getBalancesSpy = jest.spyOn( + BtcOnChainService.prototype, + 'getBalances', + ); - jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ - getFeeRates: jest.fn(), - getBalances: getBalancesSpy, - broadcastTransaction: jest.fn(), - listTransactions: jest.fn(), - getTransactionStatus: jest.fn(), - getDataForTransaction: jest.fn(), - }); return { getBalancesSpy, }; @@ -75,7 +70,7 @@ describe('getBalances', () => { it('gets balances', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); + const { getBalancesSpy } = createMockChainService(); const { walletData, sender } = await createMockAccount( network, @@ -115,7 +110,7 @@ describe('getBalances', () => { it('gets balances of the request account only', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); + const { getBalancesSpy } = createMockChainService(); const accounts = generateAccounts(10); const { walletData, sender } = await createMockAccount( network, @@ -161,7 +156,7 @@ describe('getBalances', () => { it('throws `Fail to get the balances` when transaction status fetch failed', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); + const { getBalancesSpy } = createMockChainService(); const { sender } = await createMockAccount(network, caip2ChainId); getBalancesSpy.mockRejectedValue(new Error('error')); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts index 412a8238..d9cc8eb5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts @@ -1,6 +1,7 @@ import type { Infer } from 'superstruct'; import { object, array, record, enums, assert } from 'superstruct'; +import type { BtcAccount } from '../bitcoin/wallet'; import { Config } from '../config'; import { Factory } from '../factory'; import { @@ -15,7 +16,6 @@ import { positiveStringStruct, scopeStruct, } from '../utils/superstruct'; -import type { IAccount } from '../wallet'; export const getBalancesRequestStruct = object({ assets: array(assetsStruct), @@ -44,7 +44,7 @@ export type GetBalancesResponse = Infer; * @returns A Promise that resolves to an GetBalancesResponse object. */ export async function getBalances( - account: IAccount, + account: BtcAccount, params: GetBalancesParams, ) { try { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts index 8271c47c..138c76ce 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts @@ -1,8 +1,7 @@ import { InvalidParamsError } from '@metamask/snaps-sdk'; -import { TransactionStatus } from '../chain'; +import { BtcOnChainService, TransactionStatus } from '../bitcoin/chain'; import { Caip2ChainId } from '../constants'; -import { Factory } from '../factory'; import { getTransactionStatus } from './get-transaction-status'; jest.mock('../utils/logger'); @@ -11,24 +10,18 @@ describe('getTransactionStatus', () => { const txHash = '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; - const createMockChainApiFactory = () => { - const getTransactionStatusSpy = jest.fn(); - - jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ - getFeeRates: jest.fn(), - getBalances: jest.fn(), - broadcastTransaction: jest.fn(), - listTransactions: jest.fn(), - getTransactionStatus: getTransactionStatusSpy, - getDataForTransaction: jest.fn(), - }); + const createMockChainService = () => { + const getTransactionStatusSpy = jest.spyOn( + BtcOnChainService.prototype, + 'getTransactionStatus', + ); return { getTransactionStatusSpy, }; }; it('gets status', async () => { - const { getTransactionStatusSpy } = createMockChainApiFactory(); + const { getTransactionStatusSpy } = createMockChainService(); const mockResp = { status: TransactionStatus.Confirmed, @@ -48,7 +41,7 @@ describe('getTransactionStatus', () => { }); it('throws `Fail to get the transaction status` when transaction status fetch failed', async () => { - const { getTransactionStatusSpy } = createMockChainApiFactory(); + const { getTransactionStatusSpy } = createMockChainService(); getTransactionStatusSpy.mockRejectedValue(new Error('error')); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts index 5b7e89c8..1518575a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts @@ -1,7 +1,7 @@ import type { Infer } from 'superstruct'; import { object, string, enums } from 'superstruct'; -import { TransactionStatus } from '../chain'; +import { TransactionStatus } from '../bitcoin/chain'; import { Factory } from '../factory'; import { isSnapRpcError, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 741fa218..887a7f32 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -9,15 +9,18 @@ import { generateBlockChairBroadcastTransactionResp, generateBlockChairGetUtxosResp, } from '../../test/utils'; -import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; -import { FeeRatio } from '../chain'; +import { BtcOnChainService, FeeRatio } from '../bitcoin/chain'; +import type { BtcAccount } from '../bitcoin/wallet'; +import { + BtcAccountDeriver, + BtcWallet, + type ITxInfo, + TxValidationError, +} from '../bitcoin/wallet'; import { Config } from '../config'; import { Caip2ChainId } from '../constants'; -import { Factory } from '../factory'; -import { getExplorerUrl, shortenAddress } from '../utils'; +import { getExplorerUrl, shortenAddress, satsToBtc } from '../utils'; import * as snapUtils from '../utils/snap'; -import { satsToBtc } from '../utils/unit'; -import type { IAccount, ITxInfo } from '../wallet'; import { type SendManyParams, sendMany } from './sendmany'; jest.mock('../utils/logger'); @@ -26,18 +29,19 @@ jest.mock('../utils/snap'); describe('SendManyHandler', () => { describe('sendMany', () => { const createMockChainApiFactory = () => { - const getDataForTransactionSpy = jest.fn(); - const getFeeRatesSpy = jest.fn(); - const broadcastTransactionSpy = jest.fn(); - - jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ - getFeeRates: getFeeRatesSpy, - getBalances: jest.fn(), - broadcastTransaction: broadcastTransactionSpy, - listTransactions: jest.fn(), - getTransactionStatus: jest.fn(), - getDataForTransaction: getDataForTransactionSpy, - }); + const getFeeRatesSpy = jest.spyOn( + BtcOnChainService.prototype, + 'getFeeRates', + ); + const broadcastTransactionSpy = jest.spyOn( + BtcOnChainService.prototype, + 'broadcastTransaction', + ); + const getDataForTransactionSpy = jest.spyOn( + BtcOnChainService.prototype, + 'getDataForTransaction', + ); + return { getDataForTransactionSpy, getFeeRatesSpy, @@ -70,7 +74,7 @@ describe('SendManyHandler', () => { }, methods: ['btc_sendmany'], }; - const recipients: IAccount[] = []; + const recipients: BtcAccount[] = []; for (let i = 1; i < recipientCnt + 1; i++) { recipients.push( await wallet.unlock(i, Config.wallet.defaultAccountType), @@ -85,7 +89,7 @@ describe('SendManyHandler', () => { }; const createSendManyParams = ( - recipients: IAccount[], + recipients: BtcAccount[], caip2ChainId: string, dryrun: boolean, comment = '', @@ -389,24 +393,6 @@ describe('SendManyHandler', () => { ).rejects.toThrow('Invalid amount for send'); }); - it('throws `Failed to send the transaction` error if no fee rate returns from chain service', async () => { - const { getFeeRatesSpy } = createMockChainApiFactory(); - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients } = await createSenderNRecipients( - network, - caip2ChainId, - 10, - ); - getFeeRatesSpy.mockResolvedValue({ - fees: [], - }); - - await expect( - sendMany(sender, createSendManyParams(recipients, caip2ChainId, false)), - ).rejects.toThrow('Failed to send the transaction'); - }); - it('throws `Invalid response` error if the response is unexpected', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; @@ -414,9 +400,7 @@ describe('SendManyHandler', () => { await prepareSendMany(network, caip2ChainId); broadcastTransactionSpy.mockResolvedValue({ - transactionId: { - txId: 'invalid', - }, + transactionId: '', }); await expect( sendMany(sender, { @@ -441,6 +425,24 @@ describe('SendManyHandler', () => { ).rejects.toThrow(UserRejectedRequestError); }); + it('throws `Failed to send the transaction` error if no fee rate returns from chain service', async () => { + const { getFeeRatesSpy } = createMockChainApiFactory(); + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { sender, recipients } = await createSenderNRecipients( + network, + caip2ChainId, + 10, + ); + getFeeRatesSpy.mockResolvedValue({ + fees: [], + }); + + await expect( + sendMany(sender, createSendManyParams(recipients, caip2ChainId, false)), + ).rejects.toThrow('Failed to send the transaction'); + }); + it('throws `Failed to send the transaction` error if the transaction is fail to commit', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; @@ -454,5 +456,21 @@ describe('SendManyHandler', () => { }), ).rejects.toThrow('Failed to send the transaction'); }); + + it('throws DisplayableError error meesage if the DisplayableError throwed', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { broadcastTransactionSpy, sender, recipients } = + await prepareSendMany(network, caip2ChainId); + broadcastTransactionSpy.mockRejectedValue( + new TxValidationError('some tx error'), + ); + + await expect( + sendMany(sender, { + ...createSendManyParams(recipients, caip2ChainId, false), + }), + ).rejects.toThrow('some tx error'); + }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 5cc3ca58..8ce5d6ea 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -17,9 +17,14 @@ import { boolean, refine, optional, + nonempty, } from 'superstruct'; -import { TxValidationError } from '../bitcoin/wallet'; +import { + type BtcAccount, + type ITxInfo, + TxValidationError, +} from '../bitcoin/wallet'; import { Factory } from '../factory'; import { scopeStruct, @@ -33,7 +38,6 @@ import { validateResponse, logger, } from '../utils'; -import type { IAccount, ITxInfo } from '../wallet'; export const TransactionAmountStuct = refine( record(BtcP2wpkhAddressStruct, string()), @@ -74,7 +78,7 @@ export const sendManyParamsStruct = object({ }); export const sendManyResponseStruct = object({ - txId: string(), + txId: nonempty(string()), txHash: optional(string()), }); @@ -89,7 +93,7 @@ export type SendManyResponse = Infer; * @param params - The parameters for send the transaction. * @returns A Promise that resolves to an SendManyResponse object. */ -export async function sendMany(account: IAccount, params: SendManyParams) { +export async function sendMany(account: BtcAccount, params: SendManyParams) { try { validateRequest(params, sendManyParamsStruct); @@ -153,10 +157,7 @@ export async function sendMany(account: IAccount, params: SendManyParams) { throw error as unknown as Error; } - if ( - error instanceof TxValidationError || - error instanceof UserRejectedRequestError - ) { + if (error instanceof TxValidationError) { throw error as unknown as Error; } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/static.ts b/merged-packages/bitcoin-wallet-snap/src/utils/static.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/static.ts rename to merged-packages/bitcoin-wallet-snap/src/utils/static.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts index c038e30f..a0dbbcdb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts @@ -1,5 +1,4 @@ -import { maxSatoshi, minSatoshi } from '../bitcoin/constants'; -import { satsToBtc, btcToSats } from './unit'; +import { satsToBtc, btcToSats, maxSatoshi, minSatoshi } from './unit'; describe('satsToBtc', () => { it('returns Btc unit', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts index f27b1fb8..79fd4a1b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts @@ -1,8 +1,13 @@ import Big from 'big.js'; -import { maxSatoshi } from '../bitcoin/constants'; import { Config } from '../config'; +// Maximum amount of satoshis +export const maxSatoshi = 21 * 1e14; + +// Minimum amount of satoshis +export const minSatoshi = 1; + /** * Converts a satoshis to a string representing the equivalent amount of BTC. * diff --git a/merged-packages/bitcoin-wallet-snap/src/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/wallet.ts deleted file mode 100644 index 9dbc86b9..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/wallet.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { Json } from '@metamask/snaps-sdk'; -import type { Buffer } from 'buffer'; - -export type Recipient = { - address: string; - value: bigint; -}; - -export type Transaction = { - tx: string; - txInfo: ITxInfo; -}; - -/** - * An interface that defines a `toJson` method for getting a JSON representation of a transaction info object. - */ -export type ITxInfo = { - sender: string; - change?: Recipient; - recipients: Recipient[]; - total: bigint; - txFee: bigint; - feeRate: bigint; -}; - -/** - * An interface that defines methods for unlocking accounts, signing transactions, and creating transactions. - */ -export type IWallet = { - /** - * Unlocks an account by index and script type. - * - * @param index - The index to derive from the node. - * @param type - The script type of the unlocked account, e.g. `bip122:p2pkh`. - * @returns A promise that resolves to an `IAccount` object. - */ - unlock(index: number, type?: string): Promise; - - /** - * Signs a transaction by the given encoded transaction string. - * - * @param signer - The `IAccountSigner` object to sign the transaction. - * @param transaction - The encoded transaction string to convert back to a transaction. - * @returns A promise that resolves to a string of the signed transaction. - */ - signTransaction(signer: IAccountSigner, transaction: string): Promise; - - /** - * Creates a transaction using the given account, transaction intent, and options. - * - * @param account - The `IAccount` object to create the transaction. - * @param recipients - The transaction recipients. - * @param options - The options to use when creating the transaction. - * @returns A promise that resolves to an object containing the transaction hash and transaction info. - */ - createTransaction( - account: IAccount, - recipients: Recipient[], - options: Record, - ): Promise; -}; - -/** - * An interface that defines properties for an account, including its address, HD path, public key, and signer object. - */ -export type IAccount = { - /** - * The master fingerprint of the derived node, as a string. - */ - mfp: string; - /** - * The index of the derived node, as a number. - */ - index: number; - /** - * The address of the account, as a string. - */ - address: string; - /** - * The HD path of the account, as a string. - */ - hdPath: string; - /** - * The public key of the account, as a string. - */ - pubkey: string; - /** - * The type of the account, e.g. `bip122:p2pwh`, as a string. - */ - type: string; - /** - * The `IAccountSigner` object derived from the root node. - */ - signer: IAccountSigner; -}; - -/** - * An interface that defines methods and properties for signing transactions and verifying signatures. - */ -export type IAccountSigner = { - /** - * Signs a transaction hash. - * - * @param hash - The buffer containing the transaction hash to sign. - * @returns A promise that resolves to the signed result as a Buffer. - */ - sign(hash: Buffer): Promise; - - /** - * Derives a new `IAccountSigner` object using an HD path. - * - * @param path - The HD path in string format, e.g. `m'\0'\0`. - * @returns A new `IAccountSigner` object derived by the given path. - */ - derivePath(path: string): IAccountSigner; - - /** - * Verifies a signature using the derived node of an `IAccountSigner` object. - * - * @param hash - The buffer containing the transaction hash. - * @param signature - The buffer containing the signature to verify. - * @returns A boolean indicating whether the signature is valid. - */ - verify(hash: Buffer, signature: Buffer): boolean; - - /** - * The public key of the current derived node used for verifying signatures, as a Buffer. - */ - publicKey: Buffer; - - /** - * The fingerprint of the current derived node used for verifying signatures, as a Buffer. - */ - fingerprint: Buffer; -}; From 8a6d8d4615e09067d6aa91036df342d569ef9848 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 13 Jun 2024 15:57:58 +0800 Subject: [PATCH 068/362] chore: update readme and env example (#111) * chore: update readme and env example * Update README.md --- .../bitcoin-wallet-snap/.env.example | 10 --- merged-packages/bitcoin-wallet-snap/README.md | 75 ++++++++++--------- 2 files changed, 40 insertions(+), 45 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/.env.example b/merged-packages/bitcoin-wallet-snap/.env.example index 6ba8529b..f2c971bf 100644 --- a/merged-packages/bitcoin-wallet-snap/.env.example +++ b/merged-packages/bitcoin-wallet-snap/.env.example @@ -1,13 +1,3 @@ -# Description: Environment variables for read data client -# Possible Options: BlockStream | BlockChair -# Default: BlockChair -# Required: false -DATA_CLIENT_READ_TYPE=BlockStream -# Description: Environment variables for write data client -# Possible Options: BlockChair -# Default: BlockChair -# Required: false -DATA_CLIENT_WRITE_TYPE=BlockChair # Description: Environment variables for Log Level, 0 does not log anything, 6 logs everything # Possible Options: 0-6 # Default: 0 diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index a42e75ad..df6450a7 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -6,20 +6,6 @@ Rename `.env.example` to `.env` Configurations are setup though `.env`, ```bash -# Description: Environment variables for read data client -# Possible Options: BlockStream | BlockChair -# Default: BlockChair -# Required: false -DATA_CLIENT_READ_TYPE=BlockStream -# Description: Environment variables for write data client -# Possible Options: BlockChair -# Default: BlockChair -# Required: false -DATA_CLIENT_WRITE_TYPE=BlockChair -# Description: Environment variables for Log Level, 0 does not log anything, 6 logs everything -# Possible Options: 0-6 -# Default: 0 -# Required: false LOG_LEVEL=6 # Description: Environment variables for API key of BlockChair # Required: false @@ -28,27 +14,26 @@ BLOCKCHAIR_API_KEY= ## Rpcs: -### API `chain_getTransactionStatus` +### API `keyring_createAccount` example: ```typescript provider.request({ - method: 'wallet_invokeSnap', + method: 'wallet_invokeKeyring', params: { snapId, request: { - method: 'chain_getTransactionStatus', + method: 'keyring_createAccount', params: { scope: 'bip122:000000000933ea01ad0ee984209779ba', // the CAIP-2 chain ID of bitcoin - transactionId: '5639078d-742e-4901-8993-bc25a5ef6161', // the txn id of an bitcoin transaction }, }, }, }); ``` -### API `btc_sendmany` +### API `keyring_getAccountBalances` example: @@ -58,30 +43,38 @@ provider.request({ params: { snapId, request: { - method: 'keyring_submitRequest', + method: 'keyring_getAccountBalances', params: { account: 'dc06350a-82db-434b-b113-066135804f63', // the uuid account id of the current account id: 'da40b782-e054-4260-9a6a-c8717a022f92', // an random uuid for this request + assets: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], // Caip-2 BTC Asset + }, + }, + }, +}); +``` + +### API `chain_getTransactionStatus` + +example: + +```typescript +provider.request({ + method: 'wallet_invokeSnap', + params: { + snapId, + request: { + method: 'chain_getTransactionStatus', + params: { scope: 'bip122:000000000933ea01ad0ee984209779ba', // the CAIP-2 chain ID of bitcoin - request: { - method: 'btc_sendmany', - params: { - amounts: { - ['tb1qlhkuysju47s642834n7f3tyk67mvnt2cfd9r7y']: '0.00000500', - }, // the recipient struct to indicate how many BTC to be received for each recipient - comment: 'some comment', - subtractFeeFrom: [], // not support yet - replaceable: false, // an flag to opt-in RBF - dryrun: true, // an flag to enable similation of the transaction, without broadcast to network - }, - }, + transactionId: '5639078d-742e-4901-8993-bc25a5ef6161', // the txn id of an bitcoin transaction }, }, }, }); ``` -### API `keyring_getAccountBalances` +### API `btc_sendmany` example: @@ -91,11 +84,23 @@ provider.request({ params: { snapId, request: { - method: 'keyring_getAccountBalances', + method: 'keyring_submitRequest', params: { account: 'dc06350a-82db-434b-b113-066135804f63', // the uuid account id of the current account id: 'da40b782-e054-4260-9a6a-c8717a022f92', // an random uuid for this request - assets: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], // Caip-2 BTC Asset + scope: 'bip122:000000000933ea01ad0ee984209779ba', // the CAIP-2 chain ID of bitcoin + request: { + method: 'btc_sendmany', + params: { + amounts: { + ['tb1qlhkuysju47s642834n7f3tyk67mvnt2cfd9r7y']: '0.00000500', + }, // the recipient struct to indicate how many BTC to be received for each recipient + comment: 'some comment', + subtractFeeFrom: [], // not support yet + replaceable: false, // an flag to opt-in RBF + dryrun: true, // an flag to enable similation of the transaction, without broadcast to network + }, + }, }, }, }, From e0cc7bc684d8088e3caae8d36c2459265512f36b Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 4 Jul 2024 16:47:04 +0800 Subject: [PATCH 069/362] Revert "chore: update readme and env example (#111)" This reverts commit 8a6d8d4615e09067d6aa91036df342d569ef9848. --- .../bitcoin-wallet-snap/.env.example | 10 +++ merged-packages/bitcoin-wallet-snap/README.md | 75 +++++++++---------- 2 files changed, 45 insertions(+), 40 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/.env.example b/merged-packages/bitcoin-wallet-snap/.env.example index f2c971bf..6ba8529b 100644 --- a/merged-packages/bitcoin-wallet-snap/.env.example +++ b/merged-packages/bitcoin-wallet-snap/.env.example @@ -1,3 +1,13 @@ +# Description: Environment variables for read data client +# Possible Options: BlockStream | BlockChair +# Default: BlockChair +# Required: false +DATA_CLIENT_READ_TYPE=BlockStream +# Description: Environment variables for write data client +# Possible Options: BlockChair +# Default: BlockChair +# Required: false +DATA_CLIENT_WRITE_TYPE=BlockChair # Description: Environment variables for Log Level, 0 does not log anything, 6 logs everything # Possible Options: 0-6 # Default: 0 diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index df6450a7..a42e75ad 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -6,6 +6,20 @@ Rename `.env.example` to `.env` Configurations are setup though `.env`, ```bash +# Description: Environment variables for read data client +# Possible Options: BlockStream | BlockChair +# Default: BlockChair +# Required: false +DATA_CLIENT_READ_TYPE=BlockStream +# Description: Environment variables for write data client +# Possible Options: BlockChair +# Default: BlockChair +# Required: false +DATA_CLIENT_WRITE_TYPE=BlockChair +# Description: Environment variables for Log Level, 0 does not log anything, 6 logs everything +# Possible Options: 0-6 +# Default: 0 +# Required: false LOG_LEVEL=6 # Description: Environment variables for API key of BlockChair # Required: false @@ -14,46 +28,6 @@ BLOCKCHAIR_API_KEY= ## Rpcs: -### API `keyring_createAccount` - -example: - -```typescript -provider.request({ - method: 'wallet_invokeKeyring', - params: { - snapId, - request: { - method: 'keyring_createAccount', - params: { - scope: 'bip122:000000000933ea01ad0ee984209779ba', // the CAIP-2 chain ID of bitcoin - }, - }, - }, -}); -``` - -### API `keyring_getAccountBalances` - -example: - -```typescript -provider.request({ - method: 'wallet_invokeKeyring', - params: { - snapId, - request: { - method: 'keyring_getAccountBalances', - params: { - account: 'dc06350a-82db-434b-b113-066135804f63', // the uuid account id of the current account - id: 'da40b782-e054-4260-9a6a-c8717a022f92', // an random uuid for this request - assets: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], // Caip-2 BTC Asset - }, - }, - }, -}); -``` - ### API `chain_getTransactionStatus` example: @@ -106,3 +80,24 @@ provider.request({ }, }); ``` + +### API `keyring_getAccountBalances` + +example: + +```typescript +provider.request({ + method: 'wallet_invokeKeyring', + params: { + snapId, + request: { + method: 'keyring_getAccountBalances', + params: { + account: 'dc06350a-82db-434b-b113-066135804f63', // the uuid account id of the current account + id: 'da40b782-e054-4260-9a6a-c8717a022f92', // an random uuid for this request + assets: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], // Caip-2 BTC Asset + }, + }, + }, +}); +``` From 437a19d3dfa0a2cd3128c0a401d6fefea438f36b Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 4 Jul 2024 16:47:04 +0800 Subject: [PATCH 070/362] Revert "chore: simplify code structure batch 2 (#110)" This reverts commit 38bf616b32bc66b4a26479764fdfda04ef98bb89. --- .../bitcoin-wallet-snap/jest.config.js | 2 +- .../bitcoin/chain/clients/blockchair.test.ts | 2 +- .../src/bitcoin/chain/clients/blockchair.ts | 41 +++--- .../src/bitcoin/chain/constants.ts | 11 -- .../src/bitcoin/chain/data-client.ts | 63 ++------ .../src/bitcoin/chain/index.ts | 1 - .../src/bitcoin/chain/service.test.ts | 28 +++- .../src/bitcoin/chain/service.ts | 95 +++--------- .../src/bitcoin/{wallet => }/constants.ts | 6 + .../{wallet => }/utils/address.test.ts | 0 .../src/bitcoin/{wallet => }/utils/address.ts | 0 .../src/bitcoin/{wallet => }/utils/index.ts | 0 .../{wallet => }/utils/network.test.ts | 2 +- .../src/bitcoin/{wallet => }/utils/network.ts | 2 +- .../{wallet => }/utils/payment.test.ts | 0 .../src/bitcoin/{wallet => }/utils/payment.ts | 0 .../bitcoin/{wallet => }/utils/policy.test.ts | 0 .../src/bitcoin/{wallet => }/utils/policy.ts | 0 .../src/bitcoin/wallet/account.test.ts | 8 +- .../src/bitcoin/wallet/account.ts | 66 +++------ .../src/bitcoin/wallet/coin-select.test.ts | 2 +- .../src/bitcoin/wallet/coin-select.ts | 13 +- .../src/bitcoin/wallet/deriver.test.ts | 8 +- .../src/bitcoin/wallet/deriver.ts | 48 +++---- .../src/bitcoin/wallet/index.ts | 4 +- .../src/bitcoin/wallet/psbt.test.ts | 10 +- .../src/bitcoin/wallet/psbt.ts | 113 ++++----------- .../src/bitcoin/wallet/signer.ts | 34 +---- .../src/{utils => bitcoin/wallet}/static.ts | 0 .../bitcoin/wallet/transaction-info.test.ts | 129 ++++++----------- .../src/bitcoin/wallet/transaction-info.ts | 4 +- .../bitcoin/wallet/transaction-input.test.ts | 14 +- .../src/bitcoin/wallet/transaction-input.ts | 12 +- .../src/bitcoin/wallet/wallet.test.ts | 35 ++--- .../src/bitcoin/wallet/wallet.ts | 67 +++------ .../bitcoin-wallet-snap/src/chain.ts | 111 ++++++++++++++ .../bitcoin-wallet-snap/src/factory.ts | 9 +- .../bitcoin-wallet-snap/src/index.test.ts | 2 +- .../bitcoin-wallet-snap/src/keyring.test.ts | 60 +++++--- .../bitcoin-wallet-snap/src/keyring.ts | 12 +- .../src/rpcs/get-balances.test.ts | 23 +-- .../src/rpcs/get-balances.ts | 4 +- .../src/rpcs/get-transaction-status.test.ts | 23 +-- .../src/rpcs/get-transaction-status.ts | 2 +- .../src/rpcs/sendmany.test.ts | 100 ++++++------- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 17 ++- .../src/utils/unit.test.ts | 3 +- .../bitcoin-wallet-snap/src/utils/unit.ts | 7 +- .../bitcoin-wallet-snap/src/wallet.ts | 135 ++++++++++++++++++ 49 files changed, 624 insertions(+), 704 deletions(-) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{wallet => }/constants.ts (90%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{wallet => }/utils/address.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{wallet => }/utils/address.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{wallet => }/utils/index.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{wallet => }/utils/network.test.ts (96%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{wallet => }/utils/network.ts (95%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{wallet => }/utils/payment.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{wallet => }/utils/payment.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{wallet => }/utils/policy.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{wallet => }/utils/policy.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{utils => bitcoin/wallet}/static.ts (100%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/chain.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/wallet.ts diff --git a/merged-packages/bitcoin-wallet-snap/jest.config.js b/merged-packages/bitcoin-wallet-snap/jest.config.js index 491cb4eb..3bc03249 100644 --- a/merged-packages/bitcoin-wallet-snap/jest.config.js +++ b/merged-packages/bitcoin-wallet-snap/jest.config.js @@ -7,7 +7,7 @@ module.exports = { restoreMocks: true, resetMocks: true, verbose: true, - testPathIgnorePatterns: ['/node_modules/', '/__mocks__/'], + testPathIgnorePatterns: ['/node_modules/', '/__BAK__/', '/__mocks__/'], 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 diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts index 6b81a97d..7a1e501d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts @@ -8,7 +8,7 @@ import { generateBlockChairGetStatsResp, generateBlockChairTransactionDashboard, } from '../../../../test/utils'; -import { FeeRatio, TransactionStatus } from '../constants'; +import { FeeRatio, TransactionStatus } from '../../../chain'; import { DataClientError } from '../exceptions'; import { BlockChairClient } from './blockchair'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts index 4a0965e9..f7534517 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts @@ -1,15 +1,14 @@ import type { Json } from '@metamask/snaps-sdk'; import { type Network, networks } from 'bitcoinjs-lib'; -import { compactError, logger } from '../../../utils'; -import { FeeRatio, TransactionStatus } from '../constants'; +import type { TransactionStatusData, Utxo } from '../../../chain'; +import { FeeRatio, TransactionStatus } from '../../../chain'; +import { compactError } from '../../../utils'; +import { logger } from '../../../utils/logger'; import type { + GetBalancesResp, + GetFeeRatesResp, IDataClient, - DataClientGetBalancesResp, - DataClientGetTxStatusResp, - DataClientGetUtxosResp, - DataClientSendTxResp, - DataClientGetFeeRatesResp, } from '../data-client'; import { DataClientError } from '../exceptions'; @@ -290,7 +289,7 @@ export class BlockChairClient implements IDataClient { } } - async getBalances(addresses: string[]): Promise { + async getBalances(addresses: string[]): Promise { try { logger.info( `[BlockChairClient.getBalance] start: { addresses : ${JSON.stringify( @@ -306,28 +305,26 @@ export class BlockChairClient implements IDataClient { `[BlockChairClient.getBalance] response: ${JSON.stringify(response)}`, ); - return addresses.reduce( - (data: DataClientGetBalancesResp, address: string) => { - data[address] = response.data[address] ?? 0; - return data; - }, - {}, - ); + return addresses.reduce((data: GetBalancesResp, address: string) => { + data[address] = response.data[address] ?? 0; + return data; + }, {}); } catch (error) { logger.info(`[BlockChairClient.getBalance] error: ${error.message}`); throw compactError(error, DataClientError); } } + // TODO: Get UTXOs that sufficiently cover the amount and fee, to reduce the number of requests async getUtxos( address: string, includeUnconfirmed?: boolean, - ): Promise { + ): Promise { try { let process = true; let offset = 0; const limit = 1000; - const data: DataClientGetUtxosResp = []; + const data: Utxo[] = []; while (process) { let url = `/dashboards/address/${address}?limit=0,${limit}&offset=0,${offset}`; @@ -368,7 +365,7 @@ export class BlockChairClient implements IDataClient { } } - async getFeeRates(): Promise { + async getFeeRates(): Promise { try { logger.info(`[BlockChairClient.getFeeRates] start:`); const response = await this.get(`/stats`); @@ -384,9 +381,7 @@ export class BlockChairClient implements IDataClient { } } - async sendTransaction( - signedTransaction: string, - ): Promise { + async sendTransaction(signedTransaction: string): Promise { try { const response = await this.post( `/push/transaction`, @@ -408,9 +403,7 @@ export class BlockChairClient implements IDataClient { } } - async getTransactionStatus( - txHash: string, - ): Promise { + async getTransactionStatus(txHash: string): Promise { try { const response = await this.getTxDashboardData(txHash); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts deleted file mode 100644 index 3a0720f6..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -export enum FeeRatio { - Fast = 'fast', - Medium = 'medium', - Slow = 'slow', -} - -export enum TransactionStatus { - Confirmed = 'confirmed', - Pending = 'pending', - Failed = 'failed', -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts index 3e38e9cb..a510b1a6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts @@ -1,62 +1,15 @@ -import type { FeeRatio } from './constants'; -import type { TransactionStatusData, Utxo } from './service'; +import type { FeeRatio, TransactionStatusData, Utxo } from '../../chain'; -export type DataClientGetBalancesResp = Record; +export type GetBalancesResp = Record; -export type DataClientGetUtxosResp = Utxo[]; - -export type DataClientGetFeeRatesResp = { +export type GetFeeRatesResp = { [key in FeeRatio]?: number; }; -export type DataClientGetTxStatusResp = TransactionStatusData; - -export type DataClientSendTxResp = string; - -/** - * This interface defines the methods available on a data client for interacting with the Bitcoin blockchain. - */ export type IDataClient = { - /** - * Gets the balances for a set of Bitcoin addresses. - * - * @param {string[]} address - An array of Bitcoin addresses to query. - * @returns {Promise} A promise that resolves to a record of addresses and their corresponding balances. - */ - getBalances(address: string[]): Promise; - - /** - * Gets the UTXOs for a Bitcoin address. - * - * @param {string} address - The Bitcoin address to query. - * @param {boolean} [includeUnconfirmed] - Whether or not to include unconfirmed UTXOs in the response. Defaults to false. - * @returns {Promise} A promise that resolves to an array of UTXOs. - */ - getUtxos( - address: string, - includeUnconfirmed?: boolean, - ): Promise; - - /** - * Gets the fee rates for the Bitcoin network. - * - * @returns {Promise} A promise that resolves to an object containing fee rates for different ratios. - */ - getFeeRates(): Promise; - - /** - * Gets the status of a transaction given its hash. - * - * @param {string} txHash - The hash of the transaction to query. - * @returns {Promise} A promise that resolves to the transaction status data. - */ - getTransactionStatus(txHash: string): Promise; - - /** - * Sends a transaction to the Bitcoin network. - * - * @param {string} tx - The transaction to send. - * @returns {Promise} A promise that resolves to the transaction hash. - */ - sendTransaction(tx: string): Promise; + getBalances(address: string[]): Promise; + getUtxos(address: string, includeUnconfirmed?: boolean): Promise; + getFeeRates(): Promise; + getTransactionStatus(txHash: string): Promise; + sendTransaction(tx: string): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts index 651c3bdb..cb209e05 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts @@ -1,3 +1,2 @@ export * from './exceptions'; export * from './service'; -export * from './constants'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts index 68e63b56..ad3dc7bf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts @@ -6,8 +6,8 @@ import { generateBlockChairBroadcastTransactionResp, generateBlockChairGetUtxosResp, } from '../../../test/utils'; +import { FeeRatio, TransactionStatus } from '../../chain'; import { Caip2Asset } from '../../constants'; -import { FeeRatio, TransactionStatus } from './constants'; import type { IDataClient } from './data-client'; import { BtcOnChainServiceError } from './exceptions'; import { BtcOnChainService } from './service'; @@ -127,8 +127,9 @@ describe('BtcOnChainService', () => { it('calls getUtxos with readClient', async () => { const { instance, getUtxosSpy } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); - const accounts = generateAccounts(1); + const accounts = generateAccounts(2); const sender = accounts[0].address; + const receiver = accounts[1].address; const mockResponse = generateBlockChairGetUtxosResp(sender, 10); const utxos = mockResponse.data[sender].utxo.map((utxo) => ({ block: utxo.block_id, @@ -139,7 +140,13 @@ describe('BtcOnChainService', () => { getUtxosSpy.mockResolvedValue(utxos); - const result = await txService.getDataForTransaction(sender); + const result = await txService.getDataForTransaction(sender, { + amounts: { + [receiver]: 100, + }, + subtractFeeFrom: [], + replaceable: true, + }); expect(getUtxosSpy).toHaveBeenCalledWith(sender); expect(result).toStrictEqual({ @@ -152,14 +159,21 @@ describe('BtcOnChainService', () => { it('throws error if readClient fail', async () => { const { instance, getUtxosSpy } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); - const accounts = generateAccounts(1); + const accounts = generateAccounts(2); const sender = accounts[0].address; + const receiver = accounts[1].address; getUtxosSpy.mockRejectedValue(new Error('error')); - await expect(txService.getDataForTransaction(sender)).rejects.toThrow( - BtcOnChainServiceError, - ); + await expect( + txService.getDataForTransaction(sender, { + amounts: { + [receiver]: 100, + }, + subtractFeeFrom: [], + replaceable: true, + }), + ).rejects.toThrow(BtcOnChainServiceError); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index 4817481c..d22aa65e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -1,62 +1,25 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; +import type { + FeeRatio, + IOnChainService, + AssetBalances, + TransactionIntent, + Fees, + TransactionData, + CommitedTransaction, +} from '../../chain'; import { Caip2Asset } from '../../constants'; import { compactError } from '../../utils'; -import type { FeeRatio, TransactionStatus } from './constants'; import type { IDataClient } from './data-client'; import { BtcOnChainServiceError } from './exceptions'; -export type TransactionStatusData = { - status: TransactionStatus; -}; - -export type Balance = { - amount: bigint; -}; - -export type AssetBalances = { - balances: { - [address: string]: { - [asset: string]: Balance; - }; - }; -}; - -export type Fee = { - type: FeeRatio; - rate: bigint; -}; - -export type Fees = { - fees: { - type: FeeRatio; - rate: bigint; - }[]; -}; - -export type Utxo = { - block: number; - txHash: string; - index: number; - value: number; -}; - -export type TransactionData = { - data: { - utxos: Utxo[]; - }; -}; - -export type CommitedTransaction = { - transactionId: string; -}; - export type BtcOnChainServiceOptions = { network: Network; }; -export class BtcOnChainService { +export class BtcOnChainService implements IOnChainService { protected readonly _dataClient: IDataClient; protected readonly _options: BtcOnChainServiceOptions; @@ -70,13 +33,6 @@ export class BtcOnChainService { return this._options.network; } - /** - * Gets the balances for multiple addresses and multiple assets. - * - * @param addresses - An array of addresses to fetch the balances for. - * @param assets - An array of assets to fetch the balances of. - * @returns A promise that resolves to an `AssetBalances` object. - */ async getBalances( addresses: string[], assets: string[], @@ -114,11 +70,6 @@ export class BtcOnChainService { } } - /** - * Gets the fee rates of the network. - * - * @returns A promise that resolves to a `Fees` object. - */ async getFeeRates(): Promise { try { const result = await this._dataClient.getFeeRates(); @@ -136,13 +87,7 @@ export class BtcOnChainService { } } - /** - * Gets the status of a transaction with the given transaction hash. - * - * @param txHash - The transaction hash of the transaction to get the status of. - * @returns A promise that resolves to a `TransactionStatusData` object. - */ - async getTransactionStatus(txHash: string): Promise { + async getTransactionStatus(txHash: string) { try { return await this._dataClient.getTransactionStatus(txHash); } catch (error) { @@ -150,13 +95,11 @@ export class BtcOnChainService { } } - /** - * Gets the required metadata to build a transaction for the given address and transaction intent. - * - * @param address - The address to build the transaction for. - * @returns A promise that resolves to a `TransactionData` object. - */ - async getDataForTransaction(address: string): Promise { + async getDataForTransaction( + address: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + transactionIntent?: TransactionIntent, + ): Promise { try { const data = await this._dataClient.getUtxos(address); return { @@ -169,12 +112,6 @@ export class BtcOnChainService { } } - /** - * Broadcasts a signed transaction on the blockchain network. - * - * @param signedTransaction - A signed transaction string. - * @returns A promise that resolves to a `CommitedTransaction` object. - */ async broadcastTransaction( signedTransaction: string, ): Promise { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/constants.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts similarity index 90% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/constants.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts index 7fb64ebb..0aa18b3d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts @@ -29,3 +29,9 @@ export const DustLimit = { // Maximum weight in bytes for a standard transaction export const MaxStandardTxWeight = 400000; + +// Maximum amount of satoshis +export const maxSatoshi = 21 * 1e14; + +// Minimum amount of satoshis +export const minSatoshi = 1; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/index.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts similarity index 96% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts index e60ddc65..279d4297 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts @@ -1,6 +1,6 @@ import { networks } from 'bitcoinjs-lib'; -import { Caip2ChainId } from '../../../constants'; +import { Caip2ChainId } from '../../constants'; import { getBtcNetwork, getCaip2ChainId } from './network'; describe('getBtcNetwork', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts similarity index 95% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts index 7b8b4f25..9900e71e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts @@ -1,7 +1,7 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; -import { Caip2ChainId } from '../../../constants'; +import { Caip2ChainId } from '../../constants'; /** * Gets a bitcoinjs-lib network instance based on a given CAIP-2 Chain ID. diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/payment.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts index 3cb4dcf3..e49eb86e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts @@ -2,10 +2,10 @@ import type { Network, Payment } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; +import type { IAccountSigner } from '../../wallet'; +import { ScriptType } from '../constants'; +import * as utils from '../utils/payment'; import { P2WPKHAccount } from './account'; -import { ScriptType } from './constants'; -import type { AccountSigner } from './signer'; -import * as utils from './utils/payment'; describe('BtcAccount', () => { const createMockPaymentInstance = (address: string | undefined) => { @@ -31,7 +31,7 @@ describe('BtcAccount', () => { network, P2WPKHAccount.scriptType, `bip122:${P2WPKHAccount.scriptType.toLowerCase()}`, - { sign: signerSpy } as unknown as AccountSigner, + { sign: signerSpy } as unknown as IAccountSigner, ); return { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts index fbde1ef8..09c33648 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts @@ -2,10 +2,17 @@ import type { Network, Payment } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; import { hexToBuffer } from '../../utils'; -import type { StaticImplements } from '../../utils/static'; -import { ScriptType } from './constants'; -import type { AccountSigner } from './signer'; -import { getBtcPaymentInst } from './utils'; +import type { IAccount, IAccountSigner } from '../../wallet'; +import { ScriptType } from '../constants'; +import { getBtcPaymentInst } from '../utils'; +import type { StaticImplements } from './static'; + +export type IBtcAccount = IAccount & { + payment: Payment; + scriptType: ScriptType; + network: Network; + script: Buffer; +}; export type IStaticBtcAccount = { path: string[]; @@ -18,48 +25,30 @@ export type IStaticBtcAccount = { network: Network, scriptType: ScriptType, type: string, - signer: AccountSigner, - ): BtcAccount; + signer: IAccountSigner, + ): IBtcAccount; }; -export abstract class BtcAccount { +export abstract class BtcAccount implements IBtcAccount { #address: string; #payment: Payment; - readonly network: Network; - - readonly scriptType: ScriptType; - - /** - * The master fingerprint of the derived node, as a string. - */ readonly mfp: string; - /** - * The index of the derived node, as a number. - */ readonly index: number; - /** - * The HD path of the account, as a string. - */ readonly hdPath: string; - /** - * The public key of the account, as a string. - */ readonly pubkey: string; - /** - * The type of the account, e.g. `bip122:p2pwh`, as a string. - */ + readonly network: Network; + + readonly scriptType: ScriptType; + readonly type: string; - /** - * The `IAccountSigner` object derived from the root node. - */ - readonly signer: AccountSigner; + readonly signer: IAccountSigner; constructor( mfp: string, @@ -69,7 +58,7 @@ export abstract class BtcAccount { network: Network, scriptType: ScriptType, type: string, - signer: AccountSigner, + signer: IAccountSigner, ) { this.mfp = mfp; this.index = index; @@ -81,21 +70,11 @@ export abstract class BtcAccount { this.type = type; } - /** - * A getter function to return the correcsponing account type's output script. - * - * @returns The correcsponing account type's output script. - */ get script(): Buffer { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return this.payment.output!; } - /** - * A getter function to return the correcsponing account type's address. - * - * @returns The correcsponing account type's address. - */ get address(): string { if (!this.#address) { if (!this.payment.address) { @@ -106,11 +85,6 @@ export abstract class BtcAccount { return this.#address; } - /** - * A getter function to return the correcsponing account type's payment instance. - * - * @returns The correcsponing account type's payment instance. - */ get payment(): Payment { if (!this.#payment) { this.#payment = getBtcPaymentInst( diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts index c821fbf4..0e123f68 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts @@ -1,8 +1,8 @@ import { networks, address as addressUtils } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; +import { ScriptType } from '../constants'; import { CoinSelectService } from './coin-select'; -import { ScriptType } from './constants'; import { BtcAccountDeriver } from './deriver'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index bcfe0d97..2514e825 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -18,21 +18,12 @@ export class CoinSelectService { this._feeRate = Math.round(feeRate); } - /** - * This function selects the UTXOs that will be used to pay for a transaction and its associated gas fee. - * - * @param inputs - An array of input objects. - * @param outputs - An array of output objects. - * @param changeTo - The change output object. - * @returns A SelectionResult object that includes the calculated transaction fee, selected inputs, outputs, and change (if any). - * @throws {TxValidationError} Throws a TxValidationError if there are insufficient funds to complete the transaction. - */ selectCoins( inputs: TxInput[], - outputs: TxOutput[], + recipients: TxOutput[], changeTo: TxOutput, ): SelectionResult { - const result = coinSelect(inputs, outputs, this._feeRate); + const result = coinSelect(inputs, recipients, this._feeRate); if (!result.inputs || !result.outputs) { throw new TxValidationError('Insufficient funds'); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts index 75f9692f..09970973 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts @@ -99,7 +99,7 @@ describe('BtcAccountDeriver', () => { expect(result.privateKey).toStrictEqual(pkBuffer); }); - it('throws error if private key is invalid', async () => { + it('throws `Private key is invalid` if private key is invalid', async () => { const network = networks.testnet; const spy = jest.spyOn(strUtils, 'hexToBuffer'); spy.mockImplementation(() => { @@ -108,11 +108,11 @@ describe('BtcAccountDeriver', () => { const { deriver } = await prepareBip32Deriver(network); await expect(deriver.getRoot(P2WPKHAccount.path)).rejects.toThrow( - 'error', + 'Private key is invalid', ); }); - it('throws error if chain code is invalid', async () => { + it('throws `Chain code is invalid` if chain code is invalid', async () => { const network = networks.testnet; const spy = jest.spyOn(strUtils, 'hexToBuffer'); spy @@ -123,7 +123,7 @@ describe('BtcAccountDeriver', () => { const { deriver } = await prepareBip32Deriver(network); await expect(deriver.getRoot(P2WPKHAccount.path)).rejects.toThrow( - 'error', + 'Chain code is invalid', ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts index 18809e37..1e5507e5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts @@ -7,17 +7,11 @@ import type { Buffer } from 'buffer'; import { compactError, hexToBuffer, getBip32Deriver } from '../../utils'; import { DeriverError } from './exceptions'; -/** - * This class provides a mechanism for deriving Bitcoin accounts using BIP32. - */ export class BtcAccountDeriver { protected readonly _network: Network; protected readonly _bip32Api: BIP32API; - /** - * The curve to use for account derivation. Defaults to 'secp256k1'. - */ readonly curve: 'secp256k1' | 'ed25519' = 'secp256k1'; constructor(network: Network) { @@ -25,13 +19,6 @@ export class BtcAccountDeriver { this._network = network; } - /** - * Gets the root node of a BIP32 account given a path. - * - * @param path - The BIP32 path to use for derivation. - * @returns The root node of the BIP32 account. - * @throws {DeriverError} Throws a DeriverError if the private key is missing or if the BIP32 node cannot be constructed from the private key. - */ async getRoot(path: string[]) { try { const deriver = await getBip32Deriver(path, this.curve); @@ -40,8 +27,8 @@ export class BtcAccountDeriver { throw new DeriverError('Deriver private key is missing'); } - const privateKeyBuffer = hexToBuffer(deriver.privateKey); - const chainCodeBuffer = hexToBuffer(deriver.chainCode); + const privateKeyBuffer = this.pkToBuf(deriver.privateKey); + const chainCodeBuffer = this.chainCodeToBuf(deriver.chainCode); const root = this.createBip32FromPrivateKey( privateKeyBuffer, @@ -62,14 +49,6 @@ export class BtcAccountDeriver { } } - /** - * Creates a BIP32 node from a private key and chain node. - * - * @param privateKey - The private key buffer. - * @param chainNode - The chain node buffer. - * @returns The BIP32 node. - * @throws {DeriverError} Throws a DeriverError if the BIP32 node cannot be constructed from the private key. - */ createBip32FromPrivateKey( privateKey: Buffer, chainNode: Buffer, @@ -85,14 +64,23 @@ export class BtcAccountDeriver { } } - /** - * Gets a child node of a BIP32 account given an index. - * - * @param root - The root node of the BIP32 account. - * @param idx - The index of the child node to get. - * @returns A promise that resolves to the child node. - */ async getChild(root: BIP32Interface, idx: number): Promise { return Promise.resolve(root.deriveHardened(0).derive(0).derive(idx)); } + + protected pkToBuf(pk: string): Buffer { + try { + return hexToBuffer(pk); + } catch (error) { + throw new DeriverError('Private key is invalid'); + } + } + + protected chainCodeToBuf(chainCode: string): Buffer { + try { + return hexToBuffer(chainCode); + } catch (error) { + throw new DeriverError('Chain code is invalid'); + } + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts index 206909fa..22617161 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts @@ -3,10 +3,8 @@ export * from './account'; export * from './signer'; export * from './deriver'; export * from './wallet'; +export * from './transaction-info'; export * from './coin-select'; export * from './psbt'; -export * from './transaction-info'; export * from './transaction-input'; export * from './transaction-output'; -export * from './utils'; -export * from './constants'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts index e77b195b..3ecf5539 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts @@ -2,7 +2,7 @@ import { Psbt, Transaction, networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { hexToBuffer } from '../../utils'; -import { MaxStandardTxWeight, ScriptType } from './constants'; +import { MaxStandardTxWeight, ScriptType } from '../constants'; import { BtcAccountDeriver } from './deriver'; import { PsbtServiceError } from './exceptions'; import { PsbtService } from './psbt'; @@ -151,7 +151,7 @@ describe('PsbtService', () => { } }); - it('throws `Failed to add input in PSBT` error if adding inputs fails', async () => { + it('throws `Failed to add inputs in PSBT` error if adding inputs fails', async () => { const { service, inputSpy, sender, inputs } = await preparePsbt(); inputSpy.mockImplementation(() => { throw new Error('error'); @@ -165,7 +165,7 @@ describe('PsbtService', () => { hexToBuffer(sender.pubkey, false), hexToBuffer(sender.mfp), ); - }).toThrow('Failed to add input in PSBT'); + }).toThrow('Failed to add inputs in PSBT'); }); }); @@ -190,14 +190,14 @@ describe('PsbtService', () => { } }); - it('throws `Failed to add output in PSBT` error if adding outputs fails', async () => { + it('throws `Failed to add outputs in PSBT` error if adding outputs fails', async () => { const { service, outputSpy, outputs } = await preparePsbt(); outputSpy.mockImplementation(() => { throw new Error('error'); }); expect(() => service.addOutputs(outputs)).toThrow( - 'Failed to add output in PSBT', + 'Failed to add outputs in PSBT', ); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index e3fd9b05..eb92a8b2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -1,12 +1,13 @@ import ecc from '@bitcoinerlab/secp256k1'; -import type { HDSignerAsync, Network } from 'bitcoinjs-lib'; +import type { Network } from 'bitcoinjs-lib'; import { Psbt, Transaction } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; import ECPairFactory from 'ecpair'; import { compactError, logger } from '../../utils'; -import { MaxStandardTxWeight } from './constants'; -import { PsbtServiceError } from './exceptions'; +import type { IAccountSigner } from '../../wallet'; +import { MaxStandardTxWeight } from '../constants'; +import { PsbtServiceError, TxValidationError } from './exceptions'; import type { TxInput } from './transaction-input'; import type { TxOutput } from './transaction-output'; @@ -30,29 +31,12 @@ export class PsbtService { this._network = network; } - /** - * Creates a new instance of the `PsbtService` class from a base64-encoded PSBT string and a network. - * - * @param network - The network to use for the PSBT. - * @param base64Psbt - The base64-encoded PSBT string. - * @returns A new instance of the `PsbtService` class. - */ static fromBase64(network: Network, base64Psbt: string): PsbtService { const psbt = Psbt.fromBase64(base64Psbt, { network }); const service = new PsbtService(network, psbt); return service; } - /** - * Adds an input to the PSBT. - * - * @param input - The transaction input to add. - * @param replaceable - Whether or not the transaction is replaceable. - * @param changeAddressHdPath - The HD path of the change address. - * @param changeAddressPubkey - The public key of the change address. - * @param changeAddressMfp - The master fingerprint of the change address. - * @throws {PsbtServiceError} If there was an error adding the input to the PSBT. - */ addInput( input: TxInput, replaceable: boolean, @@ -93,15 +77,6 @@ export class PsbtService { } } - /** - * Adds multiple inputs to the PSBT. - * - * @param inputs - An array of transaction inputs to add. - * @param replaceable - Whether or not the transactions are replaceable. - * @param changeAddressHdPath - The HD path of the change address. - * @param changeAddressPubkey - The public key of the change address. - * @param changeAddressMfp - The master fingerprint of the change address. - */ addInputs( inputs: TxInput[], replaceable: boolean, @@ -109,23 +84,22 @@ export class PsbtService { changeAddressPubkey: Buffer, changeAddressMfp: Buffer, ) { - for (const input of inputs) { - this.addInput( - input, - replaceable, - changeAddressHdPath, - changeAddressPubkey, - changeAddressMfp, - ); + try { + for (const input of inputs) { + this.addInput( + input, + replaceable, + changeAddressHdPath, + changeAddressPubkey, + changeAddressMfp, + ); + } + } catch (error) { + logger.error('Failed to add inputs', error); + throw new PsbtServiceError('Failed to add inputs in PSBT'); } } - /** - * Adds an output to the PSBT. - * - * @param output - The transaction output to add. - * @throws {PsbtServiceError} If there was an error adding the output to the PSBT. - */ addOutput(output: TxOutput) { try { this._psbt.addOutput({ @@ -138,23 +112,17 @@ export class PsbtService { } } - /** - * Adds multiple outputs to the PSBT. - * - * @param outputs - An array of transaction outputs to add. - */ addOutputs(outputs: TxOutput[]) { - for (const output of outputs) { - this.addOutput(output); + try { + for (const output of outputs) { + this.addOutput(output); + } + } catch (error) { + logger.error('Failed to add outputs', error); + throw new PsbtServiceError('Failed to add outputs in PSBT'); } } - /** - * Gets the fee for the PSBT. - * - * @returns The fee for the PSBT. - * @throws {PsbtServiceError} If there was an error getting the fee from the PSBT. - */ getFee(): number { try { return this._psbt.getFee(); @@ -164,14 +132,7 @@ export class PsbtService { } } - /** - * Signs all inputs in the PSBT with a dummy signature using an asynchronous signer. - * - * @param signer - The asynchronous signer to use. - * @returns A promise that resolves with a new `PsbtService` instance with the signed inputs. - * @throws {PsbtServiceError} If there was an error signing the inputs in the PSBT. - */ - async signDummy(signer: HDSignerAsync): Promise { + async signDummy(signer: IAccountSigner): Promise { try { const psbt = this._psbt.clone(); await psbt.signAllInputsHDAsync(signer); @@ -183,12 +144,6 @@ export class PsbtService { } } - /** - * Converts the PSBT to a base64-encoded string. - * - * @returns The base64-encoded string representation of the PSBT. - * @throws {PsbtServiceError} If there was an error converting the PSBT to a base64-encoded string. - */ toBase64(): string { try { return this._psbt.toBase64(); @@ -198,13 +153,7 @@ export class PsbtService { } } - /** - * Signs all inputs in the PSBT and verifies that the signatures are valid using an asynchronous signer. - * - * @param signer - The asynchronous signer to use. - * @throws {PsbtServiceError} If there was an error signing or verifying the PSBT's inputs. - */ - async signNVerify(signer: HDSignerAsync) { + async signNVerify(signer: IAccountSigner) { try { // This function signAllInputsHDAsync is used to sign all inputs with the signer. // When using the method signAllInputsHDAsync, it is important to note that the signer must derive from the root node as well as the finderprint. @@ -217,7 +166,7 @@ export class PsbtService { this.validateInputs(pubkey, msghash, signature), ) ) { - throw new PsbtServiceError( + throw new TxValidationError( "Invalid signature to sign the PSBT's inputs", ); } @@ -226,12 +175,6 @@ export class PsbtService { } } - /** - * Finalizes the PSBT and returns the resulting transaction in hexadecimal format. - * - * @returns The transaction in hexadecimal format. - * @throws {PsbtServiceError} If there was an error finalizing the PSBT. - */ finalize(): string { try { this._psbt.finalizeAllInputs(); @@ -241,7 +184,7 @@ export class PsbtService { const weight = this._psbt.extractTransaction().weight(); if (weight > MaxStandardTxWeight) { - throw new PsbtServiceError('Transaction is too large'); + throw new TxValidationError('Transaction is too large'); } return txHex; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts index 4d59f57e..53ee12d5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts @@ -2,18 +2,11 @@ import type { BIP32Interface } from 'bip32'; import type { HDSignerAsync } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; -/** - * An Signer object that defines methods and properties for signing transactions and verifying signatures in PSBT sign process. - */ -export class AccountSigner implements HDSignerAsync { - /** - * The public key of the current derived node used for verifying signatures, as a Buffer. - */ +import type { IAccountSigner } from '../../wallet'; + +export class AccountSigner implements HDSignerAsync, IAccountSigner { readonly publicKey: Buffer; - /** - * The fingerprint of the current derived node used for verifying signatures, as a Buffer. - */ readonly fingerprint: Buffer; protected readonly _node: BIP32Interface; @@ -24,13 +17,7 @@ export class AccountSigner implements HDSignerAsync { this.fingerprint = mfp ?? this._node.fingerprint; } - /** - * Derives a new `IAccountSigner` object using an HD path. - * - * @param path - The HD path in string format, e.g. `m'\0'\0`. - * @returns A new `IAccountSigner` object derived by the given path. - */ - derivePath(path: string): AccountSigner { + derivePath(path: string): IAccountSigner { try { let splitPath = path.split('/'); if (splitPath[0] === 'm') { @@ -51,23 +38,10 @@ export class AccountSigner implements HDSignerAsync { } } - /** - * Signs a transaction hash. - * - * @param hash - The buffer containing the transaction hash to sign. - * @returns A promise that resolves to the signed result as a Buffer. - */ async sign(hash: Buffer): Promise { return this._node.sign(hash); } - /** - * Verifies a signature using the derived node of an `IAccountSigner` object. - * - * @param hash - The buffer containing the transaction hash. - * @param signature - The buffer containing the signature to verify. - * @returns A boolean indicating whether the signature is valid. - */ verify(hash: Buffer, signature: Buffer): boolean { return this._node.verify(hash, signature); } diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/static.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/static.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/utils/static.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/static.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts index 0358cfab..01a21469 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts @@ -1,97 +1,48 @@ import { Buffer } from 'buffer'; import { generateAccounts } from '../../../test/utils'; -import { TxInfo } from './transaction-info'; +import { BtcTxInfo } from './transaction-info'; import { TxOutput } from './transaction-output'; -describe('TxInfo', () => { - it('accumulated total and add `TxOutput[]` as `Recipient[]`', async () => { - const accounts = generateAccounts(5); - const addresses = accounts.map((account) => account.address); - const fee = 10000; - const feeRate = 100; - let total = fee; - const outputs: TxOutput[] = []; - const sender = addresses[0]; - - const info = new TxInfo(sender, feeRate); - info.txFee = fee; - - for (let i = 1; i < addresses.length; i++) { - total += 100000; - const output = new TxOutput(100000, addresses[i], Buffer.from('dummy')); - outputs.push(output); - } - - info.addRecipients(outputs); - - const expectedRecipients = outputs.map((recipient) => { - return { - address: recipient.address, - value: recipient.bigIntValue, - }; +describe('BtcTxInfo', () => { + describe('toJson', () => { + it('returns a json', async () => { + const accounts = generateAccounts(5); + const addresses = accounts.map((account) => account.address); + const fee = 10000; + const feeRate = 100; + let total = fee; + const outputs: TxOutput[] = []; + const sender = addresses[0]; + + const info = new BtcTxInfo(sender, feeRate); + info.txFee = fee; + + for (let i = 1; i < addresses.length; i++) { + total += 100000; + const output = new TxOutput(100000, addresses[i], Buffer.from('dummy')); + outputs.push(output); + } + + const change = new TxOutput(500, addresses[0], Buffer.from('dummy')); + + info.addRecipients(outputs); + info.addChange(change); + total += 500; + + const expectedRecipients = outputs.map((recipient) => { + return { + address: recipient.address, + value: recipient.bigIntValue, + }; + }); + + expect(info.total).toBe(BigInt(total)); + expect(info.sender).toStrictEqual(sender); + expect(info.recipients).toHaveLength(expectedRecipients.length); + expect(info.change).toBeDefined(); + expect(info.txFee).toBe(BigInt(fee)); + expect(info.feeRate).toBe(BigInt(feeRate)); }); - - expect(info.total).toBe(BigInt(total)); - expect(info.sender).toStrictEqual(sender); - expect(info.recipients).toHaveLength(expectedRecipients.length); - }); - - it('accumulated total with change', async () => { - const accounts = generateAccounts(5); - const addresses = accounts.map((account) => account.address); - const fee = 10000; - const feeRate = 100; - let total = fee; - const outputs: TxOutput[] = []; - const sender = addresses[0]; - - const info = new TxInfo(sender, feeRate); - info.txFee = fee; - - for (let i = 1; i < addresses.length; i++) { - total += 100000; - const output = new TxOutput(100000, addresses[i], Buffer.from('dummy')); - outputs.push(output); - } - - const change = new TxOutput(500, addresses[0], Buffer.from('dummy')); - - info.addRecipients(outputs); - info.addChange(change); - total += 500; - - expect(info.total).toBe(BigInt(total)); - expect(info.change).toBeDefined(); - }); - - it('converts number input to bigint', async () => { - const accounts = generateAccounts(1); - const addresses = accounts.map((account) => account.address); - const fee = 10000; - const feeRate = 100; - const sender = addresses[0]; - - const info = new TxInfo(sender, feeRate); - info.txFee = fee; - info.feeRate = feeRate; - - expect(info.txFee).toBe(BigInt(fee)); - expect(info.feeRate).toBe(BigInt(feeRate)); - }); - - it('does not convert bigint input to bigint', async () => { - const accounts = generateAccounts(1); - const addresses = accounts.map((account) => account.address); - const fee = 10000; - const feeRate = 100; - const sender = addresses[0]; - - const info = new TxInfo(sender, feeRate); - info.txFee = BigInt(fee); - info.feeRate = BigInt(feeRate); - - expect(info.txFee).toBe(BigInt(fee)); - expect(info.feeRate).toBe(BigInt(feeRate)); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts index 1f74c61e..e24ef6fd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts @@ -1,7 +1,7 @@ +import type { ITxInfo, Recipient } from '../../wallet'; import type { TxOutput } from './transaction-output'; -import type { ITxInfo, Recipient } from './wallet'; -export class TxInfo implements ITxInfo { +export class BtcTxInfo implements ITxInfo { readonly sender: string; protected _change?: Recipient; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts index 99713d89..aac055c3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts @@ -1,7 +1,7 @@ import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; -import { ScriptType } from './constants'; +import { ScriptType } from '../constants'; import { BtcAccountDeriver } from './deriver'; import { TxInput } from './transaction-input'; import { BtcWallet } from './wallet'; @@ -31,16 +31,4 @@ describe('TxInput', () => { expect(input.index).toStrictEqual(utxo.index); expect(input.block).toStrictEqual(utxo.block); }); - - it('return bigint val', async () => { - const wallet = createMockWallet(networks.testnet); - const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); - const { script } = account; - - const utxo = generateFormatedUtxos(account.address, 1)[0]; - - const input = new TxInput(utxo, script); - - expect(input.bigIntValue).toStrictEqual(BigInt(utxo.value)); - }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts index e6b0bd1c..06134591 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts @@ -1,6 +1,6 @@ import type { Buffer } from 'buffer'; -import type { Utxo } from '../chain'; +import type { Utxo } from '../../chain'; export class TxInput { protected _value: bigint; @@ -16,7 +16,7 @@ export class TxInput { constructor(utxo: Utxo, script: Buffer) { this.script = script; - this._value = BigInt(utxo.value); + this.value = utxo.value; this.index = utxo.index; this.txHash = utxo.txHash; this.block = utxo.block; @@ -27,6 +27,14 @@ export class TxInput { return Number(this._value); } + set value(value: bigint | number) { + if (typeof value === 'number') { + this._value = BigInt(value); + } else { + this._value = value; + } + } + get bigIntValue(): bigint { return this._value; } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index 397c7444..269fd8f6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -1,12 +1,12 @@ import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; +import { DustLimit, ScriptType, maxSatoshi } from '../constants'; import { P2SHP2WPKHAccount, P2WPKHAccount } from './account'; import { CoinSelectService } from './coin-select'; -import { DustLimit, ScriptType } from './constants'; import { BtcAccountDeriver } from './deriver'; import { WalletError } from './exceptions'; -import { TxInfo } from './transaction-info'; +import { BtcTxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; import { BtcWallet } from './wallet'; @@ -47,19 +47,7 @@ describe('BtcWallet', () => { }; describe('unlock', () => { - it('returns an `Account` object with defualt type', async () => { - const network = networks.testnet; - const { rootSpy, childSpy, instance } = createMockWallet(network); - const idx = 0; - - const result = await instance.unlock(idx); - - expect(result).toBeInstanceOf(P2WPKHAccount); - expect(rootSpy).toHaveBeenCalledWith(P2WPKHAccount.path); - expect(childSpy).toHaveBeenCalledWith(expect.any(Object), idx); - }); - - it('returns an `Account` object with type bip122:p2wpkh', async () => { + it('returns an `Account` objec with type bip122:p2wpkh', async () => { const network = networks.testnet; const { rootSpy, childSpy, instance } = createMockWallet(network); const idx = 0; @@ -71,7 +59,7 @@ describe('BtcWallet', () => { expect(childSpy).toHaveBeenCalledWith(expect.any(Object), idx); }); - it('returns an `Account` object with type `p2wpkh`', async () => { + it('returns an `Account` objec with type `p2wpkh`', async () => { const network = networks.testnet; const { rootSpy, childSpy, instance } = createMockWallet(network); const idx = 0; @@ -113,11 +101,16 @@ describe('BtcWallet', () => { const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(account.address, 200, 100000, 100000); + const utxos = generateFormatedUtxos( + account.address, + 200, + maxSatoshi, + maxSatoshi, + ); const result = await wallet.createTransaction( account, - createMockTxIndent(account.address, 100000), + createMockTxIndent(account.address, maxSatoshi), { utxos, fee: 56, @@ -134,7 +127,7 @@ describe('BtcWallet', () => { expect(change).toBeDefined(); expect(result).toStrictEqual({ tx: expect.any(String), - txInfo: expect.any(TxInfo), + txInfo: expect.any(BtcTxInfo), }); }); @@ -164,7 +157,7 @@ describe('BtcWallet', () => { expect(change).toBeUndefined(); expect(result).toStrictEqual({ tx: expect.any(String), - txInfo: expect.any(TxInfo), + txInfo: expect.any(BtcTxInfo), }); }); @@ -241,7 +234,7 @@ describe('BtcWallet', () => { }, ); - const info: TxInfo = result.txInfo as unknown as TxInfo; + const info: BtcTxInfo = result.txInfo as unknown as BtcTxInfo; expect(info.txFee).toBe(BigInt(19500)); expect(info.change).toBeUndefined(); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 24c2023f..0dadb1c0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -1,43 +1,30 @@ import type { BIP32Interface } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; +import type { Utxo } from '../../chain'; import { bufferToString, compactError, hexToBuffer, logger } from '../../utils'; -import type { Utxo } from '../chain'; -import type { BtcAccount } from './account'; +import type { + IAccountSigner, + IWallet, + Recipient, + Transaction, +} from '../../wallet'; +import { ScriptType } from '../constants'; +import { isDust, getScriptForDestnation } from '../utils'; import { P2WPKHAccount, P2SHP2WPKHAccount, type IStaticBtcAccount, + type IBtcAccount, } from './account'; import { CoinSelectService } from './coin-select'; -import { ScriptType } from './constants'; import type { BtcAccountDeriver } from './deriver'; import { WalletError, TxValidationError } from './exceptions'; import { PsbtService } from './psbt'; import { AccountSigner } from './signer'; -import { TxInfo } from './transaction-info'; +import { BtcTxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; -import { isDust, getScriptForDestnation } from './utils'; - -export type Recipient = { - address: string; - value: bigint; -}; - -export type Transaction = { - tx: string; - txInfo: ITxInfo; -}; - -export type ITxInfo = { - sender: string; - change?: Recipient; - recipients: Recipient[]; - total: bigint; - txFee: bigint; - feeRate: bigint; -}; export type CreateTransactionOptions = { utxos: Utxo[]; @@ -49,7 +36,7 @@ export type CreateTransactionOptions = { replaceable: boolean; }; -export class BtcWallet { +export class BtcWallet implements IWallet { protected readonly _deriver: BtcAccountDeriver; protected readonly _network: Network; @@ -59,14 +46,7 @@ export class BtcWallet { this._network = network; } - /** - * Unlocks an account by index and script type. - * - * @param index - The index to derive from the node. - * @param type - The script type of the unlocked account, e.g. `bip122:p2pkh`. - * @returns A promise that resolves to an `IAccount` object. - */ - async unlock(index: number, type?: string): Promise { + async unlock(index: number, type?: string): Promise { try { const AccountCtor = this.getAccountCtor(type ?? ScriptType.P2wpkh); const rootNode = await this._deriver.getRoot(AccountCtor.path); @@ -88,16 +68,8 @@ export class BtcWallet { } } - /** - * Creates a transaction using the given account, transaction intent, and options. - * - * @param account - The `IAccount` object to create the transaction. - * @param recipients - The transaction recipients. - * @param options - The options to use when creating the transaction. - * @returns A promise that resolves to an object containing the transaction hash and transaction info. - */ async createTransaction( - account: BtcAccount, + account: IBtcAccount, recipients: Recipient[], options: CreateTransactionOptions, ): Promise { @@ -141,7 +113,7 @@ export class BtcWallet { hexToBuffer(account.mfp, false), ); - const txInfo = new TxInfo(account.address, feeRate); + const txInfo = new BtcTxInfo(account.address, feeRate); // TODO: add support of subtractFeeFrom, and throw error if output is too small after subtraction for (const output of selectionResult.outputs) { @@ -170,14 +142,7 @@ export class BtcWallet { }; } - /** - * Signs a transaction by the given encoded transaction string. - * - * @param signer - The `AccountSigner` object to sign the transaction. - * @param tx - The encoded transaction string to convert back to a transaction. - * @returns A promise that resolves to a string of the signed transaction. - */ - async signTransaction(signer: AccountSigner, tx: string): Promise { + async signTransaction(signer: IAccountSigner, tx: string): Promise { const psbtService = PsbtService.fromBase64(this._network, tx); await psbtService.signNVerify(signer); return psbtService.finalize(); diff --git a/merged-packages/bitcoin-wallet-snap/src/chain.ts b/merged-packages/bitcoin-wallet-snap/src/chain.ts new file mode 100644 index 00000000..a0a57418 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/chain.ts @@ -0,0 +1,111 @@ +export enum FeeRatio { + Fast = 'fast', + Medium = 'medium', + Slow = 'slow', +} + +export enum TransactionStatus { + Confirmed = 'confirmed', + Pending = 'pending', + Failed = 'failed', +} + +export type TransactionStatusData = { + status: TransactionStatus; +}; + +export type Balance = { + amount: bigint; +}; + +export type AssetBalances = { + balances: { + [address: string]: { + [asset: string]: Balance; + }; + }; +}; + +export type Fee = { + type: FeeRatio; + rate: bigint; +}; + +export type Fees = { + fees: Fee[]; +}; + +export type TransactionIntent = { + amounts: Record; + subtractFeeFrom: string[]; + replaceable: boolean; +}; + +export type TransactionData = { + data: { + utxos: Utxo[]; + }; +}; + +export type CommitedTransaction = { + transactionId: string; +}; + +export type Utxo = { + block: number; + txHash: string; + index: number; + value: number; +}; + +/** + * An interface that defines methods for interacting with a blockchain network. + */ +export type IOnChainService = { + /** + * Gets the balances for multiple addresses and multiple assets. + * + * @param addresses - An array of addresses to fetch the balances for. + * @param assets - An array of assets to fetch the balances of. + * @returns A promise that resolves to an `AssetBalances` object. + */ + getBalances(addresses: string[], assets: string[]): Promise; + + /** + * Gets the fee rates of the network. + * + * @returns A promise that resolves to a `Fees` object. + */ + getFeeRates(): Promise; + + /** + * Broadcasts a signed transaction on the blockchain network. + * + * @param signedTransaction - A signed transaction string. + * @returns A promise that resolves to a `CommitedTransaction` object. + */ + broadcastTransaction(signedTransaction: string): Promise; + + /** + * Gets the status of a transaction with the given transaction hash. + * + * @param txHash - The transaction hash of the transaction to get the status of. + * @returns A promise that resolves to a `TransactionStatusData` object. + */ + getTransactionStatus(txHash: string): Promise; + + /** + * Gets the required metadata to build a transaction for the given address and transaction intent. + * + * @param address - The address to build the transaction for. + * @param transactionIntent - The transaction intent object containing the transaction inputs and outputs. + * @returns A promise that resolves to a `TransactionData` object. + */ + getDataForTransaction( + address: string, + transactionIntent?: TransactionIntent, + ): Promise; + + // TODO: Implement listTransactions in next phase + listTransactions(); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts index 1ea11fb8..efc3087a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.ts @@ -1,10 +1,13 @@ import { BtcOnChainService } from './bitcoin/chain'; import { BlockChairClient } from './bitcoin/chain/clients/blockchair'; -import { BtcAccountDeriver, BtcWallet, getBtcNetwork } from './bitcoin/wallet'; +import { getBtcNetwork } from './bitcoin/utils'; +import { BtcAccountDeriver, BtcWallet } from './bitcoin/wallet'; +import type { IOnChainService } from './chain'; import { Config } from './config'; +import type { IWallet } from './wallet'; export class Factory { - static createOnChainServiceProvider(scope: string): BtcOnChainService { + static createOnChainServiceProvider(scope: string): IOnChainService { const btcNetwork = getBtcNetwork(scope); const client = new BlockChairClient({ @@ -17,7 +20,7 @@ export class Factory { }); } - static createWallet(scope: string): BtcWallet { + static createWallet(scope: string): IWallet { const btcNetwork = getBtcNetwork(scope); return new BtcWallet(new BtcAccountDeriver(btcNetwork), btcNetwork); } diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts index 34620274..ca926944 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.ts @@ -7,7 +7,7 @@ import { import { onRpcRequest, validateOrigin, onKeyringRequest } from '.'; import * as entry from '.'; -import { TransactionStatus } from './bitcoin/chain'; +import { TransactionStatus } from './chain'; import { Config } from './config'; import { BtcKeyring } from './keyring'; import { originPermissions } from './permissions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts index 73e842ea..2079fd2a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts @@ -3,7 +3,8 @@ import { MethodNotFoundError } from '@metamask/snaps-sdk'; import { v4 as uuidv4 } from 'uuid'; import { generateAccounts } from '../test/utils'; -import { BtcAccount, BtcWallet, ScriptType } from './bitcoin/wallet'; +import { ScriptType } from './bitcoin/constants'; +import { BtcAccount } from './bitcoin/wallet'; import { Config } from './config'; import { Caip2Asset, Caip2ChainId } from './constants'; import { Factory } from './factory'; @@ -11,6 +12,7 @@ import { BtcKeyring } from './keyring'; import * as getBalanceRpc from './rpcs/get-balances'; import * as sendManyRpc from './rpcs/sendmany'; import { KeyringStateManager } from './stateManagement'; +import type { IWallet } from './wallet'; jest.mock('./utils/logger'); jest.mock('./utils/snap'); @@ -22,17 +24,20 @@ jest.mock('@metamask/keyring-api', () => ({ describe('BtcKeyring', () => { const createMockWallet = () => { - const unlockSpy = jest.spyOn(BtcWallet.prototype, 'unlock'); - const signTransaction = jest.spyOn(BtcWallet.prototype, 'signTransaction'); - const createTransaction = jest.spyOn( - BtcWallet.prototype, - 'createTransaction', - ); - + const unlockSpy = jest.fn(); + class Wallet implements IWallet { + unlock = unlockSpy; + + signTransaction = jest.fn(); + + createTransaction = jest.fn(); + } + jest + .spyOn(Factory, 'createWallet') + .mockImplementation() + .mockReturnValue(new Wallet()); return { unlockSpy, - signTransaction, - createTransaction, }; }; @@ -80,6 +85,10 @@ describe('BtcKeyring', () => { }; }; + const getHdPath = (index: number) => { + return [`m`, `0'`, `0`, `${index}`].join('/'); + }; + const createSender = async (caip2ChainId: string) => { const wallet = Factory.createWallet(caip2ChainId); const sender = await wallet.unlock(0, ScriptType.P2wpkh); @@ -105,29 +114,38 @@ describe('BtcKeyring', () => { const { unlockSpy } = createMockWallet(); const { instance: stateMgr, addWalletSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, keyringAccount } = await createSender(caip2ChainId); + const scope = Caip2ChainId.Testnet; + const account = generateAccounts(1)[0]; + + unlockSpy.mockResolvedValue({ + address: account.address, + hdPath: getHdPath(account.options.index), + index: account.options.index, + type: account.type, + }); await keyring.createAccount({ - scope: caip2ChainId, + scope, }); expect(unlockSpy).toHaveBeenCalledWith( Config.wallet.defaultAccountIndex, Config.wallet.defaultAccountType, ); - expect(addWalletSpy).toHaveBeenCalledWith({ account: { - type: keyringAccount.type, + type: account.type, id: expect.any(String), - address: keyringAccount.address, - options: keyringAccount.options, - methods: keyringAccount.methods, + address: account.address, + options: { + scope, + index: account.options.index, + }, + methods: account.methods, }, - hdPath: sender.hdPath, - index: sender.index, - scope: caip2ChainId, + hdPath: getHdPath(account.options.index), + index: account.options.index, + scope, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index 7939b997..6ba16239 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -13,12 +13,12 @@ import type { Infer } from 'superstruct'; import { assert, object, StructError } from 'superstruct'; import { v4 as uuidv4 } from 'uuid'; -import type { BtcAccount, BtcWallet } from './bitcoin/wallet'; import { Config } from './config'; import { Factory } from './factory'; import { getBalances, type SendManyParams, sendMany } from './rpcs'; import type { KeyringStateManager, Wallet } from './stateManagement'; import { getProvider, scopeStruct, logger } from './utils'; +import type { IAccount, IWallet } from './wallet'; export type KeyringOptions = Record & { defaultIndex: number; @@ -230,20 +230,20 @@ export class BtcKeyring implements Keyring { return walletData; } - protected getBtcWallet(scope: string): BtcWallet { + protected getBtcWallet(scope: string): IWallet { return Factory.createWallet(scope); } protected async discoverAccount( - wallet: BtcWallet, + wallet: IWallet, index: number, type: string, - ): Promise { + ): Promise { return await wallet.unlock(index, type); } protected verifyIfAccountValid( - account: BtcAccount, + account: IAccount, keyringAccount: KeyringAccount, ): void { if (!account || account.address !== keyringAccount.address) { @@ -252,7 +252,7 @@ export class BtcKeyring implements Keyring { } protected newKeyringAccount( - account: BtcAccount, + account: IAccount, options?: CreateAccountOptions, ): KeyringAccount { return { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts index 29d2c1a7..3001f5b2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts @@ -4,10 +4,10 @@ import { networks } from 'bitcoinjs-lib'; import { v4 as uuidv4 } from 'uuid'; import { generateAccounts } from '../../test/utils'; -import { BtcOnChainService } from '../bitcoin/chain'; import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; import { Config } from '../config'; import { Caip2ChainId } from '../constants'; +import { Factory } from '../factory'; import { getBalances } from './get-balances'; jest.mock('../utils/logger'); @@ -16,12 +16,17 @@ jest.mock('../utils/snap'); describe('getBalances', () => { const asset = Config.avaliableAssets[0]; - const createMockChainService = () => { - const getBalancesSpy = jest.spyOn( - BtcOnChainService.prototype, - 'getBalances', - ); + const createMockChainApiFactory = () => { + const getBalancesSpy = jest.fn(); + jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ + getFeeRates: jest.fn(), + getBalances: getBalancesSpy, + broadcastTransaction: jest.fn(), + listTransactions: jest.fn(), + getTransactionStatus: jest.fn(), + getDataForTransaction: jest.fn(), + }); return { getBalancesSpy, }; @@ -70,7 +75,7 @@ describe('getBalances', () => { it('gets balances', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainService(); + const { getBalancesSpy } = createMockChainApiFactory(); const { walletData, sender } = await createMockAccount( network, @@ -110,7 +115,7 @@ describe('getBalances', () => { it('gets balances of the request account only', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainService(); + const { getBalancesSpy } = createMockChainApiFactory(); const accounts = generateAccounts(10); const { walletData, sender } = await createMockAccount( network, @@ -156,7 +161,7 @@ describe('getBalances', () => { it('throws `Fail to get the balances` when transaction status fetch failed', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainService(); + const { getBalancesSpy } = createMockChainApiFactory(); const { sender } = await createMockAccount(network, caip2ChainId); getBalancesSpy.mockRejectedValue(new Error('error')); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts index d9cc8eb5..412a8238 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts @@ -1,7 +1,6 @@ import type { Infer } from 'superstruct'; import { object, array, record, enums, assert } from 'superstruct'; -import type { BtcAccount } from '../bitcoin/wallet'; import { Config } from '../config'; import { Factory } from '../factory'; import { @@ -16,6 +15,7 @@ import { positiveStringStruct, scopeStruct, } from '../utils/superstruct'; +import type { IAccount } from '../wallet'; export const getBalancesRequestStruct = object({ assets: array(assetsStruct), @@ -44,7 +44,7 @@ export type GetBalancesResponse = Infer; * @returns A Promise that resolves to an GetBalancesResponse object. */ export async function getBalances( - account: BtcAccount, + account: IAccount, params: GetBalancesParams, ) { try { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts index 138c76ce..8271c47c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts @@ -1,7 +1,8 @@ import { InvalidParamsError } from '@metamask/snaps-sdk'; -import { BtcOnChainService, TransactionStatus } from '../bitcoin/chain'; +import { TransactionStatus } from '../chain'; import { Caip2ChainId } from '../constants'; +import { Factory } from '../factory'; import { getTransactionStatus } from './get-transaction-status'; jest.mock('../utils/logger'); @@ -10,18 +11,24 @@ describe('getTransactionStatus', () => { const txHash = '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; - const createMockChainService = () => { - const getTransactionStatusSpy = jest.spyOn( - BtcOnChainService.prototype, - 'getTransactionStatus', - ); + const createMockChainApiFactory = () => { + const getTransactionStatusSpy = jest.fn(); + + jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ + getFeeRates: jest.fn(), + getBalances: jest.fn(), + broadcastTransaction: jest.fn(), + listTransactions: jest.fn(), + getTransactionStatus: getTransactionStatusSpy, + getDataForTransaction: jest.fn(), + }); return { getTransactionStatusSpy, }; }; it('gets status', async () => { - const { getTransactionStatusSpy } = createMockChainService(); + const { getTransactionStatusSpy } = createMockChainApiFactory(); const mockResp = { status: TransactionStatus.Confirmed, @@ -41,7 +48,7 @@ describe('getTransactionStatus', () => { }); it('throws `Fail to get the transaction status` when transaction status fetch failed', async () => { - const { getTransactionStatusSpy } = createMockChainService(); + const { getTransactionStatusSpy } = createMockChainApiFactory(); getTransactionStatusSpy.mockRejectedValue(new Error('error')); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts index 1518575a..5b7e89c8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts @@ -1,7 +1,7 @@ import type { Infer } from 'superstruct'; import { object, string, enums } from 'superstruct'; -import { TransactionStatus } from '../bitcoin/chain'; +import { TransactionStatus } from '../chain'; import { Factory } from '../factory'; import { isSnapRpcError, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 887a7f32..741fa218 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -9,18 +9,15 @@ import { generateBlockChairBroadcastTransactionResp, generateBlockChairGetUtxosResp, } from '../../test/utils'; -import { BtcOnChainService, FeeRatio } from '../bitcoin/chain'; -import type { BtcAccount } from '../bitcoin/wallet'; -import { - BtcAccountDeriver, - BtcWallet, - type ITxInfo, - TxValidationError, -} from '../bitcoin/wallet'; +import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; +import { FeeRatio } from '../chain'; import { Config } from '../config'; import { Caip2ChainId } from '../constants'; -import { getExplorerUrl, shortenAddress, satsToBtc } from '../utils'; +import { Factory } from '../factory'; +import { getExplorerUrl, shortenAddress } from '../utils'; import * as snapUtils from '../utils/snap'; +import { satsToBtc } from '../utils/unit'; +import type { IAccount, ITxInfo } from '../wallet'; import { type SendManyParams, sendMany } from './sendmany'; jest.mock('../utils/logger'); @@ -29,19 +26,18 @@ jest.mock('../utils/snap'); describe('SendManyHandler', () => { describe('sendMany', () => { const createMockChainApiFactory = () => { - const getFeeRatesSpy = jest.spyOn( - BtcOnChainService.prototype, - 'getFeeRates', - ); - const broadcastTransactionSpy = jest.spyOn( - BtcOnChainService.prototype, - 'broadcastTransaction', - ); - const getDataForTransactionSpy = jest.spyOn( - BtcOnChainService.prototype, - 'getDataForTransaction', - ); - + const getDataForTransactionSpy = jest.fn(); + const getFeeRatesSpy = jest.fn(); + const broadcastTransactionSpy = jest.fn(); + + jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ + getFeeRates: getFeeRatesSpy, + getBalances: jest.fn(), + broadcastTransaction: broadcastTransactionSpy, + listTransactions: jest.fn(), + getTransactionStatus: jest.fn(), + getDataForTransaction: getDataForTransactionSpy, + }); return { getDataForTransactionSpy, getFeeRatesSpy, @@ -74,7 +70,7 @@ describe('SendManyHandler', () => { }, methods: ['btc_sendmany'], }; - const recipients: BtcAccount[] = []; + const recipients: IAccount[] = []; for (let i = 1; i < recipientCnt + 1; i++) { recipients.push( await wallet.unlock(i, Config.wallet.defaultAccountType), @@ -89,7 +85,7 @@ describe('SendManyHandler', () => { }; const createSendManyParams = ( - recipients: BtcAccount[], + recipients: IAccount[], caip2ChainId: string, dryrun: boolean, comment = '', @@ -393,6 +389,24 @@ describe('SendManyHandler', () => { ).rejects.toThrow('Invalid amount for send'); }); + it('throws `Failed to send the transaction` error if no fee rate returns from chain service', async () => { + const { getFeeRatesSpy } = createMockChainApiFactory(); + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { sender, recipients } = await createSenderNRecipients( + network, + caip2ChainId, + 10, + ); + getFeeRatesSpy.mockResolvedValue({ + fees: [], + }); + + await expect( + sendMany(sender, createSendManyParams(recipients, caip2ChainId, false)), + ).rejects.toThrow('Failed to send the transaction'); + }); + it('throws `Invalid response` error if the response is unexpected', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; @@ -400,7 +414,9 @@ describe('SendManyHandler', () => { await prepareSendMany(network, caip2ChainId); broadcastTransactionSpy.mockResolvedValue({ - transactionId: '', + transactionId: { + txId: 'invalid', + }, }); await expect( sendMany(sender, { @@ -425,24 +441,6 @@ describe('SendManyHandler', () => { ).rejects.toThrow(UserRejectedRequestError); }); - it('throws `Failed to send the transaction` error if no fee rate returns from chain service', async () => { - const { getFeeRatesSpy } = createMockChainApiFactory(); - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients } = await createSenderNRecipients( - network, - caip2ChainId, - 10, - ); - getFeeRatesSpy.mockResolvedValue({ - fees: [], - }); - - await expect( - sendMany(sender, createSendManyParams(recipients, caip2ChainId, false)), - ).rejects.toThrow('Failed to send the transaction'); - }); - it('throws `Failed to send the transaction` error if the transaction is fail to commit', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; @@ -456,21 +454,5 @@ describe('SendManyHandler', () => { }), ).rejects.toThrow('Failed to send the transaction'); }); - - it('throws DisplayableError error meesage if the DisplayableError throwed', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { broadcastTransactionSpy, sender, recipients } = - await prepareSendMany(network, caip2ChainId); - broadcastTransactionSpy.mockRejectedValue( - new TxValidationError('some tx error'), - ); - - await expect( - sendMany(sender, { - ...createSendManyParams(recipients, caip2ChainId, false), - }), - ).rejects.toThrow('some tx error'); - }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 8ce5d6ea..5cc3ca58 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -17,14 +17,9 @@ import { boolean, refine, optional, - nonempty, } from 'superstruct'; -import { - type BtcAccount, - type ITxInfo, - TxValidationError, -} from '../bitcoin/wallet'; +import { TxValidationError } from '../bitcoin/wallet'; import { Factory } from '../factory'; import { scopeStruct, @@ -38,6 +33,7 @@ import { validateResponse, logger, } from '../utils'; +import type { IAccount, ITxInfo } from '../wallet'; export const TransactionAmountStuct = refine( record(BtcP2wpkhAddressStruct, string()), @@ -78,7 +74,7 @@ export const sendManyParamsStruct = object({ }); export const sendManyResponseStruct = object({ - txId: nonempty(string()), + txId: string(), txHash: optional(string()), }); @@ -93,7 +89,7 @@ export type SendManyResponse = Infer; * @param params - The parameters for send the transaction. * @returns A Promise that resolves to an SendManyResponse object. */ -export async function sendMany(account: BtcAccount, params: SendManyParams) { +export async function sendMany(account: IAccount, params: SendManyParams) { try { validateRequest(params, sendManyParamsStruct); @@ -157,7 +153,10 @@ export async function sendMany(account: BtcAccount, params: SendManyParams) { throw error as unknown as Error; } - if (error instanceof TxValidationError) { + if ( + error instanceof TxValidationError || + error instanceof UserRejectedRequestError + ) { throw error as unknown as Error; } diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts index a0dbbcdb..c038e30f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts @@ -1,4 +1,5 @@ -import { satsToBtc, btcToSats, maxSatoshi, minSatoshi } from './unit'; +import { maxSatoshi, minSatoshi } from '../bitcoin/constants'; +import { satsToBtc, btcToSats } from './unit'; describe('satsToBtc', () => { it('returns Btc unit', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts index 79fd4a1b..f27b1fb8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts @@ -1,13 +1,8 @@ import Big from 'big.js'; +import { maxSatoshi } from '../bitcoin/constants'; import { Config } from '../config'; -// Maximum amount of satoshis -export const maxSatoshi = 21 * 1e14; - -// Minimum amount of satoshis -export const minSatoshi = 1; - /** * Converts a satoshis to a string representing the equivalent amount of BTC. * diff --git a/merged-packages/bitcoin-wallet-snap/src/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/wallet.ts new file mode 100644 index 00000000..9dbc86b9 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/wallet.ts @@ -0,0 +1,135 @@ +import type { Json } from '@metamask/snaps-sdk'; +import type { Buffer } from 'buffer'; + +export type Recipient = { + address: string; + value: bigint; +}; + +export type Transaction = { + tx: string; + txInfo: ITxInfo; +}; + +/** + * An interface that defines a `toJson` method for getting a JSON representation of a transaction info object. + */ +export type ITxInfo = { + sender: string; + change?: Recipient; + recipients: Recipient[]; + total: bigint; + txFee: bigint; + feeRate: bigint; +}; + +/** + * An interface that defines methods for unlocking accounts, signing transactions, and creating transactions. + */ +export type IWallet = { + /** + * Unlocks an account by index and script type. + * + * @param index - The index to derive from the node. + * @param type - The script type of the unlocked account, e.g. `bip122:p2pkh`. + * @returns A promise that resolves to an `IAccount` object. + */ + unlock(index: number, type?: string): Promise; + + /** + * Signs a transaction by the given encoded transaction string. + * + * @param signer - The `IAccountSigner` object to sign the transaction. + * @param transaction - The encoded transaction string to convert back to a transaction. + * @returns A promise that resolves to a string of the signed transaction. + */ + signTransaction(signer: IAccountSigner, transaction: string): Promise; + + /** + * Creates a transaction using the given account, transaction intent, and options. + * + * @param account - The `IAccount` object to create the transaction. + * @param recipients - The transaction recipients. + * @param options - The options to use when creating the transaction. + * @returns A promise that resolves to an object containing the transaction hash and transaction info. + */ + createTransaction( + account: IAccount, + recipients: Recipient[], + options: Record, + ): Promise; +}; + +/** + * An interface that defines properties for an account, including its address, HD path, public key, and signer object. + */ +export type IAccount = { + /** + * The master fingerprint of the derived node, as a string. + */ + mfp: string; + /** + * The index of the derived node, as a number. + */ + index: number; + /** + * The address of the account, as a string. + */ + address: string; + /** + * The HD path of the account, as a string. + */ + hdPath: string; + /** + * The public key of the account, as a string. + */ + pubkey: string; + /** + * The type of the account, e.g. `bip122:p2pwh`, as a string. + */ + type: string; + /** + * The `IAccountSigner` object derived from the root node. + */ + signer: IAccountSigner; +}; + +/** + * An interface that defines methods and properties for signing transactions and verifying signatures. + */ +export type IAccountSigner = { + /** + * Signs a transaction hash. + * + * @param hash - The buffer containing the transaction hash to sign. + * @returns A promise that resolves to the signed result as a Buffer. + */ + sign(hash: Buffer): Promise; + + /** + * Derives a new `IAccountSigner` object using an HD path. + * + * @param path - The HD path in string format, e.g. `m'\0'\0`. + * @returns A new `IAccountSigner` object derived by the given path. + */ + derivePath(path: string): IAccountSigner; + + /** + * Verifies a signature using the derived node of an `IAccountSigner` object. + * + * @param hash - The buffer containing the transaction hash. + * @param signature - The buffer containing the signature to verify. + * @returns A boolean indicating whether the signature is valid. + */ + verify(hash: Buffer, signature: Buffer): boolean; + + /** + * The public key of the current derived node used for verifying signatures, as a Buffer. + */ + publicKey: Buffer; + + /** + * The fingerprint of the current derived node used for verifying signatures, as a Buffer. + */ + fingerprint: Buffer; +}; From ffa3d87b7eea8ff974cb1ae656b441c05edcb4c4 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 4 Jul 2024 16:47:04 +0800 Subject: [PATCH 071/362] Revert "chore: refactor with simple version (#108)" This reverts commit cddeee5453c5e8286d818333404148ebc6adee9b. --- .../bitcoin-wallet-snap/package.json | 1 - .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../src/bitcoin/chain/exceptions.ts | 4 +- .../src/bitcoin/chain/index.ts | 1 + .../src/bitcoin/chain/service.test.ts | 83 ++-- .../src/bitcoin/chain/service.ts | 46 ++- .../src/bitcoin/chain/types.ts | 5 + .../src/bitcoin/config/index.ts | 1 + .../src/bitcoin/config/types.ts | 23 ++ .../src/bitcoin/constants.ts | 28 +- .../clients/blockchair.test.ts | 2 +- .../clients/blockchair.ts | 45 +-- .../data-client/clients/blockstream.test.ts | 305 +++++++++++++++ .../data-client/clients/blockstream.ts | 223 +++++++++++ .../src/bitcoin/data-client/exceptions.ts | 3 + .../src/bitcoin/data-client/factory.test.ts | 82 ++++ .../src/bitcoin/data-client/factory.ts | 50 +++ .../src/bitcoin/data-client/index.ts | 3 + .../data-client.ts => data-client/types.ts} | 14 +- .../src/bitcoin/utils/address.test.ts | 4 +- .../src/bitcoin/utils/address.ts | 2 +- .../src/bitcoin/utils/explorer.test.ts | 27 ++ .../src/bitcoin/utils/explorer.ts | 25 ++ .../src/bitcoin/utils/index.ts | 4 +- .../src/bitcoin/utils/network.test.ts | 12 +- .../src/bitcoin/utils/network.ts | 18 +- .../src/bitcoin/utils/policy.test.ts | 16 - .../src/bitcoin/utils/policy.ts | 17 - .../src/bitcoin/utils/unit.test.ts | 56 +++ .../src/bitcoin/utils/unit.ts | 38 ++ .../src/bitcoin/wallet/account.ts | 31 +- .../src/bitcoin/wallet/address.test.ts | 24 ++ .../src/bitcoin/wallet/address.ts | 18 + .../src/bitcoin/wallet/amount.test.ts | 23 ++ .../src/bitcoin/wallet/amount.ts | 26 ++ .../src/bitcoin/wallet/coin-select.test.ts | 9 +- .../src/bitcoin/wallet/coin-select.ts | 8 +- .../src/bitcoin/wallet/deriver.test.ts | 106 ++++- .../src/bitcoin/wallet/deriver.ts | 91 +++-- .../src/bitcoin/wallet/exceptions.ts | 2 +- .../src/bitcoin/wallet/factory.test.ts | 63 +++ .../src/bitcoin/wallet/factory.ts | 17 + .../src/bitcoin/wallet/index.ts | 8 +- .../src/bitcoin/wallet/psbt.test.ts | 11 +- .../src/bitcoin/wallet/psbt.ts | 3 +- .../bitcoin/wallet/transaction-info.test.ts | 54 ++- .../src/bitcoin/wallet/transaction-info.ts | 122 +++--- .../bitcoin/wallet/transaction-input.test.ts | 9 +- .../src/bitcoin/wallet/transaction-input.ts | 24 +- .../bitcoin/wallet/transaction-output.test.ts | 18 - .../src/bitcoin/wallet/transaction-output.ts | 28 +- .../src/bitcoin/wallet/types.ts | 59 +++ .../src/bitcoin/wallet/wallet.test.ts | 48 ++- .../src/bitcoin/wallet/wallet.ts | 74 ++-- .../bitcoin-wallet-snap/src/chain.ts | 21 +- .../bitcoin-wallet-snap/src/config.ts | 49 --- .../bitcoin-wallet-snap/src/config/config.ts | 103 +++++ .../bitcoin-wallet-snap/src/config/index.ts | 2 + .../src/{ => config}/permissions.ts | 12 +- .../bitcoin-wallet-snap/src/constants.ts | 9 - .../bitcoin-wallet-snap/src/factory.test.ts | 17 +- .../bitcoin-wallet-snap/src/factory.ts | 62 ++- .../bitcoin-wallet-snap/src/index.test.ts | 72 ++-- .../bitcoin-wallet-snap/src/index.ts | 37 +- .../src/keyring/exceptions.ts | 5 + .../bitcoin-wallet-snap/src/keyring/index.ts | 4 + .../src/{ => keyring}/keyring.test.ts | 369 +++++++++--------- .../src/{ => keyring}/keyring.ts | 181 ++++----- .../state.test.ts} | 40 +- .../{stateManagement.ts => keyring/state.ts} | 38 +- .../bitcoin-wallet-snap/src/keyring/types.ts | 37 ++ .../src/libs/exception/exceptions.ts | 23 ++ .../src/libs/exception/index.ts | 1 + .../logger}/__mocks__/logger.ts | 0 .../src/{utils => libs/logger}/logger.test.ts | 0 .../src/{utils => libs/logger}/logger.ts | 0 .../bitcoin-wallet-snap/src/libs/rpc/base.ts | 86 ++++ .../src/libs/rpc/exceptions.ts | 4 + .../bitcoin-wallet-snap/src/libs/rpc/index.ts | 3 + .../bitcoin-wallet-snap/src/libs/rpc/types.ts | 56 +++ .../src/libs/snap/__mocks__/helpers.ts | 25 ++ .../src/libs/snap/exceptions.ts | 3 + .../src/libs/snap/helpers.test.ts | 123 ++++++ .../src/libs/snap/helpers.ts | 68 ++++ .../src/libs/snap/index.ts | 4 + .../src/libs/snap/lock.test.ts | 24 ++ .../bitcoin-wallet-snap/src/libs/snap/lock.ts | 12 + .../snap/state.test.ts} | 31 +- .../snap-state.ts => libs/snap/state.ts} | 38 +- .../src/rpcs/create-account.ts | 59 +-- .../src/rpcs/get-balances.test.ts | 324 +++++++-------- .../src/rpcs/get-balances.ts | 169 ++++---- .../src/rpcs/get-transaction-status.test.ts | 97 ++--- .../src/rpcs/get-transaction-status.ts | 92 +++-- .../src/rpcs/helpers.test.ts | 34 ++ .../bitcoin-wallet-snap/src/rpcs/helpers.ts | 30 ++ .../bitcoin-wallet-snap/src/rpcs/index.ts | 3 +- .../src/rpcs/keyring-rpc.ts | 30 ++ .../src/rpcs/sendmany.test.ts | 290 +++++++------- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 366 +++++++++-------- .../src/{bitcoin/wallet => types}/static.ts | 0 .../src/utils/__mocks__/snap.ts | 30 -- .../bitcoin-wallet-snap/src/utils/error.ts | 24 -- .../src/utils/explorer.test.ts | 22 -- .../bitcoin-wallet-snap/src/utils/explorer.ts | 29 -- .../bitcoin-wallet-snap/src/utils/index.ts | 7 - .../src/utils/lock.test.ts | 21 - .../bitcoin-wallet-snap/src/utils/lock.ts | 16 - .../bitcoin-wallet-snap/src/utils/rpc.ts | 35 -- .../src/utils/snap.test.ts | 105 ----- .../bitcoin-wallet-snap/src/utils/snap.ts | 82 ---- .../src/utils/string.test.ts | 8 - .../bitcoin-wallet-snap/src/utils/string.ts | 10 - .../src/utils/superstruct.test.ts | 10 +- .../src/utils/superstruct.ts | 4 +- .../src/utils/unit.test.ts | 53 --- .../bitcoin-wallet-snap/src/utils/unit.ts | 46 --- .../bitcoin-wallet-snap/src/wallet.ts | 101 +++-- .../test/fixtures/blockstream.json | 220 +++++++++++ .../bitcoin-wallet-snap/test/utils.ts | 135 ++++++- 120 files changed, 3871 insertions(+), 2158 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/types.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/config/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/config/types.ts rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{chain => data-client}/clients/blockchair.test.ts (99%) rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{chain => data-client}/clients/blockchair.ts (89%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/exceptions.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/index.ts rename merged-packages/bitcoin-wallet-snap/src/bitcoin/{chain/data-client.ts => data-client/types.ts} (50%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/config.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/config/config.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/config/index.ts rename merged-packages/bitcoin-wallet-snap/src/{ => config}/permissions.ts (84%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/constants.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/keyring/index.ts rename merged-packages/bitcoin-wallet-snap/src/{ => keyring}/keyring.test.ts (69%) rename merged-packages/bitcoin-wallet-snap/src/{ => keyring}/keyring.ts (60%) rename merged-packages/bitcoin-wallet-snap/src/{stateManagement.test.ts => keyring/state.test.ts} (90%) rename merged-packages/bitcoin-wallet-snap/src/{stateManagement.ts => keyring/state.ts} (79%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/keyring/types.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/exception/exceptions.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/exception/index.ts rename merged-packages/bitcoin-wallet-snap/src/{utils => libs/logger}/__mocks__/logger.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{utils => libs/logger}/logger.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{utils => libs/logger}/logger.ts (100%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/rpc/exceptions.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/rpc/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/rpc/types.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/__mocks__/helpers.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/exceptions.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.ts rename merged-packages/bitcoin-wallet-snap/src/{utils/snap-state.test.ts => libs/snap/state.test.ts} (95%) rename merged-packages/bitcoin-wallet-snap/src/{utils/snap-state.ts => libs/snap/state.ts} (82%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts rename merged-packages/bitcoin-wallet-snap/src/{bitcoin/wallet => types}/static.ts (100%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/explorer.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/explorer.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/lock.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/lock.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/rpc.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/snap.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/unit.ts create mode 100644 merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 1e492fd6..48d4fdfd 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -34,7 +34,6 @@ "@metamask/keyring-api": "^6.3.1", "@metamask/snaps-sdk": "^4.0.0", "async-mutex": "^0.3.2", - "big.js": "^6.2.1", "bip32": "^4.0.0", "bitcoinjs-lib": "^6.1.5", "buffer": "^6.0.3", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index d68d25f5..b3a96b45 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "MO1uECXQxFuVEjZuXbUov3P6Sx1Gklzx+8cqYxSYj3U=", + "shasum": "gycY0HZ56I1PRIdcUPm4dYlATHJxoWWivbMHJddftK0=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -18,6 +18,7 @@ } }, "initialConnections": { + "https://metamask.github.io": {}, "http://localhost:8000": {}, "https://portfolio.metamask.io": {}, "https://portfolio-builds.metafi-dev.codefi.network": {}, @@ -30,6 +31,7 @@ }, "endowment:keyring": { "allowedOrigins": [ + "https://metamask.github.io", "http://localhost:8000", "https://portfolio.metamask.io", "https://portfolio-builds.metafi-dev.codefi.network", diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts index 54affb11..8321d50a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts @@ -1,5 +1,3 @@ -import { CustomError } from '../../utils'; - -export class DataClientError extends CustomError {} +import { CustomError } from '../../libs/exception'; export class BtcOnChainServiceError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts index cb209e05..5ce37176 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts @@ -1,2 +1,3 @@ export * from './exceptions'; export * from './service'; +export * from './types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts index ad3dc7bf..8d4745c0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts @@ -7,22 +7,21 @@ import { generateBlockChairGetUtxosResp, } from '../../../test/utils'; import { FeeRatio, TransactionStatus } from '../../chain'; -import { Caip2Asset } from '../../constants'; -import type { IDataClient } from './data-client'; +import { BtcAsset } from '../constants'; +import type { IReadDataClient, IWriteDataClient } from '../data-client'; +import { BtcAmount } from '../wallet'; import { BtcOnChainServiceError } from './exceptions'; import { BtcOnChainService } from './service'; -jest.mock('../../utils/logger'); +jest.mock('../../libs/logger/logger'); describe('BtcOnChainService', () => { - const createMockDataClient = () => { + const createMockReadDataClient = () => { const getBalanceSpy = jest.fn(); const getUtxosSpy = jest.fn(); const getFeeRatesSpy = jest.fn(); const getTransactionStatusSpy = jest.fn(); - const sendTransactionSpy = jest.fn(); - - class MockReadDataClient implements IDataClient { + class MockReadDataClient implements IReadDataClient { getBalances = getBalanceSpy; getUtxos = getUtxosSpy; @@ -30,8 +29,6 @@ describe('BtcOnChainService', () => { getFeeRates = getFeeRatesSpy; getTransactionStatus = getTransactionStatusSpy; - - sendTransaction = sendTransactionSpy; } return { @@ -40,16 +37,29 @@ describe('BtcOnChainService', () => { getUtxosSpy, getFeeRatesSpy, getTransactionStatusSpy, + }; + }; + + const createMockWriteDataClient = () => { + const sendTransactionSpy = jest.fn(); + + class MockWriteDataClient implements IWriteDataClient { + sendTransaction = sendTransactionSpy; + } + return { + instance: new MockWriteDataClient(), sendTransactionSpy, }; }; const createMockBtcService = ( - dataClient?: IDataClient, + readDataClient?: IReadDataClient, + writeDataClient?: IWriteDataClient, network: Network = networks.testnet, ) => { const instance = new BtcOnChainService( - dataClient ?? createMockDataClient().instance, + readDataClient ?? createMockReadDataClient().instance, + writeDataClient ?? createMockWriteDataClient().instance, { network, }, @@ -62,7 +72,7 @@ describe('BtcOnChainService', () => { describe('getBalance', () => { it('calls getBalances with readClient', async () => { - const { instance, getBalanceSpy } = createMockDataClient(); + const { instance, getBalanceSpy } = createMockReadDataClient(); const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); @@ -73,59 +83,60 @@ describe('BtcOnChainService', () => { }, {}), ); - const result = await txService.getBalances(addresses, [Caip2Asset.TBtc]); + const result = await txService.getBalances(addresses, [BtcAsset.TBtc]); expect(getBalanceSpy).toHaveBeenCalledWith(addresses); Object.values(result.balances).forEach((assetBalances) => { expect(assetBalances).toStrictEqual({ - [Caip2Asset.TBtc]: { - amount: BigInt(100), + [BtcAsset.TBtc]: { + amount: expect.any(BtcAmount), }, }); }); }); it('throws `Only one asset is supported` error if the given asset more than 1', async () => { - const { instance } = createMockDataClient(); + const { instance } = createMockReadDataClient(); const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); await expect( - txService.getBalances(addresses, [Caip2Asset.TBtc, Caip2Asset.Btc]), + txService.getBalances(addresses, [BtcAsset.TBtc, BtcAsset.Btc]), ).rejects.toThrow('Only one asset is supported'); }); it('throws `Invalid asset` error if the BTC asset is given and current network is testnet network', async () => { - const { instance } = createMockDataClient(); + const { instance } = createMockReadDataClient(); const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); await expect( - txService.getBalances(addresses, [Caip2Asset.Btc]), + txService.getBalances(addresses, [BtcAsset.Btc]), ).rejects.toThrow('Invalid asset'); }); it('throws `Invalid asset` error if the TBTC asset is given and current network is bitcoin network', async () => { - const { instance } = createMockDataClient(); + const { instance } = createMockReadDataClient(); const { instance: txService } = createMockBtcService( instance, + undefined, networks.bitcoin, ); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); await expect( - txService.getBalances(addresses, [Caip2Asset.TBtc]), + txService.getBalances(addresses, [BtcAsset.TBtc]), ).rejects.toThrow('Invalid asset'); }); }); describe('getUtxos', () => { it('calls getUtxos with readClient', async () => { - const { instance, getUtxosSpy } = createMockDataClient(); + const { instance, getUtxosSpy } = createMockReadDataClient(); const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const sender = accounts[0].address; @@ -157,7 +168,7 @@ describe('BtcOnChainService', () => { }); it('throws error if readClient fail', async () => { - const { instance, getUtxosSpy } = createMockDataClient(); + const { instance, getUtxosSpy } = createMockReadDataClient(); const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const sender = accounts[0].address; @@ -179,11 +190,11 @@ describe('BtcOnChainService', () => { describe('getFeeRates', () => { it('return getFeeRates result', async () => { - const { instance, getFeeRatesSpy } = createMockDataClient(); + const { instance, getFeeRatesSpy } = createMockReadDataClient(); const { instance: txMgr } = createMockBtcService(instance); getFeeRatesSpy.mockResolvedValue({ - [FeeRatio.Fast]: 1, - [FeeRatio.Medium]: 2, + [FeeRatio.Fast]: 1.1, + [FeeRatio.Medium]: 1.2, }); const result = await txMgr.getFeeRates(); @@ -193,18 +204,20 @@ describe('BtcOnChainService', () => { fees: [ { type: FeeRatio.Fast, - rate: BigInt(1), + rate: expect.any(BtcAmount), }, { type: FeeRatio.Medium, - rate: BigInt(2), + rate: expect.any(BtcAmount), }, ], }); + expect(result.fees[0].rate.value).toBe(1.1); + expect(result.fees[1].rate.value).toBe(1.2); }); it('throws BtcOnChainServiceError error if an error catched', async () => { - const { instance, getFeeRatesSpy } = createMockDataClient(); + const { instance, getFeeRatesSpy } = createMockReadDataClient(); const { instance: txMgr } = createMockBtcService(instance); getFeeRatesSpy.mockRejectedValue(new Error('error')); @@ -218,8 +231,8 @@ describe('BtcOnChainService', () => { '02000000000101ec81faa8b57add4c8fb3958dd8f04667f5cd829a7b94199f4400be9e52cda0760000000000ffffffff015802000000000000160014f80b562cbcbbfc97727043484c06cc5579963e8402473044022011ec3f7ea7a7cac7cb891a1ea498d94ca3cd082339b9b2620ba5421ca7cbdf3d022062f34411d6aa5335c2bd7ff4c940adb962e9509133b86a2d97996552fd811f2c012102ceea82614fdb14871ef881498c55c5dbdc24b4633d29b42040dd18b4285540f500000000'; it('calls sendTransaction with writeClient', async () => { - const { instance, sendTransactionSpy } = createMockDataClient(); - const { instance: txService } = createMockBtcService(instance); + const { instance, sendTransactionSpy } = createMockWriteDataClient(); + const { instance: txService } = createMockBtcService(undefined, instance); const resp = generateBlockChairBroadcastTransactionResp(); sendTransactionSpy.mockResolvedValue(resp.data.transaction_hash); @@ -233,8 +246,8 @@ describe('BtcOnChainService', () => { }); it('throws BtcOnChainServiceErrorr if write client execute fail', async () => { - const { instance, sendTransactionSpy } = createMockDataClient(); - const { instance: txService } = createMockBtcService(instance); + const { instance, sendTransactionSpy } = createMockWriteDataClient(); + const { instance: txService } = createMockBtcService(undefined, instance); sendTransactionSpy.mockRejectedValue(new Error('error')); await expect( @@ -248,7 +261,7 @@ describe('BtcOnChainService', () => { '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; it('return getTransactionStatus result', async () => { - const { instance, getTransactionStatusSpy } = createMockDataClient(); + const { instance, getTransactionStatusSpy } = createMockReadDataClient(); const { instance: txMgr } = createMockBtcService(instance); getTransactionStatusSpy.mockResolvedValue({ status: TransactionStatus.Confirmed, @@ -263,7 +276,7 @@ describe('BtcOnChainService', () => { }); it('throws BtcOnChainServiceError error if an error catched', async () => { - const { instance, getTransactionStatusSpy } = createMockDataClient(); + const { instance, getTransactionStatusSpy } = createMockReadDataClient(); const { instance: txMgr } = createMockBtcService(instance); getTransactionStatusSpy.mockRejectedValue(new Error('error')); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index d22aa65e..08b908a8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -4,28 +4,34 @@ import { networks } from 'bitcoinjs-lib'; import type { FeeRatio, IOnChainService, + Balances, AssetBalances, TransactionIntent, Fees, TransactionData, CommitedTransaction, } from '../../chain'; -import { Caip2Asset } from '../../constants'; import { compactError } from '../../utils'; -import type { IDataClient } from './data-client'; +import { BtcAsset } from '../constants'; +import type { IWriteDataClient, IReadDataClient } from '../data-client'; +import { BtcAmount } from '../wallet'; import { BtcOnChainServiceError } from './exceptions'; - -export type BtcOnChainServiceOptions = { - network: Network; -}; +import type { BtcOnChainServiceOptions } from './types'; export class BtcOnChainService implements IOnChainService { - protected readonly _dataClient: IDataClient; + protected readonly _readClient: IReadDataClient; + + protected readonly _writeClient: IWriteDataClient; protected readonly _options: BtcOnChainServiceOptions; - constructor(dataClient: IDataClient, options: BtcOnChainServiceOptions) { - this._dataClient = dataClient; + constructor( + readClient: IReadDataClient, + writeClient: IWriteDataClient, + options: BtcOnChainServiceOptions, + ) { + this._readClient = readClient; + this._writeClient = writeClient; this._options = options; } @@ -42,23 +48,23 @@ export class BtcOnChainService implements IOnChainService { throw new BtcOnChainServiceError('Only one asset is supported'); } - const allowedAssets = new Set(Object.values(Caip2Asset)); + const allowedAssets = new Set(Object.values(BtcAsset)); if ( !allowedAssets.has(assets[0]) || - (this.network === networks.testnet && assets[0] !== Caip2Asset.TBtc) || - (this.network === networks.bitcoin && assets[0] !== Caip2Asset.Btc) + (this.network === networks.testnet && assets[0] !== BtcAsset.TBtc) || + (this.network === networks.bitcoin && assets[0] !== BtcAsset.Btc) ) { throw new BtcOnChainServiceError('Invalid asset'); } - const balance = await this._dataClient.getBalances(addresses); + const balance: Balances = await this._readClient.getBalances(addresses); return addresses.reduce( (acc: AssetBalances, address: string) => { acc.balances[address] = { [assets[0]]: { - amount: BigInt(balance[address]), + amount: new BtcAmount(balance[address]), }, }; return acc; @@ -72,24 +78,24 @@ export class BtcOnChainService implements IOnChainService { async getFeeRates(): Promise { try { - const result = await this._dataClient.getFeeRates(); + const result = await this._readClient.getFeeRates(); return { fees: Object.entries(result).map( ([key, value]: [key: FeeRatio, value: number]) => ({ type: key, - rate: BigInt(value), + rate: new BtcAmount(value), }), ), }; } catch (error) { - throw compactError(error, BtcOnChainServiceError); + throw new BtcOnChainServiceError(error); } } async getTransactionStatus(txHash: string) { try { - return await this._dataClient.getTransactionStatus(txHash); + return await this._readClient.getTransactionStatus(txHash); } catch (error) { throw new BtcOnChainServiceError(error); } @@ -101,7 +107,7 @@ export class BtcOnChainService implements IOnChainService { transactionIntent?: TransactionIntent, ): Promise { try { - const data = await this._dataClient.getUtxos(address); + const data = await this._readClient.getUtxos(address); return { data: { utxos: data, @@ -116,7 +122,7 @@ export class BtcOnChainService implements IOnChainService { signedTransaction: string, ): Promise { try { - const transactionId = await this._dataClient.sendTransaction( + const transactionId = await this._writeClient.sendTransaction( signedTransaction, ); return { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/types.ts new file mode 100644 index 00000000..60ce9afb --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/types.ts @@ -0,0 +1,5 @@ +import type { Network } from 'bitcoinjs-lib'; + +export type BtcOnChainServiceOptions = { + network: Network; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/index.ts new file mode 100644 index 00000000..fcb073fe --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/types.ts new file mode 100644 index 00000000..a732c410 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/config/types.ts @@ -0,0 +1,23 @@ +import type { Json } from '@metamask/snaps-sdk'; + +import type { DataClient } from '../constants'; + +export type BtcOnChainServiceConfig = { + dataClient: { + read: { + type: DataClient; + options?: Record; + }; + write: { + type: DataClient; + options?: Record; + }; + }; +}; + +export type BtcWalletConfig = { + enableMultiAccounts: boolean; + defaultAccountIndex: number; + defaultAccountType: string; + deriver: string; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts index 0aa18b3d..58e194e3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/constants.ts @@ -4,6 +4,27 @@ export enum ScriptType { P2wpkh = 'p2wpkh', } +export enum Network { + Mainnet = 'bip122:000000000019d6689c085ae165831e93', + Testnet = 'bip122:000000000933ea01ad0ee984209779ba', +} + +export enum DataClient { + BlockStream = 'BlockStream', + BlockChair = 'BlockChair', +} + +export enum BtcAsset { + Btc = 'bip122:000000000019d6689c085ae165831e93/slip44:0', + TBtc = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0', +} + +export enum BtcUnit { + Btc = 'BTC', + TBtc = 'tBTC', + Sat = 'satoshi', +} + // reference https://help.magiceden.io/en/articles/8665399-navigating-bitcoin-dust-understanding-limits-and-safeguarding-your-transactions-on-magic-eden // "Dust" is defined in terms of dustRelayFee, // which has units satoshis-per-kilobyte. @@ -27,11 +48,4 @@ export const DustLimit = { /* eslint-disable */ }; -// Maximum weight in bytes for a standard transaction export const MaxStandardTxWeight = 400000; - -// Maximum amount of satoshis -export const maxSatoshi = 21 * 1e14; - -// Minimum amount of satoshis -export const minSatoshi = 1; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.test.ts similarity index 99% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.test.ts index 7a1e501d..c335d064 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.test.ts @@ -12,7 +12,7 @@ import { FeeRatio, TransactionStatus } from '../../../chain'; import { DataClientError } from '../exceptions'; import { BlockChairClient } from './blockchair'; -jest.mock('../../../utils/logger'); +jest.mock('../../../libs/logger/logger'); describe('BlockChairClient', () => { const createMockFetch = () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts similarity index 89% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts index f7534517..df958fac 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts @@ -1,16 +1,17 @@ import type { Json } from '@metamask/snaps-sdk'; import { type Network, networks } from 'bitcoinjs-lib'; -import type { TransactionStatusData, Utxo } from '../../../chain'; -import { FeeRatio, TransactionStatus } from '../../../chain'; +import type { TransactionStatusData } from '../../../chain'; +import { type Balances, FeeRatio, TransactionStatus } from '../../../chain'; +import { logger } from '../../../libs/logger/logger'; import { compactError } from '../../../utils'; -import { logger } from '../../../utils/logger'; +import type { Utxo } from '../../wallet'; +import { DataClientError } from '../exceptions'; import type { - GetBalancesResp, GetFeeRatesResp, - IDataClient, -} from '../data-client'; -import { DataClientError } from '../exceptions'; + IReadDataClient, + IWriteDataClient, +} from '../types'; export type BlockChairClientOptions = { network: Network; @@ -23,7 +24,7 @@ export type LargestTransaction = { value_usd: number; }; -export type GetBalancesResponse = { +export type GetBalanceResponse = { data: { [address: string]: number; }; @@ -203,7 +204,7 @@ export type PostTransactionResponse = { }; /* eslint-disable */ -export class BlockChairClient implements IDataClient { +export class BlockChairClient implements IReadDataClient, IWriteDataClient { protected readonly _options: BlockChairClientOptions; constructor(options: BlockChairClientOptions) { @@ -217,7 +218,7 @@ export class BlockChairClient implements IDataClient { case networks.testnet: return 'https://api.blockchair.com/bitcoin/testnet'; default: - throw new Error('Invalid network'); + throw new DataClientError('Invalid network'); } } @@ -236,7 +237,7 @@ export class BlockChairClient implements IDataClient { }); if (!response.ok) { - throw new Error( + throw new DataClientError( `Failed to fetch data from blockchair: ${response.statusText}`, ); } @@ -254,13 +255,13 @@ export class BlockChairClient implements IDataClient { if (response.status == 400) { const res = await response.json(); - throw new Error( + throw new DataClientError( `Failed to post data from blockchair: ${res.context.error}`, ); } if (!response.ok) { - throw new Error( + throw new DataClientError( `Failed to post data from blockchair: ${response.statusText}`, ); } @@ -282,14 +283,11 @@ export class BlockChairClient implements IDataClient { ); return response; } catch (error) { - logger.info( - `[BlockChairClient.getTxDashboardData] error: ${error.message}`, - ); throw compactError(error, DataClientError); } } - async getBalances(addresses: string[]): Promise { + async getBalances(addresses: string[]): Promise { try { logger.info( `[BlockChairClient.getBalance] start: { addresses : ${JSON.stringify( @@ -297,7 +295,7 @@ export class BlockChairClient implements IDataClient { )} }`, ); - const response = await this.get( + const response = await this.get( `/addresses/balances?addresses=${addresses.join(',')}`, ); @@ -305,12 +303,11 @@ export class BlockChairClient implements IDataClient { `[BlockChairClient.getBalance] response: ${JSON.stringify(response)}`, ); - return addresses.reduce((data: GetBalancesResp, address: string) => { + return addresses.reduce((data: Balances, address: string) => { data[address] = response.data[address] ?? 0; return data; }, {}); } catch (error) { - logger.info(`[BlockChairClient.getBalance] error: ${error.message}`); throw compactError(error, DataClientError); } } @@ -339,7 +336,7 @@ export class BlockChairClient implements IDataClient { ); if (!Object.prototype.hasOwnProperty.call(response.data, address)) { - throw new Error(`Data not avaiable`); + throw new DataClientError(`Data not avaiable`); } response.data[address].utxo.forEach((utxo) => { @@ -360,7 +357,6 @@ export class BlockChairClient implements IDataClient { return data; } catch (error) { - logger.info(`[BlockChairClient.getUtxos] error: ${error.message}`); throw compactError(error, DataClientError); } } @@ -376,7 +372,6 @@ export class BlockChairClient implements IDataClient { [FeeRatio.Fast]: response.data.suggested_transaction_fee_per_byte_sat, }; } catch (error) { - logger.info(`[BlockChairClient.getFeeRates] error: ${error.message}`); throw compactError(error, DataClientError); } } @@ -398,7 +393,6 @@ export class BlockChairClient implements IDataClient { return response.data.transaction_hash; } catch (error) { - logger.info(`[BlockChairClient.sendTransaction] error: ${error.message}`); throw compactError(error, DataClientError); } } @@ -424,9 +418,6 @@ export class BlockChairClient implements IDataClient { status: status, }; } catch (error) { - logger.info( - `[BlockChairClient.getTransactionStatus] error: ${error.message}`, - ); throw compactError(error, DataClientError); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts new file mode 100644 index 00000000..ae127a5b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.test.ts @@ -0,0 +1,305 @@ +import { networks } from 'bitcoinjs-lib'; + +import { + generateAccounts, + generateBlockStreamAccountStats, + generateBlockStreamGetUtxosResp, + generateBlockStreamEstFeeResp, + generateBlockStreamTransactionStatusResp, +} from '../../../../test/utils'; +import { FeeRatio, TransactionStatus } from '../../../chain'; +import * as asyncUtils from '../../../utils/async'; +import { DataClientError } from '../exceptions'; +import { BlockStreamClient } from './blockstream'; + +jest.mock('../../../libs/logger/logger'); + +describe('BlockStreamClient', () => { + const createMockFetch = () => { + // 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, + }; + }; + + 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('getBalances', () => { + it('returns balances', async () => { + const { fetchSpy } = createMockFetch(); + const accounts = generateAccounts(60); + const addresses = accounts.map((account) => account.address); + const mockResponse = generateBlockStreamAccountStats(addresses); + const expectedResult = {}; + mockResponse.forEach((data) => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(data), + }); + expectedResult[data.address] = + data.chain_stats.funded_txo_sum - data.chain_stats.spent_txo_sum; + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + const result = await instance.getBalances(addresses); + + expect(result).toStrictEqual(expectedResult); + }); + + it('assigns balance to 0 if it failed to fetch', async () => { + const { fetchSpy } = createMockFetch(); + fetchSpy.mockResolvedValue({ + ok: false, + statusText: '503', + }); + const accounts = generateAccounts(1); + const addresses = accounts.map((account) => account.address); + + const instance = new BlockStreamClient({ network: networks.testnet }); + const result = await instance.getBalances(addresses); + + expect(result).toStrictEqual({ [addresses[0]]: 0 }); + }); + + it('throws DataClientError error if an non DataClientError catched', async () => { + const asyncHelperSpy = jest.spyOn(asyncUtils, 'processBatch'); + asyncHelperSpy.mockRejectedValue(new Error('error')); + const accounts = generateAccounts(1); + const addresses = accounts.map((account) => account.address); + + const instance = new BlockStreamClient({ network: networks.testnet }); + + await expect(instance.getBalances(addresses)).rejects.toThrow( + DataClientError, + ); + }); + }); + + describe('getFeeRates', () => { + it('returns fee rate', async () => { + const { fetchSpy } = createMockFetch(); + const mockResponse = generateBlockStreamEstFeeResp(); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + const result = await instance.getFeeRates(); + + expect(result).toStrictEqual({ + [FeeRatio.Fast]: Math.round(mockResponse['1']), + [FeeRatio.Medium]: Math.round(mockResponse['25']), + [FeeRatio.Slow]: Math.round(mockResponse['144']), + }); + }); + + it('throws DataClientError error if an non DataClientError catched', async () => { + const { fetchSpy } = createMockFetch(); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockRejectedValue(new Error('error')), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + + await expect(instance.getFeeRates()).rejects.toThrow(DataClientError); + }); + + it('throws DataClientError error if an DataClientError catched', async () => { + const { fetchSpy } = createMockFetch(); + + fetchSpy.mockResolvedValueOnce({ + ok: false, + json: jest.fn().mockResolvedValue(null), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + + await expect(instance.getFeeRates()).rejects.toThrow(DataClientError); + }); + }); + + describe('getUtxos', () => { + it('returns utxos', async () => { + const { fetchSpy } = createMockFetch(); + const accounts = generateAccounts(1); + const { address } = accounts[0]; + const mockResponse = generateBlockStreamGetUtxosResp(100); + const expectedResult = mockResponse.map((utxo) => ({ + block: utxo.status.block_height, + txHash: utxo.txid, + index: utxo.vout, + value: utxo.value, + })); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + const result = await instance.getUtxos(address); + + expect(result).toStrictEqual(expectedResult); + }); + + it('includes unconfirmed if includeUnconfirmed is true', async () => { + const { fetchSpy } = createMockFetch(); + const accounts = generateAccounts(1); + const { address } = accounts[0]; + const mockConfirmedResponse = generateBlockStreamGetUtxosResp(100); + const mockUnconfirmedResponse = generateBlockStreamGetUtxosResp( + 100, + false, + ); + const combinedResponse = mockConfirmedResponse.concat( + mockUnconfirmedResponse, + ); + const expectedResult = combinedResponse.map((utxo) => ({ + block: utxo.status.block_height, + txHash: utxo.txid, + index: utxo.vout, + value: utxo.value, + })); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(combinedResponse), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + const result = await instance.getUtxos(address, true); + + expect(result).toStrictEqual(expectedResult); + }); + + it('filters unconfirmed utxos if includeUnconfirmed is true', async () => { + const { fetchSpy } = createMockFetch(); + const accounts = generateAccounts(1); + const { address } = accounts[0]; + const mockConfirmedResponse = generateBlockStreamGetUtxosResp(100); + const mockUnconfirmedResponse = generateBlockStreamGetUtxosResp( + 100, + false, + ); + const combinedResponse = mockConfirmedResponse.concat( + mockUnconfirmedResponse, + ); + const expectedResult = mockConfirmedResponse.map((utxo) => ({ + block: utxo.status.block_height, + txHash: utxo.txid, + index: utxo.vout, + value: utxo.value, + })); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(combinedResponse), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + const result = await instance.getUtxos(address, false); + + expect(result).toStrictEqual(expectedResult); + }); + + it('throws DataClientError error if an non DataClientError catched', async () => { + const { fetchSpy } = createMockFetch(); + fetchSpy.mockRejectedValue(new Error('error')); + const accounts = generateAccounts(1); + const { address } = accounts[0]; + + const instance = new BlockStreamClient({ network: networks.testnet }); + + await expect(instance.getUtxos(address)).rejects.toThrow(DataClientError); + }); + }); + + describe('getTransactionStatus', () => { + const txHash = + '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; + + it('returns correct result for confirmed transaction', async () => { + const { fetchSpy } = createMockFetch(); + const mockTxStatusResponse = generateBlockStreamTransactionStatusResp( + 200000, + true, + ); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockTxStatusResponse), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + const result = await instance.getTransactionStatus(txHash); + + expect(result).toStrictEqual({ + status: TransactionStatus.Confirmed, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('returns correct result for pending transaction', async () => { + const { fetchSpy } = createMockFetch(); + const mockTxStatusResponse = generateBlockStreamTransactionStatusResp( + 200000, + false, + ); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockTxStatusResponse), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + const result = await instance.getTransactionStatus(txHash); + + expect(result).toStrictEqual({ + status: TransactionStatus.Pending, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('throws DataClientError error if an DataClientError catched', async () => { + const { fetchSpy } = createMockFetch(); + + fetchSpy.mockResolvedValueOnce({ + ok: false, + json: jest.fn().mockResolvedValue(null), + }); + + const instance = new BlockStreamClient({ network: networks.testnet }); + + await expect(instance.getTransactionStatus(txHash)).rejects.toThrow( + DataClientError, + ); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts new file mode 100644 index 00000000..4b8b155b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts @@ -0,0 +1,223 @@ +import { type Network, networks } from 'bitcoinjs-lib'; + +import type { TransactionStatusData } from '../../../chain'; +import { type Balances, FeeRatio, TransactionStatus } from '../../../chain'; +import { logger } from '../../../libs/logger/logger'; +import { compactError, processBatch } from '../../../utils'; +import type { Utxo } from '../../wallet'; +import { DataClientError } from '../exceptions'; +import type { GetFeeRatesResp, IReadDataClient } from '../types'; + +export type BlockStreamClientOptions = { + network: Network; +}; + +/* eslint-disable */ +export type GetFeeEstimateResponse = Record; + +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; + }; +}; + +export type GetTransactionStatusResponse = { + confirmed: boolean; + block_height: number; + block_hash: string; + block_time: number; +}; + +export type GetUtxosResponse = { + txid: string; + vout: number; + status: { + confirmed: boolean; + block_height: number; + block_hash: string; + block_time: number; + }; + value: number; +}[]; + +export type GetBlocksResponse = { + id: string; + height: number; + version: number; + timestamp: number; + tx_count: number; + size: number; + weight: number; + merkle_root: string; + previousblockhash: string; + mediantime: number; + nonce: number; + bits: number; + difficulty: number; +}[]; + +/* eslint-enable */ + +export class BlockStreamClient implements IReadDataClient { + protected readonly _options: BlockStreamClientOptions; + + protected readonly _feeRateRatioMap: Record; + + constructor(options: BlockStreamClientOptions) { + this._options = options; + this._feeRateRatioMap = { + [FeeRatio.Fast]: '1', + [FeeRatio.Medium]: '144', + [FeeRatio.Slow]: '144', + }; + } + + 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'); + } + } + + 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 unknown as Resp; + } + + async getBalances(addresses: string[]): Promise { + try { + const responses: Balances = {}; + + await processBatch(addresses, async (address: string) => { + logger.info(`[BlockStreamClient.getBalance] address: ${address}`); + let balance = 0; + try { + const response = await this.get( + `/address/${address}`, + ); + logger.info( + `[BlockStreamClient.getBalance] response: ${JSON.stringify( + response, + )}`, + ); + balance = + response.chain_stats.funded_txo_sum - + response.chain_stats.spent_txo_sum; + } catch (error) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.info(`[BlockStreamClient.getBalance] error: ${error.message}`); + } finally { + responses[address] = balance; + } + }); + + return responses; + } catch (error) { + throw compactError(error, DataClientError); + } + } + + async getUtxos( + address: string, + includeUnconfirmed?: boolean, + ): Promise { + try { + const response = await this.get( + `/address/${address}/utxo`, + ); + + logger.info( + `[BlockStreamClient.getUtxos] response: ${JSON.stringify(response)}`, + ); + + const data: Utxo[] = []; + + for (const utxo of response) { + if (!includeUnconfirmed && !utxo.status.confirmed) { + continue; + } + data.push({ + block: utxo.status.block_height, + txHash: utxo.txid, + index: utxo.vout, + value: utxo.value, + }); + } + + return data; + } catch (error) { + throw compactError(error, DataClientError); + } + } + + async getFeeRates(): Promise { + try { + logger.info(`[BlockStreamClient.getFeeRates] start:`); + const response = await this.get(`/fee-estimates`); + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.info( + `[BlockStreamClient.getFeeRates] response: ${JSON.stringify(response)}`, + ); + return { + [FeeRatio.Fast]: Math.round( + response[this._feeRateRatioMap[FeeRatio.Fast]], + ), + [FeeRatio.Medium]: Math.round( + response[this._feeRateRatioMap[FeeRatio.Medium]], + ), + [FeeRatio.Slow]: Math.round( + response[this._feeRateRatioMap[FeeRatio.Slow]], + ), + }; + } catch (error) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.info(`[BlockStreamClient.getFeeRates] error: ${error.message}`); + if (error instanceof DataClientError) { + throw error; + } + throw new DataClientError(error); + } + } + + async getTransactionStatus(txHash: string): Promise { + try { + const txStatusResp = await this.get( + `/tx/${txHash}/status`, + ); + + const status = txStatusResp.confirmed + ? TransactionStatus.Confirmed + : TransactionStatus.Pending; + + return { + status, + }; + } catch (error) { + throw compactError(error, DataClientError); + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/exceptions.ts new file mode 100644 index 00000000..d46bde48 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/exceptions.ts @@ -0,0 +1,3 @@ +import { CustomError } from '../../libs/exception'; + +export class DataClientError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.test.ts new file mode 100644 index 00000000..0c9aa8a1 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.test.ts @@ -0,0 +1,82 @@ +import { networks } from 'bitcoinjs-lib'; + +import { DataClient } from '../constants'; +import { BlockChairClient } from './clients/blockchair'; +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 }, + write: { type: DataClient.BlockChair }, + }, + }, + networks.testnet, + ); + + expect(instance).toBeInstanceOf(BlockStreamClient); + }); + + it('creates BlockChairClient', () => { + const instance = DataClientFactory.createReadClient( + { + dataClient: { + read: { type: DataClient.BlockChair }, + write: { type: DataClient.BlockChair }, + }, + }, + networks.testnet, + ); + + expect(instance).toBeInstanceOf(BlockChairClient); + }); + + it('throws `Unsupported client type` if the given client is not support', () => { + expect(() => + DataClientFactory.createReadClient( + { + dataClient: { + read: { type: 'SomeClient' as unknown as DataClient }, + write: { type: DataClient.BlockChair }, + }, + }, + networks.testnet, + ), + ).toThrow('Unsupported client type: SomeClient'); + }); + }); + + describe('createWriteClient', () => { + it('creates BlockChairClient', () => { + const instance = DataClientFactory.createWriteClient( + { + dataClient: { + read: { type: DataClient.BlockChair }, + write: { type: DataClient.BlockChair }, + }, + }, + networks.testnet, + ); + + expect(instance).toBeInstanceOf(BlockChairClient); + }); + + it('throws `Unsupported client type` if the given client is not support', () => { + expect(() => + DataClientFactory.createWriteClient( + { + dataClient: { + read: { type: DataClient.BlockChair }, + write: { type: 'SomeClient' as unknown as DataClient }, + }, + }, + networks.testnet, + ), + ).toThrow('Unsupported client type: SomeClient'); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.ts new file mode 100644 index 00000000..f2d1b90f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/factory.ts @@ -0,0 +1,50 @@ +import { type Network } from 'bitcoinjs-lib'; + +import { type BtcOnChainServiceConfig } from '../config'; +import { DataClient } from '../constants'; +import { BlockChairClient } from './clients/blockchair'; +import { BlockStreamClient } from './clients/blockstream'; +import { DataClientError } from './exceptions'; +import type { IReadDataClient, IWriteDataClient } from './types'; + +export class DataClientFactory { + static createReadClient( + config: BtcOnChainServiceConfig, + network: Network, + ): IReadDataClient { + const { type, options } = config.dataClient.read; + switch (type) { + case DataClient.BlockStream: + return new BlockStreamClient({ network }); + case DataClient.BlockChair: + return new BlockChairClient({ + network, + apiKey: options?.apiKey?.toString(), + }); + default: + throw new DataClientError( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Unsupported client type: ${config.dataClient.read.type}`, + ); + } + } + + static createWriteClient( + config: BtcOnChainServiceConfig, + network: Network, + ): IWriteDataClient { + const { type, options } = config.dataClient.write; + switch (type) { + case DataClient.BlockChair: + return new BlockChairClient({ + network, + apiKey: options?.apiKey?.toString(), + }); + default: + throw new DataClientError( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Unsupported client type: ${config.dataClient.write.type}`, + ); + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/index.ts new file mode 100644 index 00000000..8decdbf9 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/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/bitcoin/chain/data-client.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/types.ts similarity index 50% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts rename to merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/types.ts index a510b1a6..f7c18ba5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/types.ts @@ -1,15 +1,19 @@ -import type { FeeRatio, TransactionStatusData, Utxo } from '../../chain'; - -export type GetBalancesResp = Record; +import type { Balances, FeeRatio, TransactionStatusData } from '../../chain'; +import type { Utxo } from '../wallet'; export type GetFeeRatesResp = { [key in FeeRatio]?: number; }; -export type IDataClient = { - getBalances(address: string[]): Promise; +export type IReadDataClient = { + getBalances(address: string[]): Promise; getUtxos(address: string, includeUnconfirmed?: boolean): Promise; getFeeRates(): Promise; getTransactionStatus(txHash: string): Promise; +}; + +export type IWriteDataClient = { sendTransaction(tx: string): Promise; }; + +export type IDataClient = IReadDataClient & IWriteDataClient; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts index e12050c8..d6d083e6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts @@ -12,10 +12,10 @@ describe('address', () => { expect(val).toBeInstanceOf(Buffer); }); - it('throws `Destination address has no matching Script` error if the given address is invalid', () => { + it('throws `Destnation address has no matching Script` error if the given address is invalid', () => { expect(() => getScriptForDestnation('bad-address', networks.testnet), - ).toThrow(`Destination address has no matching Script`); + ).toThrow(`Destnation address has no matching Script`); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts index c4736482..e36946dd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts @@ -12,6 +12,6 @@ export function getScriptForDestnation(address: string, network: Network) { try { return addressUtils.toOutputScript(address, network); } catch (error) { - throw new Error('Destination address has no matching Script'); + throw new Error('Destnation address has no matching Script'); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.test.ts new file mode 100644 index 00000000..a7487cab --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.test.ts @@ -0,0 +1,27 @@ +import { Chain, Config } from '../../config'; +import { Network } from '../constants'; +import { getExplorerUrl } from './explorer'; + +describe('getExplorerUrl', () => { + const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; + + it('returns a bitcoin testnet explorer url', () => { + const result = getExplorerUrl(address, Network.Testnet); + expect(result).toBe( + `${Config.explorer[Chain.Bitcoin][Network.Testnet]}/address/${address}`, + ); + }); + + it('returns a bitcoin mainnet explorer url', () => { + const result = getExplorerUrl(address, Network.Mainnet); + expect(result).toBe( + `${Config.explorer[Chain.Bitcoin][Network.Mainnet]}/address/${address}`, + ); + }); + + it('throws `Invalid network` error if the given network is not support', () => { + expect(() => getExplorerUrl(address, 'some invalid network')).toThrow( + 'Invalid network', + ); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts new file mode 100644 index 00000000..ed7c152f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/explorer.ts @@ -0,0 +1,25 @@ +import { Chain, Config } from '../../config'; +import { Network } from '../constants'; + +/** + * Gets the explorer URL for a given bitcoin address and CAIP-2 Chain ID. + * + * @param address - The bitcoin address to get the explorer URL for. + * @param network - The CAIP-2 Chain ID. + * @returns The explorer URL as a string. + * @throws An error if an invalid network is provided. + */ +export function getExplorerUrl(address: string, network: string): string { + switch (network) { + case Network.Mainnet: + return `${ + Config.explorer[Chain.Bitcoin][Network.Mainnet] + }/address/${address}`; + case Network.Testnet: + return `${ + Config.explorer[Chain.Bitcoin][Network.Testnet] + }/address/${address}`; + default: + throw new Error('Invalid network'); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts index a57139bb..fb79250f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/index.ts @@ -1,4 +1,4 @@ export * from './payment'; export * from './network'; -export * from './policy'; -export * from './address'; +export * from './unit'; +export * from './explorer'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts index 279d4297..29e66ac3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.test.ts @@ -1,22 +1,22 @@ import { networks } from 'bitcoinjs-lib'; -import { Caip2ChainId } from '../../constants'; +import { Network } from '../constants'; import { getBtcNetwork, getCaip2ChainId } from './network'; describe('getBtcNetwork', () => { it('returns bitcoin testnet network', () => { - const result = getBtcNetwork(Caip2ChainId.Testnet); + const result = getBtcNetwork(Network.Testnet); expect(result).toStrictEqual(networks.testnet); }); it('returns bitcoin mainnet network', () => { - const result = getBtcNetwork(Caip2ChainId.Mainnet); + const result = getBtcNetwork(Network.Mainnet); expect(result).toStrictEqual(networks.bitcoin); }); it('throws `Invalid network` error if the given network is not support', () => { expect(() => - getBtcNetwork('someInvalidNetwork' as unknown as Caip2ChainId), + getBtcNetwork('someInvalidNetwork' as unknown as Network), ).toThrow('Invalid network'); }); }); @@ -24,12 +24,12 @@ describe('getBtcNetwork', () => { describe('getCaip2ChainId', () => { it('returns CAIP-2 testnet chain ID of bitcoin', () => { const result = getCaip2ChainId(networks.testnet); - expect(result).toStrictEqual(Caip2ChainId.Testnet); + expect(result).toStrictEqual(Network.Testnet); }); it('returns CAIP-2 mainnet chain ID of bitcoin', () => { const result = getCaip2ChainId(networks.bitcoin); - expect(result).toStrictEqual(Caip2ChainId.Mainnet); + expect(result).toStrictEqual(Network.Mainnet); }); it('throws `Invalid network` error if the given network is not support', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts index 9900e71e..b6078c15 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/network.ts @@ -1,20 +1,20 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; -import { Caip2ChainId } from '../../constants'; +import { Network as NetworkEnum } from '../constants'; /** * Gets a bitcoinjs-lib network instance based on a given CAIP-2 Chain ID. * - * @param caip2ChainId - The CAIP-2 Chain ID. + * @param network - The CAIP-2 Chain ID. * @returns The instance of bitcoinjs-lib network. * @throws An error if an invalid network is provided. */ -export function getBtcNetwork(caip2ChainId: string) { - switch (caip2ChainId) { - case Caip2ChainId.Mainnet: +export function getBtcNetwork(network: string) { + switch (network) { + case NetworkEnum.Mainnet: return networks.bitcoin; - case Caip2ChainId.Testnet: + case NetworkEnum.Testnet: return networks.testnet; default: throw new Error('Invalid network'); @@ -28,12 +28,12 @@ export function getBtcNetwork(caip2ChainId: string) { * @returns The CAIP-2 Chain ID. * @throws An error if an invalid network is provided. */ -export function getCaip2ChainId(network: Network): Caip2ChainId { +export function getCaip2ChainId(network: Network): NetworkEnum { switch (network) { case networks.bitcoin: - return Caip2ChainId.Mainnet; + return NetworkEnum.Mainnet; case networks.testnet: - return Caip2ChainId.Testnet; + return NetworkEnum.Testnet; default: throw new Error('Invalid network'); } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts deleted file mode 100644 index db35d81e..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { DustLimit, ScriptType } from '../constants'; -import { isDust } from './policy'; - -describe('isDust', () => { - it('returns result', () => { - expect(isDust(DustLimit[ScriptType.P2wpkh] + 1, ScriptType.P2wpkh)).toBe( - false, - ); - expect( - isDust(BigInt(DustLimit[ScriptType.P2wpkh] + 1), ScriptType.P2wpkh), - ).toBe(false); - expect( - isDust(BigInt(DustLimit[ScriptType.P2wpkh] - 1), ScriptType.P2wpkh), - ).toBe(true); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts deleted file mode 100644 index 905ec2ec..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/policy.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ScriptType } from '../constants'; -import { DustLimit } from '../constants'; - -/** - * Determines if a given amount is considered dust based on the hardcoded dust limit for a given script type. - * - * @param amt - The amount to compare. - * @param scriptType - The script type for which to calculate the dust limit. - * @returns A boolean indicating whether the amount is considered dust. - */ -export function isDust(amt: bigint | number, scriptType: ScriptType): boolean { - // TODO: Calculate dust threshold by network fee rate, rather than hardcoding it. - if (typeof amt === 'number') { - return amt < DustLimit[scriptType]; - } - return amt < BigInt(DustLimit[scriptType]); -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.test.ts new file mode 100644 index 00000000..fcef376e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.test.ts @@ -0,0 +1,56 @@ +import { DustLimit, ScriptType } from '../constants'; +import { satsToBtc, btcToSats, isDust } from './unit'; + +describe('satsToBtc', () => { + it('returns Btc unit', () => { + const sats = 1234567899; + expect(satsToBtc(sats)).toBe('12.34567899'); + }); + + it('returns Btc unit with max Satoshis', () => { + const maxSats = 21 * Math.pow(10, 15); + + expect(satsToBtc(maxSats)).toBe('210000000.00000000'); + }); + + it('returns Btc unit with min Satoshis', () => { + const minSats = 1; + expect(satsToBtc(minSats)).toBe('0.00000001'); + }); + + it('throw an error if then given Satoshis in float', () => { + const sats = 1.1; + expect(() => satsToBtc(sats)).toThrow(Error); + }); +}); + +describe('btcToSats', () => { + it('returns Btc unit', () => { + const sats = 1234567899; + const btc = satsToBtc(sats); + expect(btcToSats(parseFloat(btc))).toBe('1234567899'); + }); + + it('returns Btc unit with max Satoshis', () => { + const maxSats = 21 * Math.pow(10, 15); + const btc = satsToBtc(maxSats); + expect(btcToSats(parseFloat(btc))).toBe('21000000000000000'); + }); + + it('returns Btc unit with min Satoshis', () => { + const minSats = 1; + const btc = satsToBtc(minSats); + expect(btcToSats(parseFloat(btc))).toBe('1'); + }); +}); + +describe('isDust', () => { + it('returns result', () => { + expect(isDust(DustLimit[ScriptType.P2wpkh] + 1, ScriptType.P2wpkh)).toBe( + false, + ); + expect(isDust(DustLimit[ScriptType.P2wpkh] - 1, ScriptType.P2wpkh)).toBe( + true, + ); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts new file mode 100644 index 00000000..301c08a3 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/unit.ts @@ -0,0 +1,38 @@ +import type { ScriptType } from '../constants'; +import { DustLimit } from '../constants'; + +/** + * Converts a number of satoshis to a string representing the equivalent amount of BTC. + * + * @param sats - The number of satoshis to convert. + * @returns The equivalent amount of BTC as a string, fixed to 8 decimal places. + * @throws A TypeError if sats is not an integer. + */ +export function satsToBtc(sats: number): string { + if (!Number.isInteger(sats)) { + throw new TypeError('satsToBtc must be called on an integer number'); + } + return (sats / 1e8).toFixed(8); +} + +/** + * Converts a number of BTC to a string representing the equivalent amount of satoshis. + * + * @param btc - The amount of BTC to convert. + * @returns The equivalent amount of satoshis as a string, rounded to the nearest integer. + */ +export function btcToSats(btc: number): string { + return (btc * 1e8).toFixed(0); +} + +/** + * Determines if a given amount is considered dust based on the hardcoded dust limit for a given script type. + * + * @param amt - The amount to compare. + * @param scriptType - The script type for which to calculate the dust limit. + * @returns A boolean indicating whether the amount is considered dust. + */ +export function isDust(amt: number, scriptType: ScriptType): boolean { + // TODO: Calculate dust threshold by network fee rate, rather than hardcoding it. + return amt < DustLimit[scriptType]; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts index 09c33648..8c83a93d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts @@ -1,35 +1,14 @@ import type { Network, Payment } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; +import type { StaticImplements } from '../../types/static'; import { hexToBuffer } from '../../utils'; -import type { IAccount, IAccountSigner } from '../../wallet'; +import type { IAccountSigner } from '../../wallet'; import { ScriptType } from '../constants'; -import { getBtcPaymentInst } from '../utils'; -import type { StaticImplements } from './static'; - -export type IBtcAccount = IAccount & { - payment: Payment; - scriptType: ScriptType; - network: Network; - script: Buffer; -}; - -export type IStaticBtcAccount = { - path: string[]; - scriptType: ScriptType; - new ( - mfp: string, - index: number, - hdPath: string, - pubkey: string, - network: Network, - scriptType: ScriptType, - type: string, - signer: IAccountSigner, - ): IBtcAccount; -}; +import { getBtcPaymentInst } from '../utils/payment'; +import type { IBtcAccount, IStaticBtcAccount } from './types'; -export abstract class BtcAccount implements IBtcAccount { +export class BtcAccount implements IBtcAccount { #address: string; #payment: Payment; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts new file mode 100644 index 00000000..b41d07c2 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.test.ts @@ -0,0 +1,24 @@ +import { BtcAddress } from './address'; + +describe('BtcAddress', () => { + const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; + + describe('toString', () => { + it('returns a address', async () => { + const val = new BtcAddress(address); + expect(val.toString()).toStrictEqual(address); + }); + + it('returns a shortern address', async () => { + const val = new BtcAddress(address); + expect(val.toString(true)).toBe('tb1qt...aeu'); + }); + }); + + describe('valueOf', () => { + it('returns a value', async () => { + const val = new BtcAddress(address); + expect(val.valueOf()).toStrictEqual(val.value); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts new file mode 100644 index 00000000..71bf7abf --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts @@ -0,0 +1,18 @@ +import { replaceMiddleChar } from '../../utils'; +import type { IAddress } from '../../wallet'; + +export class BtcAddress implements IAddress { + value: string; + + constructor(address: string) { + this.value = address; + } + + valueOf(): string { + return this.value; + } + + toString(isShortern = false): string { + return isShortern ? replaceMiddleChar(this.value, 5, 3) : this.value; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts new file mode 100644 index 00000000..82985c15 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.test.ts @@ -0,0 +1,23 @@ +import { satsToBtc } from '../utils'; +import { BtcAmount } from './amount'; + +describe('BtcAmount', () => { + describe('toString', () => { + it('returns a satsToBtc string with unit', async () => { + const val = new BtcAmount(1); + expect(val.toString(true)).toBe(`${satsToBtc(val.value)} BTC`); + }); + + it('returns a satsToBtc string without unit', async () => { + const val = new BtcAmount(1); + expect(val.toString(false)).toStrictEqual(satsToBtc(val.value)); + }); + }); + + describe('valueOf', () => { + it('returns a value', async () => { + const val = new BtcAmount(1); + expect(val.valueOf()).toStrictEqual(val.value); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts new file mode 100644 index 00000000..0b272924 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/amount.ts @@ -0,0 +1,26 @@ +import { Chain, Config } from '../../config'; +import type { IAmount } from '../../wallet'; +import { satsToBtc } from '../utils'; + +export class BtcAmount implements IAmount { + value: number; + + constructor(value: number) { + this.value = value; + } + + get unit(): string { + return Config.unit[Chain.Bitcoin]; + } + + valueOf(): number { + return this.value; + } + + toString(withUnit = false): string { + if (!withUnit) { + return `${satsToBtc(this.value)}`; + } + return `${satsToBtc(this.value)} ${this.unit}`; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts index 0e123f68..5f009fa7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts @@ -3,16 +3,19 @@ import { networks, address as addressUtils } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { ScriptType } from '../constants'; import { CoinSelectService } from './coin-select'; -import { BtcAccountDeriver } from './deriver'; +import { BtcAccountBip32Deriver } from './deriver'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; import { BtcWallet } from './wallet'; -jest.mock('../../utils/snap'); +jest.mock('../../libs/snap/helpers'); describe('CoinSelectService', () => { const createMockWallet = (network) => { - const instance = new BtcWallet(new BtcAccountDeriver(network), network); + const instance = new BtcWallet( + new BtcAccountBip32Deriver(network), + network, + ); return { instance, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index 2514e825..a6506849 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -3,13 +3,7 @@ import coinSelect from 'coinselect'; import { TxValidationError } from './exceptions'; import type { TxInput } from './transaction-input'; import type { TxOutput } from './transaction-output'; - -export type SelectionResult = { - change?: TxOutput; - fee: number; - inputs: TxInput[]; - outputs: TxOutput[]; -}; +import { type SelectionResult } from './types'; export class CoinSelectService { protected readonly _feeRate: number; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts index 09970973..b673d8b4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts @@ -1,18 +1,23 @@ -import { type SLIP10NodeInterface } from '@metamask/key-tree'; +import ecc from '@bitcoinerlab/secp256k1'; +import { + type BIP44AddressKeyDeriver, + type SLIP10NodeInterface, +} from '@metamask/key-tree'; import { networks } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; +import ECPairFactory from 'ecpair'; -import * as snapUtils from '../../utils/snap'; +import { SnapHelper } from '../../libs/snap'; import * as strUtils from '../../utils/string'; import { P2WPKHAccount } from './account'; -import { BtcAccountDeriver } from './deriver'; +import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; -jest.mock('../../utils/snap'); +jest.mock('../../libs/snap/helpers'); -describe('BtcAccountDeriver', () => { +describe('BtcAccountBip32Deriver', () => { const prepareBip32Deriver = async (network) => { - const deriver = new BtcAccountDeriver(network); - const bip32Deriver = await snapUtils.getBip32Deriver( + const deriver = new BtcAccountBip32Deriver(network); + const bip32Deriver = await SnapHelper.getBip32Deriver( P2WPKHAccount.path, deriver.curve, ); @@ -27,6 +32,37 @@ describe('BtcAccountDeriver', () => { }; }; + describe('createBip32FromSeed', () => { + it('returns an BIP32Interface', async () => { + const network = networks.testnet; + const { deriver, pkBuffer } = await prepareBip32Deriver(network); + + const result = deriver.createBip32FromSeed(pkBuffer); + + expect(result.chainCode).toBeDefined(); + expect(result.chainCode).not.toBeNull(); + expect(result.privateKey).toBeDefined(); + expect(result.privateKey).not.toBeNull(); + expect(result.publicKey).toBeDefined(); + expect(result.publicKey).not.toBeNull(); + expect(result.depth).toBeDefined(); + expect(result.depth).not.toBeNull(); + expect(result.index).toBeDefined(); + expect(result.index).not.toBeNull(); + }); + + it('throws `Unable to construct BIP32 node from seed` if an error catched', () => { + const network = networks.testnet; + const seed = Buffer.from('', 'hex'); + + const deriver = new BtcAccountBip32Deriver(network); + + expect(() => deriver.createBip32FromSeed(seed)).toThrow( + 'Unable to construct BIP32 node from seed', + ); + }); + }); + describe('createBip32FromPrivateKey', () => { it('returns an BIP32Interface', async () => { const network = networks.testnet; @@ -50,7 +86,7 @@ describe('BtcAccountDeriver', () => { it('throws `Unable to construct BIP32 node from private key` if an error catched', async () => { const network = networks.testnet; - const deriver = new BtcAccountDeriver(network); + const deriver = new BtcAccountBip32Deriver(network); const pkBuffer = Buffer.from(''); const ccBuffer = Buffer.from(''); @@ -129,10 +165,10 @@ describe('BtcAccountDeriver', () => { it('throws DeriverError if private key is missing', async () => { const network = networks.testnet; - const deriver = new BtcAccountDeriver(network); + const deriver = new BtcAccountBip32Deriver(network); jest - .spyOn(snapUtils, 'getBip32Deriver') + .spyOn(SnapHelper, 'getBip32Deriver') .mockResolvedValue({} as unknown as SLIP10NodeInterface); await expect(deriver.getRoot(P2WPKHAccount.path)).rejects.toThrow( @@ -141,3 +177,53 @@ describe('BtcAccountDeriver', () => { }); }); }); + +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 } = P2WPKHAccount; + const ecpair = ECPairFactory(ecc); + const privateKey = ecpair.makeRandom().privateKey?.toString('hex'); + const { getBip44DeriverSpy, deriverSpy } = createMockBip44Entropy(); + + deriverSpy.mockResolvedValue({ + privateKey, + }); + + const deriver = new BtcAccountBip44Deriver(network); + await deriver.getRoot(path); + + expect(getBip44DeriverSpy).toHaveBeenCalledWith(0); + }); + + it('throws `Deriver private key is missing` error if the private key is missing', async () => { + const network = networks.testnet; + const { path } = P2WPKHAccount; + const { deriverSpy } = createMockBip44Entropy(); + deriverSpy.mockResolvedValue({ + privateKey: undefined, + }); + + const deriver = new BtcAccountBip44Deriver(network); + + await expect(deriver.getRoot(path)).rejects.toThrow( + 'Deriver private key is missing', + ); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts index 1e5507e5..bd245801 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts @@ -4,48 +4,28 @@ import { BIP32Factory } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; -import { compactError, hexToBuffer, getBip32Deriver } from '../../utils'; +import { SnapHelper } from '../../libs/snap/helpers'; +import { compactError, hexToBuffer } from '../../utils'; import { DeriverError } from './exceptions'; +import type { IBtcAccountDeriver } from './types'; -export class BtcAccountDeriver { +export abstract class BtcAccountDeriver implements IBtcAccountDeriver { protected readonly _network: Network; protected readonly _bip32Api: BIP32API; - readonly curve: 'secp256k1' | 'ed25519' = 'secp256k1'; - constructor(network: Network) { this._bip32Api = BIP32Factory(ecc); this._network = network; } - async getRoot(path: string[]) { - try { - const deriver = await getBip32Deriver(path, this.curve); - - if (!deriver.privateKey) { - throw new DeriverError('Deriver private key is missing'); - } - - const privateKeyBuffer = this.pkToBuf(deriver.privateKey); - const chainCodeBuffer = this.chainCodeToBuf(deriver.chainCode); + abstract getRoot(path: string[]): Promise; - const root = this.createBip32FromPrivateKey( - 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; + createBip32FromSeed(seed: Buffer): BIP32Interface { + try { + return this._bip32Api.fromSeed(seed, this._network); } catch (error) { - throw compactError(error, DeriverError); + throw new DeriverError('Unable to construct BIP32 node from seed'); } } @@ -84,3 +64,56 @@ export class BtcAccountDeriver { } } } + +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 = this.pkToBuf(deriverNode.privateKey); + const root = this.createBip32FromSeed(privateKeyBuffer); + return root + .deriveHardened(parseInt(path[1].slice(0, -1), 10)) + .deriveHardened(0); + } catch (error) { + throw compactError(error, DeriverError); + } + } +} + +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 = this.pkToBuf(deriver.privateKey); + const chainCodeBuffer = this.chainCodeToBuf(deriver.chainCode); + + const root = this.createBip32FromPrivateKey( + 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) { + throw compactError(error, DeriverError); + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts index e8a19270..8dcb3463 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts @@ -1,4 +1,4 @@ -import { CustomError } from '../../utils'; +import { CustomError } from '../../libs/exception'; export class DeriverError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts new file mode 100644 index 00000000..43f76ce9 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts @@ -0,0 +1,63 @@ +import type { Network } from 'bitcoinjs-lib'; +import { networks } from 'bitcoinjs-lib'; + +import { ScriptType } from '../constants'; +import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; +import { BtcWalletFactory } from './factory'; +import type { IBtcAccountDeriver } from './types'; +import * as manager from './wallet'; + +describe('BtcWalletFactory', () => { + class MockBtcWallet extends manager.BtcWallet { + getDeriver() { + return this._deriver; + } + } + + const createMockBtcWallet = () => { + const spy = jest + .spyOn(manager, 'BtcWallet') + .mockImplementation((deriver: IBtcAccountDeriver, network: Network) => { + return new MockBtcWallet(deriver, network); + }); + return { + spy, + }; + }; + + describe('create', () => { + it('creates BtcWallet instance with `BtcAccountBip32Deriver`', () => { + const { spy } = createMockBtcWallet(); + + const instance = BtcWalletFactory.create( + { + defaultAccountIndex: 0, + defaultAccountType: ScriptType.P2wpkh, + deriver: 'BIP32', + enableMultiAccounts: false, + }, + networks.testnet, + ) as unknown as MockBtcWallet; + + expect(spy).toHaveBeenCalled(); + expect(instance.getDeriver()).toBeInstanceOf(BtcAccountBip32Deriver); + }); + + it('creates BtcWallet instance with `BtcAccountBip44Deriver`', () => { + const { spy } = createMockBtcWallet(); + + const instance = BtcWalletFactory.create( + { + defaultAccountIndex: 0, + defaultAccountType: ScriptType.P2wpkh, + deriver: 'BIP44', + enableMultiAccounts: false, + }, + networks.testnet, + ) as unknown as MockBtcWallet; + + expect(spy).toHaveBeenCalled(); + expect(instance.getDeriver()).toBeInstanceOf(BtcAccountBip44Deriver); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.ts new file mode 100644 index 00000000..e3c7992d --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.ts @@ -0,0 +1,17 @@ +import { type Network } from 'bitcoinjs-lib'; + +import type { IWallet } from '../../wallet'; +import { type BtcWalletConfig } from '../config'; +import { BtcAccountBip32Deriver, BtcAccountBip44Deriver } from './deriver'; +import { BtcWallet } from './wallet'; + +export class BtcWalletFactory { + static create(config: BtcWalletConfig, network: Network): IWallet { + return new BtcWallet( + config.deriver === 'BIP44' + ? new BtcAccountBip44Deriver(network) + : new BtcAccountBip32Deriver(network), + network, + ); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts index 22617161..c025f6ea 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts @@ -1,10 +1,10 @@ export * from './exceptions'; export * from './account'; +export * from './factory'; +export * from './types'; export * from './signer'; export * from './deriver'; export * from './wallet'; +export * from './address'; export * from './transaction-info'; -export * from './coin-select'; -export * from './psbt'; -export * from './transaction-input'; -export * from './transaction-output'; +export * from './amount'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts index 3ecf5539..17444791 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts @@ -3,19 +3,22 @@ import { Psbt, Transaction, networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { hexToBuffer } from '../../utils'; import { MaxStandardTxWeight, ScriptType } from '../constants'; -import { BtcAccountDeriver } from './deriver'; +import { BtcAccountBip32Deriver } from './deriver'; import { PsbtServiceError } from './exceptions'; import { PsbtService } from './psbt'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; import { BtcWallet } from './wallet'; -jest.mock('../../utils/logger'); -jest.mock('../../utils/snap'); +jest.mock('../../libs/logger/logger'); +jest.mock('../../libs/snap/helpers'); describe('PsbtService', () => { const createMockWallet = (network) => { - const instance = new BtcWallet(new BtcAccountDeriver(network), network); + const instance = new BtcWallet( + new BtcAccountBip32Deriver(network), + network, + ); return { instance, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index eb92a8b2..f4638e39 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -4,7 +4,8 @@ import { Psbt, Transaction } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; import ECPairFactory from 'ecpair'; -import { compactError, logger } from '../../utils'; +import { logger } from '../../libs/logger/logger'; +import { compactError } from '../../utils'; import type { IAccountSigner } from '../../wallet'; import { MaxStandardTxWeight } from '../constants'; import { PsbtServiceError, TxValidationError } from './exceptions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts index 01a21469..c1b15f32 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts @@ -1,22 +1,27 @@ +import { networks } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; import { generateAccounts } from '../../../test/utils'; +import { getCaip2ChainId, getExplorerUrl, satsToBtc } from '../utils'; +import { BtcAddress } from './address'; +import { BtcAmount } from './amount'; import { BtcTxInfo } from './transaction-info'; import { TxOutput } from './transaction-output'; describe('BtcTxInfo', () => { describe('toJson', () => { it('returns a json', async () => { + const network = networks.testnet; const accounts = generateAccounts(5); const addresses = accounts.map((account) => account.address); const fee = 10000; const feeRate = 100; let total = fee; const outputs: TxOutput[] = []; - const sender = addresses[0]; + const sender = new BtcAddress(addresses[0]); - const info = new BtcTxInfo(sender, feeRate); - info.txFee = fee; + const info = new BtcTxInfo(sender, feeRate, network); + info.fee = fee; for (let i = 1; i < addresses.length; i++) { total += 100000; @@ -24,25 +29,52 @@ describe('BtcTxInfo', () => { outputs.push(output); } - const change = new TxOutput(500, addresses[0], Buffer.from('dummy')); - info.addRecipients(outputs); - info.addChange(change); + + info.change = new TxOutput(500, addresses[0], Buffer.from('dummy')); total += 500; const expectedRecipients = outputs.map((recipient) => { return { - address: recipient.address, - value: recipient.bigIntValue, + address: recipient.destination.toString(true), + value: recipient.amount.toString(true), + explorerUrl: getExplorerUrl( + recipient.destination.value, + getCaip2ChainId(network), + ), }; }); - expect(info.total).toBe(BigInt(total)); + const expectedChange = [ + { + address: info.change.destination.toString(true), + value: info.change.amount.toString(true), + explorerUrl: getExplorerUrl( + info.change.destination.value, + getCaip2ChainId(network), + ), + }, + ]; + + expect(info.total).toBeInstanceOf(BtcAmount); + expect(info.total.value).toStrictEqual(total); expect(info.sender).toStrictEqual(sender); expect(info.recipients).toHaveLength(expectedRecipients.length); expect(info.change).toBeDefined(); - expect(info.txFee).toBe(BigInt(fee)); - expect(info.feeRate).toBe(BigInt(feeRate)); + expect(info.fee).toStrictEqual(fee); + expect(info.txFee).toBeInstanceOf(BtcAmount); + expect(info.txFee.value).toStrictEqual(fee); + expect(info.feeRate).toBeInstanceOf(BtcAmount); + expect(info.feeRate.value).toStrictEqual(feeRate); + + expect(info.toJson()).toStrictEqual({ + feeRate: `${satsToBtc(feeRate)} BTC`, + txFee: `${satsToBtc(fee)} BTC`, + sender: addresses[0], + recipients: expectedRecipients, + changes: expectedChange, + total: `${satsToBtc(total)} BTC`, + }); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts index e24ef6fd..b1a7d91f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts @@ -1,26 +1,52 @@ -import type { ITxInfo, Recipient } from '../../wallet'; +import type { Json } from '@metamask/snaps-sdk'; +import type { Network } from 'bitcoinjs-lib'; + +import type { ITxInfo } from '../../wallet'; +import { getCaip2ChainId, getExplorerUrl } from '../utils'; +import type { BtcAddress } from './address'; +import { BtcAmount } from './amount'; import type { TxOutput } from './transaction-output'; export class BtcTxInfo implements ITxInfo { - readonly sender: string; + readonly sender: BtcAddress; - protected _change?: Recipient; + readonly txFee: BtcAmount; - protected _recipients: Recipient[]; + change?: TxOutput; - protected _outputTotal: bigint; + protected _recipients: TxOutput[]; - protected _txFee: bigint; + protected _feeRate: BtcAmount; - protected _feeRate: bigint; + protected _outputTotal: BtcAmount; - constructor(sender: string, feeRate: number) { - this.feeRate = feeRate; - this.txFee = 0; - this.sender = sender; + protected _serializedRecipients: Json[]; + + protected _network: Network; + constructor(sender: BtcAddress, feeRate: number, network: Network) { this._recipients = []; - this._outputTotal = BigInt(0); + this._serializedRecipients = []; + this._outputTotal = new BtcAmount(0); + this._feeRate = new BtcAmount(feeRate); + this.txFee = new BtcAmount(0); + this._network = network; + this.sender = sender; + } + + protected changeToJson(): Json { + return this.change + ? [ + { + address: this.change.destination.toString(true), + value: this.change.amount.toString(true), + explorerUrl: getExplorerUrl( + this.change.destination.value, + getCaip2ChainId(this._network), + ), + }, + ] + : []; } addRecipients(outputs: TxOutput[]): void { @@ -30,58 +56,52 @@ export class BtcTxInfo implements ITxInfo { } addRecipient(output: TxOutput): void { - this._outputTotal += output.bigIntValue; + this._outputTotal.value += output.value; - this._recipients.push({ - address: output.address, - value: output.bigIntValue, - }); - } - - addChange(change: TxOutput): void { - this._change = { - address: change.address, - value: change.bigIntValue, - }; - } + this._recipients.push(output); - set txFee(fee: bigint | number) { - if (typeof fee === 'number') { - this._txFee = BigInt(fee); - } else { - this._txFee = fee; - } + this._serializedRecipients.push({ + address: output.destination.toString(true), + value: output.amount.toString(true), + explorerUrl: getExplorerUrl( + output.destination.value, + getCaip2ChainId(this._network), + ), + }); } - get txFee(): bigint { - return this._txFee; + get total(): BtcAmount { + return new BtcAmount( + this._outputTotal.value + + (this.change ? this.change.value : 0) + + this.txFee.value, + ); } - set feeRate(fee: bigint | number) { - if (typeof fee === 'number') { - this._feeRate = BigInt(fee); - } else { - this._feeRate = fee; - } + get feeRate(): BtcAmount { + return this._feeRate; } - get feeRate(): bigint { - return this._feeRate; + get recipients(): TxOutput[] { + return this._recipients; } - get total(): bigint { - return ( - this._outputTotal + - (this.change ? BigInt(this.change.value) : BigInt(0)) + - this.txFee - ); + get fee(): number { + return this.txFee.value; } - get recipients(): Recipient[] { - return this._recipients; + set fee(val: number) { + this.txFee.value = val; } - get change(): Recipient | undefined { - return this._change; + toJson>(): InfoJson { + return { + feeRate: this._feeRate.toString(true), + txFee: this.txFee.toString(true), + sender: this.sender.toString(), + recipients: this._serializedRecipients, + changes: this.changeToJson(), + total: this.total.toString(true), + } as unknown as InfoJson; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts index aac055c3..1f0877b6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts @@ -2,15 +2,18 @@ import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { ScriptType } from '../constants'; -import { BtcAccountDeriver } from './deriver'; +import { BtcAccountBip32Deriver } from './deriver'; import { TxInput } from './transaction-input'; import { BtcWallet } from './wallet'; -jest.mock('../../utils/snap'); +jest.mock('../../libs/snap/helpers'); describe('TxInput', () => { const createMockWallet = (network) => { - const instance = new BtcWallet(new BtcAccountDeriver(network), network); + const instance = new BtcWallet( + new BtcAccountBip32Deriver(network), + network, + ); return { instance, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts index 06134591..504fd251 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts @@ -1,13 +1,15 @@ import type { Buffer } from 'buffer'; -import type { Utxo } from '../../chain'; +import type { IAmount } from '../../wallet'; +import { BtcAmount } from './amount'; +import type { Utxo } from './types'; export class TxInput { - protected _value: bigint; - // consume by coinselect readonly script: Buffer; + readonly amount: IAmount; + readonly txHash: string; readonly index: number; @@ -16,7 +18,7 @@ export class TxInput { constructor(utxo: Utxo, script: Buffer) { this.script = script; - this.value = utxo.value; + this.amount = new BtcAmount(utxo.value); this.index = utxo.index; this.txHash = utxo.txHash; this.block = utxo.block; @@ -24,18 +26,6 @@ export class TxInput { // consume by coinselect get value(): number { - return Number(this._value); - } - - set value(value: bigint | number) { - if (typeof value === 'number') { - this._value = BigInt(value); - } else { - this._value = value; - } - } - - get bigIntValue(): bigint { - return this._value; + return this.amount.value; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts index 262f3d2e..ea20d13f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts @@ -13,22 +13,4 @@ describe('TxOutput', () => { expect(input.address).toStrictEqual(account.address); expect(input.value).toBe(10); }); - - it('sets output value with a number', () => { - const account = generateAccounts(1)[0]; - - const input = new TxOutput(10, account.address, Buffer.from('dummy')); - input.value = 20; - - expect(input.value).toBe(20); - }); - - it('sets output value with a bigint', () => { - const account = generateAccounts(1)[0]; - - const input = new TxOutput(10, account.address, Buffer.from('dummy')); - input.value = BigInt(20); - - expect(input.value).toBe(20); - }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts index b6758cec..298a7fca 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts @@ -1,33 +1,33 @@ import type { Buffer } from 'buffer'; +import type { IAddress, IAmount } from '../../wallet'; +import { BtcAddress } from './address'; +import { BtcAmount } from './amount'; + export class TxOutput { - protected _value: bigint; + readonly amount: IAmount; // consume by conselect readonly script: Buffer; - readonly address: string; + readonly destination: IAddress; - constructor(value: bigint | number, address: string, script: Buffer) { - this.value = value; - this.address = address; + constructor(value: number, address: string, script: Buffer) { + this.amount = new BtcAmount(value); + this.destination = new BtcAddress(address); this.script = script; } // consume by conselect get value(): number { - return Number(this._value); + return this.amount.value; } - set value(value: bigint | number) { - if (typeof value === 'number') { - this._value = BigInt(value); - } else { - this._value = value; - } + set value(value: number) { + this.amount.value = value; } - get bigIntValue(): bigint { - return this._value; + get address(): string { + return this.destination.value; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts new file mode 100644 index 00000000..4db8fff1 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts @@ -0,0 +1,59 @@ +import type { BIP32Interface } from 'bip32'; +import type { Network, Payment } from 'bitcoinjs-lib'; +import type { Buffer } from 'buffer'; + +import type { IAccount, IAccountSigner } from '../../wallet'; +import type { ScriptType } from '../constants'; +import type { TxInput } from './transaction-input'; +import type { TxOutput } from './transaction-output'; + +export type IBtcAccountDeriver = { + getRoot(path: string[]): Promise; + getChild(root: BIP32Interface, idx: number): Promise; +}; + +export type IBtcAccount = IAccount & { + payment: Payment; + scriptType: ScriptType; + network: Network; + script: Buffer; +}; + +export type IStaticBtcAccount = { + path: string[]; + scriptType: ScriptType; + new ( + mfp: string, + index: number, + hdPath: string, + pubkey: string, + network: Network, + scriptType: ScriptType, + type: string, + signer: IAccountSigner, + ): IBtcAccount; +}; + +export type CreateTransactionOptions = { + utxos: Utxo[]; + fee: number; + subtractFeeFrom: string[]; + // + // BIP125 opt-in RBF flag, + // + replaceable: boolean; +}; + +export type Utxo = { + block: number; + txHash: string; + index: number; + value: number; +}; + +export type SelectionResult = { + change?: TxOutput; + fee: number; + inputs: TxInput[]; + outputs: TxOutput[]; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index 269fd8f6..8303ad3b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -1,26 +1,28 @@ +import type { Json } from '@metamask/snaps-sdk'; import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; -import { DustLimit, ScriptType, maxSatoshi } from '../constants'; +import { DustLimit, ScriptType } from '../constants'; import { P2SHP2WPKHAccount, P2WPKHAccount } from './account'; import { CoinSelectService } from './coin-select'; -import { BtcAccountDeriver } from './deriver'; +import { BtcAccountBip32Deriver } from './deriver'; import { WalletError } from './exceptions'; import { BtcTxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; +import type { SelectionResult } from './types'; import { BtcWallet } from './wallet'; -jest.mock('../../utils/snap'); -jest.mock('../../utils/logger'); +jest.mock('../../libs/snap/helpers'); +jest.mock('../../libs/logger/logger'); describe('BtcWallet', () => { const createMockDeriver = (network) => { - const rootSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getRoot'); - const childSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getChild'); + const rootSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getRoot'); + const childSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getChild'); return { - instance: new BtcAccountDeriver(network), + instance: new BtcAccountBip32Deriver(network), rootSpy, childSpy, }; @@ -41,7 +43,7 @@ describe('BtcWallet', () => { return [ { address, - value: BigInt(amount), + value: amount, }, ]; }; @@ -100,17 +102,11 @@ describe('BtcWallet', () => { const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); - - const utxos = generateFormatedUtxos( - account.address, - 200, - maxSatoshi, - maxSatoshi, - ); + const utxos = generateFormatedUtxos(account.address, 200, 100000, 100000); const result = await wallet.createTransaction( account, - createMockTxIndent(account.address, maxSatoshi), + createMockTxIndent(account.address, 132000), { utxos, fee: 56, @@ -119,12 +115,12 @@ describe('BtcWallet', () => { }, ); - const info = result.txInfo; - const { recipients } = info; - const { change } = info; + const json = result.txInfo.toJson(); + const recipients = json.recipients as unknown as Json[]; + const changes = json.changes as unknown as Json[]; expect(recipients).toHaveLength(1); - expect(change).toBeDefined(); + expect(changes).toHaveLength(1); expect(result).toStrictEqual({ tx: expect.any(String), txInfo: expect.any(BtcTxInfo), @@ -149,12 +145,12 @@ describe('BtcWallet', () => { }, ); - const info = result.txInfo; - const { recipients } = info; - const { change } = info; + const json = result.txInfo.toJson(); + const recipients = json.recipients as unknown as Json[]; + const changes = json.changes as unknown as Json[]; expect(recipients).toHaveLength(1); - expect(change).toBeUndefined(); + expect(changes).toHaveLength(0); expect(result).toStrictEqual({ tx: expect.any(String), txInfo: expect.any(BtcTxInfo), @@ -210,7 +206,7 @@ describe('BtcWallet', () => { 'selectCoins', ); - const selectionResult = { + const selectionResult: SelectionResult = { change: new TxOutput( DustLimit[chgAccount.scriptType] - 1, chgAccount.address, @@ -236,7 +232,7 @@ describe('BtcWallet', () => { const info: BtcTxInfo = result.txInfo as unknown as BtcTxInfo; - expect(info.txFee).toBe(BigInt(19500)); + expect(info.fee).toBe(19500); expect(info.change).toBeUndefined(); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 0dadb1c0..a2647208 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -1,8 +1,8 @@ import type { BIP32Interface } from 'bip32'; import { type Network } from 'bitcoinjs-lib'; -import type { Utxo } from '../../chain'; -import { bufferToString, compactError, hexToBuffer, logger } from '../../utils'; +import { logger } from '../../libs/logger/logger'; +import { bufferToString, compactError, hexToBuffer } from '../../utils'; import type { IAccountSigner, IWallet, @@ -10,45 +10,37 @@ import type { Transaction, } from '../../wallet'; import { ScriptType } from '../constants'; -import { isDust, getScriptForDestnation } from '../utils'; -import { - P2WPKHAccount, - P2SHP2WPKHAccount, - type IStaticBtcAccount, - type IBtcAccount, -} from './account'; +import { isDust } from '../utils'; +import { getScriptForDestnation } from '../utils/address'; +import { P2WPKHAccount, P2SHP2WPKHAccount } from './account'; +import { BtcAddress } from './address'; import { CoinSelectService } from './coin-select'; -import type { BtcAccountDeriver } from './deriver'; import { WalletError, TxValidationError } from './exceptions'; import { PsbtService } from './psbt'; import { AccountSigner } from './signer'; import { BtcTxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; - -export type CreateTransactionOptions = { - utxos: Utxo[]; - fee: number; - subtractFeeFrom: string[]; - // - // BIP125 opt-in RBF flag, - // - replaceable: boolean; -}; +import type { + IStaticBtcAccount, + IBtcAccountDeriver, + IBtcAccount, + CreateTransactionOptions, +} from './types'; export class BtcWallet implements IWallet { - protected readonly _deriver: BtcAccountDeriver; + protected readonly _deriver: IBtcAccountDeriver; protected readonly _network: Network; - constructor(deriver: BtcAccountDeriver, network: Network) { + constructor(deriver: IBtcAccountDeriver, network: Network) { this._deriver = deriver; this._network = network; } - async unlock(index: number, type?: string): Promise { + async unlock(index: number, type: string): Promise { try { - const AccountCtor = this.getAccountCtor(type ?? ScriptType.P2wpkh); + const AccountCtor = this.getAccountCtor(type); const rootNode = await this._deriver.getRoot(AccountCtor.path); const childNode = await this._deriver.getChild(rootNode, index); const hdPath = [`m`, `0'`, `0`, `${index}`].join('/'); @@ -76,6 +68,17 @@ export class BtcWallet implements IWallet { const scriptOutput = account.script; const { scriptType } = account; + logger.info( + JSON.stringify( + { + recipients, + options, + }, + null, + 2, + ), + ); + // TODO: Supporting getting coins from other address (dynamic address) const inputs = options.utxos.map((utxo) => new TxInput(utxo, scriptOutput)); const outputs = recipients.map((recipient) => { @@ -104,6 +107,23 @@ export class BtcWallet implements IWallet { change, ); + logger.info( + JSON.stringify( + { + feeRate, + ...selectionResult, + }, + null, + 2, + ), + ); + + const txInfo = new BtcTxInfo( + new BtcAddress(account.address), + feeRate, + this._network, + ); + const psbtService = new PsbtService(this._network); psbtService.addInputs( selectionResult.inputs, @@ -113,8 +133,6 @@ export class BtcWallet implements IWallet { hexToBuffer(account.mfp, false), ); - const txInfo = new BtcTxInfo(account.address, feeRate); - // TODO: add support of subtractFeeFrom, and throw error if output is too small after subtraction for (const output of selectionResult.outputs) { psbtService.addOutput(output); @@ -128,13 +146,13 @@ export class BtcWallet implements IWallet { ); } else { psbtService.addOutput(selectionResult.change); - txInfo.addChange(selectionResult.change); + txInfo.change = selectionResult.change; } } // Sign dummy transaction to extract the fee which is more accurate const signedService = await psbtService.signDummy(account.signer); - txInfo.txFee = signedService.getFee(); + txInfo.fee = signedService.getFee(); return { tx: psbtService.toBase64(), diff --git a/merged-packages/bitcoin-wallet-snap/src/chain.ts b/merged-packages/bitcoin-wallet-snap/src/chain.ts index a0a57418..45f640ff 100644 --- a/merged-packages/bitcoin-wallet-snap/src/chain.ts +++ b/merged-packages/bitcoin-wallet-snap/src/chain.ts @@ -1,3 +1,7 @@ +import type { Json } from '@metamask/snaps-sdk'; + +import type { IAmount } from './wallet'; + export enum FeeRatio { Fast = 'fast', Medium = 'medium', @@ -14,8 +18,10 @@ export type TransactionStatusData = { status: TransactionStatus; }; +export type Balances = Record; + export type Balance = { - amount: bigint; + amount: IAmount; }; export type AssetBalances = { @@ -28,7 +34,7 @@ export type AssetBalances = { export type Fee = { type: FeeRatio; - rate: bigint; + rate: IAmount; }; export type Fees = { @@ -42,22 +48,13 @@ export type TransactionIntent = { }; export type TransactionData = { - data: { - utxos: Utxo[]; - }; + data: Record; }; export type CommitedTransaction = { transactionId: string; }; -export type Utxo = { - block: number; - txHash: string; - index: number; - value: number; -}; - /** * An interface that defines methods for interacting with a blockchain network. */ diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts deleted file mode 100644 index 3f17567d..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Json } from '@metamask/snaps-sdk'; - -import { Caip2ChainId, Caip2Asset } from './constants'; - -export type SnapConfig = { - onChainService: { - dataClient: { - options?: Record; - }; - }; - wallet: { - defaultAccountIndex: number; - defaultAccountType: string; - }; - avaliableNetworks: string[]; - avaliableAssets: string[]; - unit: string; - explorer: { - [network in string]: string; - }; - logLevel: string; -}; - -export const Config: SnapConfig = { - onChainService: { - dataClient: { - options: { - // eslint-disable-next-line no-restricted-globals - apiKey: process.env.BLOCKCHAIR_API_KEY, - }, - }, - }, - wallet: { - defaultAccountIndex: 0, - defaultAccountType: 'bip122:p2wpkh', - }, - avaliableNetworks: Object.values(Caip2ChainId), - avaliableAssets: Object.values(Caip2Asset), - unit: 'BTC', - explorer: { - // eslint-disable-next-line no-template-curly-in-string - [Caip2ChainId.Mainnet]: 'https://blockstream.info/address/${address}', - [Caip2ChainId.Testnet]: - // eslint-disable-next-line no-template-curly-in-string - 'https://blockstream.info/testnet/address/${address}', - }, - // eslint-disable-next-line no-restricted-globals - logLevel: process.env.LOG_LEVEL ?? '6', -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/config/config.ts b/merged-packages/bitcoin-wallet-snap/src/config/config.ts new file mode 100644 index 00000000..ca1b99fd --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/config/config.ts @@ -0,0 +1,103 @@ +import type { + BtcWalletConfig, + BtcOnChainServiceConfig, +} from '../bitcoin/config'; +import { + Network as BtcNetwork, + DataClient, + BtcAsset, + BtcUnit, +} from '../bitcoin/constants'; + +export enum Chain { + Bitcoin = 'Bitcoin', +} + +export type NetworkConfig = { + [key in string]: string; +}; + +export type OnChainServiceConfig = { + [Chain.Bitcoin]: BtcOnChainServiceConfig; +}; + +export type WalletConfig = { + [Chain.Bitcoin]: BtcWalletConfig; +}; + +export type SnapConfig = { + onChainService: OnChainServiceConfig; + wallet: WalletConfig; + avaliableNetworks: { + [key in Chain]: string[]; + }; + avaliableAssets: { + [key in Chain]: string[]; + }; + unit: { + [key in Chain]: string; + }; + explorer: { + [key in Chain]: { + [network in string]: string; + }; + }; + chain: Chain; + logLevel: string; +}; + +export const Config: SnapConfig = { + onChainService: { + [Chain.Bitcoin]: { + dataClient: { + read: { + type: + // eslint-disable-next-line no-restricted-globals + (process.env.DATA_CLIENT_READ_TYPE as DataClient) ?? + DataClient.BlockChair, + options: { + // eslint-disable-next-line no-restricted-globals + apiKey: process.env.BLOCKCHAIR_API_KEY, + }, + }, + + write: { + type: + // eslint-disable-next-line no-restricted-globals + (process.env.DATA_CLIENT_WRITE_TYPE as DataClient) ?? + DataClient.BlockChair, + options: { + // eslint-disable-next-line no-restricted-globals + apiKey: process.env.BLOCKCHAIR_API_KEY, + }, + }, + }, + }, + }, + wallet: { + [Chain.Bitcoin]: { + enableMultiAccounts: false, + defaultAccountIndex: 0, + defaultAccountType: 'bip122:p2wpkh', + deriver: 'BIP32', + }, + }, + avaliableNetworks: { + [Chain.Bitcoin]: Object.values(BtcNetwork), + }, + avaliableAssets: { + [Chain.Bitcoin]: Object.values(BtcAsset), + }, + unit: { + [Chain.Bitcoin]: BtcUnit.Btc, + }, + explorer: { + [Chain.Bitcoin]: { + [BtcNetwork.Mainnet]: 'https://blockstream.info', + [BtcNetwork.Testnet]: 'https://blockstream.info/testnet', + }, + }, + chain: Chain.Bitcoin, + // eslint-disable-next-line no-restricted-globals + logLevel: process.env.LOG_LEVEL ?? '6', +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/config/index.ts b/merged-packages/bitcoin-wallet-snap/src/config/index.ts new file mode 100644 index 00000000..ae8d958f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/config/index.ts @@ -0,0 +1,2 @@ +export * from './config'; +export * from './permissions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts similarity index 84% rename from merged-packages/bitcoin-wallet-snap/src/permissions.ts rename to merged-packages/bitcoin-wallet-snap/src/config/permissions.ts index e8af8a24..c84b4144 100644 --- a/merged-packages/bitcoin-wallet-snap/src/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config/permissions.ts @@ -1,10 +1,5 @@ import { KeyringRpcMethod } from '@metamask/keyring-api'; -export enum InternalRpcMethod { - GetTransactionStatus = 'chain_getTransactionStatus', - CreateAccount = 'chain_createAccount', -} - const dappPermissions = new Set([ // Keyring methods KeyringRpcMethod.ListAccounts, @@ -19,7 +14,7 @@ const dappPermissions = new Set([ KeyringRpcMethod.RejectRequest, KeyringRpcMethod.SubmitRequest, // Chain API methods - InternalRpcMethod.GetTransactionStatus, + 'chain_getTransactionStatus', ]); const metamaskPermissions = new Set([ @@ -37,10 +32,11 @@ const metamaskPermissions = new Set([ KeyringRpcMethod.SubmitRequest, KeyringRpcMethod.GetAccountBalances, // Chain API methods - InternalRpcMethod.GetTransactionStatus, + 'chain_getTransactionStatus', ]); const allowedOrigins = [ + 'https://metamask.github.io', 'https://portfolio.metamask.io', 'https://portfolio-builds.metafi-dev.codefi.network', 'https://dev.portfolio.metamask.io', @@ -57,5 +53,5 @@ for (const origin of allowedOrigins) { originPermissions.set(metamask, metamaskPermissions); originPermissions.set( local, - new Set([...dappPermissions, InternalRpcMethod.CreateAccount]), + new Set([...dappPermissions, 'chain_createAccount']), ); diff --git a/merged-packages/bitcoin-wallet-snap/src/constants.ts b/merged-packages/bitcoin-wallet-snap/src/constants.ts deleted file mode 100644 index 308df1e7..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/constants.ts +++ /dev/null @@ -1,9 +0,0 @@ -export enum Caip2ChainId { - Mainnet = 'bip122:000000000019d6689c085ae165831e93', - Testnet = 'bip122:000000000933ea01ad0ee984209779ba', -} - -export enum Caip2Asset { - Btc = 'bip122:000000000019d6689c085ae165831e93/slip44:0', - TBtc = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0', -} diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/factory.test.ts index c714732a..c9835078 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.test.ts @@ -1,14 +1,13 @@ import { BtcOnChainService } from './bitcoin/chain'; +import { Network } from './bitcoin/constants'; import { BtcWallet } from './bitcoin/wallet'; -import { Caip2ChainId } from './constants'; import { Factory } from './factory'; +import { BtcKeyring } from './keyring'; describe('Factory', () => { describe('createOnChainServiceProvider', () => { it('creates BtcOnChainService instance', () => { - const instance = Factory.createOnChainServiceProvider( - Caip2ChainId.Testnet, - ); + const instance = Factory.createOnChainServiceProvider(Network.Testnet); expect(instance).toBeInstanceOf(BtcOnChainService); }); @@ -16,9 +15,17 @@ describe('Factory', () => { describe('createWallet', () => { it('creates BtcWallet instance', () => { - const instance = Factory.createWallet(Caip2ChainId.Testnet); + const instance = Factory.createWallet(Network.Testnet); expect(instance).toBeInstanceOf(BtcWallet); }); }); + + describe('createKeyring', () => { + it('creates BtcKeyring instance', () => { + const instance = Factory.createKeyring(); + + expect(instance).toBeInstanceOf(BtcKeyring); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts index efc3087a..ad536089 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.ts @@ -1,27 +1,67 @@ +import { type Keyring } from '@metamask/keyring-api'; + import { BtcOnChainService } from './bitcoin/chain'; -import { BlockChairClient } from './bitcoin/chain/clients/blockchair'; +import { + type BtcWalletConfig, + type BtcOnChainServiceConfig, +} from './bitcoin/config'; +import { DataClientFactory } from './bitcoin/data-client/factory'; import { getBtcNetwork } from './bitcoin/utils'; -import { BtcAccountDeriver, BtcWallet } from './bitcoin/wallet'; +import { BtcWalletFactory } from './bitcoin/wallet'; import type { IOnChainService } from './chain'; import { Config } from './config'; +import { BtcKeyring, KeyringStateManager } from './keyring'; import type { IWallet } from './wallet'; -export class Factory { - static createOnChainServiceProvider(scope: string): IOnChainService { - const btcNetwork = getBtcNetwork(scope); +// TODO: Remove temp solution to support keyring in snap without keyring API +export type CreateBtcKeyringOptions = { + emitEvents: boolean; +}; - const client = new BlockChairClient({ +export class Factory { + static createBtcOnChainServiceProvider( + config: BtcOnChainServiceConfig, + network: string, + ) { + const btcNetwork = getBtcNetwork(network); + const readClient = DataClientFactory.createReadClient(config, btcNetwork); + const writeClient = DataClientFactory.createWriteClient(config, btcNetwork); + return new BtcOnChainService(readClient, writeClient, { network: btcNetwork, - apiKey: Config.onChainService.dataClient.options?.apiKey?.toString(), }); + } - return new BtcOnChainService(client, { - network: btcNetwork, + static createBtcWallet(config: BtcWalletConfig, network: string) { + const btcNetwork = getBtcNetwork(network); + return BtcWalletFactory.create(config, btcNetwork); + } + + static createBtcKeyring( + config: BtcWalletConfig, + options: CreateBtcKeyringOptions, + ): BtcKeyring { + return new BtcKeyring(new KeyringStateManager(), { + defaultIndex: config.defaultAccountIndex, + multiAccount: config.enableMultiAccounts, + // TODO: Remove temp solution to support keyring in snap without keyring API + emitEvents: options.emitEvents, }); } + static createOnChainServiceProvider(scope: string): IOnChainService { + return Factory.createBtcOnChainServiceProvider( + Config.onChainService[Config.chain], + scope, + ); + } + static createWallet(scope: string): IWallet { - const btcNetwork = getBtcNetwork(scope); - return new BtcWallet(new BtcAccountDeriver(btcNetwork), btcNetwork); + return Factory.createBtcWallet(Config.wallet[Config.chain], scope); + } + + static createKeyring(): Keyring { + return Factory.createBtcKeyring(Config.wallet[Config.chain], { + emitEvents: true, + }); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts index ca926944..9c10a9f8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.ts @@ -1,19 +1,19 @@ import * as keyringApi from '@metamask/keyring-api'; import { + type Json, type JsonRpcRequest, SnapError, MethodNotFoundError, } from '@metamask/snaps-sdk'; import { onRpcRequest, validateOrigin, onKeyringRequest } from '.'; -import * as entry from '.'; -import { TransactionStatus } from './chain'; -import { Config } from './config'; +import { Config, originPermissions } from './config'; import { BtcKeyring } from './keyring'; -import { originPermissions } from './permissions'; -import * as getTxStatusRpc from './rpcs/get-transaction-status'; +import { BaseSnapRpcHandler, type IStaticSnapRpcHandler } from './libs/rpc'; +import { RpcHelper } from './rpcs'; +import type { StaticImplements } from './types/static'; -jest.mock('./utils/logger'); +jest.mock('./libs/logger/logger'); jest.mock('@metamask/keyring-api', () => ({ ...jest.requireActual('@metamask/keyring-api'), @@ -50,47 +50,59 @@ describe('validateOrigin', () => { }); describe('onRpcRequest', () => { - const executeRequest = async (method = 'chain_getTransactionStatus') => { - jest.spyOn(entry, 'validateOrigin').mockReturnThis(); + const createMockChainApiHandler = () => { + const handleRequestSpy = jest.fn(); + class MockChainApiHandler + extends BaseSnapRpcHandler + implements + StaticImplements + { + handleRequest = handleRequestSpy; + } + return { handler: MockChainApiHandler, handleRequestSpy }; + }; + const executeRequest = async () => { return onRpcRequest({ origin: 'http://localhost:8000', request: { - method, + method: 'chain_getTransactionStatus', params: { - scope: Config.avaliableNetworks[0], + scope: Config.avaliableNetworks[Config.chain][0], }, } as unknown as JsonRpcRequest, }); }; it('executes the rpc request', async () => { - jest.spyOn(getTxStatusRpc, 'getTransactionStatus').mockResolvedValue({ - status: TransactionStatus.Confirmed, - } as unknown as getTxStatusRpc.GetTransactionStatusResponse); + const { handler, handleRequestSpy } = createMockChainApiHandler(); + jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_getTransactionStatus: handler, + }); + handleRequestSpy.mockResolvedValueOnce({ + data: 1, + } as Json); const resposne = await executeRequest(); - expect(resposne).toStrictEqual({ status: TransactionStatus.Confirmed }); + expect(resposne).toStrictEqual({ data: 1 }); }); - it('throws MethodNotFoundError if an method not found', async () => { - await expect(executeRequest('some-not')).rejects.toThrow( - MethodNotFoundError, - ); - }); + it('throws SnapError if an error catched', async () => { + jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({}); - it('throws SnapError if the request is failed to execute', async () => { - jest - .spyOn(getTxStatusRpc, 'getTransactionStatus') - .mockRejectedValue(new SnapError('error')); - await expect(executeRequest()).rejects.toThrow(SnapError); + await expect(executeRequest()).rejects.toThrow(MethodNotFoundError); }); - it('throws SnapError if the error is not an instance of SnapError', async () => { - jest - .spyOn(getTxStatusRpc, 'getTransactionStatus') - .mockRejectedValue(new Error('error')); + it('throws SnapError if an SnapError catched', async () => { + const { handler, handleRequestSpy } = createMockChainApiHandler(); + jest.spyOn(RpcHelper, 'getChainRpcApiHandlers').mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_getTransactionStatus: handler, + }); + handleRequestSpy.mockRejectedValue(new SnapError('error')); + await expect(executeRequest()).rejects.toThrow(SnapError); }); }); @@ -110,7 +122,7 @@ describe('onKeyringRequest', () => { request: { method: keyringApi.KeyringRpcMethod.ListAccounts, params: { - scope: Config.avaliableNetworks[0], + scope: Config.avaliableNetworks[Config.chain][0], }, } as unknown as JsonRpcRequest, }); @@ -124,7 +136,7 @@ describe('onKeyringRequest', () => { expect(handler).toHaveBeenCalledWith(expect.any(BtcKeyring), { method: keyringApi.KeyringRpcMethod.ListAccounts, params: { - scope: Config.avaliableNetworks[0], + scope: Config.avaliableNetworks[Config.chain][0], }, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 89782ea8..6aa711cd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -9,12 +9,12 @@ import { } from '@metamask/snaps-sdk'; import { Config } from './config'; -import { BtcKeyring } from './keyring'; -import { InternalRpcMethod, originPermissions } from './permissions'; -import type { CreateAccountParams, GetTransactionStatusParams } from './rpcs'; -import { createAccount, getTransactionStatus } from './rpcs'; -import { KeyringStateManager } from './stateManagement'; -import { isSnapRpcError, logger } from './utils'; +import { originPermissions } from './config/permissions'; +import { Factory } from './factory'; +import { logger } from './libs/logger/logger'; +import type { SnapRpcHandlerRequest } from './libs/rpc'; +import { RpcHelper } from './rpcs/helpers'; +import { isSnapRpcError } from './utils'; export const validateOrigin = (origin: string, method: string): void => { if (!origin) { @@ -35,19 +35,17 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ try { const { method } = request; - validateOrigin(origin, method); - switch (method) { - case InternalRpcMethod.CreateAccount: - return await createAccount(request.params as CreateAccountParams); - case InternalRpcMethod.GetTransactionStatus: - return await getTransactionStatus( - request.params as GetTransactionStatusParams, - ); - default: - throw new MethodNotFoundError(method) as unknown as Error; + const methodHanlders = RpcHelper.getChainRpcApiHandlers(); + + if (!Object.prototype.hasOwnProperty.call(methodHanlders, method)) { + throw new MethodNotFoundError() as unknown as Error; } + + return await methodHanlders[method] + .getInstance() + .execute(request.params as SnapRpcHandlerRequest); } catch (error) { let snapError = error; @@ -70,12 +68,7 @@ export const onKeyringRequest: OnKeyringRequestHandler = async ({ try { validateOrigin(origin, request.method); - const keyring = new BtcKeyring(new KeyringStateManager(), { - defaultIndex: Config.wallet.defaultAccountIndex, - // TODO: Remove temp solution to support keyring in snap without keyring API - emitEvents: true, - }); - + const keyring = Factory.createKeyring(); return (await handleKeyringRequest( keyring, request, diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts new file mode 100644 index 00000000..27acf74c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/exceptions.ts @@ -0,0 +1,5 @@ +import { CustomError } from '../libs/exception'; + +export class BtcKeyringError extends CustomError {} + +export class BtcKeyringStateError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/index.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/index.ts new file mode 100644 index 00000000..53d3dc06 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/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/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts similarity index 69% rename from merged-packages/bitcoin-wallet-snap/src/keyring.test.ts rename to merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts index 2079fd2a..c2008861 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.test.ts @@ -1,21 +1,20 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; import { MethodNotFoundError } from '@metamask/snaps-sdk'; -import { v4 as uuidv4 } from 'uuid'; - -import { generateAccounts } from '../test/utils'; -import { ScriptType } from './bitcoin/constants'; -import { BtcAccount } from './bitcoin/wallet'; -import { Config } from './config'; -import { Caip2Asset, Caip2ChainId } from './constants'; -import { Factory } from './factory'; +import { unknown } from 'superstruct'; + +import { generateAccounts } from '../../test/utils'; +import { BtcAsset, Network } from '../bitcoin/constants'; +import { Chain, Config } from '../config'; +import { Factory } from '../factory'; +import { type IStaticSnapRpcHandler, BaseSnapRpcHandler } from '../libs/rpc'; +import { GetBalancesHandler } from '../rpcs'; +import { RpcHelper } from '../rpcs/helpers'; +import type { StaticImplements } from '../types/static'; +import type { IWallet } from '../wallet'; +import { BtcKeyringError } from './exceptions'; import { BtcKeyring } from './keyring'; -import * as getBalanceRpc from './rpcs/get-balances'; -import * as sendManyRpc from './rpcs/sendmany'; -import { KeyringStateManager } from './stateManagement'; -import type { IWallet } from './wallet'; +import { KeyringStateManager } from './state'; -jest.mock('./utils/logger'); -jest.mock('./utils/snap'); +jest.mock('../libs/logger/logger'); jest.mock('@metamask/keyring-api', () => ({ ...jest.requireActual('@metamask/keyring-api'), @@ -72,16 +71,40 @@ describe('BtcKeyring', () => { }; }; + const createMockChainRPCHandler = () => { + const handleRequestSpy = jest.fn(); + class MockChainRpcHandler + extends BaseSnapRpcHandler + implements + StaticImplements + { + static override get requestStruct() { + return unknown(); + } + + handleRequest = handleRequestSpy; + } + return { + instance: MockChainRpcHandler, + handleRequestSpy, + }; + }; + const createMockKeyring = (stateMgr: KeyringStateManager) => { - const sendManySpy = jest.spyOn(sendManyRpc, 'sendMany'); - const getBalanceRpcSpy = jest.spyOn(getBalanceRpc, 'getBalances'); + const { instance: RpcHandler, handleRequestSpy } = + createMockChainRPCHandler(); + + jest.spyOn(RpcHelper, 'getKeyringRpcApiHandlers').mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/naming-convention + btc_sendmany: RpcHandler, + }); + return { instance: new BtcKeyring(stateMgr, { defaultIndex: 0, multiAccount: false, }), - sendManySpy, - getBalanceRpcSpy, + handleRequestSpy, }; }; @@ -89,32 +112,12 @@ describe('BtcKeyring', () => { return [`m`, `0'`, `0`, `${index}`].join('/'); }; - const createSender = async (caip2ChainId: string) => { - const wallet = Factory.createWallet(caip2ChainId); - const sender = await wallet.unlock(0, ScriptType.P2wpkh); - - const keyringAccount = { - type: sender.type, - id: uuidv4(), - address: sender.address, - options: { - scope: caip2ChainId, - index: sender.index, - }, - methods: ['btc_sendmany'], - }; - return { - sender, - keyringAccount, - }; - }; - describe('createAccount', () => { it('creates account', async () => { const { unlockSpy } = createMockWallet(); const { instance: stateMgr, addWalletSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); - const scope = Caip2ChainId.Testnet; + const scope = Network.Testnet; const account = generateAccounts(1)[0]; unlockSpy.mockResolvedValue({ @@ -129,8 +132,8 @@ describe('BtcKeyring', () => { }); expect(unlockSpy).toHaveBeenCalledWith( - Config.wallet.defaultAccountIndex, - Config.wallet.defaultAccountType, + Config.wallet[Chain.Bitcoin].defaultAccountIndex, + Config.wallet[Chain.Bitcoin].defaultAccountType, ); expect(addWalletSpy).toHaveBeenCalledWith({ account: { @@ -149,18 +152,18 @@ describe('BtcKeyring', () => { }); }); - it('throws Error if an error catched', async () => { + it('throws BtcKeyringError if an error catched', async () => { const { unlockSpy } = createMockWallet(); const { instance: stateMgr } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); unlockSpy.mockRejectedValue(new Error('error')); - const scope = Caip2ChainId.Testnet; + const scope = Network.Testnet; await expect( keyring.createAccount({ scope, }), - ).rejects.toThrow(Error); + ).rejects.toThrow(BtcKeyringError); }); it('throws `Invalid params to create an account` if the create options is invalid', async () => { @@ -182,11 +185,108 @@ describe('BtcKeyring', () => { const account = generateAccounts(1)[0]; await expect( - keyring.filterAccountChains(account.id, [Caip2ChainId.Testnet]), + keyring.filterAccountChains(account.id, [Network.Testnet]), ).rejects.toThrow('Method not implemented.'); }); }); + describe('submitRequest', () => { + it('calls SnapRpcHandler if the method support', async () => { + const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const { instance: keyring, handleRequestSpy } = + createMockKeyring(stateMgr); + const account = generateAccounts(1)[0]; + const params = { + scope: 'bip122:000000000933ea01ad0ee984209779ba', + amounts: { + bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah: '0.01', + bc1qf5n2h6mgelkls4497pkpemew55xpew90td2qae: '0.01', + }, + comment: 'testing', + subtractFeeFrom: ['bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah'], + replaceable: false, + }; + getWalletSpy.mockResolvedValue({ + account, + index: account.options.index, + scope: account.options.scope, + hdPath: getHdPath(account.options.index), + }); + + await keyring.submitRequest({ + id: account.id, + scope: Network.Testnet, + account: account.address, + request: { + method: 'btc_sendmany', + params, + }, + }); + + expect(handleRequestSpy).toHaveBeenCalledWith(params); + }); + + it('throws `Account not found` error if the account address not found', async () => { + const { instance: stateMgr } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + const account = generateAccounts(1)[0]; + + await expect( + keyring.submitRequest({ + id: account.id, + scope: Network.Testnet, + account: account.address, + request: { + method: 'btc_sendmany', + }, + }), + ).rejects.toThrow('Account not found'); + }); + + it("throws `Account's scope does not match with the request's scope` error if given scope is not match with the account", async () => { + const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + const account = generateAccounts(1)[0]; + + getWalletSpy.mockResolvedValue({ + account, + index: account.options.index, + scope: account.options.scope, + hdPath: getHdPath(account.options.index), + }); + + await expect( + keyring.submitRequest({ + id: account.id, + scope: Network.Mainnet, + account: account.address, + request: { + method: 'btc_sendmany', + }, + }), + ).rejects.toThrow( + "Account's scope does not match with the request's scope", + ); + }); + + it('throws MethodNotFoundError if the method not support', async () => { + const { instance: stateMgr } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + const account = generateAccounts(1)[0]; + + await expect( + keyring.submitRequest({ + id: account.id, + scope: Network.Testnet, + account: account.address, + request: { + method: 'btc_doesNotExist', + }, + }), + ).rejects.toThrow(MethodNotFoundError); + }); + }); + describe('listAccounts', () => { it('returns result', async () => { const { instance: stateMgr, listAccountsSpy } = createMockStateMgr(); @@ -200,12 +300,12 @@ describe('BtcKeyring', () => { expect(listAccountsSpy).toHaveBeenCalledTimes(1); }); - it('throws Error if an error catched', async () => { + it('throws BtcKeyringError if an error catched', async () => { const { instance: stateMgr, listAccountsSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); listAccountsSpy.mockRejectedValue(new Error('error')); - await expect(keyring.listAccounts()).rejects.toThrow(Error); + await expect(keyring.listAccounts()).rejects.toThrow(BtcKeyringError); }); }); @@ -234,13 +334,15 @@ describe('BtcKeyring', () => { expect(getAccountSpy).toHaveBeenCalledTimes(1); }); - it('throws Error if an error catched', async () => { + it('throws BtcKeyringError if an error catched', async () => { const { instance: stateMgr, getAccountSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); getAccountSpy.mockRejectedValue(new Error('error')); const accounts = generateAccounts(1); - await expect(keyring.getAccount(accounts[0].id)).rejects.toThrow(Error); + await expect(keyring.getAccount(accounts[0].id)).rejects.toThrow( + BtcKeyringError, + ); }); }); @@ -256,13 +358,15 @@ describe('BtcKeyring', () => { expect(updateAccountSpy).toHaveBeenCalledWith(account); }); - it('throws Error if an error catched', async () => { + it('throws BtcKeyringError if an error catched', async () => { const { instance: stateMgr, updateAccountSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); updateAccountSpy.mockRejectedValue(new Error('error')); const account = generateAccounts(1)[0]; - await expect(keyring.updateAccount(account)).rejects.toThrow(Error); + await expect(keyring.updateAccount(account)).rejects.toThrow( + BtcKeyringError, + ); }); }); @@ -278,166 +382,61 @@ describe('BtcKeyring', () => { expect(removeAccountsSpy).toHaveBeenCalledWith([account.id]); }); - it('throws Error if an error catched', async () => { + it('throws BtcKeyringError if an error catched', async () => { const { instance: stateMgr, removeAccountsSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); removeAccountsSpy.mockRejectedValue(new Error('error')); const account = generateAccounts(1)[0]; - await expect(keyring.deleteAccount(account.id)).rejects.toThrow(Error); + await expect(keyring.deleteAccount(account.id)).rejects.toThrow( + BtcKeyringError, + ); }); }); - describe('submitRequest', () => { - it('calls SnapRpcHandler if the method support', async () => { - const caip2ChainId = Caip2ChainId.Testnet; + describe('getAccountBalances', () => { + it('executes `GetBalancesHandler` with correct parameter', async () => { const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const { instance: keyring, sendManySpy } = createMockKeyring(stateMgr); - const { sender, keyringAccount } = await createSender(caip2ChainId); - getWalletSpy.mockResolvedValue({ - account: keyringAccount as unknown as KeyringAccount, - index: sender.index, - scope: keyringAccount.options.scope, - hdPath: sender.hdPath, - }); - sendManySpy.mockResolvedValue({ - txId: 'txid', - }); - - const params = { - scope: caip2ChainId, - amounts: { - bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah: '0.01', - bc1qf5n2h6mgelkls4497pkpemew55xpew90td2qae: '0.01', - }, - comment: 'testing', - subtractFeeFrom: ['bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah'], - replaceable: false, - }; - - await keyring.submitRequest({ - id: keyringAccount.id, - scope: Caip2ChainId.Testnet, - account: keyringAccount.address, - request: { - method: 'btc_sendmany', - params, - }, - }); - - expect(sendManySpy).toHaveBeenCalledWith(expect.any(BtcAccount), params); - }); - - it('throws `Account not found` error if the account address not found', async () => { - const { instance: stateMgr } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); const account = generateAccounts(1)[0]; - - await expect( - keyring.submitRequest({ - id: account.id, - scope: Caip2ChainId.Testnet, - account: account.address, - request: { - method: 'btc_sendmany', - }, - }), - ).rejects.toThrow('Account not found'); - }); - - it("throws `Account's scope does not match with the request's scope` error if given scope is not match with the account", async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const { sender, keyringAccount } = await createSender(caip2ChainId); - getWalletSpy.mockResolvedValue({ - account: keyringAccount as unknown as KeyringAccount, - index: sender.index, - scope: keyringAccount.options.scope, - hdPath: sender.hdPath, - }); - - await expect( - keyring.submitRequest({ - id: keyringAccount.id, - scope: Caip2ChainId.Mainnet, - account: keyringAccount.address, - request: { - method: 'btc_sendmany', - }, - }), - ).rejects.toThrow( - "Account's scope does not match with the request's scope", + const assets = [BtcAsset.TBtc]; + const getBalancesHandlerSpy = jest.spyOn( + GetBalancesHandler.prototype, + 'execute', ); - }); - - it('throws MethodNotFoundError if the method not support', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const { sender, keyringAccount } = await createSender(caip2ChainId); - getWalletSpy.mockResolvedValue({ - account: keyringAccount as unknown as KeyringAccount, - index: sender.index, - scope: keyringAccount.options.scope, - hdPath: sender.hdPath, - }); - - await expect( - keyring.submitRequest({ - id: keyringAccount.id, - scope: caip2ChainId, - account: keyringAccount.address, - request: { - method: 'btc_doesNotExist', - }, + getBalancesHandlerSpy.mockResolvedValue( + assets.reduce((acc, asset) => { + acc[asset] = { + amount: '1', + unit: Config.unit[Chain.Bitcoin], + }; + return acc; }), - ).rejects.toThrow(MethodNotFoundError); - }); - }); - - describe('getAccountBalances', () => { - it('executes `GetBalancesHandler` with correct parameter', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const { instance: keyring, getBalanceRpcSpy } = - createMockKeyring(stateMgr); - const { sender, keyringAccount } = await createSender(caip2ChainId); + ); getWalletSpy.mockResolvedValue({ - account: keyringAccount as unknown as KeyringAccount, - index: sender.index, - scope: keyringAccount.options.scope, - hdPath: sender.hdPath, + account, + index: account.options.index, + scope: account.options.scope, + hdPath: getHdPath(account.options.index), }); - const assets = [Caip2Asset.TBtc]; - const expectedResp = assets.reduce((acc, asset) => { - acc[asset] = { - amount: '1', - unit: Config.unit, - }; - return acc; - }, {}); - - getBalanceRpcSpy.mockResolvedValue(expectedResp); - - await keyring.getAccountBalances(keyringAccount.id, [Caip2Asset.TBtc]); + await keyring.getAccountBalances(account.id, [BtcAsset.TBtc]); - expect(getBalanceRpcSpy).toHaveBeenCalledWith(expect.any(BtcAccount), { - scope: caip2ChainId, + expect(getBalancesHandlerSpy).toHaveBeenCalledWith({ + scope: account.options.scope, assets, }); }); - it('throws Error if an error catched', async () => { + it('throws BtcKeyringError if an error catched', async () => { const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); getWalletSpy.mockRejectedValue(new Error('error')); const account = generateAccounts(1)[0]; await expect( - keyring.getAccountBalances(account.id, [Caip2Asset.TBtc]), - ).rejects.toThrow(Error); + keyring.getAccountBalances(account.id, [BtcAsset.TBtc]), + ).rejects.toThrow(BtcKeyringError); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts similarity index 60% rename from merged-packages/bitcoin-wallet-snap/src/keyring.ts rename to merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts index 6ba16239..74a1c60c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts @@ -9,48 +9,48 @@ import { type CaipAssetType, } from '@metamask/keyring-api'; import { MethodNotFoundError, type Json } from '@metamask/snaps-sdk'; -import type { Infer } from 'superstruct'; -import { assert, object, StructError } from 'superstruct'; +import { assert, StructError } from 'superstruct'; import { v4 as uuidv4 } from 'uuid'; -import { Config } from './config'; -import { Factory } from './factory'; -import { getBalances, type SendManyParams, sendMany } from './rpcs'; -import type { KeyringStateManager, Wallet } from './stateManagement'; -import { getProvider, scopeStruct, logger } from './utils'; -import type { IAccount, IWallet } from './wallet'; - -export type KeyringOptions = Record & { - defaultIndex: number; - multiAccount?: boolean; - // TODO: Remove temp solution to support keyring in snap without keyring API - emitEvents?: boolean; -}; - -export const CreateAccountOptionsStruct = object({ - scope: scopeStruct, -}); - -export type CreateAccountOptions = Record & - Infer; +import { Config } from '../config'; +import { Factory } from '../factory'; +import { logger } from '../libs/logger/logger'; +import type { SnapRpcHandlerRequest } from '../libs/rpc'; +import { SnapHelper } from '../libs/snap'; +import { GetBalancesHandler } from '../rpcs'; +import { RpcHelper } from '../rpcs/helpers'; +import type { IAccount, IWallet } from '../wallet'; +import { BtcKeyringError } from './exceptions'; +import type { KeyringStateManager } from './state'; +import { + CreateAccountOptionsStruct, + type ChainRPCHandlers, + type CreateAccountOptions, + type KeyringOptions, +} from './types'; export class BtcKeyring implements Keyring { protected readonly _stateMgr: KeyringStateManager; protected readonly _options: KeyringOptions; - protected readonly _methods = ['btc_sendmany']; + protected readonly _keyringMethods: string[]; + + protected readonly _handlers: ChainRPCHandlers; constructor(stateMgr: KeyringStateManager, options: KeyringOptions) { this._stateMgr = stateMgr; this._options = options; + const mapping = RpcHelper.getKeyringRpcApiHandlers(); + this._keyringMethods = Object.keys(mapping); + this._handlers = mapping; } async listAccounts(): Promise { try { return await this._stateMgr.listAccounts(); } catch (error) { - throw new Error(error); + throw new BtcKeyringError(error); } } @@ -58,7 +58,7 @@ export class BtcKeyring implements Keyring { try { return (await this._stateMgr.getAccount(id)) ?? undefined; } catch (error) { - throw new Error(error); + throw new BtcKeyringError(error); } } @@ -66,16 +66,16 @@ export class BtcKeyring implements Keyring { try { assert(options, CreateAccountOptionsStruct); - const wallet = this.getBtcWallet(options.scope); + const wallet: IWallet = Factory.createWallet(options.scope); // TODO: Create account with index 0 for now for phase 1 scope, update to use increment index later - const index = this._options.defaultIndex; - const type = Config.wallet.defaultAccountType; - - const account = await this.discoverAccount(wallet, index, type); + const index = Config.wallet[Config.chain].defaultAccountIndex; + const account = await wallet.unlock( + index, + Config.wallet[Config.chain].defaultAccountType, + ); logger.info( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `[BtcKeyring.createAccount] Account unlocked: ${account.address}`, ); @@ -108,15 +108,15 @@ export class BtcKeyring implements Keyring { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[BtcKeyring.createAccount] Error: ${error.message}`); if (error instanceof StructError) { - throw new Error('Invalid params to create an account'); + throw new BtcKeyringError('Invalid params to create an account'); } - throw new Error(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.'); + throw new BtcKeyringError('Method not implemented.'); } async updateAccount(account: KeyringAccount): Promise { @@ -128,7 +128,7 @@ export class BtcKeyring implements Keyring { } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[BtcKeyring.updateAccount] Error: ${error.message}`); - throw new Error(error); + throw new BtcKeyringError(error); } } @@ -141,11 +141,17 @@ export class BtcKeyring implements Keyring { } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[BtcKeyring.deleteAccount] Error: ${error.message}`); - throw new Error(error); + throw new BtcKeyringError(error); } } async submitRequest(request: KeyringRequest): Promise { + return this.syncSubmitRequest(request); + } + + protected async syncSubmitRequest( + request: KeyringRequest, + ): Promise { return { pending: false, result: await this.handleSubmitRequest(request), @@ -153,10 +159,14 @@ export class BtcKeyring implements Keyring { } protected async handleSubmitRequest(request: KeyringRequest): Promise { - const { scope, account: id } = request; + const { scope, account } = request; const { method, params } = request.request; - const walletData = await this.getWalletData(id); + if (!Object.prototype.hasOwnProperty.call(this._handlers, method)) { + throw new MethodNotFoundError() as unknown as Error; + } + + const walletData = await this.getAndVerifyWallet(account); if (walletData.scope !== scope) { throw new Error( @@ -164,24 +174,10 @@ export class BtcKeyring implements Keyring { ); } - const wallet = this.getBtcWallet(walletData.scope); - const account = await this.discoverAccount( - wallet, - walletData.index, - walletData.account.type, - ); - - this.verifyIfAccountValid(account, walletData.account); - - switch (method) { - case 'btc_sendmany': - return (await sendMany(account, { - ...params, - scope: walletData.scope, - } as unknown as SendManyParams)) as unknown as Json; - default: - throw new MethodNotFoundError(method) as unknown as Error; - } + return this._handlers[method].getInstance(walletData).execute({ + ...params, + scope, + } as unknown as SnapRpcHandlerRequest); } async #emitEvent( @@ -190,37 +186,44 @@ export class BtcKeyring implements Keyring { ): Promise { // TODO: Remove temp solution to support keyring in snap without extentions support if (this._options.emitEvents) { - await emitSnapKeyringEvent(getProvider(), event, data); + await emitSnapKeyringEvent(SnapHelper.provider, event, data); } } + protected newKeyringAccount( + account: IAccount, + options?: CreateAccountOptions, + ): KeyringAccount { + return { + type: account.type, + id: uuidv4(), + address: account.address, + options: { + ...options, + }, + methods: this._keyringMethods, + } as unknown as KeyringAccount; + } + async getAccountBalances( id: string, assets: CaipAssetType[], ): Promise> { try { - const walletData = await this.getWalletData(id); - const wallet = this.getBtcWallet(walletData.scope); - const account = await this.discoverAccount( - wallet, - walletData.index, - walletData.account.type, - ); + const walletData = await this.getAndVerifyWallet(id); - this.verifyIfAccountValid(account, walletData.account); - - return await getBalances(account, { - assets, + return (await GetBalancesHandler.getInstance(walletData).execute({ scope: walletData.scope, - }); + assets, + })) as unknown as Record; } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[BtcKeyring.getAccountBalances] Error: ${error.message}`); - throw new Error(error); + throw new BtcKeyringError(error); } } - protected async getWalletData(id: string): Promise { + protected async getAndVerifyWallet(id: string) { const walletData = await this._stateMgr.getWallet(id); if (!walletData) { @@ -229,40 +232,4 @@ export class BtcKeyring implements Keyring { return walletData; } - - protected getBtcWallet(scope: string): IWallet { - return Factory.createWallet(scope); - } - - protected async discoverAccount( - wallet: IWallet, - index: number, - type: string, - ): Promise { - return await wallet.unlock(index, type); - } - - protected verifyIfAccountValid( - account: IAccount, - keyringAccount: KeyringAccount, - ): void { - if (!account || account.address !== keyringAccount.address) { - throw new Error('Account not found'); - } - } - - protected newKeyringAccount( - account: IAccount, - options?: CreateAccountOptions, - ): KeyringAccount { - return { - type: account.type, - id: uuidv4(), - address: account.address, - options: { - ...options, - }, - methods: this._methods, - } as unknown as KeyringAccount; - } } diff --git a/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts similarity index 90% rename from merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts rename to merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts index 883c0045..eeddcc04 100644 --- a/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/state.test.ts @@ -1,12 +1,12 @@ -import { generateAccounts } from '../test/utils'; -import { Caip2ChainId } from './constants'; -import { KeyringStateManager } from './stateManagement'; -import * as snapUtil from './utils/snap'; +import { generateAccounts } from '../../test/utils'; +import { Network } from '../bitcoin/constants'; +import { SnapHelper, StateError } from '../libs/snap'; +import { KeyringStateManager } from './state'; describe('KeyringStateManager', () => { const createMockStateManager = () => { - const getDataSpy = jest.spyOn(snapUtil, 'getStateData'); - const setDataSpy = jest.spyOn(snapUtil, 'setStateData'); + const getDataSpy = jest.spyOn(SnapHelper, 'getStateData'); + const setDataSpy = jest.spyOn(SnapHelper, 'setStateData'); return { instance: new KeyringStateManager(), getDataSpy, @@ -18,7 +18,7 @@ describe('KeyringStateManager', () => { return [`m`, `0'`, `0`, `${index}`].join('/'); }; - const createInitState = (cnt = 1, scope = Caip2ChainId.Testnet) => { + const createInitState = (cnt = 1, scope = Network.Testnet) => { const generatedAccounts = generateAccounts(cnt); return { walletIds: generatedAccounts.map((accounts) => accounts.id), @@ -82,11 +82,11 @@ describe('KeyringStateManager', () => { expect(result).toStrictEqual([]); }); - it('throw Error if an error catched', async () => { + it('throw StateError if an error catched', async () => { const { instance, getDataSpy } = createMockStateManager(); getDataSpy.mockRejectedValue(new Error('error')); - await expect(instance.listAccounts()).rejects.toThrow(Error); + await expect(instance.listAccounts()).rejects.toThrow(StateError); }); }); @@ -140,7 +140,7 @@ describe('KeyringStateManager', () => { }); }); - it('throw Error if the given account id exist', async () => { + it('throw StateError if the given account id exist', async () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); getDataSpy.mockResolvedValue(state); @@ -153,10 +153,10 @@ describe('KeyringStateManager', () => { index: accountToSave.index, scope: accountToSave.scope, }), - ).rejects.toThrow(Error); + ).rejects.toThrow(StateError); }); - it('throw Error if the given account address exist', async () => { + it('throw StateError if the given account address exist', async () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); getDataSpy.mockResolvedValue(state); @@ -194,7 +194,7 @@ describe('KeyringStateManager', () => { expect(state.wallets).not.toContain(testInput[1]); }); - it('throw Error if the account does not exist', async () => { + it('throw StateError if the account does not exist', async () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); const nonExistAcc = generateAccounts(1, 'notexist')[0]; @@ -208,13 +208,13 @@ describe('KeyringStateManager', () => { ); }); - it('throw Error if an error catched', async () => { + 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.walletIds)).rejects.toThrow( - Error, + StateError, ); }); }); @@ -244,12 +244,12 @@ describe('KeyringStateManager', () => { expect(result).toBeNull(); }); - it('throw Error if an error catched', async () => { + 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(Error); + await expect(instance.getAccount(id)).rejects.toThrow(StateError); }); }); @@ -278,7 +278,7 @@ describe('KeyringStateManager', () => { ); }); - it('throw Error if the account does not exist', async () => { + it('throw StateError if the account does not exist', async () => { const { instance, getDataSpy } = createMockStateManager(); const state = createInitState(20); const accToUpdate = generateAccounts(1, 'notexist')[0]; @@ -346,13 +346,13 @@ describe('KeyringStateManager', () => { expect(result).toBeNull(); }); - it('throw Error if an error catched', async () => { + it('throw StateError if an error catched', async () => { const { instance, getDataSpy } = createMockStateManager(); getDataSpy.mockRejectedValue(new Error('error')); const state = createInitState(20); const { id } = state.wallets[state.walletIds[0]].account; - await expect(instance.getWallet(id)).rejects.toThrow(Error); + await expect(instance.getWallet(id)).rejects.toThrow(StateError); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/state.ts similarity index 79% rename from merged-packages/bitcoin-wallet-snap/src/stateManagement.ts rename to merged-packages/bitcoin-wallet-snap/src/keyring/state.ts index 4292895b..7a0ec24a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/state.ts @@ -1,20 +1,8 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import { compactError, SnapStateManager } from './utils'; - -export type Wallet = { - account: KeyringAccount; - hdPath: string; - index: number; - scope: string; -}; - -export type Wallets = Record; - -export type SnapState = { - walletIds: string[]; - wallets: Wallets; -}; +import { SnapStateManager, StateError } from '../libs/snap'; +import { compactError } from '../utils'; +import type { Wallet, SnapState } from './types'; export class KeyringStateManager extends SnapStateManager { protected override async get(): Promise { @@ -44,7 +32,7 @@ export class KeyringStateManager extends SnapStateManager { const state = await this.get(); return state.walletIds.map((id) => state.wallets[id].account); } catch (error) { - throw compactError(error, Error); + throw compactError(error, StateError); } } @@ -56,14 +44,14 @@ export class KeyringStateManager extends SnapStateManager { this.isAccountExist(state, id) || this.getAccountByAddress(state, address) ) { - throw new Error(`Account address ${address} already exists`); + throw new StateError(`Account address ${address} already exists`); } state.wallets[id] = wallet; state.walletIds.push(id); }); } catch (error) { - throw compactError(error, Error); + throw compactError(error, StateError); } } @@ -71,7 +59,7 @@ export class KeyringStateManager extends SnapStateManager { try { await this.update(async (state: SnapState) => { if (!this.isAccountExist(state, account.id)) { - throw new Error(`Account id ${account.id} does not exist`); + throw new StateError(`Account id ${account.id} does not exist`); } const wallet = state.wallets[account.id]; @@ -82,13 +70,13 @@ export class KeyringStateManager extends SnapStateManager { account.address.toLowerCase() || accountInState.type !== account.type ) { - throw new Error(`Account address or type is immutable`); + throw new StateError(`Account address or type is immutable`); } state.wallets[account.id].account = account; }); } catch (error) { - throw compactError(error, Error); + throw compactError(error, StateError); } } @@ -99,7 +87,7 @@ export class KeyringStateManager extends SnapStateManager { for (const id of ids) { if (!this.isAccountExist(state, id)) { - throw new Error(`Account id ${id} does not exist`); + throw new StateError(`Account id ${id} does not exist`); } removeIds.add(id); } @@ -108,7 +96,7 @@ export class KeyringStateManager extends SnapStateManager { state.walletIds = state.walletIds.filter((id) => !removeIds.has(id)); }); } catch (error) { - throw compactError(error, Error); + throw compactError(error, StateError); } } @@ -117,7 +105,7 @@ export class KeyringStateManager extends SnapStateManager { const state = await this.get(); return state.wallets[id]?.account ?? null; } catch (error) { - throw compactError(error, Error); + throw compactError(error, StateError); } } @@ -126,7 +114,7 @@ export class KeyringStateManager extends SnapStateManager { const state = await this.get(); return state.wallets[id] ?? null; } catch (error) { - throw compactError(error, Error); + throw compactError(error, StateError); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts new file mode 100644 index 00000000..ed9e02c1 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts @@ -0,0 +1,37 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import { type Json } from '@metamask/snaps-sdk'; +import type { Infer } from 'superstruct'; +import { object } from 'superstruct'; + +import type { IStaticSnapRpcHandler } from '../libs/rpc'; +import { scopeStruct } from '../utils'; + +export type Wallet = { + account: KeyringAccount; + hdPath: string; + index: number; + scope: string; +}; + +export type Wallets = Record; + +export type SnapState = { + walletIds: string[]; + wallets: Wallets; +}; + +export type KeyringOptions = Record & { + defaultIndex: number; + multiAccount?: boolean; + // TODO: Remove temp solution to support keyring in snap without keyring API + emitEvents?: boolean; +}; + +export type ChainRPCHandlers = Record; + +export const CreateAccountOptionsStruct = object({ + scope: scopeStruct, +}); + +export type CreateAccountOptions = Record & + Infer; diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/exception/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/libs/exception/exceptions.ts new file mode 100644 index 00000000..438d3c89 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/libs/exception/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/libs/exception/index.ts b/merged-packages/bitcoin-wallet-snap/src/libs/exception/index.ts new file mode 100644 index 00000000..28386125 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/libs/exception/index.ts @@ -0,0 +1 @@ +export * from './exceptions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/logger.ts b/merged-packages/bitcoin-wallet-snap/src/libs/logger/__mocks__/logger.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/logger.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/logger/__mocks__/logger.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/logger.test.ts b/merged-packages/bitcoin-wallet-snap/src/libs/logger/logger.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/utils/logger.test.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/logger/logger.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/logger.ts b/merged-packages/bitcoin-wallet-snap/src/libs/logger/logger.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/utils/logger.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/logger/logger.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts new file mode 100644 index 00000000..22f57026 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts @@ -0,0 +1,86 @@ +import { InvalidParamsError } from '@metamask/snaps-sdk'; +import { type Struct, assert } from 'superstruct'; + +import { logger } from '../logger/logger'; +import { InvalidSnapRpcResponseError } from './exceptions'; +import { + type SnapRpcHandlerOptions, + type ISnapRpcHandler, + type IStaticSnapRpcHandler, + type SnapRpcHandlerResponse, + type SnapRpcHandlerRequest, + SnapRpcHandlerRequestStruct, +} from './types'; + +export abstract class BaseSnapRpcHandler { + static instance: ISnapRpcHandler | null = null; + + static readonly requestStruct: Struct = SnapRpcHandlerRequestStruct; + + static readonly responseStruct?: Struct; + + protected isThrowValidationError = false; + + abstract handleRequest( + params: SnapRpcHandlerRequest, + ): Promise; + + protected get _requestStruct(): Struct { + return (this.constructor as typeof BaseSnapRpcHandler).requestStruct; + } + + protected get _responseStruct(): Struct | undefined { + return (this.constructor as typeof BaseSnapRpcHandler).responseStruct; + } + + protected async preExecute(params: SnapRpcHandlerRequest): Promise { + logger.info( + `[SnapRpcHandler.preExecute] Request: ${JSON.stringify(params)}`, + ); + try { + assert(params, this._requestStruct); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.info(`[SnapRpcHandler.preExecute] Error: ${error.message}`); + this.throwValidationError(error.message); + } + } + + protected async postExecute(response: SnapRpcHandlerResponse): Promise { + logger.info( + `[SnapRpcHandler.postExecute] Response: ${JSON.stringify(response)}`, + ); + + try { + if (this._responseStruct) { + assert(response, this._responseStruct); + } + } catch (error) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + logger.info(`[SnapRpcHandler.postExecute] Error: ${error.message}`); + throw new InvalidSnapRpcResponseError('Invalid Response'); + } + } + + async execute( + params: SnapRpcHandlerRequest, + ): Promise { + await this.preExecute(params); + const result = await this.handleRequest(params); + await this.postExecute(result); + return result; + } + + static getInstance( + this: IStaticSnapRpcHandler, + options?: SnapRpcHandlerOptions, + ): ISnapRpcHandler { + return new this(options); + } + + protected throwValidationError(message: string): void { + throw new InvalidParamsError( + this.isThrowValidationError ? message : undefined, + ) as unknown as Error; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/exceptions.ts new file mode 100644 index 00000000..50b6a304 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/exceptions.ts @@ -0,0 +1,4 @@ +import { CustomError } from '../exception'; + +export class SnapRpcError extends CustomError {} +export class InvalidSnapRpcResponseError extends SnapRpcError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/index.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/index.ts new file mode 100644 index 00000000..0f4d2283 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './base'; +export * from './exceptions'; diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/types.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/types.ts new file mode 100644 index 00000000..a326f49e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/types.ts @@ -0,0 +1,56 @@ +import type { Json } from '@metamask/snaps-sdk'; +import { object, type Struct, type Infer } from 'superstruct'; + +import { scopeStruct } from '../../utils'; + +export const SnapRpcHandlerRequestStruct = object({ + scope: scopeStruct, +}); + +export type SnapRpcHandlerRequest = Json & + Infer; + +export type SnapRpcHandlerResponse = Json; + +export type SnapRpcHandlerOptions = Record | null; + +export type IStaticSnapRpcHandler = { + /** + * Superstruct for the request. + */ + requestStruct: Struct; + /** + * Superstruct for the response. + */ + reponseStruct?: Struct; + + /** + * A method to create a new instance of the rpc handler. + * + * @param options - An optional parameter to create the instance. + * @returns An handler object. + */ + new (options?: SnapRpcHandlerOptions): ISnapRpcHandler; + + /** + * A method to return the instance object of the rpc handler. + * + * @param this - The static instance of the handler class. + * @param options - An optional parameter to create the instance. + * @returns An handler object. + */ + getInstance( + this: IStaticSnapRpcHandler, + options?: SnapRpcHandlerOptions, + ): ISnapRpcHandler; +}; + +export type ISnapRpcHandler = { + /** + * A method to execute the rpc method. + * + * @param params - An struct contains the require parameter for the request. + * @returns A promise that resolves to an json. + */ + execute(params: SnapRpcHandlerRequest): Promise; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/__mocks__/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/__mocks__/helpers.ts new file mode 100644 index 00000000..b8b5abff --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/libs/snap/__mocks__/helpers.ts @@ -0,0 +1,25 @@ +import { type SLIP10NodeInterface } from '@metamask/key-tree'; +import { networks } from 'bitcoinjs-lib'; + +import { createRandomBip32Data } from '../../../../test/utils'; + +export class SnapHelper { + static getBip44Deriver = jest.fn(); + + static async getBip32Deriver( + path: string[], + curve: 'secp256k1' | 'ed25519', + ): Promise { + const { data } = createRandomBip32Data(networks.bitcoin, path, curve); + return { + ...data, + toJSON: jest.fn().mockReturnValue(data), + } as SLIP10NodeInterface; + } + + static confirmDialog = jest.fn(); + + static getStateData = jest.fn(); + + static setStateData = jest.fn(); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/exceptions.ts new file mode 100644 index 00000000..5999cf38 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/libs/snap/exceptions.ts @@ -0,0 +1,3 @@ +import { CustomError } from '../exception'; + +export class StateError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts new file mode 100644 index 00000000..430d1c04 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.test.ts @@ -0,0 +1,123 @@ +import { expect } from '@jest/globals'; +import type { Component } from '@metamask/snaps-sdk'; +import { heading, panel, text, divider, row } 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.provider, '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.provider, '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.provider, 'request'); + const components: Component[] = [ + heading('header'), + text('subHeader'), + divider(), + row('Label1', text('Value1')), + text('**Label2**:'), + row('SubLabel1', text('SubValue1')), + ]; + + await SnapHelper.confirmDialog(components); + + expect(spy).toHaveBeenCalledWith({ + method: 'snap_dialog', + params: { + type: 'confirmation', + content: panel(components), + }, + }); + }); + }); + + describe('getStateData', () => { + it('gets state data', async () => { + const spy = jest.spyOn(SnapHelper.provider, 'request'); + const testcase = { + state: { + transaction: [ + { + txHash: '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.provider, 'request'); + const testcase = { + state: { + transaction: [ + { + txHash: '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/libs/snap/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.ts new file mode 100644 index 00000000..e22c3bad --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/libs/snap/helpers.ts @@ -0,0 +1,68 @@ +import { + getBIP44AddressKeyDeriver, + type BIP44AddressKeyDeriver, + type SLIP10NodeInterface, +} from '@metamask/key-tree'; +import type { Component, DialogResult, Json } from '@metamask/snaps-sdk'; +import { panel, type SnapsProvider } from '@metamask/snaps-sdk'; + +declare const snap: SnapsProvider; + +export class SnapHelper { + static provider: SnapsProvider = snap; + + static async getBip44Deriver( + coinType: number, + ): Promise { + const bip44Node = await SnapHelper.provider.request({ + method: 'snap_getBip44Entropy', + params: { + coinType, + }, + }); + return getBIP44AddressKeyDeriver(bip44Node); + } + + static async getBip32Deriver( + path: string[], + curve: 'secp256k1' | 'ed25519', + ): Promise { + const node = await SnapHelper.provider.request({ + method: 'snap_getBip32Entropy', + params: { + path, + curve, + }, + }); + return node as SLIP10NodeInterface; + } + + static async confirmDialog(components: Component[]): Promise { + return SnapHelper.provider.request({ + method: 'snap_dialog', + params: { + type: 'confirmation', + content: panel(components), + }, + }); + } + + static async getStateData(): Promise { + return (await SnapHelper.provider.request({ + method: 'snap_manageState', + params: { + operation: 'get', + }, + })) as unknown as State; + } + + static async setStateData(data: State) { + await SnapHelper.provider.request({ + method: 'snap_manageState', + params: { + operation: 'update', + newState: data as unknown as Record, + }, + }); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/snap/index.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/index.ts new file mode 100644 index 00000000..6e76333f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/libs/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/libs/snap/lock.test.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.test.ts new file mode 100644 index 00000000..66e667ed --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/libs/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/libs/snap/lock.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/lock.ts new file mode 100644 index 00000000..b4b0e4fb --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/libs/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/utils/snap-state.test.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.test.ts similarity index 95% rename from merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/snap/state.test.ts index 18c9ea3d..956542fc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.test.ts @@ -1,10 +1,11 @@ import { expect } from '@jest/globals'; -import * as lockUtil from './lock'; -import * as snapUtil from './snap'; -import { SnapStateManager } from './snap-state'; +import { StateError } from './exceptions'; +import { SnapHelper } from './helpers'; +import { MutexLock } from './lock'; +import { SnapStateManager } from './state'; -jest.mock('../utils/logger'); +jest.mock('../logger/logger'); type MockTransactionDetail = { txHash: string; @@ -124,7 +125,7 @@ describe('SnapStateManager', () => { }; const getStateDataSpy = jest - .spyOn(snapUtil, 'getStateData') + .spyOn(SnapHelper, 'getStateData') .mockImplementation(async () => { return { transaction: [...initState.transaction], @@ -146,7 +147,7 @@ describe('SnapStateManager', () => { }); const setStateDataSpy = jest - .spyOn(snapUtil, 'setStateData') + .spyOn(SnapHelper, 'setStateData') .mockImplementation(setStateDataFn); return { @@ -159,14 +160,14 @@ describe('SnapStateManager', () => { describe('constructor', () => { it('sends `false` to Lock.Acquire if parameter `createLock` is `undefined`', async () => { - const spy = jest.spyOn(lockUtil, 'acquireLock'); + 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(lockUtil, 'acquireLock'); + const spy = jest.spyOn(MutexLock, 'acquire'); createMockStateManager(true); expect(spy).toHaveBeenCalledWith(true); @@ -185,7 +186,7 @@ describe('SnapStateManager', () => { ], }; const readSpy = jest - .spyOn(snapUtil, 'getStateData') + .spyOn(SnapHelper, 'getStateData') .mockResolvedValue(state); const result = await instance.getData(); @@ -212,9 +213,9 @@ describe('SnapStateManager', () => { }, }; const readSpy = jest - .spyOn(snapUtil, 'getStateData') + .spyOn(SnapHelper, 'getStateData') .mockResolvedValue(testcase.state); - const writeSpy = jest.spyOn(snapUtil, 'setStateData'); + const writeSpy = jest.spyOn(SnapHelper, 'setStateData'); updateDataSpy.mockImplementation((state, data) => { state.transaction.push(data); }); @@ -383,7 +384,7 @@ describe('SnapStateManager', () => { } catch (error) { expectedError = error; } finally { - expect(expectedError).toBeInstanceOf(Error); + expect(expectedError).toBeInstanceOf(StateError); expect(initState.transaction).toStrictEqual(['id']); expect(setStateDataSpy).toHaveBeenCalledTimes(0); expect(initState.trasansactionDetails).toStrictEqual({ @@ -562,7 +563,7 @@ describe('SnapStateManager', () => { ).rejects.toThrow('Failed to begin transaction'); }); - it('throws Error error, if an Error catched', async () => { + it('throws StateError error, if an StateError catched', async () => { const initState = { transaction: [], trasansactionDetails: {}, @@ -581,7 +582,7 @@ describe('SnapStateManager', () => { // firsy mockImplementation is to mock the set data actions setStateDataSpy .mockImplementationOnce(async () => { - throw new Error('setStateDataSpy'); + throw new StateError('setStateDataSpy'); // second mockImplementation is to mock the rollback actions }) .mockImplementationOnce(setStateDataFn); @@ -595,7 +596,7 @@ describe('SnapStateManager', () => { }, 30, ), - ).rejects.toThrow(Error); + ).rejects.toThrow(StateError); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts b/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.ts similarity index 82% rename from merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts rename to merged-packages/bitcoin-wallet-snap/src/libs/snap/state.ts index 46991b87..509b37fb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/libs/snap/state.ts @@ -1,9 +1,11 @@ import { type MutexInterface } from 'async-mutex'; import { v4 as uuidv4 } from 'uuid'; -import { acquireLock } from './lock'; -import { logger } from './logger'; -import { getStateData, setStateData } from './snap'; +import { compactError } from '../../utils'; +import { logger } from '../logger/logger'; +import { StateError } from './exceptions'; +import { SnapHelper } from './helpers'; +import { MutexLock } from './lock'; export type Transaction = { id?: string; @@ -19,7 +21,7 @@ export abstract class SnapStateManager { #transaction: Transaction; constructor(createLock = false) { - this.mtx = acquireLock(createLock); + this.mtx = MutexLock.acquire(createLock); this.#transaction = { id: undefined, orgState: undefined, @@ -30,11 +32,11 @@ export abstract class SnapStateManager { } protected async get(): Promise { - return getStateData(); + return SnapHelper.getStateData(); } protected async set(state: State): Promise { - return setStateData(state); + return SnapHelper.setStateData(state); } protected async update( @@ -77,7 +79,7 @@ export abstract class SnapStateManager { !this.#transaction.orgState || !this.#transaction.id ) { - throw new Error('Failed to begin transaction'); + throw new StateError('Failed to begin transaction'); } logger.info( @@ -95,10 +97,11 @@ export abstract class SnapStateManager { this.#transactionId }]: error : ${JSON.stringify(error.message)}`, ); - - await this.#rollback(); - - throw error; + if (this.#transaction.hasCommited) { + // we only need to rollback if the transaction is committed + await this.#rollback(); + } + throw compactError(error, StateError); } finally { this.#cleanUpTransaction(); } @@ -107,7 +110,7 @@ export abstract class SnapStateManager { async commit() { if (!this.#transaction.current || !this.#transaction.orgState) { - throw new Error('Failed to commit transaction'); + throw new StateError('Failed to commit transaction'); } this.#transaction.hasCommited = true; await this.set(this.#transaction.current); @@ -125,12 +128,7 @@ export abstract class SnapStateManager { async #rollback(): Promise { try { - // we only need to rollback if the transaction is committed - if ( - this.#transaction.hasCommited && - !this.#transaction.isRollingBack && - this.#transaction.orgState - ) { + if (!this.#transaction.isRollingBack && this.#transaction.orgState) { logger.info( `SnapStateManager.rollback [${ this.#transactionId @@ -138,6 +136,7 @@ export abstract class SnapStateManager { ); this.#transaction.isRollingBack = true; await this.set(this.#transaction.orgState); + this.#cleanUpTransaction(); } } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions @@ -146,7 +145,8 @@ export abstract class SnapStateManager { this.#transactionId }]: error : ${JSON.stringify(error)}`, ); - throw new Error('Failed to rollback state'); + this.#cleanUpTransaction(); + throw new StateError('Failed to rollback state'); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts index b8b1ce06..166de9bb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts @@ -1,36 +1,43 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import { object, type Infer } from 'superstruct'; +import type { Infer } from 'superstruct'; import { Config } from '../config'; -import { BtcKeyring } from '../keyring'; -import { KeyringStateManager } from '../stateManagement'; -import { scopeStruct } from '../utils'; +import { BtcKeyring, KeyringStateManager } from '../keyring'; +import { SnapRpcHandlerRequestStruct, BaseSnapRpcHandler } from '../libs/rpc'; +import type { + IStaticSnapRpcHandler, + SnapRpcHandlerResponse, +} from '../libs/rpc'; +import type { StaticImplements } from '../types/static'; -export const CreateAccountParamsStruct = object({ - scope: scopeStruct, -}); +export type CreateAccountParams = Infer< + typeof CreateAccountHandler.requestStruct +>; -export type CreateAccountParams = Infer; +export type CreateAccountResponse = SnapRpcHandlerResponse & KeyringAccount; -export type CreateAccountResponse = KeyringAccount; +export class CreateAccountHandler + extends BaseSnapRpcHandler + implements + StaticImplements +{ + static override get requestStruct() { + return SnapRpcHandlerRequestStruct; + } -/** - * Creates a new account with the specified parameters. - * - * @param params - The parameters for creating the account. - * @returns A Promise that resolves to the new account. - */ -export async function createAccount( - params: CreateAccountParams, -): Promise { - const keyring = new BtcKeyring(new KeyringStateManager(), { - defaultIndex: Config.wallet.defaultAccountIndex, - emitEvents: false, - }); + async handleRequest( + params: CreateAccountParams, + ): Promise { + const keyring = new BtcKeyring(new KeyringStateManager(), { + defaultIndex: Config.wallet[Config.chain].defaultAccountIndex, + multiAccount: Config.wallet[Config.chain].enableMultiAccounts, + emitEvents: false, + }); - const account = await keyring.createAccount({ - scope: params.scope, - }); + const account = await keyring.createAccount({ + scope: params.scope, + }); - return account; + return account; + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts index 3001f5b2..7786d8fd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts @@ -4,186 +4,186 @@ import { networks } from 'bitcoinjs-lib'; import { v4 as uuidv4 } from 'uuid'; import { generateAccounts } from '../../test/utils'; -import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; +import { Network, ScriptType } from '../bitcoin/constants'; +import { + BtcAccountBip32Deriver, + BtcWallet, + BtcAmount, +} from '../bitcoin/wallet'; import { Config } from '../config'; -import { Caip2ChainId } from '../constants'; import { Factory } from '../factory'; -import { getBalances } from './get-balances'; - -jest.mock('../utils/logger'); -jest.mock('../utils/snap'); - -describe('getBalances', () => { - const asset = Config.avaliableAssets[0]; - - const createMockChainApiFactory = () => { - const getBalancesSpy = jest.fn(); - - jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ - getFeeRates: jest.fn(), - getBalances: getBalancesSpy, - broadcastTransaction: jest.fn(), - listTransactions: jest.fn(), - getTransactionStatus: jest.fn(), - getDataForTransaction: jest.fn(), - }); - return { - getBalancesSpy, +import { GetBalancesHandler } from './get-balances'; + +jest.mock('../libs/logger/logger'); +jest.mock('../libs/snap/helpers'); + +describe('GetBalancesHandler', () => { + const asset = Config.avaliableAssets[Config.chain][0]; + + describe('handleRequest', () => { + const createMockChainApiFactory = () => { + const getBalancesSpy = jest.fn(); + + jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ + getFeeRates: jest.fn(), + getBalances: getBalancesSpy, + broadcastTransaction: jest.fn(), + listTransactions: jest.fn(), + getTransactionStatus: jest.fn(), + getDataForTransaction: jest.fn(), + }); + return { + getBalancesSpy, + }; }; - }; - - const createMockDeriver = (network) => { - const rootSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getRoot'); - const childSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getChild'); - return { - instance: new BtcAccountDeriver(network), - rootSpy, - childSpy, - }; - }; - - const createMockAccount = async (network, caip2ChainId) => { - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const sender = await wallet.unlock(0, Config.wallet.defaultAccountType); - const keyringAccount = { - type: sender.type, - id: uuidv4(), - address: sender.address, - options: { - scope: caip2ChainId, - index: sender.index, - }, - methods: ['btc_sendmany'], - }; + const createMockDeriver = (network) => { + const rootSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getRoot'); + const childSpy = jest.spyOn(BtcAccountBip32Deriver.prototype, 'getChild'); - const walletData = { - account: keyringAccount as unknown as KeyringAccount, - hdPath: sender.hdPath, - index: sender.index, - scope: caip2ChainId, + return { + instance: new BtcAccountBip32Deriver(network), + rootSpy, + childSpy, + }; }; - return { - keyringAccount, - walletData, - sender, - }; - }; - - it('gets balances', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); - - const { walletData, sender } = await createMockAccount( - network, - caip2ChainId, - ); - - const addresses = [walletData.account.address]; - const mockResp = { - balances: addresses.reduce((acc, address) => { - acc[address] = { - [asset]: { - amount: BigInt(100), - }, - }; - return acc; - }, {}), - }; + const createMockAccount = async (network, caip2ChainId) => { + const { instance } = createMockDeriver(network); + const wallet = new BtcWallet(instance, network); + const sender = await wallet.unlock(0, ScriptType.P2wpkh); + const keyringAccount = { + type: sender.type, + id: uuidv4(), + address: sender.address, + options: { + scope: caip2ChainId, + index: sender.index, + }, + methods: ['btc_sendmany'], + }; + + const walletData = { + account: keyringAccount as unknown as KeyringAccount, + hdPath: sender.hdPath, + index: sender.index, + scope: caip2ChainId, + }; - const expected = { - [asset]: { - amount: '0.00000100', - unit: Config.unit, - }, + return { + keyringAccount, + walletData, + sender, + }; }; - getBalancesSpy.mockResolvedValue(mockResp); + it('gets balances', async () => { + const network = networks.testnet; + const caip2ChainId = Network.Testnet; + const { getBalancesSpy } = createMockChainApiFactory(); + + const { walletData } = await createMockAccount(network, caip2ChainId); + + const addresses = [walletData.account.address]; + const mockResp = { + balances: addresses.reduce((acc, address) => { + acc[address] = { + [asset]: { + amount: new BtcAmount(100), + }, + }; + return acc; + }, {}), + }; + + const expected = { + [asset]: { + amount: '0.00000100', + unit: Config.unit[Config.chain], + }, + }; + + getBalancesSpy.mockResolvedValue(mockResp); + + const result = await GetBalancesHandler.getInstance(walletData).execute({ + scope: walletData.scope, + assets: [asset], + }); - const result = await getBalances(sender, { - scope: walletData.scope, - assets: [asset], + expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [asset]); + expect(result).toStrictEqual(expected); }); - expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [asset]); - expect(result).toStrictEqual(expected); - }); - - it('gets balances of the request account only', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); - const accounts = generateAccounts(10); - const { walletData, sender } = await createMockAccount( - network, - caip2ChainId, - ); - - const addresses = [walletData.account.address]; - const mockResp = { - balances: [ - ...addresses, - ...accounts.map((account) => account.address), - ].reduce((acc, address) => { - acc[address] = { - [asset]: { - amount: BigInt(100), - }, - 'some-asset': { - amount: BigInt(100), - }, - }; - return acc; - }, {}), - }; - - const expected = { - [asset]: { - amount: '0.00000100', - unit: Config.unit, - }, - }; - - getBalancesSpy.mockResolvedValue(mockResp); + it('gets balances of the request account only', async () => { + const network = networks.testnet; + const caip2ChainId = Network.Testnet; + const { getBalancesSpy } = createMockChainApiFactory(); + const accounts = generateAccounts(10); + const { walletData } = await createMockAccount(network, caip2ChainId); + + const addresses = [walletData.account.address]; + const mockResp = { + balances: [ + ...addresses, + ...accounts.map((account) => account.address), + ].reduce((acc, address) => { + acc[address] = { + [asset]: { + amount: new BtcAmount(100), + }, + 'some-asset': { + amount: new BtcAmount(100), + }, + }; + return acc; + }, {}), + }; + + const expected = { + [asset]: { + amount: '0.00000100', + unit: Config.unit[Config.chain], + }, + }; + + getBalancesSpy.mockResolvedValue(mockResp); + + const result = await GetBalancesHandler.getInstance(walletData).execute({ + scope: Network.Testnet, + assets: [asset], + }); - const result = await getBalances(sender, { - scope: Caip2ChainId.Testnet, - assets: [asset], + expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [asset]); + expect(result).toStrictEqual(expected); }); - expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [asset]); - expect(result).toStrictEqual(expected); - }); - - it('throws `Fail to get the balances` when transaction status fetch failed', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); - const { sender } = await createMockAccount(network, caip2ChainId); + it('throws `Fail to get the balances` when transaction status fetch failed', async () => { + const network = networks.testnet; + const caip2ChainId = Network.Testnet; + const { getBalancesSpy } = createMockChainApiFactory(); + const { walletData } = await createMockAccount(network, caip2ChainId); - getBalancesSpy.mockRejectedValue(new Error('error')); + getBalancesSpy.mockRejectedValue(new Error('error')); - await expect( - getBalances(sender, { - scope: Caip2ChainId.Testnet, - assets: [asset], - }), - ).rejects.toThrow(`Fail to get the balances`); - }); + await expect( + GetBalancesHandler.getInstance(walletData).execute({ + scope: Network.Testnet, + assets: [asset], + }), + ).rejects.toThrow(`Fail to get the balances`); + }); - it('throws `Request params is invalid` when request parameter is not correct', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { sender } = await createMockAccount(network, caip2ChainId); - - await expect( - getBalances(sender, { - scope: Caip2ChainId.Testnet, - assets: ['some-asset'], - }), - ).rejects.toThrow(InvalidParamsError); + it('throws `Request params is invalid` when request parameter is not correct', async () => { + const network = networks.testnet; + const caip2ChainId = Network.Testnet; + const { walletData } = await createMockAccount(network, caip2ChainId); + + await expect( + GetBalancesHandler.getInstance(walletData).execute({ + scope: Network.Testnet, + assets: ['some-asset'], + }), + ).rejects.toThrow(InvalidParamsError); + }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts index 412a8238..60c698f7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts @@ -1,108 +1,99 @@ import type { Infer } from 'superstruct'; -import { object, array, record, enums, assert } from 'superstruct'; +import { object, assign, array, record, enums } from 'superstruct'; import { Config } from '../config'; import { Factory } from '../factory'; -import { - isSnapRpcError, - validateRequest, - validateResponse, - logger, - satsToBtc, -} from '../utils'; -import { - assetsStruct, - positiveStringStruct, - scopeStruct, -} from '../utils/superstruct'; -import type { IAccount } from '../wallet'; - -export const getBalancesRequestStruct = object({ - assets: array(assetsStruct), - scope: scopeStruct, -}); - -export const getBalancesResponseStruct = object({ - assets: record( - assetsStruct, - object({ - amount: positiveStringStruct, - unit: enums([Config.unit]), - }), - ), -}); - -export type GetBalancesParams = Infer; - -export type GetBalancesResponse = Infer; +import { type Wallet as WalletData } from '../keyring'; +import { SnapRpcError, SnapRpcHandlerRequestStruct } from '../libs/rpc'; +import type { + IStaticSnapRpcHandler, + SnapRpcHandlerResponse, +} from '../libs/rpc'; +import type { StaticImplements } from '../types/static'; +import { assetsStruct, positiveStringStruct } from '../utils/superstruct'; +import type { IAmount } from '../wallet'; +import { KeyringRpcHandler } from './keyring-rpc'; + +export type GetBalancesParams = Infer; + +export type GetBalancesResponse = SnapRpcHandlerResponse & + Infer; + +export class GetBalancesHandler + extends KeyringRpcHandler + implements StaticImplements +{ + protected override isThrowValidationError = true; + + static override get requestStruct() { + return assign( + object({ + assets: array(assetsStruct), + }), + SnapRpcHandlerRequestStruct, + ); + } -/** - * Get Balances by a given account. - * - * @param account - The account to get the balances. - * @param params - The parameters for get the account. - * @returns A Promise that resolves to an GetBalancesResponse object. - */ -export async function getBalances( - account: IAccount, - params: GetBalancesParams, -) { - try { - validateRequest(params, getBalancesRequestStruct); + static override get responseStruct() { + const unit = Config.unit[Config.chain]; + return record( + assetsStruct, + object({ + amount: positiveStringStruct, + unit: enums([unit]), + }), + ); + } - assert(params, getBalancesRequestStruct); + constructor(walletData: WalletData) { + super(); + this.walletData = walletData; + } - const { assets, scope } = params; + async handleRequest(params: GetBalancesParams): Promise { + try { + const { scope, assets } = params; - const chainApi = Factory.createOnChainServiceProvider(scope); - const addresses = [account.address]; - const addressesSet = new Set(addresses); - const assetsSet = new Set(assets); + const chainApi = Factory.createOnChainServiceProvider(scope); + const addresses = [this.walletAccount.address]; + const addressesSet = new Set(addresses); + const assetsSet = new Set(assets); - const balances = await chainApi.getBalances(addresses, assets); + const balances = await chainApi.getBalances(addresses, assets); - const balancesVals = Object.entries(balances.balances); - const balancesMap = new Map(); + const balancesVals = Object.entries(balances.balances); + const balancesMap = new Map(); - for (const [address, assetBalances] of balancesVals) { - if (!addressesSet.has(address)) { - continue; - } - for (const asset in assetBalances) { - if (!assetsSet.has(asset)) { + for (const [address, assetBalances] of balancesVals) { + if (!addressesSet.has(address)) { continue; } - - const { amount } = assetBalances[asset]; - let currentAmount = balancesMap.get(asset); - if (currentAmount) { - currentAmount += amount; + for (const asset in assetBalances) { + if (!assetsSet.has(asset)) { + continue; + } + + const { amount } = assetBalances[asset]; + const currentAmount = balancesMap.get(asset); + if (currentAmount) { + currentAmount.value += amount.value; + } + + balancesMap.set(asset, currentAmount ?? amount); } - - balancesMap.set(asset, currentAmount ?? amount); } - } - const resp = Object.fromEntries( - [...balancesMap.entries()].map(([asset, amount]) => [ - asset, - { - amount: satsToBtc(amount), - unit: Config.unit, - }, - ]), - ); - - validateResponse(params, getBalancesRequestStruct); - - return resp; - } catch (error) { - logger.error('Failed to get balances', error); - - if (isSnapRpcError(error)) { - throw error as unknown as Error; + return Object.fromEntries( + [...balancesMap.entries()].map(([asset, amount]) => [ + asset, + { + amount: amount.toString(), + unit: amount.unit, + }, + ]), + ); + } catch (error) { + throw new SnapRpcError('Fail to get the balances'); } - - throw new Error('Fail to get the balances'); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts index 8271c47c..36017ab5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts @@ -1,71 +1,72 @@ import { InvalidParamsError } from '@metamask/snaps-sdk'; +import { Network } from '../bitcoin/constants'; import { TransactionStatus } from '../chain'; -import { Caip2ChainId } from '../constants'; import { Factory } from '../factory'; -import { getTransactionStatus } from './get-transaction-status'; +import { GetTransactionStatusHandler } from './get-transaction-status'; -jest.mock('../utils/logger'); +jest.mock('../libs/logger/logger'); -describe('getTransactionStatus', () => { +describe('GetBalancesHandler', () => { const txHash = '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; - const createMockChainApiFactory = () => { - const getTransactionStatusSpy = jest.fn(); + describe('handleRequest', () => { + const createMockChainApiFactory = () => { + const getTransactionStatusSpy = jest.fn(); - jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ - getFeeRates: jest.fn(), - getBalances: jest.fn(), - broadcastTransaction: jest.fn(), - listTransactions: jest.fn(), - getTransactionStatus: getTransactionStatusSpy, - getDataForTransaction: jest.fn(), - }); - return { - getTransactionStatusSpy, + jest.spyOn(Factory, 'createOnChainServiceProvider').mockReturnValue({ + getFeeRates: jest.fn(), + getBalances: jest.fn(), + broadcastTransaction: jest.fn(), + listTransactions: jest.fn(), + getTransactionStatus: getTransactionStatusSpy, + getDataForTransaction: jest.fn(), + }); + return { + getTransactionStatusSpy, + }; }; - }; - it('gets status', async () => { - const { getTransactionStatusSpy } = createMockChainApiFactory(); + it('gets status', async () => { + const { getTransactionStatusSpy } = createMockChainApiFactory(); - const mockResp = { - status: TransactionStatus.Confirmed, - }; + const mockResp = { + status: TransactionStatus.Confirmed, + }; - getTransactionStatusSpy.mockResolvedValue(mockResp); + getTransactionStatusSpy.mockResolvedValue(mockResp); - const result = await getTransactionStatus({ - scope: Caip2ChainId.Testnet, - transactionId: txHash, - }); + const result = await GetTransactionStatusHandler.getInstance().execute({ + scope: Network.Testnet, + transactionId: txHash, + }); - expect(getTransactionStatusSpy).toHaveBeenCalledWith(txHash); - expect(result).toStrictEqual({ - status: TransactionStatus.Confirmed, + expect(getTransactionStatusSpy).toHaveBeenCalledWith(txHash); + expect(result).toStrictEqual({ + status: TransactionStatus.Confirmed, + }); }); - }); - it('throws `Fail to get the transaction status` when transaction status fetch failed', async () => { - const { getTransactionStatusSpy } = createMockChainApiFactory(); + it('throws `Fail to get the transaction status` when transaction status fetch failed', async () => { + const { getTransactionStatusSpy } = createMockChainApiFactory(); - getTransactionStatusSpy.mockRejectedValue(new Error('error')); + getTransactionStatusSpy.mockRejectedValue(new Error('error')); - await expect( - getTransactionStatus({ - scope: Caip2ChainId.Testnet, - transactionId: txHash, - }), - ).rejects.toThrow(`Fail to get the transaction status`); - }); + await expect( + GetTransactionStatusHandler.getInstance().execute({ + scope: Network.Testnet, + transactionId: txHash, + }), + ).rejects.toThrow(`Fail to get the transaction status`); + }); - it('throws `Request params is invalid` when request parameter is not correct', async () => { - await expect( - getTransactionStatus({ - scope: 'some-scope', - transactionId: '', - }), - ).rejects.toThrow(InvalidParamsError); + it('throws `Request params is invalid` when request parameter is not correct', async () => { + await expect( + GetTransactionStatusHandler.getInstance().execute({ + scope: Network.Testnet, + }), + ).rejects.toThrow(InvalidParamsError); + }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts index 5b7e89c8..43474a33 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts @@ -1,65 +1,61 @@ import type { Infer } from 'superstruct'; -import { object, string, enums } from 'superstruct'; +import { object, string, assign, enums } from 'superstruct'; import { TransactionStatus } from '../chain'; import { Factory } from '../factory'; import { - isSnapRpcError, - scopeStruct, - validateRequest, - validateResponse, - logger, -} from '../utils'; - -export const getTransactionStatusParamsRequestStruct = object({ - transactionId: string(), - scope: scopeStruct, -}); - -export const getTransactionStatusParamsResponseStruct = object({ - status: enums(Object.values(TransactionStatus)), -}); + SnapRpcHandlerRequestStruct, + BaseSnapRpcHandler, + SnapRpcError, +} from '../libs/rpc'; +import type { + IStaticSnapRpcHandler, + SnapRpcHandlerResponse, +} from '../libs/rpc'; +import type { StaticImplements } from '../types/static'; export type GetTransactionStatusParams = Infer< - typeof getTransactionStatusParamsRequestStruct + typeof GetTransactionStatusHandler.requestStruct >; -export type GetTransactionStatusResponse = Infer< - typeof getTransactionStatusParamsResponseStruct ->; - -/** - * Get Transaction Status by a given transaction id. - * - * @param params - The parameters for get the transaction status. - * @returns A Promise that resolves to an GetTransactionStatusResponse object. - */ -export async function getTransactionStatus( - params: GetTransactionStatusParams, -): Promise { - try { - validateRequest(params, getTransactionStatusParamsRequestStruct); - - const { scope, transactionId } = params; - - const chainApi = Factory.createOnChainServiceProvider(scope); +export type GetTransactionStatusResponse = SnapRpcHandlerResponse & + Infer; + +export class GetTransactionStatusHandler + extends BaseSnapRpcHandler + implements + StaticImplements +{ + static override get requestStruct() { + return assign( + object({ + transactionId: string(), + }), + SnapRpcHandlerRequestStruct, + ); + } - const txStatusResp = await chainApi.getTransactionStatus(transactionId); + static override get responseStruct() { + return object({ + status: enums(Object.values(TransactionStatus)), + }); + } - const resp = { - status: txStatusResp.status, - }; + async handleRequest( + params: GetTransactionStatusParams, + ): Promise { + try { + const { scope, transactionId } = params; - validateResponse(resp, getTransactionStatusParamsResponseStruct); + const chainApi = Factory.createOnChainServiceProvider(scope); - return resp; - } catch (error) { - logger.error('Failed to get transaction status', error); + const resp = await chainApi.getTransactionStatus(transactionId); - if (isSnapRpcError(error)) { - throw error as unknown as Error; + return { + status: resp.status, + }; + } catch (error) { + throw new SnapRpcError('Fail to get the transaction status'); } - - throw new Error('Fail to get the transaction status'); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts new file mode 100644 index 00000000..15329e6f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.test.ts @@ -0,0 +1,34 @@ +import { CreateAccountHandler } from './create-account'; +import { GetTransactionStatusHandler } from './get-transaction-status'; +import { RpcHelper } from './helpers'; +import { SendManyHandler } from './sendmany'; + +describe('RpcHelper', () => { + describe('getChainRpcApiHandlers', () => { + it('returns handler', () => { + expect(RpcHelper.getChainRpcApiHandlers()).toStrictEqual({ + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_createAccount: CreateAccountHandler, + // // eslint-disable-next-line @typescript-eslint/naming-convention + // chain_getBalances: GetBalancesHandler, + // // eslint-disable-next-line @typescript-eslint/naming-convention + // chain_broadcastTransaction: BroadcastTransactionHandler, + // // eslint-disable-next-line @typescript-eslint/naming-convention + // chain_getDataForTransaction: GetTransactionDataHandler, + // // eslint-disable-next-line @typescript-eslint/naming-convention + // chain_estimateFees: EstimateFeesHandler, + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_getTransactionStatus: GetTransactionStatusHandler, + }); + }); + }); + + describe('getKeyringRpcApiHandlers', () => { + it('returns handler', () => { + expect(RpcHelper.getKeyringRpcApiHandlers()).toStrictEqual({ + // eslint-disable-next-line @typescript-eslint/naming-convention + btc_sendmany: SendManyHandler, + }); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts new file mode 100644 index 00000000..e567e284 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/helpers.ts @@ -0,0 +1,30 @@ +import { CreateAccountHandler } from '.'; +import type { IStaticSnapRpcHandler } from '../libs/rpc'; +import { GetTransactionStatusHandler } from './get-transaction-status'; +import { SendManyHandler } from './sendmany'; + +export class RpcHelper { + static getChainRpcApiHandlers(): Record { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_createAccount: CreateAccountHandler, + // // eslint-disable-next-line @typescript-eslint/naming-convention + // chain_getBalances: GetBalancesHandler, + // // eslint-disable-next-line @typescript-eslint/naming-convention + // chain_broadcastTransaction: BroadcastTransactionHandler, + // // eslint-disable-next-line @typescript-eslint/naming-convention + // chain_getDataForTransaction: GetTransactionDataHandler, + // // eslint-disable-next-line @typescript-eslint/naming-convention + // chain_estimateFees: EstimateFeesHandler, + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_getTransactionStatus: GetTransactionStatusHandler, + }; + } + + static getKeyringRpcApiHandlers(): Record { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + btc_sendmany: SendManyHandler, + }; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts index 5dd3f86e..53521af2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts @@ -1,4 +1,3 @@ export * from './create-account'; export * from './get-balances'; -export * from './get-transaction-status'; -export * from './sendmany'; +export * from './helpers'; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts new file mode 100644 index 00000000..50d653c6 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/keyring-rpc.ts @@ -0,0 +1,30 @@ +import { Factory } from '../factory'; +import { type Wallet as WalletData } from '../keyring'; +import { BaseSnapRpcHandler } from '../libs/rpc'; +import type { SnapRpcHandlerRequest } from '../libs/rpc'; +import type { IAccount, IWallet } from '../wallet'; + +export abstract class KeyringRpcHandler extends BaseSnapRpcHandler { + walletData: WalletData; + + wallet: IWallet; + + walletAccount: IAccount; + + protected override async preExecute( + params: SnapRpcHandlerRequest, + ): Promise { + await super.preExecute(params); + + const { scope, index, account } = this.walletData; + const wallet = Factory.createWallet(scope); + const unlocked = await wallet.unlock(index, account.type); + + if (!unlocked || unlocked.address !== account.address) { + throw new Error('Account not found'); + } + + this.walletAccount = unlocked; + this.wallet = wallet; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 741fa218..b8780d01 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -1,3 +1,4 @@ +import type { Json } from '@metamask/snaps-sdk'; import { InvalidParamsError, UserRejectedRequestError, @@ -9,22 +10,26 @@ import { generateBlockChairBroadcastTransactionResp, generateBlockChairGetUtxosResp, } from '../../test/utils'; -import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; +import { DustLimit, Network, ScriptType } from '../bitcoin/constants'; +import { satsToBtc } from '../bitcoin/utils/unit'; +import type { IBtcAccount } from '../bitcoin/wallet'; +import { + BtcAccountBip32Deriver, + BtcWallet, + BtcAmount, +} from '../bitcoin/wallet'; import { FeeRatio } from '../chain'; -import { Config } from '../config'; -import { Caip2ChainId } from '../constants'; import { Factory } from '../factory'; -import { getExplorerUrl, shortenAddress } from '../utils'; -import * as snapUtils from '../utils/snap'; -import { satsToBtc } from '../utils/unit'; +import { SnapHelper } from '../libs/snap'; import type { IAccount, ITxInfo } from '../wallet'; -import { type SendManyParams, sendMany } from './sendmany'; +import { SendManyHandler } from './sendmany'; +import type { SendManyParams } from './sendmany'; -jest.mock('../utils/logger'); -jest.mock('../utils/snap'); +jest.mock('../libs/logger/logger'); +jest.mock('../libs/snap/helpers'); describe('SendManyHandler', () => { - describe('sendMany', () => { + describe('handleRequest', () => { const createMockChainApiFactory = () => { const getDataForTransactionSpy = jest.fn(); const getFeeRatesSpy = jest.fn(); @@ -47,7 +52,7 @@ describe('SendManyHandler', () => { const createMockDeriver = (network) => { return { - instance: new BtcAccountDeriver(network), + instance: new BtcAccountBip32Deriver(network), }; }; @@ -58,7 +63,7 @@ describe('SendManyHandler', () => { ) => { const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); - const sender = await wallet.unlock(0, Config.wallet.defaultAccountType); + const sender = await wallet.unlock(0, ScriptType.P2wpkh); const keyringAccount = { type: sender.type, @@ -72,9 +77,7 @@ describe('SendManyHandler', () => { }; const recipients: IAccount[] = []; for (let i = 1; i < recipientCnt + 1; i++) { - recipients.push( - await wallet.unlock(i, Config.wallet.defaultAccountType), - ); + recipients.push(await wallet.unlock(i, ScriptType.P2wpkh)); } return { @@ -91,8 +94,10 @@ describe('SendManyHandler', () => { comment = '', ): SendManyParams => { return { - amounts: recipients.reduce((acc, recipient) => { - acc[recipient.address] = satsToBtc(500); + amounts: recipients.reduce((acc, recipient: IBtcAccount) => { + acc[recipient.address] = satsToBtc( + DustLimit[recipient.scriptType] + 1, + ); return acc; }, {}), comment, @@ -131,7 +136,7 @@ describe('SendManyHandler', () => { getFeeRatesSpy, broadcastTransactionSpy, } = createMockChainApiFactory(); - const snapHelperSpy = jest.spyOn(snapUtils, 'confirmDialog'); + const snapHelperSpy = jest.spyOn(SnapHelper, 'confirmDialog'); const { sender, keyringAccount, recipients } = await createSenderNRecipients(network, caip2ChainId, 2); @@ -147,7 +152,7 @@ describe('SendManyHandler', () => { fees: [ { type: FeeRatio.Fast, - rate: BigInt(1), + rate: new BtcAmount(1), }, ], }); @@ -170,9 +175,9 @@ describe('SendManyHandler', () => { it('returns correct result', async () => { const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; + const caip2ChainId = Network.Testnet; const { - sender, + keyringAccount, recipients, broadcastResp, getDataForTransactionSpy, @@ -180,10 +185,11 @@ describe('SendManyHandler', () => { broadcastTransactionSpy, } = await prepareSendMany(network, caip2ChainId); - const result = await sendMany( - sender, - createSendManyParams(recipients, caip2ChainId, false), - ); + const result = await SendManyHandler.getInstance({ + scope: caip2ChainId, + index: 0, + account: keyringAccount, + }).execute(createSendManyParams(recipients, caip2ChainId, false)); expect(result).toStrictEqual({ txId: broadcastResp }); expect(getFeeRatesSpy).toHaveBeenCalledTimes(1); @@ -193,28 +199,30 @@ describe('SendManyHandler', () => { it('does not broadcast transaction if in dryrun mode', async () => { const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { recipients, sender, broadcastTransactionSpy } = + const caip2ChainId = Network.Testnet; + const { keyringAccount, recipients, broadcastTransactionSpy } = await prepareSendMany(network, caip2ChainId); - await sendMany( - sender, - createSendManyParams(recipients, caip2ChainId, true), - ); + await SendManyHandler.getInstance({ + scope: caip2ChainId, + index: 0, + account: keyringAccount, + }).execute(createSendManyParams(recipients, caip2ChainId, true)); expect(broadcastTransactionSpy).toHaveBeenCalledTimes(0); }); it('does create comment component in dialog if consumer has provide the comment', async () => { const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients, snapHelperSpy } = await prepareSendMany( - network, - caip2ChainId, - ); + const caip2ChainId = Network.Testnet; + const { keyringAccount, recipients, snapHelperSpy } = + await prepareSendMany(network, caip2ChainId); - await sendMany( - sender, + await SendManyHandler.getInstance({ + scope: caip2ChainId, + index: 0, + account: keyringAccount, + }).execute( createSendManyParams(recipients, caip2ChainId, true, 'test comment'), ); @@ -246,11 +254,9 @@ describe('SendManyHandler', () => { it('display `Recipient` as label in dialog if there is only 1 recipient', async () => { const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { recipients, snapHelperSpy, sender } = await prepareSendMany( - network, - caip2ChainId, - ); + const caip2ChainId = Network.Testnet; + const { keyringAccount, recipients, snapHelperSpy, sender } = + await prepareSendMany(network, caip2ChainId); const walletCreateTxSpy = jest.spyOn( BtcWallet.prototype, 'createTransaction', @@ -261,16 +267,22 @@ describe('SendManyHandler', () => { ); const info: ITxInfo = { - feeRate: BigInt('1'), - txFee: BigInt('1'), - sender: sender.address, - recipients: [ - { - address: recipients[0].address, - value: BigInt('1000'), - }, - ], - total: BigInt('1000'), + toJson>() { + return { + feeRate: `0.00000001 BTC`, + txFee: `0.00000001 BTC`, + sender: sender.address, + recipients: [ + { + address: recipients[0].address, + value: `0.000010 BTC`, + explorerUrl: `https://blockchair.com/bitcoin/transaction/transactionId`, + }, + ], + changes: [], + total: `0.000010 BTC`, + } as unknown as TxInfoJson; + }, }; walletCreateTxSpy.mockResolvedValue({ @@ -280,10 +292,11 @@ describe('SendManyHandler', () => { walletSignTxSpy.mockResolvedValue('txId'); - await sendMany( - sender, - createSendManyParams([recipients[0]], caip2ChainId, true), - ); + await SendManyHandler.getInstance({ + scope: caip2ChainId, + index: 0, + account: keyringAccount, + }).execute(createSendManyParams([recipients[0]], caip2ChainId, true)); const calls = snapHelperSpy.mock.calls[0][0]; @@ -297,103 +310,69 @@ describe('SendManyHandler', () => { label: 'Recipient', value: { type: 'text', - value: `[${shortenAddress( - recipients[0].address, - )}](${getExplorerUrl(recipients[0].address, caip2ChainId)})`, + value: `[${recipients[0].address}](https://blockchair.com/bitcoin/transaction/transactionId)`, }, }, { type: 'row', label: 'Amount', - value: { markdown: false, type: 'text', value: '0.00001000 BTC' }, + value: { markdown: false, type: 'text', value: '0.000010 BTC' }, }, ], }); }); - it('throws InvalidParamsError when request parameter is not correct', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { sender } = await prepareSendMany(network, caip2ChainId); + it('throws `Request params is invalid` error when request parameter is not correct', async () => { + createMockChainApiFactory(); await expect( - sendMany(sender, { - amounts: { - 'some-address': '1', - }, - } as unknown as SendManyParams), + SendManyHandler.getInstance().execute({ + scope: Network.Testnet, + }), ).rejects.toThrow(InvalidParamsError); }); - it('throws `Transaction must have at least one recipient` error if no recipient provided', async () => { + it('throws `Account not found` error when given address not match', async () => { createMockChainApiFactory(); const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { recipients, sender } = await createSenderNRecipients( + const caip2ChainId = Network.Testnet; + const { keyringAccount, recipients } = await createSenderNRecipients( network, caip2ChainId, - 0, + 2, ); await expect( - sendMany(sender, createSendManyParams(recipients, caip2ChainId, false)), - ).rejects.toThrow('Transaction must have at least one recipient'); + SendManyHandler.getInstance({ + scope: caip2ChainId, + index: 20, + account: keyringAccount, + }).execute(createSendManyParams(recipients, caip2ChainId, false)), + ).rejects.toThrow('Account not found'); }); - it('throws `Invalid amount for send` error if receive amount is not valid', async () => { + it('throws `Transaction must have at least one recipient` error if no recipient provided', async () => { createMockChainApiFactory(); const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { recipients, sender } = await createSenderNRecipients( + const caip2ChainId = Network.Testnet; + const { keyringAccount, recipients } = await createSenderNRecipients( network, caip2ChainId, - 2, + 0, ); await expect( - sendMany(sender, { - ...createSendManyParams(recipients, caip2ChainId, false), - amounts: { - [recipients[0].address]: 'invalid', - [recipients[1].address]: '0.1', - }, - }), - ).rejects.toThrow('Invalid amount for send'); - - await expect( - sendMany(sender, { - ...createSendManyParams(recipients, caip2ChainId, false), - amounts: { - [recipients[0].address]: '0', - [recipients[1].address]: '0.1', - }, - }), - ).rejects.toThrow('Invalid amount for send'); - - await expect( - sendMany(sender, { - ...createSendManyParams(recipients, caip2ChainId, false), - amounts: { - [recipients[0].address]: 'invalid', - [recipients[1].address]: '0.000000019', - }, - }), - ).rejects.toThrow('Invalid amount for send'); - - await expect( - sendMany(sender, { - ...createSendManyParams(recipients, caip2ChainId, false), - amounts: { - [recipients[0].address]: '1', - [recipients[1].address]: '999999999.99999999', - }, - }), - ).rejects.toThrow('Invalid amount for send'); + SendManyHandler.getInstance({ + scope: caip2ChainId, + index: 0, + account: keyringAccount, + }).execute(createSendManyParams(recipients, caip2ChainId, false)), + ).rejects.toThrow('Transaction must have at least one recipient'); }); it('throws `Failed to send the transaction` error if no fee rate returns from chain service', async () => { const { getFeeRatesSpy } = createMockChainApiFactory(); const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients } = await createSenderNRecipients( + const caip2ChainId = Network.Testnet; + const { keyringAccount, recipients } = await createSenderNRecipients( network, caip2ChainId, 10, @@ -403,14 +382,43 @@ describe('SendManyHandler', () => { }); await expect( - sendMany(sender, createSendManyParams(recipients, caip2ChainId, false)), + SendManyHandler.getInstance({ + scope: caip2ChainId, + index: 0, + account: keyringAccount, + }).execute(createSendManyParams(recipients, caip2ChainId, false)), ).rejects.toThrow('Failed to send the transaction'); }); + it('throws `Invalid amount for send` error if sending amount is <= 0', async () => { + const network = networks.testnet; + const caip2ChainId = Network.Testnet; + createMockChainApiFactory(); + const { keyringAccount, recipients } = await createSenderNRecipients( + network, + caip2ChainId, + 2, + ); + + await expect( + SendManyHandler.getInstance({ + scope: caip2ChainId, + index: 0, + account: keyringAccount, + }).execute({ + ...createSendManyParams(recipients, caip2ChainId, false), + amounts: { + [recipients[0].address]: satsToBtc(500), + [recipients[1].address]: satsToBtc(0), + }, + }), + ).rejects.toThrow('Invalid amount for send'); + }); + it('throws `Invalid response` error if the response is unexpected', async () => { const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients, broadcastTransactionSpy } = + const caip2ChainId = Network.Testnet; + const { keyringAccount, recipients, broadcastTransactionSpy } = await prepareSendMany(network, caip2ChainId); broadcastTransactionSpy.mockResolvedValue({ @@ -419,39 +427,43 @@ describe('SendManyHandler', () => { }, }); await expect( - sendMany(sender, { - ...createSendManyParams(recipients, caip2ChainId, false), - }), + SendManyHandler.getInstance({ + scope: caip2ChainId, + index: 0, + account: keyringAccount, + }).execute(createSendManyParams(recipients, caip2ChainId, false)), ).rejects.toThrow('Invalid Response'); }); it('throws UserRejectedRequestError error if user denied the transaction', async () => { const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { snapHelperSpy, sender, recipients } = await prepareSendMany( - network, - caip2ChainId, - ); + const caip2ChainId = Network.Testnet; + const { snapHelperSpy, keyringAccount, recipients } = + await prepareSendMany(network, caip2ChainId); snapHelperSpy.mockResolvedValue(false); await expect( - sendMany(sender, { - ...createSendManyParams(recipients, caip2ChainId, false), - }), + SendManyHandler.getInstance({ + scope: caip2ChainId, + index: 0, + account: keyringAccount, + }).execute(createSendManyParams(recipients, caip2ChainId, false)), ).rejects.toThrow(UserRejectedRequestError); }); it('throws `Failed to send the transaction` error if the transaction is fail to commit', async () => { const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { broadcastTransactionSpy, sender, recipients } = + const caip2ChainId = Network.Testnet; + const { broadcastTransactionSpy, keyringAccount, recipients } = await prepareSendMany(network, caip2ChainId); broadcastTransactionSpy.mockRejectedValue(new Error('error')); await expect( - sendMany(sender, { - ...createSendManyParams(recipients, caip2ChainId, false), - }), + SendManyHandler.getInstance({ + scope: caip2ChainId, + index: 0, + account: keyringAccount, + }).execute(createSendManyParams(recipients, caip2ChainId, false)), ).rejects.toThrow('Failed to send the transaction'); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 5cc3ca58..ba68876b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -11,6 +11,7 @@ import { import { object, string, + assign, type Infer, record, array, @@ -19,21 +20,21 @@ import { optional, } from 'superstruct'; +import { btcToSats } from '../bitcoin/utils'; import { TxValidationError } from '../bitcoin/wallet'; import { Factory } from '../factory'; -import { - scopeStruct, - confirmDialog, - isSnapRpcError, - shortenAddress, - getExplorerUrl, - btcToSats, - satsToBtc, - validateRequest, - validateResponse, - logger, -} from '../utils'; -import type { IAccount, ITxInfo } from '../wallet'; +import { type Wallet as WalletData } from '../keyring'; +import { logger } from '../libs/logger/logger'; +import { SnapRpcHandlerRequestStruct } from '../libs/rpc'; +import type { IStaticSnapRpcHandler } from '../libs/rpc'; +import { SnapHelper } from '../libs/snap'; +import type { StaticImplements } from '../types/static'; +import type { ITxInfo } from '../wallet'; +import { KeyringRpcHandler } from './keyring-rpc'; + +export type SendManyParams = Infer; + +export type SendManyResponse = Infer; export const TransactionAmountStuct = refine( record(BtcP2wpkhAddressStruct, string()), @@ -52,206 +53,201 @@ export const TransactionAmountStuct = refine( ) { return 'Invalid amount for send'; } - - try { - btcToSats(val); - } catch (error) { - return 'Invalid amount for send'; - } } return true; }, ); -export const sendManyParamsStruct = object({ - amounts: TransactionAmountStuct, - comment: string(), - subtractFeeFrom: array(BtcP2wpkhAddressStruct), - replaceable: boolean(), - dryrun: optional(boolean()), - scope: scopeStruct, -}); - -export const sendManyResponseStruct = object({ - txId: string(), - txHash: optional(string()), -}); - -export type SendManyParams = Infer; - -export type SendManyResponse = Infer; - -/** - * Send BTC to multiple account. - * - * @param account - The account to send the transaction. - * @param params - The parameters for send the transaction. - * @returns A Promise that resolves to an SendManyResponse object. - */ -export async function sendMany(account: IAccount, params: SendManyParams) { - try { - validateRequest(params, sendManyParamsStruct); - - const { dryrun, scope } = params; - const chainApi = Factory.createOnChainServiceProvider(scope); - const wallet = Factory.createWallet(scope); - - const feesResp = await chainApi.getFeeRates(); - - if (feesResp.fees.length === 0) { - throw new Error('No fee rates available'); - } - - const fee = Math.max( - Number(feesResp.fees[feesResp.fees.length - 1].rate), - 1, - ); +export type TxJson = { + feeRate: string; + txFee: string; + total: string; + sender: string; + recipients: { + address: string; + explorerUrl: string; + value: string; + }[]; + changes: { + address: string; + value: string; + explorerUrl: string; + }[]; +}; + +export class SendManyHandler + extends KeyringRpcHandler + implements StaticImplements +{ + protected override isThrowValidationError = true; + + constructor(walletData: WalletData) { + super(); + this.walletData = walletData; + } - const recipients = Object.entries(params.amounts).map( - ([address, value]) => ({ - address, - value: btcToSats(value), + static override get requestStruct() { + return assign( + object({ + amounts: TransactionAmountStuct, + comment: string(), + subtractFeeFrom: array(BtcP2wpkhAddressStruct), + replaceable: boolean(), + dryrun: boolean(), }), + SnapRpcHandlerRequestStruct, ); + } - const metadata = await chainApi.getDataForTransaction(account.address); - - const { tx, txInfo } = await wallet.createTransaction(account, recipients, { - utxos: metadata.data.utxos, - fee, - subtractFeeFrom: params.subtractFeeFrom, - replaceable: params.replaceable, + static override get responseStruct() { + return object({ + txId: string(), + txHash: optional(string()), }); + } - if (!(await getTxConsensus(txInfo, params.comment, scope))) { - throw new UserRejectedRequestError() as unknown as Error; - } - - const txHash = await wallet.signTransaction(account.signer, tx); + async handleRequest(params: SendManyParams): Promise { + try { + const { scope } = this.walletData; + const { dryrun } = params; + const chainApi = Factory.createOnChainServiceProvider(scope); - if (dryrun) { - return { - txId: '', - txHash, - }; - } + const feesResp = await chainApi.getFeeRates(); - const result = await chainApi.broadcastTransaction(txHash); + if (feesResp.fees.length === 0) { + throw new Error('No fee rates available'); + } - const resp = { - txId: result.transactionId, - }; + const fee = Math.max( + feesResp.fees[feesResp.fees.length - 1].rate.value, + 1, + ); + + const recipients = Object.entries(params.amounts).map( + ([address, value]) => ({ + address, + value: parseInt(btcToSats(parseFloat(value)), 10), + }), + ); + + const metadata = await chainApi.getDataForTransaction( + this.walletAccount.address, + ); + + const { tx, txInfo } = await this.wallet.createTransaction( + this.walletAccount, + recipients, + { + utxos: metadata.data.utxos, + fee, + subtractFeeFrom: params.subtractFeeFrom, + replaceable: params.replaceable, + }, + ); + + if (!(await this.getTxConsensus(txInfo, params.comment))) { + throw new UserRejectedRequestError() as unknown as Error; + } - validateResponse(resp, sendManyResponseStruct); + const txHash = await this.wallet.signTransaction( + this.walletAccount.signer, + tx, + ); - return resp; - } catch (error) { - logger.error('Failed to send the transaction', error); + if (dryrun) { + return { + txId: '', + txHash, + }; + } - if (isSnapRpcError(error)) { - throw error as unknown as Error; - } + const result = await chainApi.broadcastTransaction(txHash); - if ( - error instanceof TxValidationError || - error instanceof UserRejectedRequestError - ) { - throw error as unknown as Error; + return { + txId: result.transactionId, + }; + } catch (error) { + logger.error('Failed to send the transaction', error); + if ( + error instanceof TxValidationError || + error instanceof UserRejectedRequestError + ) { + throw error as unknown as Error; + } + throw new Error('Failed to send the transaction'); } - - throw new Error('Failed to send the transaction'); } -} -/** - * Display an confirmation dialog to confirm an transaction. - * - * @param info - The transaction data object contains the transaction information. - * @param comment - The comment text to display. - * @param scope - The CAIP-2 Chain ID. - * @returns A Promise that resolves to the response of the confirmation dialog. - */ -export async function getTxConsensus( - info: ITxInfo, - comment: string, - scope: string, -): Promise { - const header = `Send Request`; - const intro = `Review the request before proceeding. Once the transaction is made, it's irreversible.`; - const recipientsLabel = `Recipient`; - const amountLabel = `Amount`; - const commentLabel = `Comment`; - // const networkFeeRateLabel = `Network fee rate`; - const networkFeeLabel = `Network fee`; - const totalLabel = `Total`; - const requestedByLable = `Requested by`; - - const components: Component[] = [ - panel([ - heading(header), - text(intro), - row( - requestedByLable, - text(`[portfolio.metamask.io](https://portfolio.metamask.io/)`), - ), - ]), - divider(), - ]; - - const isMoreThanOneRecipient = - info.recipients.length + (info.change ? 1 : 0) > 1; - - let i = 0; - - const addReciptentsToComponents = (data: { - address: string; - value: bigint; - }) => { - const recipientsPanel: Component[] = []; - recipientsPanel.push( - row( - isMoreThanOneRecipient - ? `${recipientsLabel} ${i + 1}` - : recipientsLabel, - text( - `[${shortenAddress(data.address)}](${getExplorerUrl( - data.address, - scope, - )})`, + protected async getTxConsensus( + txInfo: ITxInfo, + comment: string, + ): Promise { + const header = `Send Request`; + const intro = `Review the request before proceeding. Once the transaction is made, it's irreversible.`; + const recipientsLabel = `Recipient`; + const amountLabel = `Amount`; + const commentLabel = `Comment`; + // const networkFeeRateLabel = `Network fee rate`; + const networkFeeLabel = `Network fee`; + const totalLabel = `Total`; + const requestedByLable = `Requested by`; + + const components: Component[] = [ + panel([ + heading(header), + text(intro), + row( + requestedByLable, + text(`[portfolio.metamask.io](https://portfolio.metamask.io/)`), ), - ), - ); - recipientsPanel.push( - row(amountLabel, text(satsToBtc(data.value, true), false)), - ); - i += 1; - components.push(panel(recipientsPanel)); - components.push(divider()); - }; - - info.recipients.forEach(addReciptentsToComponents); + ]), + divider(), + ]; + + const info = txInfo.toJson(); + + const isMoreThanOneRecipient = + info.recipients.length + info.changes.length > 1; + + let i = 0; + + const addReciptentsToComponents = (data: { + address: string; + explorerUrl: string; + value: string; + }) => { + const recipientsPanel: Component[] = []; + recipientsPanel.push( + row( + isMoreThanOneRecipient + ? `${recipientsLabel} ${i + 1}` + : recipientsLabel, + text(`[${data.address}](${data.explorerUrl})`), + ), + ); + recipientsPanel.push(row(amountLabel, text(data.value, false))); + i += 1; + components.push(panel(recipientsPanel)); + components.push(divider()); + }; - if (info.change) { - [info.change].forEach(addReciptentsToComponents); - } + info.recipients.forEach(addReciptentsToComponents); + info.changes.forEach(addReciptentsToComponents); - const bottomPanel: Component[] = []; - if (comment.trim().length > 0) { - bottomPanel.push(row(commentLabel, text(comment.trim(), false))); - } + const bottomPanel: Component[] = []; + if (comment.trim().length > 0) { + bottomPanel.push(row(commentLabel, text(comment.trim(), false))); + } - bottomPanel.push( - row(networkFeeLabel, text(`${satsToBtc(info.txFee, true)}`, false)), - ); + bottomPanel.push(row(networkFeeLabel, text(`${info.txFee}`, false))); - // bottomPanel.push(row(networkFeeRateLabel, text(`${info.feeRate}`, false))); + // bottomPanel.push(row(networkFeeRateLabel, text(`${info.feeRate}`, false))); - bottomPanel.push( - row(totalLabel, text(`${satsToBtc(info.total, true)}`, false)), - ); + bottomPanel.push(row(totalLabel, text(`${info.total}`, false))); - components.push(panel(bottomPanel)); + components.push(panel(bottomPanel)); - return (await confirmDialog(components)) as boolean; + return (await SnapHelper.confirmDialog(components)) as boolean; + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/static.ts b/merged-packages/bitcoin-wallet-snap/src/types/static.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/static.ts rename to merged-packages/bitcoin-wallet-snap/src/types/static.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts deleted file mode 100644 index 1dd67d68..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { type SLIP10NodeInterface } from '@metamask/key-tree'; -import { networks } from 'bitcoinjs-lib'; - -import { createRandomBip32Data } from '../../../test/utils'; - -/** - * Retrieves a SLIP10NodeInterface object for the specified path and curve. - * - * @param path - The BIP32 derivation path for which to retrieve a SLIP10NodeInterface. - * @param curve - The elliptic curve to use for key derivation. - * @returns A Promise that resolves to a SLIP10NodeInterface object. - */ -export async function getBip32Deriver( - path: string[], - curve: 'secp256k1' | 'ed25519', -): Promise { - const { data } = createRandomBip32Data(networks.bitcoin, path, curve); - return { - ...data, - toJSON: jest.fn().mockReturnValue(data), - } as SLIP10NodeInterface; -} - -export const getBip44Deriver = jest.fn(); - -export const confirmDialog = jest.fn(); - -export const getStateData = jest.fn(); - -export const setStateData = jest.fn(); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/error.ts b/merged-packages/bitcoin-wallet-snap/src/utils/error.ts index c4b7081d..9c20c537 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/error.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/error.ts @@ -18,30 +18,6 @@ import { SnapError, } from '@metamask/snaps-sdk'; -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); - } -} - /** * Compacts an error to a specific error instance. * diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/explorer.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/explorer.test.ts deleted file mode 100644 index 557545f4..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/explorer.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Caip2ChainId } from '../constants'; -import { getExplorerUrl } from './explorer'; - -describe('getExplorerUrl', () => { - const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; - - it('returns a testnet explorer url', () => { - const result = getExplorerUrl(address, Caip2ChainId.Testnet); - expect(result).toBe(`https://blockstream.info/testnet/address/${address}`); - }); - - it('returns a mainnet explorer url', () => { - const result = getExplorerUrl(address, Caip2ChainId.Mainnet); - expect(result).toBe(`https://blockstream.info/address/${address}`); - }); - - it('throws `Invalid Chain ID` error if the given Chain ID is not support', () => { - expect(() => getExplorerUrl(address, 'some Chain ID')).toThrow( - 'Invalid Chain ID', - ); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/explorer.ts b/merged-packages/bitcoin-wallet-snap/src/utils/explorer.ts deleted file mode 100644 index 04f1aecb..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/explorer.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Config } from '../config'; -import { Caip2ChainId } from '../constants'; - -/** - * Gets the explorer URL for a given bitcoin address and CAIP-2 Chain ID. - * - * @param address - The bitcoin address to get the explorer URL for. - * @param caip2ChainId - The CAIP-2 Chain ID. - * @returns The explorer URL as a string. - * @throws An error if an invalid scope is provided. - */ -export function getExplorerUrl(address: string, caip2ChainId: string): string { - switch (caip2ChainId) { - case Caip2ChainId.Mainnet: - return Config.explorer[Caip2ChainId.Mainnet].replace( - // eslint-disable-next-line no-template-curly-in-string - '${address}', - address, - ); - case Caip2ChainId.Testnet: - return Config.explorer[Caip2ChainId.Testnet].replace( - // eslint-disable-next-line no-template-curly-in-string - '${address}', - address, - ); - default: - throw new Error('Invalid Chain ID'); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/utils/index.ts index 3aa58ed8..d022966a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/index.ts @@ -2,10 +2,3 @@ export * from './error'; export * from './string'; export * from './async'; export * from './superstruct'; -export * from './lock'; -export * from './snap'; -export * from './snap-state'; -export * from './rpc'; -export * from './explorer'; -export * from './unit'; -export * from './logger'; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/lock.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/lock.test.ts deleted file mode 100644 index ca96498e..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/lock.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Mutex } from 'async-mutex'; - -import { acquireLock } from './lock'; - -jest.mock('async-mutex', () => { - return { - Mutex: jest.fn(), - }; -}); - -describe('acquireLock', () => { - it('acquires lock', () => { - acquireLock(); - expect(Mutex).toHaveBeenCalledTimes(0); - }); - - it('acquires new lock if parameter `create` is true', () => { - acquireLock(true); - expect(Mutex).toHaveBeenCalledTimes(1); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/lock.ts b/merged-packages/bitcoin-wallet-snap/src/utils/lock.ts deleted file mode 100644 index 73d0fd06..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/lock.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Mutex } from 'async-mutex'; - -const saveMutex = new Mutex(); - -/** - * Acquires or retrieves a lock. - * - * @param create - Whether to create a new lock or retrieve an existing one. - * @returns A Mutex object representing the lock. - */ -export function acquireLock(create = false) { - if (create) { - return new Mutex(); - } - return saveMutex; -} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/rpc.ts b/merged-packages/bitcoin-wallet-snap/src/utils/rpc.ts deleted file mode 100644 index 4e384a3c..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/rpc.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { InvalidParamsError, SnapError } from '@metamask/snaps-sdk'; -import type { Struct } from 'superstruct'; -import { assert } from 'superstruct'; - -/** - * Validates that the request parameters conform to the expected structure defined by the provided struct. - * - * @template Params - The expected structure of the request parameters. - * @param requestParams - The request parameters to validate. - * @param struct - The expected structure of the request parameters. - * @throws {InvalidParamsError} If the request parameters do not conform to the expected structure. - */ -export function validateRequest(requestParams: Params, struct: Struct) { - try { - assert(requestParams, struct); - } catch (error) { - throw new InvalidParamsError(error.message) as unknown as Error; - } -} - -/** - * Validates that the response conforms to the expected structure defined by the provided struct. - * - * @template Params - The expected structure of the response. - * @param response - The response to validate. - * @param struct - The expected structure of the response. - * @throws {SnapError} If the response does not conform to the expected structure. - */ -export function validateResponse(response: Params, struct: Struct) { - try { - assert(response, struct); - } catch (error) { - throw new SnapError('Invalid Response') as unknown as Error; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts deleted file mode 100644 index 780cd74f..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { expect } from '@jest/globals'; -import type { Component } from '@metamask/snaps-sdk'; -import { heading, panel, text, divider, row } from '@metamask/snaps-sdk'; - -import * as snapUtil from './snap'; - -jest.mock('@metamask/key-tree', () => ({ - getBIP44AddressKeyDeriver: jest.fn(), -})); - -describe('getBip32Deriver', () => { - it('gets bip32 deriver', async () => { - const spy = jest.spyOn(snapUtil.getProvider(), 'request'); - const path = ['m', "84'", "0'"]; - const curve = 'secp256k1'; - - await snapUtil.getBip32Deriver(path, curve); - - expect(spy).toHaveBeenCalledWith({ - method: 'snap_getBip32Entropy', - params: { - path, - curve, - }, - }); - }); -}); - -describe('confirmDialog', () => { - it('calls snap_dialog', async () => { - const spy = jest.spyOn(snapUtil.getProvider(), 'request'); - const components: Component[] = [ - heading('header'), - text('subHeader'), - divider(), - row('Label1', text('Value1')), - text('**Label2**:'), - row('SubLabel1', text('SubValue1')), - ]; - - await snapUtil.confirmDialog(components); - - expect(spy).toHaveBeenCalledWith({ - method: 'snap_dialog', - params: { - type: 'confirmation', - content: panel(components), - }, - }); - }); -}); - -describe('getStateData', () => { - it('gets state data', async () => { - const spy = jest.spyOn(snapUtil.getProvider(), 'request'); - const testcase = { - state: { - transaction: [ - { - txHash: 'hash', - chainId: 'chainId', - }, - ], - }, - }; - - spy.mockResolvedValue(testcase.state); - const result = await snapUtil.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(snapUtil.getProvider(), 'request'); - const testcase = { - state: { - transaction: [ - { - txHash: 'hash', - chainId: 'chainId', - }, - ], - }, - }; - - await snapUtil.setStateData(testcase.state); - - expect(spy).toHaveBeenCalledWith({ - method: 'snap_manageState', - params: { - operation: 'update', - newState: testcase.state, - }, - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts deleted file mode 100644 index e6b162ef..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { type SLIP10NodeInterface } from '@metamask/key-tree'; -import type { Component, DialogResult, Json } from '@metamask/snaps-sdk'; -import { panel, type SnapsProvider } from '@metamask/snaps-sdk'; - -declare const snap: SnapsProvider; - -/** - * Retrieves the current SnapsProvider. - * - * @returns The current SnapsProvider. - */ -export function getProvider(): SnapsProvider { - return snap; -} - -/** - * Retrieves a SLIP10NodeInterface object for the specified path and curve. - * - * @param path - The BIP32 derivation path for which to retrieve a SLIP10NodeInterface. - * @param curve - The elliptic curve to use for key derivation. - * @returns A Promise that resolves to a SLIP10NodeInterface object. - */ -export async function getBip32Deriver( - path: string[], - curve: 'secp256k1' | 'ed25519', -): Promise { - const node = await snap.request({ - method: 'snap_getBip32Entropy', - params: { - path, - curve, - }, - }); - return node as SLIP10NodeInterface; -} - -/** - * Displays a confirmation dialog with the specified components. - * - * @param components - An array of components to display in the dialog. - * @returns A Promise that resolves to the result of the dialog. - */ -export async function confirmDialog( - components: Component[], -): Promise { - return snap.request({ - method: 'snap_dialog', - params: { - type: 'confirmation', - content: panel(components), - }, - }); -} - -/** - * Retrieves the current state data. - * - * @returns A Promise that resolves to the current state data. - */ -export async function getStateData(): Promise { - return (await snap.request({ - method: 'snap_manageState', - params: { - operation: 'get', - }, - })) as unknown as State; -} - -/** - * Sets the current state data to the specified data. - * - * @param data - The new state data to set. - */ -export async function setStateData(data: State) { - await snap.request({ - method: 'snap_manageState', - params: { - operation: 'update', - newState: data as unknown as Record, - }, - }); -} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts index b4c14a31..f0b3e8c4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts @@ -5,7 +5,6 @@ import { hexToBuffer, bufferToString, replaceMiddleChar, - shortenAddress, } from './string'; describe('trimHexPrefix', () => { @@ -69,10 +68,3 @@ describe('replaceMiddleChar', () => { expect(replaceMiddleChar('', 5, 3)).toBe(''); }); }); - -describe('shortenAddress', () => { - const str = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; - it('shorten an address', () => { - expect(shortenAddress(str)).toBe('tb1qt...aeu'); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts index 8dc990be..acf96baa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts @@ -64,13 +64,3 @@ export function replaceMiddleChar( str.length - tailLength, )}`; } - -/** - * Format the address in shorten string. - * - * @param address - The address to format. - * @returns The formatted address. - */ -export function shortenAddress(address: string) { - return replaceMiddleChar(address, 5, 3); -} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts index f9440c9f..06495791 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts @@ -45,7 +45,10 @@ describe('superstruct', () => { describe('scopeStruct', () => { it('validates correctly', () => { expect(() => - assert(Config.avaliableNetworks[0], superstruct.scopeStruct), + assert( + Config.avaliableNetworks[Config.chain][0], + superstruct.scopeStruct, + ), ).not.toThrow(); expect(() => assert('custom scope', superstruct.scopeStruct)).toThrow( Error, @@ -56,7 +59,10 @@ describe('superstruct', () => { describe('assetsStruct', () => { it('validates correctly', () => { expect(() => - assert(Config.avaliableAssets[0], superstruct.assetsStruct), + assert( + Config.avaliableAssets[Config.chain][0], + superstruct.assetsStruct, + ), ).not.toThrow(); expect(() => assert('custom scope', superstruct.assetsStruct)).toThrow( Error, diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts index 411cb8a0..a7c15dda 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts @@ -2,9 +2,9 @@ import { enums, string, pattern } from 'superstruct'; import { Config } from '../config'; -export const assetsStruct = enums(Config.avaliableAssets); +export const assetsStruct = enums(Config.avaliableAssets[Config.chain]); -export const scopeStruct = enums(Config.avaliableNetworks); +export const scopeStruct = enums(Config.avaliableNetworks[Config.chain]); export const positiveStringStruct = pattern( string(), diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts deleted file mode 100644 index c038e30f..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { maxSatoshi, minSatoshi } from '../bitcoin/constants'; -import { satsToBtc, btcToSats } from './unit'; - -describe('satsToBtc', () => { - it('returns Btc unit', () => { - expect(satsToBtc(2099999999999999n)).toBe('20999999.99999999'); - }); - - it('returns Btc unit with max Satoshis', () => { - expect(satsToBtc(maxSatoshi)).toBe('21000000.00000000'); - }); - - it('returns Btc unit with min Satoshis', () => { - expect(satsToBtc(minSatoshi)).toBe('0.00000001'); - }); - - it('returns Btc unit with unit', () => { - expect(satsToBtc(minSatoshi, true)).toBe('0.00000001 BTC'); - }); - - it('throw an error if then given Satoshis in float', () => { - const sats = 1.1; - expect(() => satsToBtc(sats)).toThrow(Error); - }); -}); - -describe('btcToSats', () => { - it('returns Btc unit', () => { - expect(btcToSats('20999999.99999999')).toBe(2099999999999999n); - }); - - it('returns Btc unit with max Satoshis', () => { - expect(btcToSats('21000000')).toBe(2100000000000000n); - }); - - it('returns Btc unit with 0 Satoshis', () => { - expect(btcToSats('0')).toBe(0n); - }); - - it('returns Btc unit with min Satoshis', () => { - expect(btcToSats('0.00000001')).toBe(1n); - }); - - it('throws an error if the given BTC is out of range', () => { - expect(() => btcToSats('0.9999999999999')).toThrow( - 'BTC amount is out of range', - ); - expect(() => btcToSats('21000000.999999999"')).toThrow( - 'BTC amount is out of range', - ); - expect(() => btcToSats('22000000')).toThrow('BTC amount is out of range'); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts deleted file mode 100644 index f27b1fb8..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts +++ /dev/null @@ -1,46 +0,0 @@ -import Big from 'big.js'; - -import { maxSatoshi } from '../bitcoin/constants'; -import { Config } from '../config'; - -/** - * Converts a satoshis to a string representing the equivalent amount of BTC. - * - * @param sats - The number of satoshis to convert. - * @param withUnit - A boolean indicating whether to include the unit in the string representation. Default is false. - * @returns The equivalent amount of BTC as a string, fixed to 8 decimal places. - * @throws A Error if sats is not an integer. - */ -export function satsToBtc(sats: number | bigint, withUnit = false): string { - if (typeof sats === 'number' && !Number.isInteger(sats)) { - throw new Error('satsToBtc must be called on an integer number'); - } - const bigIntSat = new Big(sats); - const val = bigIntSat.div(100000000).toFixed(8); - - if (withUnit) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - return `${val} ${Config.unit}`; - } - return val; -} - -/** - * Converts a BTC to a bigint representing the equivalent amount of satoshis. - * - * @param btc - The amount of BTC to convert. - * @returns The equivalent amount of satoshis as a string, rounded to the nearest integer. - * @throws A Error if the BTC > max amount of satoshis (21 * 1e14) or the BTC < 0 or the BTC has more than 8 decimals. - */ -export function btcToSats(btc: string): bigint { - const stringVals = btc.split('.'); - if (stringVals.length > 1 && stringVals[1].length > 8) { - throw new Error('BTC amount is out of range'); - } - const bigIntBtc = new Big(btc); - const sats = bigIntBtc.times(100000000); - if (sats.lt(0) || sats.gt(maxSatoshi)) { - throw new Error('BTC amount is out of range'); - } - return BigInt(sats.toFixed(0)); -} diff --git a/merged-packages/bitcoin-wallet-snap/src/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/wallet.ts index 9dbc86b9..937fc141 100644 --- a/merged-packages/bitcoin-wallet-snap/src/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/wallet.ts @@ -3,7 +3,7 @@ import type { Buffer } from 'buffer'; export type Recipient = { address: string; - value: bigint; + value: number; }; export type Transaction = { @@ -15,49 +15,53 @@ export type Transaction = { * An interface that defines a `toJson` method for getting a JSON representation of a transaction info object. */ export type ITxInfo = { - sender: string; - change?: Recipient; - recipients: Recipient[]; - total: bigint; - txFee: bigint; - feeRate: bigint; + /** + * Returns a JSON representation of the transaction info object. + * + * @returns The JSON representation of the transaction info object. + */ + toJson>(): TxInfoJson; }; /** - * An interface that defines methods for unlocking accounts, signing transactions, and creating transactions. + * An interface that defines methods and properties for working with blockchain addresses. */ -export type IWallet = { +export type IAddress = { /** - * Unlocks an account by index and script type. - * - * @param index - The index to derive from the node. - * @param type - The script type of the unlocked account, e.g. `bip122:p2pkh`. - * @returns A promise that resolves to an `IAccount` object. + * The string value of the address. */ - unlock(index: number, type?: string): Promise; + value: string; /** - * Signs a transaction by the given encoded transaction string. + * Returns the string representation of the address. * - * @param signer - The `IAccountSigner` object to sign the transaction. - * @param transaction - The encoded transaction string to convert back to a transaction. - * @returns A promise that resolves to a string of the signed transaction. + * @param isShorten - A boolean indicating whether the address should be shortened. + * @returns The string representation of the address. */ - signTransaction(signer: IAccountSigner, transaction: string): Promise; + toString(isShorten?: boolean): string; +}; +/** + * An interface that defines properties for working with amounts of cryptocurrency. + */ +export type IAmount = { /** - * Creates a transaction using the given account, transaction intent, and options. + * The numeric value of the amount. + */ + value: number; + + /** + * The unit of the amount, e.g. "BTC" or "ETH". + */ + unit: string; + + /** + * Returns the string representation of the amount, with or without the unit. * - * @param account - The `IAccount` object to create the transaction. - * @param recipients - The transaction recipients. - * @param options - The options to use when creating the transaction. - * @returns A promise that resolves to an object containing the transaction hash and transaction info. + * @param withUnit - A boolean indicating whether to include the unit in the string representation. + * @returns The string representation of the amount. */ - createTransaction( - account: IAccount, - recipients: Recipient[], - options: Record, - ): Promise; + toString(withUnit?: boolean): string; }; /** @@ -94,6 +98,43 @@ export type IAccount = { signer: IAccountSigner; }; +/** + * An interface that defines methods for unlocking accounts, signing transactions, and creating transactions. + */ +export type IWallet = { + /** + * Unlocks an account by index and script type. + * + * @param index - The index to derive from the node. + * @param type - The script type of the unlocked account, e.g. `bip122:p2pkh`. + * @returns A promise that resolves to an `IAccount` object. + */ + unlock(index: number, type: string): Promise; + + /** + * Signs a transaction by the given encoded transaction string. + * + * @param signer - The `IAccountSigner` object to sign the transaction. + * @param transaction - The encoded transaction string to convert back to a transaction. + * @returns A promise that resolves to a string of the signed transaction. + */ + signTransaction(signer: IAccountSigner, transaction: string): Promise; + + /** + * Creates a transaction using the given account, transaction intent, and options. + * + * @param account - The `IAccount` object to create the transaction. + * @param recipients - The transaction recipients. + * @param options - The options to use when creating the transaction. + * @returns A promise that resolves to an object containing the transaction hash and transaction info. + */ + createTransaction( + account: IAccount, + recipients: Recipient[], + options: Record, + ): Promise; +}; + /** * An interface that defines methods and properties for signing transactions and verifying signatures. */ 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..4e5e1ce2 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockstream.json @@ -0,0 +1,220 @@ +{ + "getAccountStatsResp": { + "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 + } + }, + "getUtxoResp": [ + { + "txid": "bf9955de6df6d2b35aa301142ddeb85259f62dbdb5a5382ac7199b91bf6aa165", + "vout": 1, + "status": { + "confirmed": true, + "block_height": 2581760, + "block_hash": "000000000000000eba4e476fd2e7985221a2ae28ea2696c97bde455a4d40ec81", + "block_time": 1710329183 + }, + "value": 28019 + } + ], + "feeEstimateResp": { + "22": 52.373999999999995, + "6": 52.373999999999995, + "144": 46.793, + "504": 46.793, + "15": 52.373999999999995, + "19": 52.373999999999995, + "7": 52.373999999999995, + "21": 52.373999999999995, + "10": 55.343, + "3": 46.793, + "11": 55.343, + "24": 52.373999999999995, + "17": 52.373999999999995, + "5": 52.373999999999995, + "4": 52.375, + "1": 46.793, + "1008": 46.793, + "9": 52.373999999999995, + "12": 52.373999999999995, + "16": 52.373999999999995, + "18": 52.373999999999995, + "20": 52.373999999999995, + "2": 46.793, + "14": 52.373999999999995, + "13": 52.373999999999995, + "23": 52.373999999999995, + "25": 52.373999999999995, + "8": 52.373999999999995 + }, + "getLast10BlockResp": [ + { + "id": "000000000000000774b68e8353359959bb7243630a7c8994fbf7b8c53312b985", + "height": 2818504, + "version": 570884096, + "timestamp": 1716968525, + "tx_count": 6138, + "size": 2237729, + "weight": 3993176, + "merkle_root": "14cf491c608a3415b692570e286fb876936d7448a122d5fd64753d82f2a1cd4b", + "previousblockhash": "000000000000000953d3bd05c2692444ba9e9d8f81113c772ea1d2b2b82bafd9", + "mediantime": 1716968525, + "nonce": 4148296018, + "bits": 420164126, + "difficulty": 383618246.78880125 + }, + { + "id": "000000000000000953d3bd05c2692444ba9e9d8f81113c772ea1d2b2b82bafd9", + "height": 2818503, + "version": 641843200, + "timestamp": 1716968525, + "tx_count": 6296, + "size": 1931479, + "weight": 3992977, + "merkle_root": "89f7f7f2b096d04dc59182b02db2f43e5a4d2060947e3bb968d510cc25d66c1e", + "previousblockhash": "00000000000000094888e858eca8a2a2aac1624108a45bd4dceded927422e9d6", + "mediantime": 1716968524, + "nonce": 3737463343, + "bits": 420164126, + "difficulty": 383618246.78880125 + }, + { + "id": "00000000000000094888e858eca8a2a2aac1624108a45bd4dceded927422e9d6", + "height": 2818502, + "version": 677756928, + "timestamp": 1716968524, + "tx_count": 6191, + "size": 2033489, + "weight": 3992897, + "merkle_root": "5868743f6708e2102eadabceb85cb9ad31e1ed730dbd14c2f2b9771839e1abfa", + "previousblockhash": "000000004db90149f43d3a0c249e17953c7171a01c77868a2bf0781516142dc6", + "mediantime": 1716968524, + "nonce": 3766185333, + "bits": 420164126, + "difficulty": 383618246.78880125 + }, + { + "id": "000000004db90149f43d3a0c249e17953c7171a01c77868a2bf0781516142dc6", + "height": 2818501, + "version": 536870912, + "timestamp": 1716970927, + "tx_count": 9, + "size": 1871, + "weight": 5369, + "merkle_root": "3440666441c17245218a6c4b6c853505a94473e8955b3cca9945c6d74a9e5fba", + "previousblockhash": "00000000e5f036d8f93121377e83d4c6a7fcf1c0091c9e35da4f120149e3f857", + "mediantime": 1716968523, + "nonce": 1023899438, + "bits": 486604799, + "difficulty": 1.0 + }, + { + "id": "00000000e5f036d8f93121377e83d4c6a7fcf1c0091c9e35da4f120149e3f857", + "height": 2818500, + "version": 536870912, + "timestamp": 1716969726, + "tx_count": 2, + "size": 455, + "weight": 1508, + "merkle_root": "2d71a7cae3d8e76dc1601314f6c8cae0138fefd58d19d324048459c8181751c8", + "previousblockhash": "0000000040628f64b9310ac950b055234b75392944e7f143bdcf2c77c4a3399c", + "mediantime": 1716967324, + "nonce": 1017180888, + "bits": 486604799, + "difficulty": 1.0 + }, + { + "id": "0000000040628f64b9310ac950b055234b75392944e7f143bdcf2c77c4a3399c", + "height": 2818499, + "version": 536870912, + "timestamp": 1716968525, + "tx_count": 1, + "size": 250, + "weight": 892, + "merkle_root": "0ac6dc718de6c68eb0dae9b19a88494112e7e5c549dc94e0ea365b81d1517f41", + "previousblockhash": "000000009649cfb6a61dede6b96e40a664a0ceb99cda5ded6cf62a0c50b3f248", + "mediantime": 1716967323, + "nonce": 390285632, + "bits": 486604799, + "difficulty": 1.0 + }, + { + "id": "000000009649cfb6a61dede6b96e40a664a0ceb99cda5ded6cf62a0c50b3f248", + "height": 2818498, + "version": 536870912, + "timestamp": 1716967324, + "tx_count": 3, + "size": 660, + "weight": 2124, + "merkle_root": "1b7fe58a1b1be6c0bc3cf145da5f593962d1765dc114010e597dace157684cc8", + "previousblockhash": "0000000000000001e5f68dbe40f3364ebddf4f230ec9e49dd5eccd1813c8b257", + "mediantime": 1716967322, + "nonce": 4221530826, + "bits": 486604799, + "difficulty": 1.0 + }, + { + "id": "0000000000000001e5f68dbe40f3364ebddf4f230ec9e49dd5eccd1813c8b257", + "height": 2818497, + "version": 633970688, + "timestamp": 1716966123, + "tx_count": 6196, + "size": 2099705, + "weight": 3992492, + "merkle_root": "bb8c150aa19ee1e5580cc1925d06f18878a26f69fa8224f550dae07378c727a3", + "previousblockhash": "00000000caddc4212fe7617f933d4c954aa830d17343f57676fbd072eca68b9b", + "mediantime": 1716966123, + "nonce": 1829804593, + "bits": 420164126, + "difficulty": 383618246.78880125 + }, + { + "id": "00000000caddc4212fe7617f933d4c954aa830d17343f57676fbd072eca68b9b", + "height": 2818496, + "version": 536870912, + "timestamp": 1716969725, + "tx_count": 2, + "size": 473, + "weight": 1454, + "merkle_root": "2327409528477e7de65184b33f1e28b3b061c07b15bcd6cc40f03f969348afb5", + "previousblockhash": "00000000dd4d4b64c55d54edbdb3bc9634de0434f0d23292a5fa11511a8e6e90", + "mediantime": 1716966122, + "nonce": 4028091752, + "bits": 486604799, + "difficulty": 1.0 + }, + { + "id": "00000000dd4d4b64c55d54edbdb3bc9634de0434f0d23292a5fa11511a8e6e90", + "height": 2818495, + "version": 536870912, + "timestamp": 1716968524, + "tx_count": 1, + "size": 250, + "weight": 892, + "merkle_root": "70bc5770e60c093fa682dd860c2c4437aadf1276ed924d15036e7d6326f8189a", + "previousblockhash": "00000000aa597bac4c12b00b38ce4aec6547e028ad27811a7d1f4ac539051fa8", + "mediantime": 1716966121, + "nonce": 1607636480, + "bits": 486604799, + "difficulty": 1.0 + } + ], + "getTransactionStatus": { + "confirmed": true, + "block_height": 2817600, + "block_hash": "0000000000000008859508cb0ec5596e8d05363b0df010b9b02b7b4cd4dc261e", + "block_time": 1716623274 + } +} diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index c5c27fba..5152e881 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -5,10 +5,9 @@ import { Buffer } from 'buffer'; import ECPairFactory from 'ecpair'; import { v4 as uuidv4 } from 'uuid'; -import { Caip2ChainId } from '../src/constants'; +import { Network as NetworkEnum } from '../src/bitcoin/constants'; import blockChairData from './fixtures/blockchair.json'; - -/* eslint-disable */ +import blockStreamData from './fixtures/blockstream.json'; /** * Method to generate testing account. @@ -32,7 +31,7 @@ export function generateAccounts(cnt = 1, addressPrefix = '') { baseAddress.slice(0, baseAddress.length - i.toString().length) + i.toString(), options: { - scope: Caip2ChainId.Testnet, + scope: NetworkEnum.Testnet, index: i, }, methods: ['btc_sendmany'], @@ -201,6 +200,39 @@ export function createMockBip32Instance( } const randomNum = (max) => Math.floor(Math.random() * max); +/** + * Method to generate blockstream account stats resp by addresses. + * + * @param addresses - Array of address in string. + * @returns An array of blocksteam stats response. + */ +export function generateBlockStreamAccountStats(addresses: string[]) { + const template = blockStreamData.getAccountStatsResp; + const resp: (typeof template)[] = []; + for (const address of addresses) { + /* eslint-disable */ + resp.push({ + ...template, + address, + chain_stats: { + funded_txo_count: randomNum(100), + funded_txo_sum: Math.max(randomNum(1000000), 10000), + spent_txo_count: randomNum(100), + spent_txo_sum: randomNum(10000), + tx_count: randomNum(100), + }, + mempool_stats: { + funded_txo_count: randomNum(100), + funded_txo_sum: Math.max(randomNum(1000000), 10000), + spent_txo_count: randomNum(100), + spent_txo_sum: randomNum(10000), + tx_count: randomNum(100), + }, + }); + /* eslint-disable */ + } + return resp; +} /** * Method to generate blockchair getBalance resp by addresses. @@ -222,15 +254,13 @@ export function generateBlockChairGetBalanceResp(addresses: string[]) { * * @param address - address in string. * @param utxosCount - utxos count. - * @param minAmount - min amount of each utxo value. - * @param maxAmount - max amount of each utxo value. * @returns An array of blockchair getUtxos response. */ export function generateBlockChairGetUtxosResp( address: string, utxosCount: number, - minAmount = 0, - maxAmount = 1000000, + minAmount: number = 0, + maxAmount: number = 1000000, ) { const template = blockChairData.getUtxoResp; const data = { ...template.data.tb1qlq94vt9uh07fwunsgdyycpkv24uev05ywjua0r }; @@ -276,7 +306,54 @@ export function generateBlockChairGetStatsResp() { resp.data[key] = randomNum(value); } }); - resp.data.suggested_transaction_fee_per_byte_sat = randomNum(20); + resp.data['suggested_transaction_fee_per_byte_sat'] = randomNum(20); + return resp; +} + +/** + * Method to generate blockstream getUtxos resp. + * + * @param utxosCount - utxos count. + * @returns An array of blockstream getUtxos response. + */ +export function generateBlockStreamGetUtxosResp( + utxosCount: number, + confirmed: boolean = true, +) { + const template = blockStreamData.getUtxoResp; + let idx = -1; + const resp = Array.from({ length: utxosCount }, () => { + idx += 1; + return { + txid: randomNum(1000000) + .toString(16) + .padStart(template[0].txid.length, '0'), + vout: idx, + status: { + confirmed: confirmed, + block_height: randomNum(1000000), + block_hash: randomNum(1000000) + .toString(16) + .padStart(template[0].status.block_hash.length, '0'), + block_time: 1710329183, + }, + value: randomNum(1000000), + }; + }); + return resp; +} + +/** + * Method to generate blockstream estimate fee resp. + * + * @returns A blockstream estimate fee resp. + */ +export function generateBlockStreamEstFeeResp() { + const template = blockStreamData.feeEstimateResp; + const resp: typeof template = { ...template }; + Object.keys(template).forEach((key) => { + resp[key] = Math.min(0.1, randomNum(40)); + }); return resp; } @@ -291,13 +368,45 @@ export function generateBlockChairBroadcastTransactionResp() { return resp; } +/** + * Method to generate blockstream last 10 blocks resp. + * + * @param blockHeight - A start number for the block height of the resp. + * @returns A blockstream last 10 blocks resp. + */ +export function generateBlockStreamLast10BlockResp(blockHeight: number) { + const template = blockStreamData.getLast10BlockResp; + const resp: typeof template = { ...template }; + for (let i = 0; i < template.length; i++) { + resp[i].height = blockHeight - i; + } + return resp; +} + +/** + * Method to generate blockstream transaction status resp. + * + * @param blockHeight - Block height of the transaction. + * @param confirmed - Confirm status of the transaction. + * @returns A blockstream transaction status resp. + */ +export function generateBlockStreamTransactionStatusResp( + blockHeight: number, + confirmed: boolean, +) { + const template = blockStreamData.getTransactionStatus; + const resp: typeof template = { ...template }; + resp.block_height = blockHeight; + resp.confirmed = confirmed; + return resp; +} + /** * Method to generate blockchair transaction dashboards resp. * * @param txHash - Transaction hash of the transaction. * @param txnBlockHeight - Block height of the transaction. * @param txnBlockHeight - Block height of the last block. - * @param lastBlockHeight - Block height of the last block. * @param confirmed - Confirm status of the transaction. * @returns A blockchair transaction dashboards resp. */ @@ -332,8 +441,8 @@ export function generateBlockChairTransactionDashboard( * * @param address - the utxos owner address. * @param utxoCnt - count of the utox to be generated. - * @param minAmt - min amount of the utxo array. - * @param maxAmt - max amount of the utxo array. + * @param minAmount - min amount of each utxo value. + * @param maxAmount - max amount of each utxo value. * @returns An formatted utxo array. */ export function generateFormatedUtxos( @@ -356,5 +465,3 @@ export function generateFormatedUtxos( })); return formattedUtxos; } - -/* eslint-disable */ From 88ce7e556e6ac69e57e7f82d73a7b11aa923722a Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 4 Jul 2024 16:47:04 +0800 Subject: [PATCH 072/362] Revert "Update README.md" This reverts commit d29d0f19f95c740a498a84353e0eb968f591d2c7. --- merged-packages/bitcoin-wallet-snap/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index a42e75ad..2e75fbeb 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -84,7 +84,6 @@ provider.request({ ### API `keyring_getAccountBalances` example: - ```typescript provider.request({ method: 'wallet_invokeKeyring', @@ -95,7 +94,9 @@ provider.request({ params: { account: 'dc06350a-82db-434b-b113-066135804f63', // the uuid account id of the current account id: 'da40b782-e054-4260-9a6a-c8717a022f92', // an random uuid for this request - assets: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], // Caip-2 BTC Asset + assets: [ + 'bip122:000000000019d6689c085ae165831e93/slip44:0' + ], // Caip-2 BTC Asset }, }, }, From 617bad6ed1bf2737b7f79090b6e3e16921091847 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 4 Jul 2024 16:47:04 +0800 Subject: [PATCH 073/362] Revert "Update README.md (#102)" This reverts commit 09e1acf4f445efd6f17d085236c46f63aeaf63f3. --- merged-packages/bitcoin-wallet-snap/README.md | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index 2e75fbeb..54daf78e 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -80,25 +80,3 @@ provider.request({ }, }); ``` - -### API `keyring_getAccountBalances` - -example: -```typescript -provider.request({ - method: 'wallet_invokeKeyring', - params: { - snapId, - request: { - method: 'keyring_getAccountBalances', - params: { - account: 'dc06350a-82db-434b-b113-066135804f63', // the uuid account id of the current account - id: 'da40b782-e054-4260-9a6a-c8717a022f92', // an random uuid for this request - assets: [ - 'bip122:000000000019d6689c085ae165831e93/slip44:0' - ], // Caip-2 BTC Asset - }, - }, - }, -}); -``` From a08f97dba8b951c2b38adff9f46e4c38a0796d48 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 4 Jul 2024 16:47:04 +0800 Subject: [PATCH 074/362] Revert "chore: refinement batch2 (#101)" This reverts commit c81bc84a7d55c543d898d0f204d544d83c3360aa. --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/bitcoin/utils/address.test.ts | 21 ----- .../src/bitcoin/utils/address.ts | 17 ---- .../src/bitcoin/wallet/account.ts | 6 -- .../src/bitcoin/wallet/coin-select.test.ts | 39 +++++--- .../src/bitcoin/wallet/coin-select.ts | 15 ++- .../src/bitcoin/wallet/psbt.test.ts | 9 +- .../src/bitcoin/wallet/psbt.ts | 6 +- .../bitcoin/wallet/transaction-info.test.ts | 5 +- .../bitcoin/wallet/transaction-input.test.ts | 3 +- .../src/bitcoin/wallet/transaction-input.ts | 22 +++-- .../bitcoin/wallet/transaction-output.test.ts | 4 +- .../src/bitcoin/wallet/transaction-output.ts | 4 +- .../src/bitcoin/wallet/types.ts | 2 - .../src/bitcoin/wallet/wallet.test.ts | 43 ++++++++- .../src/bitcoin/wallet/wallet.ts | 93 +++++++------------ .../bitcoin-wallet-snap/src/factory.ts | 4 +- .../src/keyring/keyring.ts | 2 +- .../bitcoin-wallet-snap/src/keyring/types.ts | 2 +- .../bitcoin-wallet-snap/src/libs/rpc/base.ts | 10 +- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 6 +- .../bitcoin-wallet-snap/src/wallet.ts | 4 +- 22 files changed, 156 insertions(+), 163 deletions(-) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index b3a96b45..cb36df1b 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "gycY0HZ56I1PRIdcUPm4dYlATHJxoWWivbMHJddftK0=", + "shasum": "k2VftTYTbMrhIZSwAGQb91tdJAF/lprzhf+XXuUuHk4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts deleted file mode 100644 index d6d083e6..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { networks } from 'bitcoinjs-lib'; -import { Buffer } from 'buffer'; - -import { getScriptForDestnation } from './address'; - -describe('address', () => { - const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; - - describe('getScriptForDestnation', () => { - it('returns a script', () => { - const val = getScriptForDestnation(address, networks.testnet); - expect(val).toBeInstanceOf(Buffer); - }); - - it('throws `Destnation address has no matching Script` error if the given address is invalid', () => { - expect(() => - getScriptForDestnation('bad-address', networks.testnet), - ).toThrow(`Destnation address has no matching Script`); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts deleted file mode 100644 index e36946dd..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/utils/address.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { address as addressUtils, type Network } from 'bitcoinjs-lib'; - -/** - * Returns the script for a Bitcoin destination address. - * - * @param address - The Bitcoin destination address. - * @param network - The Bitcoin network. - * @returns The Bitcoin script for the destination address. - * @throws An error if the address does not have a matching script. - */ -export function getScriptForDestnation(address: string, network: Network) { - try { - return addressUtils.toOutputScript(address, network); - } catch (error) { - throw new Error('Destnation address has no matching Script'); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts index 8c83a93d..dc101f23 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts @@ -1,5 +1,4 @@ import type { Network, Payment } from 'bitcoinjs-lib'; -import type { Buffer } from 'buffer'; import type { StaticImplements } from '../../types/static'; import { hexToBuffer } from '../../utils'; @@ -49,11 +48,6 @@ export class BtcAccount implements IBtcAccount { this.type = type; } - get script(): Buffer { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this.payment.output!; - } - get address(): string { if (!this.#address) { if (!this.payment.address) { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts index 5f009fa7..af4718ef 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts @@ -1,4 +1,4 @@ -import { networks, address as addressUtils } from 'bitcoinjs-lib'; +import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { ScriptType } from '../constants'; @@ -34,15 +34,12 @@ describe('CoinSelectService', () => { const utxos = generateFormatedUtxos(sender.address, 2, inputMin, inputMax); - const outputs = [ - new TxOutput( - outputVal, - receiver1.address, - addressUtils.toOutputScript(receiver1.address, network), - ), - ]; + const outputs = [new TxOutput(outputVal, receiver1.address)]; - const inputs = utxos.map((utxo) => new TxInput(utxo, sender.script)); + const inputs = utxos.map( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (utxo) => new TxInput(utxo, sender.payment.output!), + ); return { sender, @@ -64,7 +61,7 @@ describe('CoinSelectService', () => { const result = coinSelectService.selectCoins( inputs, outputs, - new TxOutput(0, sender.address, sender.script), + new TxOutput(0, sender.address), ); expect(result.fee).toBeGreaterThan(1); @@ -73,6 +70,26 @@ describe('CoinSelectService', () => { expect(result.outputs.length).toBeGreaterThan(0); }); + it('converts output to TxOutput', async () => { + const network = networks.testnet; + const { inputs, outputs, sender } = await prepareCoinSlect(network); + + const coinSelectService = new CoinSelectService(1); + + const result = coinSelectService.selectCoins( + inputs, + outputs.map((output) => ({ + address: output.address, + value: output.value, + })), + new TxOutput(0, sender.address), + ); + + for (const output of result.outputs) { + expect(output).toBeInstanceOf(TxOutput); + } + }); + it('throws `Insufficient funds` error if the given utxos is not sufficient', async () => { const network = networks.testnet; const { inputs, outputs, sender } = await prepareCoinSlect( @@ -88,7 +105,7 @@ describe('CoinSelectService', () => { coinSelectService.selectCoins( inputs, outputs, - new TxOutput(0, sender.address, sender.script), + new TxOutput(0, sender.address), ), ).toThrow('Insufficient funds'); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index a6506849..c23a3f64 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -1,12 +1,13 @@ import coinSelect from 'coinselect'; +import type { Recipient } from '../../wallet'; import { TxValidationError } from './exceptions'; import type { TxInput } from './transaction-input'; -import type { TxOutput } from './transaction-output'; +import { TxOutput } from './transaction-output'; import { type SelectionResult } from './types'; export class CoinSelectService { - protected readonly _feeRate: number; + protected _feeRate: number; constructor(feeRate: number) { this._feeRate = Math.round(feeRate); @@ -14,7 +15,7 @@ export class CoinSelectService { selectCoins( inputs: TxInput[], - recipients: TxOutput[], + recipients: Recipient[] | TxOutput[], changeTo: TxOutput, ): SelectionResult { const result = coinSelect(inputs, recipients, this._feeRate); @@ -32,7 +33,13 @@ export class CoinSelectService { // restructure outputs to avoid depends on coinselect output format for (const output of result.outputs) { if (output.address) { - selectedResult.outputs.push(output); + if (output instanceof TxOutput) { + selectedResult.outputs.push(output); + } else { + selectedResult.outputs.push( + new TxOutput(output.value, output.address), + ); + } } else { changeTo.value = output.value; selectedResult.change = changeTo; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts index 17444791..70df4e16 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts @@ -47,7 +47,7 @@ describe('PsbtService', () => { const fee = 500; const outputs = receivers.map( - (account) => new TxOutput(outputVal, account.address, account.script), + (account) => new TxOutput(outputVal, account.address), ); const utxos = generateFormatedUtxos( sender.address, @@ -55,7 +55,10 @@ describe('PsbtService', () => { outputVal * outputs.length + fee, outputVal * outputs.length + fee, ); - const inputs = utxos.map((utxo) => new TxInput(utxo, sender.script)); + const inputs = utxos.map( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (utxo) => new TxInput(utxo, sender.payment.output!), + ); service.addOutputs(outputs); @@ -180,7 +183,7 @@ describe('PsbtService', () => { for (let i = 0; i < service.psbt.txOutputs.length; i++) { expect(service.psbt.txOutputs[i]).toHaveProperty( 'script', - receivers[i].script, + receivers[i].payment.output, ); expect(service.psbt.txOutputs[i]).toHaveProperty( 'value', diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index f4638e39..67fe764e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -15,9 +15,9 @@ import type { TxOutput } from './transaction-output'; const ECPair = ECPairFactory(ecc); export class PsbtService { - protected readonly _psbt: Psbt; + protected _psbt: Psbt; - protected readonly _network: Network; + protected _network: Network; get psbt() { return this._psbt; @@ -104,7 +104,7 @@ export class PsbtService { addOutput(output: TxOutput) { try { this._psbt.addOutput({ - script: output.script, + address: output.address, value: output.value, }); } catch (error) { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts index c1b15f32..4c553e10 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts @@ -1,5 +1,4 @@ import { networks } from 'bitcoinjs-lib'; -import { Buffer } from 'buffer'; import { generateAccounts } from '../../../test/utils'; import { getCaip2ChainId, getExplorerUrl, satsToBtc } from '../utils'; @@ -25,13 +24,13 @@ describe('BtcTxInfo', () => { for (let i = 1; i < addresses.length; i++) { total += 100000; - const output = new TxOutput(100000, addresses[i], Buffer.from('dummy')); + const output = new TxOutput(100000, addresses[i]); outputs.push(output); } info.addRecipients(outputs); - info.change = new TxOutput(500, addresses[0], Buffer.from('dummy')); + info.change = new TxOutput(500, addresses[0]); total += 500; const expectedRecipients = outputs.map((recipient) => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts index 1f0877b6..5dbe7061 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts @@ -1,4 +1,5 @@ import { networks } from 'bitcoinjs-lib'; +import type { Buffer } from 'buffer'; import { generateFormatedUtxos } from '../../../test/utils'; import { ScriptType } from '../constants'; @@ -22,7 +23,7 @@ describe('TxInput', () => { it('return correct property', async () => { const wallet = createMockWallet(networks.testnet); const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); - const { script } = account; + const script = account.payment.output as unknown as Buffer; const utxo = generateFormatedUtxos(account.address, 1)[0]; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts index 504fd251..c8da508d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts @@ -10,22 +10,28 @@ export class TxInput { readonly amount: IAmount; - readonly txHash: string; - - readonly index: number; - - readonly block: number; + readonly utxo: Utxo; constructor(utxo: Utxo, script: Buffer) { this.script = script; + this.utxo = utxo; this.amount = new BtcAmount(utxo.value); - this.index = utxo.index; - this.txHash = utxo.txHash; - this.block = utxo.block; } // consume by coinselect get value(): number { return this.amount.value; } + + get txHash(): string { + return this.utxo.txHash; + } + + get index(): number { + return this.utxo.index; + } + + get block(): number { + return this.utxo.block; + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts index ea20d13f..2ae196b7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts @@ -1,5 +1,3 @@ -import { Buffer } from 'buffer'; - import { generateAccounts } from '../../../test/utils'; import { TxOutput } from './transaction-output'; @@ -7,7 +5,7 @@ describe('TxOutput', () => { it('return correct property', () => { const account = generateAccounts(1)[0]; - const input = new TxOutput(10, account.address, Buffer.from('dummy')); + const input = new TxOutput(10, account.address); expect(input.value).toBe(10); expect(input.address).toStrictEqual(account.address); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts index 298a7fca..772a9ff6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts @@ -8,11 +8,11 @@ export class TxOutput { readonly amount: IAmount; // consume by conselect - readonly script: Buffer; + readonly script?: Buffer; readonly destination: IAddress; - constructor(value: number, address: string, script: Buffer) { + constructor(value: number, address: string, script?: Buffer) { this.amount = new BtcAmount(value); this.destination = new BtcAddress(address); this.script = script; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts index 4db8fff1..549caf2d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts @@ -1,6 +1,5 @@ import type { BIP32Interface } from 'bip32'; import type { Network, Payment } from 'bitcoinjs-lib'; -import type { Buffer } from 'buffer'; import type { IAccount, IAccountSigner } from '../../wallet'; import type { ScriptType } from '../constants'; @@ -16,7 +15,6 @@ export type IBtcAccount = IAccount & { payment: Payment; scriptType: ScriptType; network: Network; - script: Buffer; }; export type IStaticBtcAccount = { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index 8303ad3b..057d9fd6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -1,5 +1,5 @@ import type { Json } from '@metamask/snaps-sdk'; -import { networks } from 'bitcoinjs-lib'; +import { address as addressUtils, networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { DustLimit, ScriptType } from '../constants'; @@ -210,11 +210,19 @@ describe('BtcWallet', () => { change: new TxOutput( DustLimit[chgAccount.scriptType] - 1, chgAccount.address, - chgAccount.script, ), fee: 100, - inputs: utxos.map((utxo) => new TxInput(utxo, chgAccount.script)), - outputs: [new TxOutput(500, recipient.address, recipient.script)], + inputs: utxos.map( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (utxo) => new TxInput(utxo, chgAccount.payment.output!), + ), + outputs: [ + new TxOutput( + 500, + recipient.address, + addressUtils.toOutputScript(recipient.address, network), + ), + ], }; coinSelectServiceSpy.mockReturnValue(selectionResult); @@ -236,7 +244,7 @@ describe('BtcWallet', () => { expect(info.change).toBeUndefined(); }); - it('throws `Transaction amount too small` error if the transaction output is too small', async () => { + it('throws `Transaction amount too small` error the transaction output is too small', async () => { const network = networks.testnet; const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); @@ -256,6 +264,31 @@ describe('BtcWallet', () => { ), ).rejects.toThrow('Transaction amount too small'); }); + + it('throws `Fail to get account script hash` error if the account script hash is undefined', async () => { + const network = networks.testnet; + const { instance } = createMockDeriver(network); + const wallet = new BtcWallet(instance, network); + const account = await wallet.unlock(0, ScriptType.P2wpkh); + const utxos = generateFormatedUtxos(account.address, 2); + account.payment.output = undefined; + + await expect( + wallet.createTransaction( + account, + createMockTxIndent( + account.address, + DustLimit[account.scriptType] + 1, + ), + { + utxos, + fee: 1, + subtractFeeFrom: [], + replaceable: false, + }, + ), + ).rejects.toThrow('Fail to get account script hash'); + }); }); describe('signTransaction', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index a2647208..75157aa5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -1,5 +1,5 @@ import type { BIP32Interface } from 'bip32'; -import { type Network } from 'bitcoinjs-lib'; +import { type Network, address } from 'bitcoinjs-lib'; import { logger } from '../../libs/logger/logger'; import { bufferToString, compactError, hexToBuffer } from '../../utils'; @@ -7,11 +7,10 @@ import type { IAccountSigner, IWallet, Recipient, - Transaction, + TxCreationResult, } from '../../wallet'; import { ScriptType } from '../constants'; import { isDust } from '../utils'; -import { getScriptForDestnation } from '../utils/address'; import { P2WPKHAccount, P2SHP2WPKHAccount } from './account'; import { BtcAddress } from './address'; import { CoinSelectService } from './coin-select'; @@ -38,6 +37,21 @@ export class BtcWallet implements IWallet { this._network = network; } + protected getAccountCtor(type: string): IStaticBtcAccount { + let scriptType = type; + if (type.includes('bip122:')) { + scriptType = type.split(':')[1]; + } + switch (scriptType.toLowerCase()) { + case ScriptType.P2wpkh.toLowerCase(): + return P2WPKHAccount; + case ScriptType.P2shP2wkh.toLowerCase(): + return P2SHP2WPKHAccount; + default: + throw new WalletError('Invalid script type'); + } + } + async unlock(index: number, type: string): Promise { try { const AccountCtor = this.getAccountCtor(type); @@ -64,59 +78,36 @@ export class BtcWallet implements IWallet { account: IBtcAccount, recipients: Recipient[], options: CreateTransactionOptions, - ): Promise { - const scriptOutput = account.script; + ): Promise { + const scriptOutput = account.payment.output; const { scriptType } = account; - logger.info( - JSON.stringify( - { - recipients, - options, - }, - null, - 2, - ), - ); + if (!scriptOutput) { + throw new WalletError('Fail to get account script hash'); + } - // TODO: Supporting getting coins from other address (dynamic address) const inputs = options.utxos.map((utxo) => new TxInput(utxo, scriptOutput)); - const outputs = recipients.map((recipient) => { - if (isDust(recipient.value, scriptType)) { - throw new TxValidationError('Transaction amount too small'); - } - const destnationScriptOutput = getScriptForDestnation( - recipient.address, - this._network, - ); - return new TxOutput( - recipient.value, - recipient.address, - destnationScriptOutput, - ); - }); + const outputs = recipients.map( + (recipient) => + new TxOutput( + recipient.value, + recipient.address, + address.toOutputScript(recipient.address, this._network), + ), + ); - // Do not ever accept zero fee rate, we need to ensure it is at least 1 + // as fee rate can be 0, we need to ensure it is at least 1 // TODO: The min fee rate should be setting from parameter const feeRate = Math.max(1, options.fee); const coinSelectService = new CoinSelectService(feeRate); - const change = new TxOutput(0, account.address, scriptOutput); + const change = new TxOutput(0, account.address); const selectionResult = coinSelectService.selectCoins( inputs, outputs, change, ); - logger.info( - JSON.stringify( - { - feeRate, - ...selectionResult, - }, - null, - 2, - ), - ); + logger.info(JSON.stringify(selectionResult, null, 2)); const txInfo = new BtcTxInfo( new BtcAddress(account.address), @@ -135,6 +126,9 @@ export class BtcWallet implements IWallet { // TODO: add support of subtractFeeFrom, and throw error if output is too small after subtraction for (const output of selectionResult.outputs) { + if (isDust(output.value, scriptType)) { + throw new TxValidationError('Transaction amount too small'); + } psbtService.addOutput(output); txInfo.addRecipient(output); } @@ -169,19 +163,4 @@ export class BtcWallet implements IWallet { protected getHdSigner(rootNode: BIP32Interface) { return new AccountSigner(rootNode, rootNode.fingerprint); } - - protected getAccountCtor(type: string): IStaticBtcAccount { - let scriptType = type; - if (type.includes('bip122:')) { - scriptType = type.split(':')[1]; - } - switch (scriptType.toLowerCase()) { - case ScriptType.P2wpkh.toLowerCase(): - return P2WPKHAccount; - case ScriptType.P2shP2wkh.toLowerCase(): - return P2SHP2WPKHAccount; - default: - throw new WalletError('Invalid script type'); - } - } } diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts index ad536089..f6974caa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.ts @@ -13,7 +13,7 @@ import { Config } from './config'; import { BtcKeyring, KeyringStateManager } from './keyring'; import type { IWallet } from './wallet'; -// TODO: Remove temp solution to support keyring in snap without keyring API +// TODO: Temp solution to support keyring in snap without keyring API export type CreateBtcKeyringOptions = { emitEvents: boolean; }; @@ -43,7 +43,7 @@ export class Factory { return new BtcKeyring(new KeyringStateManager(), { defaultIndex: config.defaultAccountIndex, multiAccount: config.enableMultiAccounts, - // TODO: Remove temp solution to support keyring in snap without keyring API + // TODO: Temp solution to support keyring in snap without keyring API emitEvents: options.emitEvents, }); } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts index 74a1c60c..a59d337e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts @@ -184,7 +184,7 @@ export class BtcKeyring implements Keyring { event: KeyringEvent, data: Record, ): Promise { - // TODO: Remove temp solution to support keyring in snap without extentions support + // TODO: Temp solution to support keyring in snap without extentions support if (this._options.emitEvents) { await emitSnapKeyringEvent(SnapHelper.provider, event, data); } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts index ed9e02c1..acae60e9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/types.ts @@ -23,7 +23,7 @@ export type SnapState = { export type KeyringOptions = Record & { defaultIndex: number; multiAccount?: boolean; - // TODO: Remove temp solution to support keyring in snap without keyring API + // TODO: Temp solutio to support keyring in snap without keyring API emitEvents?: boolean; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts index 22f57026..656bf7fe 100644 --- a/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts +++ b/merged-packages/bitcoin-wallet-snap/src/libs/rpc/base.ts @@ -25,11 +25,11 @@ export abstract class BaseSnapRpcHandler { params: SnapRpcHandlerRequest, ): Promise; - protected get _requestStruct(): Struct { + protected get requestStruct(): Struct { return (this.constructor as typeof BaseSnapRpcHandler).requestStruct; } - protected get _responseStruct(): Struct | undefined { + protected get responseStruct(): Struct | undefined { return (this.constructor as typeof BaseSnapRpcHandler).responseStruct; } @@ -38,7 +38,7 @@ export abstract class BaseSnapRpcHandler { `[SnapRpcHandler.preExecute] Request: ${JSON.stringify(params)}`, ); try { - assert(params, this._requestStruct); + assert(params, this.requestStruct); } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions logger.info(`[SnapRpcHandler.preExecute] Error: ${error.message}`); @@ -52,8 +52,8 @@ export abstract class BaseSnapRpcHandler { ); try { - if (this._responseStruct) { - assert(response, this._responseStruct); + if (this.responseStruct) { + assert(response, this.responseStruct); } } catch (error) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index ba68876b..046c1046 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -46,11 +46,7 @@ export const TransactionAmountStuct = refine( for (const val of Object.values(value)) { const parsedVal = parseFloat(val); - if ( - Number.isNaN(parsedVal) || - parsedVal <= 0 || - !Number.isFinite(parsedVal) - ) { + if (Number.isNaN(parsedVal) || parsedVal <= 0) { return 'Invalid amount for send'; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/wallet.ts index 937fc141..8cc5c0b9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/wallet.ts @@ -6,7 +6,7 @@ export type Recipient = { value: number; }; -export type Transaction = { +export type TxCreationResult = { tx: string; txInfo: ITxInfo; }; @@ -132,7 +132,7 @@ export type IWallet = { account: IAccount, recipients: Recipient[], options: Record, - ): Promise; + ): Promise; }; /** From 302b75b83c21e56773b7fa7aab53954f869939b2 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 4 Jul 2024 16:47:04 +0800 Subject: [PATCH 075/362] Revert "chore: update protected property" This reverts commit 086a3e0b1fb3dff3371d3b1cbe33b70822ddf61f. --- .../src/bitcoin/chain/service.ts | 24 +++++------ .../bitcoin/data-client/clients/blockchair.ts | 10 ++--- .../data-client/clients/blockstream.ts | 16 +++---- .../src/bitcoin/wallet/coin-select.ts | 6 +-- .../src/bitcoin/wallet/deriver.ts | 16 +++---- .../src/bitcoin/wallet/factory.test.ts | 2 +- .../src/bitcoin/wallet/psbt.ts | 34 +++++++-------- .../src/bitcoin/wallet/signer.ts | 14 +++---- .../src/bitcoin/wallet/transaction-info.ts | 40 +++++++++--------- .../src/bitcoin/wallet/wallet.ts | 22 +++++----- .../src/keyring/keyring.ts | 42 +++++++++---------- 11 files changed, 111 insertions(+), 115 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index 08b908a8..62ac91e5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -19,24 +19,24 @@ import { BtcOnChainServiceError } from './exceptions'; import type { BtcOnChainServiceOptions } from './types'; export class BtcOnChainService implements IOnChainService { - protected readonly _readClient: IReadDataClient; + protected readonly readClient: IReadDataClient; - protected readonly _writeClient: IWriteDataClient; + protected readonly writeClient: IWriteDataClient; - protected readonly _options: BtcOnChainServiceOptions; + protected readonly options: BtcOnChainServiceOptions; constructor( readClient: IReadDataClient, writeClient: IWriteDataClient, options: BtcOnChainServiceOptions, ) { - this._readClient = readClient; - this._writeClient = writeClient; - this._options = options; + this.readClient = readClient; + this.writeClient = writeClient; + this.options = options; } get network(): Network { - return this._options.network; + return this.options.network; } async getBalances( @@ -58,7 +58,7 @@ export class BtcOnChainService implements IOnChainService { throw new BtcOnChainServiceError('Invalid asset'); } - const balance: Balances = await this._readClient.getBalances(addresses); + const balance: Balances = await this.readClient.getBalances(addresses); return addresses.reduce( (acc: AssetBalances, address: string) => { @@ -78,7 +78,7 @@ export class BtcOnChainService implements IOnChainService { async getFeeRates(): Promise { try { - const result = await this._readClient.getFeeRates(); + const result = await this.readClient.getFeeRates(); return { fees: Object.entries(result).map( @@ -95,7 +95,7 @@ export class BtcOnChainService implements IOnChainService { async getTransactionStatus(txHash: string) { try { - return await this._readClient.getTransactionStatus(txHash); + return await this.readClient.getTransactionStatus(txHash); } catch (error) { throw new BtcOnChainServiceError(error); } @@ -107,7 +107,7 @@ export class BtcOnChainService implements IOnChainService { transactionIntent?: TransactionIntent, ): Promise { try { - const data = await this._readClient.getUtxos(address); + const data = await this.readClient.getUtxos(address); return { data: { utxos: data, @@ -122,7 +122,7 @@ export class BtcOnChainService implements IOnChainService { signedTransaction: string, ): Promise { try { - const transactionId = await this._writeClient.sendTransaction( + const transactionId = await this.writeClient.sendTransaction( signedTransaction, ); return { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts index df958fac..7eb9a348 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockchair.ts @@ -205,14 +205,14 @@ export type PostTransactionResponse = { /* eslint-disable */ export class BlockChairClient implements IReadDataClient, IWriteDataClient { - protected readonly _options: BlockChairClientOptions; + options: BlockChairClientOptions; constructor(options: BlockChairClientOptions) { - this._options = options; + this.options = options; } get baseUrl(): string { - switch (this._options.network) { + switch (this.options.network) { case networks.bitcoin: return 'https://api.blockchair.com/bitcoin'; case networks.testnet: @@ -225,8 +225,8 @@ export class BlockChairClient implements IReadDataClient, IWriteDataClient { protected getApiUrl(endpoint: string): string { const url = new URL(`${this.baseUrl}${endpoint}`); // TODO: Update to proxy - if (this._options.apiKey) { - url.searchParams.append('key', this._options.apiKey); + if (this.options.apiKey) { + url.searchParams.append('key', this.options.apiKey); } return url.toString(); } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts index 4b8b155b..227c6cb9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/data-client/clients/blockstream.ts @@ -71,13 +71,13 @@ export type GetBlocksResponse = { /* eslint-enable */ export class BlockStreamClient implements IReadDataClient { - protected readonly _options: BlockStreamClientOptions; + protected readonly options: BlockStreamClientOptions; - protected readonly _feeRateRatioMap: Record; + protected readonly feeRateRatioMap: Record; constructor(options: BlockStreamClientOptions) { - this._options = options; - this._feeRateRatioMap = { + this.options = options; + this.feeRateRatioMap = { [FeeRatio.Fast]: '1', [FeeRatio.Medium]: '144', [FeeRatio.Slow]: '144', @@ -85,7 +85,7 @@ export class BlockStreamClient implements IReadDataClient { } get baseUrl(): string { - switch (this._options.network) { + switch (this.options.network) { case networks.bitcoin: return 'https://blockstream.info/api'; case networks.testnet: @@ -184,13 +184,13 @@ export class BlockStreamClient implements IReadDataClient { ); return { [FeeRatio.Fast]: Math.round( - response[this._feeRateRatioMap[FeeRatio.Fast]], + response[this.feeRateRatioMap[FeeRatio.Fast]], ), [FeeRatio.Medium]: Math.round( - response[this._feeRateRatioMap[FeeRatio.Medium]], + response[this.feeRateRatioMap[FeeRatio.Medium]], ), [FeeRatio.Slow]: Math.round( - response[this._feeRateRatioMap[FeeRatio.Slow]], + response[this.feeRateRatioMap[FeeRatio.Slow]], ), }; } catch (error) { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index c23a3f64..ade36a3a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -7,10 +7,10 @@ import { TxOutput } from './transaction-output'; import { type SelectionResult } from './types'; export class CoinSelectService { - protected _feeRate: number; + #feeRate: number; constructor(feeRate: number) { - this._feeRate = Math.round(feeRate); + this.#feeRate = Math.round(feeRate); } selectCoins( @@ -18,7 +18,7 @@ export class CoinSelectService { recipients: Recipient[] | TxOutput[], changeTo: TxOutput, ): SelectionResult { - const result = coinSelect(inputs, recipients, this._feeRate); + const result = coinSelect(inputs, recipients, this.#feeRate); if (!result.inputs || !result.outputs) { throw new TxValidationError('Insufficient funds'); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts index bd245801..cd80ad4f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts @@ -10,20 +10,20 @@ import { DeriverError } from './exceptions'; import type { IBtcAccountDeriver } from './types'; export abstract class BtcAccountDeriver implements IBtcAccountDeriver { - protected readonly _network: Network; + protected readonly network: Network; - protected readonly _bip32Api: BIP32API; + protected readonly bip32Api: BIP32API; constructor(network: Network) { - this._bip32Api = BIP32Factory(ecc); - this._network = network; + this.bip32Api = BIP32Factory(ecc); + this.network = network; } abstract getRoot(path: string[]): Promise; createBip32FromSeed(seed: Buffer): BIP32Interface { try { - return this._bip32Api.fromSeed(seed, this._network); + return this.bip32Api.fromSeed(seed, this.network); } catch (error) { throw new DeriverError('Unable to construct BIP32 node from seed'); } @@ -34,11 +34,7 @@ export abstract class BtcAccountDeriver implements IBtcAccountDeriver { chainNode: Buffer, ): BIP32Interface { try { - return this._bip32Api.fromPrivateKey( - privateKey, - chainNode, - this._network, - ); + return this.bip32Api.fromPrivateKey(privateKey, chainNode, this.network); } catch (error) { throw new DeriverError('Unable to construct BIP32 node from private key'); } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts index 43f76ce9..50f0a2e5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/factory.test.ts @@ -10,7 +10,7 @@ import * as manager from './wallet'; describe('BtcWalletFactory', () => { class MockBtcWallet extends manager.BtcWallet { getDeriver() { - return this._deriver; + return this.deriver; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index 67fe764e..d1bae8c2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -15,21 +15,21 @@ import type { TxOutput } from './transaction-output'; const ECPair = ECPairFactory(ecc); export class PsbtService { - protected _psbt: Psbt; + #psbt: Psbt; - protected _network: Network; + #network: Network; get psbt() { - return this._psbt; + return this.#psbt; } constructor(network: Network, psbt?: Psbt) { if (psbt === undefined) { - this._psbt = new Psbt({ network }); + this.#psbt = new Psbt({ network }); } else { - this._psbt = psbt; + this.#psbt = psbt; } - this._network = network; + this.#network = network; } static fromBase64(network: Network, base64Psbt: string): PsbtService { @@ -46,7 +46,7 @@ export class PsbtService { changeAddressMfp: Buffer, ) { try { - this._psbt.addInput({ + this.#psbt.addInput({ hash: input.txHash, index: input.index, witnessUtxo: { @@ -103,7 +103,7 @@ export class PsbtService { addOutput(output: TxOutput) { try { - this._psbt.addOutput({ + this.#psbt.addOutput({ address: output.address, value: output.value, }); @@ -126,7 +126,7 @@ export class PsbtService { getFee(): number { try { - return this._psbt.getFee(); + return this.#psbt.getFee(); } catch (error) { logger.error('Failed to get fee', error); throw new PsbtServiceError('Failed to get fee from PSBT'); @@ -135,10 +135,10 @@ export class PsbtService { async signDummy(signer: IAccountSigner): Promise { try { - const psbt = this._psbt.clone(); + const psbt = this.#psbt.clone(); await psbt.signAllInputsHDAsync(signer); psbt.finalizeAllInputs(); - return new PsbtService(this._network, psbt); + return new PsbtService(this.#network, psbt); } catch (error) { logger.error('Failed to sign dummy', error); throw new PsbtServiceError('Failed to sign dummy in PSBT'); @@ -147,7 +147,7 @@ export class PsbtService { toBase64(): string { try { - return this._psbt.toBase64(); + return this.#psbt.toBase64(); } catch (error) { logger.error('Failed to convert to base64', error); throw new PsbtServiceError('Failed to output PSBT string'); @@ -159,10 +159,10 @@ export class PsbtService { // This function signAllInputsHDAsync is used to sign all inputs with the signer. // When using the method signAllInputsHDAsync, it is important to note that the signer must derive from the root node as well as the finderprint. // For further reference, please see the getHdSigner method in BtcWallet. - await this._psbt.signAllInputsHDAsync(signer); + await this.#psbt.signAllInputsHDAsync(signer); if ( - !this._psbt.validateSignaturesOfAllInputs( + !this.#psbt.validateSignaturesOfAllInputs( (pubkey: Buffer, msghash: Buffer, signature: Buffer) => this.validateInputs(pubkey, msghash, signature), ) @@ -178,11 +178,11 @@ export class PsbtService { finalize(): string { try { - this._psbt.finalizeAllInputs(); + this.#psbt.finalizeAllInputs(); - const txHex = this._psbt.extractTransaction().toHex(); + const txHex = this.#psbt.extractTransaction().toHex(); - const weight = this._psbt.extractTransaction().weight(); + const weight = this.#psbt.extractTransaction().weight(); if (weight > MaxStandardTxWeight) { throw new TxValidationError('Transaction is too large'); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts index 53ee12d5..72bb4e8e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts @@ -9,12 +9,12 @@ export class AccountSigner implements HDSignerAsync, IAccountSigner { readonly fingerprint: Buffer; - protected readonly _node: BIP32Interface; + protected readonly node: BIP32Interface; constructor(accountNode: BIP32Interface, mfp?: Buffer) { - this._node = accountNode; - this.publicKey = this._node.publicKey; - this.fingerprint = mfp ?? this._node.fingerprint; + this.node = accountNode; + this.publicKey = this.node.publicKey; + this.fingerprint = mfp ?? this.node.fingerprint; } derivePath(path: string): IAccountSigner { @@ -31,7 +31,7 @@ export class AccountSigner implements HDSignerAsync, IAccountSigner { } index = parseInt(indexStr, 10); return prevHd.derive(index); - }, this._node); + }, this.node); return new AccountSigner(childNode, this.fingerprint); } catch (error) { throw new Error('Unable to derive path'); @@ -39,10 +39,10 @@ export class AccountSigner implements HDSignerAsync, IAccountSigner { } async sign(hash: Buffer): Promise { - return this._node.sign(hash); + return this.node.sign(hash); } verify(hash: Buffer, signature: Buffer): boolean { - return this._node.verify(hash, signature); + return this.node.verify(hash, signature); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts index b1a7d91f..fbb685d5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts @@ -14,23 +14,23 @@ export class BtcTxInfo implements ITxInfo { change?: TxOutput; - protected _recipients: TxOutput[]; + #recipients: TxOutput[]; - protected _feeRate: BtcAmount; + #feeRate: BtcAmount; - protected _outputTotal: BtcAmount; + #outputTotal: BtcAmount; - protected _serializedRecipients: Json[]; + #serializedRecipients: Json[]; - protected _network: Network; + #network: Network; constructor(sender: BtcAddress, feeRate: number, network: Network) { - this._recipients = []; - this._serializedRecipients = []; - this._outputTotal = new BtcAmount(0); - this._feeRate = new BtcAmount(feeRate); + this.#recipients = []; + this.#serializedRecipients = []; + this.#outputTotal = new BtcAmount(0); + this.#feeRate = new BtcAmount(feeRate); this.txFee = new BtcAmount(0); - this._network = network; + this.#network = network; this.sender = sender; } @@ -42,7 +42,7 @@ export class BtcTxInfo implements ITxInfo { value: this.change.amount.toString(true), explorerUrl: getExplorerUrl( this.change.destination.value, - getCaip2ChainId(this._network), + getCaip2ChainId(this.#network), ), }, ] @@ -56,34 +56,34 @@ export class BtcTxInfo implements ITxInfo { } addRecipient(output: TxOutput): void { - this._outputTotal.value += output.value; + this.#outputTotal.value += output.value; - this._recipients.push(output); + this.#recipients.push(output); - this._serializedRecipients.push({ + this.#serializedRecipients.push({ address: output.destination.toString(true), value: output.amount.toString(true), explorerUrl: getExplorerUrl( output.destination.value, - getCaip2ChainId(this._network), + getCaip2ChainId(this.#network), ), }); } get total(): BtcAmount { return new BtcAmount( - this._outputTotal.value + + this.#outputTotal.value + (this.change ? this.change.value : 0) + this.txFee.value, ); } get feeRate(): BtcAmount { - return this._feeRate; + return this.#feeRate; } get recipients(): TxOutput[] { - return this._recipients; + return this.#recipients; } get fee(): number { @@ -96,10 +96,10 @@ export class BtcTxInfo implements ITxInfo { toJson>(): InfoJson { return { - feeRate: this._feeRate.toString(true), + feeRate: this.#feeRate.toString(true), txFee: this.txFee.toString(true), sender: this.sender.toString(), - recipients: this._serializedRecipients, + recipients: this.#serializedRecipients, changes: this.changeToJson(), total: this.total.toString(true), } as unknown as InfoJson; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 75157aa5..06b5e627 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -28,13 +28,13 @@ import type { } from './types'; export class BtcWallet implements IWallet { - protected readonly _deriver: IBtcAccountDeriver; + protected readonly deriver: IBtcAccountDeriver; - protected readonly _network: Network; + protected readonly network: Network; constructor(deriver: IBtcAccountDeriver, network: Network) { - this._deriver = deriver; - this._network = network; + this.deriver = deriver; + this.network = network; } protected getAccountCtor(type: string): IStaticBtcAccount { @@ -55,8 +55,8 @@ export class BtcWallet implements IWallet { async unlock(index: number, type: string): Promise { try { const AccountCtor = this.getAccountCtor(type); - const rootNode = await this._deriver.getRoot(AccountCtor.path); - const childNode = await this._deriver.getChild(rootNode, index); + 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 AccountCtor( @@ -64,7 +64,7 @@ export class BtcWallet implements IWallet { index, hdPath, bufferToString(childNode.publicKey, 'hex'), - this._network, + this.network, AccountCtor.scriptType, `bip122:${AccountCtor.scriptType.toLowerCase()}`, this.getHdSigner(rootNode), @@ -92,7 +92,7 @@ export class BtcWallet implements IWallet { new TxOutput( recipient.value, recipient.address, - address.toOutputScript(recipient.address, this._network), + address.toOutputScript(recipient.address, this.network), ), ); @@ -112,10 +112,10 @@ export class BtcWallet implements IWallet { const txInfo = new BtcTxInfo( new BtcAddress(account.address), feeRate, - this._network, + this.network, ); - const psbtService = new PsbtService(this._network); + const psbtService = new PsbtService(this.network); psbtService.addInputs( selectionResult.inputs, options.replaceable, @@ -155,7 +155,7 @@ export class BtcWallet implements IWallet { } async signTransaction(signer: IAccountSigner, tx: string): Promise { - const psbtService = PsbtService.fromBase64(this._network, tx); + const psbtService = PsbtService.fromBase64(this.network, tx); await psbtService.signNVerify(signer); return psbtService.finalize(); } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts index a59d337e..a9e2a446 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring/keyring.ts @@ -30,25 +30,25 @@ import { } from './types'; export class BtcKeyring implements Keyring { - protected readonly _stateMgr: KeyringStateManager; + protected readonly stateMgr: KeyringStateManager; - protected readonly _options: KeyringOptions; + protected readonly options: KeyringOptions; - protected readonly _keyringMethods: string[]; + protected readonly keyringMethods: string[]; - protected readonly _handlers: ChainRPCHandlers; + protected readonly handlers: ChainRPCHandlers; constructor(stateMgr: KeyringStateManager, options: KeyringOptions) { - this._stateMgr = stateMgr; - this._options = options; + this.stateMgr = stateMgr; + this.options = options; const mapping = RpcHelper.getKeyringRpcApiHandlers(); - this._keyringMethods = Object.keys(mapping); - this._handlers = mapping; + this.keyringMethods = Object.keys(mapping); + this.handlers = mapping; } async listAccounts(): Promise { try { - return await this._stateMgr.listAccounts(); + return await this.stateMgr.listAccounts(); } catch (error) { throw new BtcKeyringError(error); } @@ -56,7 +56,7 @@ export class BtcKeyring implements Keyring { async getAccount(id: string): Promise { try { - return (await this._stateMgr.getAccount(id)) ?? undefined; + return (await this.stateMgr.getAccount(id)) ?? undefined; } catch (error) { throw new BtcKeyringError(error); } @@ -90,8 +90,8 @@ export class BtcKeyring implements Keyring { )}`, ); - await this._stateMgr.withTransaction(async () => { - await this._stateMgr.addWallet({ + await this.stateMgr.withTransaction(async () => { + await this.stateMgr.addWallet({ account: keyringAccount, hdPath: account.hdPath, index: account.index, @@ -121,8 +121,8 @@ export class BtcKeyring implements Keyring { async updateAccount(account: KeyringAccount): Promise { try { - await this._stateMgr.withTransaction(async () => { - await this._stateMgr.updateAccount(account); + await this.stateMgr.withTransaction(async () => { + await this.stateMgr.updateAccount(account); await this.#emitEvent(KeyringEvent.AccountUpdated, { account }); }); } catch (error) { @@ -134,8 +134,8 @@ export class BtcKeyring implements Keyring { async deleteAccount(id: string): Promise { try { - await this._stateMgr.withTransaction(async () => { - await this._stateMgr.removeAccounts([id]); + await this.stateMgr.withTransaction(async () => { + await this.stateMgr.removeAccounts([id]); await this.#emitEvent(KeyringEvent.AccountDeleted, { id }); }); } catch (error) { @@ -162,7 +162,7 @@ export class BtcKeyring implements Keyring { const { scope, account } = request; const { method, params } = request.request; - if (!Object.prototype.hasOwnProperty.call(this._handlers, method)) { + if (!Object.prototype.hasOwnProperty.call(this.handlers, method)) { throw new MethodNotFoundError() as unknown as Error; } @@ -174,7 +174,7 @@ export class BtcKeyring implements Keyring { ); } - return this._handlers[method].getInstance(walletData).execute({ + return this.handlers[method].getInstance(walletData).execute({ ...params, scope, } as unknown as SnapRpcHandlerRequest); @@ -185,7 +185,7 @@ export class BtcKeyring implements Keyring { data: Record, ): Promise { // TODO: Temp solution to support keyring in snap without extentions support - if (this._options.emitEvents) { + if (this.options.emitEvents) { await emitSnapKeyringEvent(SnapHelper.provider, event, data); } } @@ -201,7 +201,7 @@ export class BtcKeyring implements Keyring { options: { ...options, }, - methods: this._keyringMethods, + methods: this.keyringMethods, } as unknown as KeyringAccount; } @@ -224,7 +224,7 @@ export class BtcKeyring implements Keyring { } protected async getAndVerifyWallet(id: string) { - const walletData = await this._stateMgr.getWallet(id); + const walletData = await this.stateMgr.getWallet(id); if (!walletData) { throw new Error('Account not found'); From 72a3bcbf0ce2a90d6a01c02443c5e03a0be8a262 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 4 Jul 2024 16:47:04 +0800 Subject: [PATCH 076/362] Revert "chore: update send many (#97)" This reverts commit 82f02922d0a58accc2cddca3bb00832eac151689. --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/bitcoin/wallet/address.ts | 4 + .../src/bitcoin/wallet/coin-select.test.ts | 37 +---- .../src/bitcoin/wallet/coin-select.ts | 38 ++--- .../src/bitcoin/wallet/psbt.test.ts | 80 ++-------- .../src/bitcoin/wallet/psbt.ts | 145 ++++++------------ .../src/bitcoin/wallet/selection-result.ts | 17 ++ .../bitcoin/wallet/transaction-info.test.ts | 33 ++-- .../src/bitcoin/wallet/transaction-info.ts | 75 ++++----- .../bitcoin/wallet/transaction-input.test.ts | 9 +- .../src/bitcoin/wallet/transaction-input.ts | 15 +- .../src/bitcoin/wallet/transaction-output.ts | 17 +- .../src/bitcoin/wallet/types.ts | 9 -- .../src/bitcoin/wallet/wallet.test.ts | 116 ++++++-------- .../src/bitcoin/wallet/wallet.ts | 75 ++++----- .../src/rpcs/sendmany.test.ts | 79 ++-------- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 38 ++--- 17 files changed, 271 insertions(+), 518 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/selection-result.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index cb36df1b..5cc1fcf5 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/bitcoin.git" }, "source": { - "shasum": "k2VftTYTbMrhIZSwAGQb91tdJAF/lprzhf+XXuUuHk4=", + "shasum": "Nz/fHikjqUlxBPopJv1lTeWP6FaObv0z7jw4pMzpZns=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts index 71bf7abf..271bee60 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/address.ts @@ -1,9 +1,13 @@ +import type { Network } from 'bitcoinjs-lib'; + import { replaceMiddleChar } from '../../utils'; import type { IAddress } from '../../wallet'; export class BtcAddress implements IAddress { value: string; + network?: Network; + constructor(address: string) { this.value = address; } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts index af4718ef..e01e03e4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts @@ -4,6 +4,7 @@ import { generateFormatedUtxos } from '../../../test/utils'; import { ScriptType } from '../constants'; import { CoinSelectService } from './coin-select'; import { BtcAccountBip32Deriver } from './deriver'; +import { SelectionResult } from './selection-result'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; import { BtcWallet } from './wallet'; @@ -58,36 +59,10 @@ describe('CoinSelectService', () => { const coinSelectService = new CoinSelectService(1); - const result = coinSelectService.selectCoins( - inputs, - outputs, - new TxOutput(0, sender.address), - ); + const result = coinSelectService.selectCoins(inputs, outputs, sender); + expect(result).toBeInstanceOf(SelectionResult); expect(result.fee).toBeGreaterThan(1); - expect(result.change).toBeDefined(); - expect(result.inputs.length).toBeGreaterThan(0); - expect(result.outputs.length).toBeGreaterThan(0); - }); - - it('converts output to TxOutput', async () => { - const network = networks.testnet; - const { inputs, outputs, sender } = await prepareCoinSlect(network); - - const coinSelectService = new CoinSelectService(1); - - const result = coinSelectService.selectCoins( - inputs, - outputs.map((output) => ({ - address: output.address, - value: output.value, - })), - new TxOutput(0, sender.address), - ); - - for (const output of result.outputs) { - expect(output).toBeInstanceOf(TxOutput); - } }); it('throws `Insufficient funds` error if the given utxos is not sufficient', async () => { @@ -102,11 +77,7 @@ describe('CoinSelectService', () => { const coinSelectService = new CoinSelectService(100); expect(() => - coinSelectService.selectCoins( - inputs, - outputs, - new TxOutput(0, sender.address), - ), + coinSelectService.selectCoins(inputs, outputs, sender), ).toThrow('Insufficient funds'); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index ade36a3a..542d933c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -2,47 +2,43 @@ import coinSelect from 'coinselect'; import type { Recipient } from '../../wallet'; import { TxValidationError } from './exceptions'; +import { SelectionResult } from './selection-result'; import type { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; -import { type SelectionResult } from './types'; +import { type IBtcAccount } from './types'; export class CoinSelectService { - #feeRate: number; + protected readonly feeRate: number; constructor(feeRate: number) { - this.#feeRate = Math.round(feeRate); + this.feeRate = Math.round(feeRate); } selectCoins( inputs: TxInput[], - recipients: Recipient[] | TxOutput[], - changeTo: TxOutput, + recipients: Recipient[], + changeAccount: IBtcAccount, ): SelectionResult { - const result = coinSelect(inputs, recipients, this.#feeRate); + const result = coinSelect(inputs, recipients, this.feeRate); if (!result.inputs || !result.outputs) { throw new TxValidationError('Insufficient funds'); } - const selectedResult: SelectionResult = { - fee: result.fee, - inputs: result.inputs, - outputs: [], - }; + const selectedResult = new SelectionResult(); + selectedResult.fee = result.fee; + selectedResult.selectedInputs = result.inputs; - // restructure outputs to avoid depends on coinselect output format for (const output of result.outputs) { if (output.address) { - if (output instanceof TxOutput) { - selectedResult.outputs.push(output); - } else { - selectedResult.outputs.push( - new TxOutput(output.value, output.address), - ); - } + selectedResult.selectedOutputs.push( + new TxOutput(output.value, output.address), + ); } else { - changeTo.value = output.value; - selectedResult.change = changeTo; + selectedResult.change = new TxOutput( + output.value, + changeAccount.address, + ); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts index 70df4e16..6357706d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts @@ -1,4 +1,4 @@ -import { Psbt, Transaction, networks } from 'bitcoinjs-lib'; +import { Transaction, networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { hexToBuffer } from '../../utils'; @@ -32,14 +32,11 @@ describe('PsbtService', () => { const receiver1 = await wallet.instance.unlock(1, ScriptType.P2wpkh); const receiver2 = await wallet.instance.unlock(2, ScriptType.P2wpkh); const receivers = [receiver1, receiver2]; - const finalizeSpy = jest.spyOn(Psbt.prototype, 'finalizeAllInputs'); - const inputSpy = jest.spyOn(Psbt.prototype, 'addInput'); - const outputSpy = jest.spyOn(Psbt.prototype, 'addOutput'); - const signSpy = jest.spyOn(Psbt.prototype, 'signAllInputsHDAsync'); - const verifySpy = jest.spyOn( - Psbt.prototype, - 'validateSignaturesOfAllInputs', - ); + const finalizeSpy = jest.spyOn(service.psbt, 'finalizeAllInputs'); + const inputSpy = jest.spyOn(service.psbt, 'addInput'); + const outputSpy = jest.spyOn(service.psbt, 'addOutput'); + const signSpy = jest.spyOn(service.psbt, 'signAllInputsHDAsync'); + const verifySpy = jest.spyOn(service.psbt, 'validateSignaturesOfAllInputs'); const transactionWeightSpy = jest.spyOn(Transaction.prototype, 'weight'); const transactionHexSpy = jest.spyOn(Transaction.prototype, 'toHex'); @@ -62,13 +59,7 @@ describe('PsbtService', () => { service.addOutputs(outputs); - service.addInputs( - inputs, - rbfOptIn, - sender.hdPath, - hexToBuffer(sender.pubkey, false), - hexToBuffer(sender.mfp, false), - ); + service.addInputs(inputs, sender, rbfOptIn); return { service, @@ -92,7 +83,7 @@ describe('PsbtService', () => { const service = new PsbtService(network); - expect(service.psbt).toBeInstanceOf(Psbt); + expect(service.psbt.txInputs).toHaveLength(0); }); it('constructor with an psbt base string', async () => { @@ -101,7 +92,8 @@ describe('PsbtService', () => { const newService = PsbtService.fromBase64(networks.testnet, psbtBase64); - expect(newService.psbt).toBeInstanceOf(Psbt); + expect(newService.psbt.txInputs).toHaveLength(2); + expect(newService.psbt.txOutputs).toHaveLength(2); }); }); @@ -119,7 +111,7 @@ describe('PsbtService', () => { hash: inputs[i].txHash, index: inputs[i].index, witnessUtxo: { - script: inputs[i].script, + script: inputs[i].scriptBuf, value: inputs[i].value, }, bip32Derivation: [ @@ -142,7 +134,7 @@ describe('PsbtService', () => { hash: inputs[i].txHash, index: inputs[i].index, witnessUtxo: { - script: inputs[i].script, + script: inputs[i].scriptBuf, value: inputs[i].value, }, bip32Derivation: [ @@ -164,13 +156,7 @@ describe('PsbtService', () => { }); expect(() => { - service.addInputs( - inputs, - false, - sender.hdPath, - hexToBuffer(sender.pubkey, false), - hexToBuffer(sender.mfp), - ); + service.addInputs(inputs, sender, false); }).toThrow('Failed to add inputs in PSBT'); }); }); @@ -288,44 +274,4 @@ describe('PsbtService', () => { expect(() => service.finalize()).toThrow(PsbtServiceError); }); }); - - describe('signDummy', () => { - it('clones a psbt, then sign it and returns an PsbtService object', async () => { - const { service, sender } = await preparePsbt(); - - const signedService = await service.signDummy(sender.signer); - - expect(signedService).toBeInstanceOf(PsbtService); - }); - - it('throws `Failed to sign dummy in PSBT` error if signDummy is failed', async () => { - const { service, sender, finalizeSpy } = await preparePsbt(); - - finalizeSpy.mockImplementation(() => { - throw new Error('error'); - }); - - await expect(service.signDummy(sender.signer)).rejects.toThrow( - `Failed to sign dummy in PSBT`, - ); - }); - }); - - describe('getFee', () => { - it('extracts fee from the psbt', async () => { - const { service, sender } = await preparePsbt(); - - const signedService = await service.signDummy(sender.signer); - - const fee = signedService.getFee(); - - expect(fee).toBeGreaterThan(0); - }); - - it('throws `Failed to get fee from PSBT` error if getFee is failed', async () => { - const { service } = await preparePsbt(); - - expect(() => service.getFee()).toThrow(`Failed to get fee from PSBT`); - }); - }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts index d1bae8c2..0b7ab632 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts @@ -5,31 +5,31 @@ import type { Buffer } from 'buffer'; import ECPairFactory from 'ecpair'; import { logger } from '../../libs/logger/logger'; -import { compactError } from '../../utils'; +import { compactError, hexToBuffer } from '../../utils'; import type { IAccountSigner } from '../../wallet'; import { MaxStandardTxWeight } from '../constants'; import { PsbtServiceError, TxValidationError } from './exceptions'; import type { TxInput } from './transaction-input'; import type { TxOutput } from './transaction-output'; +import type { IBtcAccount } from './types'; const ECPair = ECPairFactory(ecc); export class PsbtService { - #psbt: Psbt; + protected readonly network: Network; - #network: Network; + protected readonly _psbt: Psbt; get psbt() { - return this.#psbt; + return this._psbt; } constructor(network: Network, psbt?: Psbt) { if (psbt === undefined) { - this.#psbt = new Psbt({ network }); + this._psbt = new Psbt({ network }); } else { - this.#psbt = psbt; + this._psbt = psbt; } - this.#network = network; } static fromBase64(network: Network, base64Psbt: string): PsbtService { @@ -38,62 +38,43 @@ export class PsbtService { return service; } - addInput( - input: TxInput, - replaceable: boolean, - changeAddressHdPath: string, - changeAddressPubkey: Buffer, - changeAddressMfp: Buffer, - ) { - try { - this.#psbt.addInput({ - hash: input.txHash, - index: input.index, - witnessUtxo: { - script: input.script, - value: input.value, - }, - // This is useful because as long as you store the masterFingerprint on - // the PSBT Creator's server, you can have the PSBT Creator do the heavy - // lifting with derivation from your m/84'/0'/0' xpub, (deriving only 0/0 ) - // and your signer just needs to pass in an HDSigner interface (ie. bip32 library) - bip32Derivation: [ - { - masterFingerprint: changeAddressMfp, - path: changeAddressHdPath, - pubkey: changeAddressPubkey, - }, - ], - - // reference : https://en.bitcoin.it/wiki/BIP_0125 - // A transaction is considered to have opted in to allowing replacement of itself if any of its inputs have an nSequence number less than (0xffffffff - 1). - // we use max sequence number - 2 to void conflicting with other possible uses of nSequence - sequence: replaceable - ? Transaction.DEFAULT_SEQUENCE - 2 - : Transaction.DEFAULT_SEQUENCE, - }); - } catch (error) { - logger.error('Failed to add input', error); - throw new PsbtServiceError('Failed to add input in PSBT'); - } - } - addInputs( inputs: TxInput[], + changeAccount: IBtcAccount, replaceable: boolean, - changeAddressHdPath: string, - changeAddressPubkey: Buffer, - changeAddressMfp: Buffer, ) { try { + const changeAddressHdPath = changeAccount.hdPath; + const changeAddressPubkey = hexToBuffer(changeAccount.pubkey, false); + const changeAddressMfp = hexToBuffer(changeAccount.mfp, false); + for (const input of inputs) { - this.addInput( - input, - replaceable, - changeAddressHdPath, - changeAddressPubkey, - changeAddressMfp, - ); + this._psbt.addInput({ + hash: input.txHash, + index: input.index, + witnessUtxo: { + script: input.scriptBuf, + value: input.value, + }, + // This is useful because as long as you store the masterFingerprint on + // the PSBT Creator's server, you can have the PSBT Creator do the heavy + // lifting with derivation from your m/84'/0'/0' xpub, (deriving only 0/0 ) + // and your signer just needs to pass in an HDSigner interface (ie. bip32 library) + bip32Derivation: [ + { + masterFingerprint: changeAddressMfp, + path: changeAddressHdPath, + pubkey: changeAddressPubkey, + }, + ], + + // reference : https://en.bitcoin.it/wiki/BIP_0125 + // A transaction is considered to have opted in to allowing replacement of itself if any of its inputs have an nSequence number less than (0xffffffff - 1). + // we use max sequence number - 2 to void conflicting with other possible uses of nSequence + sequence: replaceable + ? Transaction.DEFAULT_SEQUENCE - 2 + : Transaction.DEFAULT_SEQUENCE, + }); } } catch (error) { logger.error('Failed to add inputs', error); @@ -101,22 +82,13 @@ export class PsbtService { } } - addOutput(output: TxOutput) { - try { - this.#psbt.addOutput({ - address: output.address, - value: output.value, - }); - } catch (error) { - logger.error('Failed to add output', error); - throw new PsbtServiceError('Failed to add output in PSBT'); - } - } - addOutputs(outputs: TxOutput[]) { try { for (const output of outputs) { - this.addOutput(output); + this._psbt.addOutput({ + address: output.address, + value: output.value, + }); } } catch (error) { logger.error('Failed to add outputs', error); @@ -124,30 +96,9 @@ export class PsbtService { } } - getFee(): number { - try { - return this.#psbt.getFee(); - } catch (error) { - logger.error('Failed to get fee', error); - throw new PsbtServiceError('Failed to get fee from PSBT'); - } - } - - async signDummy(signer: IAccountSigner): Promise { - try { - const psbt = this.#psbt.clone(); - await psbt.signAllInputsHDAsync(signer); - psbt.finalizeAllInputs(); - return new PsbtService(this.#network, psbt); - } catch (error) { - logger.error('Failed to sign dummy', error); - throw new PsbtServiceError('Failed to sign dummy in PSBT'); - } - } - toBase64(): string { try { - return this.#psbt.toBase64(); + return this._psbt.toBase64(); } catch (error) { logger.error('Failed to convert to base64', error); throw new PsbtServiceError('Failed to output PSBT string'); @@ -159,10 +110,10 @@ export class PsbtService { // This function signAllInputsHDAsync is used to sign all inputs with the signer. // When using the method signAllInputsHDAsync, it is important to note that the signer must derive from the root node as well as the finderprint. // For further reference, please see the getHdSigner method in BtcWallet. - await this.#psbt.signAllInputsHDAsync(signer); + await this._psbt.signAllInputsHDAsync(signer); if ( - !this.#psbt.validateSignaturesOfAllInputs( + !this._psbt.validateSignaturesOfAllInputs( (pubkey: Buffer, msghash: Buffer, signature: Buffer) => this.validateInputs(pubkey, msghash, signature), ) @@ -178,11 +129,11 @@ export class PsbtService { finalize(): string { try { - this.#psbt.finalizeAllInputs(); + this._psbt.finalizeAllInputs(); - const txHex = this.#psbt.extractTransaction().toHex(); + const txHex = this._psbt.extractTransaction().toHex(); - const weight = this.#psbt.extractTransaction().weight(); + const weight = this._psbt.extractTransaction().weight(); if (weight > MaxStandardTxWeight) { throw new TxValidationError('Transaction is too large'); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/selection-result.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/selection-result.ts new file mode 100644 index 00000000..a9549bc5 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/selection-result.ts @@ -0,0 +1,17 @@ +import type { TxInput } from './transaction-input'; +import type { TxOutput } from './transaction-output'; + +export class SelectionResult { + selectedInputs: TxInput[]; + + selectedOutputs: TxOutput[]; + + change: TxOutput; + + fee: number; + + constructor() { + this.selectedInputs = []; + this.selectedOutputs = []; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts index 4c553e10..d1a48dd7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts @@ -3,7 +3,6 @@ import { networks } from 'bitcoinjs-lib'; import { generateAccounts } from '../../../test/utils'; import { getCaip2ChainId, getExplorerUrl, satsToBtc } from '../utils'; import { BtcAddress } from './address'; -import { BtcAmount } from './amount'; import { BtcTxInfo } from './transaction-info'; import { TxOutput } from './transaction-output'; @@ -14,21 +13,20 @@ describe('BtcTxInfo', () => { const accounts = generateAccounts(5); const addresses = accounts.map((account) => account.address); const fee = 10000; - const feeRate = 100; let total = fee; const outputs: TxOutput[] = []; - const sender = new BtcAddress(addresses[0]); - - const info = new BtcTxInfo(sender, feeRate, network); - info.fee = fee; for (let i = 1; i < addresses.length; i++) { total += 100000; - const output = new TxOutput(100000, addresses[i]); - outputs.push(output); + outputs.push(new TxOutput(100000, addresses[i])); } - - info.addRecipients(outputs); + const info = new BtcTxInfo( + new BtcAddress(addresses[0]), + outputs, + 10000, + 100, + network, + ); info.change = new TxOutput(500, addresses[0]); total += 500; @@ -55,20 +53,9 @@ describe('BtcTxInfo', () => { }, ]; - expect(info.total).toBeInstanceOf(BtcAmount); - expect(info.total.value).toStrictEqual(total); - expect(info.sender).toStrictEqual(sender); - expect(info.recipients).toHaveLength(expectedRecipients.length); - expect(info.change).toBeDefined(); - expect(info.fee).toStrictEqual(fee); - expect(info.txFee).toBeInstanceOf(BtcAmount); - expect(info.txFee.value).toStrictEqual(fee); - expect(info.feeRate).toBeInstanceOf(BtcAmount); - expect(info.feeRate.value).toStrictEqual(feeRate); - expect(info.toJson()).toStrictEqual({ - feeRate: `${satsToBtc(feeRate)} BTC`, - txFee: `${satsToBtc(fee)} BTC`, + feeRate: `${satsToBtc(100)} BTC`, + txFee: `${satsToBtc(10000)} BTC`, sender: addresses[0], recipients: expectedRecipients, changes: expectedChange, diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts index fbb685d5..05d0c1ba 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts @@ -8,15 +8,15 @@ import { BtcAmount } from './amount'; import type { TxOutput } from './transaction-output'; export class BtcTxInfo implements ITxInfo { - readonly sender: BtcAddress; + #recipients: TxOutput[]; - readonly txFee: BtcAmount; + #feeRate: BtcAmount; change?: TxOutput; - #recipients: TxOutput[]; + #sender: BtcAddress; - #feeRate: BtcAmount; + #txFee: BtcAmount; #outputTotal: BtcAmount; @@ -24,14 +24,21 @@ export class BtcTxInfo implements ITxInfo { #network: Network; - constructor(sender: BtcAddress, feeRate: number, network: Network) { + constructor( + sender: BtcAddress, + outputs: TxOutput[], + fee: number, + feeRate: number, + network: Network, + ) { this.#recipients = []; - this.#serializedRecipients = []; this.#outputTotal = new BtcAmount(0); + this.#serializedRecipients = []; this.#feeRate = new BtcAmount(feeRate); - this.txFee = new BtcAmount(0); + this.#txFee = new BtcAmount(fee); this.#network = network; - this.sender = sender; + this.#sender = sender; + this.addRecipients(outputs); } protected changeToJson(): Json { @@ -49,56 +56,40 @@ export class BtcTxInfo implements ITxInfo { : []; } - addRecipients(outputs: TxOutput[]): void { + protected addRecipients(outputs: TxOutput[]): void { for (const output of outputs) { - this.addRecipient(output); + this.#outputTotal.value += output.value; + + this.#recipients.push(output); + + this.#serializedRecipients.push({ + address: output.destination.toString(true), + value: output.amount.toString(true), + explorerUrl: getExplorerUrl( + output.destination.value, + getCaip2ChainId(this.#network), + ), + }); } } - addRecipient(output: TxOutput): void { - this.#outputTotal.value += output.value; - - this.#recipients.push(output); - - this.#serializedRecipients.push({ - address: output.destination.toString(true), - value: output.amount.toString(true), - explorerUrl: getExplorerUrl( - output.destination.value, - getCaip2ChainId(this.#network), - ), - }); + bumpFee(val: number): void { + this.#txFee.value += val; } get total(): BtcAmount { return new BtcAmount( this.#outputTotal.value + (this.change ? this.change.value : 0) + - this.txFee.value, + this.#txFee.value, ); } - get feeRate(): BtcAmount { - return this.#feeRate; - } - - get recipients(): TxOutput[] { - return this.#recipients; - } - - get fee(): number { - return this.txFee.value; - } - - set fee(val: number) { - this.txFee.value = val; - } - toJson>(): InfoJson { return { feeRate: this.#feeRate.toString(true), - txFee: this.txFee.toString(true), - sender: this.sender.toString(), + txFee: this.#txFee.toString(true), + sender: this.#sender.toString(), recipients: this.#serializedRecipients, changes: this.changeToJson(), total: this.total.toString(true), diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts index 5dbe7061..2b0760f0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts @@ -1,7 +1,7 @@ import { networks } from 'bitcoinjs-lib'; -import type { Buffer } from 'buffer'; import { generateFormatedUtxos } from '../../../test/utils'; +import { hexToBuffer } from '../../utils'; import { ScriptType } from '../constants'; import { BtcAccountBip32Deriver } from './deriver'; import { TxInput } from './transaction-input'; @@ -23,12 +23,13 @@ describe('TxInput', () => { it('return correct property', async () => { const wallet = createMockWallet(networks.testnet); const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); - const script = account.payment.output as unknown as Buffer; - + const script = account.payment.output?.toString('hex') as unknown as string; + const scriptBuf = hexToBuffer(script, false); const utxo = generateFormatedUtxos(account.address, 1)[0]; - const input = new TxInput(utxo, script); + const input = new TxInput(utxo, scriptBuf); + expect(input.scriptBuf).toStrictEqual(scriptBuf); expect(input.script).toStrictEqual(script); expect(input.value).toStrictEqual(utxo.value); expect(input.txHash).toStrictEqual(utxo.txHash); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts index c8da508d..4f0d6d63 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts @@ -1,23 +1,28 @@ import type { Buffer } from 'buffer'; +import { bufferToString } from '../../utils'; import type { IAmount } from '../../wallet'; import { BtcAmount } from './amount'; import type { Utxo } from './types'; export class TxInput { - // consume by coinselect - readonly script: Buffer; + scriptBuf: Buffer; - readonly amount: IAmount; + amount: IAmount; - readonly utxo: Utxo; + utxo: Utxo; constructor(utxo: Utxo, script: Buffer) { - this.script = script; + this.scriptBuf = script; this.utxo = utxo; this.amount = new BtcAmount(utxo.value); } + // consume by coinselect + get script(): string { + return bufferToString(this.scriptBuf, 'hex'); + } + // consume by coinselect get value(): number { return this.amount.value; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts index 772a9ff6..93b3ad5c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts @@ -1,32 +1,21 @@ -import type { Buffer } from 'buffer'; - import type { IAddress, IAmount } from '../../wallet'; import { BtcAddress } from './address'; import { BtcAmount } from './amount'; export class TxOutput { - readonly amount: IAmount; - - // consume by conselect - readonly script?: Buffer; + amount: IAmount; - readonly destination: IAddress; + destination: IAddress; - constructor(value: number, address: string, script?: Buffer) { + constructor(value: number, address: string) { this.amount = new BtcAmount(value); this.destination = new BtcAddress(address); - this.script = script; } - // consume by conselect get value(): number { return this.amount.value; } - set value(value: number) { - this.amount.value = value; - } - get address(): string { return this.destination.value; } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts index 549caf2d..6ef5432a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/types.ts @@ -3,8 +3,6 @@ import type { Network, Payment } from 'bitcoinjs-lib'; import type { IAccount, IAccountSigner } from '../../wallet'; import type { ScriptType } from '../constants'; -import type { TxInput } from './transaction-input'; -import type { TxOutput } from './transaction-output'; export type IBtcAccountDeriver = { getRoot(path: string[]): Promise; @@ -48,10 +46,3 @@ export type Utxo = { index: number; value: number; }; - -export type SelectionResult = { - change?: TxOutput; - fee: number; - inputs: TxInput[]; - outputs: TxOutput[]; -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index 057d9fd6..c4628933 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -1,16 +1,18 @@ -import type { Json } from '@metamask/snaps-sdk'; -import { address as addressUtils, networks } from 'bitcoinjs-lib'; +import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; import { DustLimit, ScriptType } from '../constants'; +import type { BtcAccount } from './account'; import { P2SHP2WPKHAccount, P2WPKHAccount } from './account'; +import { BtcAmount } from './amount'; import { CoinSelectService } from './coin-select'; import { BtcAccountBip32Deriver } from './deriver'; import { WalletError } from './exceptions'; +import { PsbtService } from './psbt'; +import { SelectionResult } from './selection-result'; import { BtcTxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; -import type { SelectionResult } from './types'; import { BtcWallet } from './wallet'; jest.mock('../../libs/snap/helpers'); @@ -97,60 +99,23 @@ describe('BtcWallet', () => { }); describe('createTransaction', () => { - it('creates an transaction with changes', async () => { + it('creates an transaction', async () => { const network = networks.testnet; const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(account.address, 200, 100000, 100000); - - const result = await wallet.createTransaction( - account, - createMockTxIndent(account.address, 132000), - { - utxos, - fee: 56, - subtractFeeFrom: [], - replaceable: false, - }, - ); - - const json = result.txInfo.toJson(); - const recipients = json.recipients as unknown as Json[]; - const changes = json.changes as unknown as Json[]; - - expect(recipients).toHaveLength(1); - expect(changes).toHaveLength(1); - expect(result).toStrictEqual({ - tx: expect.any(String), - txInfo: expect.any(BtcTxInfo), - }); - }); - - it('creates an transaction without changes', async () => { - const network = networks.testnet; - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(account.address, 200, 10000, 10000); + const utxos = generateFormatedUtxos(account.address, 2, 10000, 10000); const result = await wallet.createTransaction( account, - createMockTxIndent(account.address, 100000), + createMockTxIndent(account.address, 15000), { utxos, - fee: 50, + fee: 1, subtractFeeFrom: [], replaceable: false, }, ); - - const json = result.txInfo.toJson(); - const recipients = json.recipients as unknown as Json[]; - const changes = json.changes as unknown as Json[]; - - expect(recipients).toHaveLength(1); - expect(changes).toHaveLength(0); expect(result).toStrictEqual({ tx: expect.any(String), txInfo: expect.any(BtcTxInfo), @@ -182,7 +147,7 @@ describe('BtcWallet', () => { expect(coinSelectServiceSpy).toHaveBeenCalledWith( expect.any(Array), expect.any(Array), - expect.any(TxOutput), + account, ); for (const input of coinSelectServiceSpy.mock.calls[0][0]) { @@ -190,7 +155,10 @@ describe('BtcWallet', () => { } for (const output of coinSelectServiceSpy.mock.calls[0][1]) { - expect(output).toBeInstanceOf(TxOutput); + expect(output).toStrictEqual({ + address: account.address, + value: DustLimit[account.scriptType] + 1, + }); } }); @@ -198,32 +166,34 @@ describe('BtcWallet', () => { const network = networks.testnet; const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); - const chgAccount = await wallet.unlock(0, ScriptType.P2wpkh); + const chgAccount = (await wallet.unlock( + 0, + ScriptType.P2wpkh, + )) as unknown as BtcAccount; const recipient = await wallet.unlock(1, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(chgAccount.address, 2, 10000, 10000); + const utxos = generateFormatedUtxos(chgAccount.address, 2); const coinSelectServiceSpy = jest.spyOn( CoinSelectService.prototype, 'selectCoins', ); + const psbtServiceSpy = jest + .spyOn(PsbtService.prototype, 'addOutputs') + .mockReturnThis(); - const selectionResult: SelectionResult = { - change: new TxOutput( - DustLimit[chgAccount.scriptType] - 1, - chgAccount.address, - ), - fee: 100, - inputs: utxos.map( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (utxo) => new TxInput(utxo, chgAccount.payment.output!), - ), - outputs: [ - new TxOutput( - 500, - recipient.address, - addressUtils.toOutputScript(recipient.address, network), - ), - ], - }; + // to avoid modifiy the original object when we needed to test the output + psbtServiceSpy.mockReturnThis(); + + const selectionResult = new SelectionResult(); + selectionResult.selectedInputs = utxos.map( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (utxo) => new TxInput(utxo, chgAccount.payment.output!), + ); + selectionResult.selectedOutputs = [new TxOutput(500, recipient.address)]; + selectionResult.fee = 100; + selectionResult.change = new TxOutput( + DustLimit[chgAccount.scriptType] - 1, + chgAccount.address, + ); coinSelectServiceSpy.mockReturnValue(selectionResult); @@ -240,8 +210,18 @@ describe('BtcWallet', () => { const info: BtcTxInfo = result.txInfo as unknown as BtcTxInfo; - expect(info.fee).toBe(19500); - expect(info.change).toBeUndefined(); + expect(psbtServiceSpy).toHaveBeenCalledWith( + selectionResult.selectedOutputs, + ); + + const jsonInfo = info.toJson(); + + expect(jsonInfo).toHaveProperty( + 'txFee', + new BtcAmount(100 + DustLimit[chgAccount.scriptType] - 1).toString( + true, + ), + ); }); it('throws `Transaction amount too small` error the transaction output is too small', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 06b5e627..3c617535 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -1,8 +1,8 @@ import type { BIP32Interface } from 'bip32'; -import { type Network, address } from 'bitcoinjs-lib'; +import { type Network } from 'bitcoinjs-lib'; import { logger } from '../../libs/logger/logger'; -import { bufferToString, compactError, hexToBuffer } from '../../utils'; +import { bufferToString, compactError } from '../../utils'; import type { IAccountSigner, IWallet, @@ -19,7 +19,6 @@ import { PsbtService } from './psbt'; import { AccountSigner } from './signer'; import { BtcTxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; -import { TxOutput } from './transaction-output'; import type { IStaticBtcAccount, IBtcAccountDeriver, @@ -86,71 +85,59 @@ export class BtcWallet implements IWallet { throw new WalletError('Fail to get account script hash'); } - const inputs = options.utxos.map((utxo) => new TxInput(utxo, scriptOutput)); - const outputs = recipients.map( - (recipient) => - new TxOutput( - recipient.value, - recipient.address, - address.toOutputScript(recipient.address, this.network), - ), - ); - // as fee rate can be 0, we need to ensure it is at least 1 // TODO: The min fee rate should be setting from parameter const feeRate = Math.max(1, options.fee); - const coinSelectService = new CoinSelectService(feeRate); - const change = new TxOutput(0, account.address); - const selectionResult = coinSelectService.selectCoins( - inputs, - outputs, - change, - ); - logger.info(JSON.stringify(selectionResult, null, 2)); + const coinSelectService = new CoinSelectService(feeRate); - const txInfo = new BtcTxInfo( - new BtcAddress(account.address), - feeRate, - this.network, - ); + const inputs = options.utxos.map((utxo) => new TxInput(utxo, scriptOutput)); - const psbtService = new PsbtService(this.network); - psbtService.addInputs( - selectionResult.inputs, - options.replaceable, - account.hdPath, - hexToBuffer(account.pubkey, false), - hexToBuffer(account.mfp, false), + const selectionResult = coinSelectService.selectCoins( + inputs, + recipients, + account, ); // TODO: add support of subtractFeeFrom, and throw error if output is too small after subtraction - for (const output of selectionResult.outputs) { + for (const output of selectionResult.selectedOutputs) { if (isDust(output.value, scriptType)) { throw new TxValidationError('Transaction amount too small'); } - psbtService.addOutput(output); - txInfo.addRecipient(output); } - if (selectionResult.change) { - if (isDust(change.value, scriptType)) { + const psbtOutputs = selectionResult.selectedOutputs; + const psbtInputs = selectionResult.selectedInputs; + + const info = new BtcTxInfo( + new BtcAddress(account.address), + selectionResult.selectedOutputs, + selectionResult.fee, + feeRate, + this.network, + ); + + if (selectionResult.change && selectionResult.change.value > 0) { + if (isDust(selectionResult.change.value, scriptType)) { logger.warn( '[BtcWallet.createTransaction] Change is too small, adding to fees', ); + info.bumpFee(selectionResult.change.value); } else { - psbtService.addOutput(selectionResult.change); - txInfo.change = selectionResult.change; + info.change = selectionResult.change; + psbtOutputs.push(selectionResult.change); } } - // Sign dummy transaction to extract the fee which is more accurate - const signedService = await psbtService.signDummy(account.signer); - txInfo.fee = signedService.getFee(); + const psbtService = new PsbtService(this.network); + + psbtService.addInputs(psbtInputs, account, options.replaceable); + + psbtService.addOutputs(psbtOutputs); return { tx: psbtService.toBase64(), - txInfo, + txInfo: info, }; } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index b8780d01..436927f4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -1,4 +1,3 @@ -import type { Json } from '@metamask/snaps-sdk'; import { InvalidParamsError, UserRejectedRequestError, @@ -17,11 +16,14 @@ import { BtcAccountBip32Deriver, BtcWallet, BtcAmount, + BtcTxInfo, + BtcAddress, } from '../bitcoin/wallet'; +import { TxOutput } from '../bitcoin/wallet/transaction-output'; import { FeeRatio } from '../chain'; import { Factory } from '../factory'; import { SnapHelper } from '../libs/snap'; -import type { IAccount, ITxInfo } from '../wallet'; +import type { IAccount } from '../wallet'; import { SendManyHandler } from './sendmany'; import type { SendManyParams } from './sendmany'; @@ -228,34 +230,17 @@ describe('SendManyHandler', () => { const calls = snapHelperSpy.mock.calls[0][0]; - const lastPanel = calls[calls.length - 1]; - - expect(lastPanel).toStrictEqual({ - type: 'panel', - children: [ - { - type: 'row', - label: 'Comment', - value: { markdown: false, type: 'text', value: 'test comment' }, - }, - { - type: 'row', - label: 'Network fee', - value: { markdown: false, type: 'text', value: expect.any(String) }, - }, - { - type: 'row', - label: 'Total', - value: { markdown: false, type: 'text', value: expect.any(String) }, - }, - ], + expect(calls[calls.length - 4]).toStrictEqual({ + type: 'row', + label: 'Comment', + value: { type: 'text', value: 'test comment' }, }); }); it('display `Recipient` as label in dialog if there is only 1 recipient', async () => { const network = networks.testnet; const caip2ChainId = Network.Testnet; - const { keyringAccount, recipients, snapHelperSpy, sender } = + const { keyringAccount, recipients, snapHelperSpy } = await prepareSendMany(network, caip2ChainId); const walletCreateTxSpy = jest.spyOn( BtcWallet.prototype, @@ -266,24 +251,13 @@ describe('SendManyHandler', () => { 'signTransaction', ); - const info: ITxInfo = { - toJson>() { - return { - feeRate: `0.00000001 BTC`, - txFee: `0.00000001 BTC`, - sender: sender.address, - recipients: [ - { - address: recipients[0].address, - value: `0.000010 BTC`, - explorerUrl: `https://blockchair.com/bitcoin/transaction/transactionId`, - }, - ], - changes: [], - total: `0.000010 BTC`, - } as unknown as TxInfoJson; - }, - }; + const info = new BtcTxInfo( + new BtcAddress(recipients[0].address), + [new TxOutput(100000, recipients[0].address)], + 1, + 1, + network, + ); walletCreateTxSpy.mockResolvedValue({ tx: 'transaction', @@ -300,26 +274,7 @@ describe('SendManyHandler', () => { const calls = snapHelperSpy.mock.calls[0][0]; - const recipientsPanel = calls[2]; - - expect(recipientsPanel).toStrictEqual({ - type: 'panel', - children: [ - { - type: 'row', - label: 'Recipient', - value: { - type: 'text', - value: `[${recipients[0].address}](https://blockchair.com/bitcoin/transaction/transactionId)`, - }, - }, - { - type: 'row', - label: 'Amount', - value: { markdown: false, type: 'text', value: '0.000010 BTC' }, - }, - ], - }); + expect(calls[3]).toHaveProperty('label', 'Recipient'); }); it('throws `Request params is invalid` error when request parameter is not correct', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 046c1046..e7e4ec0c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -6,7 +6,6 @@ import { text, heading, row, - panel, } from '@metamask/snaps-sdk'; import { object, @@ -180,27 +179,15 @@ export class SendManyHandler comment: string, ): Promise { const header = `Send Request`; - const intro = `Review the request before proceeding. Once the transaction is made, it's irreversible.`; + const intro = `Review the request by [portfolio.metamask.io](https://portfolio.metamask.io/) before proceeding. Once the transaction is made, it's irreversible.`; const recipientsLabel = `Recipient`; const amountLabel = `Amount`; const commentLabel = `Comment`; - // const networkFeeRateLabel = `Network fee rate`; + const networkFeeRateLabel = `Network fee rate`; const networkFeeLabel = `Network fee`; const totalLabel = `Total`; - const requestedByLable = `Requested by`; - - const components: Component[] = [ - panel([ - heading(header), - text(intro), - row( - requestedByLable, - text(`[portfolio.metamask.io](https://portfolio.metamask.io/)`), - ), - ]), - divider(), - ]; + const components: Component[] = [heading(header), text(intro), divider()]; const info = txInfo.toJson(); const isMoreThanOneRecipient = @@ -213,8 +200,7 @@ export class SendManyHandler explorerUrl: string; value: string; }) => { - const recipientsPanel: Component[] = []; - recipientsPanel.push( + components.push( row( isMoreThanOneRecipient ? `${recipientsLabel} ${i + 1}` @@ -222,27 +208,23 @@ export class SendManyHandler text(`[${data.address}](${data.explorerUrl})`), ), ); - recipientsPanel.push(row(amountLabel, text(data.value, false))); - i += 1; - components.push(panel(recipientsPanel)); + components.push(row(amountLabel, text(data.value, false))); components.push(divider()); + i += 1; }; info.recipients.forEach(addReciptentsToComponents); info.changes.forEach(addReciptentsToComponents); - const bottomPanel: Component[] = []; if (comment.trim().length > 0) { - bottomPanel.push(row(commentLabel, text(comment.trim(), false))); + components.push(row(commentLabel, text(comment.trim()))); } - bottomPanel.push(row(networkFeeLabel, text(`${info.txFee}`, false))); - - // bottomPanel.push(row(networkFeeRateLabel, text(`${info.feeRate}`, false))); + components.push(row(networkFeeLabel, text(`${info.txFee}`, false))); - bottomPanel.push(row(totalLabel, text(`${info.total}`, false))); + components.push(row(networkFeeRateLabel, text(`${info.feeRate}`, false))); - components.push(panel(bottomPanel)); + components.push(row(totalLabel, text(`${info.total}`, false))); return (await SnapHelper.confirmDialog(components)) as boolean; } From ac1c7791f25814b3e6f3293ea5f6719de91fb7d3 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Thu, 4 Jul 2024 16:56:02 +0200 Subject: [PATCH 077/362] chore: rename "Bitcoin Manager" to "Bitcoin Wallet" (#142) * chore: rename "Bitcoin Manager" to "Bitcoin Wallet" * Update README.md Co-authored-by: Charly Chevalier * chore: update description * chore: update description * chore: add missing period * chore: update Snap's manifest description * chore: update README * chore: lint the Snap manifest --------- Co-authored-by: Charly Chevalier --- merged-packages/bitcoin-wallet-snap/README.md | 2 +- merged-packages/bitcoin-wallet-snap/package.json | 6 +++--- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index df6450a7..7aae7adc 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -1,4 +1,4 @@ -# Bitcoin Manager Snap +# Bitcoin Wallet Snap ## Configuration diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 1e492fd6..5167ccdd 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,10 +1,10 @@ { - "name": "@metamask/bitcoin", + "name": "@metamask/bitcoin-wallet-snap", "version": "0.1.2", - "description": "A MetaMask snap to manage Bitcoin", + "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", - "url": "https://github.com/MetaMask/bitcoin.git" + "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "license": "(MIT-0 OR Apache-2.0)", "main": "./dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index d68d25f5..b7ff4eb6 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,18 +1,18 @@ { "version": "0.1.2", - "description": "A MetaMask snap to manage Bitcoin", - "proposedName": "Bitcoin Manager", + "description": "Manage Bitcoin using MetaMask", + "proposedName": "Bitcoin Wallet", "repository": { "type": "git", - "url": "https://github.com/MetaMask/bitcoin.git" + "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "MO1uECXQxFuVEjZuXbUov3P6Sx1Gklzx+8cqYxSYj3U=", + "shasum": "r/KofIlziKWHLZchcutxUdZL8nqhBPXedv1m+WROvoo=", "location": { "npm": { "filePath": "dist/bundle.js", "iconPath": "images/icon.svg", - "packageName": "@metamask/bitcoin", + "packageName": "@metamask/bitcoin-wallet-snap", "registry": "https://registry.npmjs.org/" } } From e8d6ae167abb82c8208fd58f66f61107ad910893 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 4 Jul 2024 23:20:13 +0800 Subject: [PATCH 078/362] chore: move `dependencies` to `devDependencies` (#129) Co-authored-by: Daniel Rocha --- .../bitcoin-wallet-snap/package.json | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 5167ccdd..cf1483d7 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -28,35 +28,31 @@ "start": "mm-snap watch", "test": "jest --passWithNoTests" }, - "dependencies": { - "@bitcoinerlab/secp256k1": "^1.1.1", - "@metamask/key-tree": "^9.0.0", - "@metamask/keyring-api": "^6.3.1", - "@metamask/snaps-sdk": "^4.0.0", - "async-mutex": "^0.3.2", - "big.js": "^6.2.1", - "bip32": "^4.0.0", - "bitcoinjs-lib": "^6.1.5", - "buffer": "^6.0.3", - "coinselect": "^3.1.13", - "ecpair": "^2.1.0", - "superstruct": "^1.0.3", - "uuid": "^9.0.1" - }, "devDependencies": { "@babel/preset-typescript": "^7.23.3", + "@bitcoinerlab/secp256k1": "^1.1.1", "@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/key-tree": "^9.0.0", + "@metamask/keyring-api": "^6.3.1", "@metamask/snaps-cli": "^6.1.0", "@metamask/snaps-jest": "^8.0.0", + "@metamask/snaps-sdk": "^4.0.0", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", + "async-mutex": "^0.3.2", + "big.js": "^6.2.1", "bip174": "^2.1.1", + "bip32": "^4.0.0", + "bitcoinjs-lib": "^6.1.5", + "buffer": "^6.0.3", + "coinselect": "^3.1.13", "dotenv": "^16.4.5", + "ecpair": "^2.1.0", "eslint": "^8.45.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "~2.26.0", @@ -69,8 +65,10 @@ "prettier": "^2.7.1", "prettier-plugin-packagejson": "^2.2.11", "rimraf": "^3.0.2", + "superstruct": "^1.0.3", "ts-jest": "^29.1.0", - "typescript": "^4.7.4" + "typescript": "^4.7.4", + "uuid": "^9.0.1" }, "packageManager": "yarn@3.2.1", "engines": { From e800c3cd5cbdb9bd26cbe07408d137d53870aa11 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 4 Jul 2024 23:45:00 +0800 Subject: [PATCH 079/362] chore: enable get balance api (#126) --- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/src/permissions.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index cf1483d7..e633b655 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -38,7 +38,7 @@ "@metamask/eslint-config-nodejs": "^12.1.0", "@metamask/eslint-config-typescript": "^12.1.0", "@metamask/key-tree": "^9.0.0", - "@metamask/keyring-api": "^6.3.1", + "@metamask/keyring-api": "^8.0.0", "@metamask/snaps-cli": "^6.1.0", "@metamask/snaps-jest": "^8.0.0", "@metamask/snaps-sdk": "^4.0.0", diff --git a/merged-packages/bitcoin-wallet-snap/src/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/permissions.ts index e8af8a24..d7173f57 100644 --- a/merged-packages/bitcoin-wallet-snap/src/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/permissions.ts @@ -18,6 +18,7 @@ const dappPermissions = new Set([ KeyringRpcMethod.ApproveRequest, KeyringRpcMethod.RejectRequest, KeyringRpcMethod.SubmitRequest, + KeyringRpcMethod.GetAccountBalances, // Chain API methods InternalRpcMethod.GetTransactionStatus, ]); From 45ea11ca157ddd93eb5d98b0d10e7dc175b09de3 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 4 Jul 2024 17:48:48 +0200 Subject: [PATCH 080/362] chore: add 'ramps-dev.portfolio.metamask.io' origin (#144) --- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 6 ++++-- merged-packages/bitcoin-wallet-snap/src/permissions.ts | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index b7ff4eb6..aa0ce338 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -21,7 +21,8 @@ "http://localhost:8000": {}, "https://portfolio.metamask.io": {}, "https://portfolio-builds.metafi-dev.codefi.network": {}, - "https://dev.portfolio.metamask.io": {} + "https://dev.portfolio.metamask.io": {}, + "https://ramps-dev.portfolio.metamask.io": {} }, "initialPermissions": { "endowment:rpc": { @@ -33,7 +34,8 @@ "http://localhost:8000", "https://portfolio.metamask.io", "https://portfolio-builds.metafi-dev.codefi.network", - "https://dev.portfolio.metamask.io" + "https://dev.portfolio.metamask.io", + "https://ramps-dev.portfolio.metamask.io" ] }, "snap_getBip32Entropy": [ diff --git a/merged-packages/bitcoin-wallet-snap/src/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/permissions.ts index d7173f57..9a8efa16 100644 --- a/merged-packages/bitcoin-wallet-snap/src/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/permissions.ts @@ -45,6 +45,7 @@ const allowedOrigins = [ 'https://portfolio.metamask.io', 'https://portfolio-builds.metafi-dev.codefi.network', 'https://dev.portfolio.metamask.io', + 'https://ramps-dev.portfolio.metamask.io', ]; const local = 'http://localhost:8000'; From 2c703ab12236c77d4d62c99233c1f9661f263a44 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Jul 2024 18:46:28 +0200 Subject: [PATCH 081/362] 0.2.0 (#146) * 0.2.0 * chore: update changelog * chore(site): update changelog * chore: update package.json URLs --------- Co-authored-by: github-actions Co-authored-by: Charly Chevalier --- .../bitcoin-wallet-snap/CHANGELOG.md | 22 ++++++++++++++++--- .../bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 780a8a4e..ca1548d7 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] + +### Added + +- Add 'ramps-dev.portfolio.metamask.io' origin ([#144](https://github.com/MetaMask/snap-bitcoin-wallet/pull/144)) +- Enable `getAccountBalances` method for Portfolio origins ([#126](https://github.com/MetaMask/snap-bitcoin-wallet/pull/126)) +- Implement Keyring API `getAccountBalances` method ([#84](https://github.com/MetaMask/snap-bitcoin-wallet/pull/84)) +- Implement Chain API `getTransactionStatus` method ([#85](https://github.com/MetaMask/snap-bitcoin-wallet/pull/85)) + +### Changed + +- Rename "Bitcoin Manager" to "Bitcoin Wallet" ([#142](https://github.com/MetaMask/snap-bitcoin-wallet/pull/142)) +- Improve `btc_sendMany` implementation ([#97](https://github.com/MetaMask/snap-bitcoin-wallet/pull/97)) +- Update `btc_sendMany` dialogs ([#83](https://github.com/MetaMask/snap-bitcoin-wallet/pull/83)) + ## [0.1.2] ### Changed @@ -62,6 +77,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/bitcoin/compare/v0.1.2...HEAD -[0.1.2]: https://github.com/MetaMask/bitcoin/compare/v0.1.1...v0.1.2 -[0.1.1]: https://github.com/MetaMask/bitcoin/releases/tag/v0.1.1 +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.1.2...v0.2.0 +[0.1.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.1.1...v0.1.2 +[0.1.1]: https://github.com/MetaMask/snap-bitcoin-wallet/releases/tag/v0.1.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index e633b655..50bc7ba7 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.1.2", + "version": "0.2.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 41ed46b77bd3bae6c1607fe071b1c85f3b03ad8c Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 5 Jul 2024 09:27:48 +0800 Subject: [PATCH 082/362] fix: remove duplicate validation (#137) --- merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts index d9cc8eb5..3df4743f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts @@ -1,5 +1,5 @@ import type { Infer } from 'superstruct'; -import { object, array, record, enums, assert } from 'superstruct'; +import { object, array, record, enums } from 'superstruct'; import type { BtcAccount } from '../bitcoin/wallet'; import { Config } from '../config'; @@ -50,8 +50,6 @@ export async function getBalances( try { validateRequest(params, getBalancesRequestStruct); - assert(params, getBalancesRequestStruct); - const { assets, scope } = params; const chainApi = Factory.createOnChainServiceProvider(scope); From 8d9dfb9dea836d7bbe8f2fd473297a910d5f68e4 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Fri, 5 Jul 2024 16:39:11 +0200 Subject: [PATCH 083/362] ci: update CI based on SSK Snap (#150) * ci: update CI based on SSK Snap * chore: update Snap manifest * chore: add linting and fix errors * ci: build Snap before tests * ci: remove unused secret * ci: cache Snap manifest during publish * chore: add comment about why we cache the manifest file --- .../bitcoin-wallet-snap/.depcheckrc.json | 3 +++ .../bitcoin-wallet-snap/.prettierignore | 3 +++ merged-packages/bitcoin-wallet-snap/package.json | 11 +++++------ .../bitcoin-wallet-snap/snap.manifest.json | 16 ++++++++++++---- 4 files changed, 23 insertions(+), 10 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/.depcheckrc.json create mode 100644 merged-packages/bitcoin-wallet-snap/.prettierignore diff --git a/merged-packages/bitcoin-wallet-snap/.depcheckrc.json b/merged-packages/bitcoin-wallet-snap/.depcheckrc.json new file mode 100644 index 00000000..03d051fa --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/.depcheckrc.json @@ -0,0 +1,3 @@ +{ + "ignores": ["@metamask/auto-changelog"] +} diff --git a/merged-packages/bitcoin-wallet-snap/.prettierignore b/merged-packages/bitcoin-wallet-snap/.prettierignore new file mode 100644 index 00000000..e17a8756 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/.prettierignore @@ -0,0 +1,3 @@ +dist/ +coverage/ +snap.manifest.json diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 50bc7ba7..9088645d 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -19,10 +19,12 @@ "build-preinstalled-snap": "node scripts/build-preinstalled-snap.js", "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": "yarn lint:eslint && yarn lint:misc && yarn lint:deps && yarn lint:types", + "lint:deps": "depcheck", + "lint:eslint": "eslint . --cache --ext js,jsx,ts,tsx", "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", - "lint:misc": "prettier '**/*.ts' '**/*.json' '**/*.md' '!CHANGELOG.md' --ignore-path .gitignore", + "lint:misc": "prettier '**/*.json' '**/*.md' --check", + "lint:types": "tsc --noEmit", "prepublishOnly": "mm-snap manifest", "serve": "mm-snap serve", "start": "mm-snap watch", @@ -46,10 +48,8 @@ "@typescript-eslint/parser": "^5.42.1", "async-mutex": "^0.3.2", "big.js": "^6.2.1", - "bip174": "^2.1.1", "bip32": "^4.0.0", "bitcoinjs-lib": "^6.1.5", - "buffer": "^6.0.3", "coinselect": "^3.1.13", "dotenv": "^16.4.5", "ecpair": "^2.1.0", @@ -63,7 +63,6 @@ "eslint-plugin-promise": "^6.1.1", "jest": "^29.5.0", "prettier": "^2.7.1", - "prettier-plugin-packagejson": "^2.2.11", "rimraf": "^3.0.2", "superstruct": "^1.0.3", "ts-jest": "^29.1.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index aa0ce338..d7575d05 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.1.2", + "version": "0.2.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin Wallet", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "r/KofIlziKWHLZchcutxUdZL8nqhBPXedv1m+WROvoo=", + "shasum": "lwuZCMFLvOOdRK34oPPKNj9kCqedzOR6CLt/ukOP16k=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -40,11 +40,19 @@ }, "snap_getBip32Entropy": [ { - "path": ["m", "84'", "0'"], + "path": [ + "m", + "84'", + "0'" + ], "curve": "secp256k1" }, { - "path": ["m", "84'", "1'"], + "path": [ + "m", + "84'", + "1'" + ], "curve": "secp256k1" } ], From a94a7766c9dd10c9513b7474d9efbbb87cd12347 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 5 Jul 2024 17:07:21 +0200 Subject: [PATCH 084/362] 0.2.1 (#151) * 0.2.1 * chore: update changelog * chore: update Snap checksum * chore: update Snap checksum * chore: update changelog * chore: update changelog --------- Co-authored-by: github-actions Co-authored-by: Daniel Rocha <68558152+danroc@users.noreply.github.com> --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index ca1548d7..12b791f4 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.1] + +### Fixed + +- Remove duplicate validation when getting balances ([#137](https://github.com/MetaMask/snap-bitcoin-wallet/pull/137)) + ## [0.2.0] ### Added @@ -77,7 +83,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.1...HEAD +[0.2.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.1.2...v0.2.0 [0.1.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/MetaMask/snap-bitcoin-wallet/releases/tag/v0.1.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 9088645d..907d95b6 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.2.0", + "version": "0.2.1", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index d7575d05..f03e483a 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.2.0", + "version": "0.2.1", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin Wallet", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "lwuZCMFLvOOdRK34oPPKNj9kCqedzOR6CLt/ukOP16k=", + "shasum": "nesNdD9sbf5hIRuNZuR83IpfHPYs8VcYSwNgMn7doNc=", "location": { "npm": { "filePath": "dist/bundle.js", From 7149fd7bcac52ae484e69ede616a352841d96ded Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Sat, 6 Jul 2024 09:44:17 +0800 Subject: [PATCH 085/362] fix: remove unused env var (#148) --- merged-packages/bitcoin-wallet-snap/snap.config.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts index e0e93521..45b06eed 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.config.ts +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -13,8 +13,6 @@ const config: SnapConfig = { environment: { /* eslint-disable */ LOG_LEVEL: process.env.LOG_LEVEL, - DATA_CLIENT_READ_TYPE: process.env.DATA_CLIENT_READ_TYPE, - DATA_CLIENT_WRITE_TYPE: process.env.DATA_CLIENT_WRITE_TYPE, BLOCKCHAIR_API_KEY: process.env.BLOCKCHAIR_API_KEY, /* eslint-disable */ }, From f96c158f7747b012e844dc3c91332b6b3a80e0d8 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Tue, 9 Jul 2024 10:32:43 +0200 Subject: [PATCH 086/362] fix: emit event on account creation (#153) --- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index f03e483a..d5e89b60 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "nesNdD9sbf5hIRuNZuR83IpfHPYs8VcYSwNgMn7doNc=", + "shasum": "MNqMqbvHPft1pwBj4zRmvCAVw4LBRO41duCJy2HRRJU=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts index b8b1ce06..24c14f2d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts @@ -25,7 +25,7 @@ export async function createAccount( ): Promise { const keyring = new BtcKeyring(new KeyringStateManager(), { defaultIndex: Config.wallet.defaultAccountIndex, - emitEvents: false, + emitEvents: true, }); const account = await keyring.createAccount({ From da569a7e99e5c645d60c8cdeb1883dac405787af Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Jul 2024 10:51:55 +0200 Subject: [PATCH 087/362] 0.2.2 (#154) * 0.2.2 * chore: update changelog * chore: update Snap manifest * chore: update changelog --------- Co-authored-by: github-actions Co-authored-by: Daniel Rocha <68558152+danroc@users.noreply.github.com> --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 13 ++++++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 12b791f4..890a8995 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.2] + +### Changed + +- Remove unused env var ([#148](https://github.com/MetaMask/snap-bitcoin-wallet/pull/148)) + +### Fixed + +- Emit event on account creation ([#153](https://github.com/MetaMask/snap-bitcoin-wallet/pull/153)) + ## [0.2.1] ### Fixed @@ -83,7 +93,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.1...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.2...HEAD +[0.2.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.1...v0.2.2 [0.2.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.1.2...v0.2.0 [0.1.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.1.1...v0.1.2 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 907d95b6..22e763ea 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.2.1", + "version": "0.2.2", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index d5e89b60..49f9ec99 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.2.1", + "version": "0.2.2", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin Wallet", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "MNqMqbvHPft1pwBj4zRmvCAVw4LBRO41duCJy2HRRJU=", + "shasum": "qGXDWw8AhTUTgWbxmoxdQwGymwYH0k+eawCCqT5cPTw=", "location": { "npm": { "filePath": "dist/bundle.js", From ab986998e54b9b48dc4108eb810c976704c0f468 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 9 Jul 2024 17:24:11 +0800 Subject: [PATCH 088/362] fix: audit report 4.24 - remove temporary create account endpoint (#115) * chore: remove temp create account api * chore: remove temp create account api * chore: update test description * chore: update manifest shasum * chore: update manifest shasum * chore: update shasum --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/index.ts | 8 +--- .../bitcoin-wallet-snap/src/keyring.test.ts | 39 +++++++++++++++---- .../bitcoin-wallet-snap/src/keyring.ts | 7 +--- .../bitcoin-wallet-snap/src/permissions.ts | 7 +--- .../src/rpcs/create-account.ts | 36 ----------------- .../bitcoin-wallet-snap/src/rpcs/index.ts | 1 - .../src/utils/__mocks__/snap.ts | 2 + 8 files changed, 39 insertions(+), 63 deletions(-) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 49f9ec99..70a3fe4a 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "qGXDWw8AhTUTgWbxmoxdQwGymwYH0k+eawCCqT5cPTw=", + "shasum": "sTWA05+gr7bzOWcEk9zDAiQbAUEBnYfYsLjJMuS2Zf0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 89782ea8..6ffa5a15 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -11,8 +11,8 @@ import { import { Config } from './config'; import { BtcKeyring } from './keyring'; import { InternalRpcMethod, originPermissions } from './permissions'; -import type { CreateAccountParams, GetTransactionStatusParams } from './rpcs'; -import { createAccount, getTransactionStatus } from './rpcs'; +import type { GetTransactionStatusParams } from './rpcs'; +import { getTransactionStatus } from './rpcs'; import { KeyringStateManager } from './stateManagement'; import { isSnapRpcError, logger } from './utils'; @@ -39,8 +39,6 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ validateOrigin(origin, method); switch (method) { - case InternalRpcMethod.CreateAccount: - return await createAccount(request.params as CreateAccountParams); case InternalRpcMethod.GetTransactionStatus: return await getTransactionStatus( request.params as GetTransactionStatusParams, @@ -72,8 +70,6 @@ export const onKeyringRequest: OnKeyringRequestHandler = async ({ const keyring = new BtcKeyring(new KeyringStateManager(), { defaultIndex: Config.wallet.defaultAccountIndex, - // TODO: Remove temp solution to support keyring in snap without keyring API - emitEvents: true, }); return (await handleKeyringRequest( diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts index 73e842ea..6f7a3f7a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts @@ -310,16 +310,41 @@ describe('BtcKeyring', () => { expect(sendManySpy).toHaveBeenCalledWith(expect.any(BtcAccount), params); }); - it('throws `Account not found` error if the account address not found', async () => { + it('throws `Account not found` error if the account address is not match with the unlocked account', async () => { + const caip2ChainId = Caip2ChainId.Testnet; + const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + const accFromState = generateAccounts(1)[0]; + + getWalletSpy.mockResolvedValue({ + account: accFromState as unknown as KeyringAccount, + index: accFromState.options.index, + scope: accFromState.options.scope, + hdPath: [`m`, `0'`, `0`, `0`].join('/'), + }); + + await expect( + keyring.submitRequest({ + id: uuidv4(), + scope: caip2ChainId, + account: accFromState.id, + request: { + method: 'btc_sendmany', + }, + }), + ).rejects.toThrow('Account not found'); + }); + + it('throws `Account not found` error if the account id not found from state', async () => { const { instance: stateMgr } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); const account = generateAccounts(1)[0]; await expect( keyring.submitRequest({ - id: account.id, + id: uuidv4(), scope: Caip2ChainId.Testnet, - account: account.address, + account: account.id, request: { method: 'btc_sendmany', }, @@ -341,9 +366,9 @@ describe('BtcKeyring', () => { await expect( keyring.submitRequest({ - id: keyringAccount.id, + id: uuidv4(), scope: Caip2ChainId.Mainnet, - account: keyringAccount.address, + account: keyringAccount.id, request: { method: 'btc_sendmany', }, @@ -367,9 +392,9 @@ describe('BtcKeyring', () => { await expect( keyring.submitRequest({ - id: keyringAccount.id, + id: uuidv4(), scope: caip2ChainId, - account: keyringAccount.address, + account: keyringAccount.id, request: { method: 'btc_doesNotExist', }, diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index 7939b997..55c37d69 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -23,8 +23,6 @@ import { getProvider, scopeStruct, logger } from './utils'; export type KeyringOptions = Record & { defaultIndex: number; multiAccount?: boolean; - // TODO: Remove temp solution to support keyring in snap without keyring API - emitEvents?: boolean; }; export const CreateAccountOptionsStruct = object({ @@ -188,10 +186,7 @@ export class BtcKeyring implements Keyring { event: KeyringEvent, data: Record, ): Promise { - // TODO: Remove temp solution to support keyring in snap without extentions support - if (this._options.emitEvents) { - await emitSnapKeyringEvent(getProvider(), event, data); - } + await emitSnapKeyringEvent(getProvider(), event, data); } async getAccountBalances( diff --git a/merged-packages/bitcoin-wallet-snap/src/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/permissions.ts index 9a8efa16..b757005b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/permissions.ts @@ -2,7 +2,6 @@ import { KeyringRpcMethod } from '@metamask/keyring-api'; export enum InternalRpcMethod { GetTransactionStatus = 'chain_getTransactionStatus', - CreateAccount = 'chain_createAccount', } const dappPermissions = new Set([ @@ -45,10 +44,10 @@ const allowedOrigins = [ 'https://portfolio.metamask.io', 'https://portfolio-builds.metafi-dev.codefi.network', 'https://dev.portfolio.metamask.io', + 'http://localhost:8000', 'https://ramps-dev.portfolio.metamask.io', ]; -const local = 'http://localhost:8000'; const metamask = 'metamask'; export const originPermissions = new Map>([]); @@ -57,7 +56,3 @@ for (const origin of allowedOrigins) { originPermissions.set(origin, dappPermissions); } originPermissions.set(metamask, metamaskPermissions); -originPermissions.set( - local, - new Set([...dappPermissions, InternalRpcMethod.CreateAccount]), -); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts deleted file mode 100644 index 24c14f2d..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/create-account.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; -import { object, type Infer } from 'superstruct'; - -import { Config } from '../config'; -import { BtcKeyring } from '../keyring'; -import { KeyringStateManager } from '../stateManagement'; -import { scopeStruct } from '../utils'; - -export const CreateAccountParamsStruct = object({ - scope: scopeStruct, -}); - -export type CreateAccountParams = Infer; - -export type CreateAccountResponse = KeyringAccount; - -/** - * Creates a new account with the specified parameters. - * - * @param params - The parameters for creating the account. - * @returns A Promise that resolves to the new account. - */ -export async function createAccount( - params: CreateAccountParams, -): Promise { - const keyring = new BtcKeyring(new KeyringStateManager(), { - defaultIndex: Config.wallet.defaultAccountIndex, - emitEvents: true, - }); - - const account = await keyring.createAccount({ - scope: params.scope, - }); - - return account; -} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts index 5dd3f86e..22cad753 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts @@ -1,4 +1,3 @@ -export * from './create-account'; export * from './get-balances'; export * from './get-transaction-status'; export * from './sendmany'; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts index 1dd67d68..67c7e8f3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts @@ -3,6 +3,8 @@ import { networks } from 'bitcoinjs-lib'; import { createRandomBip32Data } from '../../../test/utils'; +export const getProvider = jest.fn(); + /** * Retrieves a SLIP10NodeInterface object for the specified path and curve. * From 615fcf66bf496ce58554578da32a4402334feb4c Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 10 Jul 2024 12:53:22 +0800 Subject: [PATCH 089/362] chore: update permission list for localhost (#147) * fix: update permission list --- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 6 +++--- merged-packages/bitcoin-wallet-snap/src/index.test.ts | 4 ++-- merged-packages/bitcoin-wallet-snap/src/permissions.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 70a3fe4a..ff660ad6 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "sTWA05+gr7bzOWcEk9zDAiQbAUEBnYfYsLjJMuS2Zf0=", + "shasum": "rUCkpNpfbKStO1+U5i/JrdZ0Xebbt+FrGWhcola7qtQ=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -18,7 +18,7 @@ } }, "initialConnections": { - "http://localhost:8000": {}, + "http://localhost:3000": {}, "https://portfolio.metamask.io": {}, "https://portfolio-builds.metafi-dev.codefi.network": {}, "https://dev.portfolio.metamask.io": {}, @@ -31,7 +31,7 @@ }, "endowment:keyring": { "allowedOrigins": [ - "http://localhost:8000", + "http://localhost:3000", "https://portfolio.metamask.io", "https://portfolio-builds.metafi-dev.codefi.network", "https://dev.portfolio.metamask.io", diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts index 34620274..c563bab7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.ts @@ -54,7 +54,7 @@ describe('onRpcRequest', () => { jest.spyOn(entry, 'validateOrigin').mockReturnThis(); return onRpcRequest({ - origin: 'http://localhost:8000', + origin: 'https://portfolio.metamask.io', request: { method, params: { @@ -106,7 +106,7 @@ describe('onKeyringRequest', () => { const executeRequest = async () => { return onKeyringRequest({ - origin: 'http://localhost:8000', + origin: 'https://portfolio.metamask.io', request: { method: keyringApi.KeyringRpcMethod.ListAccounts, params: { diff --git a/merged-packages/bitcoin-wallet-snap/src/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/permissions.ts index b757005b..67b81d04 100644 --- a/merged-packages/bitcoin-wallet-snap/src/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/permissions.ts @@ -44,7 +44,7 @@ const allowedOrigins = [ 'https://portfolio.metamask.io', 'https://portfolio-builds.metafi-dev.codefi.network', 'https://dev.portfolio.metamask.io', - 'http://localhost:8000', + 'http://localhost:3000', 'https://ramps-dev.portfolio.metamask.io', ]; From 19ceaa7e82eff0489237b6cf6e5c0c4fc9192e32 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 10 Jul 2024 17:08:22 +0800 Subject: [PATCH 090/362] fix: typo (#119) --- .../src/bitcoin/wallet/utils/address.test.ts | 8 ++++---- .../src/bitcoin/wallet/utils/address.ts | 2 +- .../bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.test.ts index e12050c8..e241dfe5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.test.ts @@ -1,20 +1,20 @@ import { networks } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; -import { getScriptForDestnation } from './address'; +import { getScriptForDestination } from './address'; describe('address', () => { const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; - describe('getScriptForDestnation', () => { + describe('getScriptForDestination', () => { it('returns a script', () => { - const val = getScriptForDestnation(address, networks.testnet); + const val = getScriptForDestination(address, networks.testnet); expect(val).toBeInstanceOf(Buffer); }); it('throws `Destination address has no matching Script` error if the given address is invalid', () => { expect(() => - getScriptForDestnation('bad-address', networks.testnet), + getScriptForDestination('bad-address', networks.testnet), ).toThrow(`Destination address has no matching Script`); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.ts index c4736482..6390032e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.ts @@ -8,7 +8,7 @@ import { address as addressUtils, type Network } from 'bitcoinjs-lib'; * @returns The Bitcoin script for the destination address. * @throws An error if the address does not have a matching script. */ -export function getScriptForDestnation(address: string, network: Network) { +export function getScriptForDestination(address: string, network: Network) { try { return addressUtils.toOutputScript(address, network); } catch (error) { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 24c2023f..4f5491f8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -18,7 +18,7 @@ import { AccountSigner } from './signer'; import { TxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; -import { isDust, getScriptForDestnation } from './utils'; +import { isDust, getScriptForDestination } from './utils'; export type Recipient = { address: string; @@ -110,14 +110,14 @@ export class BtcWallet { if (isDust(recipient.value, scriptType)) { throw new TxValidationError('Transaction amount too small'); } - const destnationScriptOutput = getScriptForDestnation( + const destinationScriptOutput = getScriptForDestination( recipient.address, this._network, ); return new TxOutput( recipient.value, recipient.address, - destnationScriptOutput, + destinationScriptOutput, ); }); From 6d27b0ca8ceae838ec572da58dc7ea53ce2e2dc4 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 11 Jul 2024 17:21:45 +0800 Subject: [PATCH 091/362] fix: audit report 4.14 - rename `txHash` to `signedTransaction` (#120) * chore: rename txhash to signedTransaction * chore: rename txhash to signedTransaction * chore: update shasum * chore: update shasum * chore: rollback shasum --- merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 8ce5d6ea..2a84c219 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -79,7 +79,7 @@ export const sendManyParamsStruct = object({ export const sendManyResponseStruct = object({ txId: nonempty(string()), - txHash: optional(string()), + signedTransaction: optional(string()), }); export type SendManyParams = Infer; @@ -132,16 +132,16 @@ export async function sendMany(account: BtcAccount, params: SendManyParams) { throw new UserRejectedRequestError() as unknown as Error; } - const txHash = await wallet.signTransaction(account.signer, tx); + const signedTransaction = await wallet.signTransaction(account.signer, tx); if (dryrun) { return { txId: '', - txHash, + signedTransaction, }; } - const result = await chainApi.broadcastTransaction(txHash); + const result = await chainApi.broadcastTransaction(signedTransaction); const resp = { txId: result.transactionId, From c563698f9d892df684715e82b03fd82e1cbfb8c7 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 11 Jul 2024 17:28:28 +0800 Subject: [PATCH 092/362] fix: audit report 4.13 - add a safeguard for change output (#135) * fix: add safeguard for coin select service * fix: add safeguard for coin select service * fix: add context on coin-select change output validation * fix: add safeguard for coin select service * fix: add context on coin-select change output validation * chore: update shasum * chore: update error message --- .../bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index bcfe0d97..44d166f1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -49,6 +49,11 @@ export class CoinSelectService { if (output.address) { selectedResult.outputs.push(output); } else { + // We only support 1 change output, so we do check if there are more than 1 + // and raise an error to avoid overwriting it + if (selectedResult.change !== undefined) { + throw new Error('Unexpected error: found more than 1 change output'); + } changeTo.value = output.value; selectedResult.change = changeTo; } From 414671bdc361363441a5d6247926dacf32db77f7 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 11 Jul 2024 17:40:15 +0800 Subject: [PATCH 093/362] fix: audit report 4.20 - validate `headLength` and `tailLength` in `replaceMiddleChar` (#138) * fix: add validation in replaceMiddleChar * fix: add validation in replaceMiddleChar * fix: add validation on replaceMiddleChar * fix: add validation in replaceMiddleChar * fix: add validation on replaceMiddleChar * chore: update shasum --- .../src/utils/string.test.ts | 18 ++++++++++++++++++ .../bitcoin-wallet-snap/src/utils/string.ts | 10 +++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts index b4c14a31..9807d13c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts @@ -68,6 +68,24 @@ describe('replaceMiddleChar', () => { it('does not replace if the string is empty', () => { expect(replaceMiddleChar('', 5, 3)).toBe(''); }); + + it('throws `Indexes must be positives` error if headLength or tailLength is negative value', () => { + expect(() => replaceMiddleChar(str, -1, 20)).toThrow( + 'Indexes must be positives', + ); + expect(() => replaceMiddleChar(str, 20, -10)).toThrow( + 'Indexes must be positives', + ); + }); + + it('throws `Indexes out of bounds` error if headLength + tailLength is out of bounds', () => { + expect(() => replaceMiddleChar(str, 100, 0)).toThrow( + 'Indexes out of bounds', + ); + expect(() => replaceMiddleChar(str, 0, 100)).toThrow( + 'Indexes out of bounds', + ); + }); }); describe('shortenAddress', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts index 8dc990be..ede577bc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts @@ -49,6 +49,7 @@ export function bufferToString(buffer: Buffer, encoding: BufferEncoding) { * @param tailLength - The length of the tail of the string that should not be replaced. * @param replaceStr - The string to replace the middle characters with. Default is '...'. * @returns The formatted string. + * @throws An error if the given headLength and tailLength cannot be replaced. */ export function replaceMiddleChar( str: string, @@ -59,7 +60,14 @@ export function replaceMiddleChar( if (!str) { return str; } - + // Enforces indexes to be positive to avoid parameter swapping in `.substring` + if (headLength < 0 || tailLength < 0) { + throw new Error('Indexes must be positives'); + } + // Check upper bound (using + is safe here, since we know that both lengths are positives) + if (headLength + tailLength > str.length) { + throw new Error('Indexes out of bounds'); + } return `${str.substring(0, headLength)}${replaceStr}${str.substring( str.length - tailLength, )}`; From 601d7f7db5d8d8f755719eb17dfb26bc3e95f141 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 11 Jul 2024 17:48:14 +0800 Subject: [PATCH 094/362] fix: audit report 4.23 - disable logging for production builds (#124) * chore: update default log config * chore: update default log config * chore: update shasum --- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- merged-packages/bitcoin-wallet-snap/src/config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index ff660ad6..343c4c64 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "rUCkpNpfbKStO1+U5i/JrdZ0Xebbt+FrGWhcola7qtQ=", + "shasum": "676SeZJht54ftPPaQWZaCdH1i/JH+AcadfMb2m3Pqds=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 3f17567d..34546551 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -45,5 +45,5 @@ export const Config: SnapConfig = { 'https://blockstream.info/testnet/address/${address}', }, // eslint-disable-next-line no-restricted-globals - logLevel: process.env.LOG_LEVEL ?? '6', + logLevel: process.env.LOG_LEVEL ?? '0', }; From ff0e2df171ec2fc76de8e6f38bfc6f44140729cc Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 11 Jul 2024 17:48:38 +0800 Subject: [PATCH 095/362] fix: audit report 4.9 - validate non-hex string in `hexToBuffer` (#134) * fix: add validation on hex string for hexToBuffer * fix: add validation on hex string for hexToBuffer * fix: use metamask utils * fix: package.json lint * chore: update shasum --- .../bitcoin-wallet-snap/package.json | 1 + .../src/utils/string.test.ts | 23 +++++-------------- .../bitcoin-wallet-snap/src/utils/string.ts | 14 ++++------- 3 files changed, 11 insertions(+), 27 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 22e763ea..1b8dac91 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -44,6 +44,7 @@ "@metamask/snaps-cli": "^6.1.0", "@metamask/snaps-jest": "^8.0.0", "@metamask/snaps-sdk": "^4.0.0", + "@metamask/utils": "^9.0.0", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", "async-mutex": "^0.3.2", diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts index 9807d13c..eb394277 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts @@ -1,29 +1,12 @@ import { Buffer } from 'buffer'; import { - trimHexPrefix, hexToBuffer, bufferToString, replaceMiddleChar, shortenAddress, } from './string'; -describe('trimHexPrefix', () => { - it('trims hex prefix', () => { - const key = '0x1234'; - const result = trimHexPrefix(key); - - expect(result).toBe('1234'); - }); - - it('returns key as is if it does not have hex prefix', () => { - const key = '1234'; - const result = trimHexPrefix(key); - - expect(result).toBe('1234'); - }); -}); - describe('hexToBuffer', () => { it('converts a hex string to buffer with trimed prefix', () => { const key = '0x1234'; @@ -44,6 +27,12 @@ describe('hexToBuffer', () => { 'Unable to convert hex string to buffer', ); }); + + it('throws `Unable to convert hex string to buffer` error if the given string is not a hex string', () => { + expect(() => hexToBuffer('hello123')).toThrow( + 'Unable to convert hex string to buffer', + ); + }); }); describe('bufferToString', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts index ede577bc..7941f936 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts @@ -1,13 +1,6 @@ +import { remove0x, HexStruct } from '@metamask/utils'; import { Buffer } from 'buffer'; -/** - * Removes the '0x' prefix from a given hex string. - * - * @param hexStr - The hex string to remove the prefix from. - * @returns The hex string without the prefix. - */ -export function trimHexPrefix(hexStr: string) { - return hexStr.startsWith('0x') ? hexStr.substring(2) : hexStr; -} +import { assert } from 'superstruct'; /** * Converts a hex string to a buffer instance. @@ -19,7 +12,8 @@ export function trimHexPrefix(hexStr: string) { */ export function hexToBuffer(hexStr: string, trimPrefix = true) { try { - return Buffer.from(trimPrefix ? trimHexPrefix(hexStr) : hexStr, 'hex'); + assert(hexStr, HexStruct); + return Buffer.from(trimPrefix ? remove0x(hexStr) : hexStr, 'hex'); } catch (error) { throw new Error('Unable to convert hex string to buffer'); } From 7e44c252796f8b3abdc5b0cd81250ee5ccd0acc7 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 11 Jul 2024 17:58:17 +0800 Subject: [PATCH 096/362] fix: audit report 4.4 - restrict permissions for MetaMask origin (#141) * fix: remove un use permission for mm origin * chore: update unit test * fix: update test case description * fix: remove un use permission for mm origin * chore: update unit test * fix: update test case description * chore: update shasum * chore: add test case for permission --- .../bitcoin-wallet-snap/src/index.test.ts | 58 +++++++++++++++++-- .../bitcoin-wallet-snap/src/permissions.ts | 8 --- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts index c563bab7..5adadaa2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.ts @@ -10,7 +10,7 @@ import * as entry from '.'; import { TransactionStatus } from './bitcoin/chain'; import { Config } from './config'; import { BtcKeyring } from './keyring'; -import { originPermissions } from './permissions'; +import { InternalRpcMethod, originPermissions } from './permissions'; import * as getTxStatusRpc from './rpcs/get-transaction-status'; jest.mock('./utils/logger'); @@ -21,7 +21,7 @@ jest.mock('@metamask/keyring-api', () => ({ })); describe('validateOrigin', () => { - it('does not throws error if the origin and method is match to the allowed list', () => { + it('does not throw error if the origin and method is in the allowed list', () => { const [origin, methods]: [string, Set] = originPermissions .entries() .next().value; @@ -35,13 +35,13 @@ describe('validateOrigin', () => { ); }); - it('throws `Permission denied` error if origin not match to the allowed list', () => { + it('throws `Permission denied` error if origin not in the allowed list', () => { expect(() => validateOrigin('xyz', 'chain_getTransactionStatus')).toThrow( 'Permission denied', ); }); - it('throws `Permission denied` error if the method is not match to the allowed list', () => { + it('throws `Permission denied` error if the method is not in the allowed list', () => { const elm = originPermissions.entries().next().value; expect(() => validateOrigin(elm[0], 'some_method')).toThrow( 'Permission denied', @@ -129,6 +129,31 @@ describe('onKeyringRequest', () => { }); }); + it('does not throw `Permission denied` error if the method is in the allowed list', async () => { + const { handler } = createMockHandleKeyringRequest(); + handler.mockResolvedValue({}); + + for (const method of [ + keyringApi.KeyringRpcMethod.ListAccounts, + keyringApi.KeyringRpcMethod.GetAccount, + keyringApi.KeyringRpcMethod.CreateAccount, + keyringApi.KeyringRpcMethod.FilterAccountChains, + keyringApi.KeyringRpcMethod.DeleteAccount, + keyringApi.KeyringRpcMethod.GetAccountBalances, + ]) { + const result = await onKeyringRequest({ + origin: 'metamask', + request: { + method, + params: { + scope: Config.avaliableNetworks[0], + }, + } as unknown as JsonRpcRequest, + }); + expect(result).toStrictEqual({}); + } + }); + it('throws SnapError if an error catched', async () => { const { handler } = createMockHandleKeyringRequest(); handler.mockRejectedValue(new Error('error')); @@ -142,4 +167,29 @@ describe('onKeyringRequest', () => { await expect(executeRequest()).rejects.toThrow(SnapError); }); + + it('throws `Permission denied` error if the method is not in the allowed list', async () => { + for (const method of [ + keyringApi.KeyringRpcMethod.SubmitRequest, + keyringApi.KeyringRpcMethod.ApproveRequest, + keyringApi.KeyringRpcMethod.RejectRequest, + keyringApi.KeyringRpcMethod.GetRequest, + keyringApi.KeyringRpcMethod.ListRequests, + keyringApi.KeyringRpcMethod.ExportAccount, + keyringApi.KeyringRpcMethod.UpdateAccount, + InternalRpcMethod.GetTransactionStatus, + ]) { + await expect( + onKeyringRequest({ + origin: 'metamask', + request: { + method, + params: { + scope: Config.avaliableNetworks[0], + }, + } as unknown as JsonRpcRequest, + }), + ).rejects.toThrow('Permission denied'); + } + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/permissions.ts index 67b81d04..f9bc4e04 100644 --- a/merged-packages/bitcoin-wallet-snap/src/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/permissions.ts @@ -28,16 +28,8 @@ const metamaskPermissions = new Set([ KeyringRpcMethod.GetAccount, KeyringRpcMethod.CreateAccount, KeyringRpcMethod.FilterAccountChains, - KeyringRpcMethod.UpdateAccount, KeyringRpcMethod.DeleteAccount, - KeyringRpcMethod.ListRequests, - KeyringRpcMethod.GetRequest, - KeyringRpcMethod.ApproveRequest, - KeyringRpcMethod.RejectRequest, - KeyringRpcMethod.SubmitRequest, KeyringRpcMethod.GetAccountBalances, - // Chain API methods - InternalRpcMethod.GetTransactionStatus, ]); const allowedOrigins = [ From 8aff91bb2dc3c70ba73a2dd7f7d7f54fd5185eac Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 11 Jul 2024 19:26:28 +0800 Subject: [PATCH 097/362] fix: audit report 4.8 - enforce request.method in submit request is in account.methods (#133) * fix: enforce request method is allowed from the account * fix: enforce request method is allowed from the account * fix: update unit test for keyring * fix: enforce request method is allowed from the account * fix: update unit test for keyring * chore: update shasum * chore: add comment on keyring test --- .../bitcoin-wallet-snap/src/keyring.test.ts | 30 ++++++++++++++++++- .../bitcoin-wallet-snap/src/keyring.ts | 10 +++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts index 6f7a3f7a..42bb8bec 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts @@ -383,6 +383,9 @@ describe('BtcKeyring', () => { const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); const { sender, keyringAccount } = await createSender(caip2ChainId); + // This method will not be dispatched in `handleSubmitRequest` and will throw a `MethodNotFoundError` if used + keyringAccount.methods = ['btc_notImplemented']; + getWalletSpy.mockResolvedValue({ account: keyringAccount as unknown as KeyringAccount, index: sender.index, @@ -396,11 +399,36 @@ describe('BtcKeyring', () => { scope: caip2ChainId, account: keyringAccount.id, request: { - method: 'btc_doesNotExist', + method: 'btc_notImplemented', }, }), ).rejects.toThrow(MethodNotFoundError); }); + + it('throws `Forbidden method` error if the method is not allowed from the account', async () => { + const caip2ChainId = Caip2ChainId.Testnet; + const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + const { sender, keyringAccount } = await createSender(caip2ChainId); + + getWalletSpy.mockResolvedValue({ + account: keyringAccount as unknown as KeyringAccount, + index: sender.index, + scope: keyringAccount.options.scope, + hdPath: sender.hdPath, + }); + + await expect( + keyring.submitRequest({ + id: keyringAccount.id, + scope: caip2ChainId, + account: keyringAccount.address, + request: { + method: 'btc_methodDoesNotExist', + }, + }), + ).rejects.toThrow('Forbidden method'); + }); }); describe('getAccountBalances', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index 55c37d69..44349c17 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -170,6 +170,7 @@ export class BtcKeyring implements Keyring { ); this.verifyIfAccountValid(account, walletData.account); + this.verifyIfMethodValid(method, walletData.account); switch (method) { case 'btc_sendmany': @@ -246,6 +247,15 @@ export class BtcKeyring implements Keyring { } } + protected verifyIfMethodValid( + method: string, + keyringAccount: KeyringAccount, + ): void { + if (!keyringAccount.methods.includes(method)) { + throw new Error('Forbidden method'); + } + } + protected newKeyringAccount( account: BtcAccount, options?: CreateAccountOptions, From 6135bc30bf556d65dbbbf126935797d280aff68a Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 11 Jul 2024 21:40:38 +0800 Subject: [PATCH 098/362] fix: audit report 4.3 - restrict permissions for Portfolio origin (#131) * fix: remove un use permission for dapp origin * fix: remove un use permission for dapp origin * chore: update unit test * chore: remove duplicate test * fix: remove un use permission for dapp origin * chore: update unit test * chore: update shasum * chore: update test description * chore: remove commented out line * chore: remove duplicate lines * test: add `getTransactionStatus` to test --------- Co-authored-by: Daniel Rocha <68558152+danroc@users.noreply.github.com> --- .../bitcoin-wallet-snap/src/index.test.ts | 56 ++++++++++++++++++- .../bitcoin-wallet-snap/src/permissions.ts | 10 +--- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts index 5adadaa2..a4a9c5dc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.ts @@ -129,7 +129,31 @@ describe('onKeyringRequest', () => { }); }); - it('does not throw `Permission denied` error if the method is in the allowed list', async () => { + it('does not throw a `Permission denied` error if the dapp origin requests a method that is on the allowed list', async () => { + const { handler } = createMockHandleKeyringRequest(); + handler.mockResolvedValue({}); + + for (const method of [ + keyringApi.KeyringRpcMethod.ListAccounts, + keyringApi.KeyringRpcMethod.GetAccount, + keyringApi.KeyringRpcMethod.GetAccountBalances, + keyringApi.KeyringRpcMethod.SubmitRequest, + InternalRpcMethod.GetTransactionStatus, + ]) { + const result = await onKeyringRequest({ + origin: 'https://portfolio.metamask.io', + request: { + method, + params: { + scope: Config.avaliableNetworks[0], + }, + } as unknown as JsonRpcRequest, + }); + expect(result).toStrictEqual({}); + } + }); + + it('does not throw a `Permission denied` error if the MetaMask origin requests a method that is on the allowed list', async () => { const { handler } = createMockHandleKeyringRequest(); handler.mockResolvedValue({}); @@ -168,7 +192,35 @@ describe('onKeyringRequest', () => { await expect(executeRequest()).rejects.toThrow(SnapError); }); - it('throws `Permission denied` error if the method is not in the allowed list', async () => { + it('throws a `Permission denied` error if the dapp origin requests a method that is not on the allowed list', async () => { + const { handler } = createMockHandleKeyringRequest(); + handler.mockResolvedValue({}); + + for (const method of [ + keyringApi.KeyringRpcMethod.CreateAccount, + keyringApi.KeyringRpcMethod.FilterAccountChains, + keyringApi.KeyringRpcMethod.UpdateAccount, + keyringApi.KeyringRpcMethod.DeleteAccount, + keyringApi.KeyringRpcMethod.ListRequests, + keyringApi.KeyringRpcMethod.GetRequest, + keyringApi.KeyringRpcMethod.ApproveRequest, + keyringApi.KeyringRpcMethod.RejectRequest, + ]) { + await expect( + onKeyringRequest({ + origin: 'https://portfolio.metamask.io', + request: { + method, + params: { + scope: Config.avaliableNetworks[0], + }, + } as unknown as JsonRpcRequest, + }), + ).rejects.toThrow('Permission denied'); + } + }); + + it('throws a `Permission denied` error if the MetaMask origin requests a method that is not on the allowed list', async () => { for (const method of [ keyringApi.KeyringRpcMethod.SubmitRequest, keyringApi.KeyringRpcMethod.ApproveRequest, diff --git a/merged-packages/bitcoin-wallet-snap/src/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/permissions.ts index f9bc4e04..ae3a6bc0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/permissions.ts @@ -8,16 +8,8 @@ const dappPermissions = new Set([ // Keyring methods KeyringRpcMethod.ListAccounts, KeyringRpcMethod.GetAccount, - KeyringRpcMethod.CreateAccount, - KeyringRpcMethod.FilterAccountChains, - // KeyringRpcMethod.UpdateAccount, - // KeyringRpcMethod.DeleteAccount, - KeyringRpcMethod.ListRequests, - KeyringRpcMethod.GetRequest, - KeyringRpcMethod.ApproveRequest, - KeyringRpcMethod.RejectRequest, - KeyringRpcMethod.SubmitRequest, KeyringRpcMethod.GetAccountBalances, + KeyringRpcMethod.SubmitRequest, // Chain API methods InternalRpcMethod.GetTransactionStatus, ]); From 0345f8c640303533992b6f36fa880785b6497a0e Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 11 Jul 2024 21:45:10 +0800 Subject: [PATCH 099/362] fix: audit report 4.2 - remove update account method (#130) * chore: remove update account method * fix: lint style * chore: remove update account method * fix: lint style * chore: update shasum * chore: remove lint warning --------- Co-authored-by: Daniel Rocha <68558152+danroc@users.noreply.github.com> --- .../bitcoin-wallet-snap/src/keyring.test.ts | 20 +++++-------------- .../bitcoin-wallet-snap/src/keyring.ts | 13 ++---------- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts index 42bb8bec..17bce549 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts @@ -227,24 +227,14 @@ describe('BtcKeyring', () => { }); describe('updateAccount', () => { - it('updates account', async () => { - const { instance: stateMgr, updateAccountSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const account = generateAccounts(1)[0]; - updateAccountSpy.mockReturnThis(); - - await keyring.updateAccount(account); - - expect(updateAccountSpy).toHaveBeenCalledWith(account); - }); - - it('throws Error if an error catched', async () => { - const { instance: stateMgr, updateAccountSpy } = createMockStateMgr(); + it('throws `Method not implemented` error', async () => { + const { instance: stateMgr } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); - updateAccountSpy.mockRejectedValue(new Error('error')); const account = generateAccounts(1)[0]; - await expect(keyring.updateAccount(account)).rejects.toThrow(Error); + await expect(keyring.updateAccount(account)).rejects.toThrow( + 'Method not implemented.', + ); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index 44349c17..65adee5c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -117,17 +117,8 @@ export class BtcKeyring implements Keyring { throw new Error('Method not implemented.'); } - async updateAccount(account: KeyringAccount): Promise { - try { - await this._stateMgr.withTransaction(async () => { - await this._stateMgr.updateAccount(account); - await this.#emitEvent(KeyringEvent.AccountUpdated, { account }); - }); - } catch (error) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.info(`[BtcKeyring.updateAccount] Error: ${error.message}`); - throw new Error(error); - } + async updateAccount(_account: KeyringAccount): Promise { + throw new Error('Method not implemented.'); } async deleteAccount(id: string): Promise { From 62941b88d2fe259c6b6ea8c95b18bdb53a4761b8 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Thu, 11 Jul 2024 15:58:29 +0200 Subject: [PATCH 100/362] ui: change Snap name (#158) --- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 343c4c64..1f6a131e 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,7 +1,7 @@ { "version": "0.2.2", "description": "Manage Bitcoin using MetaMask", - "proposedName": "Bitcoin Wallet", + "proposedName": "Bitcoin Manager", "repository": { "type": "git", "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" From 13debc5062f910c12ce3f235b915c44586e3def5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Jul 2024 15:43:53 +0000 Subject: [PATCH 101/362] 0.2.3 (#160) * 0.2.3 * chore: update Snap manifest * chore: update changelog * chore: sort audit PRs * chore: remove "typo" PR from changelog * chore: update changelog --------- Co-authored-by: github-actions Co-authored-by: Daniel Rocha <68558152+danroc@users.noreply.github.com> --- .../bitcoin-wallet-snap/CHANGELOG.md | 23 ++++++++++++++++++- .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 890a8995..c6c8bd1d 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.3] + +### Changed + +- Change Snap name to "Bitcoin Manager" ([#158](https://github.com/MetaMask/snap-bitcoin-wallet/pull/158)) +- Change local dapp port ([#147](https://github.com/MetaMask/snap-bitcoin-wallet/pull/147)) + +### Fixed + +- Audit 4.2: Remove update account method ([#130](https://github.com/MetaMask/snap-bitcoin-wallet/pull/130)) +- Audit 4.3: Restrict permissions for Portfolio origin ([#131](https://github.com/MetaMask/snap-bitcoin-wallet/pull/131)) +- Audit 4.4: Restrict permissions for MetaMask origin ([#141](https://github.com/MetaMask/snap-bitcoin-wallet/pull/141)) +- Audit 4.8: Ensure that `request.method` in submit request is part of `account.methods` ([#133](https://github.com/MetaMask/snap-bitcoin-wallet/pull/133)) +- Audit 4.9: Validate non-hex string in `hexToBuffer` ([#134](https://github.com/MetaMask/snap-bitcoin-wallet/pull/134)) +- Audit 4.13: Add a safeguard for change output ([#135](https://github.com/MetaMask/snap-bitcoin-wallet/pull/135)) +- Audit 4.14: Rename `txHash` to `signedTransaction` ([#120](https://github.com/MetaMask/snap-bitcoin-wallet/pull/120)) +- Audit 4.20: Validate `headLength` and `tailLength` in `replaceMiddleChar` ([#138](https://github.com/MetaMask/snap-bitcoin-wallet/pull/138)) +- Audit 4.23: Disable logging for production builds ([#124](https://github.com/MetaMask/snap-bitcoin-wallet/pull/124)) +- Audit 4.24: Remove temporary create account endpoint ([#115](https://github.com/MetaMask/snap-bitcoin-wallet/pull/115)) + ## [0.2.2] ### Changed @@ -93,7 +113,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.2...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.3...HEAD +[0.2.3]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.2...v0.2.3 [0.2.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.1...v0.2.2 [0.2.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.1.2...v0.2.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 1b8dac91..cc848832 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.2.2", + "version": "0.2.3", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 1f6a131e..ba534392 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.2.2", + "version": "0.2.3", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin Manager", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "676SeZJht54ftPPaQWZaCdH1i/JH+AcadfMb2m3Pqds=", + "shasum": "ipmE94pI4hu0ukG5y1MoUgoIS1GFZBCxuIMZSI83uqA=", "location": { "npm": { "filePath": "dist/bundle.js", From be81fce7d350934ba1152b5ac996a4578cd82337 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Fri, 12 Jul 2024 10:28:27 +0200 Subject: [PATCH 102/362] chore: update MetaMask dependencies (#162) * chore: update MetaMask dependencies * chore: use matching `snaps-sdk` version * chore: update `yarn.lock` * chore: update Snap hash * chore: update snaps-sdk * chore: update Snap hash --- merged-packages/bitcoin-wallet-snap/package.json | 8 ++++---- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index cc848832..1de478f6 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -39,11 +39,11 @@ "@metamask/eslint-config-jest": "^12.1.0", "@metamask/eslint-config-nodejs": "^12.1.0", "@metamask/eslint-config-typescript": "^12.1.0", - "@metamask/key-tree": "^9.0.0", + "@metamask/key-tree": "^9.1.2", "@metamask/keyring-api": "^8.0.0", - "@metamask/snaps-cli": "^6.1.0", - "@metamask/snaps-jest": "^8.0.0", - "@metamask/snaps-sdk": "^4.0.0", + "@metamask/snaps-cli": "^6.2.1", + "@metamask/snaps-jest": "^8.2.0", + "@metamask/snaps-sdk": "^4.4.2", "@metamask/utils": "^9.0.0", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index ba534392..66d5e20f 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "ipmE94pI4hu0ukG5y1MoUgoIS1GFZBCxuIMZSI83uqA=", + "shasum": "8LQeRbeTgPdJ25O+OQZt4OfTr9024WMeWuti1Gb0twY=", "location": { "npm": { "filePath": "dist/bundle.js", From 4d16f47e89e5419de8be00ba08a6b58907cc6ac1 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 12 Jul 2024 16:53:05 +0800 Subject: [PATCH 103/362] fix: audit report 4.7 - potential URL injections in Blockchair API calls (#132) * fix: potential url injections in blockchair api calls * fix: potential url injections in blockchair api calls * fix: update unit text context * fix: potential url injections in blockchair api calls * fix: update unit text context * chore: update shasum * chore: update blockchair unit test * chore: simplify blockchair testing code --- .../bitcoin/chain/clients/blockchair.test.ts | 57 +++++++++++++++---- .../src/bitcoin/chain/clients/blockchair.ts | 10 +++- .../src/rpcs/get-transaction-status.test.ts | 18 +++++- .../src/rpcs/get-transaction-status.ts | 5 +- .../src/utils/superstruct.ts | 2 + 5 files changed, 76 insertions(+), 16 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts index 6b81a97d..904bd773 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts @@ -1,18 +1,21 @@ import { networks } from 'bitcoinjs-lib'; import { - generateAccounts, generateBlockChairBroadcastTransactionResp, generateBlockChairGetBalanceResp, generateBlockChairGetUtxosResp, generateBlockChairGetStatsResp, generateBlockChairTransactionDashboard, } from '../../../../test/utils'; +import { Config } from '../../../config'; +import type { BtcAccount } from '../../wallet'; +import { BtcAccountDeriver, BtcWallet } from '../../wallet'; import { FeeRatio, TransactionStatus } from '../constants'; import { DataClientError } from '../exceptions'; import { BlockChairClient } from './blockchair'; jest.mock('../../../utils/logger'); +jest.mock('../../../utils/snap'); describe('BlockChairClient', () => { const createMockFetch = () => { @@ -30,6 +33,26 @@ describe('BlockChairClient', () => { }; }; + const createMockDeriver = (network) => { + return { + instance: new BtcAccountDeriver(network), + }; + }; + + const createAccounts = async (network, recipientCnt: number) => { + const { instance } = createMockDeriver(network); + const wallet = new BtcWallet(instance, network); + + const accounts: BtcAccount[] = []; + for (let i = 0; i < recipientCnt; i++) { + accounts.push(await wallet.unlock(i, Config.wallet.defaultAccountType)); + } + + return { + accounts, + }; + }; + describe('baseUrl', () => { it('returns testnet network url', () => { const instance = new BlockChairClient({ network: networks.testnet }); @@ -52,7 +75,8 @@ describe('BlockChairClient', () => { describe('getApiUrl', () => { it('append api key to query url if option `apiKey` is present', async () => { const { fetchSpy } = createMockFetch(); - const accounts = generateAccounts(1); + const network = networks.testnet; + const { accounts } = await createAccounts(network, 1); const addresses = accounts.map((account) => account.address); const mockResponse = generateBlockChairGetBalanceResp(addresses); fetchSpy.mockResolvedValueOnce({ @@ -68,7 +92,7 @@ describe('BlockChairClient', () => { expect(fetchSpy).toHaveBeenCalledWith( `https://api.blockchair.com/bitcoin/testnet/addresses/balances?addresses=${addresses.join( - ',', + '%2C', )}&key=key`, { method: 'GET' }, ); @@ -76,7 +100,8 @@ describe('BlockChairClient', () => { it('does not append api key if option `apiKey` is absent', async () => { const { fetchSpy } = createMockFetch(); - const accounts = generateAccounts(1); + const network = networks.testnet; + const { accounts } = await createAccounts(network, 1); const addresses = accounts.map((account) => account.address); const mockResponse = generateBlockChairGetBalanceResp(addresses); fetchSpy.mockResolvedValueOnce({ @@ -99,7 +124,8 @@ describe('BlockChairClient', () => { describe('getBalances', () => { it('returns balances', async () => { const { fetchSpy } = createMockFetch(); - const accounts = generateAccounts(10); + const network = networks.testnet; + const { accounts } = await createAccounts(network, 10); const addresses = accounts.map((account) => account.address); const mockResponse = generateBlockChairGetBalanceResp(addresses); const expectedResult = mockResponse.data; @@ -117,8 +143,10 @@ describe('BlockChairClient', () => { it('assigns balance to 0 if account is not exist', async () => { const { fetchSpy } = createMockFetch(); - const accounts = generateAccounts(2); - const accountWithNoBalance = generateAccounts(1, 'notexist')[0]; + const network = networks.testnet; + const { + accounts: [accountWithNoBalance, ...accounts], + } = await createAccounts(network, 3); const addresses = accounts.map((account) => account.address); const mockResponse = generateBlockChairGetBalanceResp(addresses); @@ -148,7 +176,8 @@ describe('BlockChairClient', () => { json: jest.fn().mockRejectedValue(new Error('error')), }); - const accounts = generateAccounts(1); + const network = networks.testnet; + const { accounts } = await createAccounts(network, 1); const addresses = accounts.map((account) => account.address); const instance = new BlockChairClient({ network: networks.testnet }); @@ -166,7 +195,8 @@ describe('BlockChairClient', () => { json: jest.fn().mockResolvedValue(null), }); - const accounts = generateAccounts(1); + const network = networks.testnet; + const { accounts } = await createAccounts(network, 1); const addresses = accounts.map((account) => account.address); const instance = new BlockChairClient({ network: networks.testnet }); @@ -226,7 +256,8 @@ describe('BlockChairClient', () => { describe('getUtxos', () => { it('returns utxos', async () => { const { fetchSpy } = createMockFetch(); - const accounts = generateAccounts(1); + const network = networks.testnet; + const { accounts } = await createAccounts(network, 1); const { address } = accounts[0]; const mockResponse = generateBlockChairGetUtxosResp(address, 10); const expectedResult = mockResponse.data[address].utxo.map((utxo) => ({ @@ -249,7 +280,8 @@ describe('BlockChairClient', () => { it('fetchs with pagination if utxos more than limit', async () => { const { fetchSpy } = createMockFetch(); - const accounts = generateAccounts(1); + const network = networks.testnet; + const { accounts } = await createAccounts(network, 1); const { address } = accounts[0]; const pagesToTest = 3; @@ -284,7 +316,8 @@ describe('BlockChairClient', () => { it('throws `Data not avaiable` error if given address not found from the result', async () => { const { fetchSpy } = createMockFetch(); - const accounts = generateAccounts(2); + const network = networks.testnet; + const { accounts } = await createAccounts(network, 2); const requestAddress = accounts[0].address; const actualRespAddress = accounts[1].address; const mockResponse = generateBlockChairGetUtxosResp(actualRespAddress, 1); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts index 4a0965e9..e8993879 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts @@ -1,7 +1,9 @@ +import { BtcP2wpkhAddressStruct } from '@metamask/keyring-api'; import type { Json } from '@metamask/snaps-sdk'; import { type Network, networks } from 'bitcoinjs-lib'; +import { array, assert } from 'superstruct'; -import { compactError, logger } from '../../../utils'; +import { compactError, logger, txIdStruct } from '../../../utils'; import { FeeRatio, TransactionStatus } from '../constants'; import type { IDataClient, @@ -272,6 +274,8 @@ export class BlockChairClient implements IDataClient { txHash: string, ): Promise { try { + assert(txHash, txIdStruct); + logger.info(`[BlockChairClient.getTxDashboardData] start:`); const response = await this.get( `/dashboards/transaction/${txHash}`, @@ -292,6 +296,8 @@ export class BlockChairClient implements IDataClient { async getBalances(addresses: string[]): Promise { try { + assert(addresses, array(BtcP2wpkhAddressStruct)); + logger.info( `[BlockChairClient.getBalance] start: { addresses : ${JSON.stringify( addresses, @@ -324,6 +330,8 @@ export class BlockChairClient implements IDataClient { includeUnconfirmed?: boolean, ): Promise { try { + assert(address, BtcP2wpkhAddressStruct); + let process = true; let offset = 0; const limit = 1000; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts index 138c76ce..915b2c9c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts @@ -56,9 +56,25 @@ describe('getTransactionStatus', () => { it('throws `Request params is invalid` when request parameter is not correct', async () => { await expect( getTransactionStatus({ - scope: 'some-scope', + scope: Caip2ChainId.Testnet, transactionId: '', }), ).rejects.toThrow(InvalidParamsError); + + await expect( + getTransactionStatus({ + scope: Caip2ChainId.Testnet, + transactionId: 'x', + }), + ).rejects.toThrow(InvalidParamsError); + + await expect( + getTransactionStatus({ + scope: Caip2ChainId.Testnet, + transactionId: + // 63 characters long while `transactionId` is expected to be 64 long + '2c2a9ef9cecbe08117da640ce5761c8d2209b418cd43cc3af05ffc16a425828', + }), + ).rejects.toThrow(InvalidParamsError); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts index 1518575a..2ee63c7e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts @@ -1,5 +1,5 @@ import type { Infer } from 'superstruct'; -import { object, string, enums } from 'superstruct'; +import { object, enums, nonempty } from 'superstruct'; import { TransactionStatus } from '../bitcoin/chain'; import { Factory } from '../factory'; @@ -9,10 +9,11 @@ import { validateRequest, validateResponse, logger, + txIdStruct, } from '../utils'; export const getTransactionStatusParamsRequestStruct = object({ - transactionId: string(), + transactionId: nonempty(txIdStruct), scope: scopeStruct, }); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts index 411cb8a0..e6e04571 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts @@ -10,3 +10,5 @@ export const positiveStringStruct = pattern( string(), /^(?!0\d)(\d+(\.\d+)?)$/u, ); + +export const txIdStruct = pattern(string(), /^[0-9a-fA-F]{64}$/u); From a38c439f9a840f465c89d550c5db4132af15d3b1 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 12 Jul 2024 17:07:16 +0800 Subject: [PATCH 104/362] fix: audit report 4.19 - implement keyring method filterAccountChains (#122) * feat: implement filterAccountChains * feat: implement filterAccountChains * feat: implement filterAccountChains * chore: update shasum * chore: update shasum * fix: update filterAccountChains api * chore: update mock value for filterAccountChains --- .../bitcoin-wallet-snap/src/keyring.test.ts | 59 +++++++++++++++++-- .../bitcoin-wallet-snap/src/keyring.ts | 6 +- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts index 17bce549..8ffa3a69 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts @@ -158,14 +158,61 @@ describe('BtcKeyring', () => { }); describe('filterAccountChains', () => { - it('throws `Method not implemented` error', async () => { - const { instance: stateMgr } = createMockStateMgr(); + it('returns the corresponding CAIP-2 Chain Id if the account exist', async () => { + const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const scope = Caip2ChainId.Testnet; const { instance: keyring } = createMockKeyring(stateMgr); - const account = generateAccounts(1)[0]; + const { sender, keyringAccount } = await createSender(scope); - await expect( - keyring.filterAccountChains(account.id, [Caip2ChainId.Testnet]), - ).rejects.toThrow('Method not implemented.'); + getWalletSpy.mockResolvedValue({ + account: keyringAccount as unknown as KeyringAccount, + index: 0, + scope, + hdPath: sender.hdPath, + }); + + const result = await keyring.filterAccountChains(keyringAccount.id, [ + scope, + ]); + + expect(result).toStrictEqual([scope]); + }); + + it('returns empty array if the account does not exist', async () => { + const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const scope = Caip2ChainId.Testnet; + const { instance: keyring } = createMockKeyring(stateMgr); + const { keyringAccount } = await createSender(scope); + + getWalletSpy.mockResolvedValue(null); + + const result = await keyring.filterAccountChains(keyringAccount.id, [ + scope, + ]); + + expect(result).toStrictEqual([]); + }); + + it('returns empty array if the account scope is not in `chains` list', async () => { + const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); + const scope = Caip2ChainId.Testnet; + const { instance: keyring } = createMockKeyring(stateMgr); + const { keyringAccount, sender } = await createSender(scope); + + getWalletSpy.mockResolvedValue({ + account: keyringAccount as unknown as KeyringAccount, + index: 0, + scope, + hdPath: sender.hdPath, + }); + + // Current account has been created for testnet, so requesting mainnet will yield an + // empty array: + const result = await keyring.filterAccountChains(keyringAccount.id, [ + Caip2ChainId.Mainnet, + ]); + + expect(result).toStrictEqual([]); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index 65adee5c..1e457726 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -112,9 +112,11 @@ 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.'); + const walletData = await this._stateMgr.getWallet(id); + return walletData && chains.includes(walletData.scope) + ? [walletData.scope] + : []; } async updateAccount(_account: KeyringAccount): Promise { From 6f550a50966d4b6dc876d15d66dc3044be7e5d65 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 12 Jul 2024 20:23:32 +0800 Subject: [PATCH 105/362] fix: audit report 4.5 - show origin on `btc_sendmany` confirmation dialog (#152) * fix: confirmation dialog reflect the request origin * chore: update shasum * chore: update lint style and shasum * fix: typo --- .../bitcoin-wallet-snap/src/index.ts | 1 + .../bitcoin-wallet-snap/src/keyring.test.ts | 9 +- .../bitcoin-wallet-snap/src/keyring.ts | 3 +- .../src/rpcs/sendmany.test.ts | 107 ++++++++++++++++-- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 18 +-- 5 files changed, 118 insertions(+), 20 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 6ffa5a15..f07c6c7e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -70,6 +70,7 @@ export const onKeyringRequest: OnKeyringRequestHandler = async ({ const keyring = new BtcKeyring(new KeyringStateManager(), { defaultIndex: Config.wallet.defaultAccountIndex, + origin, }); return (await handleKeyringRequest( diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts index 8ffa3a69..8b970f24 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts @@ -21,6 +21,8 @@ jest.mock('@metamask/keyring-api', () => ({ })); describe('BtcKeyring', () => { + const origin = 'http://localhost:3000'; + const createMockWallet = () => { const unlockSpy = jest.spyOn(BtcWallet.prototype, 'unlock'); const signTransaction = jest.spyOn(BtcWallet.prototype, 'signTransaction'); @@ -73,6 +75,7 @@ describe('BtcKeyring', () => { return { instance: new BtcKeyring(stateMgr, { defaultIndex: 0, + origin, multiAccount: false, }), sendManySpy, @@ -344,7 +347,11 @@ describe('BtcKeyring', () => { }, }); - expect(sendManySpy).toHaveBeenCalledWith(expect.any(BtcAccount), params); + expect(sendManySpy).toHaveBeenCalledWith( + expect.any(BtcAccount), + origin, + params, + ); }); it('throws `Account not found` error if the account address is not match with the unlocked account', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index 1e457726..e1c856a7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -23,6 +23,7 @@ import { getProvider, scopeStruct, logger } from './utils'; export type KeyringOptions = Record & { defaultIndex: number; multiAccount?: boolean; + origin: string; }; export const CreateAccountOptionsStruct = object({ @@ -167,7 +168,7 @@ export class BtcKeyring implements Keyring { switch (method) { case 'btc_sendmany': - return (await sendMany(account, { + return (await sendMany(account, this._options.origin, { ...params, scope: walletData.scope, } as unknown as SendManyParams)) as unknown as Json; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 887a7f32..5d0e2d6b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -28,6 +28,8 @@ jest.mock('../utils/snap'); describe('SendManyHandler', () => { describe('sendMany', () => { + const origin = 'http://localhost:3000'; + const createMockChainApiFactory = () => { const getFeeRatesSpy = jest.spyOn( BtcOnChainService.prototype, @@ -186,6 +188,7 @@ describe('SendManyHandler', () => { const result = await sendMany( sender, + origin, createSendManyParams(recipients, caip2ChainId, false), ); @@ -203,6 +206,7 @@ describe('SendManyHandler', () => { await sendMany( sender, + origin, createSendManyParams(recipients, caip2ChainId, true), ); @@ -219,6 +223,7 @@ describe('SendManyHandler', () => { await sendMany( sender, + origin, createSendManyParams(recipients, caip2ChainId, true, 'test comment'), ); @@ -286,6 +291,7 @@ describe('SendManyHandler', () => { await sendMany( sender, + origin, createSendManyParams([recipients[0]], caip2ChainId, true), ); @@ -315,13 +321,84 @@ describe('SendManyHandler', () => { }); }); + it('display `Origin` in dialog', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { recipients, snapHelperSpy, sender } = await prepareSendMany( + network, + caip2ChainId, + ); + const walletCreateTxSpy = jest.spyOn( + BtcWallet.prototype, + 'createTransaction', + ); + const walletSignTxSpy = jest.spyOn( + BtcWallet.prototype, + 'signTransaction', + ); + + const info: ITxInfo = { + feeRate: BigInt('1'), + txFee: BigInt('1'), + sender: sender.address, + recipients: [ + { + address: recipients[0].address, + value: BigInt('1000'), + }, + ], + total: BigInt('1000'), + }; + + walletCreateTxSpy.mockResolvedValue({ + tx: 'transaction', + txInfo: info, + }); + + walletSignTxSpy.mockResolvedValue('txId'); + + await sendMany( + sender, + origin, + createSendManyParams([recipients[0]], caip2ChainId, true), + ); + + const calls = snapHelperSpy.mock.calls[0][0]; + + const introPanel = calls[0]; + + expect(introPanel).toStrictEqual({ + type: 'panel', + children: [ + { + type: 'heading', + value: 'Send Request', + }, + { + type: 'text', + value: + "Review the request before proceeding. Once the transaction is made, it's irreversible.", + }, + { + type: 'row', + label: 'Requested by', + value: { + type: 'text', + value: origin, + markdown: false, + }, + }, + ], + }); + }); + it('throws InvalidParamsError when request parameter is not correct', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; const { sender } = await prepareSendMany(network, caip2ChainId); await expect( - sendMany(sender, { + sendMany(sender, origin, { amounts: { 'some-address': '1', }, @@ -339,7 +416,11 @@ describe('SendManyHandler', () => { 0, ); await expect( - sendMany(sender, createSendManyParams(recipients, caip2ChainId, false)), + sendMany( + sender, + origin, + createSendManyParams(recipients, caip2ChainId, false), + ), ).rejects.toThrow('Transaction must have at least one recipient'); }); @@ -353,7 +434,7 @@ describe('SendManyHandler', () => { 2, ); await expect( - sendMany(sender, { + sendMany(sender, origin, { ...createSendManyParams(recipients, caip2ChainId, false), amounts: { [recipients[0].address]: 'invalid', @@ -363,7 +444,7 @@ describe('SendManyHandler', () => { ).rejects.toThrow('Invalid amount for send'); await expect( - sendMany(sender, { + sendMany(sender, origin, { ...createSendManyParams(recipients, caip2ChainId, false), amounts: { [recipients[0].address]: '0', @@ -373,7 +454,7 @@ describe('SendManyHandler', () => { ).rejects.toThrow('Invalid amount for send'); await expect( - sendMany(sender, { + sendMany(sender, origin, { ...createSendManyParams(recipients, caip2ChainId, false), amounts: { [recipients[0].address]: 'invalid', @@ -383,7 +464,7 @@ describe('SendManyHandler', () => { ).rejects.toThrow('Invalid amount for send'); await expect( - sendMany(sender, { + sendMany(sender, origin, { ...createSendManyParams(recipients, caip2ChainId, false), amounts: { [recipients[0].address]: '1', @@ -403,7 +484,7 @@ describe('SendManyHandler', () => { transactionId: '', }); await expect( - sendMany(sender, { + sendMany(sender, origin, { ...createSendManyParams(recipients, caip2ChainId, false), }), ).rejects.toThrow('Invalid Response'); @@ -419,7 +500,7 @@ describe('SendManyHandler', () => { snapHelperSpy.mockResolvedValue(false); await expect( - sendMany(sender, { + sendMany(sender, origin, { ...createSendManyParams(recipients, caip2ChainId, false), }), ).rejects.toThrow(UserRejectedRequestError); @@ -439,7 +520,11 @@ describe('SendManyHandler', () => { }); await expect( - sendMany(sender, createSendManyParams(recipients, caip2ChainId, false)), + sendMany( + sender, + origin, + createSendManyParams(recipients, caip2ChainId, false), + ), ).rejects.toThrow('Failed to send the transaction'); }); @@ -451,7 +536,7 @@ describe('SendManyHandler', () => { broadcastTransactionSpy.mockRejectedValue(new Error('error')); await expect( - sendMany(sender, { + sendMany(sender, origin, { ...createSendManyParams(recipients, caip2ChainId, false), }), ).rejects.toThrow('Failed to send the transaction'); @@ -467,7 +552,7 @@ describe('SendManyHandler', () => { ); await expect( - sendMany(sender, { + sendMany(sender, origin, { ...createSendManyParams(recipients, caip2ChainId, false), }), ).rejects.toThrow('some tx error'); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 2a84c219..6cd9d6f4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -90,10 +90,15 @@ export type SendManyResponse = Infer; * Send BTC to multiple account. * * @param account - The account to send the transaction. + * @param origin - The origin of the request. * @param params - The parameters for send the transaction. * @returns A Promise that resolves to an SendManyResponse object. */ -export async function sendMany(account: BtcAccount, params: SendManyParams) { +export async function sendMany( + account: BtcAccount, + origin: string, + params: SendManyParams, +) { try { validateRequest(params, sendManyParamsStruct); @@ -128,7 +133,7 @@ export async function sendMany(account: BtcAccount, params: SendManyParams) { replaceable: params.replaceable, }); - if (!(await getTxConsensus(txInfo, params.comment, scope))) { + if (!(await getTxConsensus(txInfo, params.comment, scope, origin))) { throw new UserRejectedRequestError() as unknown as Error; } @@ -171,12 +176,14 @@ export async function sendMany(account: BtcAccount, params: SendManyParams) { * @param info - The transaction data object contains the transaction information. * @param comment - The comment text to display. * @param scope - The CAIP-2 Chain ID. + * @param origin - The origin of the request. * @returns A Promise that resolves to the response of the confirmation dialog. */ export async function getTxConsensus( info: ITxInfo, comment: string, scope: string, + origin: string, ): Promise { const header = `Send Request`; const intro = `Review the request before proceeding. Once the transaction is made, it's irreversible.`; @@ -186,16 +193,13 @@ export async function getTxConsensus( // const networkFeeRateLabel = `Network fee rate`; const networkFeeLabel = `Network fee`; const totalLabel = `Total`; - const requestedByLable = `Requested by`; + const requestedByLabel = `Requested by`; const components: Component[] = [ panel([ heading(header), text(intro), - row( - requestedByLable, - text(`[portfolio.metamask.io](https://portfolio.metamask.io/)`), - ), + row(requestedByLabel, text(`${origin}`, false)), ]), divider(), ]; From 8bc12af0089fdd027203009a0022c37929600674 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 12 Jul 2024 22:34:54 +0800 Subject: [PATCH 106/362] fix: audit 4.24 & 4.12 - derive accounts with different HD path by network (#118) * chore: unlock account with different hd path * chore: update explicit type annotations * fix: remove P2SHP2WPKHAccount handle * chore: unlock account with different hd path * chore: update explicit type annotations * fix: remove P2SHP2WPKHAccount handle * fix: adding P2WPKHTestnetAccount * fix: add validation on deriveByPath * fix: lint style * fix: lint style * chore: update packages/snap/src/bitcoin/wallet/deriver.ts Co-authored-by: Charly Chevalier * chore: update deriver and wallet var naming * chore: combine test flow into one * chore: update test case for deriver --------- Co-authored-by: Daniel Rocha Co-authored-by: Charly Chevalier --- .../src/bitcoin/wallet/account.ts | 10 +-- .../src/bitcoin/wallet/deriver.test.ts | 18 +++- .../src/bitcoin/wallet/deriver.ts | 16 ++-- .../src/bitcoin/wallet/signer.ts | 16 +--- .../src/bitcoin/wallet/utils/deriver.ts | 33 ++++++++ .../src/bitcoin/wallet/wallet.test.ts | 82 +++++++++++++------ .../src/bitcoin/wallet/wallet.ts | 30 ++++--- 7 files changed, 145 insertions(+), 60 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/deriver.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts index fbde1ef8..a1045550 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts @@ -132,11 +132,9 @@ export class P2WPKHAccount static readonly scriptType = ScriptType.P2wpkh; } -export class P2SHP2WPKHAccount - extends BtcAccount - implements StaticImplements +export class P2WPKHTestnetAccount + extends P2WPKHAccount + implements StaticImplements { - static readonly path = ['m', "49'", "0'"]; - - static readonly scriptType = ScriptType.P2shP2wkh; + static readonly path = ['m', "84'", "1'"]; } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts index 75f9692f..cdd3027d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts @@ -67,11 +67,11 @@ describe('BtcAccountDeriver', () => { network, ); - const idx = 0; + const hdPath = [`m`, `0'`, `0`, `0`]; const node = deriver.createBip32FromPrivateKey(pkBuffer, ccBuffer); - const result = await deriver.getChild(node, idx); + const result = await deriver.getChild(node, hdPath); expect(result.chainCode).toBeDefined(); expect(result.chainCode).not.toBeNull(); @@ -84,6 +84,20 @@ describe('BtcAccountDeriver', () => { expect(result.index).toBeDefined(); expect(result.index).not.toBeNull(); }); + + it.each(["m/1''/0/0", "m/-1'/0/0", "m/0'/-1/0", "m/0'/1a/0"])( + 'throws `Invalid index` if hdPath is invalid: %s', + async (path: string) => { + const network = networks.testnet; + const { deriver, pkBuffer, ccBuffer } = await prepareBip32Deriver( + network, + ); + const node = deriver.createBip32FromPrivateKey(pkBuffer, ccBuffer); + await expect(deriver.getChild(node, path.split('/'))).rejects.toThrow( + 'Invalid index', + ); + }, + ); }); describe('getRoot', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts index 18809e37..4046500b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts @@ -6,6 +6,7 @@ import type { Buffer } from 'buffer'; import { compactError, hexToBuffer, getBip32Deriver } from '../../utils'; import { DeriverError } from './exceptions'; +import { deriveByPath } from './utils/deriver'; /** * This class provides a mechanism for deriving Bitcoin accounts using BIP32. @@ -32,7 +33,7 @@ export class BtcAccountDeriver { * @returns The root node of the BIP32 account. * @throws {DeriverError} Throws a DeriverError if the private key is missing or if the BIP32 node cannot be constructed from the private key. */ - async getRoot(path: string[]) { + async getRoot(path: string[]): Promise { try { const deriver = await getBip32Deriver(path, this.curve); @@ -50,11 +51,11 @@ export class BtcAccountDeriver { // 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; + 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; + root.__INDEX = deriver.index; return root; } catch (error) { @@ -89,10 +90,13 @@ export class BtcAccountDeriver { * Gets a child node of a BIP32 account given an index. * * @param root - The root node of the BIP32 account. - * @param idx - The index of the child node to get. + * @param hdPath - The HD path to derive. * @returns A promise that resolves to the child node. */ - async getChild(root: BIP32Interface, idx: number): Promise { - return Promise.resolve(root.deriveHardened(0).derive(0).derive(idx)); + async getChild( + root: BIP32Interface, + hdPath: string[], + ): Promise { + return Promise.resolve(deriveByPath(root, hdPath)); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts index 4d59f57e..d14abda2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts @@ -2,6 +2,8 @@ import type { BIP32Interface } from 'bip32'; import type { HDSignerAsync } from 'bitcoinjs-lib'; import type { Buffer } from 'buffer'; +import { deriveByPath } from './utils/deriver'; + /** * An Signer object that defines methods and properties for signing transactions and verifying signatures in PSBT sign process. */ @@ -32,19 +34,7 @@ export class AccountSigner implements HDSignerAsync { */ derivePath(path: string): AccountSigner { 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); + const childNode = deriveByPath(this._node, path.split('/')); return new AccountSigner(childNode, this.fingerprint); } catch (error) { throw new Error('Unable to derive path'); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/deriver.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/deriver.ts new file mode 100644 index 00000000..00b69415 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/deriver.ts @@ -0,0 +1,33 @@ +import type { BIP32Interface } from 'bip32'; + +/** + * Derives a new BIP32Interface object using an HD path. + * + * @param rootNode - The root BIP32Interface object that derived from. + * @param path - The HD path, e.g. ["m","0'","0"]. + * @returns A new BIP32Interface object derived by the given path. + */ +export function deriveByPath( + rootNode: BIP32Interface, + path: string[], +): BIP32Interface { + let _path: string[] = path; + if (_path[0] === 'm') { + _path = _path.slice(1); + } + return _path.reduce((prevHd: BIP32Interface, indexStr: string) => { + const isHardened = indexStr.endsWith(`'`); + let _indexStr = indexStr; + + if (isHardened) { + _indexStr = _indexStr.slice(0, -1); + } + + if (!/^\d+$/u.test(_indexStr)) { + throw new Error(`Invalid index`); + } + + const index = parseInt(_indexStr, 10); + return isHardened ? prevHd.deriveHardened(index) : prevHd.derive(index); + }, rootNode); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index 397c7444..bdc5fc7d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -1,7 +1,7 @@ import { networks } from 'bitcoinjs-lib'; import { generateFormatedUtxos } from '../../../test/utils'; -import { P2SHP2WPKHAccount, P2WPKHAccount } from './account'; +import { P2WPKHAccount, P2WPKHTestnetAccount } from './account'; import { CoinSelectService } from './coin-select'; import { DustLimit, ScriptType } from './constants'; import { BtcAccountDeriver } from './deriver'; @@ -15,6 +15,9 @@ jest.mock('../../utils/snap'); jest.mock('../../utils/logger'); describe('BtcWallet', () => { + const bip44Account = "0'"; + const bip44Change = '0'; + const createMockDeriver = (network) => { const rootSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getRoot'); const childSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getChild'); @@ -47,52 +50,75 @@ describe('BtcWallet', () => { }; describe('unlock', () => { - it('returns an `Account` object with defualt type', async () => { - const network = networks.testnet; - const { rootSpy, childSpy, instance } = createMockWallet(network); + const p2wpkhPathMainnet = P2WPKHAccount.path; + const p2wpkhPathTestnet = P2WPKHTestnetAccount.path; + + it('creates an `Account` object with different hd path on different network', async () => { + const { instance, rootSpy } = createMockWallet(networks.testnet); + const { instance: mainnetInstance, rootSpy: mainnetRootSpy } = + createMockWallet(networks.bitcoin); + const idx = 0; - const result = await instance.unlock(idx); + const testnetAcc = await instance.unlock(idx); + const mainnetAcc = await mainnetInstance.unlock(idx); - expect(result).toBeInstanceOf(P2WPKHAccount); - expect(rootSpy).toHaveBeenCalledWith(P2WPKHAccount.path); - expect(childSpy).toHaveBeenCalledWith(expect.any(Object), idx); + expect(mainnetRootSpy).toHaveBeenCalledWith(p2wpkhPathMainnet); + expect(rootSpy).toHaveBeenCalledWith(p2wpkhPathTestnet); + expect(testnetAcc.signer.publicKey).not.toStrictEqual( + mainnetAcc.signer.publicKey, + ); }); - it('returns an `Account` object with type bip122:p2wpkh', async () => { + it('creates an `Account` object with default type', async () => { const network = networks.testnet; const { rootSpy, childSpy, instance } = createMockWallet(network); const idx = 0; - const result = await instance.unlock(idx, `bip122:p2wpkh`); + const result = await instance.unlock(idx); - expect(result).toBeInstanceOf(P2WPKHAccount); - expect(rootSpy).toHaveBeenCalledWith(P2WPKHAccount.path); - expect(childSpy).toHaveBeenCalledWith(expect.any(Object), idx); + expect(result).toBeInstanceOf(P2WPKHTestnetAccount); + expect(rootSpy).toHaveBeenCalledWith(p2wpkhPathTestnet); + expect(childSpy).toHaveBeenCalledWith(expect.any(Object), [ + `m`, + bip44Account, + bip44Change, + `${idx}`, + ]); }); - it('returns an `Account` object with type `p2wpkh`', async () => { + it('creates an `Account` object with type bip122:p2wpkh', async () => { const network = networks.testnet; const { rootSpy, childSpy, instance } = createMockWallet(network); const idx = 0; - const result = await instance.unlock(idx, ScriptType.P2wpkh); + const result = await instance.unlock(idx, `bip122:p2wpkh`); - expect(result).toBeInstanceOf(P2WPKHAccount); - expect(rootSpy).toHaveBeenCalledWith(P2WPKHAccount.path); - expect(childSpy).toHaveBeenCalledWith(expect.any(Object), idx); + expect(result).toBeInstanceOf(P2WPKHTestnetAccount); + expect(rootSpy).toHaveBeenCalledWith(p2wpkhPathTestnet); + expect(childSpy).toHaveBeenCalledWith(expect.any(Object), [ + `m`, + bip44Account, + bip44Change, + `${idx}`, + ]); }); - it('returns an `Account` object with type `p2shp2wkh`', async () => { + it('creates an `Account` object with type `p2wpkh`', async () => { const network = networks.testnet; const { rootSpy, childSpy, instance } = createMockWallet(network); const idx = 0; - const result = await instance.unlock(idx, ScriptType.P2shP2wkh); + const result = await instance.unlock(idx, ScriptType.P2wpkh); - expect(result).toBeInstanceOf(P2SHP2WPKHAccount); - expect(rootSpy).toHaveBeenCalledWith(P2SHP2WPKHAccount.path); - expect(childSpy).toHaveBeenCalledWith(expect.any(Object), idx); + expect(result).toBeInstanceOf(P2WPKHTestnetAccount); + expect(rootSpy).toHaveBeenCalledWith(p2wpkhPathTestnet); + expect(childSpy).toHaveBeenCalledWith(expect.any(Object), [ + `m`, + bip44Account, + bip44Change, + `${idx}`, + ]); }); it('throws error if the account cannot be unlocked', async () => { @@ -104,6 +130,16 @@ describe('BtcWallet', () => { WalletError, ); }); + + it('throws `Invalid network` error if the network is not supported', async () => { + const network = networks.regtest; + const idx = 0; + const { instance } = createMockWallet(network); + + await expect(instance.unlock(idx, ScriptType.P2wpkh)).rejects.toThrow( + `Invalid network`, + ); + }); }); describe('createTransaction', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 4f5491f8..4adfcbca 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -1,12 +1,12 @@ import type { BIP32Interface } from 'bip32'; -import { type Network } from 'bitcoinjs-lib'; +import { networks, type Network } from 'bitcoinjs-lib'; import { bufferToString, compactError, hexToBuffer, logger } from '../../utils'; import type { Utxo } from '../chain'; import type { BtcAccount } from './account'; import { P2WPKHAccount, - P2SHP2WPKHAccount, + P2WPKHTestnetAccount, type IStaticBtcAccount, } from './account'; import { CoinSelectService } from './coin-select'; @@ -64,19 +64,19 @@ export class BtcWallet { * * @param index - The index to derive from the node. * @param type - The script type of the unlocked account, e.g. `bip122:p2pkh`. - * @returns A promise that resolves to an `IAccount` object. + * @returns A promise that resolves to a `BtcAccount` object. */ async unlock(index: number, type?: string): Promise { try { const AccountCtor = this.getAccountCtor(type ?? ScriptType.P2wpkh); + const childNodeHdPath = [`m`, `0'`, `0`, `${index}`]; const rootNode = await this._deriver.getRoot(AccountCtor.path); - const childNode = await this._deriver.getChild(rootNode, index); - const hdPath = [`m`, `0'`, `0`, `${index}`].join('/'); + const childNode = await this._deriver.getChild(rootNode, childNodeHdPath); return new AccountCtor( bufferToString(rootNode.fingerprint, 'hex'), index, - hdPath, + childNodeHdPath.join('/'), bufferToString(childNode.publicKey, 'hex'), this._network, AccountCtor.scriptType, @@ -183,7 +183,7 @@ export class BtcWallet { return psbtService.finalize(); } - protected getHdSigner(rootNode: BIP32Interface) { + protected getHdSigner(rootNode: BIP32Interface): AccountSigner { return new AccountSigner(rootNode, rootNode.fingerprint); } @@ -192,13 +192,23 @@ export class BtcWallet { if (type.includes('bip122:')) { scriptType = type.split(':')[1]; } + switch (scriptType.toLowerCase()) { case ScriptType.P2wpkh.toLowerCase(): - return P2WPKHAccount; - case ScriptType.P2shP2wkh.toLowerCase(): - return P2SHP2WPKHAccount; + return this.getP2WPKHAccountCtorByNetwork(); default: throw new WalletError('Invalid script type'); } } + + protected getP2WPKHAccountCtorByNetwork(): IStaticBtcAccount { + switch (this._network) { + case networks.bitcoin: + return P2WPKHAccount; + case networks.testnet: + return P2WPKHTestnetAccount; + default: + throw new WalletError('Invalid network'); + } + } } From 4c88ad89f252faa64601dbee47a1668a824d9df2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 12 Jul 2024 17:19:12 +0200 Subject: [PATCH 107/362] 0.2.4 (#171) * 0.2.4 * chore: update changelog * chore: update Snap hash * chore: update changelog * chore: update changelog --------- Co-authored-by: github-actions Co-authored-by: Daniel Rocha <68558152+danroc@users.noreply.github.com> --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 13 ++++++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index c6c8bd1d..05003923 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.4] + +### Fixed + +- Audit 4.5: Show origin on `btc_sendmany` confirmation dialog ([#152](https://github.com/MetaMask/snap-bitcoin-wallet/pull/152)) +- Audit 4.7: Fix potential URL injections in Blockchair API calls ([#132](https://github.com/MetaMask/snap-bitcoin-wallet/pull/132)) +- Audit 4.11: Remove code for creating unsupported P2SHP2WPKH account type ([#118](https://github.com/MetaMask/snap-bitcoin-wallet/pull/118)) +- Audit 4.12: Derive accounts with different HD path by network ([#118](https://github.com/MetaMask/snap-bitcoin-wallet/pull/118)) +- Audit 4.19: Implement keyring method `filterAccountChains` ([#122](https://github.com/MetaMask/snap-bitcoin-wallet/pull/122)) + ## [0.2.3] ### Changed @@ -113,7 +123,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.3...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.4...HEAD +[0.2.4]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.3...v0.2.4 [0.2.3]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.2...v0.2.3 [0.2.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.1...v0.2.2 [0.2.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.0...v0.2.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 1de478f6..33a62a5f 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.2.3", + "version": "0.2.4", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 66d5e20f..d78b6eee 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.2.3", + "version": "0.2.4", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin Manager", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "8LQeRbeTgPdJ25O+OQZt4OfTr9024WMeWuti1Gb0twY=", + "shasum": "+MrYK3TpHg1KvFspGMGBzro78Hz6bz/4Iulnh8x4cE0=", "location": { "npm": { "filePath": "dist/bundle.js", From 227f1a728eefcd30bf86b7c0c031c06535da2788 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Mon, 15 Jul 2024 17:20:24 +0200 Subject: [PATCH 108/362] chore: fix typos (#176) * chore: fix typos * chore: fix more typos * chore: fix more typos * chore: remove unused import * chore: revert and fix import * chore: update test title Co-authored-by: Charly Chevalier * chore: update test title Co-authored-by: Charly Chevalier --------- Co-authored-by: Charly Chevalier --- .../bitcoin/chain/clients/blockchair.test.ts | 8 +- .../src/bitcoin/chain/service.test.ts | 4 +- .../src/bitcoin/chain/service.ts | 6 +- .../src/bitcoin/wallet/account.ts | 12 +- .../src/bitcoin/wallet/coin-select.test.ts | 4 +- .../src/bitcoin/wallet/deriver.test.ts | 2 +- .../src/bitcoin/wallet/psbt.test.ts | 4 +- .../src/bitcoin/wallet/signer.test.ts | 2 +- .../bitcoin/wallet/transaction-input.test.ts | 6 +- .../src/bitcoin/wallet/wallet.test.ts | 19 ++-- .../bitcoin-wallet-snap/src/config.ts | 8 +- .../bitcoin-wallet-snap/src/index.test.ts | 18 +-- .../bitcoin-wallet-snap/src/keyring.test.ts | 10 +- .../src/rpcs/get-balances.test.ts | 2 +- .../src/rpcs/sendmany.test.ts | 6 +- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 12 +- .../src/stateManagement.test.ts | 8 +- .../bitcoin-wallet-snap/src/utils/async.ts | 10 +- .../src/utils/snap-state.test.ts | 106 +++++++++--------- .../src/utils/snap-state.ts | 14 +-- .../src/utils/string.test.ts | 4 +- .../src/utils/superstruct.test.ts | 4 +- .../src/utils/superstruct.ts | 4 +- .../test/fixtures/blockchair.json | 2 +- .../bitcoin-wallet-snap/test/utils.ts | 10 +- 25 files changed, 145 insertions(+), 140 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts index 904bd773..c459dd7b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts @@ -168,7 +168,7 @@ describe('BlockChairClient', () => { expect(result).toStrictEqual(expectedResult); }); - it('throws DataClientError error if an non DataClientError catched', async () => { + it('throws DataClientError error if an non DataClientError was thrown', async () => { const { fetchSpy } = createMockFetch(); fetchSpy.mockResolvedValueOnce({ @@ -226,7 +226,7 @@ describe('BlockChairClient', () => { }); }); - it('throws DataClientError error if an non DataClientError catched', async () => { + it('throws DataClientError error if an non DataClientError was thrown', async () => { const { fetchSpy } = createMockFetch(); fetchSpy.mockResolvedValueOnce({ @@ -239,7 +239,7 @@ describe('BlockChairClient', () => { await expect(instance.getFeeRates()).rejects.toThrow(DataClientError); }); - it('throws DataClientError error if an DataClientError catched', async () => { + it('throws DataClientError error if an DataClientError was thrown', async () => { const { fetchSpy } = createMockFetch(); fetchSpy.mockResolvedValueOnce({ @@ -462,7 +462,7 @@ describe('BlockChairClient', () => { }); }); - it('throws DataClientError error if an DataClientError catched', async () => { + it('throws DataClientError error if an DataClientError was thrown', async () => { const { fetchSpy } = createMockFetch(); fetchSpy.mockResolvedValueOnce({ diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts index 68e63b56..dd6a306b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts @@ -189,7 +189,7 @@ describe('BtcOnChainService', () => { }); }); - it('throws BtcOnChainServiceError error if an error catched', async () => { + it('throws BtcOnChainServiceError error if another error was thrown', async () => { const { instance, getFeeRatesSpy } = createMockDataClient(); const { instance: txMgr } = createMockBtcService(instance); @@ -248,7 +248,7 @@ describe('BtcOnChainService', () => { }); }); - it('throws BtcOnChainServiceError error if an error catched', async () => { + it('throws BtcOnChainServiceError error if another error was thrown', async () => { const { instance, getTransactionStatusSpy } = createMockDataClient(); const { instance: txMgr } = createMockBtcService(instance); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index 4817481c..5650ee4a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -48,7 +48,7 @@ export type TransactionData = { }; }; -export type CommitedTransaction = { +export type CommittedTransaction = { transactionId: string; }; @@ -173,11 +173,11 @@ export class BtcOnChainService { * Broadcasts a signed transaction on the blockchain network. * * @param signedTransaction - A signed transaction string. - * @returns A promise that resolves to a `CommitedTransaction` object. + * @returns A promise that resolves to a `CommittedTransaction` object. */ async broadcastTransaction( signedTransaction: string, - ): Promise { + ): Promise { try { const transactionId = await this._dataClient.sendTransaction( signedTransaction, diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts index a1045550..0718c5e9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts @@ -82,9 +82,9 @@ export abstract class BtcAccount { } /** - * A getter function to return the correcsponing account type's output script. + * A getter function to return the corresponding account type's output script. * - * @returns The correcsponing account type's output script. + * @returns The corresponding account type's output script. */ get script(): Buffer { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -92,9 +92,9 @@ export abstract class BtcAccount { } /** - * A getter function to return the correcsponing account type's address. + * A getter function to return the corresponding account type's address. * - * @returns The correcsponing account type's address. + * @returns The corresponding account type's address. */ get address(): string { if (!this.#address) { @@ -107,9 +107,9 @@ export abstract class BtcAccount { } /** - * A getter function to return the correcsponing account type's payment instance. + * A getter function to return the corresponding account type's payment instance. * - * @returns The correcsponing account type's payment instance. + * @returns The corresponding account type's payment instance. */ get payment(): Payment { if (!this.#payment) { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts index c821fbf4..2f6e2fc0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts @@ -1,6 +1,6 @@ import { networks, address as addressUtils } from 'bitcoinjs-lib'; -import { generateFormatedUtxos } from '../../../test/utils'; +import { generateFormattedUtxos } from '../../../test/utils'; import { CoinSelectService } from './coin-select'; import { ScriptType } from './constants'; import { BtcAccountDeriver } from './deriver'; @@ -29,7 +29,7 @@ describe('CoinSelectService', () => { const receiver1 = await wallet.instance.unlock(1, ScriptType.P2wpkh); const receiver2 = await wallet.instance.unlock(2, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(sender.address, 2, inputMin, inputMax); + const utxos = generateFormattedUtxos(sender.address, 2, inputMin, inputMax); const outputs = [ new TxOutput( diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts index cdd3027d..9dd6c524 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts @@ -48,7 +48,7 @@ describe('BtcAccountDeriver', () => { expect(result.index).not.toBeNull(); }); - it('throws `Unable to construct BIP32 node from private key` if an error catched', async () => { + it('throws `Unable to construct BIP32 node from private key` if another error was thrown', async () => { const network = networks.testnet; const deriver = new BtcAccountDeriver(network); const pkBuffer = Buffer.from(''); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts index e77b195b..87ffafeb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts @@ -1,6 +1,6 @@ import { Psbt, Transaction, networks } from 'bitcoinjs-lib'; -import { generateFormatedUtxos } from '../../../test/utils'; +import { generateFormattedUtxos } from '../../../test/utils'; import { hexToBuffer } from '../../utils'; import { MaxStandardTxWeight, ScriptType } from './constants'; import { BtcAccountDeriver } from './deriver'; @@ -46,7 +46,7 @@ describe('PsbtService', () => { const outputs = receivers.map( (account) => new TxOutput(outputVal, account.address, account.script), ); - const utxos = generateFormatedUtxos( + const utxos = generateFormattedUtxos( sender.address, inputsCnt, outputVal * outputs.length + fee, diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.test.ts index ebaf3083..5860d183 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.test.ts @@ -26,7 +26,7 @@ describe('AccountSigner', () => { expect(signer).toBeInstanceOf(AccountSigner); }); - it('throws error if an error catched', async () => { + it('throws an Error if another error was thrown', async () => { const network = networks.testnet; const { instance: node, deriveHardenedSpy } = createMockBip32Instance(network); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts index 99713d89..e60c9cca 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts @@ -1,6 +1,6 @@ import { networks } from 'bitcoinjs-lib'; -import { generateFormatedUtxos } from '../../../test/utils'; +import { generateFormattedUtxos } from '../../../test/utils'; import { ScriptType } from './constants'; import { BtcAccountDeriver } from './deriver'; import { TxInput } from './transaction-input'; @@ -21,7 +21,7 @@ describe('TxInput', () => { const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); const { script } = account; - const utxo = generateFormatedUtxos(account.address, 1)[0]; + const utxo = generateFormattedUtxos(account.address, 1)[0]; const input = new TxInput(utxo, script); @@ -37,7 +37,7 @@ describe('TxInput', () => { const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); const { script } = account; - const utxo = generateFormatedUtxos(account.address, 1)[0]; + const utxo = generateFormattedUtxos(account.address, 1)[0]; const input = new TxInput(utxo, script); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index bdc5fc7d..897fca0b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -1,6 +1,6 @@ import { networks } from 'bitcoinjs-lib'; -import { generateFormatedUtxos } from '../../../test/utils'; +import { generateFormattedUtxos } from '../../../test/utils'; import { P2WPKHAccount, P2WPKHTestnetAccount } from './account'; import { CoinSelectService } from './coin-select'; import { DustLimit, ScriptType } from './constants'; @@ -149,7 +149,12 @@ describe('BtcWallet', () => { const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(account.address, 200, 100000, 100000); + const utxos = generateFormattedUtxos( + account.address, + 200, + 100000, + 100000, + ); const result = await wallet.createTransaction( account, @@ -179,7 +184,7 @@ describe('BtcWallet', () => { const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(account.address, 200, 10000, 10000); + const utxos = generateFormattedUtxos(account.address, 200, 10000, 10000); const result = await wallet.createTransaction( account, @@ -209,7 +214,7 @@ describe('BtcWallet', () => { const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(account.address, 2); + const utxos = generateFormattedUtxos(account.address, 2); const coinSelectServiceSpy = jest.spyOn( CoinSelectService.prototype, 'selectCoins', @@ -247,7 +252,7 @@ describe('BtcWallet', () => { const wallet = new BtcWallet(instance, network); const chgAccount = await wallet.unlock(0, ScriptType.P2wpkh); const recipient = await wallet.unlock(1, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(chgAccount.address, 2, 10000, 10000); + const utxos = generateFormattedUtxos(chgAccount.address, 2, 10000, 10000); const coinSelectServiceSpy = jest.spyOn( CoinSelectService.prototype, 'selectCoins', @@ -288,7 +293,7 @@ describe('BtcWallet', () => { const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(account.address, 2, 8000, 8000); + const utxos = generateFormattedUtxos(account.address, 2, 8000, 8000); await expect( wallet.createTransaction( @@ -311,7 +316,7 @@ describe('BtcWallet', () => { const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormatedUtxos(account.address, 2, 10000, 10000); + const utxos = generateFormattedUtxos(account.address, 2, 10000, 10000); const { tx } = await wallet.createTransaction( account, diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 34546551..1a66ac63 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -12,8 +12,8 @@ export type SnapConfig = { defaultAccountIndex: number; defaultAccountType: string; }; - avaliableNetworks: string[]; - avaliableAssets: string[]; + availableNetworks: string[]; + availableAssets: string[]; unit: string; explorer: { [network in string]: string; @@ -34,8 +34,8 @@ export const Config: SnapConfig = { defaultAccountIndex: 0, defaultAccountType: 'bip122:p2wpkh', }, - avaliableNetworks: Object.values(Caip2ChainId), - avaliableAssets: Object.values(Caip2Asset), + availableNetworks: Object.values(Caip2ChainId), + availableAssets: Object.values(Caip2Asset), unit: 'BTC', explorer: { // eslint-disable-next-line no-template-curly-in-string diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts index a4a9c5dc..bae60564 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.ts @@ -58,7 +58,7 @@ describe('onRpcRequest', () => { request: { method, params: { - scope: Config.avaliableNetworks[0], + scope: Config.availableNetworks[0], }, } as unknown as JsonRpcRequest, }); @@ -110,7 +110,7 @@ describe('onKeyringRequest', () => { request: { method: keyringApi.KeyringRpcMethod.ListAccounts, params: { - scope: Config.avaliableNetworks[0], + scope: Config.availableNetworks[0], }, } as unknown as JsonRpcRequest, }); @@ -124,7 +124,7 @@ describe('onKeyringRequest', () => { expect(handler).toHaveBeenCalledWith(expect.any(BtcKeyring), { method: keyringApi.KeyringRpcMethod.ListAccounts, params: { - scope: Config.avaliableNetworks[0], + scope: Config.availableNetworks[0], }, }); }); @@ -145,7 +145,7 @@ describe('onKeyringRequest', () => { request: { method, params: { - scope: Config.avaliableNetworks[0], + scope: Config.availableNetworks[0], }, } as unknown as JsonRpcRequest, }); @@ -170,7 +170,7 @@ describe('onKeyringRequest', () => { request: { method, params: { - scope: Config.avaliableNetworks[0], + scope: Config.availableNetworks[0], }, } as unknown as JsonRpcRequest, }); @@ -178,14 +178,14 @@ describe('onKeyringRequest', () => { } }); - it('throws SnapError if an error catched', async () => { + it('throws SnapError if an error was thrown', async () => { const { handler } = createMockHandleKeyringRequest(); handler.mockRejectedValue(new Error('error')); await expect(executeRequest()).rejects.toThrow(SnapError); }); - it('throws SnapError if an SnapError catched', async () => { + it('throws SnapError if an SnapError was thrown', async () => { const { handler } = createMockHandleKeyringRequest(); handler.mockRejectedValue(new SnapError('error')); @@ -212,7 +212,7 @@ describe('onKeyringRequest', () => { request: { method, params: { - scope: Config.avaliableNetworks[0], + scope: Config.availableNetworks[0], }, } as unknown as JsonRpcRequest, }), @@ -237,7 +237,7 @@ describe('onKeyringRequest', () => { request: { method, params: { - scope: Config.avaliableNetworks[0], + scope: Config.availableNetworks[0], }, } as unknown as JsonRpcRequest, }), diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts index 8b970f24..8ef89204 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts @@ -134,7 +134,7 @@ describe('BtcKeyring', () => { }); }); - it('throws Error if an error catched', async () => { + it('throws an Error if another Error was thrown', async () => { const { unlockSpy } = createMockWallet(); const { instance: stateMgr } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); @@ -232,7 +232,7 @@ describe('BtcKeyring', () => { expect(listAccountsSpy).toHaveBeenCalledTimes(1); }); - it('throws Error if an error catched', async () => { + it('throws an Error if another Error was thrown', async () => { const { instance: stateMgr, listAccountsSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); listAccountsSpy.mockRejectedValue(new Error('error')); @@ -266,7 +266,7 @@ describe('BtcKeyring', () => { expect(getAccountSpy).toHaveBeenCalledTimes(1); }); - it('throws Error if an error catched', async () => { + it('throws an Error if another Error was thrown', async () => { const { instance: stateMgr, getAccountSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); getAccountSpy.mockRejectedValue(new Error('error')); @@ -300,7 +300,7 @@ describe('BtcKeyring', () => { expect(removeAccountsSpy).toHaveBeenCalledWith([account.id]); }); - it('throws Error if an error catched', async () => { + it('throws an Error if another Error was thrown', async () => { const { instance: stateMgr, removeAccountsSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); removeAccountsSpy.mockRejectedValue(new Error('error')); @@ -508,7 +508,7 @@ describe('BtcKeyring', () => { }); }); - it('throws Error if an error catched', async () => { + it('throws an Error if another Error was thrown', async () => { const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); getWalletSpy.mockRejectedValue(new Error('error')); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts index 29d2c1a7..7dbd59ef 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts @@ -14,7 +14,7 @@ jest.mock('../utils/logger'); jest.mock('../utils/snap'); describe('getBalances', () => { - const asset = Config.avaliableAssets[0]; + const asset = Config.availableAssets[0]; const createMockChainService = () => { const getBalancesSpy = jest.spyOn( diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 5d0e2d6b..9ad31037 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -3,7 +3,7 @@ import { UserRejectedRequestError, } from '@metamask/snaps-sdk'; import { networks } from 'bitcoinjs-lib'; -import { v4 as uuidv4 } from 'uuid'; +import { v4 as uuidV4 } from 'uuid'; import { generateBlockChairBroadcastTransactionResp, @@ -68,7 +68,7 @@ describe('SendManyHandler', () => { const keyringAccount = { type: sender.type, - id: uuidv4(), + id: uuidV4(), address: sender.address, options: { scope: caip2ChainId, @@ -542,7 +542,7 @@ describe('SendManyHandler', () => { ).rejects.toThrow('Failed to send the transaction'); }); - it('throws DisplayableError error meesage if the DisplayableError throwed', async () => { + it('throws DisplayableError error message if the DisplayableError is thrown', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; const { broadcastTransactionSpy, sender, recipients } = diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 6cd9d6f4..b63a527c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -39,9 +39,9 @@ import { logger, } from '../utils'; -export const TransactionAmountStuct = refine( +export const TransactionAmountStruct = refine( record(BtcP2wpkhAddressStruct, string()), - 'TransactionAmountStuct', + 'TransactionAmountStruct', (value: Record) => { if (Object.entries(value).length === 0) { return 'Transaction must have at least one recipient'; @@ -69,7 +69,7 @@ export const TransactionAmountStuct = refine( ); export const sendManyParamsStruct = object({ - amounts: TransactionAmountStuct, + amounts: TransactionAmountStruct, comment: string(), subtractFeeFrom: array(BtcP2wpkhAddressStruct), replaceable: boolean(), @@ -209,7 +209,7 @@ export async function getTxConsensus( let i = 0; - const addReciptentsToComponents = (data: { + const addRecipientsToComponents = (data: { address: string; value: bigint; }) => { @@ -235,10 +235,10 @@ export async function getTxConsensus( components.push(divider()); }; - info.recipients.forEach(addReciptentsToComponents); + info.recipients.forEach(addRecipientsToComponents); if (info.change) { - [info.change].forEach(addReciptentsToComponents); + [info.change].forEach(addRecipientsToComponents); } const bottomPanel: Component[] = []; diff --git a/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts b/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts index 883c0045..a322d44f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts @@ -82,7 +82,7 @@ describe('KeyringStateManager', () => { expect(result).toStrictEqual([]); }); - it('throw Error if an error catched', async () => { + it('throws an Error if another Error was thrown', async () => { const { instance, getDataSpy } = createMockStateManager(); getDataSpy.mockRejectedValue(new Error('error')); @@ -208,7 +208,7 @@ describe('KeyringStateManager', () => { ); }); - it('throw Error if an error catched', async () => { + it('throws an Error if another Error was thrown', async () => { const { instance, getDataSpy } = createMockStateManager(); getDataSpy.mockRejectedValue(new Error('error')); const state = createInitState(1); @@ -244,7 +244,7 @@ describe('KeyringStateManager', () => { expect(result).toBeNull(); }); - it('throw Error if an error catched', async () => { + it('throws an Error if another Error was thrown', async () => { const { instance, getDataSpy } = createMockStateManager(); getDataSpy.mockRejectedValue(new Error('error')); const { id } = generateAccounts(1)[0]; @@ -346,7 +346,7 @@ describe('KeyringStateManager', () => { expect(result).toBeNull(); }); - it('throw Error if an error catched', async () => { + it('throws when getData fails', async () => { const { instance, getDataSpy } = createMockStateManager(); getDataSpy.mockRejectedValue(new Error('error')); const state = createInitState(20); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/async.ts b/merged-packages/bitcoin-wallet-snap/src/utils/async.ts index dd1eb527..78817007 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/async.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/async.ts @@ -1,22 +1,22 @@ /** * Executes the given promise callback on the data in batches. * - * @param datas - An array of data to be processed in batches. + * @param dataList - An array of data to be processed in batches. * @param callback - A promise callback to be executed on each item of the array. * @param batchSize - The size of the batch operation, default is 50. * @returns A promise that resolves when all batches have been processed. */ export async function processBatch( - datas: Data[], + dataList: Data[], callback: (item: Data) => Promise, batchSize = 50, ): Promise { let from = 0; let to = batchSize; - while (from < datas.length) { + while (from < dataList.length) { const batch: Promise[] = []; - for (let i = from; i < Math.min(to, datas.length); i++) { - batch.push(callback(datas[i])); + for (let i = from; i < Math.min(to, dataList.length); i++) { + batch.push(callback(dataList[i])); } await Promise.all(batch); from += batchSize; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts index 18c9ea3d..88e86244 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts @@ -19,7 +19,7 @@ type MockTransactions = string[]; type MockState = { transaction: MockTransactions; - trasansactionDetails: MockTransactionDetails; + transactionDetails: MockTransactionDetails; }; type MockExecuteTransactionInput = { @@ -49,7 +49,7 @@ describe('SnapStateManager', () => { const instance = new MockSnapStateManager(); - const executeTransationFn = async ( + const executeTransactionFn = async ( data: StateDataInput, delay: number, isThrowError?: boolean, @@ -63,7 +63,7 @@ describe('SnapStateManager', () => { } await new Promise((resolve) => setTimeout(resolve, delay)); if (isThrowError) { - throw new Error('executeTransationFn error'); + throw new Error('executeTransactionFn error'); } }); }; @@ -79,7 +79,7 @@ describe('SnapStateManager', () => { return { instance, updateDataSpy, - executeTransationFn, + executeTransactionFn, executeFn, }; }; @@ -87,8 +87,8 @@ describe('SnapStateManager', () => { const createMockState = (initState: MockState) => { const setStateDataFn = async (data: MockState) => { initState.transaction = [...data.transaction]; - initState.trasansactionDetails = Object.entries( - data.trasansactionDetails, + initState.transactionDetails = Object.entries( + data.transactionDetails, ).reduce( (acc, [key, value]: [key: string, value: MockTransactionDetail]) => { acc[key] = { @@ -106,19 +106,19 @@ describe('SnapStateManager', () => { ) => { if ( Object.prototype.hasOwnProperty.call( - state.trasansactionDetails, + state.transactionDetails, data.id, ) === false ) { state.transaction.push(data.id); - state.trasansactionDetails[data.id] = { + state.transactionDetails[data.id] = { txHash: data.txHash, cnt: data.cnt, }; } else { - state.trasansactionDetails[data.id] = { + state.transactionDetails[data.id] = { txHash: data.txHash, - cnt: state.trasansactionDetails[data.id].cnt + data.cnt, + cnt: state.transactionDetails[data.id].cnt + data.cnt, }; } }; @@ -128,8 +128,8 @@ describe('SnapStateManager', () => { .mockImplementation(async () => { return { transaction: [...initState.transaction], - trasansactionDetails: Object.entries( - initState.trasansactionDetails, + transactionDetails: Object.entries( + initState.transactionDetails, ).reduce( ( acc, @@ -233,7 +233,7 @@ describe('SnapStateManager', () => { it('executes callback code', async () => { const initState = { transaction: ['id'], - trasansactionDetails: { + transactionDetails: { id: { txHash: 'hash', cnt: 4, @@ -243,7 +243,7 @@ describe('SnapStateManager', () => { const { getStateDataSpy, updateDataFn } = createMockState(initState); - const { updateDataSpy, executeTransationFn } = createMockStateManager< + const { updateDataSpy, executeTransactionFn } = createMockStateManager< MockState, MockExecuteTransactionInput >(); @@ -251,7 +251,7 @@ describe('SnapStateManager', () => { updateDataSpy.mockImplementation(updateDataFn); const promiseArr = [ - executeTransationFn( + executeTransactionFn( { txHash: 'hash-final', id: 'id', @@ -259,7 +259,7 @@ describe('SnapStateManager', () => { }, 30, ), - executeTransationFn( + executeTransactionFn( { txHash: 'hash2', id: 'id2', @@ -272,7 +272,7 @@ describe('SnapStateManager', () => { await Promise.all(promiseArr); expect(initState.transaction).toStrictEqual(['id', 'id2']); - expect(initState.trasansactionDetails).toStrictEqual({ + expect(initState.transactionDetails).toStrictEqual({ id: { txHash: 'hash-final', cnt: 6, @@ -287,10 +287,10 @@ describe('SnapStateManager', () => { expect(updateDataSpy).toHaveBeenCalledTimes(promiseArr.length); }); - it('does rollback if an error catched and has committed', async () => { + it('rolls back if an error is caught after a commit', async () => { const initState = { transaction: ['id'], - trasansactionDetails: { + transactionDetails: { id: { txHash: 'hash', cnt: 4, @@ -298,14 +298,14 @@ describe('SnapStateManager', () => { }, }; const { getStateDataSpy, updateDataFn } = createMockState(initState); - const { updateDataSpy, executeTransationFn } = createMockStateManager< + const { updateDataSpy, executeTransactionFn } = createMockStateManager< MockState, MockExecuteTransactionInput >(); updateDataSpy.mockImplementation(updateDataFn); const promiseArr = [ - executeTransationFn( + executeTransactionFn( { txHash: 'hash4', id: 'id', @@ -313,7 +313,7 @@ describe('SnapStateManager', () => { }, 10, ), - executeTransationFn( + executeTransactionFn( { txHash: 'hash-final', id: 'id', @@ -323,7 +323,7 @@ describe('SnapStateManager', () => { true, true, ), - executeTransationFn( + executeTransactionFn( { txHash: 'hash2', id: 'id2', @@ -336,7 +336,7 @@ describe('SnapStateManager', () => { await Promise.allSettled(promiseArr); expect(initState.transaction).toStrictEqual(['id', 'id2']); - expect(initState.trasansactionDetails).toStrictEqual({ + expect(initState.transactionDetails).toStrictEqual({ id: { txHash: 'hash4', cnt: 5, @@ -350,11 +350,11 @@ describe('SnapStateManager', () => { expect(updateDataSpy).toHaveBeenCalledTimes(promiseArr.length); }); - it('does not trigger rollback if an error catched and has not committed', async () => { - const hasCommited = false; + it('does not trigger rollback if an error is caught and has not been committed', async () => { + const hasCommitted = false; const initState: MockState = { transaction: ['id'], - trasansactionDetails: { + transactionDetails: { id: { txHash: 'hash', cnt: 4, @@ -362,7 +362,7 @@ describe('SnapStateManager', () => { }, }; const { setStateDataSpy, updateDataFn } = createMockState(initState); - const { updateDataSpy, executeTransationFn } = createMockStateManager< + const { updateDataSpy, executeTransactionFn } = createMockStateManager< MockState, MockExecuteTransactionInput >(); @@ -370,7 +370,7 @@ describe('SnapStateManager', () => { let expectedError; try { - await executeTransationFn( + await executeTransactionFn( { txHash: 'hash-final', id: 'id', @@ -378,7 +378,7 @@ describe('SnapStateManager', () => { }, 30, true, - hasCommited, + hasCommitted, ); } catch (error) { expectedError = error; @@ -386,7 +386,7 @@ describe('SnapStateManager', () => { expect(expectedError).toBeInstanceOf(Error); expect(initState.transaction).toStrictEqual(['id']); expect(setStateDataSpy).toHaveBeenCalledTimes(0); - expect(initState.trasansactionDetails).toStrictEqual({ + expect(initState.transactionDetails).toStrictEqual({ id: { txHash: 'hash', cnt: 4, @@ -399,7 +399,7 @@ describe('SnapStateManager', () => { const committed = true; const initState: MockState = { transaction: ['id'], - trasansactionDetails: { + transactionDetails: { id: { txHash: 'hash', cnt: 4, @@ -408,7 +408,7 @@ describe('SnapStateManager', () => { }; const { setStateDataSpy, updateDataFn, setStateDataFn } = createMockState(initState); - const { updateDataSpy, executeTransationFn } = createMockStateManager< + const { updateDataSpy, executeTransactionFn } = createMockStateManager< MockState, MockExecuteTransactionInput >(); @@ -420,7 +420,7 @@ describe('SnapStateManager', () => { updateDataSpy.mockImplementation(updateDataFn); const promiseArr = [ - executeTransationFn( + executeTransactionFn( { txHash: 'hash-final', id: 'id', @@ -430,7 +430,7 @@ describe('SnapStateManager', () => { true, committed, ), - executeTransationFn( + executeTransactionFn( { txHash: 'hash-final', id: 'id', @@ -444,7 +444,7 @@ describe('SnapStateManager', () => { await Promise.allSettled(promiseArr); expect(initState.transaction).toStrictEqual(['id']); - expect(initState.trasansactionDetails).toStrictEqual({ + expect(initState.transactionDetails).toStrictEqual({ id: { txHash: 'hash-final', cnt: 6, @@ -455,16 +455,16 @@ describe('SnapStateManager', () => { it('does not have racing condition', async () => { const initState = { transaction: [], - trasansactionDetails: {}, + transactionDetails: {}, }; const { getStateDataSpy, updateDataFn } = createMockState(initState); - const { updateDataSpy, executeTransationFn, executeFn } = + const { updateDataSpy, executeTransactionFn, executeFn } = createMockStateManager(); updateDataSpy.mockImplementation(updateDataFn); const promiseArr = [ - executeTransationFn( + executeTransactionFn( { txHash: 'hash', id: 'id', @@ -472,7 +472,7 @@ describe('SnapStateManager', () => { }, 30, ), - executeTransationFn( + executeTransactionFn( { txHash: 'hash2', id: 'id2', @@ -488,7 +488,7 @@ describe('SnapStateManager', () => { }, 0, ), - executeTransationFn( + executeTransactionFn( { txHash: 'hash-updated', id: 'id', @@ -496,7 +496,7 @@ describe('SnapStateManager', () => { }, 30, ), - executeTransationFn( + executeTransactionFn( { txHash: 'hash3', id: 'id3', @@ -504,7 +504,7 @@ describe('SnapStateManager', () => { }, 10, ), - executeTransationFn( + executeTransactionFn( { txHash: 'hash-updated-final', id: 'id', @@ -517,7 +517,7 @@ describe('SnapStateManager', () => { await Promise.all(promiseArr); expect(initState.transaction).toStrictEqual(['id', 'id2', 'id3']); - expect(initState.trasansactionDetails).toStrictEqual({ + expect(initState.transactionDetails).toStrictEqual({ id: { txHash: 'hash-updated-final', cnt: 6, @@ -538,12 +538,12 @@ describe('SnapStateManager', () => { it('throws `Failed to begin transaction` error, if the transaction can not init', async () => { const initState = { transaction: [], - trasansactionDetails: {}, + transactionDetails: {}, }; const { getStateDataSpy } = createMockState(initState); - const { executeTransationFn } = createMockStateManager< + const { executeTransactionFn } = createMockStateManager< MockState, MockExecuteTransactionInput >(); @@ -551,7 +551,7 @@ describe('SnapStateManager', () => { getStateDataSpy.mockResolvedValue(undefined); await expect( - executeTransationFn( + executeTransactionFn( { txHash: 'hash-final', id: 'id', @@ -562,32 +562,32 @@ describe('SnapStateManager', () => { ).rejects.toThrow('Failed to begin transaction'); }); - it('throws Error error, if an Error catched', async () => { + it('throws an Error if another Error was thrown', async () => { const initState = { transaction: [], - trasansactionDetails: {}, + transactionDetails: {}, }; const { setStateDataSpy, setStateDataFn, updateDataFn } = createMockState(initState); - const { executeTransationFn, updateDataSpy } = createMockStateManager< + const { executeTransactionFn, updateDataSpy } = createMockStateManager< MockState, MockExecuteTransactionInput >(); updateDataSpy.mockImplementation(updateDataFn); - // firsy mockImplementation is to mock the set data actions + // first mockImplementation mocks the set data actions setStateDataSpy .mockImplementationOnce(async () => { throw new Error('setStateDataSpy'); - // second mockImplementation is to mock the rollback actions + // second mockImplementation mocks the rollback actions }) .mockImplementationOnce(setStateDataFn); await expect( - executeTransationFn( + executeTransactionFn( { txHash: 'hash-final', id: 'id', diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts index 46991b87..ff23cc83 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts @@ -10,7 +10,7 @@ export type Transaction = { orgState?: State; current?: State; isRollingBack: boolean; - hasCommited: boolean; + hasCommitted: boolean; }; export abstract class SnapStateManager { @@ -25,7 +25,7 @@ export abstract class SnapStateManager { orgState: undefined, current: undefined, isRollingBack: false, - hasCommited: false, + hasCommitted: false, }; } @@ -109,7 +109,7 @@ export abstract class SnapStateManager { if (!this.#transaction.current || !this.#transaction.orgState) { throw new Error('Failed to commit transaction'); } - this.#transaction.hasCommited = true; + this.#transaction.hasCommitted = true; await this.set(this.#transaction.current); } @@ -119,7 +119,7 @@ export abstract class SnapStateManager { orgState: await this.get(), current: await this.get(), // make sure the current is not referenced to orgState isRollingBack: false, - hasCommited: false, + hasCommitted: false, }; } @@ -127,14 +127,14 @@ export abstract class SnapStateManager { try { // we only need to rollback if the transaction is committed if ( - this.#transaction.hasCommited && + this.#transaction.hasCommitted && !this.#transaction.isRollingBack && this.#transaction.orgState ) { logger.info( `SnapStateManager.rollback [${ this.#transactionId - }]: attemp to rollback state`, + }]: attempt to rollback state`, ); this.#transaction.isRollingBack = true; await this.set(this.#transaction.orgState); @@ -155,7 +155,7 @@ export abstract class SnapStateManager { this.#transaction.current = undefined; this.#transaction.id = undefined; this.#transaction.isRollingBack = false; - this.#transaction.hasCommited = false; + this.#transaction.hasCommitted = false; } get #transactionId(): string { diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts index eb394277..329d34f1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts @@ -8,14 +8,14 @@ import { } from './string'; describe('hexToBuffer', () => { - it('converts a hex string to buffer with trimed prefix', () => { + it('converts a hex string to buffer with trimmed prefix', () => { const key = '0x1234'; const result = hexToBuffer(key); expect(result).toStrictEqual(Buffer.from('1234', 'hex')); }); - it('converts a hex string to buffer without trimed prefix', () => { + it('converts a hex string to buffer without trimmed prefix', () => { const key = '0x1234'; const result = hexToBuffer(key, false); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts index f9440c9f..48a995b3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts @@ -45,7 +45,7 @@ describe('superstruct', () => { describe('scopeStruct', () => { it('validates correctly', () => { expect(() => - assert(Config.avaliableNetworks[0], superstruct.scopeStruct), + assert(Config.availableNetworks[0], superstruct.scopeStruct), ).not.toThrow(); expect(() => assert('custom scope', superstruct.scopeStruct)).toThrow( Error, @@ -56,7 +56,7 @@ describe('superstruct', () => { describe('assetsStruct', () => { it('validates correctly', () => { expect(() => - assert(Config.avaliableAssets[0], superstruct.assetsStruct), + assert(Config.availableAssets[0], superstruct.assetsStruct), ).not.toThrow(); expect(() => assert('custom scope', superstruct.assetsStruct)).toThrow( Error, diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts index e6e04571..8c1f9470 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts @@ -2,9 +2,9 @@ import { enums, string, pattern } from 'superstruct'; import { Config } from '../config'; -export const assetsStruct = enums(Config.avaliableAssets); +export const assetsStruct = enums(Config.availableAssets); -export const scopeStruct = enums(Config.avaliableNetworks); +export const scopeStruct = enums(Config.availableNetworks); export const positiveStringStruct = pattern( string(), diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json index f7028dd1..936642f6 100644 --- a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json +++ b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json @@ -198,7 +198,7 @@ "last_major_update": "2022-11-07 02:00:00", "next_major_update": "2023-11-12 02:00:00", "documentation": "https://blockchair.com/api/docs", - "notice": "Plaese note that on November 12th, 2023 public support for the following blockchains will be dropped: Mixin, Ethereum Testnet (Goerli)" + "notice": "Please note that on November 12th, 2023 public support for the following blockchains will be dropped: Mixin, Ethereum Testnet (Goerli)" }, "servers": "API4,TBTC3", "time": 2.340773820877075, diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index c5c27fba..e7ce8fd1 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -3,7 +3,7 @@ import { BIP32Factory, type BIP32Interface } from 'bip32'; import type { Network } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; import ECPairFactory from 'ecpair'; -import { v4 as uuidv4 } from 'uuid'; +import { v4 as uuidV4 } from 'uuid'; import { Caip2ChainId } from '../src/constants'; import blockChairData from './fixtures/blockchair.json'; @@ -27,7 +27,7 @@ export function generateAccounts(cnt = 1, addressPrefix = '') { for (let i = 0; i < cnt; i++) { accounts.push({ type: 'bip122:p2wpkh', - id: uuidv4(), + id: uuidV4(), address: baseAddress.slice(0, baseAddress.length - i.toString().length) + i.toString(), @@ -328,15 +328,15 @@ export function generateBlockChairTransactionDashboard( } /** - * Method to generate formated utxos with blockchair resp. + * Method to generate formatted utxos with blockchair resp. * * @param address - the utxos owner address. - * @param utxoCnt - count of the utox to be generated. + * @param utxoCnt - count of the utxo to be generated. * @param minAmt - min amount of the utxo array. * @param maxAmt - max amount of the utxo array. * @returns An formatted utxo array. */ -export function generateFormatedUtxos( +export function generateFormattedUtxos( address: string, utxoCnt: number, minAmt?: number, From ceedfea439e9ab6409869115c6fb1ca744d627e6 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 16 Jul 2024 17:06:14 +0800 Subject: [PATCH 109/362] chore: rename struct to pascal case (#172) * chore: rename struct to pascal case * chore: rename PositiveStringStruct --- .../src/bitcoin/chain/clients/blockchair.ts | 4 +- .../bitcoin-wallet-snap/src/keyring.ts | 4 +- .../src/rpcs/get-balances.ts | 36 +++++---- .../src/rpcs/get-transaction-status.ts | 20 ++--- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 16 ++-- .../src/utils/superstruct.test.ts | 76 +++++++++---------- .../src/utils/superstruct.ts | 8 +- 7 files changed, 81 insertions(+), 83 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts index e8993879..15700832 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts @@ -3,7 +3,7 @@ import type { Json } from '@metamask/snaps-sdk'; import { type Network, networks } from 'bitcoinjs-lib'; import { array, assert } from 'superstruct'; -import { compactError, logger, txIdStruct } from '../../../utils'; +import { compactError, logger, TxIdStruct } from '../../../utils'; import { FeeRatio, TransactionStatus } from '../constants'; import type { IDataClient, @@ -274,7 +274,7 @@ export class BlockChairClient implements IDataClient { txHash: string, ): Promise { try { - assert(txHash, txIdStruct); + assert(txHash, TxIdStruct); logger.info(`[BlockChairClient.getTxDashboardData] start:`); const response = await this.get( diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index e1c856a7..fd327156 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -18,7 +18,7 @@ import { Config } from './config'; import { Factory } from './factory'; import { getBalances, type SendManyParams, sendMany } from './rpcs'; import type { KeyringStateManager, Wallet } from './stateManagement'; -import { getProvider, scopeStruct, logger } from './utils'; +import { getProvider, ScopeStruct, logger } from './utils'; export type KeyringOptions = Record & { defaultIndex: number; @@ -27,7 +27,7 @@ export type KeyringOptions = Record & { }; export const CreateAccountOptionsStruct = object({ - scope: scopeStruct, + scope: ScopeStruct, }); export type CreateAccountOptions = Record & diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts index 3df4743f..80d0a6e4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts @@ -12,29 +12,27 @@ import { satsToBtc, } from '../utils'; import { - assetsStruct, - positiveStringStruct, - scopeStruct, + AssetsStruct, + PositiveNumberStringStruct, + ScopeStruct, } from '../utils/superstruct'; -export const getBalancesRequestStruct = object({ - assets: array(assetsStruct), - scope: scopeStruct, +export const GetBalancesRequestStruct = object({ + assets: array(AssetsStruct), + scope: ScopeStruct, }); -export const getBalancesResponseStruct = object({ - assets: record( - assetsStruct, - object({ - amount: positiveStringStruct, - unit: enums([Config.unit]), - }), - ), -}); +export const GetBalancesResponseStruct = record( + AssetsStruct, + object({ + amount: PositiveNumberStringStruct, + unit: enums([Config.unit]), + }), +); -export type GetBalancesParams = Infer; +export type GetBalancesParams = Infer; -export type GetBalancesResponse = Infer; +export type GetBalancesResponse = Infer; /** * Get Balances by a given account. @@ -48,7 +46,7 @@ export async function getBalances( params: GetBalancesParams, ) { try { - validateRequest(params, getBalancesRequestStruct); + validateRequest(params, GetBalancesRequestStruct); const { assets, scope } = params; @@ -91,7 +89,7 @@ export async function getBalances( ]), ); - validateResponse(params, getBalancesRequestStruct); + validateResponse(resp, GetBalancesResponseStruct); return resp; } catch (error) { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts index 2ee63c7e..4684bc26 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts @@ -5,28 +5,28 @@ import { TransactionStatus } from '../bitcoin/chain'; import { Factory } from '../factory'; import { isSnapRpcError, - scopeStruct, + ScopeStruct, validateRequest, validateResponse, logger, - txIdStruct, + TxIdStruct, } from '../utils'; -export const getTransactionStatusParamsRequestStruct = object({ - transactionId: nonempty(txIdStruct), - scope: scopeStruct, +export const GetTransactionStatusParamsRequestStruct = object({ + transactionId: nonempty(TxIdStruct), + scope: ScopeStruct, }); -export const getTransactionStatusParamsResponseStruct = object({ +export const GetTransactionStatusParamsResponseStruct = object({ status: enums(Object.values(TransactionStatus)), }); export type GetTransactionStatusParams = Infer< - typeof getTransactionStatusParamsRequestStruct + typeof GetTransactionStatusParamsRequestStruct >; export type GetTransactionStatusResponse = Infer< - typeof getTransactionStatusParamsResponseStruct + typeof GetTransactionStatusParamsResponseStruct >; /** @@ -39,7 +39,7 @@ export async function getTransactionStatus( params: GetTransactionStatusParams, ): Promise { try { - validateRequest(params, getTransactionStatusParamsRequestStruct); + validateRequest(params, GetTransactionStatusParamsRequestStruct); const { scope, transactionId } = params; @@ -51,7 +51,7 @@ export async function getTransactionStatus( status: txStatusResp.status, }; - validateResponse(resp, getTransactionStatusParamsResponseStruct); + validateResponse(resp, GetTransactionStatusParamsResponseStruct); return resp; } catch (error) { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index b63a527c..926c5d35 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -27,7 +27,7 @@ import { } from '../bitcoin/wallet'; import { Factory } from '../factory'; import { - scopeStruct, + ScopeStruct, confirmDialog, isSnapRpcError, shortenAddress, @@ -68,23 +68,23 @@ export const TransactionAmountStruct = refine( }, ); -export const sendManyParamsStruct = object({ +export const SendManyParamsStruct = object({ amounts: TransactionAmountStruct, comment: string(), subtractFeeFrom: array(BtcP2wpkhAddressStruct), replaceable: boolean(), dryrun: optional(boolean()), - scope: scopeStruct, + scope: ScopeStruct, }); -export const sendManyResponseStruct = object({ +export const SendManyResponseStruct = object({ txId: nonempty(string()), signedTransaction: optional(string()), }); -export type SendManyParams = Infer; +export type SendManyParams = Infer; -export type SendManyResponse = Infer; +export type SendManyResponse = Infer; /** * Send BTC to multiple account. @@ -100,7 +100,7 @@ export async function sendMany( params: SendManyParams, ) { try { - validateRequest(params, sendManyParamsStruct); + validateRequest(params, SendManyParamsStruct); const { dryrun, scope } = params; const chainApi = Factory.createOnChainServiceProvider(scope); @@ -152,7 +152,7 @@ export async function sendMany( txId: result.transactionId, }; - validateResponse(resp, sendManyResponseStruct); + validateResponse(resp, SendManyResponseStruct); return resp; } catch (error) { diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts index 48a995b3..b45c0031 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts @@ -4,61 +4,61 @@ import { Config } from '../config'; import * as superstruct from './superstruct'; describe('superstruct', () => { - describe('positiveStringStruct', () => { + describe('PositiveNumberStringStruct', () => { it('validates correctly', () => { - expect(() => assert('1', superstruct.positiveStringStruct)).not.toThrow( - Error, - ); - expect(() => assert('1.2', superstruct.positiveStringStruct)).not.toThrow( - Error, - ); - expect(() => assert('0', superstruct.positiveStringStruct)).not.toThrow( - Error, - ); expect(() => - assert('0.0023', superstruct.positiveStringStruct), + assert('1', superstruct.PositiveNumberStringStruct), + ).not.toThrow(Error); + expect(() => + assert('1.2', superstruct.PositiveNumberStringStruct), + ).not.toThrow(Error); + expect(() => + assert('0', superstruct.PositiveNumberStringStruct), + ).not.toThrow(Error); + expect(() => + assert('0.0023', superstruct.PositiveNumberStringStruct), ).not.toThrow(); - expect(() => assert('0101', superstruct.positiveStringStruct)).toThrow( - Error, - ); - expect(() => assert('0101.1', superstruct.positiveStringStruct)).toThrow( - Error, - ); - expect(() => assert('-0101', superstruct.positiveStringStruct)).toThrow( - Error, - ); - expect(() => assert('-1.3', superstruct.positiveStringStruct)).toThrow( - Error, - ); - expect(() => assert(' 1.3', superstruct.positiveStringStruct)).toThrow( - Error, - ); - expect(() => assert('+1-3', superstruct.positiveStringStruct)).toThrow( - Error, - ); - expect(() => assert('abc', superstruct.positiveStringStruct)).toThrow( - Error, - ); + expect(() => + assert('0101', superstruct.PositiveNumberStringStruct), + ).toThrow(Error); + expect(() => + assert('0101.1', superstruct.PositiveNumberStringStruct), + ).toThrow(Error); + expect(() => + assert('-0101', superstruct.PositiveNumberStringStruct), + ).toThrow(Error); + expect(() => + assert('-1.3', superstruct.PositiveNumberStringStruct), + ).toThrow(Error); + expect(() => + assert(' 1.3', superstruct.PositiveNumberStringStruct), + ).toThrow(Error); + expect(() => + assert('+1-3', superstruct.PositiveNumberStringStruct), + ).toThrow(Error); + expect(() => + assert('abc', superstruct.PositiveNumberStringStruct), + ).toThrow(Error); }); }); - describe('scopeStruct', () => { + describe('ScopeStruct', () => { it('validates correctly', () => { expect(() => - assert(Config.availableNetworks[0], superstruct.scopeStruct), + assert(Config.availableNetworks[0], superstruct.ScopeStruct), ).not.toThrow(); - expect(() => assert('custom scope', superstruct.scopeStruct)).toThrow( + expect(() => assert('custom scope', superstruct.ScopeStruct)).toThrow( Error, ); }); }); - describe('assetsStruct', () => { + describe('AssetsStruct', () => { it('validates correctly', () => { expect(() => - assert(Config.availableAssets[0], superstruct.assetsStruct), + assert(Config.availableAssets[0], superstruct.AssetsStruct), ).not.toThrow(); - expect(() => assert('custom scope', superstruct.assetsStruct)).toThrow( + expect(() => assert('custom scope', superstruct.AssetsStruct)).toThrow( Error, ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts index 8c1f9470..7564843a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts @@ -2,13 +2,13 @@ import { enums, string, pattern } from 'superstruct'; import { Config } from '../config'; -export const assetsStruct = enums(Config.availableAssets); +export const AssetsStruct = enums(Config.availableAssets); -export const scopeStruct = enums(Config.availableNetworks); +export const ScopeStruct = enums(Config.availableNetworks); -export const positiveStringStruct = pattern( +export const PositiveNumberStringStruct = pattern( string(), /^(?!0\d)(\d+(\.\d+)?)$/u, ); -export const txIdStruct = pattern(string(), /^[0-9a-fA-F]{64}$/u); +export const TxIdStruct = pattern(string(), /^[0-9a-fA-F]{64}$/u); From a5802447e0a37f39cb13c849d970adcc0d08ba88 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Thu, 18 Jul 2024 11:29:13 +0200 Subject: [PATCH 110/362] chore: update '@metamask/*' dependencies (#183) * chore: update '@metamask/*' dependencies * chore: rebase `main` --- merged-packages/bitcoin-wallet-snap/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 33a62a5f..19982aed 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -40,11 +40,11 @@ "@metamask/eslint-config-nodejs": "^12.1.0", "@metamask/eslint-config-typescript": "^12.1.0", "@metamask/key-tree": "^9.1.2", - "@metamask/keyring-api": "^8.0.0", + "@metamask/keyring-api": "^8.0.1", "@metamask/snaps-cli": "^6.2.1", "@metamask/snaps-jest": "^8.2.0", - "@metamask/snaps-sdk": "^4.4.2", - "@metamask/utils": "^9.0.0", + "@metamask/snaps-sdk": "^6.0.0", + "@metamask/utils": "^9.1.0", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", "async-mutex": "^0.3.2", From f4e442e7118c42f2a0ecd738c3421bf6455e848f Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 19 Jul 2024 21:06:23 +0800 Subject: [PATCH 111/362] refactor: use custom superstruct validator for AmountStruct (#184) * chore: move tx amount struct to resuable * chore: update error msg * chore: update superstruct test case * chore: remove un necessary it.each * Update superstruct.test.ts --- .../src/rpcs/sendmany.test.ts | 21 ++- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 19 +-- .../src/utils/superstruct.test.ts | 123 +++++++++++------- .../src/utils/superstruct.ts | 25 +++- 4 files changed, 123 insertions(+), 65 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 9ad31037..7e458369 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -424,7 +424,7 @@ describe('SendManyHandler', () => { ).rejects.toThrow('Transaction must have at least one recipient'); }); - it('throws `Invalid amount for send` error if receive amount is not valid', async () => { + it('throws `Invalid amount, must be a positive finite number` error if receive amount is not valid', async () => { createMockChainApiFactory(); const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; @@ -441,7 +441,7 @@ describe('SendManyHandler', () => { [recipients[1].address]: '0.1', }, }), - ).rejects.toThrow('Invalid amount for send'); + ).rejects.toThrow('Invalid amount, must be a positive finite number'); await expect( sendMany(sender, origin, { @@ -451,7 +451,7 @@ describe('SendManyHandler', () => { [recipients[1].address]: '0.1', }, }), - ).rejects.toThrow('Invalid amount for send'); + ).rejects.toThrow('Invalid amount, must be a positive finite number'); await expect( sendMany(sender, origin, { @@ -461,7 +461,18 @@ describe('SendManyHandler', () => { [recipients[1].address]: '0.000000019', }, }), - ).rejects.toThrow('Invalid amount for send'); + ).rejects.toThrow('Invalid amount, must be a positive finite number'); + }); + + it('throws `Invalid amount, out of bounds` error if receive amount is out of bounds', async () => { + createMockChainApiFactory(); + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { recipients, sender } = await createSenderNRecipients( + network, + caip2ChainId, + 2, + ); await expect( sendMany(sender, origin, { @@ -471,7 +482,7 @@ describe('SendManyHandler', () => { [recipients[1].address]: '999999999.99999999', }, }), - ).rejects.toThrow('Invalid amount for send'); + ).rejects.toThrow('Invalid amount, out of bounds'); }); it('throws `Invalid response` error if the response is unexpected', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 926c5d35..e3ca4f6a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -18,6 +18,7 @@ import { refine, optional, nonempty, + assert, } from 'superstruct'; import { @@ -37,6 +38,7 @@ import { validateRequest, validateResponse, logger, + AmountStruct, } from '../utils'; export const TransactionAmountStruct = refine( @@ -48,20 +50,7 @@ export const TransactionAmountStruct = refine( } for (const val of Object.values(value)) { - const parsedVal = parseFloat(val); - if ( - Number.isNaN(parsedVal) || - parsedVal <= 0 || - !Number.isFinite(parsedVal) - ) { - return 'Invalid amount for send'; - } - - try { - btcToSats(val); - } catch (error) { - return 'Invalid amount for send'; - } + assert(val, AmountStruct); } return true; @@ -163,7 +152,7 @@ export async function sendMany( } if (error instanceof TxValidationError) { - throw error as unknown as Error; + throw error; } throw new Error('Failed to send the transaction'); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts index b45c0031..44707622 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts @@ -5,48 +5,34 @@ import * as superstruct from './superstruct'; describe('superstruct', () => { describe('PositiveNumberStringStruct', () => { - it('validates correctly', () => { - expect(() => - assert('1', superstruct.PositiveNumberStringStruct), - ).not.toThrow(Error); - expect(() => - assert('1.2', superstruct.PositiveNumberStringStruct), - ).not.toThrow(Error); - expect(() => - assert('0', superstruct.PositiveNumberStringStruct), - ).not.toThrow(Error); - expect(() => - assert('0.0023', superstruct.PositiveNumberStringStruct), - ).not.toThrow(); - expect(() => - assert('0101', superstruct.PositiveNumberStringStruct), - ).toThrow(Error); - expect(() => - assert('0101.1', superstruct.PositiveNumberStringStruct), - ).toThrow(Error); - expect(() => - assert('-0101', superstruct.PositiveNumberStringStruct), - ).toThrow(Error); - expect(() => - assert('-1.3', superstruct.PositiveNumberStringStruct), - ).toThrow(Error); - expect(() => - assert(' 1.3', superstruct.PositiveNumberStringStruct), - ).toThrow(Error); - expect(() => - assert('+1-3', superstruct.PositiveNumberStringStruct), - ).toThrow(Error); - expect(() => - assert('abc', superstruct.PositiveNumberStringStruct), - ).toThrow(Error); - }); + it.each(['1', '1.2', '0.0023'])( + 'does not throw error if the value is valid: %s', + (val: string) => { + expect(() => + assert(val, superstruct.PositiveNumberStringStruct), + ).not.toThrow(Error); + }, + ); + + it.each(['0101', '0101.1', '0101', '-1.3', ' 1.3', '+1-3', 'abc', '1e89'])( + 'throws error if the value is invalid: %s', + (val: string) => { + expect(() => + assert(val, superstruct.PositiveNumberStringStruct), + ).toThrow(Error); + }, + ); }); describe('ScopeStruct', () => { - it('validates correctly', () => { - expect(() => - assert(Config.availableNetworks[0], superstruct.ScopeStruct), - ).not.toThrow(); + it.each(Config.availableNetworks)( + 'does not throw error if the value is valid: %s', + (val: string) => { + expect(() => assert(val, superstruct.ScopeStruct)).not.toThrow(Error); + }, + ); + + it('throws error if the value is invalid', () => { expect(() => assert('custom scope', superstruct.ScopeStruct)).toThrow( Error, ); @@ -54,13 +40,62 @@ describe('superstruct', () => { }); describe('AssetsStruct', () => { - it('validates correctly', () => { - expect(() => - assert(Config.availableAssets[0], superstruct.AssetsStruct), - ).not.toThrow(); - expect(() => assert('custom scope', superstruct.AssetsStruct)).toThrow( + it.each(Config.availableAssets)( + 'does not throw error if the value is valid: %s', + (val: string) => { + expect(() => assert(val, superstruct.AssetsStruct)).not.toThrow(Error); + }, + ); + + it('throws error if the value is invalid', () => { + expect(() => assert('custom asset', superstruct.AssetsStruct)).toThrow( Error, ); }); }); + + describe('TxIdStruct', () => { + it('does not throw error if the value is valid', () => { + expect(() => + assert( + '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08', + superstruct.TxIdStruct, + ), + ).not.toThrow(); + }); + + it.each([ + // 63 characters long while `transactionId` is expected to be 64 long + '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c0', + // 64 characters long but with invalid characters * / z + '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c*z', + 'z9a', + ])('throws error if the value is valid: %s', (val: string) => { + expect(() => assert(val, superstruct.TxIdStruct)).toThrow(Error); + }); + }); + + describe('AmountStruct', () => { + it('does not throw error if the value is valid', () => { + expect(() => assert('1', superstruct.AmountStruct)).not.toThrow(); + }); + + it.each(['0', '-1', 'abc', '1e-99999999999'])( + 'throws `Invalid amount, must be a positive finite number` error if the value is invalid: %s', + (val: string) => { + expect(() => assert(val, superstruct.AmountStruct)).toThrow( + 'Invalid amount, must be a positive finite number', + ); + }, + ); + + it.each(['9999999999.9999999999', '9999999999.0', '0.9999999999'])( + 'throws `Invalid amount, out of bounds` error if the value is out of bounds: %s', + (val: string) => { + expect(() => assert(val, superstruct.AmountStruct)).toThrow( + 'Invalid amount, out of bounds', + ); + }, + ); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts index 7564843a..9009e00c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts @@ -1,6 +1,7 @@ -import { enums, string, pattern } from 'superstruct'; +import { enums, string, pattern, refine } from 'superstruct'; import { Config } from '../config'; +import { btcToSats } from './unit'; export const AssetsStruct = enums(Config.availableAssets); @@ -12,3 +13,25 @@ export const PositiveNumberStringStruct = pattern( ); export const TxIdStruct = pattern(string(), /^[0-9a-fA-F]{64}$/u); + +export const AmountStruct = refine( + string(), + 'AmountStruct', + (value: string) => { + const parsedVal = parseFloat(value); + if ( + Number.isNaN(parsedVal) || + parsedVal <= 0 || + !Number.isFinite(parsedVal) + ) { + return 'Invalid amount, must be a positive finite number'; + } + + try { + btcToSats(value); + } catch (error) { + return 'Invalid amount, out of bounds'; + } + return true; + }, +); From 3c84b9dedf4dcc7139107e2041a03e7c8cf62af6 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 22 Jul 2024 17:24:20 +0800 Subject: [PATCH 112/362] chore: add reusable error (#185) --- .../bitcoin-wallet-snap/src/exceptions.ts | 19 +++++++++++++++++ .../bitcoin-wallet-snap/src/keyring.test.ts | 21 ++++++++++--------- .../bitcoin-wallet-snap/src/keyring.ts | 15 ++++++++----- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 3 ++- 4 files changed, 42 insertions(+), 16 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/exceptions.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/exceptions.ts new file mode 100644 index 00000000..4d5161f1 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/exceptions.ts @@ -0,0 +1,19 @@ +import { CustomError } from './utils'; + +export class AccountNotFoundError extends CustomError { + constructor(errMsg?: string) { + super(errMsg ?? `Account not found`); + } +} + +export class MethodNotImplementedError extends CustomError { + constructor(errMsg?: string) { + super(errMsg ?? `Method not implemented`); + } +} + +export class FeeRateUnavailableError extends CustomError { + constructor(errMsg?: string) { + super(errMsg ?? `No fee rates available`); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts index 8ef89204..30fa4164 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts @@ -1,11 +1,12 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import { MethodNotFoundError } from '@metamask/snaps-sdk'; +import { MethodNotFoundError, UnauthorizedError } from '@metamask/snaps-sdk'; import { v4 as uuidv4 } from 'uuid'; import { generateAccounts } from '../test/utils'; import { BtcAccount, BtcWallet, ScriptType } from './bitcoin/wallet'; import { Config } from './config'; import { Caip2Asset, Caip2ChainId } from './constants'; +import { AccountNotFoundError, MethodNotImplementedError } from './exceptions'; import { Factory } from './factory'; import { BtcKeyring } from './keyring'; import * as getBalanceRpc from './rpcs/get-balances'; @@ -277,13 +278,13 @@ describe('BtcKeyring', () => { }); describe('updateAccount', () => { - it('throws `Method not implemented` error', async () => { + it('throws `MethodNotImplementedError`', async () => { const { instance: stateMgr } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); const account = generateAccounts(1)[0]; await expect(keyring.updateAccount(account)).rejects.toThrow( - 'Method not implemented.', + MethodNotImplementedError, ); }); }); @@ -354,7 +355,7 @@ describe('BtcKeyring', () => { ); }); - it('throws `Account not found` error if the account address is not match with the unlocked account', async () => { + it('throws `AccountNotFoundError` if the account address is not match with the unlocked account', async () => { const caip2ChainId = Caip2ChainId.Testnet; const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); @@ -376,10 +377,10 @@ describe('BtcKeyring', () => { method: 'btc_sendmany', }, }), - ).rejects.toThrow('Account not found'); + ).rejects.toThrow(AccountNotFoundError); }); - it('throws `Account not found` error if the account id not found from state', async () => { + it('throws `AccountNotFoundError` if the account id not found from state', async () => { const { instance: stateMgr } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); const account = generateAccounts(1)[0]; @@ -393,7 +394,7 @@ describe('BtcKeyring', () => { method: 'btc_sendmany', }, }), - ).rejects.toThrow('Account not found'); + ).rejects.toThrow(AccountNotFoundError); }); it("throws `Account's scope does not match with the request's scope` error if given scope is not match with the account", async () => { @@ -422,7 +423,7 @@ describe('BtcKeyring', () => { ); }); - it('throws MethodNotFoundError if the method not support', async () => { + it('throws `MethodNotFoundError` if the method not support', async () => { const caip2ChainId = Caip2ChainId.Testnet; const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); @@ -449,7 +450,7 @@ describe('BtcKeyring', () => { ).rejects.toThrow(MethodNotFoundError); }); - it('throws `Forbidden method` error if the method is not allowed from the account', async () => { + it('throws `UnauthorizedError` if the method is not allowed from the account', async () => { const caip2ChainId = Caip2ChainId.Testnet; const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); const { instance: keyring } = createMockKeyring(stateMgr); @@ -471,7 +472,7 @@ describe('BtcKeyring', () => { method: 'btc_methodDoesNotExist', }, }), - ).rejects.toThrow('Forbidden method'); + ).rejects.toThrow(UnauthorizedError); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index fd327156..9d84c09d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -8,13 +8,18 @@ import { type Balance, type CaipAssetType, } from '@metamask/keyring-api'; -import { MethodNotFoundError, type Json } from '@metamask/snaps-sdk'; +import { + MethodNotFoundError, + UnauthorizedError, + type Json, +} from '@metamask/snaps-sdk'; import type { Infer } from 'superstruct'; import { assert, object, StructError } from 'superstruct'; import { v4 as uuidv4 } from 'uuid'; import type { BtcAccount, BtcWallet } from './bitcoin/wallet'; import { Config } from './config'; +import { AccountNotFoundError, MethodNotImplementedError } from './exceptions'; import { Factory } from './factory'; import { getBalances, type SendManyParams, sendMany } from './rpcs'; import type { KeyringStateManager, Wallet } from './stateManagement'; @@ -121,7 +126,7 @@ export class BtcKeyring implements Keyring { } async updateAccount(_account: KeyringAccount): Promise { - throw new Error('Method not implemented.'); + throw new MethodNotImplementedError(); } async deleteAccount(id: string): Promise { @@ -214,7 +219,7 @@ export class BtcKeyring implements Keyring { const walletData = await this._stateMgr.getWallet(id); if (!walletData) { - throw new Error('Account not found'); + throw new AccountNotFoundError(); } return walletData; @@ -237,7 +242,7 @@ export class BtcKeyring implements Keyring { keyringAccount: KeyringAccount, ): void { if (!account || account.address !== keyringAccount.address) { - throw new Error('Account not found'); + throw new AccountNotFoundError(); } } @@ -246,7 +251,7 @@ export class BtcKeyring implements Keyring { keyringAccount: KeyringAccount, ): void { if (!keyringAccount.methods.includes(method)) { - throw new Error('Forbidden method'); + throw new UnauthorizedError(`Permission denied`) as unknown as Error; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index e3ca4f6a..fa090ad1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -26,6 +26,7 @@ import { type ITxInfo, TxValidationError, } from '../bitcoin/wallet'; +import { FeeRateUnavailableError } from '../exceptions'; import { Factory } from '../factory'; import { ScopeStruct, @@ -98,7 +99,7 @@ export async function sendMany( const feesResp = await chainApi.getFeeRates(); if (feesResp.fees.length === 0) { - throw new Error('No fee rates available'); + throw new FeeRateUnavailableError(); } const fee = Math.max( From 6efea4e6b381a5811b32aa2941989cd0949e6ea1 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 22 Jul 2024 17:39:11 +0800 Subject: [PATCH 113/362] chore: fix overrided message in MethodNotFoundError (#189) --- merged-packages/bitcoin-wallet-snap/src/index.ts | 2 +- merged-packages/bitcoin-wallet-snap/src/keyring.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index f07c6c7e..e0666efc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -44,7 +44,7 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request.params as GetTransactionStatusParams, ); default: - throw new MethodNotFoundError(method) as unknown as Error; + throw new MethodNotFoundError() as unknown as Error; } } catch (error) { let snapError = error; diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index 9d84c09d..ea723212 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -178,7 +178,7 @@ export class BtcKeyring implements Keyring { scope: walletData.scope, } as unknown as SendManyParams)) as unknown as Json; default: - throw new MethodNotFoundError(method) as unknown as Error; + throw new MethodNotFoundError() as unknown as Error; } } From e0295e11951e9ab9cbc4f9b527fe2169e8e926b6 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Tue, 23 Jul 2024 11:25:19 +0200 Subject: [PATCH 114/362] chore: update internal dependencies (#197) --- 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 19982aed..205c2195 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -43,7 +43,7 @@ "@metamask/keyring-api": "^8.0.1", "@metamask/snaps-cli": "^6.2.1", "@metamask/snaps-jest": "^8.2.0", - "@metamask/snaps-sdk": "^6.0.0", + "@metamask/snaps-sdk": "^6.1.0", "@metamask/utils": "^9.1.0", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", From a66e0be3265c450703b0f69e3f4c6d546840af31 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jul 2024 16:50:35 +0200 Subject: [PATCH 115/362] 0.2.5 (#198) * 0.2.5 * chore: update changelog * chore: update Snap manifest * chore: update changelog * chore: update `packages/snap/CHANGELOG.md` Co-authored-by: Charly Chevalier * chore: update `packages/snap/CHANGELOG.md` Co-authored-by: Charly Chevalier --------- Co-authored-by: github-actions Co-authored-by: Daniel Rocha <68558152+danroc@users.noreply.github.com> Co-authored-by: Daniel Rocha Co-authored-by: Charly Chevalier --- .../bitcoin-wallet-snap/CHANGELOG.md | 17 ++++++++++++++++- .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 05003923..c78c5c52 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.5] + +### Added + +- Add reusable error types ([#185](https://github.com/MetaMask/snap-bitcoin-wallet/pull/185)) + +### Changed + +- Use custom `superstruct` validator for `AmountStruct` ([#184](https://github.com/MetaMask/snap-bitcoin-wallet/pull/184)) + +### Fixed + +- Fix overridden message in `MethodNotFoundError` ([#189](https://github.com/MetaMask/snap-bitcoin-wallet/pull/189)) + ## [0.2.4] ### Fixed @@ -123,7 +137,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.4...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.5...HEAD +[0.2.5]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.4...v0.2.5 [0.2.4]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.3...v0.2.4 [0.2.3]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.2...v0.2.3 [0.2.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.1...v0.2.2 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 205c2195..087dceeb 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.2.4", + "version": "0.2.5", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index d78b6eee..7791456b 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.2.4", + "version": "0.2.5", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin Manager", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "+MrYK3TpHg1KvFspGMGBzro78Hz6bz/4Iulnh8x4cE0=", + "shasum": "PO/duEhS4Gru/Nghz8qHdQiMnwzZk1Xi14Taiz7Rh9c=", "location": { "npm": { "filePath": "dist/bundle.js", From f828f9781a759c269a4063637301766649f587f3 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 25 Jul 2024 17:04:47 +0800 Subject: [PATCH 116/362] refactor: consolidate get fee rate logic into a util function (#206) * chore: refactor get fee rate * chore: update lint style * chore: update code import * chore: update FeeRatio to FeeRate --- .../bitcoin/chain/clients/blockchair.test.ts | 4 +-- .../src/bitcoin/chain/clients/blockchair.ts | 5 ++-- .../src/bitcoin/chain/constants.ts | 2 +- .../src/bitcoin/chain/data-client.ts | 4 +-- .../src/bitcoin/chain/service.test.ts | 10 +++---- .../src/bitcoin/chain/service.ts | 11 +++---- .../bitcoin-wallet-snap/src/config.ts | 3 ++ .../src/rpcs/sendmany.test.ts | 4 +-- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 11 ++----- .../bitcoin-wallet-snap/src/utils/fee.ts | 30 +++++++++++++++++++ .../bitcoin-wallet-snap/src/utils/index.ts | 1 + 11 files changed, 55 insertions(+), 30 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/fee.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts index c459dd7b..a8dde076 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts @@ -10,7 +10,7 @@ import { import { Config } from '../../../config'; import type { BtcAccount } from '../../wallet'; import { BtcAccountDeriver, BtcWallet } from '../../wallet'; -import { FeeRatio, TransactionStatus } from '../constants'; +import { TransactionStatus } from '../constants'; import { DataClientError } from '../exceptions'; import { BlockChairClient } from './blockchair'; @@ -221,7 +221,7 @@ describe('BlockChairClient', () => { const result = await instance.getFeeRates(); expect(result).toStrictEqual({ - [FeeRatio.Fast]: + [Config.defaultFeeRate]: mockResponse.data.suggested_transaction_fee_per_byte_sat, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts index 15700832..461afe2b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts @@ -3,8 +3,9 @@ import type { Json } from '@metamask/snaps-sdk'; import { type Network, networks } from 'bitcoinjs-lib'; import { array, assert } from 'superstruct'; +import { Config } from '../../../config'; import { compactError, logger, TxIdStruct } from '../../../utils'; -import { FeeRatio, TransactionStatus } from '../constants'; +import { TransactionStatus } from '../constants'; import type { IDataClient, DataClientGetBalancesResp, @@ -384,7 +385,7 @@ export class BlockChairClient implements IDataClient { `[BlockChairClient.getFeeRates] response: ${JSON.stringify(response)}`, ); return { - [FeeRatio.Fast]: response.data.suggested_transaction_fee_per_byte_sat, + [Config.defaultFeeRate]: response.data.suggested_transaction_fee_per_byte_sat, }; } catch (error) { logger.info(`[BlockChairClient.getFeeRates] error: ${error.message}`); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts index 3a0720f6..b07e9f71 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts @@ -1,4 +1,4 @@ -export enum FeeRatio { +export enum FeeRate { Fast = 'fast', Medium = 'medium', Slow = 'slow', diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts index 3e38e9cb..eeb2982e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts @@ -1,4 +1,4 @@ -import type { FeeRatio } from './constants'; +import type { FeeRate } from './constants'; import type { TransactionStatusData, Utxo } from './service'; export type DataClientGetBalancesResp = Record; @@ -6,7 +6,7 @@ export type DataClientGetBalancesResp = Record; export type DataClientGetUtxosResp = Utxo[]; export type DataClientGetFeeRatesResp = { - [key in FeeRatio]?: number; + [key in FeeRate]?: number; }; export type DataClientGetTxStatusResp = TransactionStatusData; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts index dd6a306b..964385e8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts @@ -7,7 +7,7 @@ import { generateBlockChairGetUtxosResp, } from '../../../test/utils'; import { Caip2Asset } from '../../constants'; -import { FeeRatio, TransactionStatus } from './constants'; +import { FeeRate, TransactionStatus } from './constants'; import type { IDataClient } from './data-client'; import { BtcOnChainServiceError } from './exceptions'; import { BtcOnChainService } from './service'; @@ -168,8 +168,8 @@ describe('BtcOnChainService', () => { const { instance, getFeeRatesSpy } = createMockDataClient(); const { instance: txMgr } = createMockBtcService(instance); getFeeRatesSpy.mockResolvedValue({ - [FeeRatio.Fast]: 1, - [FeeRatio.Medium]: 2, + [FeeRate.Fast]: 1, + [FeeRate.Medium]: 2, }); const result = await txMgr.getFeeRates(); @@ -178,11 +178,11 @@ describe('BtcOnChainService', () => { expect(result).toStrictEqual({ fees: [ { - type: FeeRatio.Fast, + type: FeeRate.Fast, rate: BigInt(1), }, { - type: FeeRatio.Medium, + type: FeeRate.Medium, rate: BigInt(2), }, ], diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index 5650ee4a..9f4544f4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -3,7 +3,7 @@ import { networks } from 'bitcoinjs-lib'; import { Caip2Asset } from '../../constants'; import { compactError } from '../../utils'; -import type { FeeRatio, TransactionStatus } from './constants'; +import type { FeeRate, TransactionStatus } from './constants'; import type { IDataClient } from './data-client'; import { BtcOnChainServiceError } from './exceptions'; @@ -24,15 +24,12 @@ export type AssetBalances = { }; export type Fee = { - type: FeeRatio; + type: FeeRate; rate: bigint; }; export type Fees = { - fees: { - type: FeeRatio; - rate: bigint; - }[]; + fees: Fee[]; }; export type Utxo = { @@ -125,7 +122,7 @@ export class BtcOnChainService { return { fees: Object.entries(result).map( - ([key, value]: [key: FeeRatio, value: number]) => ({ + ([key, value]: [key: FeeRate, value: number]) => ({ type: key, rate: BigInt(value), }), diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 1a66ac63..a338066e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -1,5 +1,6 @@ import type { Json } from '@metamask/snaps-sdk'; +import { FeeRate } from './bitcoin/chain/constants'; import { Caip2ChainId, Caip2Asset } from './constants'; export type SnapConfig = { @@ -14,6 +15,7 @@ export type SnapConfig = { }; availableNetworks: string[]; availableAssets: string[]; + defaultFeeRate: FeeRate; unit: string; explorer: { [network in string]: string; @@ -36,6 +38,7 @@ export const Config: SnapConfig = { }, availableNetworks: Object.values(Caip2ChainId), availableAssets: Object.values(Caip2Asset), + defaultFeeRate: FeeRate.Medium, unit: 'BTC', explorer: { // eslint-disable-next-line no-template-curly-in-string diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 7e458369..326558fd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -9,7 +9,7 @@ import { generateBlockChairBroadcastTransactionResp, generateBlockChairGetUtxosResp, } from '../../test/utils'; -import { BtcOnChainService, FeeRatio } from '../bitcoin/chain'; +import { BtcOnChainService } from '../bitcoin/chain'; import type { BtcAccount } from '../bitcoin/wallet'; import { BtcAccountDeriver, @@ -152,7 +152,7 @@ describe('SendManyHandler', () => { getFeeRatesSpy.mockResolvedValue({ fees: [ { - type: FeeRatio.Fast, + type: Config.defaultFeeRate, rate: BigInt(1), }, ], diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index fa090ad1..c70a7fc8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -26,7 +26,6 @@ import { type ITxInfo, TxValidationError, } from '../bitcoin/wallet'; -import { FeeRateUnavailableError } from '../exceptions'; import { Factory } from '../factory'; import { ScopeStruct, @@ -40,6 +39,7 @@ import { validateResponse, logger, AmountStruct, + getFeeRate, } from '../utils'; export const TransactionAmountStruct = refine( @@ -98,14 +98,7 @@ export async function sendMany( const feesResp = await chainApi.getFeeRates(); - if (feesResp.fees.length === 0) { - throw new FeeRateUnavailableError(); - } - - const fee = Math.max( - Number(feesResp.fees[feesResp.fees.length - 1].rate), - 1, - ); + const fee = getFeeRate(feesResp.fees); const recipients = Object.entries(params.amounts).map( ([address, value]) => ({ diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/fee.ts b/merged-packages/bitcoin-wallet-snap/src/utils/fee.ts new file mode 100644 index 00000000..a28733e7 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/fee.ts @@ -0,0 +1,30 @@ +import { assert, enums } from 'superstruct'; + +import type { Fee } from '../bitcoin/chain'; +import { FeeRate } from '../bitcoin/chain/constants'; +import { Config } from '../config'; +import { FeeRateUnavailableError } from '../exceptions'; + +/** + * Retrieves the fee rate from an array of Fee objects based on the fee type provided. + * If no fee type is provided, the default fee rate specified in the configuration will be used. + * + * @param fees - The array of Fee objects. + * @param feeType - The fee type to retrieve the fee rate for. Default is `Config.defaultFeeRate` + * @returns The fee rate for the given fee type or default fee rate. + * @throws {FeeRateUnavailableError} If no fee object is found for the provided fee type. + */ +export function getFeeRate( + fees: Fee[], + feeType: FeeRate = Config.defaultFeeRate, +) { + assert(feeType, enums(Object.values(FeeRate))); + + const selectedFees = fees.find((fee) => fee.type === feeType); + + if (!selectedFees) { + throw new FeeRateUnavailableError(); + } + // Fee rate cannot be lower than 1 + return Math.max(Number(selectedFees.rate), 1); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/utils/index.ts index 3aa58ed8..de0d0a04 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/index.ts @@ -9,3 +9,4 @@ export * from './rpc'; export * from './explorer'; export * from './unit'; export * from './logger'; +export * from './fee'; From 271c1d36e2163c1302fc55b66bdc0015d25a4cb2 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 25 Jul 2024 17:34:29 +0800 Subject: [PATCH 117/362] refactor: consolidate account address verify logic (#207) * chore: consolidate account address verify logic * chore: update snap function doc * Update account.ts --- .../bitcoin-wallet-snap/src/keyring.ts | 15 +--- .../src/utils/account.test.ts | 87 +++++++++++++++++++ .../bitcoin-wallet-snap/src/utils/account.ts | 26 ++++++ 3 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/account.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/account.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index ea723212..8547096a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -24,6 +24,7 @@ import { Factory } from './factory'; import { getBalances, type SendManyParams, sendMany } from './rpcs'; import type { KeyringStateManager, Wallet } from './stateManagement'; import { getProvider, ScopeStruct, logger } from './utils'; +import { verifyIfAccountValid } from './utils/account'; export type KeyringOptions = Record & { defaultIndex: number; @@ -168,7 +169,8 @@ export class BtcKeyring implements Keyring { walletData.account.type, ); - this.verifyIfAccountValid(account, walletData.account); + verifyIfAccountValid(account, walletData.account); + this.verifyIfMethodValid(method, walletData.account); switch (method) { @@ -202,7 +204,7 @@ export class BtcKeyring implements Keyring { walletData.account.type, ); - this.verifyIfAccountValid(account, walletData.account); + verifyIfAccountValid(account, walletData.account); return await getBalances(account, { assets, @@ -237,15 +239,6 @@ export class BtcKeyring implements Keyring { return await wallet.unlock(index, type); } - protected verifyIfAccountValid( - account: BtcAccount, - keyringAccount: KeyringAccount, - ): void { - if (!account || account.address !== keyringAccount.address) { - throw new AccountNotFoundError(); - } - } - protected verifyIfMethodValid( method: string, keyringAccount: KeyringAccount, diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/account.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/account.test.ts new file mode 100644 index 00000000..e0065f73 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/account.test.ts @@ -0,0 +1,87 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import type { Network } from 'bitcoinjs-lib'; +import { networks } from 'bitcoinjs-lib'; +import { v4 as uuidV4 } from 'uuid'; + +import type { BtcAccount } from '../bitcoin/wallet'; +import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; +import { Config } from '../config'; +import { Caip2ChainId } from '../constants'; +import { AccountNotFoundError } from '../exceptions'; +import { verifyIfAccountValid } from './account'; + +jest.mock('./snap'); + +describe('verifyIfAccountValid', function () { + const createMockDeriver = (network) => { + return { + instance: new BtcAccountDeriver(network), + }; + }; + + const createBtcAccount = async (network: Network, index = 0) => { + const { instance } = createMockDeriver(network); + const wallet = new BtcWallet(instance, network); + return await wallet.unlock(index, Config.wallet.defaultAccountType); + }; + + const createKeyringAccount = ( + address: string, + caip2ChainId: string, + index: number, + ) => { + return { + type: Config.wallet.defaultAccountType, + id: uuidV4(), + address, + options: { + scope: caip2ChainId, + index, + }, + methods: ['btc_sendmany'], + } as unknown as KeyringAccount; + }; + + it('does not throw error if BtcAccount and KeyringAccount are valid and consistent', async function () { + const btcAccount = await createBtcAccount(networks.testnet); + const keyringAccount = createKeyringAccount( + btcAccount.address, + Caip2ChainId.Testnet, + btcAccount.index, + ); + + expect(() => + verifyIfAccountValid(btcAccount, keyringAccount), + ).not.toThrow(); + }); + + it('throws AccountNotFoundError if either the BtcAccount object or the KeyringAccount object is not provided', async function () { + const btcAccount = await createBtcAccount(networks.testnet); + const keyringAccount = createKeyringAccount( + btcAccount.address, + Caip2ChainId.Testnet, + btcAccount.index, + ); + + expect(() => + verifyIfAccountValid(btcAccount, null as unknown as KeyringAccount), + ).toThrow(AccountNotFoundError); + expect(() => + verifyIfAccountValid(null as unknown as BtcAccount, keyringAccount), + ).toThrow(AccountNotFoundError); + }); + + it("throws `Inconsistent account found` error if the BtcAccount's address is not matching the KeyringAccount's address", async function () { + const btcAccount = await createBtcAccount(networks.testnet, 0); + const inconsistentBtcAccount = await createBtcAccount(networks.testnet, 1); + const keyringAccount = createKeyringAccount( + inconsistentBtcAccount.address, + Caip2ChainId.Testnet, + inconsistentBtcAccount.index, + ); + + expect(() => verifyIfAccountValid(btcAccount, keyringAccount)).toThrow( + new AccountNotFoundError('Inconsistent account found'), + ); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/account.ts b/merged-packages/bitcoin-wallet-snap/src/utils/account.ts new file mode 100644 index 00000000..e87ae081 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/account.ts @@ -0,0 +1,26 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; + +import type { BtcAccount } from '../bitcoin/wallet'; +import { AccountNotFoundError } from '../exceptions'; + +/** + * Verifies if the provided BtcAccount object and KeyringAccount object are valid and that their addresses are consistent. + * + * @param account - The BtcAccount object to verify. + * @param keyringAccount - The KeyringAccount object to verify. + * @throws {AccountNotFoundError} If either the BtcAccount object or the KeyringAccount object is not provided. + * @throws {AccountNotFoundError} If the BtcAccount's address and the KeyringAccount's address are not matching. + */ +export function verifyIfAccountValid( + account: BtcAccount, + keyringAccount: KeyringAccount, +): void { + if (!account || !keyringAccount) { + throw new AccountNotFoundError(); + } + // Make sure the BtcAccount's address is consistent with the state data, if not, then either the state has been corrupted or + // the derivation scheme changed (which should not happen without an explicit migration of the state) + if (account.address !== keyringAccount.address) { + throw new AccountNotFoundError('Inconsistent account found'); + } +} From c0b14d119148d3e13939fc13d73efbffc4cc5b6d Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 26 Jul 2024 18:48:51 +0800 Subject: [PATCH 118/362] feat: add `estimateFee` RPC endpoint (#169) * feat: add fee api * chore: do not throw error if insufficient fund for estimate fee * chore: fix lint style * chore: update test case * chore: move tx amount struct to resuable * chore: add reusable error * chore: remove duplicate method after rebase * chore: update estimate fee struct * chore: update error msg * chore: add unit test for estimate fee * chore: fix lint style * chore: add estimate fee unit test in wallet * chore: update superstruct test case * chore: update estimate fee api name * fix: estimate fee UI * chore: increase test coverage * chore: increase test coverage * chore: update lint style * chore: update merge main * chore: update wording * chore: add getAndCheckFeeRate * chore: update getAndCheckFeeRate * chore: update getAndCheckFeeRate to getFeeRate * chore: update estimate fee doc * chore: use getFeeRate utils in estimateFee rpc * chore: update Fee Estimate API to use FeeRate and verifyIfAccountValid * chore: update jsdoc * chore: re wording * chore: add waring message if estimate fee is inaccurate * chore: add comment --- .../src/bitcoin/wallet/coin-select.test.ts | 20 -- .../src/bitcoin/wallet/coin-select.ts | 32 +- .../src/bitcoin/wallet/wallet.test.ts | 118 +++++++- .../src/bitcoin/wallet/wallet.ts | 134 ++++++--- .../bitcoin-wallet-snap/src/index.test.ts | 47 ++- .../bitcoin-wallet-snap/src/index.ts | 6 +- .../bitcoin-wallet-snap/src/keyring.ts | 8 +- .../bitcoin-wallet-snap/src/permissions.ts | 3 + .../src/rpcs/estimate-fee.test.ts | 280 ++++++++++++++++++ .../src/rpcs/estimate-fee.ts | 127 ++++++++ .../bitcoin-wallet-snap/src/rpcs/index.ts | 1 + .../src/rpcs/sendmany.test.ts | 2 +- .../bitcoin-wallet-snap/src/utils/index.ts | 1 + 13 files changed, 684 insertions(+), 95 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts index 2f6e2fc0..e19bd9d1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts @@ -69,25 +69,5 @@ describe('CoinSelectService', () => { expect(result.inputs.length).toBeGreaterThan(0); expect(result.outputs.length).toBeGreaterThan(0); }); - - it('throws `Insufficient funds` error if the given utxos is not sufficient', async () => { - const network = networks.testnet; - const { inputs, outputs, sender } = await prepareCoinSlect( - network, - 100000, - 1, - 10, - ); - - const coinSelectService = new CoinSelectService(100); - - expect(() => - coinSelectService.selectCoins( - inputs, - outputs, - new TxOutput(0, sender.address, sender.script), - ), - ).toThrow('Insufficient funds'); - }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index 44d166f1..fa5000d0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -1,6 +1,5 @@ import coinSelect from 'coinselect'; -import { TxValidationError } from './exceptions'; import type { TxInput } from './transaction-input'; import type { TxOutput } from './transaction-output'; @@ -25,7 +24,6 @@ export class CoinSelectService { * @param outputs - An array of output objects. * @param changeTo - The change output object. * @returns A SelectionResult object that includes the calculated transaction fee, selected inputs, outputs, and change (if any). - * @throws {TxValidationError} Throws a TxValidationError if there are insufficient funds to complete the transaction. */ selectCoins( inputs: TxInput[], @@ -34,28 +32,28 @@ export class CoinSelectService { ): SelectionResult { const result = coinSelect(inputs, outputs, this._feeRate); - if (!result.inputs || !result.outputs) { - throw new TxValidationError('Insufficient funds'); - } - const selectedResult: SelectionResult = { fee: result.fee, inputs: result.inputs, outputs: [], }; - // restructure outputs to avoid depends on coinselect output format - for (const output of result.outputs) { - if (output.address) { - selectedResult.outputs.push(output); - } else { - // We only support 1 change output, so we do check if there are more than 1 - // and raise an error to avoid overwriting it - if (selectedResult.change !== undefined) { - throw new Error('Unexpected error: found more than 1 change output'); + if (result.outputs) { + // Re-structure outputs to avoid depending on `coinSelect` output structure + for (const output of result.outputs) { + if (output.address) { + selectedResult.outputs.push(output); + } else { + // We only support 1 change output, so we do check if there are more than 1 + // and raise an error to avoid overwriting it + if (selectedResult.change !== undefined) { + throw new Error( + 'Unexpected error: found more than 1 change output', + ); + } + changeTo.value = output.value; + selectedResult.change = changeTo; } - changeTo.value = output.value; - selectedResult.change = changeTo; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index 897fca0b..812b4acc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -1,4 +1,5 @@ import { networks } from 'bitcoinjs-lib'; +import { Buffer } from 'buffer'; import { generateFormattedUtxos } from '../../../test/utils'; import { P2WPKHAccount, P2WPKHTestnetAccount } from './account'; @@ -6,6 +7,7 @@ import { CoinSelectService } from './coin-select'; import { DustLimit, ScriptType } from './constants'; import { BtcAccountDeriver } from './deriver'; import { WalletError } from './exceptions'; +import { PsbtService } from './psbt'; import { TxInfo } from './transaction-info'; import { TxInput } from './transaction-input'; import { TxOutput } from './transaction-output'; @@ -40,7 +42,7 @@ describe('BtcWallet', () => { }; }; - const createMockTxIndent = (address: string, amount: number) => { + const createMockTxIntent = (address: string, amount: number) => { return [ { address, @@ -158,7 +160,7 @@ describe('BtcWallet', () => { const result = await wallet.createTransaction( account, - createMockTxIndent(account.address, 100000), + createMockTxIntent(account.address, 100000), { utxos, fee: 56, @@ -188,7 +190,7 @@ describe('BtcWallet', () => { const result = await wallet.createTransaction( account, - createMockTxIndent(account.address, 100000), + createMockTxIntent(account.address, 100000), { utxos, fee: 50, @@ -222,7 +224,7 @@ describe('BtcWallet', () => { await wallet.createTransaction( account, - createMockTxIndent(account.address, DustLimit[account.scriptType] + 1), + createMockTxIntent(account.address, DustLimit[account.scriptType] + 1), { utxos, fee: 1, @@ -246,6 +248,34 @@ describe('BtcWallet', () => { } }); + it('uses `replaceable = false` if not provided', async () => { + const network = networks.testnet; + const { instance } = createMockDeriver(network); + const wallet = new BtcWallet(instance, network); + const chgAccount = await wallet.unlock(0, ScriptType.P2wpkh); + const recipient = await wallet.unlock(1, ScriptType.P2wpkh); + const utxos = generateFormattedUtxos(chgAccount.address, 1, 10000, 10000); + const psbtSpy = jest.spyOn(PsbtService.prototype, 'addInputs'); + + await wallet.createTransaction( + chgAccount, + createMockTxIntent(recipient.address, 500), + { + utxos, + fee: 1, + subtractFeeFrom: [], + }, + ); + + expect(psbtSpy).toHaveBeenCalledWith( + expect.any(Array), + false, + expect.any(String), + expect.any(Buffer), + expect.any(Buffer), + ); + }); + it('remove dist change', async () => { const network = networks.testnet; const { instance } = createMockDeriver(network); @@ -273,7 +303,7 @@ describe('BtcWallet', () => { const result = await wallet.createTransaction( chgAccount, - createMockTxIndent(recipient.address, 500), + createMockTxIntent(recipient.address, 500), { utxos, fee: 1, @@ -298,7 +328,7 @@ describe('BtcWallet', () => { await expect( wallet.createTransaction( account, - createMockTxIndent(account.address, 1), + createMockTxIntent(account.address, 1), { utxos, fee: 20, @@ -308,6 +338,28 @@ describe('BtcWallet', () => { ), ).rejects.toThrow('Transaction amount too small'); }); + + it('throws `Insufficient funds` error if the given utxos total amount is insufficient', async () => { + const network = networks.testnet; + const { instance } = createMockDeriver(network); + const wallet = new BtcWallet(instance, network); + const chgAccount = await wallet.unlock(0, ScriptType.P2wpkh); + const recipient = await wallet.unlock(1, ScriptType.P2wpkh); + const utxos = generateFormattedUtxos(chgAccount.address, 2, 1000, 1000); + + await expect( + wallet.createTransaction( + chgAccount, + createMockTxIntent(recipient.address, 50000), + { + utxos, + fee: 20, + subtractFeeFrom: [], + replaceable: false, + }, + ), + ).rejects.toThrow('Insufficient funds'); + }); }); describe('signTransaction', () => { @@ -320,7 +372,7 @@ describe('BtcWallet', () => { const { tx } = await wallet.createTransaction( account, - createMockTxIndent(account.address, DustLimit[account.scriptType] + 1), + createMockTxIntent(account.address, DustLimit[account.scriptType] + 1), { utxos, fee: 1, @@ -335,4 +387,56 @@ describe('BtcWallet', () => { expect(sign).not.toBe(''); }); }); + + describe('estimateFee', () => { + it('estimates fee', async () => { + const network = networks.testnet; + const { instance } = createMockDeriver(network); + const wallet = new BtcWallet(instance, network); + const chgAccount = await wallet.unlock(0, ScriptType.P2wpkh); + const utxos = generateFormattedUtxos( + chgAccount.address, + 10, + 10000, + 10000, + ); + const coinSelectServiceSpy = jest.spyOn( + CoinSelectService.prototype, + 'selectCoins', + ); + + const selectionResult = { + change: new TxOutput( + DustLimit[chgAccount.scriptType] - 1, + chgAccount.address, + chgAccount.script, + ), + fee: 100, + inputs: utxos.map((utxo) => new TxInput(utxo, chgAccount.script)), + outputs: [new TxOutput(500, chgAccount.address, chgAccount.script)], + }; + + coinSelectServiceSpy.mockReturnValue(selectionResult); + + const result = await wallet.estimateFee( + chgAccount, + createMockTxIntent( + chgAccount.address, + DustLimit[chgAccount.scriptType] + 1, + ), + { + utxos, + fee: 1, + }, + ); + + expect(coinSelectServiceSpy).toHaveBeenCalledWith( + expect.any(Array), + expect.any(Array), + expect.any(TxOutput), + ); + + expect(result).toStrictEqual(selectionResult); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 4adfcbca..acca7085 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -1,5 +1,6 @@ import type { BIP32Interface } from 'bip32'; import { networks, type Network } from 'bitcoinjs-lib'; +import { type Buffer } from 'buffer'; import { bufferToString, compactError, hexToBuffer, logger } from '../../utils'; import type { Utxo } from '../chain'; @@ -9,6 +10,7 @@ import { P2WPKHTestnetAccount, type IStaticBtcAccount, } from './account'; +import type { SelectionResult } from './coin-select'; import { CoinSelectService } from './coin-select'; import { ScriptType } from './constants'; import type { BtcAccountDeriver } from './deriver'; @@ -42,11 +44,11 @@ export type ITxInfo = { export type CreateTransactionOptions = { utxos: Utxo[]; fee: number; - subtractFeeFrom: string[]; + subtractFeeFrom?: string[]; // // BIP125 opt-in RBF flag, // - replaceable: boolean; + replaceable?: boolean; }; export class BtcWallet { @@ -95,53 +97,49 @@ export class BtcWallet { * @param recipients - The transaction recipients. * @param options - The options to use when creating the transaction. * @returns A promise that resolves to an object containing the transaction hash and transaction info. + * @throws {TxValidationError} Throws a TxValidationError if there are insufficient funds to complete the transaction. */ async createTransaction( account: BtcAccount, recipients: Recipient[], options: CreateTransactionOptions, ): Promise { - const scriptOutput = account.script; - const { scriptType } = account; + const { + scriptType, + script: scriptOutput, + address, + hdPath, + pubkey, + mfp, + signer, + } = account; - // TODO: Supporting getting coins from other address (dynamic address) - const inputs = options.utxos.map((utxo) => new TxInput(utxo, scriptOutput)); - const outputs = recipients.map((recipient) => { - if (isDust(recipient.value, scriptType)) { - throw new TxValidationError('Transaction amount too small'); - } - const destinationScriptOutput = getScriptForDestination( - recipient.address, - this._network, - ); - return new TxOutput( - recipient.value, - recipient.address, - destinationScriptOutput, - ); - }); + const inputs = this.createTxInput(options.utxos, scriptOutput); + const outputs = this.createTxOutput(recipients, scriptType); + // TODO: We could have the minimum fee rate coming from the parameter and use it here: + const feeRate = this.getFeeRate(options.fee); - // Do not ever accept zero fee rate, we need to ensure it is at least 1 - // TODO: The min fee rate should be setting from parameter - const feeRate = Math.max(1, options.fee); - const coinSelectService = new CoinSelectService(feeRate); - const change = new TxOutput(0, account.address, scriptOutput); - const selectionResult = coinSelectService.selectCoins( + const selectionResult = this.selectCoins( inputs, outputs, - change, + new TxOutput(0, address, scriptOutput), + feeRate, ); + if (!selectionResult.inputs || !selectionResult.outputs) { + throw new TxValidationError('Insufficient funds'); + } + const psbtService = new PsbtService(this._network); psbtService.addInputs( selectionResult.inputs, - options.replaceable, - account.hdPath, - hexToBuffer(account.pubkey, false), - hexToBuffer(account.mfp, false), + options.replaceable ?? false, + hdPath, + hexToBuffer(pubkey, false), + hexToBuffer(mfp, false), ); - const txInfo = new TxInfo(account.address, feeRate); + const txInfo = new TxInfo(address, feeRate); // TODO: add support of subtractFeeFrom, and throw error if output is too small after subtraction for (const output of selectionResult.outputs) { @@ -150,7 +148,7 @@ export class BtcWallet { } if (selectionResult.change) { - if (isDust(change.value, scriptType)) { + if (isDust(selectionResult.change.value, scriptType)) { logger.warn( '[BtcWallet.createTransaction] Change is too small, adding to fees', ); @@ -161,7 +159,7 @@ export class BtcWallet { } // Sign dummy transaction to extract the fee which is more accurate - const signedService = await psbtService.signDummy(account.signer); + const signedService = await psbtService.signDummy(signer); txInfo.txFee = signedService.getFee(); return { @@ -170,6 +168,34 @@ export class BtcWallet { }; } + /** + * Creates a transaction using the given account, transaction intent, and options. + * + * @param account - The `IAccount` object to create the transaction. + * @param recipients - The transaction recipients. + * @param options - The options to use when creating the transaction. + * @returns A promise that resolves to an object containing the transaction hash and transaction info. + */ + async estimateFee( + account: BtcAccount, + recipients: Recipient[], + options: CreateTransactionOptions, + ): Promise { + const { scriptType, script: scriptOutput } = account; + + const inputs = this.createTxInput(options.utxos, scriptOutput); + const outputs = this.createTxOutput(recipients, scriptType); + // TODO: We could have the minimum fee rate coming from the parameter and use it here: + const feeRate = this.getFeeRate(options.fee); + + return this.selectCoins( + inputs, + outputs, + new TxOutput(0, account.address, scriptOutput), + feeRate, + ); + } + /** * Signs a transaction by the given encoded transaction string. * @@ -201,6 +227,40 @@ export class BtcWallet { } } + protected createTxInput(utxos: Utxo[], scriptOutput: Buffer): TxInput[] { + return utxos.map((utxo) => new TxInput(utxo, scriptOutput)); + } + + protected createTxOutput( + recipients: Recipient[], + scriptType: ScriptType, + ): TxOutput[] { + return recipients.map((recipient) => { + if (isDust(recipient.value, scriptType)) { + throw new TxValidationError('Transaction amount too small'); + } + const destinationScriptOutput = getScriptForDestination( + recipient.address, + this._network, + ); + return new TxOutput( + recipient.value, + recipient.address, + destinationScriptOutput, + ); + }); + } + + protected selectCoins( + inputs: TxInput[], + outputs: TxOutput[], + change: TxOutput, + feeRate: number, + ): SelectionResult { + const coinSelectService = new CoinSelectService(feeRate); + return coinSelectService.selectCoins(inputs, outputs, change); + } + protected getP2WPKHAccountCtorByNetwork(): IStaticBtcAccount { switch (this._network) { case networks.bitcoin: @@ -211,4 +271,10 @@ export class BtcWallet { throw new WalletError('Invalid network'); } } + + protected getFeeRate(feeRate: number) { + // READ THIS CAREFULLY: + // Do not ever accept a 0 fee rate, we need to ensure it is at least 1 + return Math.max(feeRate, 1); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts index bae60564..94318e0d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.ts @@ -7,10 +7,10 @@ import { import { onRpcRequest, validateOrigin, onKeyringRequest } from '.'; import * as entry from '.'; -import { TransactionStatus } from './bitcoin/chain'; import { Config } from './config'; import { BtcKeyring } from './keyring'; import { InternalRpcMethod, originPermissions } from './permissions'; +import * as estimateFeeRpc from './rpcs/estimate-fee'; import * as getTxStatusRpc from './rpcs/get-transaction-status'; jest.mock('./utils/logger'); @@ -50,7 +50,9 @@ describe('validateOrigin', () => { }); describe('onRpcRequest', () => { - const executeRequest = async (method = 'chain_getTransactionStatus') => { + const executeRequest = async ( + method = InternalRpcMethod.GetTransactionStatus, + ) => { jest.spyOn(entry, 'validateOrigin').mockReturnThis(); return onRpcRequest({ @@ -64,20 +66,39 @@ describe('onRpcRequest', () => { }); }; - it('executes the rpc request', async () => { - jest.spyOn(getTxStatusRpc, 'getTransactionStatus').mockResolvedValue({ - status: TransactionStatus.Confirmed, - } as unknown as getTxStatusRpc.GetTransactionStatusResponse); + it.each([ + { + rpcMethod: InternalRpcMethod.EstimateFee, + method: 'estimateFee', + mockRpcModule: estimateFeeRpc, + }, + { + rpcMethod: InternalRpcMethod.GetTransactionStatus, + method: 'getTransactionStatus', + mockRpcModule: getTxStatusRpc, + }, + ])( + 'executes the rpc request with method: $rpcKey', + async (testData: { + rpcMethod: InternalRpcMethod; + method: string; + mockRpcModule: any; + }) => { + const spy = jest.spyOn(testData.mockRpcModule, testData.method); - const resposne = await executeRequest(); + spy.mockReturnThis(); - expect(resposne).toStrictEqual({ status: TransactionStatus.Confirmed }); - }); + await executeRequest(testData.rpcMethod); + + expect(spy).toHaveBeenCalled(); + }, + ); it('throws MethodNotFoundError if an method not found', async () => { - await expect(executeRequest('some-not')).rejects.toThrow( - MethodNotFoundError, - ); + // test a method that is not handled from the `onRpcRequest` + await expect( + executeRequest('not_aMethod' as unknown as InternalRpcMethod), + ).rejects.toThrow(MethodNotFoundError); }); it('throws SnapError if the request is failed to execute', async () => { @@ -139,6 +160,7 @@ describe('onKeyringRequest', () => { keyringApi.KeyringRpcMethod.GetAccountBalances, keyringApi.KeyringRpcMethod.SubmitRequest, InternalRpcMethod.GetTransactionStatus, + InternalRpcMethod.EstimateFee, ]) { const result = await onKeyringRequest({ origin: 'https://portfolio.metamask.io', @@ -230,6 +252,7 @@ describe('onKeyringRequest', () => { keyringApi.KeyringRpcMethod.ExportAccount, keyringApi.KeyringRpcMethod.UpdateAccount, InternalRpcMethod.GetTransactionStatus, + InternalRpcMethod.EstimateFee, ]) { await expect( onKeyringRequest({ diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index e0666efc..a7bf9f41 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -11,8 +11,8 @@ import { import { Config } from './config'; import { BtcKeyring } from './keyring'; import { InternalRpcMethod, originPermissions } from './permissions'; -import type { GetTransactionStatusParams } from './rpcs'; -import { getTransactionStatus } from './rpcs'; +import type { GetTransactionStatusParams, EstimateFeeParams } from './rpcs'; +import { getTransactionStatus, estimateFee } from './rpcs'; import { KeyringStateManager } from './stateManagement'; import { isSnapRpcError, logger } from './utils'; @@ -43,6 +43,8 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ return await getTransactionStatus( request.params as GetTransactionStatusParams, ); + case InternalRpcMethod.EstimateFee: + return await estimateFee(request.params as EstimateFeeParams); default: throw new MethodNotFoundError() as unknown as Error; } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index 8547096a..34b392f8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -23,8 +23,12 @@ import { AccountNotFoundError, MethodNotImplementedError } from './exceptions'; import { Factory } from './factory'; import { getBalances, type SendManyParams, sendMany } from './rpcs'; import type { KeyringStateManager, Wallet } from './stateManagement'; -import { getProvider, ScopeStruct, logger } from './utils'; -import { verifyIfAccountValid } from './utils/account'; +import { + getProvider, + ScopeStruct, + logger, + verifyIfAccountValid, +} from './utils'; export type KeyringOptions = Record & { defaultIndex: number; diff --git a/merged-packages/bitcoin-wallet-snap/src/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/permissions.ts index ae3a6bc0..66a9f36a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/permissions.ts @@ -2,6 +2,7 @@ import { KeyringRpcMethod } from '@metamask/keyring-api'; export enum InternalRpcMethod { GetTransactionStatus = 'chain_getTransactionStatus', + EstimateFee = 'estimateFee', } const dappPermissions = new Set([ @@ -12,6 +13,8 @@ const dappPermissions = new Set([ KeyringRpcMethod.SubmitRequest, // Chain API methods InternalRpcMethod.GetTransactionStatus, + // Internal methods + InternalRpcMethod.EstimateFee, ]); const metamaskPermissions = new Set([ diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.test.ts new file mode 100644 index 00000000..6e0830a5 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.test.ts @@ -0,0 +1,280 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import { InvalidParamsError } from '@metamask/snaps-sdk'; +import { networks } from 'bitcoinjs-lib'; +import { v4 as uuidV4 } from 'uuid'; + +import { generateBlockChairGetUtxosResp } from '../../test/utils'; +import { BtcOnChainService } from '../bitcoin/chain'; +import { + BtcAccountDeriver, + BtcWallet, + CoinSelectService, + TxValidationError, +} from '../bitcoin/wallet'; +import { Config } from '../config'; +import { Caip2ChainId } from '../constants'; +import { AccountNotFoundError } from '../exceptions'; +import { KeyringStateManager } from '../stateManagement'; +import { satsToBtc } from '../utils'; +import type { EstimateFeeParams } from './estimate-fee'; +import { estimateFee } from './estimate-fee'; + +jest.mock('../utils/logger'); +jest.mock('../utils/snap'); + +describe('EstimateFeeHandler', () => { + describe('estimateFee', () => { + const createMockChainApiFactory = () => { + const getFeeRatesSpy = jest.spyOn( + BtcOnChainService.prototype, + 'getFeeRates', + ); + const getDataForTransactionSpy = jest.spyOn( + BtcOnChainService.prototype, + 'getDataForTransaction', + ); + + return { + getDataForTransactionSpy, + getFeeRatesSpy, + }; + }; + + const createMockDeriver = (network) => { + return { + instance: new BtcAccountDeriver(network), + }; + }; + + const getHdPath = (index: number) => { + return `m/0'/0/${index}`; + }; + + const createAccount = async (network, caip2ChainId: string) => { + const { instance } = createMockDeriver(network); + const wallet = new BtcWallet(instance, network); + const sender = await wallet.unlock(0, Config.wallet.defaultAccountType); + const getWalletSpy = jest.spyOn( + KeyringStateManager.prototype, + 'getWallet', + ); + + const keyringAccount = { + type: sender.type, + id: uuidV4(), + address: sender.address, + options: { + scope: caip2ChainId, + index: sender.index, + }, + methods: ['btc_sendmany'], + } as unknown as KeyringAccount; + + getWalletSpy.mockResolvedValue({ + account: keyringAccount, + hdPath: getHdPath(sender.index), + index: sender.index, + scope: caip2ChainId, + }); + + return { + sender, + getWalletSpy, + keyringAccount, + wallet, + }; + }; + + const createMockGetDataForTransactionResp = ( + address: string, + counter: number, + ) => { + const mockResponse = generateBlockChairGetUtxosResp( + address, + counter, + 100000, + 100000, + ); + let total = 0; + const data = mockResponse.data[address].utxo.map((utxo) => { + const { value } = utxo; + total += value; + return { + block: utxo.block_id, + txHash: utxo.transaction_hash, + index: utxo.index, + value, + }; + }); + + return { + data, + total, + }; + }; + + const createMockCoinSelectService = () => { + const coinSelectServiceSpy = jest.spyOn( + CoinSelectService.prototype, + 'selectCoins', + ); + + return { + coinSelectServiceSpy, + }; + }; + + it('returns fee correctly', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { coinSelectServiceSpy } = createMockCoinSelectService(); + const { sender, keyringAccount } = await createAccount( + network, + caip2ChainId, + ); + const { data: utxoDataList } = createMockGetDataForTransactionResp( + sender.address, + 10, + ); + const { getDataForTransactionSpy, getFeeRatesSpy } = + createMockChainApiFactory(); + getDataForTransactionSpy.mockResolvedValue({ + data: { + utxos: utxoDataList, + }, + }); + getFeeRatesSpy.mockResolvedValue({ + fees: [ + { + type: Config.defaultFeeRate, + rate: BigInt(1), + }, + ], + }); + const expectedFee = 200; + coinSelectServiceSpy.mockReturnValue({ + inputs: [], + outputs: [], + fee: expectedFee, + }); + + const result = await estimateFee({ + account: keyringAccount.id, + amount: '0.0001', + }); + + expect(result).toStrictEqual({ + fee: { + amount: expect.any(String), + unit: 'BTC', + }, + }); + + expect(result.fee.amount).toBe(satsToBtc(expectedFee)); + }); + + it('throws `InvalidParamsError` when request parameter is not correct', async () => { + await expect( + estimateFee({ + amount: '0.0001', + } as unknown as EstimateFeeParams), + ).rejects.toThrow(InvalidParamsError); + }); + + it('throws `AccountNotFoundError` if the account does not exist', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { getWalletSpy } = await createAccount(network, caip2ChainId); + + getWalletSpy.mockReset().mockResolvedValue(null); + + await expect( + estimateFee({ + account: uuidV4(), + amount: '0.0001', + }), + ).rejects.toThrow(AccountNotFoundError); + }); + + it('throws `AccountNotFoundError` if the derived account is not matching with the account from state', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { getWalletSpy, keyringAccount, wallet, sender } = + await createAccount(network, caip2ChainId); + + // force state manager to return an account with same hd index but different address, to reproduce an case that the derived account address is not match with the state data + const unmatchAccount = await wallet.unlock( + 1, + Config.wallet.defaultAccountType, + ); + getWalletSpy.mockReset().mockResolvedValue({ + account: { + ...keyringAccount, + address: unmatchAccount.address, + }, + hdPath: getHdPath(sender.index), + index: sender.index, + scope: caip2ChainId, + }); + + await expect( + estimateFee({ + account: keyringAccount.id, + amount: '0.0001', + }), + ).rejects.toThrow(AccountNotFoundError); + }); + + it('throws `Failed to estimate fee` error if no fee rate is returned from the chain service', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { keyringAccount } = await createAccount(network, caip2ChainId); + const { getFeeRatesSpy } = createMockChainApiFactory(); + getFeeRatesSpy.mockResolvedValue({ + fees: [], + }); + + await expect( + estimateFee({ + account: keyringAccount.id, + amount: '0.0001', + }), + ).rejects.toThrow('Failed to estimate fee'); + }); + + it('throws `Transaction amount too small` error if amount to estimate for is considered dust', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { sender, keyringAccount } = await createAccount( + network, + caip2ChainId, + ); + const { data: utxoDataList } = createMockGetDataForTransactionResp( + sender.address, + 10, + ); + const { getDataForTransactionSpy, getFeeRatesSpy } = + createMockChainApiFactory(); + getDataForTransactionSpy.mockResolvedValue({ + data: { + utxos: utxoDataList, + }, + }); + getFeeRatesSpy.mockResolvedValue({ + fees: [ + { + type: Config.defaultFeeRate, + rate: BigInt(1), + }, + ], + }); + + await expect( + estimateFee({ + account: keyringAccount.id, + amount: satsToBtc(1), + }), + ).rejects.toThrow(new TxValidationError('Transaction amount too small')); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts new file mode 100644 index 00000000..f4cd4e83 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts @@ -0,0 +1,127 @@ +import { object, string, type Infer, nonempty, enums } from 'superstruct'; + +import { TxValidationError } from '../bitcoin/wallet'; +import { Config } from '../config'; +import { AccountNotFoundError } from '../exceptions'; +import { Factory } from '../factory'; +import { KeyringStateManager } from '../stateManagement'; +import { + isSnapRpcError, + btcToSats, + validateRequest, + validateResponse, + logger, + PositiveNumberStringStruct, + AmountStruct, + satsToBtc, + getFeeRate, + verifyIfAccountValid, +} from '../utils'; + +export const EstimateFeeParamsStruct = object({ + account: nonempty(string()), + amount: AmountStruct, +}); + +export const EstimateFeeResponseStruct = object({ + fee: object({ + amount: nonempty(PositiveNumberStringStruct), + unit: enums([Config.unit]), + }), +}); + +export type EstimateFeeParams = Infer; + +export type EstimateFeeResponse = Infer; + +/** + * Estimate transaction fee. + * + * This function will traverse account's UTXOs to verify the amount and compute the fee estimation. + * + * @param params - The parameters to use when estimating the fee. + * @returns A Promise that resolves to an EstimateFeeResponse object. + */ +export async function estimateFee(params: EstimateFeeParams) { + try { + validateRequest(params, EstimateFeeParamsStruct); + + const { account: accountId, amount } = params; + + const stateManager = new KeyringStateManager(); + const walletData = await stateManager.getWallet(accountId); + + if (!walletData) { + throw new AccountNotFoundError(); + } + + const wallet = Factory.createWallet(walletData.scope); + + const account = await wallet.unlock( + walletData.index, + walletData.account.type, + ); + + verifyIfAccountValid(account, walletData.account); + + const chainApi = Factory.createOnChainServiceProvider(walletData.scope); + + const feesResp = await chainApi.getFeeRates(); + + const fee = getFeeRate(feesResp.fees); + + const metadata = await chainApi.getDataForTransaction(account.address); + + // TODO: change this to use the first address from account when we support multi-addresses per accounts + // We do not need the real recipient address when estimating the fees, so we just use our account's address here + const recipients = [ + { + address: account.address, + value: btcToSats(amount), + }, + ]; + + const result = await wallet.estimateFee(account, recipients, { + utxos: metadata.data.utxos, + fee, + }); + + // The fee estimation will be inaccurate when: + // - The account has no input (no UTXOs) + // - The account does not have enough funds to cover the requested amount + // - There is no output when computing the estimation + // + // NOTE: It is by design that we do not raise any error for now + if (!result.inputs || !result.outputs) { + logger.warn( + 'No input or output found, fee estimation might be inaccurate', + ); + } + + const resp: EstimateFeeResponse = { + fee: { + amount: satsToBtc(result.fee), + unit: Config.unit, + }, + }; + + validateResponse(resp, EstimateFeeResponseStruct); + + return resp; + } catch (error) { + logger.error('Failed to estimate fee', error); + + if (isSnapRpcError(error)) { + throw error as unknown as Error; + } + + if ( + error instanceof TxValidationError || + error instanceof AccountNotFoundError + ) { + throw error; + } + + throw new Error('Failed to estimate fee'); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts index 22cad753..7ce79966 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts @@ -1,3 +1,4 @@ export * from './get-balances'; export * from './get-transaction-status'; export * from './sendmany'; +export * from './estimate-fee'; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 326558fd..2c7ef3c0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -517,7 +517,7 @@ describe('SendManyHandler', () => { ).rejects.toThrow(UserRejectedRequestError); }); - it('throws `Failed to send the transaction` error if no fee rate returns from chain service', async () => { + it('throws `Failed to send the transaction` error if no fee rate is returned from the chain service', async () => { const { getFeeRatesSpy } = createMockChainApiFactory(); const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/utils/index.ts index de0d0a04..cbda3030 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/index.ts @@ -10,3 +10,4 @@ export * from './explorer'; export * from './unit'; export * from './logger'; export * from './fee'; +export * from './account'; From b060206202e0ae0365dbe5bb50d56b2e68945e5e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 26 Jul 2024 17:50:24 +0200 Subject: [PATCH 119/362] 0.3.0 (#209) * 0.3.0 * chore: update changelog * chore: yarn build --------- Co-authored-by: github-actions Co-authored-by: Charly Chevalier --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index c78c5c52..045fa64f 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] + +### Added + +- Add `estimateFee` RPC endpoint ([#169](https://github.com/MetaMask/snap-bitcoin-wallet/pull/169)) + ## [0.2.5] ### Added @@ -137,7 +143,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.5...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.5...v0.3.0 [0.2.5]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.4...v0.2.5 [0.2.4]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.3...v0.2.4 [0.2.3]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.2...v0.2.3 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 087dceeb..ae52d31e 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.2.5", + "version": "0.3.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 7791456b..ac079859 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.2.5", + "version": "0.3.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin Manager", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "PO/duEhS4Gru/Nghz8qHdQiMnwzZk1Xi14Taiz7Rh9c=", + "shasum": "elN+h6hXf5U90SwD+3jtTqSf9gEXw3H9Hv7gMXtXWsE=", "location": { "npm": { "filePath": "dist/bundle.js", From bd2671fc7bf06972f2f44b49f0da6ee40fcebc7c Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Mon, 29 Jul 2024 17:39:10 +0200 Subject: [PATCH 120/362] fix: audit 4.6 - pin Bitcoin and cryptographic dependencies (#205) * chore(deps): pin Bitcoin and cryptographic dependencies * chore: update `yarn.lock` --- merged-packages/bitcoin-wallet-snap/package.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index ae52d31e..471e44af 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -32,7 +32,7 @@ }, "devDependencies": { "@babel/preset-typescript": "^7.23.3", - "@bitcoinerlab/secp256k1": "^1.1.1", + "@bitcoinerlab/secp256k1": "1.1.1", "@jest/globals": "^29.5.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/eslint-config": "^12.2.0", @@ -48,12 +48,12 @@ "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", "async-mutex": "^0.3.2", - "big.js": "^6.2.1", - "bip32": "^4.0.0", - "bitcoinjs-lib": "^6.1.5", - "coinselect": "^3.1.13", + "big.js": "6.2.1", + "bip32": "4.0.0", + "bitcoinjs-lib": "6.1.5", + "coinselect": "3.1.13", "dotenv": "^16.4.5", - "ecpair": "^2.1.0", + "ecpair": "2.1.0", "eslint": "^8.45.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "~2.26.0", From 48e2eff9bd4d74a8b895ec1d1264d10779cc39cb Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 30 Jul 2024 16:29:02 +0200 Subject: [PATCH 121/362] feat: emit acccount suggested name from Snap (#210) --- .../bitcoin-wallet-snap/src/keyring.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index 34b392f8..5987eb75 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -19,6 +19,7 @@ import { v4 as uuidv4 } from 'uuid'; import type { BtcAccount, BtcWallet } from './bitcoin/wallet'; import { Config } from './config'; +import { Caip2ChainId } from './constants'; import { AccountNotFoundError, MethodNotImplementedError } from './exceptions'; import { Factory } from './factory'; import { getBalances, type SendManyParams, sendMany } from './rpcs'; @@ -109,6 +110,7 @@ export class BtcKeyring implements Keyring { await this.#emitEvent(KeyringEvent.AccountCreated, { account: keyringAccount, + accountNameSuggestion: this.getKeyringAccountNameSuggestion(options), }); }); @@ -266,4 +268,19 @@ export class BtcKeyring implements Keyring { methods: this._methods, } as unknown as KeyringAccount; } + + protected getKeyringAccountNameSuggestion( + options?: CreateAccountOptions, + ): string { + switch (options?.scope) { + case Caip2ChainId.Mainnet: + return 'Bitcoin Account'; + case Caip2ChainId.Testnet: + return 'Bitcoin Testnet Account'; + + default: + // Leave it blank to fallback to auto-suggested name on the extension side + return ''; + } + } } From 017cf42a35ec77b919a384932b25788713dddb9e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 30 Jul 2024 17:32:51 +0200 Subject: [PATCH 122/362] 0.4.0 (#212) * 0.4.0 * chore: yarn build * chore: update changelog * chore: update changelog Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * chore: prettier --------- Co-authored-by: github-actions Co-authored-by: Charly Chevalier Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 14 +++++++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 045fa64f..c8ccf3ce 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.0] + +### Added + +- Emit account suggested name when creating account ([#210](https://github.com/MetaMask/snap-bitcoin-wallet/pull/210)) + - This name suggestion can then be used by the client to name the account accordingly. + +### Changed + +- Audit 4.6: Pin Bitcoin and cryptographic dependencies ([#205](https://github.com/MetaMask/snap-bitcoin-wallet/pull/205)) + ## [0.3.0] ### Added @@ -143,7 +154,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.5...v0.3.0 [0.2.5]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.4...v0.2.5 [0.2.4]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.3...v0.2.4 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 471e44af..48360605 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.3.0", + "version": "0.4.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index ac079859..74488b75 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.3.0", + "version": "0.4.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin Manager", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "elN+h6hXf5U90SwD+3jtTqSf9gEXw3H9Hv7gMXtXWsE=", + "shasum": "92FMQEgQKMe3kWjGtvKfe3fHn2otac7MgL2p6/BvOqc=", "location": { "npm": { "filePath": "dist/bundle.js", From a80e198b95e3f24212ca11df64490cba419f710e Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 1 Aug 2024 19:49:00 +0800 Subject: [PATCH 123/362] feat: add `getMaxSpendableBalance` RPC endpoint (#188) * feat: add fee api * chore: do not throw error if insufficient fund for estimate fee * chore: fix lint style * chore: update test case * chore: move tx amount struct to resuable * chore: add reusable error * chore: remove duplicate method after rebase * chore: update estimate fee struct * chore: update error msg * chore: add unit test for estimate fee * chore: fix lint style * chore: add estimate fee unit test in wallet * chore: update superstruct test case * chore: update estimate fee api name * feat: add getMaxSpendableBalance api * chore: add ui for get max spendable balance * fix: incorrect permission in snap * fix: lint style in ui * chore: update max spendable balance to use verifyIfAccountValid and getFeeRate * chore: use Config.defaultFeeRate * chore: update example dapp for missing component * chore: add document * chore: update comment * chore: update unit test * chore: use big int * chore: update var name * chore: update test case * chore: update comment * chore: fix review comment * chore: update big int floor * chore: update test comment * chore: fix missing closing on the unit test --- .../src/bitcoin/wallet/exceptions.ts | 12 + .../src/bitcoin/wallet/wallet.ts | 10 +- .../bitcoin-wallet-snap/src/index.test.ts | 2 + .../bitcoin-wallet-snap/src/index.ts | 16 +- .../bitcoin-wallet-snap/src/permissions.ts | 2 + .../src/rpcs/estimate-fee.test.ts | 2 +- .../src/rpcs/estimate-fee.ts | 6 +- .../rpcs/get-max-spendable-balance.test.ts | 368 ++++++++++++++++++ .../src/rpcs/get-max-spendable-balance.ts | 163 ++++++++ .../bitcoin-wallet-snap/src/rpcs/index.ts | 1 + .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 6 +- 11 files changed, 578 insertions(+), 10 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts index e8a19270..29a98ec7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts @@ -8,6 +8,18 @@ export class WalletError extends CustomError {} export class TxValidationError extends WalletError {} +export class TransactionDustError extends TxValidationError { + constructor(errMsg?: string) { + super(errMsg ?? 'Transaction amount too small'); + } +} + +export class InsufficientFundsError extends TxValidationError { + constructor(errMsg?: string) { + super(errMsg ?? 'Insufficient funds'); + } +} + export class PsbtServiceError extends CustomError {} export class PsbtSigValidateError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index acca7085..c8ab0a53 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -14,7 +14,11 @@ import type { SelectionResult } from './coin-select'; import { CoinSelectService } from './coin-select'; import { ScriptType } from './constants'; import type { BtcAccountDeriver } from './deriver'; -import { WalletError, TxValidationError } from './exceptions'; +import { + WalletError, + TransactionDustError, + InsufficientFundsError, +} from './exceptions'; import { PsbtService } from './psbt'; import { AccountSigner } from './signer'; import { TxInfo } from './transaction-info'; @@ -127,7 +131,7 @@ export class BtcWallet { ); if (!selectionResult.inputs || !selectionResult.outputs) { - throw new TxValidationError('Insufficient funds'); + throw new InsufficientFundsError(); } const psbtService = new PsbtService(this._network); @@ -237,7 +241,7 @@ export class BtcWallet { ): TxOutput[] { return recipients.map((recipient) => { if (isDust(recipient.value, scriptType)) { - throw new TxValidationError('Transaction amount too small'); + throw new TransactionDustError(); } const destinationScriptOutput = getScriptForDestination( recipient.address, diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts index 94318e0d..5cdbf2d4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.ts @@ -161,6 +161,7 @@ describe('onKeyringRequest', () => { keyringApi.KeyringRpcMethod.SubmitRequest, InternalRpcMethod.GetTransactionStatus, InternalRpcMethod.EstimateFee, + InternalRpcMethod.GetMaxSpendableBalance, ]) { const result = await onKeyringRequest({ origin: 'https://portfolio.metamask.io', @@ -253,6 +254,7 @@ describe('onKeyringRequest', () => { keyringApi.KeyringRpcMethod.UpdateAccount, InternalRpcMethod.GetTransactionStatus, InternalRpcMethod.EstimateFee, + InternalRpcMethod.GetMaxSpendableBalance, ]) { await expect( onKeyringRequest({ diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index a7bf9f41..671727e6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -11,8 +11,16 @@ import { import { Config } from './config'; import { BtcKeyring } from './keyring'; import { InternalRpcMethod, originPermissions } from './permissions'; -import type { GetTransactionStatusParams, EstimateFeeParams } from './rpcs'; -import { getTransactionStatus, estimateFee } from './rpcs'; +import type { + GetTransactionStatusParams, + EstimateFeeParams, + GetMaxSpendableBalanceParams, +} from './rpcs'; +import { + getTransactionStatus, + estimateFee, + getMaxSpendableBalance, +} from './rpcs'; import { KeyringStateManager } from './stateManagement'; import { isSnapRpcError, logger } from './utils'; @@ -45,6 +53,10 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ ); case InternalRpcMethod.EstimateFee: return await estimateFee(request.params as EstimateFeeParams); + case InternalRpcMethod.GetMaxSpendableBalance: + return await getMaxSpendableBalance( + request.params as GetMaxSpendableBalanceParams, + ); default: throw new MethodNotFoundError() as unknown as Error; } diff --git a/merged-packages/bitcoin-wallet-snap/src/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/permissions.ts index 66a9f36a..176010de 100644 --- a/merged-packages/bitcoin-wallet-snap/src/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/permissions.ts @@ -3,6 +3,7 @@ import { KeyringRpcMethod } from '@metamask/keyring-api'; export enum InternalRpcMethod { GetTransactionStatus = 'chain_getTransactionStatus', EstimateFee = 'estimateFee', + GetMaxSpendableBalance = 'getMaxSpendableBalance', } const dappPermissions = new Set([ @@ -15,6 +16,7 @@ const dappPermissions = new Set([ InternalRpcMethod.GetTransactionStatus, // Internal methods InternalRpcMethod.EstimateFee, + InternalRpcMethod.GetMaxSpendableBalance, ]); const metamaskPermissions = new Set([ diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.test.ts index 6e0830a5..e89adde4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.test.ts @@ -173,7 +173,7 @@ describe('EstimateFeeHandler', () => { expect(result.fee.amount).toBe(satsToBtc(expectedFee)); }); - it('throws `InvalidParamsError` when request parameter is not correct', async () => { + it('throws `InvalidParamsError` when the request parameter is not correct', async () => { await expect( estimateFee({ amount: '0.0001', diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts index f4cd4e83..e1c512c1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts @@ -70,7 +70,9 @@ export async function estimateFee(params: EstimateFeeParams) { const fee = getFeeRate(feesResp.fees); - const metadata = await chainApi.getDataForTransaction(account.address); + const { + data: { utxos }, + } = await chainApi.getDataForTransaction(account.address); // TODO: change this to use the first address from account when we support multi-addresses per accounts // We do not need the real recipient address when estimating the fees, so we just use our account's address here @@ -82,7 +84,7 @@ export async function estimateFee(params: EstimateFeeParams) { ]; const result = await wallet.estimateFee(account, recipients, { - utxos: metadata.data.utxos, + utxos, fee, }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.test.ts new file mode 100644 index 00000000..ad7b2cdd --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.test.ts @@ -0,0 +1,368 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import { InvalidParamsError } from '@metamask/snaps-sdk'; +import { networks } from 'bitcoinjs-lib'; +import { v4 as uuidV4 } from 'uuid'; + +import { generateBlockChairGetUtxosResp } from '../../test/utils'; +import { BtcOnChainService } from '../bitcoin/chain'; +import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; +import { Config } from '../config'; +import { Caip2ChainId } from '../constants'; +import { AccountNotFoundError } from '../exceptions'; +import { KeyringStateManager } from '../stateManagement'; +import { btcToSats, satsToBtc } from '../utils'; +import type { GetMaxSpendableBalanceParams } from './get-max-spendable-balance'; +import { getMaxSpendableBalance } from './get-max-spendable-balance'; + +jest.mock('../utils/logger'); +jest.mock('../utils/snap'); + +describe('GetMaxSpendableBalanceHandler', () => { + describe('getMaxSpendableBalance', () => { + const createMockChainApiFactory = () => { + const getFeeRatesSpy = jest.spyOn( + BtcOnChainService.prototype, + 'getFeeRates', + ); + const getDataForTransactionSpy = jest.spyOn( + BtcOnChainService.prototype, + 'getDataForTransaction', + ); + + return { + getDataForTransactionSpy, + getFeeRatesSpy, + }; + }; + + const createMockDeriver = (network) => { + return { + instance: new BtcAccountDeriver(network), + }; + }; + + const getHdPath = (index: number) => { + return `m/0'/0/${index}`; + }; + + const createAccount = async (network, caip2ChainId: string) => { + const { instance } = createMockDeriver(network); + const wallet = new BtcWallet(instance, network); + const sender = await wallet.unlock(0, Config.wallet.defaultAccountType); + const getWalletSpy = jest.spyOn( + KeyringStateManager.prototype, + 'getWallet', + ); + + const keyringAccount = { + type: sender.type, + id: uuidV4(), + address: sender.address, + options: { + scope: caip2ChainId, + index: sender.index, + }, + methods: ['btc_sendmany'], + } as unknown as KeyringAccount; + + getWalletSpy.mockResolvedValue({ + account: keyringAccount, + hdPath: getHdPath(sender.index), + index: sender.index, + scope: caip2ChainId, + }); + + return { + sender, + getWalletSpy, + keyringAccount, + wallet, + }; + }; + + const createMockGetDataForTransactionResp = ( + address: string, + counter: number, + minVal = 10000, + maxVal = 100000, + ) => { + const mockResponse = generateBlockChairGetUtxosResp( + address, + counter, + minVal, + maxVal, + ); + let total = 0; + const data = mockResponse.data[address].utxo.map((utxo) => { + const { value } = utxo; + total += value; + return { + block: utxo.block_id, + txHash: utxo.transaction_hash, + index: utxo.index, + value, + }; + }); + + return { + data, + total, + }; + }; + + it('returns the maximum spendable balance correctly', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { sender, keyringAccount } = await createAccount( + network, + caip2ChainId, + ); + const { data: utxoDataList, total: utxoTotalValue } = + createMockGetDataForTransactionResp( + sender.address, + 100, + 10000, + 10000000000, + ); + const { getDataForTransactionSpy, getFeeRatesSpy } = + createMockChainApiFactory(); + getDataForTransactionSpy.mockResolvedValue({ + data: { + utxos: utxoDataList, + }, + }); + getFeeRatesSpy.mockResolvedValue({ + fees: [ + { + type: Config.defaultFeeRate, + rate: BigInt(15), + }, + ], + }); + + const result = await getMaxSpendableBalance({ + account: keyringAccount.id, + }); + + expect(result).toStrictEqual({ + fee: { + amount: result.fee.amount, + unit: 'BTC', + }, + balance: { + amount: result.balance.amount, + unit: 'BTC', + }, + }); + + // If all UTXOs are above the dust threshold, then the total balance should be equal to the sum of the fee and the spendable balance. + expect( + btcToSats(result.fee.amount) + btcToSats(result.balance.amount), + ).toStrictEqual(BigInt(utxoTotalValue)); + }); + + it('estimates the maximum spendable balance by excluding any UTXO whose value is equal to or less than the dust threshold', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { sender, keyringAccount } = await createAccount( + network, + caip2ChainId, + ); + const { data: utxoDataList } = createMockGetDataForTransactionResp( + sender.address, + 10, + ); + // When using 104 satoshis per byte and 1 input contains 63 bytes, the dust threshold (fee for using this UTXO) will be 104 * 63 bytes = 6552 satoshis. Any UTXO less than this amount will be discarded as it would be a waste to use it. + const feeRate = 104; + const utxoInputBytesSize = 63; + const dustThreshold = utxoInputBytesSize * feeRate; + utxoDataList[0].value = dustThreshold; + const utxoTotalValue = utxoDataList.reduce( + (acc, utxo) => acc + utxo.value, + 0, + ); + + const { getDataForTransactionSpy, getFeeRatesSpy } = + createMockChainApiFactory(); + getDataForTransactionSpy.mockResolvedValue({ + data: { + utxos: utxoDataList, + }, + }); + getFeeRatesSpy.mockResolvedValue({ + fees: [ + { + type: Config.defaultFeeRate, + rate: BigInt(feeRate), + }, + ], + }); + + const result = await getMaxSpendableBalance({ + account: keyringAccount.id, + }); + + // One of our UTXO was below the dust threshold, then the total balance will not count this UTXO, thus we need to subtract it from the total UTXO balance. + expect( + btcToSats(result.fee.amount) + btcToSats(result.balance.amount), + ).toStrictEqual(BigInt(utxoTotalValue) - BigInt(dustThreshold)); + }); + + it.each([ + { + utxoCnt: 1, + utxoVal: 200, + }, + { + utxoCnt: 0, + utxoVal: 1, + }, + ])( + "returns a zero-spendable-balance if the account's balance is too small or the account does not have UTXO", + async ({ utxoCnt, utxoVal }: { utxoCnt: number; utxoVal: number }) => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { sender, keyringAccount } = await createAccount( + network, + caip2ChainId, + ); + const { data: utxoDataList } = createMockGetDataForTransactionResp( + sender.address, + utxoCnt, + utxoVal, + utxoVal, + ); + const { getDataForTransactionSpy, getFeeRatesSpy } = + createMockChainApiFactory(); + getDataForTransactionSpy.mockResolvedValue({ + data: { + utxos: utxoDataList, + }, + }); + getFeeRatesSpy.mockResolvedValue({ + fees: [ + { + type: Config.defaultFeeRate, + rate: BigInt(1), + }, + ], + }); + + const result = await getMaxSpendableBalance({ + account: keyringAccount.id, + }); + + expect(result).toStrictEqual({ + fee: { + amount: satsToBtc(0), + unit: 'BTC', + }, + balance: { + amount: satsToBtc(0), + unit: 'BTC', + }, + }); + }, + ); + + it('throws `InvalidParamsError` when the request parameter is not correct', async () => { + await expect( + getMaxSpendableBalance({} as unknown as GetMaxSpendableBalanceParams), + ).rejects.toThrow(InvalidParamsError); + }); + + it('throws `AccountNotFoundError` if the account does not exist', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { getWalletSpy } = await createAccount(network, caip2ChainId); + + getWalletSpy.mockReset().mockResolvedValue(null); + + await expect( + getMaxSpendableBalance({ + account: uuidV4(), + }), + ).rejects.toThrow(AccountNotFoundError); + }); + + it('throws `AccountNotFoundError` if the derived account if the derived account is not matching with the account from state', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { getWalletSpy, keyringAccount, wallet, sender } = + await createAccount(network, caip2ChainId); + + // force state manager to return an account with same hd index but different address, to reproduce an case that the derived account address is not match with the state data + const unmatchAccount = await wallet.unlock( + 1, + Config.wallet.defaultAccountType, + ); + getWalletSpy.mockReset().mockResolvedValue({ + account: { + ...keyringAccount, + address: unmatchAccount.address, + }, + hdPath: getHdPath(sender.index), + index: sender.index, + scope: caip2ChainId, + }); + + await expect( + getMaxSpendableBalance({ + account: keyringAccount.id, + }), + ).rejects.toThrow(AccountNotFoundError); + }); + + it('throws `Failed to get max spendable balance` error if no fee rate is returned from the chain service', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { keyringAccount } = await createAccount(network, caip2ChainId); + const { getFeeRatesSpy } = createMockChainApiFactory(); + getFeeRatesSpy.mockResolvedValue({ + fees: [], + }); + + await expect( + getMaxSpendableBalance({ + account: keyringAccount.id, + }), + ).rejects.toThrow('Failed to get max spendable balance'); + }); + + it('throws `Failed to get max spendable balance` error if another error was thrown during the estimation', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { sender } = await createAccount(network, caip2ChainId); + const { data: utxoDataList } = createMockGetDataForTransactionResp( + sender.address, + 1, + 10000, + 10000, + ); + const { getDataForTransactionSpy, getFeeRatesSpy } = + createMockChainApiFactory(); + getDataForTransactionSpy.mockResolvedValue({ + data: { + utxos: utxoDataList, + }, + }); + getFeeRatesSpy.mockResolvedValue({ + fees: [ + { + type: Config.defaultFeeRate, + rate: BigInt(1), + }, + ], + }); + jest + .spyOn(BtcWallet.prototype, 'estimateFee') + .mockRejectedValue(new Error('error')); + + await expect( + getMaxSpendableBalance({ + account: uuidV4(), + }), + ).rejects.toThrow('Failed to get max spendable balance'); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.ts new file mode 100644 index 00000000..42a9612f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.ts @@ -0,0 +1,163 @@ +import { object, string, type Infer, nonempty, enums } from 'superstruct'; + +import { TransactionDustError, TxValidationError } from '../bitcoin/wallet'; +import { Config } from '../config'; +import { AccountNotFoundError } from '../exceptions'; +import { Factory } from '../factory'; +import { KeyringStateManager } from '../stateManagement'; +import { + isSnapRpcError, + validateRequest, + validateResponse, + logger, + PositiveNumberStringStruct, + satsToBtc, + verifyIfAccountValid, + getFeeRate, +} from '../utils'; + +export const GetMaxSpendableBalanceParamsStruct = object({ + account: nonempty(string()), +}); + +export const GetMaxSpendableBalanceResponseStruct = object({ + fee: object({ + amount: nonempty(PositiveNumberStringStruct), + unit: enums([Config.unit]), + }), + balance: object({ + amount: nonempty(PositiveNumberStringStruct), + unit: enums([Config.unit]), + }), +}); + +export type GetMaxSpendableBalanceParams = Infer< + typeof GetMaxSpendableBalanceParamsStruct +>; + +export type GetMaxSpendableBalanceResponse = Infer< + typeof GetMaxSpendableBalanceResponseStruct +>; + +/** + * Get max spendable balance. + * + * This function uses a binary search algorithm to compute the maximum spendable balance of the given account. + * + * @param params - The parameters to use when computing the max spendable balance. + * @returns A Promise that resolves to an GetMaxSpendableBalanceResponse object. + */ +export async function getMaxSpendableBalance( + params: GetMaxSpendableBalanceParams, +) { + try { + validateRequest(params, GetMaxSpendableBalanceParamsStruct); + + const { account: accountId } = params; + + const stateManager = new KeyringStateManager(); + const walletData = await stateManager.getWallet(accountId); + + if (!walletData) { + throw new AccountNotFoundError(); + } + + const wallet = Factory.createWallet(walletData.scope); + + const account = await wallet.unlock( + walletData.index, + walletData.account.type, + ); + + verifyIfAccountValid(account, walletData.account); + + const chainApi = Factory.createOnChainServiceProvider(walletData.scope); + + const feesResp = await chainApi.getFeeRates(); + + const fee = getFeeRate(feesResp.fees); + + const { + data: { utxos }, + } = await chainApi.getDataForTransaction(account.address); + + let spendable = BigInt(0); + let estimatedFee = BigInt(0); + let low = BigInt(0); + // Using the sum of all UTXO values as the high value, instead of directly using the balance. + // This is more accurate because balance data may be outdated. + let high = utxos.reduce((acc, utxo) => acc + BigInt(utxo.value), BigInt(0)); + + while (low <= high) { + // Compute the middle value. + const mid = (low + high) / BigInt(2); + + // Test the middle value. + try { + const estimateResult = await wallet.estimateFee( + account, + [ + { + address: account.address, + value: mid, + }, + ], + { + utxos, + fee, + }, + ); + + // If the middle value is valid, we can increase the low value to test a higher amount. + if (estimateResult.outputs && estimateResult.outputs.length > 0) { + low = mid + BigInt(1); + + if (mid > spendable) { + // If the updated spendable amount is larger than the previous amount, then update both the spendable amount and the estimated fee. + spendable = mid; + estimatedFee = BigInt(estimateResult.fee); + } + } else { + // If the middle value is out of bounds, we need to decrease the high value to test a lower amount. + high = mid - BigInt(1); + } + } catch (error) { + // In the case where the middle value is too small, we can increase the low value to test a higher amount. This scenario typically occurs when the sum of the account's UTXOs is too small. + if (error instanceof TransactionDustError) { + low = mid + BigInt(1); + } else { + throw error; + } + } + } + + const resp: GetMaxSpendableBalanceResponse = { + fee: { + amount: satsToBtc(estimatedFee), + unit: Config.unit, + }, + balance: { + amount: satsToBtc(spendable), + unit: Config.unit, + }, + }; + + validateResponse(resp, GetMaxSpendableBalanceResponseStruct); + return resp; + } catch (error) { + logger.error('Failed to get max spendable balance', error); + + if (isSnapRpcError(error)) { + throw error as unknown as Error; + } + + if ( + error instanceof TxValidationError || + error instanceof AccountNotFoundError + ) { + throw error; + } + + throw new Error('Failed to get max spendable balance'); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts index 7ce79966..3f31a9ac 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts @@ -2,3 +2,4 @@ export * from './get-balances'; export * from './get-transaction-status'; export * from './sendmany'; export * from './estimate-fee'; +export * from './get-max-spendable-balance'; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index c70a7fc8..9991dc7f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -107,10 +107,12 @@ export async function sendMany( }), ); - const metadata = await chainApi.getDataForTransaction(account.address); + const { + data: { utxos }, + } = await chainApi.getDataForTransaction(account.address); const { tx, txInfo } = await wallet.createTransaction(account, recipients, { - utxos: metadata.data.utxos, + utxos, fee, subtractFeeFrom: params.subtractFeeFrom, replaceable: params.replaceable, From abd680200cfd3fb84d1b984431814f34b039ca73 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 2 Aug 2024 15:09:47 +0800 Subject: [PATCH 124/362] chore: update metamask/* dep (#218) --- merged-packages/bitcoin-wallet-snap/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 48360605..567c3ae4 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -40,10 +40,10 @@ "@metamask/eslint-config-nodejs": "^12.1.0", "@metamask/eslint-config-typescript": "^12.1.0", "@metamask/key-tree": "^9.1.2", - "@metamask/keyring-api": "^8.0.1", - "@metamask/snaps-cli": "^6.2.1", - "@metamask/snaps-jest": "^8.2.0", - "@metamask/snaps-sdk": "^6.1.0", + "@metamask/keyring-api": "^8.0.2", + "@metamask/snaps-cli": "^6.3.0", + "@metamask/snaps-jest": "^8.3.0", + "@metamask/snaps-sdk": "^6.2.0", "@metamask/utils": "^9.1.0", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", From 24fe6b56c2cac7f5602edef47a814fd1019682de Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 2 Aug 2024 20:19:53 +0800 Subject: [PATCH 125/362] chore: increase keyring test coverage (#220) --- .../bitcoin-wallet-snap/src/keyring.test.ts | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts index 30fa4164..ff3c103e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts @@ -8,6 +8,7 @@ import { Config } from './config'; import { Caip2Asset, Caip2ChainId } from './constants'; import { AccountNotFoundError, MethodNotImplementedError } from './exceptions'; import { Factory } from './factory'; +import type { CreateAccountOptions } from './keyring'; import { BtcKeyring } from './keyring'; import * as getBalanceRpc from './rpcs/get-balances'; import * as sendManyRpc from './rpcs/sendmany'; @@ -21,6 +22,13 @@ jest.mock('@metamask/keyring-api', () => ({ emitSnapKeyringEvent: jest.fn(), })); +class MockBtcKeyring extends BtcKeyring { + // Mock protected method getKeyringAccountNameSuggestion to public for test purpose + public getKeyringAccountNameSuggestion(options?: CreateAccountOptions) { + return super.getKeyringAccountNameSuggestion(options); + } +} + describe('BtcKeyring', () => { const origin = 'http://localhost:3000'; @@ -73,8 +81,9 @@ describe('BtcKeyring', () => { const createMockKeyring = (stateMgr: KeyringStateManager) => { const sendManySpy = jest.spyOn(sendManyRpc, 'sendMany'); const getBalanceRpcSpy = jest.spyOn(getBalanceRpc, 'getBalances'); + return { - instance: new BtcKeyring(stateMgr, { + instance: new MockBtcKeyring(stateMgr, { defaultIndex: 0, origin, multiAccount: false, @@ -520,4 +529,40 @@ describe('BtcKeyring', () => { ).rejects.toThrow(Error); }); }); + + describe('getKeyringAccountNameSuggestion', () => { + it.each([ + { + scope: Caip2ChainId.Mainnet, + accountName: 'Bitcoin Account', + }, + { + scope: Caip2ChainId.Testnet, + accountName: 'Bitcoin Testnet Account', + }, + ])( + 'returns "$accountName" if the Caip 2 ChainId is $scope', + ({ scope, accountName }: { scope: string; accountName: string }) => { + const { instance: stateMgr } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + + expect( + keyring.getKeyringAccountNameSuggestion({ + scope, + }), + ).toStrictEqual(accountName); + }, + ); + }); + + it('returns empty string if the Caip 2 ChainId is neither Testnet or Mainnet', () => { + const { instance: stateMgr } = createMockStateMgr(); + const { instance: keyring } = createMockKeyring(stateMgr); + + expect( + keyring.getKeyringAccountNameSuggestion({ + scope: 'Other Caip 2 Chain Id', + }), + ).toBe(''); + }); }); From 099fa9f2ddd293b8935dd424c8c0079c5a71d4b9 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 23 Aug 2024 17:23:55 +0800 Subject: [PATCH 126/362] doc: update snap read me (#230) --- merged-packages/bitcoin-wallet-snap/README.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index 7aae7adc..be69045f 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -106,3 +106,41 @@ provider.request({ }, }); ``` + +### API `estimateFee` + +example: + +```typescript +provider.request({ + method: 'wallet_invokeSnap', + params: { + snapId, + request: { + method: 'estimateFee', + params: { + account: '9506e13e-d343-406f-8408-fad033ab7c0d', // the uuid account id of the current account + amount: '0.00001', // transaction amount in BTC + }, + }, + }, +}); +``` + +### API `getMaxSpendableBalance` + +example: + +```typescript +provider.request({ + method: 'wallet_invokeSnap', + params: { + snapId, + request: { + method: 'getMaxSpendableBalance', + params: { + account: "9506e13e-d343-406f-8408-fad033ab7c0d", // the uuid account id of the current account + }, + }, +}); +``` From 932635a06c823c50c292012b3bc419f94aff0a4e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 28 Aug 2024 10:21:44 +0200 Subject: [PATCH 127/362] 0.5.0 (#234) * 0.5.0 * chore: update changelog * chore: update Snap manifest --------- Co-authored-by: github-actions Co-authored-by: Charly Chevalier --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 13 ++++++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index c8ccf3ce..016e6ece 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] + +### Added + +- Add `getMaxSpendableBalance` RPC endpoint ([#188](https://github.com/MetaMask/snap-bitcoin-wallet/pull/188)) + +### Changed + +- Improve keyring tests coverage ([#220](https://github.com/MetaMask/snap-bitcoin-wallet/pull/220)) + ## [0.4.0] ### Added @@ -154,7 +164,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.4.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.5...v0.3.0 [0.2.5]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.4...v0.2.5 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 567c3ae4..5ddc3220 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.4.0", + "version": "0.5.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 74488b75..7dd7ab69 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.4.0", + "version": "0.5.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin Manager", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "92FMQEgQKMe3kWjGtvKfe3fHn2otac7MgL2p6/BvOqc=", + "shasum": "btJxflITIeHy+uAvWfAFafYPTzjAjtlSvRgfA3gmnrs=", "location": { "npm": { "filePath": "dist/bundle.js", From 5cfe16079b28d895347a20b17c7c1b59792671cc Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 11 Sep 2024 08:24:52 +0800 Subject: [PATCH 128/362] chore: display a alert dialog if an insufficient funds error has thrown in RPC - `SendMany` (#236) * chore: add tx warning when insufficient funds error occur in send many * chore: refine comment * chore: update test * chore: update test * chore: address comment --- .../src/rpcs/sendmany.test.ts | 366 +++++++++++------- .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 157 +++++--- .../src/utils/__mocks__/snap.ts | 2 + .../bitcoin-wallet-snap/src/utils/snap.ts | 22 +- 4 files changed, 359 insertions(+), 188 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 2c7ef3c0..aae5fffa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -10,12 +10,13 @@ import { generateBlockChairGetUtxosResp, } from '../../test/utils'; import { BtcOnChainService } from '../bitcoin/chain'; -import type { BtcAccount } from '../bitcoin/wallet'; +import type { BtcAccount, Recipient } from '../bitcoin/wallet'; import { BtcAccountDeriver, BtcWallet, type ITxInfo, TxValidationError, + InsufficientFundsError, } from '../bitcoin/wallet'; import { Config } from '../config'; import { Caip2ChainId } from '../constants'; @@ -95,10 +96,11 @@ describe('SendManyHandler', () => { caip2ChainId: string, dryrun: boolean, comment = '', + amount = 500, ): SendManyParams => { return { amounts: recipients.reduce((acc, recipient) => { - acc[recipient.address] = satsToBtc(500); + acc[recipient.address] = satsToBtc(amount); return acc; }, {}), comment, @@ -137,7 +139,7 @@ describe('SendManyHandler', () => { getFeeRatesSpy, broadcastTransactionSpy, } = createMockChainApiFactory(); - const snapHelperSpy = jest.spyOn(snapUtils, 'confirmDialog'); + const confirmDialogSpy = jest.spyOn(snapUtils, 'confirmDialog'); const { sender, keyringAccount, recipients } = await createSenderNRecipients(network, caip2ChainId, 2); @@ -160,7 +162,7 @@ describe('SendManyHandler', () => { broadcastTransactionSpy.mockResolvedValue({ transactionId: broadcastResp, }); - snapHelperSpy.mockResolvedValue(true); + confirmDialogSpy.mockResolvedValue(true); return { sender, @@ -170,7 +172,93 @@ describe('SendManyHandler', () => { getDataForTransactionSpy, getFeeRatesSpy, broadcastTransactionSpy, - snapHelperSpy, + confirmDialogSpy, + }; + }; + + // this method is to create a expected response of a divider component + const createExpectedDividerComponent = (): unknown => { + return { + type: 'divider', + }; + }; + + // this method is to create a expected response of a recipient list component + const createExpectedRecipientListComponent = ( + recipients: Recipient[], + caip2ChainId: string, + ): unknown[] => { + const expectedComponents: unknown[] = []; + const recipientsLen = recipients.length; + + for (let idx = 0; idx < recipientsLen; idx++) { + const recipient = recipients[idx]; + + expectedComponents.push({ + type: 'panel', + children: [ + { + type: 'row', + label: recipientsLen > 1 ? `Recipient ${idx + 1}` : `Recipient`, + value: { + type: 'text', + value: `[${shortenAddress(recipient.address)}](${getExplorerUrl( + recipient.address, + caip2ChainId, + )})`, + }, + }, + { + type: 'row', + label: 'Amount', + value: { + markdown: false, + type: 'text', + value: satsToBtc(recipient.value, true), + }, + }, + ], + }); + expectedComponents.push(createExpectedDividerComponent()); + } + return expectedComponents; + }; + + // this method is to create a expected response of a header panel component + const createExpectedHeadingPanelComponent = ( + requestBy: string, + includeReviewText = true, + ): unknown => { + const headingComponent = { + type: 'heading', + value: 'Send Request', + }; + + const reviewTextComponent = { + type: 'text', + value: + "Review the request before proceeding. Once the transaction is made, it's irreversible.", + }; + + const requestByComponent = { + type: 'row', + label: 'Requested by', + value: { + type: 'text', + value: requestBy, + markdown: false, + }, + }; + + const panelChilds: unknown[] = [headingComponent]; + if (includeReviewText) { + panelChilds.push(reviewTextComponent); + } + panelChilds.push(requestByComponent); + + return { + type: 'panel', + children: panelChilds, }; }; @@ -213,53 +301,34 @@ describe('SendManyHandler', () => { expect(broadcastTransactionSpy).toHaveBeenCalledTimes(0); }); - it('does create comment component in dialog if consumer has provide the comment', async () => { + it('displays a transaction confirmation dialog if the bitcoin transaction has been created successfully', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients, snapHelperSpy } = await prepareSendMany( + const { recipients, confirmDialogSpy, sender } = await prepareSendMany( network, caip2ChainId, ); - - await sendMany( - sender, - origin, - createSendManyParams(recipients, caip2ChainId, true, 'test comment'), - ); - - const calls = snapHelperSpy.mock.calls[0][0]; - - const lastPanel = calls[calls.length - 1]; - - expect(lastPanel).toStrictEqual({ - type: 'panel', - children: [ - { - type: 'row', - label: 'Comment', - value: { markdown: false, type: 'text', value: 'test comment' }, - }, - { - type: 'row', - label: 'Network fee', - value: { markdown: false, type: 'text', value: expect.any(String) }, - }, - { - type: 'row', - label: 'Total', - value: { markdown: false, type: 'text', value: expect.any(String) }, - }, - ], - }); - }); - - it('display `Recipient` as label in dialog if there is only 1 recipient', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { recipients, snapHelperSpy, sender } = await prepareSendMany( - network, + const sendAmtInSats = 500; + const txFee = 1; + const sendParams = createSendManyParams( + recipients, caip2ChainId, + true, + '', + sendAmtInSats, ); + const mockTxInfo: ITxInfo = { + feeRate: BigInt(1), + txFee: BigInt(txFee), + sender: sender.address, + recipients: recipients.map((recipient) => ({ + address: recipient.address, + value: BigInt(sendAmtInSats), + })), + total: BigInt(sendAmtInSats * recipients.length + txFee), + }; + + // mock createTransaction and signTransaction response const walletCreateTxSpy = jest.spyOn( BtcWallet.prototype, 'createTransaction', @@ -268,130 +337,153 @@ describe('SendManyHandler', () => { BtcWallet.prototype, 'signTransaction', ); - - const info: ITxInfo = { - feeRate: BigInt('1'), - txFee: BigInt('1'), - sender: sender.address, - recipients: [ - { - address: recipients[0].address, - value: BigInt('1000'), - }, - ], - total: BigInt('1000'), - }; - walletCreateTxSpy.mockResolvedValue({ tx: 'transaction', - txInfo: info, + txInfo: mockTxInfo, }); - walletSignTxSpy.mockResolvedValue('txId'); - await sendMany( - sender, - origin, - createSendManyParams([recipients[0]], caip2ChainId, true), - ); - - const calls = snapHelperSpy.mock.calls[0][0]; - - const recipientsPanel = calls[2]; - - expect(recipientsPanel).toStrictEqual({ - type: 'panel', - children: [ - { - type: 'row', - label: 'Recipient', - value: { - type: 'text', - value: `[${shortenAddress( - recipients[0].address, - )}](${getExplorerUrl(recipients[0].address, caip2ChainId)})`, + await sendMany(sender, origin, sendParams); + + expect(confirmDialogSpy).toHaveBeenCalledTimes(1); + expect(confirmDialogSpy).toHaveBeenCalledWith([ + // heading panel + createExpectedHeadingPanelComponent(origin), + // divider + createExpectedDividerComponent(), + // recipient panel + ...createExpectedRecipientListComponent( + mockTxInfo.recipients, + caip2ChainId, + ), + // bottom panel + { + type: 'panel', + children: [ + { + type: 'row', + label: 'Network fee', + value: { + markdown: false, + type: 'text', + value: satsToBtc(mockTxInfo.txFee, true), + }, }, - }, - { - type: 'row', - label: 'Amount', - value: { markdown: false, type: 'text', value: '0.00001000 BTC' }, - }, - ], - }); + { + type: 'row', + label: 'Total', + value: { + markdown: false, + type: 'text', + value: satsToBtc(mockTxInfo.total, true), + }, + }, + ], + }, + ]); }); - it('display `Origin` in dialog', async () => { + it('creates a comment component in the transaction confirmation dialog if a comment has been provided', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { recipients, snapHelperSpy, sender } = await prepareSendMany( + const { sender, recipients, confirmDialogSpy } = await prepareSendMany( network, caip2ChainId, ); - const walletCreateTxSpy = jest.spyOn( - BtcWallet.prototype, - 'createTransaction', - ); - const walletSignTxSpy = jest.spyOn( - BtcWallet.prototype, - 'signTransaction', - ); - - const info: ITxInfo = { - feeRate: BigInt('1'), - txFee: BigInt('1'), - sender: sender.address, - recipients: [ - { - address: recipients[0].address, - value: BigInt('1000'), - }, - ], - total: BigInt('1000'), - }; - - walletCreateTxSpy.mockResolvedValue({ - tx: 'transaction', - txInfo: info, - }); - - walletSignTxSpy.mockResolvedValue('txId'); + const comment = 'test comment'; await sendMany( sender, origin, - createSendManyParams([recipients[0]], caip2ChainId, true), + createSendManyParams(recipients, caip2ChainId, true, comment), ); - const calls = snapHelperSpy.mock.calls[0][0]; + const calls = confirmDialogSpy.mock.calls[0][0]; - const introPanel = calls[0]; + expect(calls.length).toBeGreaterThan(0); + const lastPanel = calls[calls.length - 1]; - expect(introPanel).toStrictEqual({ + expect(lastPanel).toStrictEqual({ type: 'panel', children: [ { - type: 'heading', - value: 'Send Request', + type: 'row', + label: 'Comment', + value: { markdown: false, type: 'text', value: comment }, }, { - type: 'text', - value: - "Review the request before proceeding. Once the transaction is made, it's irreversible.", + type: 'row', + label: 'Network fee', + value: { markdown: false, type: 'text', value: expect.any(String) }, }, { type: 'row', - label: 'Requested by', - value: { - type: 'text', - value: origin, - markdown: false, - }, + label: 'Total', + value: { markdown: false, type: 'text', value: expect.any(String) }, }, ], }); }); + it('displays a warning dialog if the account has insufficient funds to pay the transaction', async () => { + const network = networks.testnet; + const caip2ChainId = Caip2ChainId.Testnet; + const { + recipients: [recipient], + sender, + getDataForTransactionSpy, + } = await prepareSendMany(network, caip2ChainId); + + const alertDialogSpy = jest.spyOn(snapUtils, 'alertDialog'); + alertDialogSpy.mockReturnThis(); + + // force account to have insufficient funds + getDataForTransactionSpy.mockReset().mockResolvedValue({ + data: { + utxos: [], + }, + }); + const sendAmtInSats = 500; + + await expect( + sendMany( + sender, + origin, + createSendManyParams( + [recipient], + caip2ChainId, + true, + '', + sendAmtInSats, + ), + ), + ).rejects.toThrow(InsufficientFundsError); + expect(alertDialogSpy).toHaveBeenCalledTimes(1); + expect(alertDialogSpy).toHaveBeenCalledWith([ + // heading panel + createExpectedHeadingPanelComponent(origin, false), + // divider + createExpectedDividerComponent(), + // recipient panel + ...createExpectedRecipientListComponent( + [ + { + address: recipient.address, + value: BigInt(sendAmtInSats), + }, + ], + caip2ChainId, + ), + // warning message + { + markdown: false, + type: 'text', + value: + 'You do not have enough BTC in your account to pay for transaction amount or transaction fees on Bitcoin network.', + }, + ]); + }); + it('throws InvalidParamsError when request parameter is not correct', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; @@ -504,11 +596,11 @@ describe('SendManyHandler', () => { it('throws UserRejectedRequestError error if user denied the transaction', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { snapHelperSpy, sender, recipients } = await prepareSendMany( + const { confirmDialogSpy, sender, recipients } = await prepareSendMany( network, caip2ChainId, ); - snapHelperSpy.mockResolvedValue(false); + confirmDialogSpy.mockResolvedValue(false); await expect( sendMany(sender, origin, { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 9991dc7f..5240c8d4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -21,10 +21,12 @@ import { assert, } from 'superstruct'; +import type { Recipient, Transaction } from '../bitcoin/wallet'; import { type BtcAccount, type ITxInfo, TxValidationError, + InsufficientFundsError, } from '../bitcoin/wallet'; import { Factory } from '../factory'; import { @@ -40,6 +42,7 @@ import { logger, AmountStruct, getFeeRate, + alertDialog, } from '../utils'; export const TransactionAmountStruct = refine( @@ -92,7 +95,7 @@ export async function sendMany( try { validateRequest(params, SendManyParamsStruct); - const { dryrun, scope } = params; + const { dryrun, scope, subtractFeeFrom, replaceable, comment } = params; const chainApi = Factory.createOnChainServiceProvider(scope); const wallet = Factory.createWallet(scope); @@ -111,18 +114,33 @@ export async function sendMany( data: { utxos }, } = await chainApi.getDataForTransaction(account.address); - const { tx, txInfo } = await wallet.createTransaction(account, recipients, { - utxos, - fee, - subtractFeeFrom: params.subtractFeeFrom, - replaceable: params.replaceable, - }); + let txResp: Transaction; + + try { + txResp = await wallet.createTransaction(account, recipients, { + utxos, + fee, + subtractFeeFrom, + replaceable, + }); + } catch (createTxError) { + // Wallet.createTransaction may throw an insufficient funds error + // And end-user is expected to know about it. + // Hence we display an alert dialog to indicate the issue. + if (createTxError instanceof InsufficientFundsError) { + await displayInsufficientFundsWarning(recipients, scope, origin); + } + throw createTxError; + } - if (!(await getTxConsensus(txInfo, params.comment, scope, origin))) { + if (!(await getTxConsensus(txResp.txInfo, comment, scope, origin))) { throw new UserRejectedRequestError() as unknown as Error; } - const signedTransaction = await wallet.signTransaction(account.signer, tx); + const signedTransaction = await wallet.signTransaction( + account.signer, + txResp.tx, + ); if (dryrun) { return { @@ -156,7 +174,7 @@ export async function sendMany( } /** - * Display an confirmation dialog to confirm an transaction. + * Display a confirmation dialog to confirm an transaction. * * @param info - The transaction data object contains the transaction information. * @param comment - The comment text to display. @@ -172,15 +190,13 @@ export async function getTxConsensus( ): Promise { const header = `Send Request`; const intro = `Review the request before proceeding. Once the transaction is made, it's irreversible.`; - const recipientsLabel = `Recipient`; - const amountLabel = `Amount`; const commentLabel = `Comment`; // const networkFeeRateLabel = `Network fee rate`; const networkFeeLabel = `Network fee`; const totalLabel = `Total`; const requestedByLabel = `Requested by`; - const components: Component[] = [ + let components: Component[] = [ panel([ heading(header), text(intro), @@ -189,59 +205,102 @@ export async function getTxConsensus( divider(), ]; - const isMoreThanOneRecipient = - info.recipients.length + (info.change ? 1 : 0) > 1; + components = components.concat( + buildRecipientsComponent( + info.change ? info.recipients.concat(info.change) : info.recipients, + scope, + ), + ); + + const bottomPanel: Component[] = []; + const commentText = comment.trim(); + if (commentText.length > 0) { + bottomPanel.push(row(commentLabel, text(commentText, false))); + } + + bottomPanel.push( + row(networkFeeLabel, text(`${satsToBtc(info.txFee, true)}`, false)), + ); + + bottomPanel.push( + row(totalLabel, text(`${satsToBtc(info.total, true)}`, false)), + ); + + components.push(panel(bottomPanel)); + + return (await confirmDialog(components)) as boolean; +} + +/** + * Displays an alert dialog to display the warning message of insufficient funds to pay the transaction. + * + * @param recipients - The recipient list of the request. + * @param scope - The Caip2 Chain Id of the request. + * @param origin - The origin of the request. + */ +export async function displayInsufficientFundsWarning( + recipients: Recipient[], + scope: string, + origin: string, +): Promise { + const header = `Send Request`; + const requestedByLabel = `Requested by`; + const insufficientFundsMsg = `You do not have enough BTC in your account to pay for transaction amount or transaction fees on Bitcoin network.`; - let i = 0; + let components: Component[] = [ + panel([heading(header), row(requestedByLabel, text(`${origin}`, false))]), + divider(), + ]; - const addRecipientsToComponents = (data: { - address: string; - value: bigint; - }) => { + components = components.concat(buildRecipientsComponent(recipients, scope)); + + components.push(text(`${insufficientFundsMsg}`, false)); + + await alertDialog(components); +} + +/** + * Builds a snap component to display the transcation recipient list. + * + * @param recipients - The recipient list of request. + * @param scope - The Caip2 Chain Id of request. + * @returns An array of Snap component. + */ +export function buildRecipientsComponent( + recipients: Recipient[], + scope: string, +): Component[] { + const recipientsLabel = `Recipient`; + const amountLabel = `Amount`; + + const recipientsLen = recipients.length; + const isMoreThanOneRecipient = recipientsLen > 1; + + const components: Component[] = []; + for (let idx = 0; idx < recipientsLen; idx++) { + const recipient = recipients[idx]; const recipientsPanel: Component[] = []; + recipientsPanel.push( row( isMoreThanOneRecipient - ? `${recipientsLabel} ${i + 1}` + ? `${recipientsLabel} ${idx + 1}` : recipientsLabel, text( - `[${shortenAddress(data.address)}](${getExplorerUrl( - data.address, + `[${shortenAddress(recipient.address)}](${getExplorerUrl( + recipient.address, scope, )})`, ), ), ); recipientsPanel.push( - row(amountLabel, text(satsToBtc(data.value, true), false)), + row(amountLabel, text(satsToBtc(recipient.value, true), false)), ); - i += 1; + components.push(panel(recipientsPanel)); components.push(divider()); - }; - - info.recipients.forEach(addRecipientsToComponents); - - if (info.change) { - [info.change].forEach(addRecipientsToComponents); } - const bottomPanel: Component[] = []; - if (comment.trim().length > 0) { - bottomPanel.push(row(commentLabel, text(comment.trim(), false))); - } - - bottomPanel.push( - row(networkFeeLabel, text(`${satsToBtc(info.txFee, true)}`, false)), - ); - - // bottomPanel.push(row(networkFeeRateLabel, text(`${info.feeRate}`, false))); - - bottomPanel.push( - row(totalLabel, text(`${satsToBtc(info.total, true)}`, false)), - ); - - components.push(panel(bottomPanel)); - - return (await confirmDialog(components)) as boolean; + return components; } diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts index 67c7e8f3..fb4ccfdc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts @@ -27,6 +27,8 @@ export const getBip44Deriver = jest.fn(); export const confirmDialog = jest.fn(); +export const alertDialog = jest.fn(); + export const getStateData = jest.fn(); export const setStateData = jest.fn(); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts index e6b162ef..45438d20 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts @@ -1,6 +1,6 @@ import { type SLIP10NodeInterface } from '@metamask/key-tree'; import type { Component, DialogResult, Json } from '@metamask/snaps-sdk'; -import { panel, type SnapsProvider } from '@metamask/snaps-sdk'; +import { DialogType, panel, type SnapsProvider } from '@metamask/snaps-sdk'; declare const snap: SnapsProvider; @@ -46,7 +46,25 @@ export async function confirmDialog( return snap.request({ method: 'snap_dialog', params: { - type: 'confirmation', + type: DialogType.Confirmation, + content: panel(components), + }, + }); +} + +/** + * Displays a alert dialog with the specified components. + * + * @param components - An array of components to display in the dialog. + * @returns A Promise that resolves to the result of the dialog. + */ +export async function alertDialog( + components: Component[], +): Promise { + return snap.request({ + method: 'snap_dialog', + params: { + type: DialogType.Alert, content: panel(components), }, }); From 272cdf76de7ab2cb0448faf543ddc41f35282d13 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 11 Sep 2024 15:52:55 +0800 Subject: [PATCH 129/362] chore: fix shorten address format (#238) --- merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts | 2 +- merged-packages/bitcoin-wallet-snap/src/utils/string.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts index 329d34f1..d0c6a275 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts @@ -80,6 +80,6 @@ describe('replaceMiddleChar', () => { describe('shortenAddress', () => { const str = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; it('shorten an address', () => { - expect(shortenAddress(str)).toBe('tb1qt...aeu'); + expect(shortenAddress(str)).toBe('tb1qt2m...dxaeu'); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts index 7941f936..76dec0fe 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts @@ -74,5 +74,5 @@ export function replaceMiddleChar( * @returns The formatted address. */ export function shortenAddress(address: string) { - return replaceMiddleChar(address, 5, 3); + return replaceMiddleChar(address, 7, 5); } From 8956cc503199accd38a9137a04c6f28d5a69d579 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 13 Sep 2024 17:11:30 +0200 Subject: [PATCH 130/362] 0.6.0 (#244) * 0.6.0 * chore: update manifest * chore: update changelog --------- Co-authored-by: github-actions Co-authored-by: Charly Chevalier --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 13 ++++++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 016e6ece..f3f534f3 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] + +### Added + +- Display an alert dialog when an error happens during `btc_sendmany` ([#236](https://github.com/MetaMask/snap-bitcoin-wallet/pull/236)) + +### Changed + +- Use similar shorten-address format than the extension ([#238](https://github.com/MetaMask/snap-bitcoin-wallet/pull/238)) + ## [0.5.0] ### Added @@ -164,7 +174,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.5.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.2.5...v0.3.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 5ddc3220..bd643124 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.5.0", + "version": "0.6.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 7dd7ab69..dde88c32 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.5.0", + "version": "0.6.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin Manager", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "btJxflITIeHy+uAvWfAFafYPTzjAjtlSvRgfA3gmnrs=", + "shasum": "2WoKDYtZ0/Z8yRrnxLRV1P/HEqfaO00b8yoJfQwI/wA=", "location": { "npm": { "filePath": "dist/bundle.js", From 086b0df99313cba2536b9a854c492a5a14fd9209 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 20 Sep 2024 16:15:28 +0800 Subject: [PATCH 131/362] refactor: add rpc test helper (#222) * chore: add rpc test helper * chore: lint * chore: update testhelper var name * chore: update test * chore: fix estimate fee test * chore: update jest config * fix: address comment * Update packages/snap/src/utils/account.ts Co-authored-by: Charly Chevalier --------- Co-authored-by: Charly Chevalier --- .../bitcoin-wallet-snap/jest.config.js | 3 +- .../bitcoin/chain/clients/blockchair.test.ts | 4 +- .../src/bitcoin/wallet/psbt.test.ts | 4 +- .../src/rpcs/__tests__/helper.ts | 337 ++++++++++++++++++ .../src/rpcs/estimate-fee.test.ts | 254 ++++--------- .../src/rpcs/estimate-fee.ts | 2 +- .../rpcs/get-max-spendable-balance.test.ts | 294 +++------------ .../src/rpcs/sendmany.test.ts | 229 ++---------- .../src/utils/__mocks__/snap.ts | 6 +- .../src/utils/account.test.ts | 4 +- .../bitcoin-wallet-snap/src/utils/account.ts | 10 +- .../bitcoin-wallet-snap/src/utils/fee.ts | 6 +- .../bitcoin-wallet-snap/src/utils/lock.ts | 2 +- .../bitcoin-wallet-snap/src/utils/snap.ts | 6 +- 14 files changed, 522 insertions(+), 639 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts diff --git a/merged-packages/bitcoin-wallet-snap/jest.config.js b/merged-packages/bitcoin-wallet-snap/jest.config.js index 491cb4eb..ccfae1cb 100644 --- a/merged-packages/bitcoin-wallet-snap/jest.config.js +++ b/merged-packages/bitcoin-wallet-snap/jest.config.js @@ -7,7 +7,7 @@ module.exports = { restoreMocks: true, resetMocks: true, verbose: true, - testPathIgnorePatterns: ['/node_modules/', '/__mocks__/'], + testPathIgnorePatterns: ['/node_modules/', '/__mocks__/', '/__tests__/'], 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 @@ -17,6 +17,7 @@ module.exports = { '!./src/**/index.ts', '!./src/**/__BAK__/**', '!./src/**/__mocks__/**', + '!./src/**/__tests__/**', '!./src/config/*.ts', '!./src/**/type?(s).ts', '!./src/**/exception?(s).ts', diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts index a8dde076..2c15f23f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts @@ -39,12 +39,12 @@ describe('BlockChairClient', () => { }; }; - const createAccounts = async (network, recipientCnt: number) => { + const createAccounts = async (network, recipientCount: number) => { const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); const accounts: BtcAccount[] = []; - for (let i = 0; i < recipientCnt; i++) { + for (let i = 0; i < recipientCount; i++) { accounts.push(await wallet.unlock(i, Config.wallet.defaultAccountType)); } diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts index 87ffafeb..3fa4dca8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts @@ -21,7 +21,7 @@ describe('PsbtService', () => { }; }; - const preparePsbt = async (rbfOptIn = false, inputsCnt = 2) => { + const preparePsbt = async (rbfOptIn = false, inputsCount = 2) => { const network = networks.testnet; const service = new PsbtService(network); const wallet = createMockWallet(network); @@ -48,7 +48,7 @@ describe('PsbtService', () => { ); const utxos = generateFormattedUtxos( sender.address, - inputsCnt, + inputsCount, outputVal * outputs.length + fee, outputVal * outputs.length + fee, ); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts new file mode 100644 index 00000000..0d1f81e3 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts @@ -0,0 +1,337 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import { v4 as uuidV4 } from 'uuid'; + +import { + generateBlockChairBroadcastTransactionResp, + generateBlockChairGetUtxosResp, +} from '../../../test/utils'; +import type { Utxo } from '../../bitcoin/chain'; +import { BtcOnChainService } from '../../bitcoin/chain'; +import type { BtcAccount, BtcWallet } from '../../bitcoin/wallet'; +import { Config } from '../../config'; +import { Factory } from '../../factory'; +import { KeyringStateManager } from '../../stateManagement'; +import * as snapUtils from '../../utils/snap'; + +jest.mock('../../utils/snap'); + +/** + * Create Mock Chain API Factory. + * + * @returns The spy instances of the Chain API methods - `getBalances`, `getFeeRates`, `getDataForTransaction`, `getTransactionStatus`, `broadcastTransaction`. + */ +export function createMockChainApiFactory() { + const getBalancesSpy = jest.spyOn(BtcOnChainService.prototype, 'getBalances'); + + const getFeeRatesSpy = jest.spyOn(BtcOnChainService.prototype, 'getFeeRates'); + + const getDataForTransactionSpy = jest.spyOn( + BtcOnChainService.prototype, + 'getDataForTransaction', + ); + + const getTransactionStatusSpy = jest.spyOn( + BtcOnChainService.prototype, + 'getTransactionStatus', + ); + + const broadcastTransactionSpy = jest.spyOn( + BtcOnChainService.prototype, + 'broadcastTransaction', + ); + + return { + getDataForTransactionSpy, + broadcastTransactionSpy, + getTransactionStatusSpy, + getBalancesSpy, + getFeeRatesSpy, + }; +} + +/** + * Generate UTXOs for a given address. + * + * @param address - The address to generate UTXOs for. + * @param counter - The number of UTXOs to generate. + * @param minVal - The minimum value of the UTXOs. + * @param maxVal - The maximum value of the UTXOs. + * @returns The generated UTXOs. + */ +export function createMockGetDataForTransactionResp( + address: string, + counter: number, + minVal = 10000, + maxVal = 100000, +) { + const mockResponse = generateBlockChairGetUtxosResp( + address, + counter, + minVal, + maxVal, + ); + + let total = 0; + const data = mockResponse.data[address].utxo.map((utxo) => { + const { value } = utxo; + total += value; + return { + block: utxo.block_id, + txHash: utxo.transaction_hash, + index: utxo.index, + value, + }; + }); + + return { + data, + total, + }; +} + +/** + * Create a mock `keyringAccount`. + * + * @param account - The `BtcAccount` object. + * @param caip2ChainId - The Caip2 Chain ID. + * @returns The `keyringAccount` and `getWalletSpy`. + */ +export async function createMockKeyringAccount( + account: BtcAccount, + caip2ChainId: string, +) { + const getWalletSpy = jest.spyOn(KeyringStateManager.prototype, 'getWallet'); + + const keyringAccount = { + type: account.type, + id: uuidV4(), + address: account.address, + options: { + scope: caip2ChainId, + index: account.index, + }, + methods: ['btc_sendmany'], + } as unknown as KeyringAccount; + + getWalletSpy.mockResolvedValue({ + account: keyringAccount, + hdPath: `m/0'/0/${account.index}`, + index: account.index, + scope: caip2ChainId, + }); + + return { + getWalletSpy, + keyringAccount, + }; +} + +/** + * Create a mock `confirmDialog`. + * + * @returns The `confirmDialogSpy`. + */ +export function createMockConfirmDialog() { + const confirmDialogSpy = jest.spyOn(snapUtils, 'confirmDialog'); + return confirmDialogSpy; +} + +/** + * Create a mock `alertDialog`. + * + * @returns The `alertDialogSpy`. + */ +export function createMockAlertDialog() { + const alertDialogSpy = jest.spyOn(snapUtils, 'alertDialog'); + return alertDialogSpy; +} + +export type AccountTestCreateOption = { + caip2ChainId: string; +}; + +export type EstimateFeeTestCreateOption = AccountTestCreateOption & { + feeRate: number; + utxoCount: number; + utxoMinVal: number; + utxoMaxVal: number; +}; + +export type SendManyCreateOption = EstimateFeeTestCreateOption & { + recipientCount: number; +}; + +export class AccountTest { + testCase: AccountTestCreateOption; + + keyringAccount: KeyringAccount; + + sender: BtcAccount; + + wallet: BtcWallet; + + getWalletSpy: jest.SpyInstance; + + constructor(testCase: AccountTestCreateOption) { + this.testCase = testCase; + } + + async setup() { + const { caip2ChainId } = this.testCase; + this.wallet = Factory.createWallet(caip2ChainId); + this.sender = await this.wallet.unlock(0, Config.wallet.defaultAccountType); + + const { keyringAccount, getWalletSpy } = await createMockKeyringAccount( + this.sender, + caip2ChainId, + ); + + this.keyringAccount = keyringAccount; + this.getWalletSpy = getWalletSpy; + } + + async setupAccountNotFoundTest() { + this.getWalletSpy.mockReset().mockResolvedValue(null); + } + + async setupAccountNotMatchingTest() { + const unmatchAccount = await this.wallet.unlock( + this.sender.index + 1, + Config.wallet.defaultAccountType, + ); + + this.getWalletSpy.mockReset().mockResolvedValue({ + account: { + ...this.keyringAccount, + address: unmatchAccount.address, + }, + hdPath: `m/0'/0/${this.sender.index}`, + index: this.sender.index, + scope: this.testCase.caip2ChainId, + }); + } +} + +export class EstimateFeeTest extends AccountTest { + testCase: EstimateFeeTestCreateOption; + + getFeeRatesSpy: jest.SpyInstance; + + getDataForTransactionSpy: jest.SpyInstance; + + utxos: { + list: Utxo[]; + total: number; + }; + + constructor(testCase: EstimateFeeTestCreateOption) { + super(testCase); + const { getDataForTransactionSpy, getFeeRatesSpy } = + createMockChainApiFactory(); + this.getFeeRatesSpy = getFeeRatesSpy; + this.getDataForTransactionSpy = getDataForTransactionSpy; + this.utxos = { + list: [], + total: 0, + }; + } + + protected createUtxos() { + const { data: utxoDataList, total: utxoTotalValue } = + createMockGetDataForTransactionResp( + this.sender.address, + this.testCase.utxoCount, + this.testCase.utxoMinVal, + this.testCase.utxoMaxVal, + ); + this.utxos.list = utxoDataList; + this.utxos.total = utxoTotalValue; + this.getDataForTransactionSpy.mockResolvedValue({ + data: { + utxos: this.utxos.list, + }, + }); + } + + async setup() { + await super.setup(); + + this.createUtxos(); + + this.getFeeRatesSpy.mockResolvedValue({ + fees: [ + { + type: Config.defaultFeeRate, + rate: BigInt(this.testCase.feeRate), + }, + ], + }); + } + + async setupNoFeeAvailableTest() { + this.getFeeRatesSpy.mockReset().mockResolvedValue({ + fees: [], + }); + } +} + +export class GetMaxSpendableBalanceTest extends EstimateFeeTest {} + +export class SendManyTest extends EstimateFeeTest { + recipients: BtcAccount[]; + + testCase: SendManyCreateOption; + + broadcastTransactionSpy: jest.SpyInstance; + + confirmDialogSpy: jest.SpyInstance; + + alertDialogSpy: jest.SpyInstance; + + constructor(testCase: SendManyCreateOption) { + super(testCase); + const { broadcastTransactionSpy } = createMockChainApiFactory(); + this.broadcastTransactionSpy = broadcastTransactionSpy; + this.confirmDialogSpy = createMockConfirmDialog(); + this.alertDialogSpy = createMockAlertDialog(); + } + + async setup() { + await super.setup(); + this.recipients = await this.createMockRecipients( + this.testCase.recipientCount, + ); + this.broadcastTransactionSpy.mockResolvedValue({ + transactionId: this.broadCastTxResp, + }); + // expect to be override by the test case + this.confirmDialogSpy.mockResolvedValue(true); + this.alertDialogSpy.mockReturnThis(); + } + + async setupUserDeniedTest() { + this.confirmDialogSpy.mockReset().mockResolvedValue(false); + } + + async setupInsufficientFundsTest() { + this.getDataForTransactionSpy.mockReset().mockResolvedValue({ + data: { + utxos: [], + }, + }); + } + + async createMockRecipients(recipientCount: number) { + const recipients: BtcAccount[] = []; + for (let i = 1; i < recipientCount + 1; i++) { + recipients.push( + await this.wallet.unlock(i, Config.wallet.defaultAccountType), + ); + } + return recipients; + } + + get broadCastTxResp() { + return generateBlockChairBroadcastTransactionResp().data.transaction_hash; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.test.ts index e89adde4..2371a6ee 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.test.ts @@ -1,21 +1,11 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; import { InvalidParamsError } from '@metamask/snaps-sdk'; -import { networks } from 'bitcoinjs-lib'; import { v4 as uuidV4 } from 'uuid'; -import { generateBlockChairGetUtxosResp } from '../../test/utils'; -import { BtcOnChainService } from '../bitcoin/chain'; -import { - BtcAccountDeriver, - BtcWallet, - CoinSelectService, - TxValidationError, -} from '../bitcoin/wallet'; -import { Config } from '../config'; +import { CoinSelectService, TxValidationError } from '../bitcoin/wallet'; import { Caip2ChainId } from '../constants'; import { AccountNotFoundError } from '../exceptions'; -import { KeyringStateManager } from '../stateManagement'; -import { satsToBtc } from '../utils'; +import { logger, satsToBtc } from '../utils'; +import { EstimateFeeTest } from './__tests__/helper'; import type { EstimateFeeParams } from './estimate-fee'; import { estimateFee } from './estimate-fee'; @@ -24,134 +14,70 @@ jest.mock('../utils/snap'); describe('EstimateFeeHandler', () => { describe('estimateFee', () => { - const createMockChainApiFactory = () => { - const getFeeRatesSpy = jest.spyOn( - BtcOnChainService.prototype, - 'getFeeRates', - ); - const getDataForTransactionSpy = jest.spyOn( - BtcOnChainService.prototype, - 'getDataForTransaction', - ); - - return { - getDataForTransactionSpy, - getFeeRatesSpy, - }; - }; - - const createMockDeriver = (network) => { - return { - instance: new BtcAccountDeriver(network), - }; - }; - - const getHdPath = (index: number) => { - return `m/0'/0/${index}`; - }; - - const createAccount = async (network, caip2ChainId: string) => { - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const sender = await wallet.unlock(0, Config.wallet.defaultAccountType); - const getWalletSpy = jest.spyOn( - KeyringStateManager.prototype, - 'getWallet', - ); - - const keyringAccount = { - type: sender.type, - id: uuidV4(), - address: sender.address, - options: { - scope: caip2ChainId, - index: sender.index, - }, - methods: ['btc_sendmany'], - } as unknown as KeyringAccount; - - getWalletSpy.mockResolvedValue({ - account: keyringAccount, - hdPath: getHdPath(sender.index), - index: sender.index, - scope: caip2ChainId, + const prepareEstimateFee = async ( + caip2ChainId: string, + feeRate = 1, + utxoCount = 10, + utxoMinVal = 100000, + utxoMaxVal = 100000, + ) => { + const testHelper = new EstimateFeeTest({ + caip2ChainId, + utxoCount, + utxoMinVal, + utxoMaxVal, + feeRate, }); + await testHelper.setup(); - return { - sender, - getWalletSpy, - keyringAccount, - wallet, - }; + return testHelper; }; - const createMockGetDataForTransactionResp = ( - address: string, - counter: number, - ) => { - const mockResponse = generateBlockChairGetUtxosResp( - address, - counter, + it('returns fee correctly', async () => { + // Create test with 1 utxos of 100000 sats + const { keyringAccount } = await prepareEstimateFee( + Caip2ChainId.Testnet, + 1, + 1, 100000, 100000, ); - let total = 0; - const data = mockResponse.data[address].utxo.map((utxo) => { - const { value } = utxo; - total += value; - return { - block: utxo.block_id, - txHash: utxo.transaction_hash, - index: utxo.index, - value, - }; + + const result = await estimateFee({ + account: keyringAccount.id, + // spend 10000 sats to make sure we have change + amount: satsToBtc(10000), }); - return { - data, - total, - }; - }; + expect(result).toStrictEqual({ + fee: { + // 1 input = 63 bytes + // 1 output = 31 bytes + // 1 change = 34 bytes + // 1 overhead = 10 + // FeeRate * (1 input bytes + 1 output bytes + overhead) = 1 * (63 + 34 + 10) = 138 sats + amount: satsToBtc(138), + unit: 'BTC', + }, + }); + }); + + it('does not throw error if the account has insufficient funds to pay the tx fee', async () => { + // Create test with 1 utxos of 1000 sats, to make sure the account has insufficient funds to pay the tx fee + const { keyringAccount } = await prepareEstimateFee( + Caip2ChainId.Testnet, + 1, + 1, + 1000, + 1000, + ); - const createMockCoinSelectService = () => { const coinSelectServiceSpy = jest.spyOn( CoinSelectService.prototype, 'selectCoins', ); - return { - coinSelectServiceSpy, - }; - }; - - it('returns fee correctly', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { coinSelectServiceSpy } = createMockCoinSelectService(); - const { sender, keyringAccount } = await createAccount( - network, - caip2ChainId, - ); - const { data: utxoDataList } = createMockGetDataForTransactionResp( - sender.address, - 10, - ); - const { getDataForTransactionSpy, getFeeRatesSpy } = - createMockChainApiFactory(); - getDataForTransactionSpy.mockResolvedValue({ - data: { - utxos: utxoDataList, - }, - }); - getFeeRatesSpy.mockResolvedValue({ - fees: [ - { - type: Config.defaultFeeRate, - rate: BigInt(1), - }, - ], - }); - const expectedFee = 200; + const expectedFee = 2000; coinSelectServiceSpy.mockReturnValue({ inputs: [], outputs: [], @@ -160,17 +86,18 @@ describe('EstimateFeeHandler', () => { const result = await estimateFee({ account: keyringAccount.id, - amount: '0.0001', + amount: '1', }); + expect(logger.warn).toHaveBeenCalledWith( + 'No input or output found, fee estimation might be inaccurate', + ); expect(result).toStrictEqual({ fee: { - amount: expect.any(String), + amount: satsToBtc(expectedFee), unit: 'BTC', }, }); - - expect(result.fee.amount).toBe(satsToBtc(expectedFee)); }); it('throws `InvalidParamsError` when the request parameter is not correct', async () => { @@ -182,11 +109,8 @@ describe('EstimateFeeHandler', () => { }); it('throws `AccountNotFoundError` if the account does not exist', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { getWalletSpy } = await createAccount(network, caip2ChainId); - - getWalletSpy.mockReset().mockResolvedValue(null); + const helper = await prepareEstimateFee(Caip2ChainId.Testnet); + await helper.setupAccountNotFoundTest(); await expect( estimateFee({ @@ -197,77 +121,31 @@ describe('EstimateFeeHandler', () => { }); it('throws `AccountNotFoundError` if the derived account is not matching with the account from state', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { getWalletSpy, keyringAccount, wallet, sender } = - await createAccount(network, caip2ChainId); - - // force state manager to return an account with same hd index but different address, to reproduce an case that the derived account address is not match with the state data - const unmatchAccount = await wallet.unlock( - 1, - Config.wallet.defaultAccountType, - ); - getWalletSpy.mockReset().mockResolvedValue({ - account: { - ...keyringAccount, - address: unmatchAccount.address, - }, - hdPath: getHdPath(sender.index), - index: sender.index, - scope: caip2ChainId, - }); + const helper = await prepareEstimateFee(Caip2ChainId.Testnet); + await helper.setupAccountNotMatchingTest(); await expect( estimateFee({ - account: keyringAccount.id, + account: helper.keyringAccount.id, amount: '0.0001', }), ).rejects.toThrow(AccountNotFoundError); }); it('throws `Failed to estimate fee` error if no fee rate is returned from the chain service', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { keyringAccount } = await createAccount(network, caip2ChainId); - const { getFeeRatesSpy } = createMockChainApiFactory(); - getFeeRatesSpy.mockResolvedValue({ - fees: [], - }); + const helper = await prepareEstimateFee(Caip2ChainId.Testnet); + await helper.setupNoFeeAvailableTest(); await expect( estimateFee({ - account: keyringAccount.id, + account: helper.keyringAccount.id, amount: '0.0001', }), ).rejects.toThrow('Failed to estimate fee'); }); it('throws `Transaction amount too small` error if amount to estimate for is considered dust', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, keyringAccount } = await createAccount( - network, - caip2ChainId, - ); - const { data: utxoDataList } = createMockGetDataForTransactionResp( - sender.address, - 10, - ); - const { getDataForTransactionSpy, getFeeRatesSpy } = - createMockChainApiFactory(); - getDataForTransactionSpy.mockResolvedValue({ - data: { - utxos: utxoDataList, - }, - }); - getFeeRatesSpy.mockResolvedValue({ - fees: [ - { - type: Config.defaultFeeRate, - rate: BigInt(1), - }, - ], - }); + const { keyringAccount } = await prepareEstimateFee(Caip2ChainId.Testnet); await expect( estimateFee({ diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts index e1c512c1..edfa45c5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts @@ -94,7 +94,7 @@ export async function estimateFee(params: EstimateFeeParams) { // - There is no output when computing the estimation // // NOTE: It is by design that we do not raise any error for now - if (!result.inputs || !result.outputs) { + if (result.inputs.length === 0 || result.outputs.length === 0) { logger.warn( 'No input or output found, fee estimation might be inaccurate', ); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.test.ts index ad7b2cdd..6d828ed5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.test.ts @@ -1,16 +1,11 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; import { InvalidParamsError } from '@metamask/snaps-sdk'; -import { networks } from 'bitcoinjs-lib'; import { v4 as uuidV4 } from 'uuid'; -import { generateBlockChairGetUtxosResp } from '../../test/utils'; -import { BtcOnChainService } from '../bitcoin/chain'; -import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; -import { Config } from '../config'; +import { BtcWallet } from '../bitcoin/wallet'; import { Caip2ChainId } from '../constants'; import { AccountNotFoundError } from '../exceptions'; -import { KeyringStateManager } from '../stateManagement'; import { btcToSats, satsToBtc } from '../utils'; +import { GetMaxSpendableBalanceTest } from './__tests__/helper'; import type { GetMaxSpendableBalanceParams } from './get-max-spendable-balance'; import { getMaxSpendableBalance } from './get-max-spendable-balance'; @@ -19,126 +14,29 @@ jest.mock('../utils/snap'); describe('GetMaxSpendableBalanceHandler', () => { describe('getMaxSpendableBalance', () => { - const createMockChainApiFactory = () => { - const getFeeRatesSpy = jest.spyOn( - BtcOnChainService.prototype, - 'getFeeRates', - ); - const getDataForTransactionSpy = jest.spyOn( - BtcOnChainService.prototype, - 'getDataForTransaction', - ); - - return { - getDataForTransactionSpy, - getFeeRatesSpy, - }; - }; - - const createMockDeriver = (network) => { - return { - instance: new BtcAccountDeriver(network), - }; - }; - - const getHdPath = (index: number) => { - return `m/0'/0/${index}`; - }; - - const createAccount = async (network, caip2ChainId: string) => { - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const sender = await wallet.unlock(0, Config.wallet.defaultAccountType); - const getWalletSpy = jest.spyOn( - KeyringStateManager.prototype, - 'getWallet', - ); - - const keyringAccount = { - type: sender.type, - id: uuidV4(), - address: sender.address, - options: { - scope: caip2ChainId, - index: sender.index, - }, - methods: ['btc_sendmany'], - } as unknown as KeyringAccount; - - getWalletSpy.mockResolvedValue({ - account: keyringAccount, - hdPath: getHdPath(sender.index), - index: sender.index, - scope: caip2ChainId, - }); - - return { - sender, - getWalletSpy, - keyringAccount, - wallet, - }; - }; - - const createMockGetDataForTransactionResp = ( - address: string, - counter: number, - minVal = 10000, - maxVal = 100000, + const prepareGetMaxSpendableBalance = async ( + caip2ChainId: string, + feeRate = 1, + utxoCount = 10, + utxoMinVal = 100000, + utxoMaxVal = 100000, ) => { - const mockResponse = generateBlockChairGetUtxosResp( - address, - counter, - minVal, - maxVal, - ); - let total = 0; - const data = mockResponse.data[address].utxo.map((utxo) => { - const { value } = utxo; - total += value; - return { - block: utxo.block_id, - txHash: utxo.transaction_hash, - index: utxo.index, - value, - }; + const testHelper = new GetMaxSpendableBalanceTest({ + caip2ChainId, + utxoCount, + utxoMinVal, + utxoMaxVal, + feeRate, }); + await testHelper.setup(); - return { - data, - total, - }; + return testHelper; }; it('returns the maximum spendable balance correctly', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, keyringAccount } = await createAccount( - network, - caip2ChainId, + const { keyringAccount, utxos } = await prepareGetMaxSpendableBalance( + Caip2ChainId.Testnet, ); - const { data: utxoDataList, total: utxoTotalValue } = - createMockGetDataForTransactionResp( - sender.address, - 100, - 10000, - 10000000000, - ); - const { getDataForTransactionSpy, getFeeRatesSpy } = - createMockChainApiFactory(); - getDataForTransactionSpy.mockResolvedValue({ - data: { - utxos: utxoDataList, - }, - }); - getFeeRatesSpy.mockResolvedValue({ - fees: [ - { - type: Config.defaultFeeRate, - rate: BigInt(15), - }, - ], - }); const result = await getMaxSpendableBalance({ account: keyringAccount.id, @@ -158,45 +56,27 @@ describe('GetMaxSpendableBalanceHandler', () => { // If all UTXOs are above the dust threshold, then the total balance should be equal to the sum of the fee and the spendable balance. expect( btcToSats(result.fee.amount) + btcToSats(result.balance.amount), - ).toStrictEqual(BigInt(utxoTotalValue)); + ).toStrictEqual(BigInt(utxos.total)); }); it('estimates the maximum spendable balance by excluding any UTXO whose value is equal to or less than the dust threshold', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, keyringAccount } = await createAccount( - network, - caip2ChainId, - ); - const { data: utxoDataList } = createMockGetDataForTransactionResp( - sender.address, - 10, + const feeRate = 104; + const { utxos, keyringAccount } = await prepareGetMaxSpendableBalance( + Caip2ChainId.Testnet, + feeRate, + 100, + 10000, + 10000000000, ); + // When using 104 satoshis per byte and 1 input contains 63 bytes, the dust threshold (fee for using this UTXO) will be 104 * 63 bytes = 6552 satoshis. Any UTXO less than this amount will be discarded as it would be a waste to use it. - const feeRate = 104; const utxoInputBytesSize = 63; const dustThreshold = utxoInputBytesSize * feeRate; - utxoDataList[0].value = dustThreshold; - const utxoTotalValue = utxoDataList.reduce( - (acc, utxo) => acc + utxo.value, - 0, - ); - - const { getDataForTransactionSpy, getFeeRatesSpy } = - createMockChainApiFactory(); - getDataForTransactionSpy.mockResolvedValue({ - data: { - utxos: utxoDataList, - }, - }); - getFeeRatesSpy.mockResolvedValue({ - fees: [ - { - type: Config.defaultFeeRate, - rate: BigInt(feeRate), - }, - ], - }); + // An assertion to make sure the utxos.length is > 0. + expect(utxos.list.length).toBeGreaterThan(0); + // We set the first UTXO to be the dust threshold and re-calculate the total. + utxos.total = utxos.total - utxos.list[0].value + dustThreshold; + utxos.list[0].value = dustThreshold; const result = await getMaxSpendableBalance({ account: keyringAccount.id, @@ -205,48 +85,34 @@ describe('GetMaxSpendableBalanceHandler', () => { // One of our UTXO was below the dust threshold, then the total balance will not count this UTXO, thus we need to subtract it from the total UTXO balance. expect( btcToSats(result.fee.amount) + btcToSats(result.balance.amount), - ).toStrictEqual(BigInt(utxoTotalValue) - BigInt(dustThreshold)); + ).toStrictEqual(BigInt(utxos.total) - BigInt(dustThreshold)); }); it.each([ { - utxoCnt: 1, + utxoCount: 1, utxoVal: 200, }, { - utxoCnt: 0, + utxoCount: 0, utxoVal: 1, }, ])( "returns a zero-spendable-balance if the account's balance is too small or the account does not have UTXO", - async ({ utxoCnt, utxoVal }: { utxoCnt: number; utxoVal: number }) => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, keyringAccount } = await createAccount( - network, - caip2ChainId, - ); - const { data: utxoDataList } = createMockGetDataForTransactionResp( - sender.address, - utxoCnt, + async ({ + utxoCount, + utxoVal, + }: { + utxoCount: number; + utxoVal: number; + }) => { + const { keyringAccount } = await prepareGetMaxSpendableBalance( + Caip2ChainId.Testnet, + 1, + utxoCount, utxoVal, utxoVal, ); - const { getDataForTransactionSpy, getFeeRatesSpy } = - createMockChainApiFactory(); - getDataForTransactionSpy.mockResolvedValue({ - data: { - utxos: utxoDataList, - }, - }); - getFeeRatesSpy.mockResolvedValue({ - fees: [ - { - type: Config.defaultFeeRate, - rate: BigInt(1), - }, - ], - }); const result = await getMaxSpendableBalance({ account: keyringAccount.id, @@ -272,11 +138,8 @@ describe('GetMaxSpendableBalanceHandler', () => { }); it('throws `AccountNotFoundError` if the account does not exist', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { getWalletSpy } = await createAccount(network, caip2ChainId); - - getWalletSpy.mockReset().mockResolvedValue(null); + const helper = await prepareGetMaxSpendableBalance(Caip2ChainId.Testnet); + await helper.setupAccountNotFoundTest(); await expect( getMaxSpendableBalance({ @@ -286,81 +149,38 @@ describe('GetMaxSpendableBalanceHandler', () => { }); it('throws `AccountNotFoundError` if the derived account if the derived account is not matching with the account from state', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { getWalletSpy, keyringAccount, wallet, sender } = - await createAccount(network, caip2ChainId); - - // force state manager to return an account with same hd index but different address, to reproduce an case that the derived account address is not match with the state data - const unmatchAccount = await wallet.unlock( - 1, - Config.wallet.defaultAccountType, - ); - getWalletSpy.mockReset().mockResolvedValue({ - account: { - ...keyringAccount, - address: unmatchAccount.address, - }, - hdPath: getHdPath(sender.index), - index: sender.index, - scope: caip2ChainId, - }); + const helper = await prepareGetMaxSpendableBalance(Caip2ChainId.Testnet); + await helper.setupAccountNotMatchingTest(); await expect( getMaxSpendableBalance({ - account: keyringAccount.id, + account: helper.keyringAccount.id, }), ).rejects.toThrow(AccountNotFoundError); }); it('throws `Failed to get max spendable balance` error if no fee rate is returned from the chain service', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { keyringAccount } = await createAccount(network, caip2ChainId); - const { getFeeRatesSpy } = createMockChainApiFactory(); - getFeeRatesSpy.mockResolvedValue({ - fees: [], - }); + const helper = await prepareGetMaxSpendableBalance(Caip2ChainId.Testnet); + await helper.setupNoFeeAvailableTest(); await expect( getMaxSpendableBalance({ - account: keyringAccount.id, + account: helper.keyringAccount.id, }), ).rejects.toThrow('Failed to get max spendable balance'); }); it('throws `Failed to get max spendable balance` error if another error was thrown during the estimation', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { sender } = await createAccount(network, caip2ChainId); - const { data: utxoDataList } = createMockGetDataForTransactionResp( - sender.address, - 1, - 10000, - 10000, + const { keyringAccount } = await prepareGetMaxSpendableBalance( + Caip2ChainId.Testnet, ); - const { getDataForTransactionSpy, getFeeRatesSpy } = - createMockChainApiFactory(); - getDataForTransactionSpy.mockResolvedValue({ - data: { - utxos: utxoDataList, - }, - }); - getFeeRatesSpy.mockResolvedValue({ - fees: [ - { - type: Config.defaultFeeRate, - rate: BigInt(1), - }, - ], - }); jest .spyOn(BtcWallet.prototype, 'estimateFee') .mockRejectedValue(new Error('error')); await expect( getMaxSpendableBalance({ - account: uuidV4(), + account: keyringAccount.id, }), ).rejects.toThrow('Failed to get max spendable balance'); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index aae5fffa..65f43f27 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -2,26 +2,17 @@ import { InvalidParamsError, UserRejectedRequestError, } from '@metamask/snaps-sdk'; -import { networks } from 'bitcoinjs-lib'; -import { v4 as uuidV4 } from 'uuid'; -import { - generateBlockChairBroadcastTransactionResp, - generateBlockChairGetUtxosResp, -} from '../../test/utils'; -import { BtcOnChainService } from '../bitcoin/chain'; import type { BtcAccount, Recipient } from '../bitcoin/wallet'; import { - BtcAccountDeriver, BtcWallet, type ITxInfo, TxValidationError, InsufficientFundsError, } from '../bitcoin/wallet'; -import { Config } from '../config'; import { Caip2ChainId } from '../constants'; import { getExplorerUrl, shortenAddress, satsToBtc } from '../utils'; -import * as snapUtils from '../utils/snap'; +import { SendManyTest } from './__tests__/helper'; import { type SendManyParams, sendMany } from './sendmany'; jest.mock('../utils/logger'); @@ -31,66 +22,6 @@ describe('SendManyHandler', () => { describe('sendMany', () => { const origin = 'http://localhost:3000'; - const createMockChainApiFactory = () => { - const getFeeRatesSpy = jest.spyOn( - BtcOnChainService.prototype, - 'getFeeRates', - ); - const broadcastTransactionSpy = jest.spyOn( - BtcOnChainService.prototype, - 'broadcastTransaction', - ); - const getDataForTransactionSpy = jest.spyOn( - BtcOnChainService.prototype, - 'getDataForTransaction', - ); - - return { - getDataForTransactionSpy, - getFeeRatesSpy, - broadcastTransactionSpy, - }; - }; - - const createMockDeriver = (network) => { - return { - instance: new BtcAccountDeriver(network), - }; - }; - - const createSenderNRecipients = async ( - network, - caip2ChainId: string, - recipientCnt: number, - ) => { - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const sender = await wallet.unlock(0, Config.wallet.defaultAccountType); - - const keyringAccount = { - type: sender.type, - id: uuidV4(), - address: sender.address, - options: { - scope: caip2ChainId, - index: sender.index, - }, - methods: ['btc_sendmany'], - }; - const recipients: BtcAccount[] = []; - for (let i = 1; i < recipientCnt + 1; i++) { - recipients.push( - await wallet.unlock(i, Config.wallet.defaultAccountType), - ); - } - - return { - sender, - keyringAccount, - recipients, - }; - }; - const createSendManyParams = ( recipients: BtcAccount[], caip2ChainId: string, @@ -111,69 +42,25 @@ describe('SendManyHandler', () => { } as unknown as SendManyParams; }; - const createMockGetDataForTransactionResp = ( - address: string, - counter: number, + const prepareSendMany = async ( + caip2ChainId: string, + recipientCount = 10, + feeRate = 1, + utxoCount = 10, + utxoMinVal = 100000, + utxoMaxVal = 100000, ) => { - const mockResponse = generateBlockChairGetUtxosResp( - address, - counter, - 100000, - 100000, - ); - return mockResponse.data[address].utxo.map((utxo) => ({ - block: utxo.block_id, - txHash: utxo.transaction_hash, - index: utxo.index, - value: utxo.value, - })); - }; - - const createMockBroadcastTransactionResp = () => { - return generateBlockChairBroadcastTransactionResp().data.transaction_hash; - }; - - const prepareSendMany = async (network, caip2ChainId) => { - const { - getDataForTransactionSpy, - getFeeRatesSpy, - broadcastTransactionSpy, - } = createMockChainApiFactory(); - const confirmDialogSpy = jest.spyOn(snapUtils, 'confirmDialog'); - - const { sender, keyringAccount, recipients } = - await createSenderNRecipients(network, caip2ChainId, 2); - - const broadcastResp = createMockBroadcastTransactionResp(); - - getDataForTransactionSpy.mockResolvedValue({ - data: { - utxos: createMockGetDataForTransactionResp(sender.address, 10), - }, - }); - getFeeRatesSpy.mockResolvedValue({ - fees: [ - { - type: Config.defaultFeeRate, - rate: BigInt(1), - }, - ], - }); - broadcastTransactionSpy.mockResolvedValue({ - transactionId: broadcastResp, + const testHelper = new SendManyTest({ + caip2ChainId, + utxoCount, + utxoMinVal, + utxoMaxVal, + feeRate, + recipientCount, }); - confirmDialogSpy.mockResolvedValue(true); + await testHelper.setup(); - return { - sender, - keyringAccount, - recipients, - broadcastResp, - getDataForTransactionSpy, - getFeeRatesSpy, - broadcastTransactionSpy, - confirmDialogSpy, - }; + return testHelper; }; // this method is to create a expected response of a divider component @@ -263,16 +150,15 @@ describe('SendManyHandler', () => { }; it('returns correct result', async () => { - const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; const { sender, recipients, - broadcastResp, + broadCastTxResp, getDataForTransactionSpy, getFeeRatesSpy, broadcastTransactionSpy, - } = await prepareSendMany(network, caip2ChainId); + } = await prepareSendMany(caip2ChainId); const result = await sendMany( sender, @@ -280,17 +166,16 @@ describe('SendManyHandler', () => { createSendManyParams(recipients, caip2ChainId, false), ); - expect(result).toStrictEqual({ txId: broadcastResp }); + expect(result).toStrictEqual({ txId: broadCastTxResp }); expect(getFeeRatesSpy).toHaveBeenCalledTimes(1); expect(getDataForTransactionSpy).toHaveBeenCalledTimes(1); expect(broadcastTransactionSpy).toHaveBeenCalledTimes(1); }); it('does not broadcast transaction if in dryrun mode', async () => { - const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; const { recipients, sender, broadcastTransactionSpy } = - await prepareSendMany(network, caip2ChainId); + await prepareSendMany(caip2ChainId); await sendMany( sender, @@ -302,10 +187,8 @@ describe('SendManyHandler', () => { }); it('displays a transaction confirmation dialog if the bitcoin transaction has been created successfully', async () => { - const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; const { recipients, confirmDialogSpy, sender } = await prepareSendMany( - network, caip2ChainId, ); const sendAmtInSats = 500; @@ -384,10 +267,8 @@ describe('SendManyHandler', () => { }); it('creates a comment component in the transaction confirmation dialog if a comment has been provided', async () => { - const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; const { sender, recipients, confirmDialogSpy } = await prepareSendMany( - network, caip2ChainId, ); const comment = 'test comment'; @@ -426,23 +307,16 @@ describe('SendManyHandler', () => { }); it('displays a warning dialog if the account has insufficient funds to pay the transaction', async () => { - const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; + const helper = await prepareSendMany(caip2ChainId); const { recipients: [recipient], sender, - getDataForTransactionSpy, - } = await prepareSendMany(network, caip2ChainId); + alertDialogSpy, + } = helper; - const alertDialogSpy = jest.spyOn(snapUtils, 'alertDialog'); - alertDialogSpy.mockReturnThis(); + await helper.setupInsufficientFundsTest(); - // force account to have insufficient funds - getDataForTransactionSpy.mockReset().mockResolvedValue({ - data: { - utxos: [], - }, - }); const sendAmtInSats = 500; await expect( @@ -485,9 +359,8 @@ describe('SendManyHandler', () => { }); it('throws InvalidParamsError when request parameter is not correct', async () => { - const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { sender } = await prepareSendMany(network, caip2ChainId); + const { sender } = await prepareSendMany(caip2ChainId); await expect( sendMany(sender, origin, { @@ -499,14 +372,9 @@ describe('SendManyHandler', () => { }); it('throws `Transaction must have at least one recipient` error if no recipient provided', async () => { - createMockChainApiFactory(); - const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { recipients, sender } = await createSenderNRecipients( - network, - caip2ChainId, - 0, - ); + const { sender, recipients } = await prepareSendMany(caip2ChainId, 0); + await expect( sendMany( sender, @@ -517,14 +385,9 @@ describe('SendManyHandler', () => { }); it('throws `Invalid amount, must be a positive finite number` error if receive amount is not valid', async () => { - createMockChainApiFactory(); - const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { recipients, sender } = await createSenderNRecipients( - network, - caip2ChainId, - 2, - ); + const { sender, recipients } = await prepareSendMany(caip2ChainId, 2); + await expect( sendMany(sender, origin, { ...createSendManyParams(recipients, caip2ChainId, false), @@ -557,14 +420,8 @@ describe('SendManyHandler', () => { }); it('throws `Invalid amount, out of bounds` error if receive amount is out of bounds', async () => { - createMockChainApiFactory(); - const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { recipients, sender } = await createSenderNRecipients( - network, - caip2ChainId, - 2, - ); + const { sender, recipients } = await prepareSendMany(caip2ChainId, 2); await expect( sendMany(sender, origin, { @@ -578,10 +435,9 @@ describe('SendManyHandler', () => { }); it('throws `Invalid response` error if the response is unexpected', async () => { - const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; const { sender, recipients, broadcastTransactionSpy } = - await prepareSendMany(network, caip2ChainId); + await prepareSendMany(caip2ChainId); broadcastTransactionSpy.mockResolvedValue({ transactionId: '', @@ -594,13 +450,10 @@ describe('SendManyHandler', () => { }); it('throws UserRejectedRequestError error if user denied the transaction', async () => { - const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { confirmDialogSpy, sender, recipients } = await prepareSendMany( - network, - caip2ChainId, - ); - confirmDialogSpy.mockResolvedValue(false); + const helper = await prepareSendMany(caip2ChainId); + const { sender, recipients } = helper; + await helper.setupUserDeniedTest(); await expect( sendMany(sender, origin, { @@ -610,13 +463,9 @@ describe('SendManyHandler', () => { }); it('throws `Failed to send the transaction` error if no fee rate is returned from the chain service', async () => { - const { getFeeRatesSpy } = createMockChainApiFactory(); - const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients } = await createSenderNRecipients( - network, + const { sender, recipients, getFeeRatesSpy } = await prepareSendMany( caip2ChainId, - 10, ); getFeeRatesSpy.mockResolvedValue({ fees: [], @@ -632,10 +481,9 @@ describe('SendManyHandler', () => { }); it('throws `Failed to send the transaction` error if the transaction is fail to commit', async () => { - const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; const { broadcastTransactionSpy, sender, recipients } = - await prepareSendMany(network, caip2ChainId); + await prepareSendMany(caip2ChainId); broadcastTransactionSpy.mockRejectedValue(new Error('error')); await expect( @@ -646,10 +494,9 @@ describe('SendManyHandler', () => { }); it('throws DisplayableError error message if the DisplayableError is thrown', async () => { - const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; const { broadcastTransactionSpy, sender, recipients } = - await prepareSendMany(network, caip2ChainId); + await prepareSendMany(caip2ChainId); broadcastTransactionSpy.mockRejectedValue( new TxValidationError('some tx error'), ); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts index fb4ccfdc..79c1ea81 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts @@ -6,11 +6,11 @@ import { createRandomBip32Data } from '../../../test/utils'; export const getProvider = jest.fn(); /** - * Retrieves a SLIP10NodeInterface object for the specified path and curve. + * Retrieves a `SLIP10NodeInterface` object for the specified path and curve. * - * @param path - The BIP32 derivation path for which to retrieve a SLIP10NodeInterface. + * @param path - The BIP32 derivation path for which to retrieve a `SLIP10NodeInterface`. * @param curve - The elliptic curve to use for key derivation. - * @returns A Promise that resolves to a SLIP10NodeInterface object. + * @returns A Promise that resolves to a `SLIP10NodeInterface` object. */ export async function getBip32Deriver( path: string[], diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/account.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/account.test.ts index e0065f73..7a6b33a7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/account.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/account.test.ts @@ -42,7 +42,7 @@ describe('verifyIfAccountValid', function () { } as unknown as KeyringAccount; }; - it('does not throw error if BtcAccount and KeyringAccount are valid and consistent', async function () { + it('does not throw error if `BtcAccount` object and the `KeyringAccount` object are valid and consistent', async function () { const btcAccount = await createBtcAccount(networks.testnet); const keyringAccount = createKeyringAccount( btcAccount.address, @@ -55,7 +55,7 @@ describe('verifyIfAccountValid', function () { ).not.toThrow(); }); - it('throws AccountNotFoundError if either the BtcAccount object or the KeyringAccount object is not provided', async function () { + it('throws AccountNotFoundError if either the `BtcAccount` object or the `KeyringAccount` object is not provided', async function () { const btcAccount = await createBtcAccount(networks.testnet); const keyringAccount = createKeyringAccount( btcAccount.address, diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/account.ts b/merged-packages/bitcoin-wallet-snap/src/utils/account.ts index e87ae081..9218f9b8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/account.ts @@ -4,12 +4,12 @@ import type { BtcAccount } from '../bitcoin/wallet'; import { AccountNotFoundError } from '../exceptions'; /** - * Verifies if the provided BtcAccount object and KeyringAccount object are valid and that their addresses are consistent. + * Verifies if the provided `BtcAccount` object and `KeyringAccount` object are valid and that their addresses are consistent. * - * @param account - The BtcAccount object to verify. - * @param keyringAccount - The KeyringAccount object to verify. - * @throws {AccountNotFoundError} If either the BtcAccount object or the KeyringAccount object is not provided. - * @throws {AccountNotFoundError} If the BtcAccount's address and the KeyringAccount's address are not matching. + * @param account - The `BtcAccount` object to verify. + * @param keyringAccount - The `KeyringAccount` object to verify. + * @throws {AccountNotFoundError} If either the `BtcAccount` object or the `KeyringAccount` object is not provided. + * @throws {AccountNotFoundError} If the `BtcAccount`'s address and the `KeyringAccount`'s address are not matching. */ export function verifyIfAccountValid( account: BtcAccount, diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/fee.ts b/merged-packages/bitcoin-wallet-snap/src/utils/fee.ts index a28733e7..1902ff34 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/fee.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/fee.ts @@ -6,13 +6,13 @@ import { Config } from '../config'; import { FeeRateUnavailableError } from '../exceptions'; /** - * Retrieves the fee rate from an array of Fee objects based on the fee type provided. + * Retrieves the fee rate from an array of `Fee` objects based on the fee type provided. * If no fee type is provided, the default fee rate specified in the configuration will be used. * - * @param fees - The array of Fee objects. + * @param fees - The array of `Fee` objects. * @param feeType - The fee type to retrieve the fee rate for. Default is `Config.defaultFeeRate` * @returns The fee rate for the given fee type or default fee rate. - * @throws {FeeRateUnavailableError} If no fee object is found for the provided fee type. + * @throws {FeeRateUnavailableError} If no `Fee` object is found for the provided fee type. */ export function getFeeRate( fees: Fee[], diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/lock.ts b/merged-packages/bitcoin-wallet-snap/src/utils/lock.ts index 73d0fd06..f5098f74 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/lock.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/lock.ts @@ -6,7 +6,7 @@ const saveMutex = new Mutex(); * Acquires or retrieves a lock. * * @param create - Whether to create a new lock or retrieve an existing one. - * @returns A Mutex object representing the lock. + * @returns A `Mutex` object representing the lock. */ export function acquireLock(create = false) { if (create) { diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts index 45438d20..ce238ff2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts @@ -14,11 +14,11 @@ export function getProvider(): SnapsProvider { } /** - * Retrieves a SLIP10NodeInterface object for the specified path and curve. + * Retrieves a `SLIP10NodeInterface` object for the specified path and curve. * - * @param path - The BIP32 derivation path for which to retrieve a SLIP10NodeInterface. + * @param path - The BIP32 derivation path for which to retrieve a `SLIP10NodeInterface`. * @param curve - The elliptic curve to use for key derivation. - * @returns A Promise that resolves to a SLIP10NodeInterface object. + * @returns A Promise that resolves to a `SLIP10NodeInterface` object. */ export async function getBip32Deriver( path: string[], From aae3d7cc678451eb95f0b152973c72e26941019b Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 26 Sep 2024 00:57:55 +0800 Subject: [PATCH 132/362] feat: add `QuickNode` api client (#247) * feat: add quicknode client * chore: add quicknode unit test * chore: rename quick node client instance * chore: refine qn test * chore: refine test * chore: increase test coverage * chore: fix pr comment * chore: fix lint * chore: address review comment and refactor QN client * chore: fix QN lint * chore: use config value for btc confirmation --- .../bitcoin/chain/clients/quicknode.test.ts | 450 ++++++++++++++++++ .../src/bitcoin/chain/clients/quicknode.ts | 279 +++++++++++ .../bitcoin/chain/clients/quicknode.types.ts | 92 ++++ .../bitcoin-wallet-snap/src/config.ts | 3 + .../bitcoin-wallet-snap/src/utils/error.ts | 2 +- .../test/fixtures/quicknode.json | 71 +++ .../bitcoin-wallet-snap/test/utils.ts | 146 ++++++ 7 files changed, 1042 insertions(+), 1 deletion(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.types.ts create mode 100644 merged-packages/bitcoin-wallet-snap/test/fixtures/quicknode.json diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts new file mode 100644 index 00000000..cdc3e378 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts @@ -0,0 +1,450 @@ +import type { Json } from '@metamask/utils'; +import type { Network } from 'bitcoinjs-lib'; +import { networks } from 'bitcoinjs-lib'; + +import { + generateQuickNodeGetBalanceResp, + generateQuickNodeEstimatefeeResp, + generateQuickNodeGetUtxosResp, + generateQuickNodeGetRawTransactionResp, + generateQuickNodeSendRawTransactionResp, +} from '../../../../test/utils'; +import { Config } from '../../../config'; +import { btcToSats } from '../../../utils'; +import * as asyncUtils from '../../../utils/async'; +import type { BtcAccount } from '../../wallet'; +import { BtcAccountDeriver, BtcWallet } from '../../wallet'; +import { TransactionStatus } from '../constants'; +import { DataClientError } from '../exceptions'; +import { QuickNodeClient } from './quicknode'; +import type { QuickNodeResponse } from './quicknode.types'; + +jest.mock('../../../utils/logger'); +jest.mock('../../../utils/snap'); + +describe('QuickNodeClient', () => { + const testnetEndpoint = 'https://api.quicknode.com/testnet'; + const mainnetEndpoint = 'https://api.quicknode.com/mainnet'; + + class MockQuickNodeClient extends QuickNodeClient { + async post( + body: Json, + ): Promise { + return super.post(body); + } + } + + const createMockFetch = () => { + const fetchSpy = jest.fn(); + + // eslint-disable-next-line no-restricted-globals + Object.defineProperty(global, 'fetch', { + value: fetchSpy, + }); + + return { + fetchSpy, + }; + }; + + const createAccounts = async (network: Network, recipientCnt: number) => { + const wallet = new BtcWallet(new BtcAccountDeriver(network), network); + + const accounts: BtcAccount[] = []; + for (let i = 0; i < recipientCnt; i++) { + accounts.push(await wallet.unlock(i, Config.wallet.defaultAccountType)); + } + + return { + accounts, + }; + }; + + const createQuickNodeClient = (network: Network) => { + return new MockQuickNodeClient({ + network, + testnetEndpoint, + mainnetEndpoint, + }); + }; + + const mockErrorResponse = ({ + fetchSpy, + isOk = true, + status = 200, + statusText = 'error', + errorResp = { + result: null, + error: { + code: 1, + message: 'some error', + }, + id: null, + }, + }: { + fetchSpy: jest.SpyInstance; + isOk?: boolean; + status?: number; + statusText?: string; + errorResp?: Record; + }) => { + fetchSpy.mockResolvedValueOnce({ + ok: isOk, + status, + statusText, + json: jest.fn().mockResolvedValue(errorResp), + }); + }; + + const mockApiSuccessResponse = ({ + fetchSpy, + mockResponse, + }: { + fetchSpy: jest.SpyInstance; + mockResponse: unknown; + }) => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue(mockResponse), + }); + }; + + describe('post', () => { + it('executes a request', async () => { + const { fetchSpy } = createMockFetch(); + + const mockResponse = true; + mockApiSuccessResponse({ + fetchSpy, + mockResponse, + }); + + const postBody = { + method: 'testmethod', + params: [1], + }; + + const client = createQuickNodeClient(networks.testnet); + const result = await client.post(postBody); + + expect(fetchSpy).toHaveBeenCalledWith(client.baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(postBody), + }); + expect(result).toBe(mockResponse); + }); + + it('throws `Failed to post data from quicknode` error if the http status is not 200', async () => { + const { fetchSpy } = createMockFetch(); + + mockErrorResponse({ + fetchSpy, + status: 500, + isOk: true, + errorResp: { + error: 'api error', + }, + }); + + const postBody = { + method: 'testmethod', + params: [1], + }; + + const client = createQuickNodeClient(networks.testnet); + + await expect(client.post(postBody)).rejects.toThrow( + 'Failed to post data from quicknode: api error', + ); + }); + + it('throws `Failed to post data from quicknode` error if the `response.ok` is false', async () => { + const { fetchSpy } = createMockFetch(); + + mockErrorResponse({ + fetchSpy, + status: 200, + statusText: 'api error', + isOk: false, + }); + + const postBody = { + method: 'testmethod', + params: [1], + }; + + const client = createQuickNodeClient(networks.testnet); + + await expect(client.post(postBody)).rejects.toThrow( + 'Failed to post data from quicknode: api error', + ); + }); + }); + + describe('baseUrl', () => { + it('returns the testnet api endpoint', () => { + const client = createQuickNodeClient(networks.testnet); + + expect(client.baseUrl).toStrictEqual(testnetEndpoint); + }); + + it('returns the mainnet api endpoint', () => { + const client = createQuickNodeClient(networks.bitcoin); + + expect(client.baseUrl).toStrictEqual(mainnetEndpoint); + }); + + it('throws `Invalid network` error if the given network is not supported', () => { + const client = createQuickNodeClient(networks.regtest); + + expect(() => client.baseUrl).toThrow('Invalid network'); + }); + }); + + describe('getBalances', () => { + it('returns balances', async () => { + const { fetchSpy } = createMockFetch(); + const network = networks.testnet; + const { accounts } = await createAccounts(network, 5); + const addresses = accounts.map((account) => account.address); + + const expectedResult = {}; + for (const address of addresses) { + const mockResponse = generateQuickNodeGetBalanceResp(address); + + expectedResult[address] = parseInt(mockResponse.result.balance, 10); + + mockApiSuccessResponse({ + fetchSpy, + mockResponse, + }); + } + + const client = createQuickNodeClient(network); + const result = await client.getBalances(addresses); + + expect(result).toStrictEqual(expectedResult); + }); + + // This case should never happen, but to ensure the test is 100% covered, hence we mock the processBatch to not process any request + it('assign 0 balance to address if the address cannot be found in the hashmap', async () => { + const network = networks.testnet; + const { accounts } = await createAccounts(network, 5); + const addresses = accounts.map((account) => account.address); + + jest.spyOn(asyncUtils, 'processBatch').mockReturnThis(); + + const client = createQuickNodeClient(network); + const result = await client.getBalances(addresses); + + const expectedEmptyBalances = addresses.reduce((acc, address) => { + acc[address] = 0; + return acc; + }, {}); + + expect(result).toStrictEqual(expectedEmptyBalances); + }); + + it('throws DataClientError if the api response is invalid', async () => { + const { fetchSpy } = createMockFetch(); + const network = networks.testnet; + const { accounts } = await createAccounts(network, 5); + const addresses = accounts.map((account) => account.address); + + mockErrorResponse({ + fetchSpy, + }); + + const client = createQuickNodeClient(network); + + await expect(client.getBalances(addresses)).rejects.toThrow( + DataClientError, + ); + }); + }); + + describe('getFeeRates', () => { + it('returns fee rates', async () => { + const { fetchSpy } = createMockFetch(); + const expectedFeeRate = 0.0001; + const mockResponse = generateQuickNodeEstimatefeeResp({ + feerate: expectedFeeRate, + }); + + mockApiSuccessResponse({ + fetchSpy, + mockResponse, + }); + + const client = createQuickNodeClient(networks.testnet); + const result = await client.getFeeRates(); + + expect(result).toStrictEqual({ + [Config.defaultFeeRate]: Number(btcToSats(expectedFeeRate.toString())), + }); + }); + + it('throws DataClientError if the api response is invalid', async () => { + const { fetchSpy } = createMockFetch(); + + mockErrorResponse({ + fetchSpy, + }); + + const client = createQuickNodeClient(networks.testnet); + + await expect(client.getFeeRates()).rejects.toThrow(DataClientError); + }); + }); + + describe('getUtxos', () => { + it('returns utxos', async () => { + const { fetchSpy } = createMockFetch(); + const network = networks.testnet; + const { + accounts: [{ address }], + } = await createAccounts(network, 1); + const mockResponse = generateQuickNodeGetUtxosResp({ + utxosCount: 10, + }); + const expectedResult = mockResponse.result.map((utxo) => ({ + block: utxo.height, + txHash: utxo.txid, + index: utxo.vout, + value: parseInt(utxo.value, 10), + })); + + mockApiSuccessResponse({ + fetchSpy, + mockResponse, + }); + + const client = createQuickNodeClient(network); + const result = await client.getUtxos(address); + + expect(result).toStrictEqual(expectedResult); + }); + + it('throws DataClientError if the api response is invalid', async () => { + const { fetchSpy } = createMockFetch(); + const network = networks.testnet; + const { + accounts: [{ address }], + } = await createAccounts(network, 1); + + mockErrorResponse({ + fetchSpy, + }); + + const client = createQuickNodeClient(network); + + await expect(client.getUtxos(address)).rejects.toThrow(DataClientError); + }); + }); + + describe('getTransactionStatus', () => { + const txid = + '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; + + it.each([ + Config.defaultConfirmationThreshold, + Config.defaultConfirmationThreshold + 1, + ])( + "returns `confirmed` if the transaction's confirmation number is $s", + async (confirmations: number) => { + const { fetchSpy } = createMockFetch(); + + const mockResponse = generateQuickNodeGetRawTransactionResp({ + txid, + confirmations, + }); + + mockApiSuccessResponse({ + fetchSpy, + mockResponse, + }); + + const client = createQuickNodeClient(networks.testnet); + const result = await client.getTransactionStatus(txid); + + expect(result).toStrictEqual({ + status: TransactionStatus.Confirmed, + }); + }, + ); + + it(`returns 'pending' if the transaction's confirmation number is < ${Config.defaultConfirmationThreshold}`, async () => { + const { fetchSpy } = createMockFetch(); + + const mockResponse = generateQuickNodeGetRawTransactionResp({ + txid, + confirmations: Config.defaultConfirmationThreshold - 1, + }); + + mockApiSuccessResponse({ + fetchSpy, + mockResponse, + }); + + const client = createQuickNodeClient(networks.testnet); + const result = await client.getTransactionStatus(txid); + + expect(result).toStrictEqual({ + status: TransactionStatus.Pending, + }); + }); + + it('throws DataClientError if the api response is invalid', async () => { + const { fetchSpy } = createMockFetch(); + + mockErrorResponse({ + fetchSpy, + }); + + const client = createQuickNodeClient(networks.testnet); + + await expect(client.getTransactionStatus(txid)).rejects.toThrow( + DataClientError, + ); + }); + }); + + describe('sendTransaction', () => { + const signedTransaction = + '02000000000101ec81faa8b57add4c8fb3958dd8f04667f5cd829a7b94199f4400be9e52cda0760000000000ffffffff015802000000000000160014f80b562cbcbbfc97727043484c06cc5579963e8402473044022011ec3f7ea7a7cac7cb891a1ea498d94ca3cd082339b9b2620ba5421ca7cbdf3d022062f34411d6aa5335c2bd7ff4c940adb962e9509133b86a2d97996552fd811f2c012102ceea82614fdb14871ef881498c55c5dbdc24b4633d29b42040dd18b4285540f500000000'; + + it('broadcasts a transaction', async () => { + const { fetchSpy } = createMockFetch(); + const mockResponse = generateQuickNodeSendRawTransactionResp(); + const expectedResult = mockResponse.result.hex; + + mockApiSuccessResponse({ + fetchSpy, + mockResponse, + }); + + const client = createQuickNodeClient(networks.testnet); + const result = await client.sendTransaction(signedTransaction); + + expect(result).toStrictEqual(expectedResult); + }); + + it('throws DataClientError if the api response is invalid', async () => { + const { fetchSpy } = createMockFetch(); + + mockErrorResponse({ + fetchSpy, + }); + + const client = createQuickNodeClient(networks.testnet); + + await expect(client.sendTransaction(signedTransaction)).rejects.toThrow( + DataClientError, + ); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts new file mode 100644 index 00000000..7fd01cf4 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts @@ -0,0 +1,279 @@ +import { BtcP2wpkhAddressStruct } from '@metamask/keyring-api'; +import type { Json } from '@metamask/snaps-sdk'; +import { networks } from 'bitcoinjs-lib'; +import type { Struct } from 'superstruct'; +import { array, assert, mask } from 'superstruct'; + +import { Config } from '../../../config'; +import { btcToSats, compactError, logger, processBatch } from '../../../utils'; +import { FeeRate, TransactionStatus } from '../constants'; +import type { + IDataClient, + DataClientGetBalancesResp, + DataClientGetTxStatusResp, + DataClientGetUtxosResp, + DataClientSendTxResp, + DataClientGetFeeRatesResp, +} from '../data-client'; +import { DataClientError } from '../exceptions'; +import { + type QuickNodeClientOptions, + type QuickNodeGetBalancesResponse, + type QuickNodeGetUtxosResponse, + type QuickNodeSendTransactionResponse, + type QuickNodeEstimateFeeResponse, + type QuickNodeGetTransaction, + type QuickNodeResponse, + QuickNodeGetBalancesResponseStruct, + QuickNodeGetUtxosResponseStruct, + QuickNodeSendTransactionResponseStruct, + QuickNodeGetTransactionStruct, + QuickNodeEstimateFeeResponseStruct, +} from './quicknode.types'; + +export class QuickNodeClient implements IDataClient { + protected readonly _options: QuickNodeClientOptions; + + protected readonly _priorityMap: Record; + + constructor(options: QuickNodeClientOptions) { + this._options = options; + this._priorityMap = { + [FeeRate.Fast]: 1, + [FeeRate.Medium]: 2, + [FeeRate.Slow]: 3, + }; + } + + get baseUrl(): string { + switch (this._options.network) { + case networks.bitcoin: + return this._options.mainnetEndpoint; + case networks.testnet: + return this._options.testnetEndpoint; + default: + throw new Error('Invalid network'); + } + } + + protected async post( + body: Json, + ): Promise { + const response = await fetch(this.baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + // QuickNode returns 200 status code for successful requests, others are errors status code + if (response.status !== 200) { + const res = (await response.json()) as unknown as Response; + throw new Error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Failed to post data from quicknode: ${res.error}`, + ); + } + + if (!response.ok) { + throw new Error( + `Failed to post data from quicknode: ${response.statusText}`, + ); + } + return response.json() as unknown as Response; + } + + protected isErrorResponse( + response: Response, + ): boolean { + // Possible error response from QuickNode: + // - { result : null, error : "some error message" } + // - { result : null, error : { code: -8, message: "some error message" } } + // - { result : { error : "some error message" } } + // - empty + return ( + !response.result || + Object.prototype.hasOwnProperty.call(response.result, 'error') + ); + } + + protected async submitJsonRPCRequest({ + request, + responseStruct, + }: { + request: { + method: string; + params: Json; + }; + responseStruct: Struct; + }) { + try { + logger.debug( + `[QuickNodeClient.${request.method}] request:`, + JSON.stringify(request), + ); + + const response = await this.post(request); + + logger.debug( + `[QuickNodeClient.${request.method}] response:`, + JSON.stringify(response), + ); + + // Safeguard to detect if the response is an error response, but they are not caught by the fetch error + if (this.isErrorResponse(response)) { + throw new Error(`Error response from quicknode`); + } + + // Safeguard to identify if the response has some unexpected changes from quicknode + mask(response, responseStruct, 'Unexpected response from quicknode'); + + return response; + } catch (error) { + logger.info( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `[QuickNodeClient.${request.method}] error: ${error.message}`, + ); + + throw compactError(error, DataClientError); + } + } + + async getBalances(addresses: string[]): Promise { + assert(addresses, array(BtcP2wpkhAddressStruct)); + + const addressBalanceMap = new Map(); + + await processBatch(addresses, async (address) => { + const response = + await this.submitJsonRPCRequest({ + // index 0 of the params refer to the account address, + // index 1 .details refer to the output flag: + // - 'basic' for basic address information + // - 'txids' to also include transaction IDs + // - 'txs' to include full transaction data + request: { + method: 'bb_getaddress', + params: [ + address, + { + details: 'basic', + }, + ], + }, + responseStruct: QuickNodeGetBalancesResponseStruct, + }); + + addressBalanceMap.set(address, parseInt(response.result.balance, 10)); + }); + + return addresses.reduce( + (data: DataClientGetBalancesResp, address: string) => { + // The hashmap should include the balance for each requested addresses + // but in case there are some behavior changes, we set the default balance to 0 + data[address] = addressBalanceMap.get(address) ?? 0; + return data; + }, + {}, + ); + } + + async getUtxos( + address: string, + includeUnconfirmed?: boolean, + ): Promise { + assert(address, BtcP2wpkhAddressStruct); + + const response = await this.submitJsonRPCRequest( + { + request: { + method: 'bb_getutxos', + params: [ + address, + { + confirmed: !includeUnconfirmed, + }, + ], + }, + responseStruct: QuickNodeGetUtxosResponseStruct, + }, + ); + + return response.result.map((utxo) => ({ + block: utxo.height, + txHash: utxo.txid, + index: utxo.vout, + // the utxo.value will be return as sats + // it is safe to use number in bitcoin rather than big int, due to max sats will not exceed 2100000000000000 + value: parseInt(utxo.value, 10), + })); + } + + async getFeeRates(): Promise { + // There is no UX to allow end user to select the fee rate, + // hence we can just fetch the default fee rate. + const processItems = { + [Config.defaultFeeRate]: this._priorityMap[Config.defaultFeeRate], + }; + + const feeRates: Record = {}; + // keep this batch process in case we have to switch to support multiple fee rates. + await processBatch( + Object.entries(processItems), + async ([feeRate, target]) => { + const response = + await this.submitJsonRPCRequest({ + request: { + method: 'estimatesmartfee', + params: [target], + }, + responseStruct: QuickNodeEstimateFeeResponseStruct, + }); + + // The fee rate will be returned in btc unit + // e.g. 0.00005081 + feeRates[feeRate] = Number( + btcToSats(response.result.feerate.toString()), + ); + }, + ); + + return feeRates; + } + + async sendTransaction( + signedTransaction: string, + ): Promise { + const response = + await this.submitJsonRPCRequest({ + request: { + method: 'sendrawtransaction', + params: [signedTransaction], + }, + responseStruct: QuickNodeSendTransactionResponseStruct, + }); + return response.result.hex; + } + + async getTransactionStatus(txid: string): Promise { + const response = await this.submitJsonRPCRequest({ + // index 0 of the params refer to the tx id, + // index 1 refer to the verbose flag, + // - 0: hex-encoded data + // - 1: JSON object + // - 2: JSON object with fee and prevout + request: { method: 'getrawtransaction', params: [txid, 1] }, + responseStruct: QuickNodeGetTransactionStruct, + }); + + // Bitcoin transaction is often considered secure after six confirmations + // reference: https://www.bitcoin.com/get-started/what-is-a-confirmation/#:~:text=Different%20cryptocurrencies%20require%20different%20numbers,secure%20after%20around%2030%20confirmations. + return { + status: + response.result.confirmations >= Config.defaultConfirmationThreshold + ? TransactionStatus.Confirmed + : TransactionStatus.Pending, + }; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.types.ts new file mode 100644 index 00000000..3eb67cb0 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.types.ts @@ -0,0 +1,92 @@ +import type { Network } from 'bitcoinjs-lib'; +import type { Infer } from 'superstruct'; +import { array, number, object, string } from 'superstruct'; + +export type QuickNodeClientOptions = { + network: Network; + // The endpoints will be setup via the environment variable + testnetEndpoint: string; + mainnetEndpoint: string; +}; + +export type QuickNodeError = { + error: null | { + code: string; + message: string; + }; +}; + +export type QuickNodeResponse = QuickNodeError & { + result: unknown; +}; + +export const QuickNodeGetBalancesResponseStruct = object({ + result: object({ + address: string(), + balance: string(), + totalReceived: string(), + totalSent: string(), + unconfirmedBalance: string(), + unconfirmedTxs: number(), + txs: number(), + }), +}); + +export type QuickNodeGetBalancesResponse = QuickNodeResponse & + Infer; + +export const QuickNodeGetUtxosResponseStruct = object({ + result: array( + object({ + txid: string(), + vout: number(), + value: string(), + height: number(), + confirmations: number(), + }), + ), +}); + +export type QuickNodeGetUtxosResponse = QuickNodeResponse & + Infer; + +export const QuickNodeSendTransactionResponseStruct = object({ + result: object({ + // eslint-disable-next-line id-denylist + hex: string(), + }), +}); + +export type QuickNodeSendTransactionResponse = QuickNodeResponse & + Infer; + +export const QuickNodeEstimateFeeResponseStruct = object({ + result: object({ + blocks: number(), + feerate: number(), + }), +}); + +export type QuickNodeEstimateFeeResponse = QuickNodeResponse & + Infer; + +export const QuickNodeGetTransactionStruct = object({ + result: object({ + txid: string(), + hash: string(), + version: number(), + size: number(), + vsize: number(), + weight: number(), + locktime: number(), + // eslint-disable-next-line id-denylist + hex: string(), + blockhash: string(), + confirmations: number(), + time: number(), + blocktime: number(), + }), +}); + +export type QuickNodeGetTransaction = QuickNodeResponse & + Infer; diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index a338066e..d29ac31b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -21,6 +21,7 @@ export type SnapConfig = { [network in string]: string; }; logLevel: string; + defaultConfirmationThreshold: number; }; export const Config: SnapConfig = { @@ -49,4 +50,6 @@ export const Config: SnapConfig = { }, // eslint-disable-next-line no-restricted-globals logLevel: process.env.LOG_LEVEL ?? '0', + // the number of confirmations required for a transaction to be considered confirmed + defaultConfirmationThreshold: 6, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/error.ts b/merged-packages/bitcoin-wallet-snap/src/utils/error.ts index c4b7081d..4ad5d501 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/error.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/error.ts @@ -21,7 +21,7 @@ import { export class CustomError extends Error { name!: string; - constructor(message: string) { + constructor(message?: string) { super(message); // set error name as constructor name, make it not enumerable to keep native Error behavior diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/quicknode.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/quicknode.json new file mode 100644 index 00000000..6078438c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/test/fixtures/quicknode.json @@ -0,0 +1,71 @@ +{ + "getrawtransactionResp": { + "result": { + "txid": "xxxxx", + "hash": "xxxxx", + "version": 1, + "size": 246, + "vsize": 165, + "weight": 657, + "locktime": 0, + "vin": [ + { + "txid": "xxxxx", + "vout": 1, + "txinwitness": [ + "304402207b3f3e6d5bf8fe781d226af2043566ba5b5dc375940c1eee56c175621a739f8b02200e6e57f1b4a401f4abea080f07e460ca613c9cc980c81699bbb15c69388dda3001", + "03927dd425ca1e744ac605aa60a62afd22b6e7e3b1c4f962d7acd6cb00e422627d" + ], + "sequence": 4294967295 + } + ], + "vout": [ + { + "value": 0.00105089, + "n": 0 + } + ], + "hex": "xxxxx", + "blockhash": "xxxxx", + "confirmations": 185569, + "time": 1616673563, + "blocktime": 1616673563 + }, + "error": null, + "id": null + }, + "estimatesmartfeeResp": { + "result": { + "feerate": 0.00004213, + "blocks": 2 + }, + "error": null, + "id": null + }, + "bb_getaddressResp": { + "id": null, + "result": { + "address": "xxxxxx", + "balance": "1", + "totalReceived": "100000", + "totalSent": "100000", + "unconfirmedBalance": "0", + "unconfirmedTxs": 0, + "txs": 100 + }, + "jsonrpc": "2.0" + }, + "bb_getutxosResp": { + "id": null, + "result": [ + { + "txid": "xxxxxx", + "vout": 1, + "value": "1", + "height": 100000, + "confirmations": 1000 + } + ], + "jsonrpc": "2.0" + } +} diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index e7ce8fd1..8571e4d3 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -7,6 +7,7 @@ import { v4 as uuidV4 } from 'uuid'; import { Caip2ChainId } from '../src/constants'; import blockChairData from './fixtures/blockchair.json'; +import quickNodeData from './fixtures/quicknode.json'; /* eslint-disable */ @@ -327,6 +328,151 @@ export function generateBlockChairTransactionDashboard( return resp; } +/** + * Generate QuickNode bb_getaddress response by address. + * + * @param address - The account address. + * @returns A QuickNode bb_getaddress response. + */ +export function generateQuickNodeGetBalanceResp(address: string) { + const template = quickNodeData.bb_getaddressResp; + const data: typeof template = { + ...template, result: { + ...template.result, + address: address, + balance: randomNum(1000000).toString(), + } + }; + + return data; +} + +/** + * Generate QuickNode bb_getutxos response. + * + * @param params - The params to generate mock utxos. + * @param params.utxosCount - The utxos count. + * @param params.minAmount - The min amount of each utxo value. + * @param params.maxAmount - The max amount of each utxo value. + * @param params.minConfirmations - The min confirmation of each utxo. + * @param params.maxConfirmations - The max confirmation of each utxo. + * @returns A QuickNode bb_getutxos response. + */ +export function generateQuickNodeGetUtxosResp( + { + utxosCount, + minAmount = 0, + maxAmount = 1000000, + minConfirmations = 1000, + maxConfirmations = 10000, + }: { + utxosCount: number, + minAmount?: number, + maxAmount?: number, + minConfirmations?: number, + maxConfirmations?: number, + } +) { + const template = quickNodeData.bb_getutxosResp; + const data = { ...template }; + data.result = Array.from({ length: utxosCount }, (_, idx) => { + return { + txid: generateRandomTransactionId(), + vout: idx, + value: Math.max(minAmount, randomNum(maxAmount)).toString(), + height: 100000 + idx, + confirmations: Math.max(minConfirmations, randomNum(maxConfirmations)), + + }; + }); + return data; +} + +/** + * Generate QuickNode get rawtransaction response. + * + * @param params - The params to generate mock get rawtransaction response. + * @param params.txid - The transaction id of the transaction. + * @param params.confirmations - The number of confirmations of the transaction. + * @returns A QuickNode get rawtransaction response. + */ +export function generateQuickNodeGetRawTransactionResp( + { + txid, + confirmations, + }: { + txid: string, + confirmations: number, + } +) { + const template = quickNodeData.getrawtransactionResp; + const data = { + ...template, + result: { + ...template.result, + txid, + confirmations: confirmations, + }, + }; + return data; +} + +/** + * Generate QuickNode estimate smartfee response. + * + * @param params - The params to generate mock estimate smartfee response. + * @param params.feerate - The fee rate in btc unit. + * @returns A QuickNode estimate smartfee response. + */ +export function generateQuickNodeEstimatefeeResp( + { + feerate + }: { + feerate: number; + } +) { + const template = quickNodeData.estimatesmartfeeResp; + const data = { + ...template, + result: { + ...template.result, + feerate, + block: Math.max(1000, randomNum(100000)), + }, + }; + return data; +} + +/** + * Generate QuickNode send rawtransaction response. + * + * @returns A QuickNode send rawtransaction response. + */ +export function generateQuickNodeSendRawTransactionResp() { + const template = quickNodeData.estimatesmartfeeResp; + const data = { + ...template, + result: { + hex: generateRandomTransactionId() + }, + }; + return data; +} + +/** + * Generate a random 64 long hex transaction id. + * + * @returns A 64 long hex transaction id. + */ +export function generateRandomTransactionId() { + return randomNum(100000000) + .toString(16) + .padStart( + 64, + '0', + ) +} + /** * Method to generate formatted utxos with blockchair resp. * From 69943047a920eae654b249be684a5095a09bdb9d Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 26 Sep 2024 16:15:35 +0200 Subject: [PATCH 133/362] chore: bump keyring-api to ^8.1.3 (#253) * chore: bump keyring-api to ^8.1.3 * chore: yarn.lock --- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index bd643124..ae7281bf 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -40,7 +40,7 @@ "@metamask/eslint-config-nodejs": "^12.1.0", "@metamask/eslint-config-typescript": "^12.1.0", "@metamask/key-tree": "^9.1.2", - "@metamask/keyring-api": "^8.0.2", + "@metamask/keyring-api": "^8.1.3", "@metamask/snaps-cli": "^6.3.0", "@metamask/snaps-jest": "^8.3.0", "@metamask/snaps-sdk": "^6.2.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index dde88c32..860c2c8c 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "2WoKDYtZ0/Z8yRrnxLRV1P/HEqfaO00b8yoJfQwI/wA=", + "shasum": "ExIyB+/Kct64/6NJLR6fdbqUEtMQpLtY/+7JRrW8A+U=", "location": { "npm": { "filePath": "dist/bundle.js", From a3d406a2cdd708ff80614c57dd843248c0e0ab7f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 26 Sep 2024 18:11:04 +0200 Subject: [PATCH 134/362] 0.6.1 (#255) * 0.6.1 * chore: update changelog + snap manifest --------- Co-authored-by: github-actions Co-authored-by: Charly Chevalier --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 15 ++++++++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index f3f534f3..ef573df7 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.1] + +### Added + +- Add `QuickNode` API client ([#247](https://github.com/MetaMask/snap-bitcoin-wallet/pull/247)) + - This client is not yet used, we still use Blockchair provider for the moment. + +### Changed + +- Bump `@metamask/keyring-api` from `^8.0.2` to `^8.1.3` ([#253](https://github.com/MetaMask/snap-bitcoin-wallet/pull/253)) + - This version is now built slightly differently and is part of the [accounts monorepo](https://github.com/MetaMask/accounts). + ## [0.6.0] ### Added @@ -174,7 +186,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.1...HEAD +[0.6.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.0...v0.6.1 [0.6.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.3.0...v0.4.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index ae7281bf..96e4413d 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.6.0", + "version": "0.6.1", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 860c2c8c..8f39d7eb 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.6.0", + "version": "0.6.1", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin Manager", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "ExIyB+/Kct64/6NJLR6fdbqUEtMQpLtY/+7JRrW8A+U=", + "shasum": "BhTdGmGmVlyLoNBQQYn5PQjFC5iQ3OcNOj7B3QfY/Kc=", "location": { "npm": { "filePath": "dist/bundle.js", From 491e3192e372f099c1a3066e9518ab8fa4a1c992 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 30 Sep 2024 17:26:00 +0800 Subject: [PATCH 135/362] fix: invalid response in estimate fee rpc (#261) --- .../src/bitcoin/wallet/coin-select.test.ts | 22 +++++++++++++++++++ .../src/bitcoin/wallet/coin-select.ts | 4 +++- .../src/bitcoin/wallet/wallet.ts | 5 ++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts index e19bd9d1..3bf2c94b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts @@ -69,5 +69,27 @@ describe('CoinSelectService', () => { expect(result.inputs.length).toBeGreaterThan(0); expect(result.outputs.length).toBeGreaterThan(0); }); + + it('returns empty inputs and outputs when the provided UTXOs are insufficient', async () => { + const network = networks.testnet; + // Setup a test case where required utxos are greater than the available utxos + const { inputs, outputs, sender } = await prepareCoinSlect( + network, + 10000, + 500, + 500, + ); + + const coinSelectService = new CoinSelectService(1); + + const result = coinSelectService.selectCoins( + inputs, + outputs, + new TxOutput(0, sender.address, sender.script), + ); + + expect(result.inputs).toHaveLength(0); + expect(result.outputs).toHaveLength(0); + }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts index fa5000d0..c8c150ac 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts @@ -34,7 +34,9 @@ export class CoinSelectService { const selectedResult: SelectionResult = { fee: result.fee, - inputs: result.inputs, + // CoinSelect returns undefined inputs when the provided UTXOs are insufficient, + // Hence, assign an empty array to standardize the return value + inputs: result.inputs ?? [], outputs: [], }; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index c8ab0a53..ad0ae545 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -130,7 +130,10 @@ export class BtcWallet { feeRate, ); - if (!selectionResult.inputs || !selectionResult.outputs) { + if ( + selectionResult.inputs.length === 0 || + selectionResult.outputs.length === 0 + ) { throw new InsufficientFundsError(); } From d3b7d8163525c9f13b4f9891112655690e50f440 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 30 Sep 2024 18:00:28 +0800 Subject: [PATCH 136/362] fix: update `QuickNode` api response handle (#263) * fix: update quicknode api response handle * Update packages/snap/src/bitcoin/chain/clients/quicknode.ts Co-authored-by: Charly Chevalier --------- Co-authored-by: Charly Chevalier --- .../bitcoin/chain/clients/quicknode.test.ts | 23 ++++++++++++++++++- .../src/bitcoin/chain/clients/quicknode.ts | 4 +++- .../bitcoin/chain/clients/quicknode.types.ts | 16 ++++++------- .../bitcoin-wallet-snap/test/utils.ts | 6 ++--- 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts index cdc3e378..b7e5ff8b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts @@ -398,6 +398,27 @@ describe('QuickNodeClient', () => { }); }); + it(`returns 'pending' if the transaction's confirmation number is undefined`, async () => { + const { fetchSpy } = createMockFetch(); + + const mockResponse = generateQuickNodeGetRawTransactionResp({ + txid, + confirmations: undefined, + }); + + mockApiSuccessResponse({ + fetchSpy, + mockResponse, + }); + + const client = createQuickNodeClient(networks.testnet); + const result = await client.getTransactionStatus(txid); + + expect(result).toStrictEqual({ + status: TransactionStatus.Pending, + }); + }); + it('throws DataClientError if the api response is invalid', async () => { const { fetchSpy } = createMockFetch(); @@ -420,7 +441,7 @@ describe('QuickNodeClient', () => { it('broadcasts a transaction', async () => { const { fetchSpy } = createMockFetch(); const mockResponse = generateQuickNodeSendRawTransactionResp(); - const expectedResult = mockResponse.result.hex; + const expectedResult = mockResponse.result; mockApiSuccessResponse({ fetchSpy, diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts index 7fd01cf4..ef07f34e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts @@ -253,7 +253,7 @@ export class QuickNodeClient implements IDataClient { }, responseStruct: QuickNodeSendTransactionResponseStruct, }); - return response.result.hex; + return response.result; } async getTransactionStatus(txid: string): Promise { @@ -271,6 +271,8 @@ export class QuickNodeClient implements IDataClient { // reference: https://www.bitcoin.com/get-started/what-is-a-confirmation/#:~:text=Different%20cryptocurrencies%20require%20different%20numbers,secure%20after%20around%2030%20confirmations. return { status: + // If `confirmations` is not defined, then the transaction is "pending" in the memory pool. + response.result.confirmations && response.result.confirmations >= Config.defaultConfirmationThreshold ? TransactionStatus.Confirmed : TransactionStatus.Pending, diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.types.ts index 3eb67cb0..c4efe147 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.types.ts @@ -1,6 +1,6 @@ import type { Network } from 'bitcoinjs-lib'; import type { Infer } from 'superstruct'; -import { array, number, object, string } from 'superstruct'; +import { array, number, object, optional, string } from 'superstruct'; export type QuickNodeClientOptions = { network: Network; @@ -51,10 +51,7 @@ export type QuickNodeGetUtxosResponse = QuickNodeResponse & Infer; export const QuickNodeSendTransactionResponseStruct = object({ - result: object({ - // eslint-disable-next-line id-denylist - hex: string(), - }), + result: string(), }); export type QuickNodeSendTransactionResponse = QuickNodeResponse & @@ -81,10 +78,11 @@ export const QuickNodeGetTransactionStruct = object({ locktime: number(), // eslint-disable-next-line id-denylist hex: string(), - blockhash: string(), - confirmations: number(), - time: number(), - blocktime: number(), + // The following fields will not be set if the transaction is in the memory pool + blockhash: optional(string()), + confirmations: optional(number()), + time: optional(number()), + blocktime: optional(number()), }), }); diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index 8571e4d3..e3df0885 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -402,7 +402,7 @@ export function generateQuickNodeGetRawTransactionResp( confirmations, }: { txid: string, - confirmations: number, + confirmations: number | undefined, } ) { const template = quickNodeData.getrawtransactionResp; @@ -452,9 +452,7 @@ export function generateQuickNodeSendRawTransactionResp() { const template = quickNodeData.estimatesmartfeeResp; const data = { ...template, - result: { - hex: generateRandomTransactionId() - }, + result: generateRandomTransactionId(), }; return data; } From 5d61337719fdee994ffdc4f5abd8f59aee8211e7 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 30 Sep 2024 18:32:23 +0800 Subject: [PATCH 137/362] chore: change `dataclient` from `BlockChairClient` to `QuickNodeClient` (#250) * feat: add quicknode client * chore: add quicknode unit test * chore: rename quick node client instance * chore: refine qn test * chore: refine test * chore: increase test coverage * chore: adopt quicknode * chore: refine qn test * chore: increase test coverage * chore: adopt quicknode * chore: update env variable names * chore: update SnapChainConfig type * chore: fix unit test and increase test coverage * chore: update comment * chore: fix QN send tx response * chore: fix QN send tx response mock * fix: remove duplicate method * chore: address comment * Update packages/snap/src/bitcoin/chain/clients/quicknode.test.ts Co-authored-by: Charly Chevalier --------- Co-authored-by: gantunesr <17601467+gantunesr@users.noreply.github.com> Co-authored-by: Charly Chevalier --- .../bitcoin-wallet-snap/.env.example | 10 ++++-- .../bitcoin-wallet-snap/snap.config.ts | 3 +- .../bitcoin/chain/clients/quicknode.test.ts | 5 +-- .../bitcoin-wallet-snap/src/config.ts | 15 ++++---- .../bitcoin-wallet-snap/src/factory.test.ts | 35 +++++++++++++++++++ .../bitcoin-wallet-snap/src/factory.ts | 20 ++++++++--- .../src/rpcs/__tests__/helper.ts | 5 +++ .../src/rpcs/get-balances.test.ts | 19 +++------- .../src/rpcs/get-transaction-status.test.ts | 17 +++------ 9 files changed, 86 insertions(+), 43 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/.env.example b/merged-packages/bitcoin-wallet-snap/.env.example index f2c971bf..041c601f 100644 --- a/merged-packages/bitcoin-wallet-snap/.env.example +++ b/merged-packages/bitcoin-wallet-snap/.env.example @@ -3,6 +3,12 @@ # Default: 0 # Required: false LOG_LEVEL=6 -# Description: Environment variables for API key of BlockChair +# Description: Environment variables for the API key of BlockChair # Required: false -BLOCKCHAIR_API_KEY= \ No newline at end of file +BLOCKCHAIR_API_KEY= +# Description: Environment variables for the Testnet endpoint of QuickNode +# Required: true +QUICKNODE_TESTNET_ENDPOINT= +# Description: Environment variables for the Mainnet endpoint of QuickNode +# Required: true +QUICKNODE_MAINNET_ENDPOINT= diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts index 45b06eed..f7147040 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.config.ts +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -13,7 +13,8 @@ const config: SnapConfig = { environment: { /* eslint-disable */ LOG_LEVEL: process.env.LOG_LEVEL, - BLOCKCHAIR_API_KEY: process.env.BLOCKCHAIR_API_KEY, + QUICKNODE_MAINNET_ENDPOINT: process.env.QUICKNODE_MAINNET_ENDPOINT, + QUICKNODE_TESTNET_ENDPOINT: process.env.QUICKNODE_TESTNET_ENDPOINT, /* eslint-disable */ }, polyfills: true, diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts index b7e5ff8b..5cda64d1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts @@ -36,9 +36,10 @@ describe('QuickNodeClient', () => { const createMockFetch = () => { const fetchSpy = jest.fn(); - // eslint-disable-next-line no-restricted-globals Object.defineProperty(global, 'fetch', { + // Allow `fetch` to be redefined in the global scope + writable: true, value: fetchSpy, }); @@ -231,7 +232,7 @@ describe('QuickNodeClient', () => { }); // This case should never happen, but to ensure the test is 100% covered, hence we mock the processBatch to not process any request - it('assign 0 balance to address if the address cannot be found in the hashmap', async () => { + it('assigns 0 balance to the address if it cannot be found in the hashmap', async () => { const network = networks.testnet; const { accounts } = await createAccounts(network, 5); const addresses = accounts.map((account) => account.address); diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index d29ac31b..5a0d21d3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -1,12 +1,13 @@ -import type { Json } from '@metamask/snaps-sdk'; - import { FeeRate } from './bitcoin/chain/constants'; import { Caip2ChainId, Caip2Asset } from './constants'; -export type SnapConfig = { +export type SnapChainConfig = { onChainService: { dataClient: { - options?: Record; + options: { + mainnetEndpoint: string | undefined; + testnetEndpoint: string | undefined; + }; }; }; wallet: { @@ -24,12 +25,14 @@ export type SnapConfig = { defaultConfirmationThreshold: number; }; -export const Config: SnapConfig = { +export const Config: SnapChainConfig = { onChainService: { dataClient: { options: { // eslint-disable-next-line no-restricted-globals - apiKey: process.env.BLOCKCHAIR_API_KEY, + testnetEndpoint: process.env.QUICKNODE_TESTNET_ENDPOINT, + // eslint-disable-next-line no-restricted-globals + mainnetEndpoint: process.env.QUICKNODE_MAINNET_ENDPOINT, }, }, }, diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/factory.test.ts index c714732a..04be7996 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.test.ts @@ -1,11 +1,15 @@ import { BtcOnChainService } from './bitcoin/chain'; +import { QuickNodeClient } from './bitcoin/chain/clients/quicknode'; import { BtcWallet } from './bitcoin/wallet'; +import { Config } from './config'; import { Caip2ChainId } from './constants'; import { Factory } from './factory'; describe('Factory', () => { describe('createOnChainServiceProvider', () => { it('creates BtcOnChainService instance', () => { + jest.spyOn(Factory, 'createQuickNodeClient').mockReturnThis(); + const instance = Factory.createOnChainServiceProvider( Caip2ChainId.Testnet, ); @@ -14,6 +18,37 @@ describe('Factory', () => { }); }); + describe('createQuickNodeClient', () => { + afterEach(() => { + Config.onChainService.dataClient.options = { + mainnetEndpoint: undefined, + testnetEndpoint: undefined, + }; + }); + + it('creates CreateQuickNodeClient instance', () => { + Config.onChainService.dataClient.options = { + mainnetEndpoint: 'http://mainnetEndpoint', + testnetEndpoint: 'http://testnetEndpoint', + }; + + const instance = Factory.createQuickNodeClient(Caip2ChainId.Testnet); + + expect(instance).toBeInstanceOf(QuickNodeClient); + }); + + it('throws `QuickNode endpoints have not been configured` error if the endpoints have not been provided', () => { + Config.onChainService.dataClient.options = { + mainnetEndpoint: undefined, + testnetEndpoint: undefined, + }; + + expect(() => Factory.createQuickNodeClient(Caip2ChainId.Testnet)).toThrow( + 'QuickNode endpoints have not been configured', + ); + }); + }); + describe('createWallet', () => { it('creates BtcWallet instance', () => { const instance = Factory.createWallet(Caip2ChainId.Testnet); diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts index 1ea11fb8..02dbfef0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.ts @@ -1,5 +1,5 @@ import { BtcOnChainService } from './bitcoin/chain'; -import { BlockChairClient } from './bitcoin/chain/clients/blockchair'; +import { QuickNodeClient } from './bitcoin/chain/clients/quicknode'; import { BtcAccountDeriver, BtcWallet, getBtcNetwork } from './bitcoin/wallet'; import { Config } from './config'; @@ -7,13 +7,25 @@ export class Factory { static createOnChainServiceProvider(scope: string): BtcOnChainService { const btcNetwork = getBtcNetwork(scope); - const client = new BlockChairClient({ + return new BtcOnChainService(Factory.createQuickNodeClient(scope), { network: btcNetwork, - apiKey: Config.onChainService.dataClient.options?.apiKey?.toString(), }); + } + + static createQuickNodeClient(scope: string): QuickNodeClient { + const btcNetwork = getBtcNetwork(scope); + + const { mainnetEndpoint, testnetEndpoint } = + Config.onChainService.dataClient.options; + + if (!mainnetEndpoint || !testnetEndpoint) { + throw new Error('QuickNode endpoints have not been configured'); + } - return new BtcOnChainService(client, { + return new QuickNodeClient({ network: btcNetwork, + mainnetEndpoint, + testnetEndpoint, }); } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts index 0d1f81e3..39ebd127 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts @@ -40,7 +40,12 @@ export function createMockChainApiFactory() { 'broadcastTransaction', ); + const createQuickNodeClientSpy = jest.spyOn(Factory, 'createQuickNodeClient'); + + createQuickNodeClientSpy.mockReturnThis(); + return { + createQuickNodeClientSpy, getDataForTransactionSpy, broadcastTransactionSpy, getTransactionStatusSpy, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts index 7dbd59ef..7bb9ee17 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts @@ -4,10 +4,10 @@ import { networks } from 'bitcoinjs-lib'; import { v4 as uuidv4 } from 'uuid'; import { generateAccounts } from '../../test/utils'; -import { BtcOnChainService } from '../bitcoin/chain'; import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; import { Config } from '../config'; import { Caip2ChainId } from '../constants'; +import { createMockChainApiFactory } from './__tests__/helper'; import { getBalances } from './get-balances'; jest.mock('../utils/logger'); @@ -16,17 +16,6 @@ jest.mock('../utils/snap'); describe('getBalances', () => { const asset = Config.availableAssets[0]; - const createMockChainService = () => { - const getBalancesSpy = jest.spyOn( - BtcOnChainService.prototype, - 'getBalances', - ); - - return { - getBalancesSpy, - }; - }; - const createMockDeriver = (network) => { const rootSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getRoot'); const childSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getChild'); @@ -70,7 +59,7 @@ describe('getBalances', () => { it('gets balances', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainService(); + const { getBalancesSpy } = createMockChainApiFactory(); const { walletData, sender } = await createMockAccount( network, @@ -110,7 +99,7 @@ describe('getBalances', () => { it('gets balances of the request account only', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainService(); + const { getBalancesSpy } = createMockChainApiFactory(); const accounts = generateAccounts(10); const { walletData, sender } = await createMockAccount( network, @@ -156,7 +145,7 @@ describe('getBalances', () => { it('throws `Fail to get the balances` when transaction status fetch failed', async () => { const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainService(); + const { getBalancesSpy } = createMockChainApiFactory(); const { sender } = await createMockAccount(network, caip2ChainId); getBalancesSpy.mockRejectedValue(new Error('error')); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts index 915b2c9c..094377cf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts @@ -1,7 +1,8 @@ import { InvalidParamsError } from '@metamask/snaps-sdk'; -import { BtcOnChainService, TransactionStatus } from '../bitcoin/chain'; +import { TransactionStatus } from '../bitcoin/chain'; import { Caip2ChainId } from '../constants'; +import { createMockChainApiFactory } from './__tests__/helper'; import { getTransactionStatus } from './get-transaction-status'; jest.mock('../utils/logger'); @@ -10,18 +11,8 @@ describe('getTransactionStatus', () => { const txHash = '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; - const createMockChainService = () => { - const getTransactionStatusSpy = jest.spyOn( - BtcOnChainService.prototype, - 'getTransactionStatus', - ); - return { - getTransactionStatusSpy, - }; - }; - it('gets status', async () => { - const { getTransactionStatusSpy } = createMockChainService(); + const { getTransactionStatusSpy } = createMockChainApiFactory(); const mockResp = { status: TransactionStatus.Confirmed, @@ -41,7 +32,7 @@ describe('getTransactionStatus', () => { }); it('throws `Fail to get the transaction status` when transaction status fetch failed', async () => { - const { getTransactionStatusSpy } = createMockChainService(); + const { getTransactionStatusSpy } = createMockChainApiFactory(); getTransactionStatusSpy.mockRejectedValue(new Error('error')); From 1c23e461d121ed0416472b9b2ae9c36640474cb7 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 4 Oct 2024 04:07:36 +0200 Subject: [PATCH 138/362] fix(quicknode): workaround testnet conf_target for fee estimation (#267) --- .../src/bitcoin/chain/clients/quicknode.ts | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts index ef07f34e..8fe554c6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts @@ -31,18 +31,33 @@ import { QuickNodeEstimateFeeResponseStruct, } from './quicknode.types'; +const MAINNET_CONFIRMATION_TARGET = { + [FeeRate.Fast]: 1, + [FeeRate.Medium]: 2, + [FeeRate.Slow]: 3, +}; + +// FIXME: There's an issue right now with QuickNode for testnet. Looks like there is not enough +// data on testnet to be able to target the very first blocks. So workaround this, we just shift +// everything by 20 to be able to use their API +const TESTNET_CONFIRMATION_TARGET = { + [FeeRate.Fast]: 21, + [FeeRate.Medium]: 22, + [FeeRate.Slow]: 23, +}; + export class QuickNodeClient implements IDataClient { protected readonly _options: QuickNodeClientOptions; protected readonly _priorityMap: Record; constructor(options: QuickNodeClientOptions) { + const isMainnet = options.network === networks.bitcoin; + this._options = options; - this._priorityMap = { - [FeeRate.Fast]: 1, - [FeeRate.Medium]: 2, - [FeeRate.Slow]: 3, - }; + this._priorityMap = isMainnet + ? MAINNET_CONFIRMATION_TARGET + : TESTNET_CONFIRMATION_TARGET; } get baseUrl(): string { From 11a484a5a6209f17e6248b729c297213ee7a11d6 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Fri, 4 Oct 2024 16:00:30 +0200 Subject: [PATCH 139/362] fix(quicknode): fix fee estimate (#266) * fix: fix unit conversion + use bignumber.js instead of big.js The `btcToSats` function was a bit fragile. It was checking the input as a string to make sure were not having more than 8 digits after the decimal point. However, the `.toString()` methods might be using scientific notation when converting some numbers, which would be an error with the previous check we made. Instead, we now use `BigNumber` which also supports integers and we use this to make sure BTC numbers are within range. * fix(quicknode): fix fee estimation (kvB -> vB) * chore: lint * chore: update bitcoin core comments Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * refactor: add big prefix when using BigNumber(s) * refactor: btcSatsFactor > satsIn1Btc * fix(unit): check if kvb can be safely converted to vb + round up * test: add tests for satsKVBToVB --------- Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> --- .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin/chain/clients/quicknode.test.ts | 10 ++-- .../src/bitcoin/chain/clients/quicknode.ts | 14 +++-- .../src/utils/unit.test.ts | 26 ++++++++- .../bitcoin-wallet-snap/src/utils/unit.ts | 54 +++++++++++++++---- 5 files changed, 86 insertions(+), 20 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 96e4413d..5fcd32a6 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -48,7 +48,7 @@ "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", "async-mutex": "^0.3.2", - "big.js": "6.2.1", + "bignumber.js": "9.1.2", "bip32": "4.0.0", "bitcoinjs-lib": "6.1.5", "coinselect": "3.1.13", diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts index 5cda64d1..6bc5ccc2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts @@ -10,7 +10,7 @@ import { generateQuickNodeSendRawTransactionResp, } from '../../../../test/utils'; import { Config } from '../../../config'; -import { btcToSats } from '../../../utils'; +import { btcToSats, satsKVBToVB } from '../../../utils'; import * as asyncUtils from '../../../utils/async'; import type { BtcAccount } from '../../wallet'; import { BtcAccountDeriver, BtcWallet } from '../../wallet'; @@ -271,9 +271,9 @@ describe('QuickNodeClient', () => { describe('getFeeRates', () => { it('returns fee rates', async () => { const { fetchSpy } = createMockFetch(); - const expectedFeeRate = 0.0001; + const expectedFeeRateKVB = 0.0001; const mockResponse = generateQuickNodeEstimatefeeResp({ - feerate: expectedFeeRate, + feerate: expectedFeeRateKVB, }); mockApiSuccessResponse({ @@ -285,7 +285,9 @@ describe('QuickNodeClient', () => { const result = await client.getFeeRates(); expect(result).toStrictEqual({ - [Config.defaultFeeRate]: Number(btcToSats(expectedFeeRate.toString())), + [Config.defaultFeeRate]: Number( + satsKVBToVB(btcToSats(expectedFeeRateKVB.toString())), + ), }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts index 8fe554c6..d194378e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts @@ -5,7 +5,13 @@ import type { Struct } from 'superstruct'; import { array, assert, mask } from 'superstruct'; import { Config } from '../../../config'; -import { btcToSats, compactError, logger, processBatch } from '../../../utils'; +import { + btcToSats, + compactError, + logger, + processBatch, + satsKVBToVB, +} from '../../../utils'; import { FeeRate, TransactionStatus } from '../constants'; import type { IDataClient, @@ -246,10 +252,12 @@ export class QuickNodeClient implements IDataClient { responseStruct: QuickNodeEstimateFeeResponseStruct, }); - // The fee rate will be returned in btc unit + // The fee rate will be returned in BTC/kvB unit (note the kilobyte here) // e.g. 0.00005081 + // See: https://www.quicknode.com/docs/bitcoin/estimatesmartfee + // > Estimates the smart fee per **kilobyte** ... feeRates[feeRate] = Number( - btcToSats(response.result.feerate.toString()), + satsKVBToVB(btcToSats(response.result.feerate.toString())), ); }, ); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts index a0dbbcdb..6e9ca8e8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts @@ -1,4 +1,28 @@ -import { satsToBtc, btcToSats, maxSatoshi, minSatoshi } from './unit'; +import { + satsToBtc, + btcToSats, + maxSatoshi, + minSatoshi, + satsKVBToVB, +} from './unit'; + +describe('satsKVBToVB', () => { + it.each([ + { kvb: 1000n, vb: 1n }, + { kvb: 1001n, vb: 1n }, + { kvb: 1499n, vb: 1n }, + { kvb: 1500n, vb: 2n }, + { kvb: 1501n, vb: 2n }, + { kvb: 1999n, vb: 2n }, + { kvb: 123456789123456789n, vb: 123456789123457n }, + ])('converts from "$kvb" (sats/kvB) to "$vb" (sats/vB)', ({ kvb, vb }) => { + expect(satsKVBToVB(kvb)).toBe(vb); + }); + + it.each([0, 1, 500, 999])('throws an error if not convertible: %s', (kvb) => { + expect(() => satsKVBToVB(kvb)).toThrow(Error); + }); +}); describe('satsToBtc', () => { it('returns Btc unit', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts index 79fd4a1b..80722a9e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts @@ -1,13 +1,43 @@ -import Big from 'big.js'; +import BigNumber from 'bignumber.js'; import { Config } from '../config'; +// Bitcoin Core's RPC 'estimatesmartfee' returns values in BTC/kvB. +// Hence, we will have to convert it back to standard BTC/vB. +// See: https://github.com/bitcoin/bitcoin/blob/v28.0/src/policy/feerate.cpp#L17 +export const bitcoinCoreKB = 1000; + // Maximum amount of satoshis export const maxSatoshi = 21 * 1e14; // Minimum amount of satoshis export const minSatoshi = 1; +// Rate to convert from BTC to sats +export const satsIn1Btc = 100000000; + +/** + * Converts sats/kvB to sats/vB. + * + * @param kvb - The amount in sats/kvB. + * @returns The converted amount to sats/vB. + */ +export function satsKVBToVB(kvb: number | bigint): bigint { + // It cannot be lower than 1 sat per vB. + if (kvb < bitcoinCoreKB) { + throw new Error(`Unable to convert kvB to vB: "${kvb}" is too small`); + } + // NOTE: From here, we now we can safely divides by `bitcoinCoreKB` and we know it's not + // going to give a results of 0. + + // We still use `BigNumber` here and not `bigint` to be able to round up the result + const bigKVB = BigNumber(kvb.toString()); + const bigKB = BigNumber(bitcoinCoreKB); + const bigVB = bigKVB.div(bigKB).toFixed(0, BigNumber.ROUND_HALF_UP); + + return BigInt(bigVB.toString()); +} + /** * Converts a satoshis to a string representing the equivalent amount of BTC. * @@ -20,14 +50,14 @@ export function satsToBtc(sats: number | bigint, withUnit = false): string { if (typeof sats === 'number' && !Number.isInteger(sats)) { throw new Error('satsToBtc must be called on an integer number'); } - const bigIntSat = new Big(sats); - const val = bigIntSat.div(100000000).toFixed(8); + const bigSat = new BigNumber(sats.toString()); + const bigBtc = bigSat.div(satsIn1Btc).toFixed(8); if (withUnit) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - return `${val} ${Config.unit}`; + return `${bigBtc} ${Config.unit}`; } - return val; + return bigBtc; } /** @@ -38,14 +68,16 @@ export function satsToBtc(sats: number | bigint, withUnit = false): string { * @throws A Error if the BTC > max amount of satoshis (21 * 1e14) or the BTC < 0 or the BTC has more than 8 decimals. */ export function btcToSats(btc: string): bigint { - const stringVals = btc.split('.'); - if (stringVals.length > 1 && stringVals[1].length > 8) { + const bigBtc = new BigNumber(btc); + const bigSats = bigBtc.times(satsIn1Btc); + + // If it's not a "true" integer, it means we still have some decimals, so the original + // amount had more than 8 digits after the decimal point. + if (!bigSats.isInteger()) { throw new Error('BTC amount is out of range'); } - const bigIntBtc = new Big(btc); - const sats = bigIntBtc.times(100000000); - if (sats.lt(0) || sats.gt(maxSatoshi)) { + if (bigSats.lt(0) || bigSats.gt(maxSatoshi)) { throw new Error('BTC amount is out of range'); } - return BigInt(sats.toFixed(0)); + return BigInt(bigSats.toFixed(0)); } From 10e6cf17985d5ba1c8f0b9fe2ecf6bef3a3a6c03 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 15:00:15 +0200 Subject: [PATCH 140/362] 0.7.0 (#271) * 0.7.0 * chore: update changelog + snap manifest * chore: fix changelog --------- Co-authored-by: github-actions Co-authored-by: Charly Chevalier --- .../bitcoin-wallet-snap/CHANGELOG.md | 17 ++++++++++++++++- .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index ef573df7..e44d73da 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] + +### Changed + +- Use `QuickNode` as the main provider ([#250](https://github.com/MetaMask/snap-bitcoin-wallet/pull/250)) +- Workaround `QuickNode` fee estimation for testnet ([#267](https://github.com/MetaMask/snap-bitcoin-wallet/pull/267)) + - We temporarily changed the confirmation target block to a higher block number to make sure the API is not failing with an error. + +### Fixed + +- Fix fee estimation with `QuickNode` ([#266](https://github.com/MetaMask/snap-bitcoin-wallet/pull/266)), ([#261](https://github.com/MetaMask/snap-bitcoin-wallet/pull/261)) + - Properly uses `kvB` instead of `vB`. + - Will **NOT** throw an error if the account has not enough UTXOs when estimating the fees. + ## [0.6.1] ### Added @@ -186,7 +200,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.1...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.7.0...HEAD +[0.7.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.1...v0.7.0 [0.6.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.0...v0.6.1 [0.6.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.4.0...v0.5.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 5fcd32a6..d1cb75a6 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.6.1", + "version": "0.7.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 8f39d7eb..02aa375c 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.6.1", + "version": "0.7.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin Manager", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "BhTdGmGmVlyLoNBQQYn5PQjFC5iQ3OcNOj7B3QfY/Kc=", + "shasum": "rf1fLdl99k9r6/WQNcsoE6iv5bSzLPZfU0+DF3u4B1I=", "location": { "npm": { "filePath": "dist/bundle.js", From 355edb6de88004f38b79f15f78aba649211908c4 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 10 Oct 2024 16:14:57 +0200 Subject: [PATCH 141/362] refactor: better naming for KVB/KB/VB (#270) * refactor: better naming for KVB/KB/VB * refactor: update remaining KVB/KB/VB names --- .../bitcoin/chain/clients/quicknode.test.ts | 8 ++--- .../src/bitcoin/chain/clients/quicknode.ts | 4 +-- .../src/utils/unit.test.ts | 36 +++++++++++-------- .../bitcoin-wallet-snap/src/utils/unit.ts | 16 ++++----- 4 files changed, 35 insertions(+), 29 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts index 6bc5ccc2..570cd65e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts @@ -10,7 +10,7 @@ import { generateQuickNodeSendRawTransactionResp, } from '../../../../test/utils'; import { Config } from '../../../config'; -import { btcToSats, satsKVBToVB } from '../../../utils'; +import { btcToSats, satsKvbToVb } from '../../../utils'; import * as asyncUtils from '../../../utils/async'; import type { BtcAccount } from '../../wallet'; import { BtcAccountDeriver, BtcWallet } from '../../wallet'; @@ -271,9 +271,9 @@ describe('QuickNodeClient', () => { describe('getFeeRates', () => { it('returns fee rates', async () => { const { fetchSpy } = createMockFetch(); - const expectedFeeRateKVB = 0.0001; + const expectedFeeRateKvb = 0.0001; const mockResponse = generateQuickNodeEstimatefeeResp({ - feerate: expectedFeeRateKVB, + feerate: expectedFeeRateKvb, }); mockApiSuccessResponse({ @@ -286,7 +286,7 @@ describe('QuickNodeClient', () => { expect(result).toStrictEqual({ [Config.defaultFeeRate]: Number( - satsKVBToVB(btcToSats(expectedFeeRateKVB.toString())), + satsKvbToVb(btcToSats(expectedFeeRateKvb.toString())), ), }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts index d194378e..34d027e5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts @@ -10,7 +10,7 @@ import { compactError, logger, processBatch, - satsKVBToVB, + satsKvbToVb, } from '../../../utils'; import { FeeRate, TransactionStatus } from '../constants'; import type { @@ -257,7 +257,7 @@ export class QuickNodeClient implements IDataClient { // See: https://www.quicknode.com/docs/bitcoin/estimatesmartfee // > Estimates the smart fee per **kilobyte** ... feeRates[feeRate] = Number( - satsKVBToVB(btcToSats(response.result.feerate.toString())), + satsKvbToVb(btcToSats(response.result.feerate.toString())), ); }, ); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts index 6e9ca8e8..c37176f7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts @@ -3,25 +3,31 @@ import { btcToSats, maxSatoshi, minSatoshi, - satsKVBToVB, + satsKvbToVb, } from './unit'; -describe('satsKVBToVB', () => { +describe('satsKvbToVb', () => { it.each([ - { kvb: 1000n, vb: 1n }, - { kvb: 1001n, vb: 1n }, - { kvb: 1499n, vb: 1n }, - { kvb: 1500n, vb: 2n }, - { kvb: 1501n, vb: 2n }, - { kvb: 1999n, vb: 2n }, - { kvb: 123456789123456789n, vb: 123456789123457n }, - ])('converts from "$kvb" (sats/kvB) to "$vb" (sats/vB)', ({ kvb, vb }) => { - expect(satsKVBToVB(kvb)).toBe(vb); - }); + { fromKvb: 1000n, toVb: 1n }, + { fromKvb: 1001n, toVb: 1n }, + { fromKvb: 1499n, toVb: 1n }, + { fromKvb: 1500n, toVb: 2n }, + { fromKvb: 1501n, toVb: 2n }, + { fromKvb: 1999n, toVb: 2n }, + { fromKvb: 123456789123456789n, toVb: 123456789123457n }, + ])( + 'converts from "$fromKvb" (sats/kvB) to "$toVb" (sats/vB)', + ({ fromKvb, toVb }) => { + expect(satsKvbToVb(fromKvb)).toBe(toVb); + }, + ); - it.each([0, 1, 500, 999])('throws an error if not convertible: %s', (kvb) => { - expect(() => satsKVBToVB(kvb)).toThrow(Error); - }); + it.each([0, 1, 500, 999])( + 'throws an error if not convertible: %s', + (fromKvb) => { + expect(() => satsKvbToVb(fromKvb)).toThrow(Error); + }, + ); }); describe('satsToBtc', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts index 80722a9e..da14c0e0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts @@ -5,7 +5,7 @@ import { Config } from '../config'; // Bitcoin Core's RPC 'estimatesmartfee' returns values in BTC/kvB. // Hence, we will have to convert it back to standard BTC/vB. // See: https://github.com/bitcoin/bitcoin/blob/v28.0/src/policy/feerate.cpp#L17 -export const bitcoinCoreKB = 1000; +export const bitcoinCoreKb = 1000; // Maximum amount of satoshis export const maxSatoshi = 21 * 1e14; @@ -22,20 +22,20 @@ export const satsIn1Btc = 100000000; * @param kvb - The amount in sats/kvB. * @returns The converted amount to sats/vB. */ -export function satsKVBToVB(kvb: number | bigint): bigint { +export function satsKvbToVb(kvb: number | bigint): bigint { // It cannot be lower than 1 sat per vB. - if (kvb < bitcoinCoreKB) { + if (kvb < bitcoinCoreKb) { throw new Error(`Unable to convert kvB to vB: "${kvb}" is too small`); } - // NOTE: From here, we now we can safely divides by `bitcoinCoreKB` and we know it's not + // NOTE: From here, we now we can safely divides by `bitcoinCoreKb` and we know it's not // going to give a results of 0. // We still use `BigNumber` here and not `bigint` to be able to round up the result - const bigKVB = BigNumber(kvb.toString()); - const bigKB = BigNumber(bitcoinCoreKB); - const bigVB = bigKVB.div(bigKB).toFixed(0, BigNumber.ROUND_HALF_UP); + const bigKvb = BigNumber(kvb.toString()); + const bigKb = BigNumber(bitcoinCoreKb); + const bigVb = bigKvb.div(bigKb).toFixed(0, BigNumber.ROUND_HALF_UP); - return BigInt(bigVB.toString()); + return BigInt(bigVb.toString()); } /** From 80e53d9a950d63e8ae1a8fe7aca1bbeec407cb6c Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 14 Oct 2024 20:43:29 +0800 Subject: [PATCH 142/362] feat(QuickNode): add fallback solution if `QuickNode` RPC `estimatesmartfee` returns data unavailable (#269) * feat(QuickNode): add feerate fallback solution * fix(QuickNode): correct comment wording * fix(QuickNode): fee rate unit test * chore(QuickNode): update `prepareEstimateFeeRateMock` to `mockEstimateFeeRate ` * chore(QuickNode): update `DefaultTxMinFeeRateInBtcPerKvb` comment * chore(QuickNode): update test title * refactor: better naming for KVB/KB/VB * refactor: update remaining KVB/KB/VB names * chore(QuickNode): update PR comment * chore: address comment * chore: address PR comment * chore: update gas fee estimation error handle --------- Co-authored-by: Charly Chevalier --- .../bitcoin/chain/clients/quicknode.test.ts | 107 ++++++++++++++++-- .../src/bitcoin/chain/clients/quicknode.ts | 71 ++++++++++-- .../bitcoin/chain/clients/quicknode.types.ts | 27 ++++- .../src/bitcoin/wallet/constants.ts | 4 + .../bitcoin-wallet-snap/src/utils/fee.test.ts | 49 ++++++++ .../bitcoin-wallet-snap/src/utils/fee.ts | 27 +++++ .../test/fixtures/quicknode.json | 17 +++ .../bitcoin-wallet-snap/test/utils.ts | 29 ++++- 8 files changed, 310 insertions(+), 21 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/fee.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts index 570cd65e..2382c3f4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts @@ -8,16 +8,20 @@ import { generateQuickNodeGetUtxosResp, generateQuickNodeGetRawTransactionResp, generateQuickNodeSendRawTransactionResp, + generateQuickNodeMempoolResp, } from '../../../../test/utils'; import { Config } from '../../../config'; -import { btcToSats, satsKvbToVb } from '../../../utils'; +import { btcToSats, logger, satsKvbToVb } from '../../../utils'; import * as asyncUtils from '../../../utils/async'; import type { BtcAccount } from '../../wallet'; import { BtcAccountDeriver, BtcWallet } from '../../wallet'; import { TransactionStatus } from '../constants'; import { DataClientError } from '../exceptions'; -import { QuickNodeClient } from './quicknode'; -import type { QuickNodeResponse } from './quicknode.types'; +import { NoFeeRateError, QuickNodeClient } from './quicknode'; +import type { + QuickNodeEstimateFeeResponse, + QuickNodeResponse, +} from './quicknode.types'; jest.mock('../../../utils/logger'); jest.mock('../../../utils/snap'); @@ -32,6 +36,10 @@ describe('QuickNodeClient', () => { ): Promise { return super.post(body); } + + getPriorityMap() { + return this._priorityMap; + } } const createMockFetch = () => { @@ -269,16 +277,57 @@ describe('QuickNodeClient', () => { }); describe('getFeeRates', () => { - it('returns fee rates', async () => { - const { fetchSpy } = createMockFetch(); - const expectedFeeRateKvb = 0.0001; - const mockResponse = generateQuickNodeEstimatefeeResp({ - feerate: expectedFeeRateKvb, + const mockEstimateFeeRate = ({ + fetchSpy, + mempoolminfee = 0.0001, + minrelaytxfee = 0.0001, + smartFee, + estimateFeeErrors = [], + }: { + fetchSpy: jest.SpyInstance; + mempoolminfee?: number; + minrelaytxfee?: number; + smartFee?: number; + estimateFeeErrors?: string[]; + }) => { + const mockMempoolInfoResponse = generateQuickNodeMempoolResp({ + mempoolminfee, + minrelaytxfee, + }); + const mockEstimateFeeResponse = generateQuickNodeEstimatefeeResp({ + feerate: smartFee, }); + // Mock Mempool Info Response + mockApiSuccessResponse({ + fetchSpy, + mockResponse: mockMempoolInfoResponse, + }); + + let estimateFeeResponse: QuickNodeEstimateFeeResponse = + mockEstimateFeeResponse; + if (estimateFeeErrors && estimateFeeErrors.length > 0) { + estimateFeeResponse = { + ...mockEstimateFeeResponse, + result: { + ...mockEstimateFeeResponse.result, + errors: estimateFeeErrors, + }, + }; + } + // Mock Estimate Fee Response mockApiSuccessResponse({ fetchSpy, - mockResponse, + mockResponse: estimateFeeResponse, + }); + }; + + it('returns fee rates', async () => { + const { fetchSpy } = createMockFetch(); + const expectedFeeRateKvb = 0.0002; + mockEstimateFeeRate({ + fetchSpy, + smartFee: expectedFeeRateKvb, }); const client = createQuickNodeClient(networks.testnet); @@ -291,6 +340,46 @@ describe('QuickNodeClient', () => { }); }); + it('does not throw any error if the fee rate is unavailable', async () => { + const { fetchSpy } = createMockFetch(); + const mempoolminfee = 0.0001; + const minrelaytxfee = 0.0001; + + mockEstimateFeeRate({ + fetchSpy, + smartFee: undefined, + minrelaytxfee, + mempoolminfee, + estimateFeeErrors: [NoFeeRateError], + }); + + const client = createQuickNodeClient(networks.testnet); + const target = client.getPriorityMap()[Config.defaultFeeRate]; + const result = await client.getFeeRates(); + + expect(logger.warn).toHaveBeenCalledWith( + `The feerate is unavailable on target block ${target}, use mempool data 'mempoolminfee' instead`, + ); + expect(result).toStrictEqual({ + [Config.defaultFeeRate]: Number( + satsKvbToVb(btcToSats(minrelaytxfee.toString())), + ), + }); + }); + + it('throws an error if the fee rate is unavailable and the api response is an unexpected error', async () => { + const { fetchSpy } = createMockFetch(); + mockEstimateFeeRate({ + fetchSpy, + smartFee: undefined, + estimateFeeErrors: ['Unexpected error'], + }); + + const client = createQuickNodeClient(networks.testnet); + + await expect(client.getFeeRates()).rejects.toThrow(DataClientError); + }); + it('throws DataClientError if the api response is invalid', async () => { const { fetchSpy } = createMockFetch(); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts index 34d027e5..21ebd828 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts @@ -8,6 +8,7 @@ import { Config } from '../../../config'; import { btcToSats, compactError, + getMinimumFeeRateInKvb, logger, processBatch, satsKvbToVb, @@ -22,6 +23,7 @@ import type { DataClientGetFeeRatesResp, } from '../data-client'; import { DataClientError } from '../exceptions'; +import type { QuickNodeGetMempoolResponse } from './quicknode.types'; import { type QuickNodeClientOptions, type QuickNodeGetBalancesResponse, @@ -35,6 +37,7 @@ import { QuickNodeSendTransactionResponseStruct, QuickNodeGetTransactionStruct, QuickNodeEstimateFeeResponseStruct, + QuickNodeGetMempoolStruct, } from './quicknode.types'; const MAINNET_CONFIRMATION_TARGET = { @@ -52,6 +55,8 @@ const TESTNET_CONFIRMATION_TARGET = { [FeeRate.Slow]: 23, }; +export const NoFeeRateError = 'Insufficient data or no feerate found'; + export class QuickNodeClient implements IDataClient { protected readonly _options: QuickNodeClientOptions; @@ -239,25 +244,63 @@ export class QuickNodeClient implements IDataClient { }; const feeRates: Record = {}; + + const { + result: { mempoolminfee, minrelaytxfee }, + } = await this.getMempoolInfo(); + // keep this batch process in case we have to switch to support multiple fee rates. await processBatch( Object.entries(processItems), async ([feeRate, target]) => { - const response = - await this.submitJsonRPCRequest({ - request: { - method: 'estimatesmartfee', - params: [target], - }, - responseStruct: QuickNodeEstimateFeeResponseStruct, - }); + const { + result: { feerate, errors }, + } = await this.submitJsonRPCRequest({ + request: { + method: 'estimatesmartfee', + params: [target], + }, + responseStruct: QuickNodeEstimateFeeResponseStruct, + }); + + // When the feerate data is unavailable, + // the api response will look like: + // { + // "result": { + // "errors": ['Insufficient data or no feerate found'], + // "blocks": 2 + // }, + // "error": null, + // "id": null + // } + // In that case, we will use the mempool min fee instead. + if ( + Array.isArray(errors) && + errors.length === 1 && + errors[0] === NoFeeRateError + ) { + logger.warn( + `The feerate is unavailable on target block ${target}, use mempool data 'mempoolminfee' instead`, + ); + } else if (errors) { + throw new DataClientError( + `Failed to get fee rate from quicknode: ${JSON.stringify(errors)}`, + ); + } + + // A safeguard to ensure the feerate is not reject by the chain with the min requirement by mempool + const feeRateInBtcPerKvb = getMinimumFeeRateInKvb( + feerate ?? 0, + mempoolminfee, + minrelaytxfee, + ); // The fee rate will be returned in BTC/kvB unit (note the kilobyte here) // e.g. 0.00005081 // See: https://www.quicknode.com/docs/bitcoin/estimatesmartfee // > Estimates the smart fee per **kilobyte** ... feeRates[feeRate] = Number( - satsKvbToVb(btcToSats(response.result.feerate.toString())), + satsKvbToVb(btcToSats(feeRateInBtcPerKvb.toString())), ); }, ); @@ -265,6 +308,16 @@ export class QuickNodeClient implements IDataClient { return feeRates; } + protected async getMempoolInfo(): Promise { + return await this.submitJsonRPCRequest({ + request: { + method: 'getmempoolinfo', + params: [], + }, + responseStruct: QuickNodeGetMempoolStruct, + }); + } + async sendTransaction( signedTransaction: string, ): Promise { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.types.ts index c4efe147..5553a281 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.types.ts @@ -1,6 +1,6 @@ import type { Network } from 'bitcoinjs-lib'; import type { Infer } from 'superstruct'; -import { array, number, object, optional, string } from 'superstruct'; +import { array, boolean, number, object, optional, string } from 'superstruct'; export type QuickNodeClientOptions = { network: Network; @@ -60,7 +60,11 @@ export type QuickNodeSendTransactionResponse = QuickNodeResponse & export const QuickNodeEstimateFeeResponseStruct = object({ result: object({ blocks: number(), - feerate: number(), + // if the fee rate is not available, the field `feerate` will be omitted + feerate: optional(number()), + // if the fee rate is not available, the field `errors` will be present + // e.g errors: ["Insufficient data or no feerate found"] + errors: optional(array(string())), }), }); @@ -88,3 +92,22 @@ export const QuickNodeGetTransactionStruct = object({ export type QuickNodeGetTransaction = QuickNodeResponse & Infer; + +// Reference: https://www.quicknode.com/docs/bitcoin/getmempoolinfo +export const QuickNodeGetMempoolStruct = object({ + result: object({ + loaded: boolean(), + size: number(), + bytes: number(), + usage: number(), + maxmempool: number(), + mempoolminfee: number(), + minrelaytxfee: number(), + unbroadcastcount: number(), + incrementalrelayfee: number(), + fullrbf: boolean(), + }), +}); + +export type QuickNodeGetMempoolResponse = QuickNodeResponse & + Infer; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/constants.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/constants.ts index 7fb64ebb..45d0b771 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/constants.ts @@ -29,3 +29,7 @@ export const DustLimit = { // Maximum weight in bytes for a standard transaction export const MaxStandardTxWeight = 400000; + +// Default minimum fee rate in BTC/KvB +// To align with the response from RPC provider, we use BTC unit in KvB +export const DefaultTxMinFeeRateInBtcPerKvb = 0.0001 \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/fee.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/fee.test.ts new file mode 100644 index 00000000..f512af89 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/fee.test.ts @@ -0,0 +1,49 @@ +import { DefaultTxMinFeeRateInBtcPerKvb } from '../bitcoin/wallet'; +import { getMinimumFeeRateInKvb } from './fee'; + +describe('getMinimumFeeRateInKvb', () => { + it.each( + [ + // when all inputs are same + [1, 1, 1, 1], + // when minrelaytxfee is the highest + [1, 2, 3, 3], + // when mempoolminfee is the highest + [1, 3, 2, 3], + // when smartFee is the highest + [4, 2, 1, 4], + // when all inputs less than DefaultTxMinFeeRateInBtcPerKvb + [ + DefaultTxMinFeeRateInBtcPerKvb / 10, + DefaultTxMinFeeRateInBtcPerKvb / 10, + DefaultTxMinFeeRateInBtcPerKvb / 10, + DefaultTxMinFeeRateInBtcPerKvb, + ], + ].map((data) => ({ + smartFee: data[0], + mempoolminfee: data[1], + minrelaytxfee: data[2], + expected: data[3], + })), + )( + 'returns the minimum required fee for: smartFee=$smartFee, mempoolminfee=$mempoolminfee, minrelaytxfee=$minrelaytxfee', + ({ + smartFee, + mempoolminfee, + minrelaytxfee, + expected, + }: { + smartFee: number; + mempoolminfee: number; + minrelaytxfee: number; + expected: number; + }) => { + const result = getMinimumFeeRateInKvb( + smartFee, + mempoolminfee, + minrelaytxfee, + ); + expect(result).toStrictEqual(expected); + }, + ); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/fee.ts b/merged-packages/bitcoin-wallet-snap/src/utils/fee.ts index 1902ff34..3bca7311 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/fee.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/fee.ts @@ -2,6 +2,7 @@ import { assert, enums } from 'superstruct'; import type { Fee } from '../bitcoin/chain'; import { FeeRate } from '../bitcoin/chain/constants'; +import { DefaultTxMinFeeRateInBtcPerKvb } from '../bitcoin/wallet'; import { Config } from '../config'; import { FeeRateUnavailableError } from '../exceptions'; @@ -28,3 +29,29 @@ export function getFeeRate( // Fee rate cannot be lower than 1 return Math.max(Number(selectedFees.rate), 1); } + +/** + * Estimate the minimum fee rate considering required fee. + * Reference: https://github.com/bitcoin/bitcoin/blob/v28.0/src/wallet/fees.cpp#L58-L81. + * + * @param smartFee - The fee rate estimated by the estimatesmartfee rpc in BTC/kvB. + * @param mempoolminfee - Minimum fee rate in BTC/kvB for tx to be accepted. + * @param minrelaytxfee - Current minimum relay fee in BTC/kB for transactions. + * @returns The minimum fee rate in BTC/kvB. + */ +export function getMinimumFeeRateInKvb( + smartFee: number, + mempoolminfee: number, + minrelaytxfee: number, +) { + // Obey mempool min fee when using smart fee estimation + const minFee = Math.max(smartFee, mempoolminfee); + // Prevent user from paying a fee below the required fee rate - `minrelaytxfee` + const minRequiredFee = Math.max( + minFee, + minrelaytxfee, + DefaultTxMinFeeRateInBtcPerKvb, + ); + + return minRequiredFee; +} diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/quicknode.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/quicknode.json index 6078438c..62136ed4 100644 --- a/merged-packages/bitcoin-wallet-snap/test/fixtures/quicknode.json +++ b/merged-packages/bitcoin-wallet-snap/test/fixtures/quicknode.json @@ -67,5 +67,22 @@ } ], "jsonrpc": "2.0" + }, + "getmempoolinfo": { + "result": { + "loaded": true, + "size": 124821, + "bytes": 83361781, + "usage": 481941024, + "total_fee": 1.11361224, + "maxmempool": 2048000000, + "mempoolminfee": 0.00001, + "minrelaytxfee": 0.00001, + "incrementalrelayfee": 0.00001, + "unbroadcastcount": 0, + "fullrbf": true + }, + "error": null, + "id": null } } diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index e3df0885..b693a10f 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -428,7 +428,7 @@ export function generateQuickNodeEstimatefeeResp( { feerate }: { - feerate: number; + feerate: number | undefined; } ) { const template = quickNodeData.estimatesmartfeeResp; @@ -443,6 +443,33 @@ export function generateQuickNodeEstimatefeeResp( return data; } +/** + * Generate QuickNode get mempool info response. + * + * @param params - The params to generate mock get mempool info response. + * @param params.mempoolminfee - Minimum fee rate in BTC/kvB for tx to be accepted. + * @param params.minrelaytxfee - Minimum relay fee in BTC/kB for transactions. + * @returns A QuickNode get mempool info response. + */ +export function generateQuickNodeMempoolResp( { + mempoolminfee = Math.max(1000, randomNum(10000)), + minrelaytxfee, +} : { + mempoolminfee?: number + minrelaytxfee?: number +}) { + const template = quickNodeData.getmempoolinfo; + const data = { + ...template, + result: { + ...template.result, + mempoolminfee: mempoolminfee, + minrelaytxfee: minrelaytxfee ?? Math.max(mempoolminfee, randomNum(10000)), + }, + }; + return data; +} + /** * Generate QuickNode send rawtransaction response. * From a6e878e4d44b6619995b90b62e784b4fe7dc1835 Mon Sep 17 00:00:00 2001 From: Daniel Rocha <68558152+danroc@users.noreply.github.com> Date: Mon, 14 Oct 2024 14:53:49 +0200 Subject: [PATCH 143/362] feat: rename proposed name to "Bitcoin" (#283) --- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 02aa375c..85926c92 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,7 +1,7 @@ { "version": "0.7.0", "description": "Manage Bitcoin using MetaMask", - "proposedName": "Bitcoin Manager", + "proposedName": "Bitcoin", "repository": { "type": "git", "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" From 9c08096ea7ad2dd208d9ce131406161d7c0decf3 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Wed, 16 Oct 2024 17:40:44 +0800 Subject: [PATCH 144/362] feat: send ui (#281) * fix: upate configs to support dynamic ui * feat: initial send ui * fix: manifest * fix: drynrun off * fix: address validation * refactor: send flow * fix: remove old screen * fix: total * fix: hardcode to replaceable * refactor: send flow event handler * fix: loader style * fix: permissions * refactor: using new components * fix: working initial screen * feat: add validations * fix: onUserInput validation * refactor: estimate * fix: formValidation amount check * feat: currency switcher * fix: lint * fix: store fee in request * feat: Transaction summary screen (#252) * [WIP] TX summary screen * basic Review screen implementation * fix rebase * refactor: to use updateSendFlow * refactor: to store all transaction params in request state * fix: header alignment * fix: remove unused * chore: bump snaps sdk * fix: back and cancel * fix: disable dryrun * refactor: form errors * fix: clear button * fix: show jazzicon if the recipient is valid * fix: loading screen not appearing after setting a valid amount the first time * fix: send flow error handling * feat(deps): add jest-transform-stub, bitcoin-address-validation * refactor: types and move renders to a separate file * fix: remove number type in input * fix: set empty string as address if its not set in the formState * test: add send-many-controller test * feat: add max button * refactor: create startTransactionFlow * test: add start-send-transaction-flow tests * fix: tests * test: add utils test * fix: revert site changes * fix: remove unused bignumber and remove resolution * fix: undo static log level * chore: remove logs and fix lint * fix: check if total exceeds balance * fix: total amount omitting fee * chore: bump snaps deps * fix: lint issue from provider type * Apply suggestions from code review Co-authored-by: Charly Chevalier * fix: remove start request from keyring * refactor: CaipToNetowrkName to Caip2ChainIdToNetworkName * chore: remove log * deps: remove unused deps * refactor: resolveInterface to a method * refactor: rename variable and refactor ternary to getAmountFrom * fix: lint and variable name * chore: fix lint * refactor: create TransactionStatus enum * Update packages/snap/src/ui/components/SendFlowHeader.tsx Co-authored-by: Charly Chevalier * fix: create utility func getAssetTypeFromScope * fix: remove jazzicon dep * Apply suggestions from code review Co-authored-by: Charly Chevalier * refactor: max send to a method * fix: remove unused account type * fix: add comment and fix lint error * fix: remove unused btc_initiateSendMany * fix: remove truncate in favor of shorten * fix: refactor sendMany case into another method * fix: manifest * fix: add getNetworkNameFromScope helper * fix: remove request on rejection * refactor: generateDefaultSendFlowParams into transaction utils * refactor: rates mock into a separate file * chore: add comment * Apply suggestions from code review Co-authored-by: Charly Chevalier Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * refactor: errors enum * refactor: merge defaultSendManyParams and sendStateToSendManyParams * fix: lint * refactor: createSendUIDialog, and rename functions * refactor: rates and balances call to getRatesAndBalances * refactor: create generateSendFlowRequest helper * fix: balances value * Apply suggestions from code review Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> * fix: rename to createRatesAndBalancesMock * fix: tests * fix: nit * fix: remove duplicate * fix: validate startSEndTransactionFlowParamsStruct * fix: move dep to devdep * fix: naming * fix: remove dep * chore: remove dep * fix: tests * fix: test names * fix: add try catch in handleSendMany * fix: remove request if there is an error during sendmany * fix: remove rejected * fix: isSnapException. * fix: lint --------- Co-authored-by: Guillaume Roux Co-authored-by: Charly Chevalier Co-authored-by: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> --- .../bitcoin-wallet-snap/.depcheckrc.json | 2 +- .../bitcoin-wallet-snap/.eslintrc.js | 9 + .../bitcoin-wallet-snap/jest.config.js | 4 + .../bitcoin-wallet-snap/package.json | 8 +- .../bitcoin-wallet-snap/snap.config.ts | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/constants.ts | 7 + .../bitcoin-wallet-snap/src/exceptions.ts | 21 + .../src/{index.test.ts => index.test.tsx} | 4 - .../src/{index.ts => index.tsx} | 47 + .../bitcoin-wallet-snap/src/keyring.test.ts | 27 +- .../bitcoin-wallet-snap/src/keyring.ts | 86 +- .../bitcoin-wallet-snap/src/permissions.ts | 11 +- .../src/rpcs/__tests__/helper.ts | 186 ++++ .../src/rpcs/get-rates-and-balances.test.ts | 103 +++ .../src/rpcs/get-rates-and-balances.ts | 66 ++ .../src/rpcs/sendmany.test.ts | 301 +------ .../bitcoin-wallet-snap/src/rpcs/sendmany.ts | 199 +---- .../rpcs/start-send-transaction-flow.test.ts | 212 +++++ .../src/rpcs/start-send-transaction-flow.ts | 165 ++++ .../src/stateManagement.test.ts | 331 ++++++- .../src/stateManagement.ts | 112 +++ .../src/ui/components/AccountSelector.tsx | 70 ++ .../src/ui/components/ReviewTransaction.tsx | 96 +++ .../src/ui/components/SendFlow.tsx | 77 ++ .../src/ui/components/SendFlowFooter.tsx | 30 + .../src/ui/components/SendFlowHeader.tsx | 32 + .../src/ui/components/SendForm.tsx | 162 ++++ .../src/ui/components/TransactionSummary.tsx | 76 ++ .../src/ui/components/index.ts | 2 + .../controller/send-many-controller.test.ts | 808 ++++++++++++++++++ .../src/ui/controller/send-many-controller.ts | 285 ++++++ .../src/ui/images/btc-halo.svg | 38 + .../bitcoin-wallet-snap/src/ui/images/btc.svg | 12 + .../src/ui/images/jazzicon1.svg | 11 + .../src/ui/images/jazzicon2.svg | 11 + .../src/ui/images/jazzicon3.svg | 11 + .../src/ui/render-interfaces.tsx | 104 +++ .../bitcoin-wallet-snap/src/ui/types.ts | 84 ++ .../bitcoin-wallet-snap/src/ui/utils.test.ts | 556 ++++++++++++ .../bitcoin-wallet-snap/src/ui/utils.ts | 357 ++++++++ .../src/utils/__mocks__/snap.ts | 2 + .../bitcoin-wallet-snap/src/utils/rates.ts | 6 + .../bitcoin-wallet-snap/src/utils/snap.ts | 15 + .../src/utils/transaction.ts | 58 ++ .../bitcoin-wallet-snap/tsconfig.json | 6 +- 46 files changed, 4313 insertions(+), 501 deletions(-) rename merged-packages/bitcoin-wallet-snap/src/{index.test.ts => index.test.tsx} (97%) rename merged-packages/bitcoin-wallet-snap/src/{index.ts => index.tsx} (68%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/AccountSelector.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/ReviewTransaction.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlow.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowFooter.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/TransactionSummary.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/images/btc-halo.svg create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/images/btc.svg create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon1.svg create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon2.svg create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon3.svg create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/types.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/utils.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/rates.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/transaction.ts diff --git a/merged-packages/bitcoin-wallet-snap/.depcheckrc.json b/merged-packages/bitcoin-wallet-snap/.depcheckrc.json index 03d051fa..98a3b27a 100644 --- a/merged-packages/bitcoin-wallet-snap/.depcheckrc.json +++ b/merged-packages/bitcoin-wallet-snap/.depcheckrc.json @@ -1,3 +1,3 @@ { - "ignores": ["@metamask/auto-changelog"] + "ignores": ["@metamask/auto-changelog", "jest-transform-stub"] } diff --git a/merged-packages/bitcoin-wallet-snap/.eslintrc.js b/merged-packages/bitcoin-wallet-snap/.eslintrc.js index 8090a2b7..6e53bdd3 100644 --- a/merged-packages/bitcoin-wallet-snap/.eslintrc.js +++ b/merged-packages/bitcoin-wallet-snap/.eslintrc.js @@ -1,5 +1,14 @@ module.exports = { extends: ['../../.eslintrc.js'], + rules: { + // This allows importing the `Text` JSX component. + '@typescript-eslint/no-shadow': [ + 'error', + { + allow: ['Text'], + }, + ], + }, parserOptions: { tsconfigRootDir: __dirname, diff --git a/merged-packages/bitcoin-wallet-snap/jest.config.js b/merged-packages/bitcoin-wallet-snap/jest.config.js index ccfae1cb..b2ee112b 100644 --- a/merged-packages/bitcoin-wallet-snap/jest.config.js +++ b/merged-packages/bitcoin-wallet-snap/jest.config.js @@ -3,6 +3,7 @@ module.exports = { transform: { '^.+\\.(t|j)sx?$': 'ts-jest', }, + setupFilesAfterEnv: ['/jest.setup.ts'], restoreMocks: true, resetMocks: true, @@ -32,4 +33,7 @@ module.exports = { // A list of reporter names that Jest uses when writing coverage reports coverageReporters: ['html', 'json-summary', 'text'], + moduleNameMapper: { + '^.+.(svg)$': 'jest-transform-stub', + }, }; diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index d1cb75a6..3a5ddf31 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -41,15 +41,16 @@ "@metamask/eslint-config-typescript": "^12.1.0", "@metamask/key-tree": "^9.1.2", "@metamask/keyring-api": "^8.1.3", - "@metamask/snaps-cli": "^6.3.0", - "@metamask/snaps-jest": "^8.3.0", - "@metamask/snaps-sdk": "^6.2.0", + "@metamask/snaps-cli": "^6.4.0", + "@metamask/snaps-jest": "^8.5.0", + "@metamask/snaps-sdk": "6.8.0", "@metamask/utils": "^9.1.0", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", "async-mutex": "^0.3.2", "bignumber.js": "9.1.2", "bip32": "4.0.0", + "bitcoin-address-validation": "^2.2.3", "bitcoinjs-lib": "6.1.5", "coinselect": "3.1.13", "dotenv": "^16.4.5", @@ -63,6 +64,7 @@ "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-promise": "^6.1.1", "jest": "^29.5.0", + "jest-transform-stub": "2.0.0", "prettier": "^2.7.1", "rimraf": "^3.0.2", "superstruct": "^1.0.3", diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts index f7147040..8b7b79ea 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.config.ts +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -6,7 +6,7 @@ require('dotenv').config(); const config: SnapConfig = { bundler: 'webpack', - input: resolve(__dirname, 'src/index.ts'), + input: resolve(__dirname, 'src/index.tsx'), server: { port: 8080, }, diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 85926c92..a20b724c 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "rf1fLdl99k9r6/WQNcsoE6iv5bSzLPZfU0+DF3u4B1I=", + "shasum": "PGMPA0TkbP5R+DTllZ01Bz0BAuycpI0iAc0hKUMqOGg=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/constants.ts b/merged-packages/bitcoin-wallet-snap/src/constants.ts index 308df1e7..e073c180 100644 --- a/merged-packages/bitcoin-wallet-snap/src/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/src/constants.ts @@ -1,3 +1,5 @@ +import type { CaipChainId } from '@metamask/utils'; + export enum Caip2ChainId { Mainnet = 'bip122:000000000019d6689c085ae165831e93', Testnet = 'bip122:000000000933ea01ad0ee984209779ba', @@ -7,3 +9,8 @@ export enum Caip2Asset { Btc = 'bip122:000000000019d6689c085ae165831e93/slip44:0', TBtc = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0', } + +export const Caip2ChainIdToNetworkName: Record = { + [Caip2ChainId.Mainnet]: 'Bitcoin Mainnet', + [Caip2ChainId.Testnet]: 'Bitcoin Testnet', +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/exceptions.ts index 4d5161f1..f0ef7986 100644 --- a/merged-packages/bitcoin-wallet-snap/src/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/exceptions.ts @@ -17,3 +17,24 @@ export class FeeRateUnavailableError extends CustomError { super(errMsg ?? `No fee rates available`); } } + +export class SendFlowRequestNotFoundError extends CustomError { + constructor(errMsg?: string) { + super(errMsg ?? `Send flow request not found`); + } +} + +/** + * Determines if the given error is a Snap exception. + * + * @param error - The error instance to be checked. + * @returns A boolean indicating whether the error is a Snap . + */ +export function isSnapException(error: Error): boolean { + const errors = [ + AccountNotFoundError, + MethodNotImplementedError, + SendFlowRequestNotFoundError, + ]; + return errors.some((errType) => error instanceof errType); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.tsx similarity index 97% rename from merged-packages/bitcoin-wallet-snap/src/index.test.ts rename to merged-packages/bitcoin-wallet-snap/src/index.test.tsx index 5cdbf2d4..4e90b4ee 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.tsx @@ -245,16 +245,12 @@ describe('onKeyringRequest', () => { it('throws a `Permission denied` error if the MetaMask origin requests a method that is not on the allowed list', async () => { for (const method of [ - keyringApi.KeyringRpcMethod.SubmitRequest, keyringApi.KeyringRpcMethod.ApproveRequest, keyringApi.KeyringRpcMethod.RejectRequest, keyringApi.KeyringRpcMethod.GetRequest, keyringApi.KeyringRpcMethod.ListRequests, keyringApi.KeyringRpcMethod.ExportAccount, keyringApi.KeyringRpcMethod.UpdateAccount, - InternalRpcMethod.GetTransactionStatus, - InternalRpcMethod.EstimateFee, - InternalRpcMethod.GetMaxSpendableBalance, ]) { await expect( onKeyringRequest({ diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.tsx similarity index 68% rename from merged-packages/bitcoin-wallet-snap/src/index.ts rename to merged-packages/bitcoin-wallet-snap/src/index.tsx index 671727e6..f6dc04a8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.tsx @@ -2,6 +2,7 @@ import { handleKeyringRequest } from '@metamask/keyring-api'; import { type OnRpcRequestHandler, type OnKeyringRequestHandler, + type OnUserInputHandler, type Json, UnauthorizedError, SnapError, @@ -21,7 +22,14 @@ import { estimateFee, getMaxSpendableBalance, } from './rpcs'; +import type { StartSendTransactionFlowParams } from './rpcs/start-send-transaction-flow'; +import { startSendTransactionFlow } from './rpcs/start-send-transaction-flow'; import { KeyringStateManager } from './stateManagement'; +import { + isSendFormEvent, + SendManyController, +} from './ui/controller/send-many-controller'; +import type { SendFlowContext, SendFormState } from './ui/types'; import { isSnapRpcError, logger } from './utils'; export const validateOrigin = (origin: string, method: string): void => { @@ -57,6 +65,12 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ return await getMaxSpendableBalance( request.params as GetMaxSpendableBalanceParams, ); + case InternalRpcMethod.StartSendTransactionFlow: { + return await startSendTransactionFlow( + request.params as StartSendTransactionFlowParams, + ); + } + default: throw new MethodNotFoundError() as unknown as Error; } @@ -103,3 +117,36 @@ export const onKeyringRequest: OnKeyringRequestHandler = async ({ throw snapError; } }; + +export const onUserInput: OnUserInputHandler = async ({ + id, + event, + context, +}) => { + const { requestId } = context as SendFlowContext; + const state = await snap.request({ + method: 'snap_getInterfaceState', + params: { id }, + }); + + const stateManager = new KeyringStateManager(true); + const request = await stateManager.getRequest(requestId); + + if (!request) { + throw new Error('Request not found'); + } + + if (isSendFormEvent(event)) { + const sendManyController = new SendManyController({ + stateManager, + request, + context: context as SendFlowContext, + interfaceId: id, + }); + await sendManyController.handleEvent( + event, + context as SendFlowContext, + state.sendForm as SendFormState, + ); + } +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts index ff3c103e..a5aa2674 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts @@ -22,6 +22,20 @@ jest.mock('@metamask/keyring-api', () => ({ emitSnapKeyringEvent: jest.fn(), })); +jest.mock('./rpcs/get-rates-and-balances', () => ({ + createRatesAndBalances: () => + jest.fn().mockResolvedValue({ + rates: { + value: 'mockRates', + error: '', + }, + balances: { + value: 'mockBalance', + error: '', + }, + })(), +})); + class MockBtcKeyring extends BtcKeyring { // Mock protected method getKeyringAccountNameSuggestion to public for test purpose public getKeyringAccountNameSuggestion(options?: CreateAccountOptions) { @@ -322,9 +336,14 @@ describe('BtcKeyring', () => { describe('submitRequest', () => { it('calls SnapRpcHandler if the method support', async () => { + // Mocking user interaction const caip2ChainId = Caip2ChainId.Testnet; const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const { instance: keyring, sendManySpy } = createMockKeyring(stateMgr); + const { + instance: keyring, + sendManySpy, + getBalanceRpcSpy, + } = createMockKeyring(stateMgr); const { sender, keyringAccount } = await createSender(caip2ChainId); getWalletSpy.mockResolvedValue({ account: keyringAccount as unknown as KeyringAccount, @@ -335,6 +354,12 @@ describe('BtcKeyring', () => { sendManySpy.mockResolvedValue({ txId: 'txid', }); + getBalanceRpcSpy.mockResolvedValue({ + [Caip2Asset.TBtc]: { + amount: '1', + unit: Config.unit, + }, + }); const params = { scope: caip2ChainId, diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index 5987eb75..a5dcceb4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -11,24 +11,32 @@ import { import { MethodNotFoundError, UnauthorizedError, + UserRejectedRequestError, type Json, } from '@metamask/snaps-sdk'; import type { Infer } from 'superstruct'; import { assert, object, StructError } from 'superstruct'; import { v4 as uuidv4 } from 'uuid'; -import type { BtcAccount, BtcWallet } from './bitcoin/wallet'; +import { type BtcAccount, type BtcWallet } from './bitcoin/wallet'; import { Config } from './config'; import { Caip2ChainId } from './constants'; import { AccountNotFoundError, MethodNotImplementedError } from './exceptions'; import { Factory } from './factory'; import { getBalances, type SendManyParams, sendMany } from './rpcs'; -import type { KeyringStateManager, Wallet } from './stateManagement'; +import { createRatesAndBalances } from './rpcs/get-rates-and-balances'; +import { + TransactionStatus, + type KeyringStateManager, + type Wallet, +} from './stateManagement'; +import { generateSendFlowRequest, getAssetTypeFromScope } from './ui/utils'; import { getProvider, ScopeStruct, logger, verifyIfAccountValid, + createSendUIDialog, } from './utils'; export type KeyringOptions = Record & { @@ -180,11 +188,14 @@ export class BtcKeyring implements Keyring { this.verifyIfMethodValid(method, walletData.account); switch (method) { - case 'btc_sendmany': - return (await sendMany(account, this._options.origin, { - ...params, - scope: walletData.scope, - } as unknown as SendManyParams)) as unknown as Json; + case 'btc_sendmany': { + return await this.handleSendMany({ + scope: scope as Caip2ChainId, + walletData, + account, + params: params as SendManyParams, + }); + } default: throw new MethodNotFoundError() as unknown as Error; } @@ -194,6 +205,7 @@ export class BtcKeyring implements Keyring { event: KeyringEvent, data: Record, ): Promise { + // @ts-expect-error TODO: fix type await emitSnapKeyringEvent(getProvider(), event, data); } @@ -283,4 +295,64 @@ export class BtcKeyring implements Keyring { return ''; } } + + protected async handleSendMany({ + scope, + walletData, + account, + params, + }: { + scope: Caip2ChainId; + walletData: Wallet; + account: BtcAccount; + params: SendManyParams; + }): Promise { + const asset = getAssetTypeFromScope(scope); + + const { rates, balances } = await createRatesAndBalances({ + asset, + scope, + btcAccount: account, + }); + + if (rates.error || balances.error) { + throw new Error( + `Error fetching rates and balances: ${rates.error ?? balances.error}`, + ); + } + + const sendFlowRequest = await generateSendFlowRequest( + walletData, + TransactionStatus.Review, + rates.value, + balances.value, + params, + ); + + await this._stateMgr.upsertRequest(sendFlowRequest); + const result = await createSendUIDialog(sendFlowRequest.id); + + if (!result) { + await this._stateMgr.removeRequest(sendFlowRequest.id); + throw new UserRejectedRequestError() as unknown as Error; + } + + // Get the latest send flow request from the state manager + // this has been updated via onInputHandler + await this._stateMgr.upsertRequest(sendFlowRequest); + try { + const tx = await sendMany(account, this._options.origin, { + ...sendFlowRequest.transaction, + scope, + }); + + sendFlowRequest.txId = tx.txId; + await this._stateMgr.upsertRequest(sendFlowRequest); + return tx; + } catch (error) { + await this._stateMgr.removeRequest(sendFlowRequest.id); + + throw error; + } + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/permissions.ts index 176010de..35d68174 100644 --- a/merged-packages/bitcoin-wallet-snap/src/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/permissions.ts @@ -4,6 +4,7 @@ export enum InternalRpcMethod { GetTransactionStatus = 'chain_getTransactionStatus', EstimateFee = 'estimateFee', GetMaxSpendableBalance = 'getMaxSpendableBalance', + StartSendTransactionFlow = 'startSendTransactionFlow', } const dappPermissions = new Set([ @@ -12,9 +13,8 @@ const dappPermissions = new Set([ KeyringRpcMethod.GetAccount, KeyringRpcMethod.GetAccountBalances, KeyringRpcMethod.SubmitRequest, - // Chain API methods - InternalRpcMethod.GetTransactionStatus, // Internal methods + InternalRpcMethod.GetTransactionStatus, InternalRpcMethod.EstimateFee, InternalRpcMethod.GetMaxSpendableBalance, ]); @@ -27,6 +27,13 @@ const metamaskPermissions = new Set([ KeyringRpcMethod.FilterAccountChains, KeyringRpcMethod.DeleteAccount, KeyringRpcMethod.GetAccountBalances, + KeyringRpcMethod.SubmitRequest, + // Internal methods + InternalRpcMethod.GetTransactionStatus, + InternalRpcMethod.EstimateFee, + InternalRpcMethod.GetMaxSpendableBalance, + // Internal methods with a UI + InternalRpcMethod.StartSendTransactionFlow, ]); const allowedOrigins = [ diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts index 39ebd127..f8b4325f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts @@ -9,9 +9,14 @@ import type { Utxo } from '../../bitcoin/chain'; import { BtcOnChainService } from '../../bitcoin/chain'; import type { BtcAccount, BtcWallet } from '../../bitcoin/wallet'; import { Config } from '../../config'; +import { Caip2Asset } from '../../constants'; import { Factory } from '../../factory'; +import type { SendFlowRequest } from '../../stateManagement'; import { KeyringStateManager } from '../../stateManagement'; +import * as renderInterfaces from '../../ui/render-interfaces'; import * as snapUtils from '../../utils/snap'; +import { generateDefaultSendFlowRequest } from '../../utils/transaction'; +import * as ratesAndBalances from '../get-rates-and-balances'; jest.mock('../../utils/snap'); @@ -54,6 +59,20 @@ export function createMockChainApiFactory() { }; } +/** + * Create a mock for getting balances and rates. + * + * @returns The spy instance for `getRatesAndBalances`. + */ +export function createRatesAndBalancesMock() { + const getRatesAndBalancesSpy = jest.spyOn( + ratesAndBalances, + 'createRatesAndBalances', + ); + + return getRatesAndBalancesSpy; +} + /** * Generate UTXOs for a given address. * @@ -151,6 +170,58 @@ export function createMockAlertDialog() { return alertDialogSpy; } +/** + * Create request mocks for testing. + * + * @param defaultRequest - The default send flow request. + * @returns An object containing spies for `getRequest` and `upsertRequest`. + */ +export function createRequestMocks(defaultRequest: SendFlowRequest) { + const getRequestSpy = jest.spyOn(KeyringStateManager.prototype, 'getRequest'); + const upsertRequestSpy = jest.spyOn( + KeyringStateManager.prototype, + 'upsertRequest', + ); + + upsertRequestSpy.mockResolvedValue(); + getRequestSpy.mockResolvedValue(defaultRequest); + + return { + getRequestSpy, + upsertRequestSpy, + }; +} + +/** + * Create a mock `sendUIDialog`. + * + * @returns The `sendUIDialogSpy`. + */ +export function createSendUIDialogMock() { + const sendUIDialogSpy = jest.spyOn(snapUtils, 'createSendUIDialog'); + return sendUIDialogSpy; +} + +/** + * Create mock spies for send flow functions. + * + * @returns An object containing spies for `generateSendFlow`, `updateSendFlow`, and `generateConfirmationReviewInterface`. + */ +export function createMockSendFlow() { + const generateSendFlowSpy = jest.spyOn(renderInterfaces, 'generateSendFlow'); + const updateSendFlowSpy = jest.spyOn(renderInterfaces, 'updateSendFlow'); + const generateConfirmationReviewInterfaceSpy = jest.spyOn( + renderInterfaces, + 'generateConfirmationReviewInterface', + ); + + return { + generateSendFlowSpy, + updateSendFlowSpy, + generateConfirmationReviewInterfaceSpy, + }; +} + export type AccountTestCreateOption = { caip2ChainId: string; }; @@ -340,3 +411,118 @@ export class SendManyTest extends EstimateFeeTest { return generateBlockChairBroadcastTransactionResp().data.transaction_hash; } } + +export class StartSendTransactionFlowTest extends SendManyTest { + generateSendFlowSpy: jest.SpyInstance; + + updateSendFlowSpy: jest.SpyInstance; + + generateConfirmationReviewInterfaceSpy: jest.SpyInstance; + + getBalancesSpy: jest.SpyInstance; + + snapRequestSpy: jest.SpyInstance; + + getRequestSpy: jest.SpyInstance; + + upsertRequestSpy: jest.SpyInstance; + + createSendUIDialogMock: jest.SpyInstance; + + getBalanceAndRatesSpy: jest.SpyInstance; + + scope: string; + + requestId = 'mock-requestId'; + + interfaceId = 'mock-interfaceId'; + + constructor(testCase: SendManyCreateOption) { + super(testCase); + this.scope = testCase.caip2ChainId; + const mocks = createMockSendFlow(); + const { getBalancesSpy } = createMockChainApiFactory(); + this.getBalancesSpy = getBalancesSpy; + this.generateSendFlowSpy = mocks.generateSendFlowSpy; + this.updateSendFlowSpy = mocks.updateSendFlowSpy; + this.generateConfirmationReviewInterfaceSpy = + mocks.generateConfirmationReviewInterfaceSpy; + this.createSendUIDialogMock = createSendUIDialogMock(); + const { getRequestSpy, upsertRequestSpy } = createRequestMocks( + generateDefaultSendFlowRequest( + this.keyringAccount, + this.scope, + this.requestId, + this.interfaceId, + ), + ); + this.upsertRequestSpy = upsertRequestSpy; + this.getRequestSpy = getRequestSpy; + this.getBalanceAndRatesSpy = createRatesAndBalancesMock(); + } + + async setup() { + await super.setup(); + this.snapRequestSpy = jest.spyOn(snap, 'request').mockResolvedValue(true); + this.broadcastTransactionSpy.mockResolvedValue({ + transactionId: this.broadCastTxResp, + }); + // expect to be override by the test case + const sendFlowRequest = generateDefaultSendFlowRequest( + this.keyringAccount, + this.scope, + this.requestId, + this.interfaceId, + ); + + this.generateSendFlowSpy.mockResolvedValue(sendFlowRequest); + this.updateSendFlowSpy.mockResolvedValue(true); + this.generateConfirmationReviewInterfaceSpy.mockResolvedValue(true); + this.getBalancesSpy.mockResolvedValue({ + balances: { + [this.keyringAccount.address]: { + [Caip2Asset.TBtc]: { + amount: '100000000', + }, + [Caip2Asset.Btc]: { + amount: '100000000', + }, + }, + }, + }); + this.createSendUIDialogMock.mockResolvedValue(true); + this.getBalanceAndRatesSpy.mockResolvedValue({ + rates: { + value: '62000', + error: '', + }, + balances: { + value: { + [Caip2Asset.TBtc]: { + amount: '1', + }, + [Caip2Asset.Btc]: { + amount: '1', + }, + error: '', + }, + }, + }); + } + + async setupMockRequest(sendFlowRequest: SendFlowRequest): Promise { + this.generateSendFlowSpy.mockReset().mockResolvedValue(sendFlowRequest); + } + + async setupSnapRequestSpy(result: any): Promise { + this.snapRequestSpy.mockReset().mockResolvedValue(result); + } + + async setupGetRequest(sendFlowRequest: SendFlowRequest): Promise { + this.getRequestSpy.mockReset().mockResolvedValue(sendFlowRequest); + } + + async rejectSnapRequest(): Promise { + this.snapRequestSpy.mockResolvedValue(false); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.test.ts new file mode 100644 index 00000000..8ba8a498 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.test.ts @@ -0,0 +1,103 @@ +import { getRates } from '../utils/rates'; +import { getBalances } from './get-balances'; +import type { GetRatesAndBalancesParams } from './get-rates-and-balances'; +import { createRatesAndBalances } from './get-rates-and-balances'; + +jest.mock('../utils/rates'); +jest.mock('./get-balances'); + +describe('getRatesAndBalances', () => { + const mockAsset = { + /* mock asset data */ + } as any; + const mockBtcAccount = { + /* mock btc account data */ + } as any; + const mockScope = 'test-scope'; + + const params: GetRatesAndBalancesParams = { + asset: mockAsset, + scope: mockScope, + btcAccount: mockBtcAccount, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns rates and balances when both promises are fulfilled', async () => { + (getRates as jest.Mock).mockResolvedValue('mockRates'); + (getBalances as jest.Mock).mockResolvedValue({ + [mockAsset]: { amount: 'mockBalance' }, + }); + + const result = await createRatesAndBalances(params); + + expect(result).toStrictEqual({ + rates: { + value: 'mockRates', + error: '', + }, + balances: { + value: 'mockBalance', + error: '', + }, + }); + }); + + it('returns an error for rates when getRates promise is rejected', async () => { + (getRates as jest.Mock).mockRejectedValue(new Error('Rates error')); + (getBalances as jest.Mock).mockResolvedValue({ + [mockAsset]: { amount: 'mockBalance' }, + }); + + const result = await createRatesAndBalances(params); + + expect(result).toStrictEqual({ + rates: { + value: undefined, + error: 'Rates error: Rates error', + }, + balances: { + value: 'mockBalance', + error: '', + }, + }); + }); + + it('returns an error for balances when getBalances promise is rejected', async () => { + (getRates as jest.Mock).mockResolvedValue('mockRates'); + (getBalances as jest.Mock).mockRejectedValue(new Error('Balances error')); + + const result = await createRatesAndBalances(params); + + expect(result).toStrictEqual({ + rates: { + value: 'mockRates', + error: '', + }, + balances: { + value: undefined, + error: 'Balances error: Balances error', + }, + }); + }); + + it('returns errors for both rates and balances when both promises are rejected', async () => { + (getRates as jest.Mock).mockRejectedValue(new Error('Rates error')); + (getBalances as jest.Mock).mockRejectedValue(new Error('Balances error')); + + const result = await createRatesAndBalances(params); + + expect(result).toStrictEqual({ + rates: { + value: undefined, + error: 'Rates error: Rates error', + }, + balances: { + value: undefined, + error: 'Balances error: Balances error', + }, + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.ts new file mode 100644 index 00000000..4b44c78b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.ts @@ -0,0 +1,66 @@ +import type { BtcAccount } from '../bitcoin/wallet'; +import type { Caip2Asset } from '../constants'; +import { getRates } from '../utils/rates'; +import { getBalances } from './get-balances'; + +export type GetRatesAndBalancesParams = { + asset: Caip2Asset; + scope: string; + btcAccount: BtcAccount; +}; + +/** + * Fetches rates and balances for a given asset and account. + * + * @param options - The options for fetching rates and balances. + * @param options.asset - The asset to fetch rates and balances for. + * @param options.scope - The scope for fetching rates. + * @param options.btcAccount - The Bitcoin account to fetch balances for. + * @returns An object containing rates and balances. + */ +export async function createRatesAndBalances({ + asset, + scope, + btcAccount, +}: GetRatesAndBalancesParams) { + const errors = { + rates: '', + balances: '', + }; + let rates; + let balances; + + const [ratesResult, balancesResult] = await Promise.allSettled([ + getRates(asset), + getBalances(btcAccount, { scope, assets: [asset] }), + ]); + + if (ratesResult.status === 'fulfilled') { + rates = ratesResult.value; + } else { + errors.rates = `Rates error: ${ratesResult.reason.message as string}`; + } + + if (balancesResult.status === 'fulfilled') { + balances = balancesResult.value[asset]?.amount; + // Double-check that `getBalances` returned a valid amount for that asset. + if (balances === undefined) { + errors.balances = `Balances error: no balance found for "${asset}"`; + } + } else { + errors.balances = `Balances error: ${ + balancesResult.reason.message as string + }`; + } + + return { + rates: { + value: rates, + error: errors.rates, + }, + balances: { + value: balances, + error: errors.balances, + }, + }; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts index 65f43f27..eee68a14 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts @@ -1,17 +1,8 @@ -import { - InvalidParamsError, - UserRejectedRequestError, -} from '@metamask/snaps-sdk'; +import { InvalidParamsError } from '@metamask/snaps-sdk'; -import type { BtcAccount, Recipient } from '../bitcoin/wallet'; -import { - BtcWallet, - type ITxInfo, - TxValidationError, - InsufficientFundsError, -} from '../bitcoin/wallet'; +import type { BtcAccount } from '../bitcoin/wallet'; import { Caip2ChainId } from '../constants'; -import { getExplorerUrl, shortenAddress, satsToBtc } from '../utils'; +import { satsToBtc } from '../utils'; import { SendManyTest } from './__tests__/helper'; import { type SendManyParams, sendMany } from './sendmany'; @@ -63,92 +54,6 @@ describe('SendManyHandler', () => { return testHelper; }; - // this method is to create a expected response of a divider component - const createExpectedDividerComponent = (): unknown => { - return { - type: 'divider', - }; - }; - - // this method is to create a expected response of a recipient list component - const createExpectedRecipientListComponent = ( - recipients: Recipient[], - caip2ChainId: string, - ): unknown[] => { - const expectedComponents: unknown[] = []; - const recipientsLen = recipients.length; - - for (let idx = 0; idx < recipientsLen; idx++) { - const recipient = recipients[idx]; - - expectedComponents.push({ - type: 'panel', - children: [ - { - type: 'row', - label: recipientsLen > 1 ? `Recipient ${idx + 1}` : `Recipient`, - value: { - type: 'text', - value: `[${shortenAddress(recipient.address)}](${getExplorerUrl( - recipient.address, - caip2ChainId, - )})`, - }, - }, - { - type: 'row', - label: 'Amount', - value: { - markdown: false, - type: 'text', - value: satsToBtc(recipient.value, true), - }, - }, - ], - }); - expectedComponents.push(createExpectedDividerComponent()); - } - return expectedComponents; - }; - - // this method is to create a expected response of a header panel component - const createExpectedHeadingPanelComponent = ( - requestBy: string, - includeReviewText = true, - ): unknown => { - const headingComponent = { - type: 'heading', - value: 'Send Request', - }; - - const reviewTextComponent = { - type: 'text', - value: - "Review the request before proceeding. Once the transaction is made, it's irreversible.", - }; - - const requestByComponent = { - type: 'row', - label: 'Requested by', - value: { - type: 'text', - value: requestBy, - markdown: false, - }, - }; - - const panelChilds: unknown[] = [headingComponent]; - if (includeReviewText) { - panelChilds.push(reviewTextComponent); - } - panelChilds.push(requestByComponent); - - return { - type: 'panel', - children: panelChilds, - }; - }; - it('returns correct result', async () => { const caip2ChainId = Caip2ChainId.Testnet; const { @@ -186,178 +91,6 @@ describe('SendManyHandler', () => { expect(broadcastTransactionSpy).toHaveBeenCalledTimes(0); }); - it('displays a transaction confirmation dialog if the bitcoin transaction has been created successfully', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { recipients, confirmDialogSpy, sender } = await prepareSendMany( - caip2ChainId, - ); - const sendAmtInSats = 500; - const txFee = 1; - const sendParams = createSendManyParams( - recipients, - caip2ChainId, - true, - '', - sendAmtInSats, - ); - const mockTxInfo: ITxInfo = { - feeRate: BigInt(1), - txFee: BigInt(txFee), - sender: sender.address, - recipients: recipients.map((recipient) => ({ - address: recipient.address, - value: BigInt(sendAmtInSats), - })), - total: BigInt(sendAmtInSats * recipients.length + txFee), - }; - - // mock createTransaction and signTransaction response - const walletCreateTxSpy = jest.spyOn( - BtcWallet.prototype, - 'createTransaction', - ); - const walletSignTxSpy = jest.spyOn( - BtcWallet.prototype, - 'signTransaction', - ); - walletCreateTxSpy.mockResolvedValue({ - tx: 'transaction', - txInfo: mockTxInfo, - }); - walletSignTxSpy.mockResolvedValue('txId'); - - await sendMany(sender, origin, sendParams); - - expect(confirmDialogSpy).toHaveBeenCalledTimes(1); - expect(confirmDialogSpy).toHaveBeenCalledWith([ - // heading panel - createExpectedHeadingPanelComponent(origin), - // divider - createExpectedDividerComponent(), - // recipient panel - ...createExpectedRecipientListComponent( - mockTxInfo.recipients, - caip2ChainId, - ), - // bottom panel - { - type: 'panel', - children: [ - { - type: 'row', - label: 'Network fee', - value: { - markdown: false, - type: 'text', - value: satsToBtc(mockTxInfo.txFee, true), - }, - }, - { - type: 'row', - label: 'Total', - value: { - markdown: false, - type: 'text', - value: satsToBtc(mockTxInfo.total, true), - }, - }, - ], - }, - ]); - }); - - it('creates a comment component in the transaction confirmation dialog if a comment has been provided', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients, confirmDialogSpy } = await prepareSendMany( - caip2ChainId, - ); - const comment = 'test comment'; - - await sendMany( - sender, - origin, - createSendManyParams(recipients, caip2ChainId, true, comment), - ); - - const calls = confirmDialogSpy.mock.calls[0][0]; - - expect(calls.length).toBeGreaterThan(0); - const lastPanel = calls[calls.length - 1]; - - expect(lastPanel).toStrictEqual({ - type: 'panel', - children: [ - { - type: 'row', - label: 'Comment', - value: { markdown: false, type: 'text', value: comment }, - }, - { - type: 'row', - label: 'Network fee', - value: { markdown: false, type: 'text', value: expect.any(String) }, - }, - { - type: 'row', - label: 'Total', - value: { markdown: false, type: 'text', value: expect.any(String) }, - }, - ], - }); - }); - - it('displays a warning dialog if the account has insufficient funds to pay the transaction', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const helper = await prepareSendMany(caip2ChainId); - const { - recipients: [recipient], - sender, - alertDialogSpy, - } = helper; - - await helper.setupInsufficientFundsTest(); - - const sendAmtInSats = 500; - - await expect( - sendMany( - sender, - origin, - createSendManyParams( - [recipient], - caip2ChainId, - true, - '', - sendAmtInSats, - ), - ), - ).rejects.toThrow(InsufficientFundsError); - expect(alertDialogSpy).toHaveBeenCalledTimes(1); - expect(alertDialogSpy).toHaveBeenCalledWith([ - // heading panel - createExpectedHeadingPanelComponent(origin, false), - // divider - createExpectedDividerComponent(), - // recipient panel - ...createExpectedRecipientListComponent( - [ - { - address: recipient.address, - value: BigInt(sendAmtInSats), - }, - ], - caip2ChainId, - ), - // warning message - { - markdown: false, - type: 'text', - value: - 'You do not have enough BTC in your account to pay for transaction amount or transaction fees on Bitcoin network.', - }, - ]); - }); - it('throws InvalidParamsError when request parameter is not correct', async () => { const caip2ChainId = Caip2ChainId.Testnet; const { sender } = await prepareSendMany(caip2ChainId); @@ -449,19 +182,6 @@ describe('SendManyHandler', () => { ).rejects.toThrow('Invalid Response'); }); - it('throws UserRejectedRequestError error if user denied the transaction', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const helper = await prepareSendMany(caip2ChainId); - const { sender, recipients } = helper; - await helper.setupUserDeniedTest(); - - await expect( - sendMany(sender, origin, { - ...createSendManyParams(recipients, caip2ChainId, false), - }), - ).rejects.toThrow(UserRejectedRequestError); - }); - it('throws `Failed to send the transaction` error if no fee rate is returned from the chain service', async () => { const caip2ChainId = Caip2ChainId.Testnet; const { sender, recipients, getFeeRatesSpy } = await prepareSendMany( @@ -492,20 +212,5 @@ describe('SendManyHandler', () => { }), ).rejects.toThrow('Failed to send the transaction'); }); - - it('throws DisplayableError error message if the DisplayableError is thrown', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { broadcastTransactionSpy, sender, recipients } = - await prepareSendMany(caip2ChainId); - broadcastTransactionSpy.mockRejectedValue( - new TxValidationError('some tx error'), - ); - - await expect( - sendMany(sender, origin, { - ...createSendManyParams(recipients, caip2ChainId, false), - }), - ).rejects.toThrow('some tx error'); - }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts index 5240c8d4..094d5d02 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts @@ -1,13 +1,4 @@ import { BtcP2wpkhAddressStruct } from '@metamask/keyring-api'; -import type { Component } from '@metamask/snaps-sdk'; -import { - UserRejectedRequestError, - divider, - text, - heading, - row, - panel, -} from '@metamask/snaps-sdk'; import { object, string, @@ -21,28 +12,17 @@ import { assert, } from 'superstruct'; -import type { Recipient, Transaction } from '../bitcoin/wallet'; -import { - type BtcAccount, - type ITxInfo, - TxValidationError, - InsufficientFundsError, -} from '../bitcoin/wallet'; +import { type BtcAccount, TxValidationError } from '../bitcoin/wallet'; import { Factory } from '../factory'; import { ScopeStruct, - confirmDialog, isSnapRpcError, - shortenAddress, - getExplorerUrl, btcToSats, - satsToBtc, validateRequest, validateResponse, logger, AmountStruct, getFeeRate, - alertDialog, } from '../utils'; export const TransactionAmountStruct = refine( @@ -61,12 +41,16 @@ export const TransactionAmountStruct = refine( }, ); -export const SendManyParamsStruct = object({ +export const SendManyStruct = object({ amounts: TransactionAmountStruct, comment: string(), subtractFeeFrom: array(BtcP2wpkhAddressStruct), replaceable: boolean(), dryrun: optional(boolean()), +}); + +export const SendManyParamsStruct = object({ + ...SendManyStruct.schema, scope: ScopeStruct, }); @@ -83,19 +67,19 @@ export type SendManyResponse = Infer; * Send BTC to multiple account. * * @param account - The account to send the transaction. - * @param origin - The origin of the request. + * @param _origin - The origin of the request. * @param params - The parameters for send the transaction. * @returns A Promise that resolves to an SendManyResponse object. */ export async function sendMany( account: BtcAccount, - origin: string, + _origin: string, params: SendManyParams, ) { try { validateRequest(params, SendManyParamsStruct); - const { dryrun, scope, subtractFeeFrom, replaceable, comment } = params; + const { dryrun, scope, subtractFeeFrom, replaceable } = params; const chainApi = Factory.createOnChainServiceProvider(scope); const wallet = Factory.createWallet(scope); @@ -114,28 +98,12 @@ export async function sendMany( data: { utxos }, } = await chainApi.getDataForTransaction(account.address); - let txResp: Transaction; - - try { - txResp = await wallet.createTransaction(account, recipients, { - utxos, - fee, - subtractFeeFrom, - replaceable, - }); - } catch (createTxError) { - // Wallet.createTransaction may throw an insufficient funds error - // And end-user is expected to know about it. - // Hence we display an alert dialog to indicate the issue. - if (createTxError instanceof InsufficientFundsError) { - await displayInsufficientFundsWarning(recipients, scope, origin); - } - throw createTxError; - } - - if (!(await getTxConsensus(txResp.txInfo, comment, scope, origin))) { - throw new UserRejectedRequestError() as unknown as Error; - } + const txResp = await wallet.createTransaction(account, recipients, { + utxos, + fee, + subtractFeeFrom, + replaceable, + }); const signedTransaction = await wallet.signTransaction( account.signer, @@ -143,10 +111,11 @@ export async function sendMany( ); if (dryrun) { - return { + const result = { txId: '', signedTransaction, }; + return result; } const result = await chainApi.broadcastTransaction(signedTransaction); @@ -155,6 +124,8 @@ export async function sendMany( txId: result.transactionId, }; + logger.debug(`Submitted transaction ID: ${resp.txId}`); + validateResponse(resp, SendManyResponseStruct); return resp; @@ -172,135 +143,3 @@ export async function sendMany( throw new Error('Failed to send the transaction'); } } - -/** - * Display a confirmation dialog to confirm an transaction. - * - * @param info - The transaction data object contains the transaction information. - * @param comment - The comment text to display. - * @param scope - The CAIP-2 Chain ID. - * @param origin - The origin of the request. - * @returns A Promise that resolves to the response of the confirmation dialog. - */ -export async function getTxConsensus( - info: ITxInfo, - comment: string, - scope: string, - origin: string, -): Promise { - const header = `Send Request`; - const intro = `Review the request before proceeding. Once the transaction is made, it's irreversible.`; - const commentLabel = `Comment`; - // const networkFeeRateLabel = `Network fee rate`; - const networkFeeLabel = `Network fee`; - const totalLabel = `Total`; - const requestedByLabel = `Requested by`; - - let components: Component[] = [ - panel([ - heading(header), - text(intro), - row(requestedByLabel, text(`${origin}`, false)), - ]), - divider(), - ]; - - components = components.concat( - buildRecipientsComponent( - info.change ? info.recipients.concat(info.change) : info.recipients, - scope, - ), - ); - - const bottomPanel: Component[] = []; - const commentText = comment.trim(); - if (commentText.length > 0) { - bottomPanel.push(row(commentLabel, text(commentText, false))); - } - - bottomPanel.push( - row(networkFeeLabel, text(`${satsToBtc(info.txFee, true)}`, false)), - ); - - bottomPanel.push( - row(totalLabel, text(`${satsToBtc(info.total, true)}`, false)), - ); - - components.push(panel(bottomPanel)); - - return (await confirmDialog(components)) as boolean; -} - -/** - * Displays an alert dialog to display the warning message of insufficient funds to pay the transaction. - * - * @param recipients - The recipient list of the request. - * @param scope - The Caip2 Chain Id of the request. - * @param origin - The origin of the request. - */ -export async function displayInsufficientFundsWarning( - recipients: Recipient[], - scope: string, - origin: string, -): Promise { - const header = `Send Request`; - const requestedByLabel = `Requested by`; - const insufficientFundsMsg = `You do not have enough BTC in your account to pay for transaction amount or transaction fees on Bitcoin network.`; - - let components: Component[] = [ - panel([heading(header), row(requestedByLabel, text(`${origin}`, false))]), - divider(), - ]; - - components = components.concat(buildRecipientsComponent(recipients, scope)); - - components.push(text(`${insufficientFundsMsg}`, false)); - - await alertDialog(components); -} - -/** - * Builds a snap component to display the transcation recipient list. - * - * @param recipients - The recipient list of request. - * @param scope - The Caip2 Chain Id of request. - * @returns An array of Snap component. - */ -export function buildRecipientsComponent( - recipients: Recipient[], - scope: string, -): Component[] { - const recipientsLabel = `Recipient`; - const amountLabel = `Amount`; - - const recipientsLen = recipients.length; - const isMoreThanOneRecipient = recipientsLen > 1; - - const components: Component[] = []; - for (let idx = 0; idx < recipientsLen; idx++) { - const recipient = recipients[idx]; - const recipientsPanel: Component[] = []; - - recipientsPanel.push( - row( - isMoreThanOneRecipient - ? `${recipientsLabel} ${idx + 1}` - : recipientsLabel, - text( - `[${shortenAddress(recipient.address)}](${getExplorerUrl( - recipient.address, - scope, - )})`, - ), - ), - ); - recipientsPanel.push( - row(amountLabel, text(satsToBtc(recipient.value, true), false)), - ); - - components.push(panel(recipientsPanel)); - components.push(divider()); - } - - return components; -} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts new file mode 100644 index 00000000..331f12c1 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts @@ -0,0 +1,212 @@ +import { Caip2Asset, Caip2ChainId } from '../constants'; +import { + AccountNotFoundError, + SendFlowRequestNotFoundError, +} from '../exceptions'; +import { TransactionStatus } from '../stateManagement'; +import { AssetType } from '../ui/types'; +import { generateSendManyParams } from '../ui/utils'; +import { StartSendTransactionFlowTest } from './__tests__/helper'; +import { startSendTransactionFlow } from './start-send-transaction-flow'; + +jest.mock('../utils/logger'); +jest.mock('../utils/snap'); + +const prepareStartSendTransactionFlow = async ( + caip2ChainId, + recipientCount = 1, + feeRate = 1, + utxoCount = 1, + utxoMinVal = 100000000, + utxoMaxVal = 100000000, +) => { + const testHelper = new StartSendTransactionFlowTest({ + caip2ChainId, + feeRate, + utxoCount, + utxoMinVal, + utxoMaxVal, + recipientCount, + }); + + await testHelper.setup(); + + return testHelper; +}; + +describe('startSendTransactionFlow', () => { + const mockScope = Caip2ChainId.Testnet; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('creates a new request', async () => { + const helper = await prepareStartSendTransactionFlow(mockScope); + const { keyringAccount, getBalanceAndRatesSpy } = helper; + getBalanceAndRatesSpy.mockResolvedValue({ + balances: { + value: { + [Caip2Asset.Btc]: { amount: '1' }, + [Caip2Asset.TBtc]: { amount: '1' }, + }, + error: '', + }, + rates: { + value: 62000, + error: '', + }, + }); + const mockRequestWithCorrectValues = { + id: 'mock-requestId', + interfaceId: 'mock-interfaceId', + account: keyringAccount, + scope: mockScope, + transaction: generateSendManyParams(mockScope), + status: TransactionStatus.Draft, + selectedCurrency: AssetType.BTC, + recipient: { + address: keyringAccount.address, + error: '', + valid: true, + }, + fees: { + amount: '0.01', + fiat: '620.00', + loading: true, + error: '', + }, + amount: { + amount: '0.5', + fiat: '31000.00', + error: '', + valid: true, + }, + rates: '62000', + balance: { + amount: '1', + fiat: '', + }, + total: { + amount: '0.51', + fiat: '31620.00', + valid: true, + error: '', + }, + }; + await helper.setupGetRequest(mockRequestWithCorrectValues); + + const transactionTx = await startSendTransactionFlow({ + account: keyringAccount.id, + scope: mockScope, + }); + + expect(helper.generateSendFlowSpy).toHaveBeenCalledTimes(1); + expect(helper.upsertRequestSpy).toHaveBeenCalledTimes(4); + expect(transactionTx).toStrictEqual({ + txId: '0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098', + }); + }); + + it('throws an error when the user rejects the transaction', async () => { + const helper = await prepareStartSendTransactionFlow(mockScope); + const { keyringAccount, getBalanceAndRatesSpy, createSendUIDialogMock } = + helper; + createSendUIDialogMock.mockResolvedValue(false); + getBalanceAndRatesSpy.mockResolvedValue({ + balances: { + value: { + [Caip2Asset.Btc]: { amount: '1' }, + [Caip2Asset.TBtc]: { amount: '1' }, + }, + error: '', + }, + rates: { + value: 62000, + error: '', + }, + }); + const mockRequestWithCorrectValues = { + id: 'mock-requestId', + interfaceId: 'mock-interfaceId', + account: keyringAccount, + scope: mockScope, + transaction: generateSendManyParams(mockScope), + status: TransactionStatus.Draft, + selectedCurrency: AssetType.BTC, + recipient: { + address: keyringAccount.address, + error: '', + valid: true, + }, + fees: { + amount: '0.01', + fiat: '620.00', + loading: true, + error: '', + }, + amount: { + amount: '0.5', + fiat: '31000.00', + error: '', + valid: true, + }, + rates: '62000', + balance: { + amount: '1', + fiat: '', + }, + total: { + amount: '0.51', + fiat: '31620.00', + valid: true, + error: '', + }, + }; + await helper.setupGetRequest(mockRequestWithCorrectValues); + await helper.rejectSnapRequest(); + + await expect( + startSendTransactionFlow({ + account: keyringAccount.id, + scope: mockScope, + }), + ).rejects.toThrow('User rejected the request'); + }); + + it('throws an error when the account is not found', async () => { + await expect( + startSendTransactionFlow({ + account: 'non-existing-account-id', + scope: mockScope, + }), + ).rejects.toThrow(AccountNotFoundError); + }); + + it('throws an error when send flow request is not found', async () => { + const helper = await prepareStartSendTransactionFlow(mockScope); + const { keyringAccount, getBalanceAndRatesSpy } = helper; + getBalanceAndRatesSpy.mockResolvedValue({ + balances: { + value: { + [Caip2Asset.Btc]: { amount: '1' }, + [Caip2Asset.TBtc]: { amount: '1' }, + }, + error: '', + }, + rates: { + value: 62000, + error: '', + }, + }); + // @ts-expect-error - We are testing the error case + await helper.setupGetRequest(null); + + await expect( + startSendTransactionFlow({ + account: keyringAccount.id, + scope: mockScope, + }), + ).rejects.toThrow(SendFlowRequestNotFoundError); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts new file mode 100644 index 00000000..fd378660 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts @@ -0,0 +1,165 @@ +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; +import { enums, nonempty, object, string } from 'superstruct'; + +import { TxValidationError } from '../bitcoin/wallet'; +import { Caip2ChainId } from '../constants'; +import { + AccountNotFoundError, + isSnapException, + SendFlowRequestNotFoundError, +} from '../exceptions'; +import { Factory } from '../factory'; +import { KeyringStateManager, TransactionStatus } from '../stateManagement'; +import { generateSendFlow, updateSendFlow } from '../ui/render-interfaces'; +import { + btcToFiat, + getAssetTypeFromScope, + generateSendManyParams, +} from '../ui/utils'; +import { + createSendUIDialog, + isSnapRpcError, + logger, + validateRequest, + verifyIfAccountValid, +} from '../utils'; +import { createRatesAndBalances } from './get-rates-and-balances'; +import { sendMany } from './sendmany'; + +export type StartSendTransactionFlowParams = { + account: string; + scope: Caip2ChainId; +}; + +export const StartSendTransactionFlowParamsStruct = object({ + account: nonempty(string()), + scope: enums([...Object.values(Caip2ChainId)]), +}); + +/** + * Starts the send transaction flow for a given account and scope. + * + * @param params - The parameters for starting the transaction flow. + * @returns The transaction result. + */ +export async function startSendTransactionFlow( + params: StartSendTransactionFlowParams, +) { + validateRequest( + params, + StartSendTransactionFlowParamsStruct, + ); + const { account, scope } = params; + try { + const stateManager = new KeyringStateManager(); + const walletData = await stateManager.getWallet(account); + + if (!walletData) { + throw new AccountNotFoundError(); + } + + const wallet = Factory.createWallet(walletData.scope); + const btcAccount = await wallet.unlock( + walletData.index, + walletData.account.type, + ); + verifyIfAccountValid(btcAccount, walletData.account); + + const asset = getAssetTypeFromScope(scope); + + const sendFlowRequest = await generateSendFlow({ + account: walletData.account, + scope, + }); + + await stateManager.upsertRequest(sendFlowRequest); + + // This will be awaited later on the flow in order to display the UI as soon as possible. + // If we don't, then the UI will be displayed after the balances and rates call are finished. + const sendFlowPromise = createSendUIDialog(sendFlowRequest.interfaceId); + + const { rates, balances } = await createRatesAndBalances({ + asset, + scope, + btcAccount, + }); + + const errors: string[] = []; + + if (rates.error) { + errors.push(rates.error); + } + + if (balances.error) { + errors.push(balances.error); + } + + if (errors.length > 0) { + throw new Error(`Error fetching rates and balances: ${errors.join(',')}`); + } + + sendFlowRequest.balance.amount = balances.value; + sendFlowRequest.balance.fiat = btcToFiat(balances.value, rates.value); + sendFlowRequest.rates = rates.value; + await stateManager.upsertRequest(sendFlowRequest); + + await updateSendFlow({ + request: { + ...sendFlowRequest, + }, + }); + + const sendFlowResult = await sendFlowPromise; + + if (!sendFlowResult) { + await stateManager.removeRequest(sendFlowRequest.id); + throw new UserRejectedRequestError() as unknown as Error; + } + + // Get the latest send flow request from the state manager + // this has been updated via onInputHandler + const updatedSendFlowRequest = await stateManager.getRequest( + sendFlowRequest.id, + ); + + if (!updatedSendFlowRequest) { + throw new SendFlowRequestNotFoundError(); + } + + const sendManyParams = generateSendManyParams( + walletData.scope, + updatedSendFlowRequest, + ); + sendFlowRequest.transaction = sendManyParams; + sendFlowRequest.status = TransactionStatus.Confirmed; + await stateManager.upsertRequest(sendFlowRequest); + + let tx; + try { + tx = await sendMany(btcAccount, scope, { + ...sendFlowRequest.transaction, + scope, + }); + + sendFlowRequest.txId = tx.txId; + } catch (error) { + await stateManager.removeRequest(sendFlowRequest.id); + throw error; + } + + await stateManager.upsertRequest(sendFlowRequest); + return tx; + } catch (error) { + logger.error('Failed to start send transaction flow', error); + + if (isSnapRpcError(error)) { + throw error as unknown as Error; + } + + if (isSnapException(error) || error instanceof TxValidationError) { + throw error; + } + + throw new Error('Failed to send the transaction'); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts b/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts index a322d44f..36a5c1a6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts @@ -1,6 +1,9 @@ import { generateAccounts } from '../test/utils'; import { Caip2ChainId } from './constants'; -import { KeyringStateManager } from './stateManagement'; +import type { SendFlowRequest } from './stateManagement'; +import { KeyringStateManager, TransactionStatus } from './stateManagement'; +import { AssetType } from './ui/types'; +import { generateSendManyParams } from './ui/utils'; import * as snapUtil from './utils/snap'; describe('KeyringStateManager', () => { @@ -31,6 +34,7 @@ describe('KeyringStateManager', () => { }; return acc; }, {}), + requests: {} as Record, }; }; @@ -355,4 +359,329 @@ describe('KeyringStateManager', () => { await expect(instance.getWallet(id)).rejects.toThrow(Error); }); }); + + describe('Requests', () => { + describe('getRequest', () => { + it('returns the request if it exists', async () => { + const { instance, getDataSpy } = createMockStateManager(); + const state = createInitState(20); + const requestId = 'request-1'; + const request = { + id: requestId, + interfaceId: 'interface-1', + account: state.wallets[state.walletIds[0]].account, + scope: 'scope-1', + transaction: { + ...generateSendManyParams('scope-1'), + sender: 'sender-1', + recipient: 'recipient-1', + amount: '1', + total: '1', + }, + status: TransactionStatus.Draft, + selectedCurrency: AssetType.BTC, + recipient: { + address: 'recipient-address', + error: '', + valid: true, + }, + fees: { + amount: '0.0001', + fiat: '0.01', + loading: false, + error: '', + }, + amount: { + amount: '1', + fiat: '100', + error: '', + valid: true, + }, + rates: '100', + balance: { + amount: '10', + fiat: '1000', + }, + total: { + amount: '1.0001', + fiat: '100.01', + error: '', + valid: true, + }, + }; + state.requests[requestId] = request; + getDataSpy.mockResolvedValue(state); + + const result = await instance.getRequest(requestId); + + expect(getDataSpy).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual(request); + }); + + it('returns null if the request does not exist', async () => { + const { instance, getDataSpy } = createMockStateManager(); + const state = createInitState(20); + getDataSpy.mockResolvedValue(state); + const requestId = 'non-existent-request'; + + const result = await instance.getRequest(requestId); + + expect(getDataSpy).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + }); + + it('returns null if the state is null', async () => { + const { instance, getDataSpy } = createMockStateManager(); + getDataSpy.mockResolvedValue(null); + const requestId = 'request-1'; + + const result = await instance.getRequest(requestId); + + expect(getDataSpy).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + }); + + it('throws an Error if another Error was thrown', async () => { + const { instance, getDataSpy } = createMockStateManager(); + getDataSpy.mockRejectedValue(new Error('error')); + const requestId = 'request-1'; + + await expect(instance.getRequest(requestId)).rejects.toThrow(Error); + }); + }); + + describe('upsertRequest', () => { + it('adds a new request if it does not exist', async () => { + const { instance, getDataSpy, setDataSpy } = createMockStateManager(); + const state = createInitState(20); + getDataSpy.mockResolvedValue(state); + const requestId = 'new-request'; + const newRequest: SendFlowRequest = { + id: requestId, + interfaceId: 'interface-1', + account: state.wallets[state.walletIds[0]].account, + scope: 'scope-1', + transaction: { + ...generateSendManyParams('scope-1'), + }, + status: TransactionStatus.Draft, + selectedCurrency: AssetType.BTC, + recipient: { + address: 'recipient-address', + error: '', + valid: true, + }, + fees: { + amount: '0.0001', + fiat: '0.01', + loading: false, + error: '', + }, + amount: { + amount: '1', + fiat: '100', + error: '', + valid: true, + }, + rates: '100', + balance: { + amount: '10', + fiat: '1000', + }, + total: { + amount: '1.0001', + fiat: '100.01', + error: '', + valid: true, + }, + }; + + await instance.upsertRequest(newRequest); + + expect(getDataSpy).toHaveBeenCalledTimes(1); + expect(setDataSpy).toHaveBeenCalledTimes(1); + expect(state.requests[requestId]).toStrictEqual(newRequest); + }); + + it('updates an existing request if it exists', async () => { + const { instance, getDataSpy, setDataSpy } = createMockStateManager(); + const state = createInitState(20); + const requestId = 'existing-request'; + const existingRequest: SendFlowRequest = { + id: requestId, + interfaceId: 'interface-1', + account: state.wallets[state.walletIds[0]].account, + scope: 'scope-1', + transaction: { + ...generateSendManyParams('scope-1'), + }, + status: TransactionStatus.Draft, + selectedCurrency: AssetType.BTC, + recipient: { + address: 'recipient-address', + error: '', + valid: true, + }, + fees: { + amount: '0.0001', + fiat: '0.01', + loading: false, + error: '', + }, + amount: { + amount: '1', + fiat: '100', + error: '', + valid: true, + }, + rates: '100', + balance: { + amount: '10', + fiat: '1000', + }, + total: { + amount: '1.0001', + fiat: '100.01', + error: '', + valid: true, + }, + }; + state.requests[requestId] = existingRequest; + getDataSpy.mockResolvedValue(state); + + const updatedRequest: SendFlowRequest = { + ...existingRequest, + status: TransactionStatus.Review, + }; + + await instance.upsertRequest(updatedRequest); + + expect(getDataSpy).toHaveBeenCalledTimes(1); + expect(setDataSpy).toHaveBeenCalledTimes(1); + expect(state.requests[requestId]).toStrictEqual(updatedRequest); + }); + + it('throws an Error if another Error was thrown', async () => { + const { instance, getDataSpy } = createMockStateManager(); + getDataSpy.mockRejectedValue(new Error('error')); + const requestId = 'request-1'; + const request: SendFlowRequest = { + id: requestId, + interfaceId: 'interface-1', + account: generateAccounts(1)[0], + scope: 'scope-1', + transaction: { + ...generateSendManyParams('scope-1'), + }, + status: TransactionStatus.Draft, + selectedCurrency: AssetType.BTC, + recipient: { + address: 'recipient-address', + error: '', + valid: true, + }, + fees: { + amount: '0.0001', + fiat: '0.01', + loading: false, + error: '', + }, + amount: { + amount: '1', + fiat: '100', + error: '', + valid: true, + }, + rates: '100', + balance: { + amount: '10', + fiat: '1000', + }, + total: { + amount: '1.0001', + fiat: '100.01', + error: '', + valid: true, + }, + }; + + await expect(instance.upsertRequest(request)).rejects.toThrow(Error); + }); + }); + describe('removeRequest', () => { + it('removes the request if it exists', async () => { + const { instance, getDataSpy, setDataSpy } = createMockStateManager(); + const state = createInitState(20); + const requestId = 'request-to-remove'; + const request: SendFlowRequest = { + id: requestId, + interfaceId: 'interface-1', + account: state.wallets[state.walletIds[0]].account, + scope: 'scope-1', + transaction: { + ...generateSendManyParams('scope-1'), + }, + status: TransactionStatus.Draft, + selectedCurrency: AssetType.BTC, + recipient: { + address: 'recipient-address', + error: '', + valid: true, + }, + fees: { + amount: '0.0001', + fiat: '0.01', + loading: false, + error: '', + }, + amount: { + amount: '1', + fiat: '100', + error: '', + valid: true, + }, + rates: '100', + balance: { + amount: '10', + fiat: '1000', + }, + total: { + amount: '1.0001', + fiat: '100.01', + error: '', + valid: true, + }, + }; + state.requests[requestId] = request; + getDataSpy.mockResolvedValue(state); + + await instance.removeRequest(requestId); + + expect(getDataSpy).toHaveBeenCalledTimes(1); + expect(setDataSpy).toHaveBeenCalledTimes(1); + expect(state.requests[requestId]).toBeUndefined(); + }); + + it('does nothing if the request does not exist', async () => { + const { instance, getDataSpy, setDataSpy } = createMockStateManager(); + const state = createInitState(20); + getDataSpy.mockResolvedValue(state); + const requestId = 'non-existent-request'; + + await instance.removeRequest(requestId); + + expect(getDataSpy).toHaveBeenCalledTimes(1); + expect(setDataSpy).toHaveBeenCalledTimes(1); + expect(state.requests[requestId]).toBeUndefined(); + }); + + it('throws an Error if another Error was thrown', async () => { + const { instance, getDataSpy } = createMockStateManager(); + getDataSpy.mockRejectedValue(new Error('error')); + const requestId = 'request-1'; + + await expect(instance.removeRequest(requestId)).rejects.toThrow(Error); + }); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts b/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts index 4292895b..4cca682b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts +++ b/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts @@ -1,5 +1,7 @@ import type { KeyringAccount } from '@metamask/keyring-api'; +import { type EstimateFeeResponse, type SendManyParams } from './rpcs'; +import type { AssetType, Currency } from './ui/types'; import { compactError, SnapStateManager } from './utils'; export type Wallet = { @@ -11,9 +13,76 @@ export type Wallet = { export type Wallets = Record; +export type SendEstimate = { + // The estimated fee in BTC. + fees: EstimateFeeResponse & { loading: boolean }; + // The estimated time to confirmation time. + confirmationTime: string; +}; + +export type Transaction = SendManyParams & { + sender: string; + recipient: string; + amount: string; + total: string; +}; + +export type BaseRequestState = { + id: string; + interfaceId: string; + account: KeyringAccount; + scope: string; +}; + +export type SendFlowParams = { + selectedCurrency: AssetType; + recipient: { + address: string; + error: string; + valid: boolean; + }; + fees: Currency & { loading: boolean; error: string }; + amount: Currency & { error: string; valid: boolean }; + rates: string; + balance: Currency; // TODO: To be removed once metadata is available + total: Currency & { error: string; valid: boolean }; +}; + +export enum TransactionStatus { + Draft = 'draft', + Review = 'review', + Signed = 'signed', + Rejected = 'rejected', + Confirmed = 'confirmed', + Pending = 'pending', + Failure = 'failure', +} + +export type TransactionState = { + transaction: Omit; + /* The status of the transaction + - draft: The transaction is being created and edited + - review: The transaction is in a review state that is ready to be confirmed by the user to sign + - signed: The transaction is signed and ready to be sent + - rejected: The transaction is rejected by the user + - confirmed: The transaction is confirmed by the network + - pending: The transaction is pending confirmation + - failure: The transaction failed + */ + status: TransactionStatus; + txId?: string; +}; + +export type SendFlowRequest = BaseRequestState & + SendFlowParams & + TransactionState; + export type SnapState = { walletIds: string[]; wallets: Wallets; + requests: { + [id: string]: SendFlowRequest; + }; }; export class KeyringStateManager extends SnapStateManager { @@ -24,6 +93,7 @@ export class KeyringStateManager extends SnapStateManager { state = { walletIds: [], wallets: {}, + requests: {}, }; } @@ -35,6 +105,10 @@ export class KeyringStateManager extends SnapStateManager { state.wallets = {}; } + if (!state.requests) { + state.requests = {}; + } + return state; }); } @@ -130,6 +204,40 @@ export class KeyringStateManager extends SnapStateManager { } } + async getRequest(id: string): Promise { + try { + const state = await this.get(); + return state.requests[id] ?? null; + } catch (error) { + throw compactError(error, Error); + } + } + + async upsertRequest(sendFlowRequest: SendFlowRequest): Promise { + try { + await this.update(async (state: SnapState) => { + state.requests[sendFlowRequest.id] = { + ...state.requests[sendFlowRequest.id], + ...sendFlowRequest, + }; + }); + } catch (error) { + throw compactError(error, Error); + } + } + + async removeRequest(id: string): Promise { + try { + await this.update(async (state: SnapState) => { + if (state.requests[id]) { + delete state.requests[id]; + } + }); + } catch (error) { + throw compactError(error, Error); + } + } + protected getAccountByAddress( state: SnapState, address: string, @@ -144,4 +252,8 @@ export class KeyringStateManager extends SnapStateManager { protected isAccountExist(state: SnapState, id: string): boolean { return Object.prototype.hasOwnProperty.call(state.wallets, id); } + + protected isRequestExist(state: SnapState, id: string): boolean { + return Object.prototype.hasOwnProperty.call(state.requests, id); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/AccountSelector.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/AccountSelector.tsx new file mode 100644 index 00000000..502e26e0 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/AccountSelector.tsx @@ -0,0 +1,70 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import { + Card, + Field, + Selector, + SelectorOption, + type SnapComponent, +} from '@metamask/snaps-sdk/jsx'; + +import { shortenAddress } from '../../utils'; +import jazzicon1 from '../images/jazzicon1.svg'; +import type { Currency } from '../types'; + +/** + * The props for the {@link AccountSelector} component. + * + * @property selectedAccount - The currently selected account. + * @property balance - The balance of the selected account. + * @property accounts - The available accounts. + */ +export type AccountSelectorProps = { + selectedAccount: string; + balance: Currency; + accounts: KeyringAccount[]; +}; + +const loadingMessage = 'Loading'; + +/** + * A component that shows the account selector. + * + * @param props - The component props. + * @param props.selectedAccount - The currently selected account. + * @param props.accounts - The available accounts. + * @param props.balance - The balance of the selected account. + * @returns The AccountSelector component. + */ +export const AccountSelector: SnapComponent = ({ + selectedAccount, + accounts, + balance, +}) => ( + + + {accounts.map(({ address }) => { + return ( + + + + ); + })} + + +); diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/ReviewTransaction.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/ReviewTransaction.tsx new file mode 100644 index 00000000..04a2bf4c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/ReviewTransaction.tsx @@ -0,0 +1,96 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { + Address, + Box, + Button, + Container, + Footer, + Heading, + Row, + Section, + Text, + Value, + Image, +} from '@metamask/snaps-sdk/jsx'; +import type { CaipAccountId } from '@metamask/utils'; + +import type { SendFlowRequest } from '../../stateManagement'; +import btcIcon from '../images/btc.svg'; +import { getNetworkNameFromScope } from '../utils'; +import { SendFlowHeader } from './SendFlowHeader'; +import { SendFormNames } from './SendForm'; + +export type ReviewTransactionProps = SendFlowRequest & { + txSpeed: string; +}; + +export const ReviewTransaction: SnapComponent = ({ + account, + amount, + total, + recipient, + scope, + txSpeed, + fees, +}) => { + const network = getNetworkNameFromScope(scope); + const disabledSend = Boolean( + amount.error || recipient.error || total.error || fees.error, + ); + + return ( + + + + + + + + {`Sending ${total.amount} BTC`} + Review the transaction before proceeding + +
+ +
+
+ + + + +
+
+
+
+ + {network} + + + {txSpeed} + + + + + + + +
+ {Boolean(recipient.error) && ( + {recipient.error} + )} + {Boolean(amount.error) && {amount.error}} + {Boolean(fees.error) && {fees.error}} + {Boolean(total.error) && {total.error}} +
+
+ +
+
+ ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlow.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlow.tsx new file mode 100644 index 00000000..07b8142e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlow.tsx @@ -0,0 +1,77 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Box, Container } from '@metamask/snaps-sdk/jsx'; + +import type { SendFlowParams } from '../../stateManagement'; +import { SendFlowFooter } from './SendFlowFooter'; +import { SendFlowHeader } from './SendFlowHeader'; +import { SendForm } from './SendForm'; +import { TransactionSummary } from './TransactionSummary'; + +/** + * The props for the {@link SendFlow} component. + * + * @property account - The account information for the transaction. + * @property flushToAddress - Flag to flush to address. + * @property sendFlowParams - Additional parameters for the send flow. + * @property currencySwitched - Flag indicating if the currency was switched. + * @property backEventTriggered - Flag indicating if the back event was triggered. + */ +export type SendFlowProps = { + account: KeyringAccount; + flushToAddress?: boolean; + sendFlowParams: SendFlowParams; + currencySwitched?: boolean; + backEventTriggered?: boolean; +}; + +/** + * A send flow component, which shows the user a form to send funds to another. + * + * @param props - The properties object. + * @param props.account - The account information for the transaction. + * @param props.flushToAddress - Flag to flush to address. + * @param props.sendFlowParams - Additional parameters for the send flow. + * @param props.currencySwitched - Flag indicating if the currency was switched. + * @param props.backEventTriggered - Flag indicating if the back event was triggered. + * @returns The rendered SendFlow component. + */ +export const SendFlow: SnapComponent = ({ + account, + sendFlowParams, + flushToAddress = false, + currencySwitched = false, + backEventTriggered = false, +}) => { + const { amount, recipient, fees } = sendFlowParams; + + const disabledReview = Boolean( + !amount.valid || !recipient.valid || fees.loading || fees.error, + ); + + const showTransactionSummary = + Boolean(!amount.error && amount.amount) || fees.loading; + + return ( + + + + + {showTransactionSummary && ( + + )} + + + + ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowFooter.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowFooter.tsx new file mode 100644 index 00000000..fb8237d4 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowFooter.tsx @@ -0,0 +1,30 @@ +import { Button, Footer, type SnapComponent } from '@metamask/snaps-sdk/jsx'; + +import { SendFormNames } from './SendForm'; + +/** + * The props for the {@link SendFlowFooter} component. + * + * @property disabled - Whether the button is disabled or not. + */ +export type SendFlowFooterProps = { + disabled: boolean; +}; + +/** + * A component that shows the send flow footer. + * + * @param props - The options object. + * @param props.disabled - Whether the button is disabled or not. + * @returns The SendFlowFooter component. + */ +export const SendFlowFooter: SnapComponent = ({ + disabled, +}: SendFlowFooterProps) => ( +
+ + +
+); diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx new file mode 100644 index 00000000..33e5156b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx @@ -0,0 +1,32 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Box, Button, Heading, Icon, Text } from '@metamask/snaps-sdk/jsx'; + +import { SendFormNames } from './SendForm'; + +/** + * The props for the {@link SendFlowHeader} component. + * + * @property heading - The heading to display. + */ +export type SendFlowHeaderProps = { + heading: string; +}; + +/** + * A component that shows the send flow header. + * + * @param props - The component props. + * @param props.heading - The heading to display. + * @returns The SendFlowHeader component. + */ +export const SendFlowHeader: SnapComponent = ({ + heading, +}) => ( + + + {heading} + + +); diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx new file mode 100644 index 00000000..1389c3af --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx @@ -0,0 +1,162 @@ +import { + Box, + Button, + Field, + Form, + Icon, + Image, + Input, + Text, + type SnapComponent, +} from '@metamask/snaps-sdk/jsx'; + +import type { SendFlowParams } from '../../stateManagement'; +import btcIcon from '../images/btc-halo.svg'; +import jazzicon3 from '../images/jazzicon3.svg'; +import type { AccountWithBalance } from '../types'; +import { AssetType } from '../types'; +import { AccountSelector as AccountSelectorComponent } from './AccountSelector'; + +export enum SendFormNames { + Amount = 'amount', + To = 'to', + SwapCurrencyDisplay = 'swap', + AccountSelector = 'accountSelector', + Clear = 'clear', + Close = 'close', + Review = 'review', + Cancel = 'cancel', + Send = 'send', + HeaderBack = 'headerBack', + SetMax = 'max', +} + +/** + * The props for the {@link SendForm} component. + * + * @property selectedAccount - The currently selected account. + * @property accounts - The available accounts. + * @property errors - The form errors. + * @property selectedCurrency - The selected currency to display. + * @property flushToAddress - Whether to flush the address field or not. + */ +export type SendFormProps = { + selectedAccount: string; + accounts: AccountWithBalance[]; + balance: SendFlowParams['balance']; + amount: SendFlowParams['amount']; + selectedCurrency: SendFlowParams['selectedCurrency']; + recipient: SendFlowParams['recipient']; + total: SendFlowParams['total']; + flushToAddress?: boolean; + currencySwitched: boolean; + backEventTriggered: boolean; +}; + +const getAmountFrom = ( + selectedCurrency: AssetType, + amount: SendFlowParams['amount'], +) => { + return selectedCurrency === AssetType.BTC ? amount.amount : amount.fiat; +}; + +/** + * A component that shows the send form. + * + * @param props - The component props. + * @param props.selectedAccount - The currently selected account. + * @param props.accounts - The available accounts. + * @param props.balance - The balance of the account. + * @param props.amount - The amount of the transaction from the formState. + * @param props.selectedCurrency - The selected currency to display. + * @param props.flushToAddress - Whether to flush the address field or not. + * @param props.recipient - The recipient details including address and validation status. + * @param props.total - The total amount including fees. + * @param props.currencySwitched - Whether the currency display has been switched. + * @param props.backEventTriggered - Whether the back event has been triggered. + * @returns The SendForm component. + */ +export const SendForm: SnapComponent = ({ + selectedAccount, + accounts, + selectedCurrency, + flushToAddress, + balance, + amount, + recipient, + total, + currencySwitched, + backEventTriggered, +}) => { + const showRecipientError = recipient.address.length > 0 && !recipient.error; + const amountToDisplay = + currencySwitched || backEventTriggered + ? getAmountFrom(selectedCurrency, amount) + : undefined; + + let addressToDisplay: string | undefined; + if (backEventTriggered) { + addressToDisplay = recipient.address; + } else if (flushToAddress) { + addressToDisplay = ''; + } + + return ( +
+ + + + + + + + + {selectedCurrency === AssetType.FIAT ? 'USD' : selectedCurrency} + + + + + + {Boolean(balance.fiat) && ( + {`Balance: $${balance.fiat.toLocaleLowerCase()}`} + )} + + + + {recipient.valid && ( + + + + )} + + {Boolean(recipient.address) && ( + + + + )} + + {showRecipientError && Valid bitcoin address} +
+ ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/TransactionSummary.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/TransactionSummary.tsx new file mode 100644 index 00000000..c35fcf80 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/TransactionSummary.tsx @@ -0,0 +1,76 @@ +import { + Box, + Row, + Section, + Spinner, + Text, + Value, + type SnapComponent, +} from '@metamask/snaps-sdk/jsx'; + +import type { SendFlowRequest } from '../../stateManagement'; + +/** + * The props for the {@link TransactionSummary} component. + * + * @property fees - The fees for the transaction. + * @property total - The total cost of the transaction. + */ +export type TransactionSummaryProps = { + fees: SendFlowRequest['fees']; + total: SendFlowRequest['total']; +}; + +/** + * A component that shows the transaction summary. + * + * @param props - The component props. + * @param props.fees - The fees for the transaction. + * @param props.total - The total cost of the transaction. + * @returns The TransactionSummary component. + */ +export const TransactionSummary: SnapComponent = ({ + fees, + total, +}) => { + if (fees.loading) { + return ( +
+ + + Preparing transaction + +
+ ); + } + + if (fees.error) { + return ( +
+ + {fees.error} + +
+ ); + } + + return ( +
+ + + + + 30m + + + + +
+ ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/index.ts b/merged-packages/bitcoin-wallet-snap/src/ui/components/index.ts new file mode 100644 index 00000000..072f0137 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/index.ts @@ -0,0 +1,2 @@ +export * from './SendFlow'; +export * from './ReviewTransaction'; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.test.ts b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.test.ts new file mode 100644 index 00000000..eb64e2b5 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.test.ts @@ -0,0 +1,808 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import { BtcAccountType } from '@metamask/keyring-api'; +import type { UserInputEvent } from '@metamask/snaps-sdk'; +import { UserInputEventType } from '@metamask/snaps-sdk'; +import BigNumber from 'bignumber.js'; +import { v4 as uuidv4 } from 'uuid'; + +import { Caip2ChainId } from '../../constants'; +import { estimateFee, getMaxSpendableBalance } from '../../rpcs'; +import { KeyringStateManager, TransactionStatus } from '../../stateManagement'; +import { generateDefaultSendFlowRequest } from '../../utils/transaction'; +import { SendFormNames } from '../components/SendForm'; +import { updateSendFlow } from '../render-interfaces'; +import type { SendFlowContext, SendFormState } from '../types'; +import { AssetType } from '../types'; +import { SendManyController, isSendFormEvent } from './send-many-controller'; + +jest.mock('../../rpcs', () => ({ + ...jest.requireActual('../../rpcs'), + estimateFee: jest.fn(), + getMaxSpendableBalance: jest.fn(), +})); + +// @ts-expect-error Mocking Snap global object +// eslint-disable-next-line no-restricted-globals +global.snap = { + request: jest.fn(), +}; + +const mockGenerateConfirmationReviewInterface = jest.fn(); +jest.mock('../render-interfaces', () => ({ + updateSendFlow: jest.fn(), + generateConfirmationReviewInterface: (args) => + mockGenerateConfirmationReviewInterface(args), +})); + +const mockInterfaceId = 'interfaceId'; +const mockScope = Caip2ChainId.Mainnet; +const mockRequestId = 'requestId'; +const mockAccount = { + type: BtcAccountType.P2wpkh, + id: uuidv4(), + address: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', + options: { + scope: Caip2ChainId.Mainnet, + index: '1', + }, + methods: ['btc_sendmany'], +}; + +const mockContext: SendFlowContext = { + accounts: [{ id: 'account1' } as KeyringAccount], + scope: mockScope, + requestId: mockRequestId, +}; + +const createMockStateManager = () => { + const stateManager = new KeyringStateManager(); + const upsertRequestSpy = jest + .spyOn(stateManager, 'upsertRequest') + .mockResolvedValue(undefined); + + return { + instance: stateManager, + upsertRequestSpy, + }; +}; + +describe('SendManyController', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('isSendFormEvent', () => { + it.each([ + { + formEvent: { + name: SendFormNames.AccountSelector, + type: UserInputEventType.InputChangeEvent, + value: '25671892-75c7-4661-9a01-3dcfd2fedcdf', + }, + result: true, + }, + { + formEvent: { + name: SendFormNames.Amount, + type: UserInputEventType.InputChangeEvent, + value: '123', + }, + result: true, + }, + { + formEvent: { + name: SendFormNames.Amount, + type: UserInputEventType.InputChangeEvent, + value: 'tb1q9lakrt5sw0w0twnc6ww4vxs7hm0q23e03286k8', + }, + result: true, + }, + { + formEvent: { + name: SendFormNames.Cancel, + type: UserInputEventType.ButtonClickEvent, + }, + result: true, + }, + { + formEvent: { + name: SendFormNames.Clear, + type: UserInputEventType.ButtonClickEvent, + }, + result: true, + }, + { + formEvent: { + name: SendFormNames.Close, + type: UserInputEventType.ButtonClickEvent, + }, + result: true, + }, + { + formEvent: { + name: SendFormNames.HeaderBack, + type: UserInputEventType.ButtonClickEvent, + }, + result: true, + }, + { + formEvent: { + name: SendFormNames.Review, + type: UserInputEventType.ButtonClickEvent, + }, + result: true, + }, + { + formEvent: { + name: SendFormNames.Send, + type: UserInputEventType.ButtonClickEvent, + }, + result: true, + }, + { + formEvent: { + name: SendFormNames.SwapCurrencyDisplay, + type: UserInputEventType.ButtonClickEvent, + }, + result: true, + }, + { + formEvent: { + name: 'unknown', + type: UserInputEventType.InputChangeEvent, + }, + result: false, + }, + { + formEvent: { + name: 'unknown', + type: UserInputEventType.ButtonClickEvent, + }, + result: false, + }, + ])( + 'returns $result for event name $formEvent.name and type $formEvent.type', + ({ formEvent, result }) => { + // @ts-expect-error testing error case + expect(isSendFormEvent(formEvent)).toBe(result); + }, + ); + }); + + describe('handleEvent', () => { + it('should handle input change event', async () => { + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + + const mockEvent: UserInputEvent = { + name: SendFormNames.To, + type: UserInputEventType.InputChangeEvent, + value: 'address', + }; + + const mockFormState: SendFormState = { + to: 'address', + amount: '', + accountSelector: '', + }; + + const { instance: stateManager, upsertRequestSpy } = + createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + await controller.handleEvent(mockEvent, mockContext, mockFormState); + + const expectedRequest = { + ...mockRequest, + recipient: { + ...mockRequest.recipient, + address: 'address', + }, + }; + + expect(upsertRequestSpy).toHaveBeenCalledWith(expectedRequest); + expect(updateSendFlow).toHaveBeenCalledWith({ + request: expectedRequest, + }); + }); + + it('should handle button click event', async () => { + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + + const mockEvent: UserInputEvent = { + name: SendFormNames.Cancel, + type: UserInputEventType.ButtonClickEvent, + }; + + const mockFormState: SendFormState = { + to: 'address', + amount: '', + accountSelector: '', + }; + + const { instance: stateManager } = createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + jest.spyOn(controller, 'handleButtonEvent').mockResolvedValue(undefined); + await controller.handleEvent(mockEvent, mockContext, mockFormState); + expect(controller.handleButtonEvent).toHaveBeenCalledWith(mockEvent.name); + }); + + it('should not handle unknown event type', async () => { + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + + const mockEvent: UserInputEvent = { + name: 'unknown', + type: UserInputEventType.ButtonClickEvent, + }; + + const mockFormState: SendFormState = { + to: '', + amount: '', + accountSelector: '', + }; + + const { instance: stateManager } = createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + jest.spyOn(controller, 'handleButtonEvent').mockResolvedValue(undefined); + jest.spyOn(controller, 'handleInputEvent').mockResolvedValue(undefined); + await controller.handleEvent(mockEvent, mockContext, mockFormState); + expect(controller.handleButtonEvent).not.toHaveBeenCalled(); + expect(controller.handleInputEvent).not.toHaveBeenCalled(); + expect(updateSendFlow).not.toHaveBeenCalled(); + }); + }); + + describe('handleInputEvent', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('To input event', () => { + it('handles valid "To" input event', async () => { + const mockAddress = 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a'; + const mockFormState = { + to: mockAddress, + amount: '', + accountSelector: '', + }; + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + mockRequest.status = TransactionStatus.Review; + + const { instance: stateManager } = createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + await controller.handleInputEvent( + SendFormNames.To, + mockContext, + mockFormState, + ); + expect(controller.request).toStrictEqual({ + ...mockRequest, + recipient: { + address: mockAddress, + valid: true, + error: '', + }, + }); + expect(updateSendFlow).toHaveBeenCalledWith({ + request: controller.request, + }); + }); + + it('handle invalid address "To" input event', async () => { + const mockAddress = 'invalid address'; + const mockFormState = { + to: mockAddress, + amount: '', + accountSelector: '', + }; + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + mockRequest.status = TransactionStatus.Review; + + const { instance: stateManager } = createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + await controller.handleInputEvent( + SendFormNames.To, + mockContext, + mockFormState, + ); + expect(controller.request).toStrictEqual({ + ...mockRequest, + recipient: { + address: mockAddress, + valid: false, + error: 'Invalid address', + }, + }); + expect(updateSendFlow).toHaveBeenCalledWith({ + request: controller.request, + }); + }); + }); + + describe('Amount input event', () => { + it('handles valid "Amount" input event with BTC currency', async () => { + const mockAmount = '0.01'; + const mockFormState = { + to: '', + amount: mockAmount, + accountSelector: '', + }; + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + mockRequest.selectedCurrency = AssetType.BTC; + + const { instance: stateManager } = createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + + (estimateFee as jest.Mock).mockResolvedValue({ + fee: { amount: '0.0001' }, + }); + + await controller.handleInputEvent( + SendFormNames.Amount, + mockContext, + mockFormState, + ); + + expect(controller.request.amount.amount).toBe(mockAmount); + expect(controller.request.amount.fiat).toBeDefined(); + expect(controller.request.fees.amount).toBe('0.0001'); + expect(controller.request.total.amount).toBeDefined(); + expect(updateSendFlow).toHaveBeenCalledWith({ + request: controller.request, + }); + }); + + it('handles valid "Amount" input event with FIAT currency', async () => { + const mockAmount = '100.00'; + const mockFormState = { + to: '', + amount: mockAmount, + accountSelector: '', + }; + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + mockRequest.selectedCurrency = AssetType.FIAT; + mockRequest.rates = '60000'; + + const { instance: stateManager } = createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + + (estimateFee as jest.Mock).mockResolvedValue({ + fee: { amount: '0.0001' }, + }); + + await controller.handleInputEvent( + SendFormNames.Amount, + mockContext, + mockFormState, + ); + + expect(controller.request.amount.amount).toBe('0.00166667'); + expect(controller.request.amount.fiat).toBe(mockAmount); + expect(controller.request.fees.amount).toBe('0.0001'); + expect(controller.request.total.amount).toBeDefined(); + expect(updateSendFlow).toHaveBeenCalledWith({ + request: controller.request, + }); + }); + + it('handles invalid "Amount" input event', async () => { + const mockAmount = 'invalid amount'; + const mockFormState = { + to: '', + amount: mockAmount, + accountSelector: '', + }; + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + + const { instance: stateManager } = createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + + await controller.handleInputEvent( + SendFormNames.Amount, + mockContext, + mockFormState, + ); + + expect(controller.request.amount.valid).toBe(false); + expect(updateSendFlow).toHaveBeenCalledWith({ + request: controller.request, + }); + }); + + it('handles fee estimation error', async () => { + const mockAmount = '0.01'; + const mockFormState = { + to: '', + amount: mockAmount, + accountSelector: '', + }; + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + mockRequest.selectedCurrency = AssetType.BTC; + + const { instance: stateManager } = createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + + (estimateFee as jest.Mock).mockRejectedValue( + new Error('Fee estimation error'), + ); + + await controller.handleInputEvent( + SendFormNames.Amount, + mockContext, + mockFormState, + ); + + expect(controller.request.fees.error).toBe('Fee estimation error'); + expect(updateSendFlow).toHaveBeenCalledWith({ + request: controller.request, + }); + }); + }); + }); + + describe('handleButtonEvent', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should handle "HeaderBack" button event when the status is in review', async () => { + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + mockRequest.status = TransactionStatus.Review; + + const { instance: stateManager } = createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + await controller.handleButtonEvent(SendFormNames.HeaderBack); + expect(controller.request.status).toBe(TransactionStatus.Draft); + expect(controller.request).toStrictEqual({ + ...mockRequest, + status: TransactionStatus.Draft, + }); + expect(updateSendFlow).toHaveBeenCalled(); + }); + + it('should handle "HeaderBack" button event when the status is in draft', async () => { + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + mockRequest.status = TransactionStatus.Draft; + + const { instance: stateManager } = createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + await controller.handleButtonEvent(SendFormNames.HeaderBack); + expect(controller.request.status).toBe(TransactionStatus.Rejected); + expect(controller.request).toStrictEqual({ + ...mockRequest, + status: TransactionStatus.Rejected, + }); + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_resolveInterface', + params: { + id: controller.interfaceId, + value: false, + }, + }); + }); + + it('should handle "Clear" button event', async () => { + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + mockRequest.recipient.address = 'address'; + const { instance: stateManager } = createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + await controller.handleButtonEvent(SendFormNames.Clear); + expect(controller.request.recipient.address).toBe(''); + }); + + it('should handle "Cancel" button event', async () => { + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + const { instance: stateManager } = createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + await controller.handleButtonEvent(SendFormNames.Cancel); + expect(controller.request.status).toBe(TransactionStatus.Rejected); + }); + + it('should handle "SwapCurrencyDisplay" button event', async () => { + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + const { instance: stateManager } = createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + await controller.handleButtonEvent(SendFormNames.SwapCurrencyDisplay); + const expectedResult = { + ...mockRequest, + selectedCurrency: AssetType.FIAT, + }; + expect(controller.request.selectedCurrency).toBe(AssetType.FIAT); + expect(stateManager.upsertRequest).toHaveBeenCalledWith(expectedResult); + expect(updateSendFlow).toHaveBeenCalledWith({ + request: expectedResult, + flushToAddress: false, + currencySwitched: true, + }); + }); + + it('should handle "Review" button event', async () => { + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + const { instance: stateManager } = createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + await controller.handleButtonEvent(SendFormNames.Review); + const expectedResult = { + ...mockRequest, + status: TransactionStatus.Review, + }; + expect(controller.request.status).toBe(TransactionStatus.Review); + expect(stateManager.upsertRequest).toHaveBeenCalledWith(expectedResult); + expect(mockGenerateConfirmationReviewInterface).toHaveBeenCalledWith({ + request: expectedResult, + }); + }); + + it('should handle "Send" button event', async () => { + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + mockRequest.status = TransactionStatus.Review; + const { instance: stateManager } = createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + await controller.handleButtonEvent(SendFormNames.Send); + const expectedResult = { + ...mockRequest, + status: TransactionStatus.Signed, + }; + expect(controller.request.status).toBe(TransactionStatus.Signed); + expect(stateManager.upsertRequest).toHaveBeenCalledWith(expectedResult); + expect(snap.request).toHaveBeenCalledWith({ + method: 'snap_resolveInterface', + params: { + id: expectedResult.interfaceId, + value: true, + }, + }); + }); + + it('should handle "SetMax" button event', async () => { + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + const { instance: stateManager } = createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + + const mockMaxSpendableBalance = { + balance: { amount: '0.05' }, + fee: { amount: '0.0001' }, + }; + + jest.spyOn(controller, 'persistRequest').mockResolvedValue(undefined); + (getMaxSpendableBalance as jest.Mock).mockResolvedValue( + mockMaxSpendableBalance, + ); + + await controller.handleButtonEvent(SendFormNames.SetMax); + + expect(controller.request.amount.amount).toBe( + mockMaxSpendableBalance.balance.amount, + ); + expect(controller.request.fees.amount).toBe( + mockMaxSpendableBalance.fee.amount, + ); + expect(controller.request.total.amount).toBe( + new BigNumber(mockMaxSpendableBalance.balance.amount) + .plus(new BigNumber(mockMaxSpendableBalance.fee.amount)) + .toString(), + ); + expect(updateSendFlow).toHaveBeenCalledWith({ + request: controller.request, + currencySwitched: true, + }); + }); + + it('should handle "SetMax" button event with error', async () => { + const mockRequest = generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ); + const { instance: stateManager } = createMockStateManager(); + + const controller = new SendManyController({ + stateManager, + request: mockRequest, + context: mockContext, + interfaceId: mockInterfaceId, + }); + + jest.spyOn(controller, 'persistRequest').mockResolvedValue(undefined); + (getMaxSpendableBalance as jest.Mock).mockRejectedValue( + new Error('Error fetching max amount'), + ); + + await controller.handleButtonEvent(SendFormNames.SetMax); + + expect(controller.request.amount.error).toBe( + 'Error fetching max amount: Error fetching max amount', + ); + expect(controller.request.fees.loading).toBe(false); + expect(updateSendFlow).toHaveBeenCalledWith({ + request: controller.request, + currencySwitched: true, + }); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.ts b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.ts new file mode 100644 index 00000000..c872d86f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.ts @@ -0,0 +1,285 @@ +import type { UserInputEvent } from '@metamask/snaps-sdk'; +import { UserInputEventType } from '@metamask/snaps-sdk'; +import { BigNumber } from 'bignumber.js'; + +import { estimateFee, getMaxSpendableBalance } from '../../rpcs'; +import type { KeyringStateManager } from '../../stateManagement'; +import { TransactionStatus, type SendFlowRequest } from '../../stateManagement'; +import { SendFormNames } from '../components/SendForm'; +import { + generateConfirmationReviewInterface, + updateSendFlow, +} from '../render-interfaces'; +import { AssetType, type SendFlowContext, type SendFormState } from '../types'; +import { btcToFiat, fiatToBtc, formValidation, validateTotal } from '../utils'; + +export const isSendFormEvent = (event: UserInputEvent): boolean => { + return Object.values(SendFormNames).includes(event?.name as SendFormNames); +}; + +export class SendManyController { + protected stateManager: KeyringStateManager; + + request: SendFlowRequest; + + context: SendFlowContext; + + interfaceId: string; + + constructor({ + stateManager, + request, + context, + interfaceId, + }: { + stateManager: KeyringStateManager; + request: SendFlowRequest; + context: SendFlowContext; + interfaceId: string; + }) { + this.stateManager = stateManager; + this.request = request; + this.context = context; + this.interfaceId = interfaceId; + } + + async persistRequest(request: Partial) { + await this.stateManager.upsertRequest({ + ...this.request, + ...request, + }); + } + + async handleEvent( + event: UserInputEvent, + context: SendFlowContext, + formState: SendFormState, + ) { + if (!isSendFormEvent(event)) { + return; + } + + switch (event.type) { + case UserInputEventType.InputChangeEvent: { + await this.handleInputEvent( + event.name as SendFormNames, + context, + formState, + ); + break; + } + case UserInputEventType.ButtonClickEvent: { + await this.handleButtonEvent(event.name as SendFormNames); + break; + } + default: + break; + } + } + + async handleInputEvent( + eventName: SendFormNames, + context: SendFlowContext, + formState: SendFormState, + ): Promise { + formValidation(formState, context, this.request); + + switch (eventName) { + case SendFormNames.To: { + this.request.recipient.address = formState.to; + this.request.recipient.valid = Boolean(!this.request.recipient.error); + await this.persistRequest(this.request); + await updateSendFlow({ + request: this.request, + }); + break; + } + case SendFormNames.Amount: { + if (this.request.amount.error) { + await updateSendFlow({ + request: this.request, + }); + return; + } + this.request.amount.valid = Boolean(!this.request.amount.error); + this.request.fees.loading = true; + + // show loading state for fees + await updateSendFlow({ + request: this.request, + }); + + if (this.request.selectedCurrency === AssetType.BTC) { + this.request.amount.amount = formState.amount; + this.request.amount.fiat = btcToFiat( + formState.amount, + this.request.rates, + ); + } else { + this.request.amount.fiat = formState.amount; + this.request.amount.amount = fiatToBtc( + formState.amount, + this.request.rates, + ); + } + + try { + const estimates = await estimateFee({ + account: this.context.accounts[0].id, + amount: this.request.amount.amount, + }); + this.request.fees = { + fiat: btcToFiat(estimates.fee.amount, this.request.rates), + amount: estimates.fee.amount, + loading: false, + error: '', + }; + this.request.total = validateTotal( + this.request.amount.amount, + estimates.fee.amount, + this.request.balance.amount, + this.request.rates, + ); + } catch (feeError) { + this.request.fees = { + fiat: '', + amount: '', + loading: false, + error: feeError.message, + }; + } + await this.persistRequest(this.request); + await updateSendFlow({ + request: this.request, + }); + break; + } + default: + break; + } + } + + async handleButtonEvent(eventName: SendFormNames): Promise { + switch (eventName) { + case SendFormNames.HeaderBack: { + if (this.request.status === TransactionStatus.Review) { + this.request.status = TransactionStatus.Draft; + await this.persistRequest(this.request); + return await updateSendFlow({ + request: this.request, + flushToAddress: false, + backEventTriggered: true, + }); + } else if (this.request.status === TransactionStatus.Draft) { + this.request.status = TransactionStatus.Rejected; + await this.persistRequest(this.request); + return await this.resolveInterface(false); + } + throw new Error('Invalid state'); + } + case SendFormNames.Clear: + this.request.recipient = { + address: '', + error: '', + valid: false, + }; + await this.persistRequest(this.request); + return await updateSendFlow({ + request: this.request, + flushToAddress: true, + }); + case SendFormNames.Cancel: + case SendFormNames.Close: { + this.request.status = TransactionStatus.Rejected; + await this.persistRequest(this.request); + await this.resolveInterface(false); + return null; + } + case SendFormNames.SwapCurrencyDisplay: { + this.request.selectedCurrency = + this.request.selectedCurrency === AssetType.BTC + ? AssetType.FIAT + : AssetType.BTC; + await this.persistRequest(this.request); + return await updateSendFlow({ + request: this.request, + flushToAddress: false, + currencySwitched: true, + }); + } + case SendFormNames.Review: { + this.request.status = TransactionStatus.Review; + await this.persistRequest(this.request); + await generateConfirmationReviewInterface({ request: this.request }); + return null; + } + case SendFormNames.Send: { + this.request.status = TransactionStatus.Signed; + await this.persistRequest(this.request); + await this.resolveInterface(true); + return null; + } + case SendFormNames.SetMax: { + this.request.fees.loading = true; + await updateSendFlow({ + request: this.request, + }); + return await this.handleSetMax(); + } + default: + return null; + } + } + + async resolveInterface(value: boolean): Promise { + await snap.request({ + method: 'snap_resolveInterface', + params: { + id: this.interfaceId, + value, + }, + }); + } + + async handleSetMax() { + try { + const maxAmount = await getMaxSpendableBalance({ + account: this.context.accounts[0].id, + }); + if (new BigNumber(maxAmount.balance.amount).lte(new BigNumber(0))) { + this.request.amount.error = 'Fees exceed max sendable amount'; + this.request.fees.loading = false; + } else { + this.request.amount = { + amount: maxAmount.balance.amount, + fiat: btcToFiat(maxAmount.balance.amount, this.request.rates), + error: '', + valid: true, + }; + this.request.fees = { + amount: maxAmount.fee.amount, + fiat: btcToFiat(maxAmount.fee.amount, this.request.rates), + loading: false, + error: '', + }; + this.request.total = validateTotal( + maxAmount.balance.amount, + maxAmount.fee.amount, + this.request.balance.amount, + this.request.rates, + ); + } + } catch (error) { + this.request.amount.error = `Error fetching max amount: ${ + error.message as string + }`; + this.request.fees.loading = false; + } + + await this.persistRequest(this.request); + return await updateSendFlow({ + request: this.request, + currencySwitched: true, + }); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/images/btc-halo.svg b/merged-packages/bitcoin-wallet-snap/src/ui/images/btc-halo.svg new file mode 100644 index 00000000..69a14a20 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/images/btc-halo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/images/btc.svg b/merged-packages/bitcoin-wallet-snap/src/ui/images/btc.svg new file mode 100644 index 00000000..f5889766 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/images/btc.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon1.svg b/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon1.svg new file mode 100644 index 00000000..c7cc308b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon1.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon2.svg b/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon2.svg new file mode 100644 index 00000000..b00299ac --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon2.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon3.svg b/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon3.svg new file mode 100644 index 00000000..c43767a6 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon3.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx new file mode 100644 index 00000000..1c165e3a --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx @@ -0,0 +1,104 @@ +import { v4 as uuidv4 } from 'uuid'; + +import type { SendFlowRequest } from '../stateManagement'; +import { + generateDefaultSendFlowParams, + generateDefaultSendFlowRequest, +} from '../utils/transaction'; +import { SendFlow, ReviewTransaction } from './components'; +import type { GenerateSendFlowParams, UpdateSendFlowParams } from './types'; + +/** + * Generate the send flow. + * + * @param params - The parameters for the send form. + * @param params.account - The selected account. + * @param params.scope - The scope of the send flow. + * @returns The interface ID. + */ +export async function generateSendFlow({ + account, + scope, +}: GenerateSendFlowParams): Promise { + const requestId = uuidv4(); + const sendFlowProps = generateDefaultSendFlowParams(); + const interfaceId = await snap.request({ + method: 'snap_createInterface', + params: { + ui: ( + + ), + context: { + requestId, + accounts: [account], + scope, + }, + }, + }); + + const sendFlowRequest = generateDefaultSendFlowRequest( + account, + scope, + requestId, + interfaceId, + ); + + return sendFlowRequest; +} + +/** + * Update the send flow interface. + * + * @param options - The options for updating the send flow. + * @param options.request - The send flow request object. + * @param options.flushToAddress - Whether to flush to address. + * @param options.currencySwitched - Whether the currency was switched. + * @param options.backEventTriggered - Whether the back event was triggered. + */ +export async function updateSendFlow({ + request, + flushToAddress = false, + currencySwitched = false, + backEventTriggered = false, +}: UpdateSendFlowParams) { + await snap.request({ + method: 'snap_updateInterface', + params: { + id: request.interfaceId, + ui: ( + + ), + }, + }); +} + +/** + * Generate the confirmation review interface. + * + * @param options0 - The options for generating the confirmation review interface. + * @param options0.request - The send flow request object. + * @returns The interface ID as a string. + */ +export async function generateConfirmationReviewInterface({ + request, +}: { + request: SendFlowRequest; +}): Promise { + return await snap.request({ + method: 'snap_createInterface', + params: { + ui: , + }, + }); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/types.ts b/merged-packages/bitcoin-wallet-snap/src/ui/types.ts new file mode 100644 index 00000000..3b0c9c3b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/types.ts @@ -0,0 +1,84 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; + +import type { SendFlowRequest } from '../stateManagement'; + +/** + * The state of the send form. + * + * @property to - The receiving address. + * @property amount - The amount to send. + * @property accountSelector - The selected account. + */ +export type SendFormState = { + to: string; + amount: string; + accountSelector: string; +}; + +export enum SendFormError { + InvalidAddress = 'Invalid address', + InvalidAmount = 'Invalid amount', + ZeroAmount = 'Amount must be greater than 0', + InsufficientFunds = 'Insufficient funds', + TotalExceedsBalance = 'Amount and fees exceeds balance', + InvalidTotal = 'Invalid total', + InvalidFees = 'Invalid fees', +} + +/** + * The form errors. + * + * @property to - The error for the receiving address. + * @property amount - The error for the amount. + * @property total - The error for the total amount. + * @property fees - The error for the estimated fees. + */ +export type SendFormErrorsObject = { + to: SendFormError; + amount: SendFormError; + total: SendFormError; + fees: SendFormError; +}; + +/** + * A currency value. + * + * @property amount - The amount in the selected currency. + * @property fiat - The amount in fiat currency. + */ +export type Currency = { + amount: string; + fiat: string; +}; + +/** + * The context of the send flow interface. + * + * @property accounts - The available accounts. + * @property fees - The fees for the transaction. + * @property requestId - The ID of the send flow request. + */ +export type SendFlowContext = { + accounts: AccountWithBalance[]; + scope: string; + requestId: string; +}; + +export type AccountWithBalance = KeyringAccount & { balance?: Currency }; + +export enum AssetType { + BTC = 'BTC', + FIAT = '$', +} + +export type GenerateSendFlowParams = { + account: KeyringAccount; + scope: string; +}; + +export type UpdateSendFlowParams = { + request: SendFlowRequest; + flushToAddress?: boolean; + currencySwitched?: boolean; + backEventTriggered?: boolean; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts b/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts new file mode 100644 index 00000000..63219393 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts @@ -0,0 +1,556 @@ +import { expect } from '@jest/globals'; +import { BtcAccountType } from '@metamask/keyring-api'; +import { BigNumber } from 'bignumber.js'; +import { v4 as uuidv4 } from 'uuid'; + +import { Caip2ChainId, Caip2ChainIdToNetworkName } from '../constants'; +import type { SendManyParams } from '../rpcs'; +import { generateDefaultSendFlowRequest } from '../utils/transaction'; +import { AssetType, SendFormError } from './types'; +import { + validateAmount, + validateRecipient, + btcToFiat, + fiatToBtc, + generateSendManyParams, + sendManyParamsToSendFlowParams, + formValidation, + getNetworkNameFromScope, +} from './utils'; + +const mockEstimateFee = jest.fn(); +jest.mock('../rpcs/estimate-fee', () => ({ + estimateFee: () => mockEstimateFee(), +})); + +const mockInterfaceId = 'interfaceId'; +const mockScope = Caip2ChainId.Mainnet; +const mockRequestId = 'requestId'; +const mockAccount = { + type: BtcAccountType.P2wpkh, + id: uuidv4(), + address: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', + options: { + scope: Caip2ChainId.Mainnet, + index: '1', + }, + methods: ['btc_sendmany'], +}; + +describe('utils', () => { + describe('validateAmount', () => { + it('should return error if amount is not a number', () => { + const result = validateAmount('abc', '100', '62000'); + expect(result).toStrictEqual({ + amount: '', + fiat: '', + error: SendFormError.InvalidAmount, + valid: false, + }); + }); + + it('should return error if amount is less than or equal to 0', () => { + const result = validateAmount('0', '100', '62000'); + expect(result).toStrictEqual({ + amount: '0', + fiat: '0', + error: SendFormError.ZeroAmount, + valid: false, + }); + }); + + it('should return error if amount is greater than balance', () => { + const result = validateAmount('200', '100', '62000'); + expect(result).toStrictEqual({ + amount: '200', + fiat: '12400000.00', + error: SendFormError.InsufficientFunds, + valid: false, + }); + }); + + it('should return valid amount if amount is valid', () => { + const result = validateAmount('50', '100', '62000'); + expect(result).toStrictEqual({ + amount: '50', + fiat: '3100000.00', + error: '', + valid: true, + }); + }); + }); + + describe('validateRecipient', () => { + it('should return valid for a correct mainnet address', () => { + const result = validateRecipient( + 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', + Caip2ChainId.Mainnet, + ); + expect(result).toStrictEqual({ + address: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', + error: '', + valid: true, + }); + }); + + it('should return valid for a correct testnet address', () => { + const result = validateRecipient( + 'mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn', + Caip2ChainId.Testnet, + ); + expect(result).toStrictEqual({ + address: 'mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn', + error: '', + valid: true, + }); + }); + + it('should return error for an invalid mainnet address', () => { + const result = validateRecipient('invalidAddress', Caip2ChainId.Mainnet); + expect(result).toStrictEqual({ + address: 'invalidAddress', + error: SendFormError.InvalidAddress, + valid: false, + }); + }); + + it('should return error for an invalid testnet address', () => { + const result = validateRecipient('invalidAddress', Caip2ChainId.Testnet); + expect(result).toStrictEqual({ + address: 'invalidAddress', + error: SendFormError.InvalidAddress, + valid: false, + }); + }); + + it('should return error for a valid mainnet address in testnet scope', () => { + const result = validateRecipient( + 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', + Caip2ChainId.Testnet, + ); + expect(result).toStrictEqual({ + address: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', + error: SendFormError.InvalidAddress, + valid: false, + }); + }); + + it('should return error for a valid testnet address in mainnet scope', () => { + const result = validateRecipient( + 'mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn', + Caip2ChainId.Mainnet, + ); + expect(result).toStrictEqual({ + address: 'mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn', + error: SendFormError.InvalidAddress, + valid: false, + }); + }); + + it('should handle empty address', () => { + const result = validateRecipient('', Caip2ChainId.Mainnet); + expect(result).toStrictEqual({ + address: '', + error: SendFormError.InvalidAddress, + valid: false, + }); + }); + + it('should handle null address', () => { + const result = validateRecipient( + null as unknown as string, + Caip2ChainId.Mainnet, + ); + expect(result).toStrictEqual({ + address: '', + error: SendFormError.InvalidAddress, + valid: false, + }); + }); + + it('should handle undefined address', () => { + const result = validateRecipient( + undefined as unknown as string, + Caip2ChainId.Mainnet, + ); + expect(result).toStrictEqual({ + address: '', + error: SendFormError.InvalidAddress, + valid: false, + }); + }); + }); + + describe('sendStateToSendManyParams', () => { + it('should convert send state to SendManyParams correctly', async () => { + const request = { + ...generateDefaultSendFlowRequest( + mockAccount, + mockScope, + mockRequestId, + mockInterfaceId, + ), + recipient: { + address: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', + error: '', + valid: true, + }, + amount: { amount: '0.1', fiat: '6200.00', error: '', valid: true }, + fees: { amount: '0.0001', fiat: '6.20', loading: false, error: '' }, + selectedCurrency: AssetType.BTC, + rates: '62000', + balance: { amount: '1', fiat: '62000.00' }, + }; + const scope = Caip2ChainId.Mainnet; + + const result = generateSendManyParams(scope, request); + + expect(result).toStrictEqual({ + amounts: { bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a: '0.1' }, + comment: '', + subtractFeeFrom: [], + replaceable: true, + dryrun: false, + scope, + }); + }); + }); + + describe('sendManyParamsToSendFlowParams', () => { + const mockFee = { + fee: { + amount: '0.0001', + unit: 'BTC', + }, + }; + + beforeEach(() => { + mockEstimateFee.mockResolvedValue(mockFee); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should convert SendManyParams to SendFlowParams correctly', async () => { + const params: Omit = { + amounts: { bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a: '0.1' }, + comment: '', + subtractFeeFrom: [], + replaceable: true, + dryrun: false, + }; + const account = 'testAccount'; + const scope = Caip2ChainId.Mainnet; + const rates = '62000'; + const balance = '1'; + + const result = await sendManyParamsToSendFlowParams( + params, + account, + scope, + rates, + balance, + ); + + const expectedAmount = Object.values(params.amounts)[0]; + const expectedTotal = new BigNumber(expectedAmount) + .plus(new BigNumber(mockFee.fee.amount)) + .toString(); + + expect(result).toStrictEqual({ + rates, + recipient: { + address: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', + error: '', + valid: true, + }, + balance: { + amount: balance, + fiat: btcToFiat(balance, rates), + }, + fees: { + amount: mockFee.fee.amount, + fiat: btcToFiat(mockFee.fee.amount, rates), + loading: false, + error: '', + }, + amount: { + amount: expectedAmount, + fiat: btcToFiat(expectedAmount, rates), + error: '', + valid: true, + }, + total: { + amount: expectedTotal, + fiat: btcToFiat(expectedTotal, rates), + valid: true, + error: '', + }, + selectedCurrency: AssetType.BTC, + }); + }); + + it('should handle invalid recipient address', async () => { + const params = { + amounts: { invalidAddress: '0.1' }, + comment: '', + subtractFeeFrom: [], + replaceable: true, + dryrun: false, + }; + const account = 'testAccount'; + const scope = Caip2ChainId.Mainnet; + const rates = '62000'; + const balance = '1'; + + const result = await sendManyParamsToSendFlowParams( + params, + account, + scope, + rates, + balance, + ); + expect(result.recipient.error).toBe('Invalid address'); + }); + + it('should handle invalid amount', async () => { + const params = { + amounts: { bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a: '0' }, + comment: '', + subtractFeeFrom: [], + replaceable: true, + dryrun: false, + }; + const account = 'testAccount'; + const scope = Caip2ChainId.Mainnet; + const rates = '62000'; + const balance = '1'; + + const result = await sendManyParamsToSendFlowParams( + params, + account, + scope, + rates, + balance, + ); + + expect(result.amount.error).toBe('Amount must be greater than 0'); + }); + + it('should handle insufficient balance', async () => { + const params = { + amounts: { bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a: '2' }, + comment: '', + subtractFeeFrom: [], + replaceable: true, + dryrun: false, + }; + const account = 'testAccount'; + const scope = Caip2ChainId.Mainnet; + const rates = '62000'; + const balance = '1'; + + const result = sendManyParamsToSendFlowParams( + params, + account, + scope, + rates, + balance, + ); + expect((await result).amount.error).toBe(SendFormError.InsufficientFunds); + }); + }); + + describe('btcToFiat', () => { + it.each([ + { amount: '1', rate: '62000', expected: '62000.00' }, + { amount: '0.1', rate: '62000', expected: '6200.00' }, + { amount: '1.1', rate: '62000', expected: '68200.00' }, + { amount: '1', rate: '0', expected: '0.00' }, + { amount: '0', rate: '62000', expected: '0.00' }, + { amount: '12', rate: '62000.888', expected: '744010.66' }, + { amount: '12', rate: '0.888', expected: '10.66' }, + ])( + 'should convert $amount btc to $rate fiat', + ({ amount, rate, expected }) => { + expect(btcToFiat(amount, rate)).toStrictEqual(expected); + }, + ); + }); + + describe('fiatToBtc', () => { + it.each([ + { amount: '1', rate: '62000', expected: '0.00001613' }, + { amount: '0.1', rate: '62000', expected: '0.00000161' }, + { amount: '1.1', rate: '62000', expected: '0.00001774' }, + { amount: '0', rate: '62000', expected: '0.00000000' }, + { amount: '12', rate: '62000.888', expected: '0.00019355' }, + { amount: '12', rate: '0.888', expected: '13.51351351' }, + ])( + 'should convert $amount fiat to $rate btc', + ({ amount, rate, expected }) => { + expect(fiatToBtc(amount, rate)).toStrictEqual(expected); + }, + ); + }); + + describe('formValidation', () => { + const context = { scope: Caip2ChainId.Mainnet }; + const rates = '62000'; + const balance = '1'; + + it('should validate form correctly with valid data', () => { + const formState = { + amount: '0.1', + to: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', + }; + const request = { + total: { amount: '', fiat: '' }, + recipient: { address: '', error: '', valid: false }, + amount: { amount: '', fiat: '', error: '', valid: false }, + fees: { amount: '', fiat: '', loading: false, error: '' }, + selectedCurrency: AssetType.BTC, + rates, + balance: { amount: balance, fiat: '62000.00' }, + }; + // @ts-expect-error test only request params and not the whole object + const result = formValidation(formState, context, request); + + expect(result).toStrictEqual({ + recipient: { + address: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', + error: '', + valid: true, + }, + amount: { + amount: '0.1', + fiat: '6200.00', + error: '', + valid: true, + }, + fees: { amount: '', fiat: '', loading: false, error: '' }, + selectedCurrency: AssetType.BTC, + rates, + balance: { amount: balance, fiat: '62000.00' }, + total: { + amount: '', + fiat: '', + }, + }); + }); + + it.each([ + { + formState: { + amount: 'abc', + to: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', + }, + expectedAmountError: SendFormError.InvalidAmount, + expectedRecipientError: '', + }, + { + formState: { + amount: '0', + to: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', + }, + expectedAmountError: SendFormError.ZeroAmount, + expectedRecipientError: '', + }, + { + formState: { + amount: '2', + to: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', + }, + expectedAmountError: SendFormError.InsufficientFunds, + expectedRecipientError: '', + }, + { + formState: { amount: '0.1', to: 'invalidAddress' }, + expectedAmountError: '', + expectedRecipientError: SendFormError.InvalidAddress, + }, + ])( + 'should handle error cases for form validation', + ({ formState, expectedAmountError, expectedRecipientError }) => { + const request = { + id: 'test-id', + interfaceId: 'test-interface-id', + account: mockAccount, + scope: Caip2ChainId.Mainnet, + recipient: { address: '', error: '', valid: false }, + amount: { amount: '', fiat: '', error: '', valid: false }, + fees: { amount: '', fiat: '', loading: false, error: '' }, + selectedCurrency: AssetType.BTC, + rates, + balance: { amount: balance, fiat: '62000.00' }, + }; + // @ts-expect-error test only request params and not the whole object + const result = formValidation(formState, context, request); + + expect(result.amount.error).toBe(expectedAmountError); + expect(result.recipient.error).toBe(expectedRecipientError); + }, + ); + + it('should reset fees if amount is invalid', () => { + const formState = { + amount: 'abc', + to: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', + }; + const request = { + recipient: { address: '', error: '', valid: false }, + amount: { amount: '', fiat: '', error: '', valid: false }, + fees: { amount: '0.0001', fiat: '6.20', loading: false, error: '' }, + selectedCurrency: AssetType.BTC, + rates, + balance: { amount: balance, fiat: '62000.00' }, + }; + // @ts-expect-error test only request params and not the whole object + const result = formValidation(formState, context, request); + + expect(result.fees).toStrictEqual({ + amount: '', + fiat: '', + loading: false, + error: '', + }); + }); + }); + + describe('getNetworkNameFromScope', () => { + const expectedError = 'Unknown Network'; + + it('should return "Mainnet" for Caip2ChainId.Mainnet', () => { + const result = getNetworkNameFromScope(Caip2ChainId.Mainnet); + expect(result).toBe(Caip2ChainIdToNetworkName[Caip2ChainId.Mainnet]); + }); + + it('should return "Testnet" for Caip2ChainId.Testnet', () => { + const result = getNetworkNameFromScope(Caip2ChainId.Testnet); + expect(result).toBe(Caip2ChainIdToNetworkName[Caip2ChainId.Testnet]); + }); + + it('should return "Unknown" for an unknown scope', () => { + const result = getNetworkNameFromScope('unknownScope' as Caip2ChainId); + expect(result).toBe(expectedError); + }); + + it('should return "Unknown" for an empty string', () => { + const result = getNetworkNameFromScope('' as Caip2ChainId); + expect(result).toBe(expectedError); + }); + + it('should return "Unknown" for null', () => { + const result = getNetworkNameFromScope(null as unknown as Caip2ChainId); + expect(result).toBe(expectedError); + }); + + it('should return "Unknown" for undefined', () => { + const result = getNetworkNameFromScope( + undefined as unknown as Caip2ChainId, + ); + expect(result).toBe(expectedError); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts b/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts new file mode 100644 index 00000000..4313632d --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts @@ -0,0 +1,357 @@ +import { BigNumber } from 'bignumber.js'; +// eslint-disable-next-line import/no-named-as-default +import validate, { Network } from 'bitcoin-address-validation'; +import { v4 as uuidv4 } from 'uuid'; + +import { + Caip2Asset, + Caip2ChainId, + Caip2ChainIdToNetworkName, +} from '../constants'; +import type { SendManyParams } from '../rpcs'; +import { estimateFee } from '../rpcs'; +import type { SendFlowParams, Wallet } from '../stateManagement'; +import { TransactionStatus, type SendFlowRequest } from '../stateManagement'; +import { generateDefaultSendFlowParams } from '../utils/transaction'; +import { generateConfirmationReviewInterface } from './render-interfaces'; +import { AssetType, SendFormError } from './types'; +import type { SendFlowContext, SendFormState } from './types'; + +/** + * Validate the send form. + * + * @param formState - The state of the send form. + * @param context - The context of the interface. + * @param request - The request object containing form data and errors. + * @returns The `SendFlowRequest` object with the assigned errors. + */ +export function formValidation( + formState: SendFormState, + context: SendFlowContext, + request: SendFlowRequest, +): SendFlowRequest { + // reset errors + request.amount.error = ''; + request.recipient.error = ''; + request.fees.error = ''; + + const formAmount = formState.amount ?? '0'; + const cryptoAmount = + request.selectedCurrency === AssetType.BTC + ? formAmount + : fiatToBtc(formAmount, request.rates); + + request.recipient = validateRecipient(formState.to, context.scope); + request.amount = validateAmount( + cryptoAmount, + request.balance.amount, + request.rates, + ); + + // Reset the fees if the amount is invalid + if (request.amount.error) { + request.fees = { + amount: '', + fiat: '', + loading: false, + error: '', + }; + } + + return request; +} + +/** + * Validates the amount to be sent. + * + * @param amount - The amount to be validated. + * @param balance - The current balance of the account. + * @param rates - The conversion rates from Bitcoin to fiat. + * @returns An object containing the validated amount, fiat equivalent, error message, and validity status. + */ +export function validateAmount( + amount: string, + balance: string, + rates: string, +): SendFlowRequest['amount'] { + if (amount && isNaN(Number(amount))) { + return { + amount: '', + fiat: '', + error: SendFormError.InvalidAmount, + valid: false, + }; + } + + if (amount && new BigNumber(amount).lte(new BigNumber(0))) { + return { + amount: '0', + fiat: '0', + error: SendFormError.ZeroAmount, + valid: false, + }; + } + + if (amount && new BigNumber(amount).gt(new BigNumber(balance))) { + return { + amount, + fiat: btcToFiat(amount, rates), + error: SendFormError.InsufficientFunds, + valid: false, + }; + } + + return { + amount, + fiat: btcToFiat(amount, rates), + error: '', + valid: true, + }; +} + +/** + * Validates the amount to be sent. + * + * @param amount - The amount to be validated. + * @param fees - The fees to be validated. + * @param balance - The current balance of the account. + * @param rates - The conversion rates from Bitcoin to fiat. + * @returns An object containing the validated amount, fiat equivalent, error message, and validity status. + */ +export function validateTotal( + amount: string, + fees: string, + balance: string, + rates: string, +): SendFlowRequest['total'] { + if ([amount, fees, balance, rates].some((value) => isNaN(Number(value)))) { + return { + amount: '', + fiat: '', + error: '', + valid: false, + }; + } + + const total = new BigNumber(amount).plus(new BigNumber(fees)); + if (total.gt(new BigNumber(balance))) { + return { + amount, + fiat: btcToFiat(amount, rates), + error: SendFormError.TotalExceedsBalance, + valid: false, + }; + } + + const newTotal = total.toString(); + return { + amount: newTotal, + fiat: btcToFiat(newTotal, rates), + error: '', + valid: true, + }; +} + +/** + * Converts the send state to SendManyParams. + * + * @param scope - The scope of the network (mainnet or testnet). + * @param request - The request object containing form data and errors. + * @returns A promise that resolves to the SendManyParams object. + */ +export function generateSendManyParams( + scope: string, + request?: SendFlowRequest, +): SendManyParams { + if (!request) { + return { + amounts: {}, + comment: '', + subtractFeeFrom: [], + replaceable: true, + dryrun: false, + scope, + }; + } + + return { + amounts: { + [request.recipient.address]: request.amount.amount, + }, + comment: '', + subtractFeeFrom: [], + replaceable: true, + dryrun: false, + scope, + }; +} + +/** + * Converts a Bitcoin amount to its equivalent fiat value. + * + * @param amount - The amount of Bitcoin to convert. + * @param rate - The conversion rate from Bitcoin to fiat. + * @returns The equivalent fiat value as a string. + */ +export function btcToFiat(amount: string, rate: string): string { + const amountBN = new BigNumber(amount); + const rateBN = new BigNumber(rate); + return amountBN.multipliedBy(rateBN).toFixed(2); +} + +/** + * Converts a fiat amount to its equivalent Bitcoin value. + * + * @param amount - The amount of fiat currency to convert. + * @param rate - The conversion rate from fiat to Bitcoin. + * @returns The equivalent Bitcoin value as a string. + */ +export function fiatToBtc(amount: string, rate: string): string { + const amountBN = new BigNumber(amount); + const rateBN = new BigNumber(rate); + return amountBN.dividedBy(rateBN).toFixed(8); // 8 is the number of decimals for btc +} + +/** + * Validates the recipient address based on the given scope. + * + * @param address - The recipient address to validate. + * @param scope - The scope of the network (mainnet or testnet). + * @returns An object containing the address, error message, and validity status. + */ +export function validateRecipient( + address: string, + scope: string, +): SendFlowRequest['recipient'] { + if ( + (scope === Caip2ChainId.Mainnet && !validate(address, Network.mainnet)) || + (scope === Caip2ChainId.Testnet && !validate(address, Network.testnet)) + ) { + return { + address: address ?? '', + error: SendFormError.InvalidAddress, + valid: false, + }; + } + + return { + address, + error: '', + valid: true, + }; +} + +/** + * Generates a send flow request object. + * + * @param wallet - The wallet object containing account and scope information. + * @param status - The current transaction status. + * @param rates - The conversion rates from Bitcoin to fiat. + * @param balance - The current balance of the account. + * @param transaction - Optional transaction details. + * @returns A promise that resolves to the SendFlowRequest object. + */ +export async function generateSendFlowRequest( + wallet: Wallet, + status: TransactionStatus, + rates: string, + balance: string, + transaction?: SendFlowRequest['transaction'], +): Promise { + const sendManyParams = transaction ?? generateSendManyParams(wallet.scope); + const sendFlowRequest = { + id: uuidv4(), + account: wallet.account, + scope: wallet.scope, + transaction: sendManyParams, + interfaceId: '', + status: status ?? TransactionStatus.Draft, + ...(await sendManyParamsToSendFlowParams( + sendManyParams, + wallet.account.id, + wallet.scope, + rates, + balance, + )), + }; + + const interfaceId = await generateConfirmationReviewInterface({ + request: sendFlowRequest, + }); + + sendFlowRequest.interfaceId = interfaceId; + + return sendFlowRequest; +} + +/** + * Converts SendManyParams to SendFlowParams. + * + * @param params - The parameters for sending many transactions. + * @param account - The account from which the transactions will be sent. + * @param scope - The scope of the network (mainnet or testnet). + * @param rates - The conversion rates from Bitcoin to fiat. + * @param balance - The balance of the account. + * @returns A promise that resolves to the send flow parameters. + */ +export async function sendManyParamsToSendFlowParams( + params: Omit, + account: string, + scope: string, + rates: string, + balance: string, +): Promise { + const defaultParams = generateDefaultSendFlowParams(); + // This is safe because we validate the recipient in `validateRecipient` if it is not defined. + const recipient = Object.keys(params.amounts)[0]; + const amount = params.amounts[recipient]; + + defaultParams.rates = rates; + defaultParams.recipient = validateRecipient(recipient, scope); + defaultParams.balance = { + amount: balance, + fiat: btcToFiat(balance, rates), + }; + + try { + const estimatedFees = await estimateFee({ + account, + amount, + }); + defaultParams.fees.amount = estimatedFees.fee.amount; + defaultParams.fees.fiat = btcToFiat(estimatedFees.fee.amount, rates); + defaultParams.amount = validateAmount(amount, balance, rates); + defaultParams.total = validateTotal( + amount, + estimatedFees.fee.amount, + balance, + rates, + ); + } catch (error) { + defaultParams.fees.error = `Error estimating fees: ${ + error.message as string + }`; + } + + return defaultParams; +} + +/** + * Gets the asset type based on the given scope. + * + * @param scope - The scope of the network (mainnet or testnet). + * @returns The asset type corresponding to the scope. + */ +export function getAssetTypeFromScope(scope: string): Caip2Asset { + return scope === Caip2ChainId.Mainnet ? Caip2Asset.Btc : Caip2Asset.TBtc; +} + +/** + * Gets the network name based on the given scope. + * + * @param scope - The scope of the network (mainnet or testnet). + * @returns The network name corresponding to the scope. + */ +export function getNetworkNameFromScope(scope: string): string { + return Caip2ChainIdToNetworkName[scope] ?? 'Unknown Network'; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts index 79c1ea81..bab01542 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts @@ -32,3 +32,5 @@ export const alertDialog = jest.fn(); export const getStateData = jest.fn(); export const setStateData = jest.fn(); + +export const createSendUIDialog = () => jest.fn().mockResolvedValue(true)(); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/rates.ts b/merged-packages/bitcoin-wallet-snap/src/utils/rates.ts new file mode 100644 index 00000000..d5c87884 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/rates.ts @@ -0,0 +1,6 @@ +import type { Caip2Asset } from '../constants'; + +export const getRates = async (_asset: Caip2Asset): Promise => { + // TODO: replace with actual rates call + return '64000'; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts index ce238ff2..8e9e08db 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts @@ -98,3 +98,18 @@ export async function setStateData(data: State) { }, }); } + +/** + * Creates and sends a UI dialog with the specified interface ID. + * + * @param interfaceId - The ID of the interface to create the dialog for. + * @returns A Promise that resolves to the result of the dialog request. + */ +export async function createSendUIDialog(interfaceId: string) { + return await snap.request({ + method: 'snap_dialog', + params: { + id: interfaceId, + }, + }); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/transaction.ts b/merged-packages/bitcoin-wallet-snap/src/utils/transaction.ts new file mode 100644 index 00000000..5edcc464 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/transaction.ts @@ -0,0 +1,58 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; + +import type { SendFlowRequest } from '../stateManagement'; +import { TransactionStatus, type SendFlowParams } from '../stateManagement'; +import { AssetType } from '../ui/types'; +import { generateSendManyParams } from '../ui/utils'; + +export const generateDefaultSendFlowParams = (): SendFlowParams => { + return { + selectedCurrency: AssetType.BTC, + recipient: { + address: '', + error: '', + valid: false, + }, + fees: { + amount: '', + fiat: '', + loading: false, + error: '', + }, + amount: { + amount: '', + fiat: '', + error: '', + valid: false, + }, + rates: '', + balance: { + amount: '', + fiat: '', + }, + total: { + amount: '', + fiat: '', + error: '', + valid: false, + }, + }; +}; + +export const generateDefaultSendFlowRequest = ( + account: KeyringAccount, + scope: string, + requestId: string, + interfaceId: string, +): SendFlowRequest => { + return { + id: requestId, + interfaceId, + account, + scope, + transaction: generateSendManyParams(scope), + status: TransactionStatus.Draft, + // Send flow params + ...generateDefaultSendFlowParams(), + }; +}; diff --git a/merged-packages/bitcoin-wallet-snap/tsconfig.json b/merged-packages/bitcoin-wallet-snap/tsconfig.json index 0fbd5d41..b5eb0cac 100644 --- a/merged-packages/bitcoin-wallet-snap/tsconfig.json +++ b/merged-packages/bitcoin-wallet-snap/tsconfig.json @@ -6,7 +6,9 @@ "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. */ + "strictNullChecks": true /* Enable strict null checks. */, + "jsx": "react-jsx", + "jsxImportSource": "@metamask/snaps-sdk" }, - "include": ["**/*.ts"] + "include": ["**/*.ts", "**/*.tsx", "src/**/*.tsx", "src/index.tsx"] } From 7c46e1b3366cb0b55a4a2b917f3068545051e1f5 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 16 Oct 2024 17:51:31 +0800 Subject: [PATCH 145/362] refactor(QuickNode): refactor data client into a base class (#282) * chore: data client abstraction * chore: update js docs * Update api-client.ts * chore: update error text * chore: address PR comment * chore: add comment * chore: fix return type of quick node * chore: update request id to name * chore: update quicknode types * chore: address comment * fix: lint * chore: update logging of api client * chore: address comment * fix: test * fix: error message * chore: rename api client method * fix: PR comment --- .../src/bitcoin/chain/api-client.ts | 131 ++++++++++++++++++ .../bitcoin/chain/clients/quicknode.test.ts | 65 ++++++--- .../src/bitcoin/chain/clients/quicknode.ts | 115 +++++++-------- 3 files changed, 227 insertions(+), 84 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/api-client.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/api-client.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/api-client.ts new file mode 100644 index 00000000..082b7ed8 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/api-client.ts @@ -0,0 +1,131 @@ +import type { Json } from '@metamask/snaps-sdk'; +import type { Struct } from 'superstruct'; +import { mask } from 'superstruct'; + +import { compactError, logger } from '../../utils'; +import { DataClientError } from './exceptions'; + +export enum HttpMethod { + Get = 'GET', + Post = 'POST', +} + +export type HttpHeaders = Record; + +export type HttpRequest = { + url: string; + method: HttpMethod; + headers: HttpHeaders; + body?: string; +}; + +export type HttpResponse = globalThis.Response; + +export abstract class ApiClient { + /** + * The name of the API Client. + */ + abstract apiClientName: string; + + /** + * An internal method called internally by `submitRequest()` to verify and convert the HTTP response to the expected API response. + * + * @param response - The HTTP response to verify and convert. + * @returns A promise that resolves to the API response. + */ + protected async getResponse( + response: HttpResponse, + ): Promise { + try { + return (await response.json()) as unknown as ApiResponse; + } catch (error) { + throw new Error( + 'API response error: response body can not be deserialised.', + ); + } + } + + /** + * An internal method used to build the `HttpRequest` object. + * + * @param params - The request parameters. + * @param params.method - The HTTP method (GET or POST). + * @param params.headers - The HTTP headers. + * @param params.url - The request URL. + * @param [params.body] - The request body (optional). + * @returns A `HttpRequest` object. + */ + protected buildHttpRequest({ + method, + headers = {}, + url, + body, + }: { + method: HttpMethod; + headers?: HttpHeaders; + url: string; + body?: Json; + }): HttpRequest { + const request = { + url, + method, + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: + method === HttpMethod.Post && body ? JSON.stringify(body) : undefined, + }; + + return request; + } + + /** + * An internal method used to submit the API request. + * + * @param params - The request parameters. + * @param [params.requestName] - The name of the request (optional). + * @param params.request - The `HttpRequest` object. + * @param params.responseStruct - The superstruct used to verify the API response. + * @returns A promise that resolves to a JSON object. + */ + protected async submitHttpRequest({ + requestName = '', + request, + responseStruct, + }: { + requestName?: string; + request: HttpRequest; + responseStruct: Struct; + }): Promise { + const logPrefix = `[${this.apiClientName}.${requestName}]`; + + try { + logger.debug(`${logPrefix} request: ${request.method}`); // Log HTTP method being used. + + const fetchRequest = { + method: request.method, + headers: request.headers, + body: request.body, + }; + + const httpResponse = await fetch(request.url, fetchRequest); + + const jsonResponse = await this.getResponse(httpResponse); + + logger.debug(`${logPrefix} response:`, JSON.stringify(jsonResponse)); + + // Safeguard to identify if the response has some unexpected changes from the API client + mask(jsonResponse, responseStruct, `Unexpected response from API client`); + + return jsonResponse; + } catch (error) { + logger.info( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${logPrefix} error: ${error.message}`, + ); + + throw compactError(error, DataClientError); + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts index 2382c3f4..1d98bc9e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts @@ -1,6 +1,8 @@ import type { Json } from '@metamask/utils'; import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; +import type { Struct } from 'superstruct'; +import { any } from 'superstruct'; import { generateQuickNodeGetBalanceResp, @@ -31,10 +33,20 @@ describe('QuickNodeClient', () => { const mainnetEndpoint = 'https://api.quicknode.com/mainnet'; class MockQuickNodeClient extends QuickNodeClient { - async post( - body: Json, - ): Promise { - return super.post(body); + async submitJsonRPCRequest({ + request, + responseStruct, + }: { + request: { + method: string; + params: Json; + }; + responseStruct: Struct; + }) { + return super.submitJsonRPCRequest({ + request, + responseStruct, + }); } getPriorityMap() { @@ -119,11 +131,13 @@ describe('QuickNodeClient', () => { }); }; - describe('post', () => { + describe('submitJsonRPCRequest', () => { it('executes a request', async () => { const { fetchSpy } = createMockFetch(); + const mockResponse = { + result: true, + }; - const mockResponse = true; mockApiSuccessResponse({ fetchSpy, mockResponse, @@ -135,7 +149,10 @@ describe('QuickNodeClient', () => { }; const client = createQuickNodeClient(networks.testnet); - const result = await client.post(postBody); + const result = await client.submitJsonRPCRequest({ + request: postBody, + responseStruct: any(), + }); expect(fetchSpy).toHaveBeenCalledWith(client.baseUrl, { method: 'POST', @@ -144,16 +161,16 @@ describe('QuickNodeClient', () => { }, body: JSON.stringify(postBody), }); + expect(result).toBe(mockResponse); }); - it('throws `Failed to post data from quicknode` error if the http status is not 200', async () => { + it('throws `API response error` error if the http status is not 200', async () => { const { fetchSpy } = createMockFetch(); mockErrorResponse({ fetchSpy, status: 500, - isOk: true, errorResp: { error: 'api error', }, @@ -166,19 +183,24 @@ describe('QuickNodeClient', () => { const client = createQuickNodeClient(networks.testnet); - await expect(client.post(postBody)).rejects.toThrow( - 'Failed to post data from quicknode: api error', + await expect( + client.submitJsonRPCRequest({ + request: postBody, + responseStruct: any(), + }), + ).rejects.toThrow( + // The error message will be JSON stringified, hence the quotes here. + 'API response error: "api error"', ); }); - it('throws `Failed to post data from quicknode` error if the `response.ok` is false', async () => { + it('throws `API response error: response body can not be deserialised.` error if the response body can not be deserialised', async () => { const { fetchSpy } = createMockFetch(); - mockErrorResponse({ - fetchSpy, - status: 200, - statusText: 'api error', - isOk: false, + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 500, + json: jest.fn().mockRejectedValue(false), }); const postBody = { @@ -188,8 +210,13 @@ describe('QuickNodeClient', () => { const client = createQuickNodeClient(networks.testnet); - await expect(client.post(postBody)).rejects.toThrow( - 'Failed to post data from quicknode: api error', + await expect( + client.submitJsonRPCRequest({ + request: postBody, + responseStruct: any(), + }), + ).rejects.toThrow( + 'API response error: response body can not be deserialised.', ); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts index 21ebd828..fa08abc0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts @@ -2,17 +2,18 @@ import { BtcP2wpkhAddressStruct } from '@metamask/keyring-api'; import type { Json } from '@metamask/snaps-sdk'; import { networks } from 'bitcoinjs-lib'; import type { Struct } from 'superstruct'; -import { array, assert, mask } from 'superstruct'; +import { array, assert } from 'superstruct'; import { Config } from '../../../config'; import { btcToSats, - compactError, getMinimumFeeRateInKvb, logger, processBatch, satsKvbToVb, } from '../../../utils'; +import type { HttpResponse } from '../api-client'; +import { ApiClient, HttpMethod } from '../api-client'; import { FeeRate, TransactionStatus } from '../constants'; import type { IDataClient, @@ -57,12 +58,15 @@ const TESTNET_CONFIRMATION_TARGET = { export const NoFeeRateError = 'Insufficient data or no feerate found'; -export class QuickNodeClient implements IDataClient { +export class QuickNodeClient extends ApiClient implements IDataClient { + apiClientName = 'QuickNodeClient'; + protected readonly _options: QuickNodeClientOptions; protected readonly _priorityMap: Record; constructor(options: QuickNodeClientOptions) { + super(); const isMainnet = options.network === networks.bitcoin; this._options = options; @@ -82,36 +86,8 @@ export class QuickNodeClient implements IDataClient { } } - protected async post( - body: Json, - ): Promise { - const response = await fetch(this.baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - // QuickNode returns 200 status code for successful requests, others are errors status code - if (response.status !== 200) { - const res = (await response.json()) as unknown as Response; - throw new Error( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Failed to post data from quicknode: ${res.error}`, - ); - } - - if (!response.ok) { - throw new Error( - `Failed to post data from quicknode: ${response.statusText}`, - ); - } - return response.json() as unknown as Response; - } - - protected isErrorResponse( - response: Response, + protected isErrorResponse( + response: ApiResponse, ): boolean { // Possible error response from QuickNode: // - { result : null, error : "some error message" } @@ -124,7 +100,36 @@ export class QuickNodeClient implements IDataClient { ); } - protected async submitJsonRPCRequest({ + protected formatError( + apiResponse: ApiResponse, + ): string { + return JSON.stringify(apiResponse.error); + } + + protected async getResponse( + response: HttpResponse, + ): Promise { + const apiResponse = await super.getResponse< + ApiResponse & QuickNodeResponse + >(response); + + // QuickNode returns 200 status code for successful requests, others are errors status code + if (response.status !== 200) { + throw new Error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `API response error: ${this.formatError(apiResponse)}`, + ); + } + + // Safeguard to detect if the response is an error response, but they are not caught by the fetch error + if (this.isErrorResponse(apiResponse)) { + throw new Error(`Error response from quicknode`); + } + + return apiResponse; + } + + protected async submitJsonRPCRequest({ request, responseStruct, }: { @@ -133,37 +138,17 @@ export class QuickNodeClient implements IDataClient { params: Json; }; responseStruct: Struct; - }) { - try { - logger.debug( - `[QuickNodeClient.${request.method}] request:`, - JSON.stringify(request), - ); - - const response = await this.post(request); - - logger.debug( - `[QuickNodeClient.${request.method}] response:`, - JSON.stringify(response), - ); - - // Safeguard to detect if the response is an error response, but they are not caught by the fetch error - if (this.isErrorResponse(response)) { - throw new Error(`Error response from quicknode`); - } - - // Safeguard to identify if the response has some unexpected changes from quicknode - mask(response, responseStruct, 'Unexpected response from quicknode'); - - return response; - } catch (error) { - logger.info( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `[QuickNodeClient.${request.method}] error: ${error.message}`, - ); - - throw compactError(error, DataClientError); - } + }): Promise { + return await this.submitHttpRequest({ + request: this.buildHttpRequest({ + method: HttpMethod.Post, + url: this.baseUrl, + body: request, + }), + responseStruct, + // Use the JSON-RPC method name as the requestName for underlying logging purposes + requestName: request.method, + }); } async getBalances(addresses: string[]): Promise { From 6a6ae5c405752b427b6b44267b8dcaf771a36a26 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Wed, 16 Oct 2024 23:57:27 +0800 Subject: [PATCH 146/362] feat: use snap_getCurrencyRates (#289) * deps: bump snap deps * feat: use snap_getCurrencyRate * fix: confirmation interface * chore: remove logs * Apply suggestions from code review Co-authored-by: Charly Chevalier * fix: lint --------- Co-authored-by: Charly Chevalier --- .../bitcoin-wallet-snap/package.json | 6 ++--- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/exceptions.ts | 7 +++++ .../bitcoin-wallet-snap/src/keyring.ts | 1 - .../controller/send-many-controller.test.ts | 7 +++-- .../src/ui/controller/send-many-controller.ts | 4 +-- .../src/ui/render-interfaces.tsx | 21 +++++++++++++++ .../bitcoin-wallet-snap/src/utils/rates.ts | 11 ++++++-- .../bitcoin-wallet-snap/src/utils/snap.ts | 26 ++++++++++++++++++- 9 files changed, 71 insertions(+), 14 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 3a5ddf31..e2c2d7a5 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -41,9 +41,9 @@ "@metamask/eslint-config-typescript": "^12.1.0", "@metamask/key-tree": "^9.1.2", "@metamask/keyring-api": "^8.1.3", - "@metamask/snaps-cli": "^6.4.0", - "@metamask/snaps-jest": "^8.5.0", - "@metamask/snaps-sdk": "6.8.0", + "@metamask/snaps-cli": "^6.5.0", + "@metamask/snaps-jest": "^8.6.0", + "@metamask/snaps-sdk": "^6.9.0", "@metamask/utils": "^9.1.0", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index a20b724c..a8e9e87a 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "PGMPA0TkbP5R+DTllZ01Bz0BAuycpI0iAc0hKUMqOGg=", + "shasum": "JyhT1mBhHdij1Ux2wDkKMAPwI2vTys9tmwSXPH7TTAU=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/exceptions.ts index f0ef7986..244981d0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/exceptions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/exceptions.ts @@ -24,6 +24,12 @@ export class SendFlowRequestNotFoundError extends CustomError { } } +export class CurrencyRatesNotAvailableError extends CustomError { + constructor(errMsg?: string) { + super(errMsg ?? `Currency rates not available`); + } +} + /** * Determines if the given error is a Snap exception. * @@ -35,6 +41,7 @@ export function isSnapException(error: Error): boolean { AccountNotFoundError, MethodNotImplementedError, SendFlowRequestNotFoundError, + CurrencyRatesNotAvailableError, ]; return errors.some((errType) => error instanceof errType); } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index a5dcceb4..eb640607 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -205,7 +205,6 @@ export class BtcKeyring implements Keyring { event: KeyringEvent, data: Record, ): Promise { - // @ts-expect-error TODO: fix type await emitSnapKeyringEvent(getProvider(), event, data); } diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.test.ts b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.test.ts index eb64e2b5..76575f2f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.test.ts @@ -27,11 +27,10 @@ global.snap = { request: jest.fn(), }; -const mockGenerateConfirmationReviewInterface = jest.fn(); +const mockDisplayConfirmationReview = jest.fn(); jest.mock('../render-interfaces', () => ({ updateSendFlow: jest.fn(), - generateConfirmationReviewInterface: (args) => - mockGenerateConfirmationReviewInterface(args), + displayConfirmationReview: (args) => mockDisplayConfirmationReview(args), })); const mockInterfaceId = 'interfaceId'; @@ -690,7 +689,7 @@ describe('SendManyController', () => { }; expect(controller.request.status).toBe(TransactionStatus.Review); expect(stateManager.upsertRequest).toHaveBeenCalledWith(expectedResult); - expect(mockGenerateConfirmationReviewInterface).toHaveBeenCalledWith({ + expect(mockDisplayConfirmationReview).toHaveBeenCalledWith({ request: expectedResult, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.ts b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.ts index c872d86f..69d61602 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.ts @@ -7,7 +7,7 @@ import type { KeyringStateManager } from '../../stateManagement'; import { TransactionStatus, type SendFlowRequest } from '../../stateManagement'; import { SendFormNames } from '../components/SendForm'; import { - generateConfirmationReviewInterface, + displayConfirmationReview, updateSendFlow, } from '../render-interfaces'; import { AssetType, type SendFlowContext, type SendFormState } from '../types'; @@ -210,7 +210,7 @@ export class SendManyController { case SendFormNames.Review: { this.request.status = TransactionStatus.Review; await this.persistRequest(this.request); - await generateConfirmationReviewInterface({ request: this.request }); + await displayConfirmationReview({ request: this.request }); return null; } case SendFormNames.Send: { diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx index 1c165e3a..ecb99c43 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx @@ -102,3 +102,24 @@ export async function generateConfirmationReviewInterface({ }, }); } + +/** + * Display the confirmation review interface. + * + * @param options0 - The options for displaying the confirmation review interface. + * @param options0.request - The send flow request object. + * @returns A promise that resolves when the confirmation review interface is displayed. + */ +export async function displayConfirmationReview({ + request, +}: { + request: SendFlowRequest; +}) { + return await snap.request({ + method: 'snap_updateInterface', + params: { + id: request.id, + ui: , + }, + }); +} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/rates.ts b/merged-packages/bitcoin-wallet-snap/src/utils/rates.ts index d5c87884..5d2d393a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/rates.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/rates.ts @@ -1,6 +1,13 @@ import type { Caip2Asset } from '../constants'; +import { CurrencyRatesNotAvailableError } from '../exceptions'; +import { getRatesFromMetamask } from './snap'; export const getRates = async (_asset: Caip2Asset): Promise => { - // TODO: replace with actual rates call - return '64000'; + // _asset is not used because the only supported asset is 'btc' for now. + const ratesResult = await getRatesFromMetamask('btc'); + + if (!ratesResult) { + throw new CurrencyRatesNotAvailableError(); + } + return ratesResult.conversionRate.toString(); }; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts index 8e9e08db..50463294 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts @@ -1,5 +1,10 @@ import { type SLIP10NodeInterface } from '@metamask/key-tree'; -import type { Component, DialogResult, Json } from '@metamask/snaps-sdk'; +import type { + Component, + DialogResult, + GetCurrencyRateResult, + Json, +} from '@metamask/snaps-sdk'; import { DialogType, panel, type SnapsProvider } from '@metamask/snaps-sdk'; declare const snap: SnapsProvider; @@ -113,3 +118,22 @@ export async function createSendUIDialog(interfaceId: string) { }, }); } + +/** + * Retrieves the currency rates from MetaMask for the specified asset. + * + * @param currency - The currency for which to retrieve the currency rates. + * @returns A Promise that resolves to the currency rates. + */ +export async function getRatesFromMetamask( + currency: string, +): Promise { + return (await snap.request({ + // @ts-expect-error TODO: snaps will fix this type error + method: 'snap_getCurrencyRate', + params: { + // @ts-expect-error TODO: snaps will fix this type error + currency, + }, + })) as GetCurrencyRateResult; +} From 0170d31100c760c1086c13c0bdf3556c04375f6c Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Thu, 17 Oct 2024 17:16:42 +0800 Subject: [PATCH 147/362] fix: use input props (#304) --- .../bitcoin-wallet-snap/snap.manifest.json | 14 +++----------- .../src/ui/components/SendForm.tsx | 3 +++ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index a8e9e87a..f87212a1 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "JyhT1mBhHdij1Ux2wDkKMAPwI2vTys9tmwSXPH7TTAU=", + "shasum": "38qGseoO2gGSYQWOm+NcTXmXePq6hjGpz215nIR1LK4=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -40,19 +40,11 @@ }, "snap_getBip32Entropy": [ { - "path": [ - "m", - "84'", - "0'" - ], + "path": ["m", "84'", "0'"], "curve": "secp256k1" }, { - "path": [ - "m", - "84'", - "1'" - ], + "path": ["m", "84'", "1'"], "curve": "secp256k1" } ], diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx index 1389c3af..d8796075 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx @@ -114,6 +114,9 @@ export const SendForm: SnapComponent = ({ From 84d885798046f7d49698a48d30200fa3e37cf052 Mon Sep 17 00:00:00 2001 From: Daniel Rocha <68558152+danroc@users.noreply.github.com> Date: Thu, 17 Oct 2024 11:28:26 +0200 Subject: [PATCH 148/362] feat!: make transactions replaceable by default (#297) --- .../bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts | 4 ++-- .../bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index 812b4acc..995d4369 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -248,7 +248,7 @@ describe('BtcWallet', () => { } }); - it('uses `replaceable = false` if not provided', async () => { + it('uses `replaceable = true` if not provided', async () => { const network = networks.testnet; const { instance } = createMockDeriver(network); const wallet = new BtcWallet(instance, network); @@ -269,7 +269,7 @@ describe('BtcWallet', () => { expect(psbtSpy).toHaveBeenCalledWith( expect.any(Array), - false, + true, expect.any(String), expect.any(Buffer), expect.any(Buffer), diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index ad0ae545..37b14827 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -140,7 +140,7 @@ export class BtcWallet { const psbtService = new PsbtService(this._network); psbtService.addInputs( selectionResult.inputs, - options.replaceable ?? false, + options.replaceable ?? true, hdPath, hexToBuffer(pubkey, false), hexToBuffer(mfp, false), From 83d3626993673f142be467c03666b2df7f763766 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Thu, 17 Oct 2024 20:50:39 +0800 Subject: [PATCH 149/362] fix: show btc logo and add links to address (#305) * fix: show btc logo * feat: add link * fix: size * Update packages/snap/src/ui/components/ReviewTransaction.tsx Co-authored-by: Charly Chevalier * fix: manifest * fix: lint --------- Co-authored-by: Charly Chevalier --- .../bitcoin-wallet-snap/snap.manifest.json | 14 ++++++-- .../bitcoin-wallet-snap/src/constants.ts | 5 +++ .../src/ui/components/ReviewTransaction.tsx | 33 +++++++++++++++---- .../src/ui/components/SendFlowHeader.tsx | 2 +- .../src/ui/render-interfaces.tsx | 2 +- 5 files changed, 44 insertions(+), 12 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index f87212a1..59f79e99 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "38qGseoO2gGSYQWOm+NcTXmXePq6hjGpz215nIR1LK4=", + "shasum": "Mj7JTq/axjdDRsTOiIIuwI5CM6bwpGEDA3Sptv6AprQ=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -40,11 +40,19 @@ }, "snap_getBip32Entropy": [ { - "path": ["m", "84'", "0'"], + "path": [ + "m", + "84'", + "0'" + ], "curve": "secp256k1" }, { - "path": ["m", "84'", "1'"], + "path": [ + "m", + "84'", + "1'" + ], "curve": "secp256k1" } ], diff --git a/merged-packages/bitcoin-wallet-snap/src/constants.ts b/merged-packages/bitcoin-wallet-snap/src/constants.ts index e073c180..7bb50f1d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/src/constants.ts @@ -14,3 +14,8 @@ export const Caip2ChainIdToNetworkName: Record = { [Caip2ChainId.Mainnet]: 'Bitcoin Mainnet', [Caip2ChainId.Testnet]: 'Bitcoin Testnet', }; + +export enum BaseExplorerUrl { + Mainnet = 'https://blockstream.info/address', + Testnet = 'https://blockstream.info/testnet/address', +} diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/ReviewTransaction.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/ReviewTransaction.tsx index 04a2bf4c..413e7423 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/ReviewTransaction.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/ReviewTransaction.tsx @@ -11,11 +11,13 @@ import { Text, Value, Image, + Link, } from '@metamask/snaps-sdk/jsx'; import type { CaipAccountId } from '@metamask/utils'; +import { BaseExplorerUrl, Caip2ChainId } from '../../constants'; import type { SendFlowRequest } from '../../stateManagement'; -import btcIcon from '../images/btc.svg'; +import btcIcon from '../images/btc-halo.svg'; import { getNetworkNameFromScope } from '../utils'; import { SendFlowHeader } from './SendFlowHeader'; import { SendFormNames } from './SendForm'; @@ -24,6 +26,15 @@ export type ReviewTransactionProps = SendFlowRequest & { txSpeed: string; }; +const getExplorerLink = (scope: string, address: string) => { + const explorerBaseLink = + scope === Caip2ChainId.Mainnet + ? BaseExplorerUrl.Mainnet + : BaseExplorerUrl.Testnet; + + return `${explorerBaseLink}/${address}`; +}; + export const ReviewTransaction: SnapComponent = ({ account, amount, @@ -43,23 +54,31 @@ export const ReviewTransaction: SnapComponent = ({ - + - {`Sending ${total.amount} BTC`} + {`Sending ${total.amount} BTC`} Review the transaction before proceeding
-
+ +
+
-
+ +
+
diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx index 33e5156b..acbbb5eb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx @@ -26,7 +26,7 @@ export const SendFlowHeader: SnapComponent = ({ - {heading} + {heading}
); diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx index ecb99c43..f69bb0db 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx @@ -118,7 +118,7 @@ export async function displayConfirmationReview({ return await snap.request({ method: 'snap_updateInterface', params: { - id: request.id, + id: request.interfaceId, ui: , }, }); From 0d75bf0d81f82b277c1fefaf28119929856a1998 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 17 Oct 2024 14:54:56 +0200 Subject: [PATCH 150/362] refactor!: sendmany -> sendBitcoin (#303) * chore!: bump @metamask/keyring-api to version ^9.0.0 * refactor: sendmany -> sendBitcoin * refactor: use replaceable = true everywhere * chore: yarn dedupe * refactor: use string instead of BtcMethod type * refactor: sendMany* -> sendBitcoin* --- merged-packages/bitcoin-wallet-snap/README.md | 10 +- .../bitcoin-wallet-snap/package.json | 2 +- .../src/bitcoin/wallet/wallet.test.ts | 22 ++-- .../src/bitcoin/wallet/wallet.ts | 3 +- .../bitcoin-wallet-snap/src/index.tsx | 8 +- .../bitcoin-wallet-snap/src/keyring.test.ts | 29 +++-- .../bitcoin-wallet-snap/src/keyring.ts | 17 +-- .../src/rpcs/__tests__/helper.ts | 16 +-- .../src/rpcs/get-balances.test.ts | 4 +- .../bitcoin-wallet-snap/src/rpcs/index.ts | 2 +- ...{sendmany.test.ts => send-bitcoin.test.ts} | 101 +++++++++--------- .../src/rpcs/{sendmany.ts => send-bitcoin.ts} | 38 +++---- .../rpcs/start-send-transaction-flow.test.ts | 6 +- .../src/rpcs/start-send-transaction-flow.ts | 10 +- .../src/stateManagement.test.ts | 14 +-- .../src/stateManagement.ts | 6 +- ...est.ts => send-bitcoin-controller.test.ts} | 47 ++++---- ...ntroller.ts => send-bitcoin-controller.ts} | 2 +- .../bitcoin-wallet-snap/src/ui/utils.test.ts | 52 ++++----- .../bitcoin-wallet-snap/src/ui/utils.ts | 37 +++---- .../src/utils/account.test.ts | 4 +- .../src/utils/transaction.ts | 4 +- .../bitcoin-wallet-snap/test/utils.ts | 3 +- 23 files changed, 205 insertions(+), 232 deletions(-) rename merged-packages/bitcoin-wallet-snap/src/rpcs/{sendmany.test.ts => send-bitcoin.test.ts} (65%) rename merged-packages/bitcoin-wallet-snap/src/rpcs/{sendmany.ts => send-bitcoin.ts} (72%) rename merged-packages/bitcoin-wallet-snap/src/ui/controller/{send-many-controller.test.ts => send-bitcoin-controller.test.ts} (94%) rename merged-packages/bitcoin-wallet-snap/src/ui/controller/{send-many-controller.ts => send-bitcoin-controller.ts} (99%) diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index be69045f..e52a3873 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -74,7 +74,7 @@ provider.request({ }); ``` -### API `btc_sendmany` +### API `sendBitcoin` example: @@ -90,14 +90,12 @@ provider.request({ id: 'da40b782-e054-4260-9a6a-c8717a022f92', // an random uuid for this request scope: 'bip122:000000000933ea01ad0ee984209779ba', // the CAIP-2 chain ID of bitcoin request: { - method: 'btc_sendmany', + method: 'sendBitcoin', params: { - amounts: { + recipients: { ['tb1qlhkuysju47s642834n7f3tyk67mvnt2cfd9r7y']: '0.00000500', }, // the recipient struct to indicate how many BTC to be received for each recipient - comment: 'some comment', - subtractFeeFrom: [], // not support yet - replaceable: false, // an flag to opt-in RBF + replaceable: true, // an flag to opt-in RBF dryrun: true, // an flag to enable similation of the transaction, without broadcast to network }, }, diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index e2c2d7a5..5a44a3ed 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -40,7 +40,7 @@ "@metamask/eslint-config-nodejs": "^12.1.0", "@metamask/eslint-config-typescript": "^12.1.0", "@metamask/key-tree": "^9.1.2", - "@metamask/keyring-api": "^8.1.3", + "@metamask/keyring-api": "^9.0.0", "@metamask/snaps-cli": "^6.5.0", "@metamask/snaps-jest": "^8.6.0", "@metamask/snaps-sdk": "^6.9.0", diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts index 995d4369..721a2c3b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts @@ -164,8 +164,7 @@ describe('BtcWallet', () => { { utxos, fee: 56, - subtractFeeFrom: [], - replaceable: false, + replaceable: true, }, ); @@ -194,8 +193,7 @@ describe('BtcWallet', () => { { utxos, fee: 50, - subtractFeeFrom: [], - replaceable: false, + replaceable: true, }, ); @@ -228,8 +226,7 @@ describe('BtcWallet', () => { { utxos, fee: 1, - subtractFeeFrom: [], - replaceable: false, + replaceable: true, }, ); @@ -263,7 +260,6 @@ describe('BtcWallet', () => { { utxos, fee: 1, - subtractFeeFrom: [], }, ); @@ -307,8 +303,7 @@ describe('BtcWallet', () => { { utxos, fee: 1, - subtractFeeFrom: [], - replaceable: false, + replaceable: true, }, ); @@ -332,8 +327,7 @@ describe('BtcWallet', () => { { utxos, fee: 20, - subtractFeeFrom: [], - replaceable: false, + replaceable: true, }, ), ).rejects.toThrow('Transaction amount too small'); @@ -354,8 +348,7 @@ describe('BtcWallet', () => { { utxos, fee: 20, - subtractFeeFrom: [], - replaceable: false, + replaceable: true, }, ), ).rejects.toThrow('Insufficient funds'); @@ -376,8 +369,7 @@ describe('BtcWallet', () => { { utxos, fee: 1, - subtractFeeFrom: [], - replaceable: false, + replaceable: true, }, ); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts index 37b14827..93317c93 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts @@ -48,7 +48,6 @@ export type ITxInfo = { export type CreateTransactionOptions = { utxos: Utxo[]; fee: number; - subtractFeeFrom?: string[]; // // BIP125 opt-in RBF flag, // @@ -140,6 +139,7 @@ export class BtcWallet { const psbtService = new PsbtService(this._network); psbtService.addInputs( selectionResult.inputs, + // Is now enabled by default (requirement for the Keyring API v9.0.0) options.replaceable ?? true, hdPath, hexToBuffer(pubkey, false), @@ -148,7 +148,6 @@ export class BtcWallet { const txInfo = new TxInfo(address, feeRate); - // TODO: add support of subtractFeeFrom, and throw error if output is too small after subtraction for (const output of selectionResult.outputs) { psbtService.addOutput(output); txInfo.addRecipient(output); diff --git a/merged-packages/bitcoin-wallet-snap/src/index.tsx b/merged-packages/bitcoin-wallet-snap/src/index.tsx index f6dc04a8..f09d9e20 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/index.tsx @@ -27,8 +27,8 @@ import { startSendTransactionFlow } from './rpcs/start-send-transaction-flow'; import { KeyringStateManager } from './stateManagement'; import { isSendFormEvent, - SendManyController, -} from './ui/controller/send-many-controller'; + SendBitcoinController, +} from './ui/controller/send-bitcoin-controller'; import type { SendFlowContext, SendFormState } from './ui/types'; import { isSnapRpcError, logger } from './utils'; @@ -137,13 +137,13 @@ export const onUserInput: OnUserInputHandler = async ({ } if (isSendFormEvent(event)) { - const sendManyController = new SendManyController({ + const sendBitcoinController = new SendBitcoinController({ stateManager, request, context: context as SendFlowContext, interfaceId: id, }); - await sendManyController.handleEvent( + await sendBitcoinController.handleEvent( event, context as SendFlowContext, state.sendForm as SendFormState, diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts index a5aa2674..7dc03bfa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts @@ -1,4 +1,5 @@ import type { KeyringAccount } from '@metamask/keyring-api'; +import { BtcMethod } from '@metamask/keyring-api'; import { MethodNotFoundError, UnauthorizedError } from '@metamask/snaps-sdk'; import { v4 as uuidv4 } from 'uuid'; @@ -11,7 +12,7 @@ import { Factory } from './factory'; import type { CreateAccountOptions } from './keyring'; import { BtcKeyring } from './keyring'; import * as getBalanceRpc from './rpcs/get-balances'; -import * as sendManyRpc from './rpcs/sendmany'; +import * as sendBitcoinRpc from './rpcs/send-bitcoin'; import { KeyringStateManager } from './stateManagement'; jest.mock('./utils/logger'); @@ -93,7 +94,7 @@ describe('BtcKeyring', () => { }; const createMockKeyring = (stateMgr: KeyringStateManager) => { - const sendManySpy = jest.spyOn(sendManyRpc, 'sendMany'); + const sendBitcoinSpy = jest.spyOn(sendBitcoinRpc, 'sendBitcoin'); const getBalanceRpcSpy = jest.spyOn(getBalanceRpc, 'getBalances'); return { @@ -102,7 +103,7 @@ describe('BtcKeyring', () => { origin, multiAccount: false, }), - sendManySpy, + sendBitcoinSpy, getBalanceRpcSpy, }; }; @@ -119,7 +120,7 @@ describe('BtcKeyring', () => { scope: caip2ChainId, index: sender.index, }, - methods: ['btc_sendmany'], + methods: [`${BtcMethod.SendBitcoin}`], }; return { sender, @@ -341,7 +342,7 @@ describe('BtcKeyring', () => { const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); const { instance: keyring, - sendManySpy, + sendBitcoinSpy, getBalanceRpcSpy, } = createMockKeyring(stateMgr); const { sender, keyringAccount } = await createSender(caip2ChainId); @@ -351,7 +352,7 @@ describe('BtcKeyring', () => { scope: keyringAccount.options.scope, hdPath: sender.hdPath, }); - sendManySpy.mockResolvedValue({ + sendBitcoinSpy.mockResolvedValue({ txId: 'txid', }); getBalanceRpcSpy.mockResolvedValue({ @@ -363,13 +364,11 @@ describe('BtcKeyring', () => { const params = { scope: caip2ChainId, - amounts: { + recipients: { bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah: '0.01', bc1qf5n2h6mgelkls4497pkpemew55xpew90td2qae: '0.01', }, - comment: 'testing', - subtractFeeFrom: ['bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah'], - replaceable: false, + replaceable: true, }; await keyring.submitRequest({ @@ -377,12 +376,12 @@ describe('BtcKeyring', () => { scope: Caip2ChainId.Testnet, account: keyringAccount.address, request: { - method: 'btc_sendmany', + method: `${BtcMethod.SendBitcoin}`, params, }, }); - expect(sendManySpy).toHaveBeenCalledWith( + expect(sendBitcoinSpy).toHaveBeenCalledWith( expect.any(BtcAccount), origin, params, @@ -408,7 +407,7 @@ describe('BtcKeyring', () => { scope: caip2ChainId, account: accFromState.id, request: { - method: 'btc_sendmany', + method: `${BtcMethod.SendBitcoin}`, }, }), ).rejects.toThrow(AccountNotFoundError); @@ -425,7 +424,7 @@ describe('BtcKeyring', () => { scope: Caip2ChainId.Testnet, account: account.id, request: { - method: 'btc_sendmany', + method: `${BtcMethod.SendBitcoin}`, }, }), ).rejects.toThrow(AccountNotFoundError); @@ -449,7 +448,7 @@ describe('BtcKeyring', () => { scope: Caip2ChainId.Mainnet, account: keyringAccount.id, request: { - method: 'btc_sendmany', + method: `${BtcMethod.SendBitcoin}`, }, }), ).rejects.toThrow( diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index eb640607..160c50c6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -1,4 +1,5 @@ import { + BtcMethod, KeyringEvent, emitSnapKeyringEvent, type Keyring, @@ -23,7 +24,7 @@ import { Config } from './config'; import { Caip2ChainId } from './constants'; import { AccountNotFoundError, MethodNotImplementedError } from './exceptions'; import { Factory } from './factory'; -import { getBalances, type SendManyParams, sendMany } from './rpcs'; +import { getBalances, type SendBitcoinParams, sendBitcoin } from './rpcs'; import { createRatesAndBalances } from './rpcs/get-rates-and-balances'; import { TransactionStatus, @@ -57,7 +58,7 @@ export class BtcKeyring implements Keyring { protected readonly _options: KeyringOptions; - protected readonly _methods = ['btc_sendmany']; + protected readonly _methods = [`${BtcMethod.SendBitcoin}`]; constructor(stateMgr: KeyringStateManager, options: KeyringOptions) { this._stateMgr = stateMgr; @@ -188,12 +189,12 @@ export class BtcKeyring implements Keyring { this.verifyIfMethodValid(method, walletData.account); switch (method) { - case 'btc_sendmany': { - return await this.handleSendMany({ + case `${BtcMethod.SendBitcoin}`: { + return await this.handleSendBitcoin({ scope: scope as Caip2ChainId, walletData, account, - params: params as SendManyParams, + params: params as SendBitcoinParams, }); } default: @@ -295,7 +296,7 @@ export class BtcKeyring implements Keyring { } } - protected async handleSendMany({ + protected async handleSendBitcoin({ scope, walletData, account, @@ -304,7 +305,7 @@ export class BtcKeyring implements Keyring { scope: Caip2ChainId; walletData: Wallet; account: BtcAccount; - params: SendManyParams; + params: SendBitcoinParams; }): Promise { const asset = getAssetTypeFromScope(scope); @@ -340,7 +341,7 @@ export class BtcKeyring implements Keyring { // this has been updated via onInputHandler await this._stateMgr.upsertRequest(sendFlowRequest); try { - const tx = await sendMany(account, this._options.origin, { + const tx = await sendBitcoin(account, this._options.origin, { ...sendFlowRequest.transaction, scope, }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts index f8b4325f..d566669d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts @@ -1,4 +1,4 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; +import { BtcMethod, type KeyringAccount } from '@metamask/keyring-api'; import { v4 as uuidV4 } from 'uuid'; import { @@ -134,7 +134,7 @@ export async function createMockKeyringAccount( scope: caip2ChainId, index: account.index, }, - methods: ['btc_sendmany'], + methods: [`${BtcMethod.SendBitcoin}`], } as unknown as KeyringAccount; getWalletSpy.mockResolvedValue({ @@ -233,7 +233,7 @@ export type EstimateFeeTestCreateOption = AccountTestCreateOption & { utxoMaxVal: number; }; -export type SendManyCreateOption = EstimateFeeTestCreateOption & { +export type SendBitcoinCreateOption = EstimateFeeTestCreateOption & { recipientCount: number; }; @@ -353,10 +353,10 @@ export class EstimateFeeTest extends AccountTest { export class GetMaxSpendableBalanceTest extends EstimateFeeTest {} -export class SendManyTest extends EstimateFeeTest { +export class SendBitcoinTest extends EstimateFeeTest { recipients: BtcAccount[]; - testCase: SendManyCreateOption; + testCase: SendBitcoinCreateOption; broadcastTransactionSpy: jest.SpyInstance; @@ -364,7 +364,7 @@ export class SendManyTest extends EstimateFeeTest { alertDialogSpy: jest.SpyInstance; - constructor(testCase: SendManyCreateOption) { + constructor(testCase: SendBitcoinCreateOption) { super(testCase); const { broadcastTransactionSpy } = createMockChainApiFactory(); this.broadcastTransactionSpy = broadcastTransactionSpy; @@ -412,7 +412,7 @@ export class SendManyTest extends EstimateFeeTest { } } -export class StartSendTransactionFlowTest extends SendManyTest { +export class StartSendTransactionFlowTest extends SendBitcoinTest { generateSendFlowSpy: jest.SpyInstance; updateSendFlowSpy: jest.SpyInstance; @@ -437,7 +437,7 @@ export class StartSendTransactionFlowTest extends SendManyTest { interfaceId = 'mock-interfaceId'; - constructor(testCase: SendManyCreateOption) { + constructor(testCase: SendBitcoinCreateOption) { super(testCase); this.scope = testCase.caip2ChainId; const mocks = createMockSendFlow(); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts index 7bb9ee17..24945874 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts @@ -1,4 +1,4 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; +import { BtcMethod, type KeyringAccount } from '@metamask/keyring-api'; import { InvalidParamsError } from '@metamask/snaps-sdk'; import { networks } from 'bitcoinjs-lib'; import { v4 as uuidv4 } from 'uuid'; @@ -39,7 +39,7 @@ describe('getBalances', () => { scope: caip2ChainId, index: sender.index, }, - methods: ['btc_sendmany'], + methods: [`${BtcMethod.SendBitcoin}`], }; const walletData = { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts index 3f31a9ac..e1909bd0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts @@ -1,5 +1,5 @@ export * from './get-balances'; export * from './get-transaction-status'; -export * from './sendmany'; +export * from './send-bitcoin'; export * from './estimate-fee'; export * from './get-max-spendable-balance'; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.test.ts similarity index 65% rename from merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts rename to merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.test.ts index eee68a14..392ec76a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.test.ts @@ -3,37 +3,34 @@ import { InvalidParamsError } from '@metamask/snaps-sdk'; import type { BtcAccount } from '../bitcoin/wallet'; import { Caip2ChainId } from '../constants'; import { satsToBtc } from '../utils'; -import { SendManyTest } from './__tests__/helper'; -import { type SendManyParams, sendMany } from './sendmany'; +import { SendBitcoinTest } from './__tests__/helper'; +import { type SendBitcoinParams, sendBitcoin } from './send-bitcoin'; jest.mock('../utils/logger'); jest.mock('../utils/snap'); -describe('SendManyHandler', () => { - describe('sendMany', () => { +describe('SendBitcoinHandler', () => { + describe('sendBitcoin', () => { const origin = 'http://localhost:3000'; - const createSendManyParams = ( + const createSendBitcoinParams = ( recipients: BtcAccount[], caip2ChainId: string, dryrun: boolean, - comment = '', amount = 500, - ): SendManyParams => { + ): SendBitcoinParams => { return { - amounts: recipients.reduce((acc, recipient) => { + recipients: recipients.reduce((acc, recipient) => { acc[recipient.address] = satsToBtc(amount); return acc; }, {}), - comment, - subtractFeeFrom: [], - replaceable: false, + replaceable: true, dryrun, scope: caip2ChainId, - } as unknown as SendManyParams; + } as unknown as SendBitcoinParams; }; - const prepareSendMany = async ( + const prepareSendBitcoin = async ( caip2ChainId: string, recipientCount = 10, feeRate = 1, @@ -41,7 +38,7 @@ describe('SendManyHandler', () => { utxoMinVal = 100000, utxoMaxVal = 100000, ) => { - const testHelper = new SendManyTest({ + const testHelper = new SendBitcoinTest({ caip2ChainId, utxoCount, utxoMinVal, @@ -63,12 +60,12 @@ describe('SendManyHandler', () => { getDataForTransactionSpy, getFeeRatesSpy, broadcastTransactionSpy, - } = await prepareSendMany(caip2ChainId); + } = await prepareSendBitcoin(caip2ChainId); - const result = await sendMany( + const result = await sendBitcoin( sender, origin, - createSendManyParams(recipients, caip2ChainId, false), + createSendBitcoinParams(recipients, caip2ChainId, false), ); expect(result).toStrictEqual({ txId: broadCastTxResp }); @@ -80,12 +77,12 @@ describe('SendManyHandler', () => { it('does not broadcast transaction if in dryrun mode', async () => { const caip2ChainId = Caip2ChainId.Testnet; const { recipients, sender, broadcastTransactionSpy } = - await prepareSendMany(caip2ChainId); + await prepareSendBitcoin(caip2ChainId); - await sendMany( + await sendBitcoin( sender, origin, - createSendManyParams(recipients, caip2ChainId, true), + createSendBitcoinParams(recipients, caip2ChainId, true), ); expect(broadcastTransactionSpy).toHaveBeenCalledTimes(0); @@ -93,38 +90,38 @@ describe('SendManyHandler', () => { it('throws InvalidParamsError when request parameter is not correct', async () => { const caip2ChainId = Caip2ChainId.Testnet; - const { sender } = await prepareSendMany(caip2ChainId); + const { sender } = await prepareSendBitcoin(caip2ChainId); await expect( - sendMany(sender, origin, { - amounts: { + sendBitcoin(sender, origin, { + recipients: { 'some-address': '1', }, - } as unknown as SendManyParams), + } as unknown as SendBitcoinParams), ).rejects.toThrow(InvalidParamsError); }); - it('throws `Transaction must have at least one recipient` error if no recipient provided', async () => { + it('throws `Recipients object must have at least one recipient` error if no recipient provided', async () => { const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients } = await prepareSendMany(caip2ChainId, 0); + const { sender, recipients } = await prepareSendBitcoin(caip2ChainId, 0); await expect( - sendMany( + sendBitcoin( sender, origin, - createSendManyParams(recipients, caip2ChainId, false), + createSendBitcoinParams(recipients, caip2ChainId, false), ), - ).rejects.toThrow('Transaction must have at least one recipient'); + ).rejects.toThrow('Recipients object must have at least one recipient'); }); it('throws `Invalid amount, must be a positive finite number` error if receive amount is not valid', async () => { const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients } = await prepareSendMany(caip2ChainId, 2); + const { sender, recipients } = await prepareSendBitcoin(caip2ChainId, 2); await expect( - sendMany(sender, origin, { - ...createSendManyParams(recipients, caip2ChainId, false), - amounts: { + sendBitcoin(sender, origin, { + ...createSendBitcoinParams(recipients, caip2ChainId, false), + recipients: { [recipients[0].address]: 'invalid', [recipients[1].address]: '0.1', }, @@ -132,9 +129,9 @@ describe('SendManyHandler', () => { ).rejects.toThrow('Invalid amount, must be a positive finite number'); await expect( - sendMany(sender, origin, { - ...createSendManyParams(recipients, caip2ChainId, false), - amounts: { + sendBitcoin(sender, origin, { + ...createSendBitcoinParams(recipients, caip2ChainId, false), + recipients: { [recipients[0].address]: '0', [recipients[1].address]: '0.1', }, @@ -142,9 +139,9 @@ describe('SendManyHandler', () => { ).rejects.toThrow('Invalid amount, must be a positive finite number'); await expect( - sendMany(sender, origin, { - ...createSendManyParams(recipients, caip2ChainId, false), - amounts: { + sendBitcoin(sender, origin, { + ...createSendBitcoinParams(recipients, caip2ChainId, false), + recipients: { [recipients[0].address]: 'invalid', [recipients[1].address]: '0.000000019', }, @@ -154,12 +151,12 @@ describe('SendManyHandler', () => { it('throws `Invalid amount, out of bounds` error if receive amount is out of bounds', async () => { const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients } = await prepareSendMany(caip2ChainId, 2); + const { sender, recipients } = await prepareSendBitcoin(caip2ChainId, 2); await expect( - sendMany(sender, origin, { - ...createSendManyParams(recipients, caip2ChainId, false), - amounts: { + sendBitcoin(sender, origin, { + ...createSendBitcoinParams(recipients, caip2ChainId, false), + recipients: { [recipients[0].address]: '1', [recipients[1].address]: '999999999.99999999', }, @@ -170,21 +167,21 @@ describe('SendManyHandler', () => { it('throws `Invalid response` error if the response is unexpected', async () => { const caip2ChainId = Caip2ChainId.Testnet; const { sender, recipients, broadcastTransactionSpy } = - await prepareSendMany(caip2ChainId); + await prepareSendBitcoin(caip2ChainId); broadcastTransactionSpy.mockResolvedValue({ transactionId: '', }); await expect( - sendMany(sender, origin, { - ...createSendManyParams(recipients, caip2ChainId, false), + sendBitcoin(sender, origin, { + ...createSendBitcoinParams(recipients, caip2ChainId, false), }), ).rejects.toThrow('Invalid Response'); }); it('throws `Failed to send the transaction` error if no fee rate is returned from the chain service', async () => { const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients, getFeeRatesSpy } = await prepareSendMany( + const { sender, recipients, getFeeRatesSpy } = await prepareSendBitcoin( caip2ChainId, ); getFeeRatesSpy.mockResolvedValue({ @@ -192,10 +189,10 @@ describe('SendManyHandler', () => { }); await expect( - sendMany( + sendBitcoin( sender, origin, - createSendManyParams(recipients, caip2ChainId, false), + createSendBitcoinParams(recipients, caip2ChainId, false), ), ).rejects.toThrow('Failed to send the transaction'); }); @@ -203,12 +200,12 @@ describe('SendManyHandler', () => { it('throws `Failed to send the transaction` error if the transaction is fail to commit', async () => { const caip2ChainId = Caip2ChainId.Testnet; const { broadcastTransactionSpy, sender, recipients } = - await prepareSendMany(caip2ChainId); + await prepareSendBitcoin(caip2ChainId); broadcastTransactionSpy.mockRejectedValue(new Error('error')); await expect( - sendMany(sender, origin, { - ...createSendManyParams(recipients, caip2ChainId, false), + sendBitcoin(sender, origin, { + ...createSendBitcoinParams(recipients, caip2ChainId, false), }), ).rejects.toThrow('Failed to send the transaction'); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.ts similarity index 72% rename from merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts rename to merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.ts index 094d5d02..7c867a09 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/sendmany.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.ts @@ -4,7 +4,6 @@ import { string, type Infer, record, - array, boolean, refine, optional, @@ -25,12 +24,12 @@ import { getFeeRate, } from '../utils'; -export const TransactionAmountStruct = refine( +export const RecipientsStruct = refine( record(BtcP2wpkhAddressStruct, string()), - 'TransactionAmountStruct', + 'RecipientsStruct', (value: Record) => { if (Object.entries(value).length === 0) { - return 'Transaction must have at least one recipient'; + return 'Recipients object must have at least one recipient'; } for (const val of Object.values(value)) { @@ -41,27 +40,25 @@ export const TransactionAmountStruct = refine( }, ); -export const SendManyStruct = object({ - amounts: TransactionAmountStruct, - comment: string(), - subtractFeeFrom: array(BtcP2wpkhAddressStruct), +export const SendBitcoinStruct = object({ + recipients: RecipientsStruct, replaceable: boolean(), dryrun: optional(boolean()), }); -export const SendManyParamsStruct = object({ - ...SendManyStruct.schema, +export const SendBitcoinParamsStruct = object({ + ...SendBitcoinStruct.schema, scope: ScopeStruct, }); -export const SendManyResponseStruct = object({ +export const SendBitcoinResponseStruct = object({ txId: nonempty(string()), signedTransaction: optional(string()), }); -export type SendManyParams = Infer; +export type SendBitcoinParams = Infer; -export type SendManyResponse = Infer; +export type SendBitcoinResponse = Infer; /** * Send BTC to multiple account. @@ -69,17 +66,17 @@ export type SendManyResponse = Infer; * @param account - The account to send the transaction. * @param _origin - The origin of the request. * @param params - The parameters for send the transaction. - * @returns A Promise that resolves to an SendManyResponse object. + * @returns A Promise that resolves to an SendBitcoinResponse object. */ -export async function sendMany( +export async function sendBitcoin( account: BtcAccount, _origin: string, - params: SendManyParams, + params: SendBitcoinParams, ) { try { - validateRequest(params, SendManyParamsStruct); + validateRequest(params, SendBitcoinParamsStruct); - const { dryrun, scope, subtractFeeFrom, replaceable } = params; + const { dryrun, scope, replaceable } = params; const chainApi = Factory.createOnChainServiceProvider(scope); const wallet = Factory.createWallet(scope); @@ -87,7 +84,7 @@ export async function sendMany( const fee = getFeeRate(feesResp.fees); - const recipients = Object.entries(params.amounts).map( + const recipients = Object.entries(params.recipients).map( ([address, value]) => ({ address, value: btcToSats(value), @@ -101,7 +98,6 @@ export async function sendMany( const txResp = await wallet.createTransaction(account, recipients, { utxos, fee, - subtractFeeFrom, replaceable, }); @@ -126,7 +122,7 @@ export async function sendMany( logger.debug(`Submitted transaction ID: ${resp.txId}`); - validateResponse(resp, SendManyResponseStruct); + validateResponse(resp, SendBitcoinResponseStruct); return resp; } catch (error) { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts index 331f12c1..76ec0d64 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts @@ -5,7 +5,7 @@ import { } from '../exceptions'; import { TransactionStatus } from '../stateManagement'; import { AssetType } from '../ui/types'; -import { generateSendManyParams } from '../ui/utils'; +import { generateSendBitcoinParams } from '../ui/utils'; import { StartSendTransactionFlowTest } from './__tests__/helper'; import { startSendTransactionFlow } from './start-send-transaction-flow'; @@ -62,7 +62,7 @@ describe('startSendTransactionFlow', () => { interfaceId: 'mock-interfaceId', account: keyringAccount, scope: mockScope, - transaction: generateSendManyParams(mockScope), + transaction: generateSendBitcoinParams(mockScope), status: TransactionStatus.Draft, selectedCurrency: AssetType.BTC, recipient: { @@ -131,7 +131,7 @@ describe('startSendTransactionFlow', () => { interfaceId: 'mock-interfaceId', account: keyringAccount, scope: mockScope, - transaction: generateSendManyParams(mockScope), + transaction: generateSendBitcoinParams(mockScope), status: TransactionStatus.Draft, selectedCurrency: AssetType.BTC, recipient: { diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts index fd378660..13a2f36e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts @@ -14,7 +14,7 @@ import { generateSendFlow, updateSendFlow } from '../ui/render-interfaces'; import { btcToFiat, getAssetTypeFromScope, - generateSendManyParams, + generateSendBitcoinParams, } from '../ui/utils'; import { createSendUIDialog, @@ -24,7 +24,7 @@ import { verifyIfAccountValid, } from '../utils'; import { createRatesAndBalances } from './get-rates-and-balances'; -import { sendMany } from './sendmany'; +import { sendBitcoin } from './send-bitcoin'; export type StartSendTransactionFlowParams = { account: string; @@ -126,17 +126,17 @@ export async function startSendTransactionFlow( throw new SendFlowRequestNotFoundError(); } - const sendManyParams = generateSendManyParams( + const sendBitcoinParams = generateSendBitcoinParams( walletData.scope, updatedSendFlowRequest, ); - sendFlowRequest.transaction = sendManyParams; + sendFlowRequest.transaction = sendBitcoinParams; sendFlowRequest.status = TransactionStatus.Confirmed; await stateManager.upsertRequest(sendFlowRequest); let tx; try { - tx = await sendMany(btcAccount, scope, { + tx = await sendBitcoin(btcAccount, scope, { ...sendFlowRequest.transaction, scope, }); diff --git a/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts b/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts index 36a5c1a6..5b9ce8c2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts @@ -3,7 +3,7 @@ import { Caip2ChainId } from './constants'; import type { SendFlowRequest } from './stateManagement'; import { KeyringStateManager, TransactionStatus } from './stateManagement'; import { AssetType } from './ui/types'; -import { generateSendManyParams } from './ui/utils'; +import { generateSendBitcoinParams } from './ui/utils'; import * as snapUtil from './utils/snap'; describe('KeyringStateManager', () => { @@ -265,7 +265,7 @@ describe('KeyringStateManager', () => { const accToUpdate = { ...state.wallets[state.walletIds[0]].account, - methods: ['btc_sendmanys'], + methods: ['newBtcMethod'], }; await instance.updateAccount(accToUpdate); @@ -372,7 +372,7 @@ describe('KeyringStateManager', () => { account: state.wallets[state.walletIds[0]].account, scope: 'scope-1', transaction: { - ...generateSendManyParams('scope-1'), + ...generateSendBitcoinParams('scope-1'), sender: 'sender-1', recipient: 'recipient-1', amount: '1', @@ -462,7 +462,7 @@ describe('KeyringStateManager', () => { account: state.wallets[state.walletIds[0]].account, scope: 'scope-1', transaction: { - ...generateSendManyParams('scope-1'), + ...generateSendBitcoinParams('scope-1'), }, status: TransactionStatus.Draft, selectedCurrency: AssetType.BTC, @@ -513,7 +513,7 @@ describe('KeyringStateManager', () => { account: state.wallets[state.walletIds[0]].account, scope: 'scope-1', transaction: { - ...generateSendManyParams('scope-1'), + ...generateSendBitcoinParams('scope-1'), }, status: TransactionStatus.Draft, selectedCurrency: AssetType.BTC, @@ -571,7 +571,7 @@ describe('KeyringStateManager', () => { account: generateAccounts(1)[0], scope: 'scope-1', transaction: { - ...generateSendManyParams('scope-1'), + ...generateSendBitcoinParams('scope-1'), }, status: TransactionStatus.Draft, selectedCurrency: AssetType.BTC, @@ -619,7 +619,7 @@ describe('KeyringStateManager', () => { account: state.wallets[state.walletIds[0]].account, scope: 'scope-1', transaction: { - ...generateSendManyParams('scope-1'), + ...generateSendBitcoinParams('scope-1'), }, status: TransactionStatus.Draft, selectedCurrency: AssetType.BTC, diff --git a/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts b/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts index 4cca682b..b4b79fdb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts +++ b/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts @@ -1,6 +1,6 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import { type EstimateFeeResponse, type SendManyParams } from './rpcs'; +import { type EstimateFeeResponse, type SendBitcoinParams } from './rpcs'; import type { AssetType, Currency } from './ui/types'; import { compactError, SnapStateManager } from './utils'; @@ -20,7 +20,7 @@ export type SendEstimate = { confirmationTime: string; }; -export type Transaction = SendManyParams & { +export type Transaction = SendBitcoinParams & { sender: string; recipient: string; amount: string; @@ -59,7 +59,7 @@ export enum TransactionStatus { } export type TransactionState = { - transaction: Omit; + transaction: Omit; /* The status of the transaction - draft: The transaction is being created and edited - review: The transaction is in a review state that is ready to be confirmed by the user to sign diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.test.ts b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts similarity index 94% rename from merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.test.ts rename to merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts index 76575f2f..f5a63647 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts @@ -1,5 +1,5 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import { BtcAccountType } from '@metamask/keyring-api'; +import { BtcAccountType, BtcMethod } from '@metamask/keyring-api'; import type { UserInputEvent } from '@metamask/snaps-sdk'; import { UserInputEventType } from '@metamask/snaps-sdk'; import BigNumber from 'bignumber.js'; @@ -13,7 +13,10 @@ import { SendFormNames } from '../components/SendForm'; import { updateSendFlow } from '../render-interfaces'; import type { SendFlowContext, SendFormState } from '../types'; import { AssetType } from '../types'; -import { SendManyController, isSendFormEvent } from './send-many-controller'; +import { + SendBitcoinController, + isSendFormEvent, +} from './send-bitcoin-controller'; jest.mock('../../rpcs', () => ({ ...jest.requireActual('../../rpcs'), @@ -44,7 +47,7 @@ const mockAccount = { scope: Caip2ChainId.Mainnet, index: '1', }, - methods: ['btc_sendmany'], + methods: [`${BtcMethod.SendBitcoin}`], }; const mockContext: SendFlowContext = { @@ -65,7 +68,7 @@ const createMockStateManager = () => { }; }; -describe('SendManyController', () => { +describe('SendBitcoinController', () => { afterEach(() => { jest.resetAllMocks(); }); @@ -192,7 +195,7 @@ describe('SendManyController', () => { const { instance: stateManager, upsertRequestSpy } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, @@ -235,7 +238,7 @@ describe('SendManyController', () => { const { instance: stateManager } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, @@ -267,7 +270,7 @@ describe('SendManyController', () => { const { instance: stateManager } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, @@ -305,7 +308,7 @@ describe('SendManyController', () => { const { instance: stateManager } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, @@ -346,7 +349,7 @@ describe('SendManyController', () => { const { instance: stateManager } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, @@ -389,7 +392,7 @@ describe('SendManyController', () => { const { instance: stateManager } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, @@ -433,7 +436,7 @@ describe('SendManyController', () => { const { instance: stateManager } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, @@ -475,7 +478,7 @@ describe('SendManyController', () => { const { instance: stateManager } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, @@ -511,7 +514,7 @@ describe('SendManyController', () => { const { instance: stateManager } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, @@ -552,7 +555,7 @@ describe('SendManyController', () => { const { instance: stateManager } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, @@ -578,7 +581,7 @@ describe('SendManyController', () => { const { instance: stateManager } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, @@ -609,7 +612,7 @@ describe('SendManyController', () => { mockRequest.recipient.address = 'address'; const { instance: stateManager } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, @@ -628,7 +631,7 @@ describe('SendManyController', () => { ); const { instance: stateManager } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, @@ -647,7 +650,7 @@ describe('SendManyController', () => { ); const { instance: stateManager } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, @@ -676,7 +679,7 @@ describe('SendManyController', () => { ); const { instance: stateManager } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, @@ -704,7 +707,7 @@ describe('SendManyController', () => { mockRequest.status = TransactionStatus.Review; const { instance: stateManager } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, @@ -735,7 +738,7 @@ describe('SendManyController', () => { ); const { instance: stateManager } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, @@ -780,7 +783,7 @@ describe('SendManyController', () => { ); const { instance: stateManager } = createMockStateManager(); - const controller = new SendManyController({ + const controller = new SendBitcoinController({ stateManager, request: mockRequest, context: mockContext, diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.ts b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.ts similarity index 99% rename from merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.ts rename to merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.ts index 69d61602..7b6fb5b6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-many-controller.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.ts @@ -17,7 +17,7 @@ export const isSendFormEvent = (event: UserInputEvent): boolean => { return Object.values(SendFormNames).includes(event?.name as SendFormNames); }; -export class SendManyController { +export class SendBitcoinController { protected stateManager: KeyringStateManager; request: SendFlowRequest; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts b/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts index 63219393..10d4cefc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts @@ -1,10 +1,10 @@ import { expect } from '@jest/globals'; -import { BtcAccountType } from '@metamask/keyring-api'; +import { BtcAccountType, BtcMethod } from '@metamask/keyring-api'; import { BigNumber } from 'bignumber.js'; import { v4 as uuidv4 } from 'uuid'; import { Caip2ChainId, Caip2ChainIdToNetworkName } from '../constants'; -import type { SendManyParams } from '../rpcs'; +import type { SendBitcoinParams } from '../rpcs'; import { generateDefaultSendFlowRequest } from '../utils/transaction'; import { AssetType, SendFormError } from './types'; import { @@ -12,8 +12,8 @@ import { validateRecipient, btcToFiat, fiatToBtc, - generateSendManyParams, - sendManyParamsToSendFlowParams, + generateSendBitcoinParams, + sendBitcoinParamsToSendFlowParams, formValidation, getNetworkNameFromScope, } from './utils'; @@ -34,7 +34,7 @@ const mockAccount = { scope: Caip2ChainId.Mainnet, index: '1', }, - methods: ['btc_sendmany'], + methods: [`${BtcMethod.SendBitcoin}`], }; describe('utils', () => { @@ -181,8 +181,8 @@ describe('utils', () => { }); }); - describe('sendStateToSendManyParams', () => { - it('should convert send state to SendManyParams correctly', async () => { + describe('sendStateToSendBitcoinParams', () => { + it('should convert send state to SendBitcoinParams correctly', async () => { const request = { ...generateDefaultSendFlowRequest( mockAccount, @@ -203,12 +203,10 @@ describe('utils', () => { }; const scope = Caip2ChainId.Mainnet; - const result = generateSendManyParams(scope, request); + const result = generateSendBitcoinParams(scope, request); expect(result).toStrictEqual({ - amounts: { bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a: '0.1' }, - comment: '', - subtractFeeFrom: [], + recipients: { bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a: '0.1' }, replaceable: true, dryrun: false, scope, @@ -216,7 +214,7 @@ describe('utils', () => { }); }); - describe('sendManyParamsToSendFlowParams', () => { + describe('sendBitcoinParamsToSendFlowParams', () => { const mockFee = { fee: { amount: '0.0001', @@ -232,11 +230,9 @@ describe('utils', () => { jest.resetAllMocks(); }); - it('should convert SendManyParams to SendFlowParams correctly', async () => { - const params: Omit = { - amounts: { bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a: '0.1' }, - comment: '', - subtractFeeFrom: [], + it('should convert SendBitcoinParams to SendFlowParams correctly', async () => { + const params: Omit = { + recipients: { bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a: '0.1' }, replaceable: true, dryrun: false, }; @@ -245,7 +241,7 @@ describe('utils', () => { const rates = '62000'; const balance = '1'; - const result = await sendManyParamsToSendFlowParams( + const result = await sendBitcoinParamsToSendFlowParams( params, account, scope, @@ -253,7 +249,7 @@ describe('utils', () => { balance, ); - const expectedAmount = Object.values(params.amounts)[0]; + const expectedAmount = Object.values(params.recipients)[0]; const expectedTotal = new BigNumber(expectedAmount) .plus(new BigNumber(mockFee.fee.amount)) .toString(); @@ -293,9 +289,7 @@ describe('utils', () => { it('should handle invalid recipient address', async () => { const params = { - amounts: { invalidAddress: '0.1' }, - comment: '', - subtractFeeFrom: [], + recipients: { invalidAddress: '0.1' }, replaceable: true, dryrun: false, }; @@ -304,7 +298,7 @@ describe('utils', () => { const rates = '62000'; const balance = '1'; - const result = await sendManyParamsToSendFlowParams( + const result = await sendBitcoinParamsToSendFlowParams( params, account, scope, @@ -316,9 +310,7 @@ describe('utils', () => { it('should handle invalid amount', async () => { const params = { - amounts: { bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a: '0' }, - comment: '', - subtractFeeFrom: [], + recipients: { bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a: '0' }, replaceable: true, dryrun: false, }; @@ -327,7 +319,7 @@ describe('utils', () => { const rates = '62000'; const balance = '1'; - const result = await sendManyParamsToSendFlowParams( + const result = await sendBitcoinParamsToSendFlowParams( params, account, scope, @@ -340,9 +332,7 @@ describe('utils', () => { it('should handle insufficient balance', async () => { const params = { - amounts: { bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a: '2' }, - comment: '', - subtractFeeFrom: [], + recipients: { bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a: '2' }, replaceable: true, dryrun: false, }; @@ -351,7 +341,7 @@ describe('utils', () => { const rates = '62000'; const balance = '1'; - const result = sendManyParamsToSendFlowParams( + const result = sendBitcoinParamsToSendFlowParams( params, account, scope, diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts b/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts index 4313632d..5983ab63 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts @@ -8,7 +8,7 @@ import { Caip2ChainId, Caip2ChainIdToNetworkName, } from '../constants'; -import type { SendManyParams } from '../rpcs'; +import type { SendBitcoinParams } from '../rpcs'; import { estimateFee } from '../rpcs'; import type { SendFlowParams, Wallet } from '../stateManagement'; import { TransactionStatus, type SendFlowRequest } from '../stateManagement'; @@ -153,21 +153,19 @@ export function validateTotal( } /** - * Converts the send state to SendManyParams. + * Converts the send state to SendBitcoinParams. * * @param scope - The scope of the network (mainnet or testnet). * @param request - The request object containing form data and errors. - * @returns A promise that resolves to the SendManyParams object. + * @returns A promise that resolves to the SendBitcoinParams object. */ -export function generateSendManyParams( +export function generateSendBitcoinParams( scope: string, request?: SendFlowRequest, -): SendManyParams { +): SendBitcoinParams { if (!request) { return { - amounts: {}, - comment: '', - subtractFeeFrom: [], + recipients: {}, replaceable: true, dryrun: false, scope, @@ -175,11 +173,9 @@ export function generateSendManyParams( } return { - amounts: { + recipients: { [request.recipient.address]: request.amount.amount, }, - comment: '', - subtractFeeFrom: [], replaceable: true, dryrun: false, scope, @@ -258,16 +254,17 @@ export async function generateSendFlowRequest( balance: string, transaction?: SendFlowRequest['transaction'], ): Promise { - const sendManyParams = transaction ?? generateSendManyParams(wallet.scope); + const sendBitcoinParams = + transaction ?? generateSendBitcoinParams(wallet.scope); const sendFlowRequest = { id: uuidv4(), account: wallet.account, scope: wallet.scope, - transaction: sendManyParams, + transaction: sendBitcoinParams, interfaceId: '', status: status ?? TransactionStatus.Draft, - ...(await sendManyParamsToSendFlowParams( - sendManyParams, + ...(await sendBitcoinParamsToSendFlowParams( + sendBitcoinParams, wallet.account.id, wallet.scope, rates, @@ -285,7 +282,7 @@ export async function generateSendFlowRequest( } /** - * Converts SendManyParams to SendFlowParams. + * Converts SendBitcoinParams to SendFlowParams. * * @param params - The parameters for sending many transactions. * @param account - The account from which the transactions will be sent. @@ -294,8 +291,8 @@ export async function generateSendFlowRequest( * @param balance - The balance of the account. * @returns A promise that resolves to the send flow parameters. */ -export async function sendManyParamsToSendFlowParams( - params: Omit, +export async function sendBitcoinParamsToSendFlowParams( + params: Omit, account: string, scope: string, rates: string, @@ -303,8 +300,8 @@ export async function sendManyParamsToSendFlowParams( ): Promise { const defaultParams = generateDefaultSendFlowParams(); // This is safe because we validate the recipient in `validateRecipient` if it is not defined. - const recipient = Object.keys(params.amounts)[0]; - const amount = params.amounts[recipient]; + const recipient = Object.keys(params.recipients)[0]; + const amount = params.recipients[recipient]; defaultParams.rates = rates; defaultParams.recipient = validateRecipient(recipient, scope); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/account.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/account.test.ts index 7a6b33a7..6c9d5fa4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/account.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/account.test.ts @@ -1,4 +1,4 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; +import { BtcMethod, type KeyringAccount } from '@metamask/keyring-api'; import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; import { v4 as uuidV4 } from 'uuid'; @@ -38,7 +38,7 @@ describe('verifyIfAccountValid', function () { scope: caip2ChainId, index, }, - methods: ['btc_sendmany'], + methods: [`${BtcMethod.SendBitcoin}`], } as unknown as KeyringAccount; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/transaction.ts b/merged-packages/bitcoin-wallet-snap/src/utils/transaction.ts index 5edcc464..04d56d07 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/transaction.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/transaction.ts @@ -3,7 +3,7 @@ import type { KeyringAccount } from '@metamask/keyring-api'; import type { SendFlowRequest } from '../stateManagement'; import { TransactionStatus, type SendFlowParams } from '../stateManagement'; import { AssetType } from '../ui/types'; -import { generateSendManyParams } from '../ui/utils'; +import { generateSendBitcoinParams } from '../ui/utils'; export const generateDefaultSendFlowParams = (): SendFlowParams => { return { @@ -50,7 +50,7 @@ export const generateDefaultSendFlowRequest = ( interfaceId, account, scope, - transaction: generateSendManyParams(scope), + transaction: generateSendBitcoinParams(scope), status: TransactionStatus.Draft, // Send flow params ...generateDefaultSendFlowParams(), diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index b693a10f..064a2ef0 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -1,4 +1,5 @@ import ecc from '@bitcoinerlab/secp256k1'; +import { BtcMethod } from '@metamask/keyring-api'; import { BIP32Factory, type BIP32Interface } from 'bip32'; import type { Network } from 'bitcoinjs-lib'; import { Buffer } from 'buffer'; @@ -36,7 +37,7 @@ export function generateAccounts(cnt = 1, addressPrefix = '') { scope: Caip2ChainId.Testnet, index: i, }, - methods: ['btc_sendmany'], + methods: [`${BtcMethod.SendBitcoin}`], }); } From d49187113e84f900ec3c61c73dcc7eac0536836e Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Thu, 17 Oct 2024 21:18:07 +0800 Subject: [PATCH 151/362] fix: allow any btc address in sendmany (#308) --- .../bitcoin-wallet-snap/snap.manifest.json | 12 ++-------- .../src/rpcs/send-bitcoin.ts | 4 ++-- .../src/utils/superstruct.test.ts | 23 +++++++++++++++++++ .../src/utils/superstruct.ts | 12 ++++++++++ 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 59f79e99..96ae0c3a 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -40,19 +40,11 @@ }, "snap_getBip32Entropy": [ { - "path": [ - "m", - "84'", - "0'" - ], + "path": ["m", "84'", "0'"], "curve": "secp256k1" }, { - "path": [ - "m", - "84'", - "1'" - ], + "path": ["m", "84'", "1'"], "curve": "secp256k1" } ], diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.ts index 7c867a09..86ba2897 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.ts @@ -1,4 +1,3 @@ -import { BtcP2wpkhAddressStruct } from '@metamask/keyring-api'; import { object, string, @@ -22,10 +21,11 @@ import { logger, AmountStruct, getFeeRate, + BitcoinAddressStruct, } from '../utils'; export const RecipientsStruct = refine( - record(BtcP2wpkhAddressStruct, string()), + record(BitcoinAddressStruct, string()), 'RecipientsStruct', (value: Record) => { if (Object.entries(value).length === 0) { diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts index 44707622..bdf5c6ff 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts @@ -4,6 +4,29 @@ import { Config } from '../config'; import * as superstruct from './superstruct'; describe('superstruct', () => { + describe('BitcoinAddressStruct', () => { + it.each([ + '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy', + 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', + 'bc1p5d7rjq7g6rdk2yhzks9smlaqtedr4dekq08ge8ztwac72sfr9rusxg3297', + 'tb1q9lakrt5sw0w0twnc6ww4vxs7hm0q23e03286k8', + ])('returns true for valid address: %s', (val: string) => { + expect(() => assert(val, superstruct.BitcoinAddressStruct)).not.toThrow( + Error, + ); + }); + + it.each(['invalid', '0xA86Df057874BB37C324210a19d3DA51aA31A74C8'])( + 'returns false for invalid address: %s', + (val: string) => { + expect(() => assert(val, superstruct.BitcoinAddressStruct)).toThrow( + Error, + ); + }, + ); + }); + describe('PositiveNumberStringStruct', () => { it.each(['1', '1.2', '0.0023'])( 'does not throw error if the value is valid: %s', diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts index 9009e00c..ee6d29ff 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts @@ -1,8 +1,20 @@ +// eslint-disable-next-line import/no-named-as-default +import validate, { Network } from 'bitcoin-address-validation'; import { enums, string, pattern, refine } from 'superstruct'; import { Config } from '../config'; import { btcToSats } from './unit'; +export const BitcoinAddressStruct = refine( + string(), + 'BitcoinAddressStruct', + (address: string) => { + return ( + validate(address, Network.mainnet) || validate(address, Network.testnet) + ); + }, +); + export const AssetsStruct = enums(Config.availableAssets); export const ScopeStruct = enums(Config.availableNetworks); From 0fcda7ff41a9a785460919fdaaf696b20f02453f Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Thu, 17 Oct 2024 21:59:57 +0800 Subject: [PATCH 152/362] fix: footer validation (#309) --- .../bitcoin-wallet-snap/snap.manifest.json | 14 +++++++++++--- .../src/ui/components/SendFlow.tsx | 8 ++++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 96ae0c3a..1c8d9b14 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "Mj7JTq/axjdDRsTOiIIuwI5CM6bwpGEDA3Sptv6AprQ=", + "shasum": "PMjkNWvEhGA3Q5FmCqFyecBFLi0HGHyt/O5iHnDZ2mg=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -40,11 +40,19 @@ }, "snap_getBip32Entropy": [ { - "path": ["m", "84'", "0'"], + "path": [ + "m", + "84'", + "0'" + ], "curve": "secp256k1" }, { - "path": ["m", "84'", "1'"], + "path": [ + "m", + "84'", + "1'" + ], "curve": "secp256k1" } ], diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlow.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlow.tsx index 07b8142e..ca5dce85 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlow.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlow.tsx @@ -43,10 +43,14 @@ export const SendFlow: SnapComponent = ({ currencySwitched = false, backEventTriggered = false, }) => { - const { amount, recipient, fees } = sendFlowParams; + const { amount, recipient, fees, total } = sendFlowParams; const disabledReview = Boolean( - !amount.valid || !recipient.valid || fees.loading || fees.error, + !amount.valid || + !recipient.valid || + !total.valid || + fees.loading || + fees.error, ); const showTransactionSummary = From 16f71d8bc627d6071a4ecc85fad7c1dad6f5613b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 17 Oct 2024 17:25:33 +0200 Subject: [PATCH 153/362] 0.8.0 (#311) * 0.8.0 * chore: update changelogs * chore: update manifest * chore: lint changelog * chore: update changelog * chore: update changelog * chore: lint changelog * chore: update changelog --------- Co-authored-by: github-actions Co-authored-by: Charly Chevalier --- .../bitcoin-wallet-snap/CHANGELOG.md | 23 ++++++++++++++++++- .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index e44d73da..8c620bd4 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.0] + +### Added + +- Add send flow UI ([#281](https://github.com/MetaMask/snap-bitcoin-wallet/pull/281)), ([#309](https://github.com/MetaMask/snap-bitcoin-wallet/pull/309)), ([#308](https://github.com/MetaMask/snap-bitcoin-wallet/pull/308)), ([#305](https://github.com/MetaMask/snap-bitcoin-wallet/pull/305)), ([#304](https://github.com/MetaMask/snap-bitcoin-wallet/pull/304)), ([#289](https://github.com/MetaMask/snap-bitcoin-wallet/pull/289)) + - The send flow can be started using the `startSendTransactionFlow` internal method. + - The UI allows to send an amount to one recipient (according to its balance). + - There is a confirmation screen that summarize everything regarding the transaction (amount, recipient, fee estimation), once confirmed the transaction will be broadcasted to the blockchain. + - Calling the `sendBitcoin` account's method will trigger the confirmation screen too. + +### Changed + +- **BREAKING:** Rename `btc_sendmany` method to `sendBitcoin` ([#303](https://github.com/MetaMask/snap-bitcoin-wallet/pull/303)) + - The `comment` and `subtractFeeFrom` options have been removed too. +- **BREAKING:** Make transactions replaceable by default ([#297](https://github.com/MetaMask/snap-bitcoin-wallet/pull/297)) +- Rename proposed name to "Bitcoin" (was "Bitcoin Manager") ([#283](https://github.com/MetaMask/snap-bitcoin-wallet/pull/283)) +- Adds fee estimation fallback for `QuickNode` ([#269](https://github.com/MetaMask/snap-bitcoin-wallet/pull/269)) + - In some cases, `QuickNode` may fail to process fee estimation. In such instances, we fall back on using fee information directly from the mempool. + - We also added a default minimum fee as the last resort in case everything else failed. + ## [0.7.0] ### Changed @@ -200,7 +220,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.7.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.0...HEAD +[0.8.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.1...v0.7.0 [0.6.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.0...v0.6.1 [0.6.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.5.0...v0.6.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 5a44a3ed..569c47e3 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.7.0", + "version": "0.8.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 1c8d9b14..9656c5be 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.7.0", + "version": "0.8.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "PMjkNWvEhGA3Q5FmCqFyecBFLi0HGHyt/O5iHnDZ2mg=", + "shasum": "eor5/9at726mEPRXNanb7cpFVPxx31HdprdTIqDX8nA=", "location": { "npm": { "filePath": "dist/bundle.js", From e7519a373bc9977c73111c9190265d92c63e100d Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 17 Oct 2024 18:22:04 +0200 Subject: [PATCH 154/362] feat: add hideSnapBranding when building (#312) --- .../bitcoin-wallet-snap/scripts/build-preinstalled-snap.js | 1 + 1 file changed, 1 insertion(+) diff --git a/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js b/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js index 0c6e675b..68f471b8 100644 --- a/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js +++ b/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js @@ -54,6 +54,7 @@ const preinstalledSnap = { }, ], removable: false, + hideSnapBranding: true, }; // Write preinstalled-snap file From cc9841e4274db9414c01980b7760ce568037f41c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 17 Oct 2024 18:36:25 +0200 Subject: [PATCH 155/362] 0.8.1 (#313) * 0.8.1 * chore: update changelog * chore: update manifest --------- Co-authored-by: github-actions Co-authored-by: Charly Chevalier --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 8c620bd4..61efd17d 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.1] + +### Fixed + +- Use `hideSnapBranding` flag when building ([#312](https://github.com/MetaMask/snap-bitcoin-wallet/pull/312)) + ## [0.8.0] ### Added @@ -220,7 +226,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.1...HEAD +[0.8.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.0...v0.8.1 [0.8.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.1...v0.7.0 [0.6.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.0...v0.6.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 569c47e3..ef100c62 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.8.0", + "version": "0.8.1", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 9656c5be..75307a61 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.8.0", + "version": "0.8.1", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "eor5/9at726mEPRXNanb7cpFVPxx31HdprdTIqDX8nA=", + "shasum": "lesX1zzjdQbizWj5usj/HHAbopV5iAWhjTByE1lyILo=", "location": { "npm": { "filePath": "dist/bundle.js", From 58a096a91c507ec8afa5648d4ae37d6c9f6a4eb8 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 21 Oct 2024 18:11:24 +0800 Subject: [PATCH 156/362] chore: remove `BlockChair` DataClient (#298) * chore: remove blockchair * fix: lint * chore: resolve conflict --- .../bitcoin-wallet-snap/.env.example | 3 - merged-packages/bitcoin-wallet-snap/README.md | 9 +- .../bitcoin/chain/clients/blockchair.test.ts | 480 ------------------ .../src/bitcoin/chain/clients/blockchair.ts | 449 ---------------- .../src/bitcoin/chain/service.test.ts | 18 +- .../src/rpcs/__tests__/helper.ts | 32 +- .../rpcs/start-send-transaction-flow.test.ts | 2 +- .../test/fixtures/blockchair.json | 210 -------- .../bitcoin-wallet-snap/test/utils.ts | 168 +----- 9 files changed, 44 insertions(+), 1327 deletions(-) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json diff --git a/merged-packages/bitcoin-wallet-snap/.env.example b/merged-packages/bitcoin-wallet-snap/.env.example index 041c601f..dfe7ef2e 100644 --- a/merged-packages/bitcoin-wallet-snap/.env.example +++ b/merged-packages/bitcoin-wallet-snap/.env.example @@ -3,9 +3,6 @@ # Default: 0 # Required: false LOG_LEVEL=6 -# Description: Environment variables for the API key of BlockChair -# Required: false -BLOCKCHAIR_API_KEY= # Description: Environment variables for the Testnet endpoint of QuickNode # Required: true QUICKNODE_TESTNET_ENDPOINT= diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index e52a3873..3f043a65 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -7,9 +7,12 @@ Configurations are setup though `.env`, ```bash LOG_LEVEL=6 -# Description: Environment variables for API key of BlockChair -# Required: false -BLOCKCHAIR_API_KEY= +# Description: Environment variables for the Testnet endpoint of QuickNode +# Required: true +QUICKNODE_TESTNET_ENDPOINT= +# Description: Environment variables for the Mainnet endpoint of QuickNode +# Required: true +QUICKNODE_MAINNET_ENDPOINT= ``` ## Rpcs: diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts deleted file mode 100644 index 2c15f23f..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.test.ts +++ /dev/null @@ -1,480 +0,0 @@ -import { networks } from 'bitcoinjs-lib'; - -import { - generateBlockChairBroadcastTransactionResp, - generateBlockChairGetBalanceResp, - generateBlockChairGetUtxosResp, - generateBlockChairGetStatsResp, - generateBlockChairTransactionDashboard, -} from '../../../../test/utils'; -import { Config } from '../../../config'; -import type { BtcAccount } from '../../wallet'; -import { BtcAccountDeriver, BtcWallet } from '../../wallet'; -import { TransactionStatus } from '../constants'; -import { DataClientError } from '../exceptions'; -import { BlockChairClient } from './blockchair'; - -jest.mock('../../../utils/logger'); -jest.mock('../../../utils/snap'); - -describe('BlockChairClient', () => { - const createMockFetch = () => { - // 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, - }; - }; - - const createMockDeriver = (network) => { - return { - instance: new BtcAccountDeriver(network), - }; - }; - - const createAccounts = async (network, recipientCount: number) => { - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - - const accounts: BtcAccount[] = []; - for (let i = 0; i < recipientCount; i++) { - accounts.push(await wallet.unlock(i, Config.wallet.defaultAccountType)); - } - - return { - accounts, - }; - }; - - describe('baseUrl', () => { - it('returns testnet network url', () => { - const instance = new BlockChairClient({ network: networks.testnet }); - expect(instance.baseUrl).toBe( - 'https://api.blockchair.com/bitcoin/testnet', - ); - }); - - it('returns mainnet network url', () => { - const instance = new BlockChairClient({ network: networks.bitcoin }); - expect(instance.baseUrl).toBe('https://api.blockchair.com/bitcoin'); - }); - - it('throws `Invalid network` error if the given network is not support', () => { - const instance = new BlockChairClient({ network: networks.regtest }); - expect(() => instance.baseUrl).toThrow('Invalid network'); - }); - }); - - describe('getApiUrl', () => { - it('append api key to query url if option `apiKey` is present', async () => { - const { fetchSpy } = createMockFetch(); - const network = networks.testnet; - const { accounts } = await createAccounts(network, 1); - const addresses = accounts.map((account) => account.address); - const mockResponse = generateBlockChairGetBalanceResp(addresses); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const instance = new BlockChairClient({ - network: networks.testnet, - apiKey: 'key', - }); - await instance.getBalances(addresses); - - expect(fetchSpy).toHaveBeenCalledWith( - `https://api.blockchair.com/bitcoin/testnet/addresses/balances?addresses=${addresses.join( - '%2C', - )}&key=key`, - { method: 'GET' }, - ); - }); - - it('does not append api key if option `apiKey` is absent', async () => { - const { fetchSpy } = createMockFetch(); - const network = networks.testnet; - const { accounts } = await createAccounts(network, 1); - const addresses = accounts.map((account) => account.address); - const mockResponse = generateBlockChairGetBalanceResp(addresses); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const instance = new BlockChairClient({ network: networks.testnet }); - await instance.getBalances(addresses); - - expect(fetchSpy).toHaveBeenCalledWith( - `https://api.blockchair.com/bitcoin/testnet/addresses/balances?addresses=${addresses.join( - ',', - )}`, - { method: 'GET' }, - ); - }); - }); - - describe('getBalances', () => { - it('returns balances', async () => { - const { fetchSpy } = createMockFetch(); - const network = networks.testnet; - const { accounts } = await createAccounts(network, 10); - const addresses = accounts.map((account) => account.address); - const mockResponse = generateBlockChairGetBalanceResp(addresses); - const expectedResult = mockResponse.data; - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const instance = new BlockChairClient({ network: networks.testnet }); - const result = await instance.getBalances(addresses); - - expect(result).toStrictEqual(expectedResult); - }); - - it('assigns balance to 0 if account is not exist', async () => { - const { fetchSpy } = createMockFetch(); - const network = networks.testnet; - const { - accounts: [accountWithNoBalance, ...accounts], - } = await createAccounts(network, 3); - const addresses = accounts.map((account) => account.address); - const mockResponse = generateBlockChairGetBalanceResp(addresses); - - const expectedResult = { - ...mockResponse.data, - [accountWithNoBalance.address]: 0, - }; - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const instance = new BlockChairClient({ network: networks.testnet }); - const result = await instance.getBalances( - addresses.concat(accountWithNoBalance.address), - ); - - expect(result).toStrictEqual(expectedResult); - }); - - it('throws DataClientError error if an non DataClientError was thrown', async () => { - const { fetchSpy } = createMockFetch(); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockRejectedValue(new Error('error')), - }); - - const network = networks.testnet; - const { accounts } = await createAccounts(network, 1); - const addresses = accounts.map((account) => account.address); - - const instance = new BlockChairClient({ network: networks.testnet }); - - await expect(instance.getBalances(addresses)).rejects.toThrow( - DataClientError, - ); - }); - - it('throws DataClientError error if an fetch response is falsely', async () => { - const { fetchSpy } = createMockFetch(); - - fetchSpy.mockResolvedValueOnce({ - ok: false, - json: jest.fn().mockResolvedValue(null), - }); - - const network = networks.testnet; - const { accounts } = await createAccounts(network, 1); - const addresses = accounts.map((account) => account.address); - - const instance = new BlockChairClient({ network: networks.testnet }); - - await expect(instance.getBalances(addresses)).rejects.toThrow( - DataClientError, - ); - }); - }); - - describe('getFeeRates', () => { - it('returns fee rate', async () => { - const { fetchSpy } = createMockFetch(); - const mockResponse = generateBlockChairGetStatsResp(); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const instance = new BlockChairClient({ network: networks.testnet }); - const result = await instance.getFeeRates(); - - expect(result).toStrictEqual({ - [Config.defaultFeeRate]: - mockResponse.data.suggested_transaction_fee_per_byte_sat, - }); - }); - - it('throws DataClientError error if an non DataClientError was thrown', async () => { - const { fetchSpy } = createMockFetch(); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockRejectedValue(new Error('error')), - }); - - const instance = new BlockChairClient({ network: networks.testnet }); - - await expect(instance.getFeeRates()).rejects.toThrow(DataClientError); - }); - - it('throws DataClientError error if an DataClientError was thrown', async () => { - const { fetchSpy } = createMockFetch(); - - fetchSpy.mockResolvedValueOnce({ - ok: false, - json: jest.fn().mockResolvedValue(null), - }); - - const instance = new BlockChairClient({ network: networks.testnet }); - - await expect(instance.getFeeRates()).rejects.toThrow(DataClientError); - }); - }); - - describe('getUtxos', () => { - it('returns utxos', async () => { - const { fetchSpy } = createMockFetch(); - const network = networks.testnet; - const { accounts } = await createAccounts(network, 1); - const { address } = accounts[0]; - const mockResponse = generateBlockChairGetUtxosResp(address, 10); - const expectedResult = mockResponse.data[address].utxo.map((utxo) => ({ - block: utxo.block_id, - txHash: utxo.transaction_hash, - index: utxo.index, - value: utxo.value, - })); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const instance = new BlockChairClient({ network: networks.testnet }); - const result = await instance.getUtxos(address); - - expect(result).toStrictEqual(expectedResult); - }); - - it('fetchs with pagination if utxos more than limit', async () => { - const { fetchSpy } = createMockFetch(); - const network = networks.testnet; - const { accounts } = await createAccounts(network, 1); - const { address } = accounts[0]; - - const pagesToTest = 3; - const limit = 1000; - let expectedResult: unknown[] = []; - - for (let i = 0; i < pagesToTest; i++) { - const mockResponse = generateBlockChairGetUtxosResp( - address, - i === pagesToTest - 1 ? 0 : limit, - ); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - expectedResult = expectedResult.concat( - mockResponse.data[address].utxo.map((utxo) => ({ - block: utxo.block_id, - txHash: utxo.transaction_hash, - index: utxo.index, - value: utxo.value, - })), - ); - } - - const instance = new BlockChairClient({ network: networks.testnet }); - const result = await instance.getUtxos(address); - - expect(result).toStrictEqual(expectedResult); - expect(fetchSpy).toHaveBeenCalledTimes(3); - }); - - it('throws `Data not avaiable` error if given address not found from the result', async () => { - const { fetchSpy } = createMockFetch(); - const network = networks.testnet; - const { accounts } = await createAccounts(network, 2); - const requestAddress = accounts[0].address; - const actualRespAddress = accounts[1].address; - const mockResponse = generateBlockChairGetUtxosResp(actualRespAddress, 1); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const instance = new BlockChairClient({ network: networks.testnet }); - - await expect(instance.getUtxos(requestAddress)).rejects.toThrow( - `Data not avaiable`, - ); - }); - }); - - describe('sendTransaction', () => { - const signedTransaction = - '02000000000101ec81faa8b57add4c8fb3958dd8f04667f5cd829a7b94199f4400be9e52cda0760000000000ffffffff015802000000000000160014f80b562cbcbbfc97727043484c06cc5579963e8402473044022011ec3f7ea7a7cac7cb891a1ea498d94ca3cd082339b9b2620ba5421ca7cbdf3d022062f34411d6aa5335c2bd7ff4c940adb962e9509133b86a2d97996552fd811f2c012102ceea82614fdb14871ef881498c55c5dbdc24b4633d29b42040dd18b4285540f500000000'; - - it('broadcasts an transaction', async () => { - const { fetchSpy } = createMockFetch(); - const mockResponse = generateBlockChairBroadcastTransactionResp(); - const expectedResult = mockResponse.data; - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const instance = new BlockChairClient({ network: networks.testnet }); - const result = await instance.sendTransaction(signedTransaction); - - expect(result).toStrictEqual(expectedResult.transaction_hash); - }); - - it('throws DataClientError error if an fetch response is falsely', async () => { - const { fetchSpy } = createMockFetch(); - - fetchSpy.mockResolvedValueOnce({ - ok: false, - json: jest.fn().mockResolvedValue(null), - }); - - const instance = new BlockChairClient({ network: networks.testnet }); - - await expect(instance.sendTransaction(signedTransaction)).rejects.toThrow( - DataClientError, - ); - }); - - it('conducts error message with response`s context if status is 400', async () => { - const { fetchSpy } = createMockFetch(); - - fetchSpy.mockResolvedValueOnce({ - ok: false, - status: 400, - json: jest.fn().mockResolvedValue({ - data: null, - context: { - code: 400, - error: - 'Invalid transaction. Error: Transaction already in block chain', - }, - }), - }); - - const instance = new BlockChairClient({ network: networks.testnet }); - await expect(instance.sendTransaction(signedTransaction)).rejects.toThrow( - 'Failed to post data from blockchair: Invalid transaction. Error: Transaction already in block chain', - ); - }); - }); - - describe('getTransactionStatus', () => { - const txHash = - '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; - - it('returns correct result for confirmed transaction', async () => { - const { fetchSpy } = createMockFetch(); - const mockResponse = generateBlockChairTransactionDashboard( - txHash, - 200000, - 200002, - true, - ); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const instance = new BlockChairClient({ network: networks.testnet }); - const result = await instance.getTransactionStatus(txHash); - - expect(result).toStrictEqual({ - status: TransactionStatus.Confirmed, - }); - }); - - it('returns correct result for pending transaction', async () => { - const { fetchSpy } = createMockFetch(); - const mockResponse = generateBlockChairTransactionDashboard( - txHash, - 200000, - 200002, - false, - ); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); - - const instance = new BlockChairClient({ network: networks.testnet }); - const result = await instance.getTransactionStatus(txHash); - - expect(result).toStrictEqual({ - status: TransactionStatus.Pending, - }); - }); - - it('returns pending status if blockchair result is empty', async () => { - const { fetchSpy } = createMockFetch(); - - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ - data: [], - context: { - state: 200002, - }, - }), - }); - - const instance = new BlockChairClient({ network: networks.testnet }); - const result = await instance.getTransactionStatus(txHash); - - expect(result).toStrictEqual({ - status: TransactionStatus.Pending, - }); - }); - - it('throws DataClientError error if an DataClientError was thrown', async () => { - const { fetchSpy } = createMockFetch(); - - fetchSpy.mockResolvedValueOnce({ - ok: false, - json: jest.fn().mockResolvedValue(null), - }); - - const instance = new BlockChairClient({ network: networks.testnet }); - - await expect(instance.getTransactionStatus(txHash)).rejects.toThrow( - DataClientError, - ); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts deleted file mode 100644 index 461afe2b..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/blockchair.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { BtcP2wpkhAddressStruct } from '@metamask/keyring-api'; -import type { Json } from '@metamask/snaps-sdk'; -import { type Network, networks } from 'bitcoinjs-lib'; -import { array, assert } from 'superstruct'; - -import { Config } from '../../../config'; -import { compactError, logger, TxIdStruct } from '../../../utils'; -import { TransactionStatus } from '../constants'; -import type { - IDataClient, - DataClientGetBalancesResp, - DataClientGetTxStatusResp, - DataClientGetUtxosResp, - DataClientSendTxResp, - DataClientGetFeeRatesResp, -} from '../data-client'; -import { DataClientError } from '../exceptions'; - -export type BlockChairClientOptions = { - network: Network; - apiKey?: string; -}; - -/* eslint-disable */ -export type LargestTransaction = { - hash: string; - value_usd: number; -}; - -export type GetBalancesResponse = { - data: { - [address: string]: number; - }; -}; - -export type GetStatResponse = { - data: { - blocks: number; - transactions: number; - outputs: number; - circulation: number; - blocks_24h: number; - transactions_24h: number; - difficulty: number; - volume_24h: number; - mempool_transactions: number; - mempool_size: number; - mempool_tps: number; - mempool_total_fee_usd: number; - best_block_height: number; - best_block_hash: string; - best_block_time: string; - blockchain_size: number; - average_transaction_fee_24h: number; - inflation_24h: number; - median_transaction_fee_24h: number; - cdd_24h: number; - mempool_outputs: number; - largest_transaction_24h: LargestTransaction; - nodes: number; - hashrate_24h: string; - inflation_usd_24h: number; - average_transaction_fee_usd_24h: number; - median_transaction_fee_usd_24h: number; - market_price_usd: number; - market_price_btc: number; - market_price_usd_change_24h_percentage: number; - market_cap_usd: number; - market_dominance_percentage: number; - next_retarget_time_estimate: string; - next_difficulty_estimate: number; - countdowns: never[]; - suggested_transaction_fee_per_byte_sat: number; - hodling_addresses: number; - }; -}; - -export type GetUtxosResponse = { - data: { - [address: string]: { - address: { - type: string; - script_hex: string; - balance: number; - balance_usd: number; - received: number; - received_usd: number; - spent: number; - spent_usd: number; - output_count: number; - unspent_output_count: number; - first_seen_receiving: string; - last_seen_receiving: string; - first_seen_spending: string; - last_seen_spending: string; - scripthash_type: null; - transaction_count: null; - }; - transactions: any[]; - utxo: { - block_id: number; - transaction_hash: string; - index: number; - value: number; - }[]; - }; - }; -}; - -export type GetTransactionDashboardDataResponse = { - data: { - [key: string]: { - transaction: { - block_id: number; - id: number; - hash: string; - date: string; - time: string; - size: number; - weight: number; - version: number; - lock_time: number; - is_coinbase: boolean; - has_witness: boolean; - input_count: number; - output_count: number; - input_total: number; - input_total_usd: number; - output_total: number; - output_total_usd: number; - fee: number; - fee_usd: number; - fee_per_kb: number; - fee_per_kb_usd: number; - fee_per_kwu: number; - fee_per_kwu_usd: number; - cdd_total: number; - is_rbf: boolean; - }; - inputs: { - block_id: number; - transaction_id: number; - index: number; - transaction_hash: string; - date: string; - time: string; - value: number; - value_usd: number; - recipient: string; - type: string; - script_hex: string; - is_from_coinbase: boolean; - is_spendable: null; - is_spent: boolean; - spending_block_id: number | null; - spending_transaction_id: number | null; - spending_index: number | null; - spending_transaction_hash: string | null; - spending_date: string | null; - spending_time: string | null; - spending_value_usd: number | null; - spending_sequence: number | null; - spending_signature_hex: string | null; - spending_witness: string | null; - lifespan: number | null; - cdd: number | null; - }[]; - outputs: { - block_id: number; - transaction_id: number; - index: number; - transaction_hash: string; - date: string; - time: string; - value: number; - value_usd: number; - recipient: string; - type: string; - script_hex: string; - is_from_coinbase: boolean; - is_spendable: null; - is_spent: boolean; - spending_block_id: null; - spending_transaction_id: null; - spending_index: null; - spending_transaction_hash: null; - spending_date: null; - spending_time: null; - spending_value_usd: null; - spending_sequence: null; - spending_signature_hex: null; - spending_witness: null; - lifespan: null; - cdd: null; - }[]; - }; - }; - context: { - state: number; - }; -}; - -export type PostTransactionResponse = { - data: { - transaction_hash: string; - }; -}; -/* eslint-disable */ - -export class BlockChairClient implements IDataClient { - protected readonly _options: BlockChairClientOptions; - - constructor(options: BlockChairClientOptions) { - this._options = options; - } - - get baseUrl(): string { - switch (this._options.network) { - case networks.bitcoin: - return 'https://api.blockchair.com/bitcoin'; - case networks.testnet: - return 'https://api.blockchair.com/bitcoin/testnet'; - default: - throw new Error('Invalid network'); - } - } - - protected getApiUrl(endpoint: string): string { - const url = new URL(`${this.baseUrl}${endpoint}`); - // TODO: Update to proxy - if (this._options.apiKey) { - url.searchParams.append('key', this._options.apiKey); - } - return url.toString(); - } - - protected async get(endpoint: string): Promise { - const response = await fetch(this.getApiUrl(endpoint), { - method: 'GET', - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch data from blockchair: ${response.statusText}`, - ); - } - return response.json() as unknown as Resp; - } - - protected async post(endpoint: string, body: Json): Promise { - const response = await fetch(this.getApiUrl(endpoint), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - if (response.status == 400) { - const res = await response.json(); - throw new Error( - `Failed to post data from blockchair: ${res.context.error}`, - ); - } - - if (!response.ok) { - throw new Error( - `Failed to post data from blockchair: ${response.statusText}`, - ); - } - return response.json() as unknown as Resp; - } - - protected async getTxDashboardData( - txHash: string, - ): Promise { - try { - assert(txHash, TxIdStruct); - - logger.info(`[BlockChairClient.getTxDashboardData] start:`); - const response = await this.get( - `/dashboards/transaction/${txHash}`, - ); - logger.info( - `[BlockChairClient.getTxDashboardData] response: ${JSON.stringify( - response, - )}`, - ); - return response; - } catch (error) { - logger.info( - `[BlockChairClient.getTxDashboardData] error: ${error.message}`, - ); - throw compactError(error, DataClientError); - } - } - - async getBalances(addresses: string[]): Promise { - try { - assert(addresses, array(BtcP2wpkhAddressStruct)); - - logger.info( - `[BlockChairClient.getBalance] start: { addresses : ${JSON.stringify( - addresses, - )} }`, - ); - - const response = await this.get( - `/addresses/balances?addresses=${addresses.join(',')}`, - ); - - logger.info( - `[BlockChairClient.getBalance] response: ${JSON.stringify(response)}`, - ); - - return addresses.reduce( - (data: DataClientGetBalancesResp, address: string) => { - data[address] = response.data[address] ?? 0; - return data; - }, - {}, - ); - } catch (error) { - logger.info(`[BlockChairClient.getBalance] error: ${error.message}`); - throw compactError(error, DataClientError); - } - } - - async getUtxos( - address: string, - includeUnconfirmed?: boolean, - ): Promise { - try { - assert(address, BtcP2wpkhAddressStruct); - - let process = true; - let offset = 0; - const limit = 1000; - const data: DataClientGetUtxosResp = []; - - while (process) { - let url = `/dashboards/address/${address}?limit=0,${limit}&offset=0,${offset}`; - if (!includeUnconfirmed) { - url += '&state=latest'; - } - - const response = await this.get(url); - - logger.info( - `[BlockChairClient.getUtxos] response: ${JSON.stringify(response)}`, - ); - - if (!Object.prototype.hasOwnProperty.call(response.data, address)) { - throw new Error(`Data not avaiable`); - } - - response.data[address].utxo.forEach((utxo) => { - data.push({ - block: utxo.block_id, - txHash: utxo.transaction_hash, - index: utxo.index, - value: utxo.value, - }); - }); - - offset += 1; - - if (response.data[address].utxo.length < limit) { - process = false; - } - } - - return data; - } catch (error) { - logger.info(`[BlockChairClient.getUtxos] error: ${error.message}`); - throw compactError(error, DataClientError); - } - } - - async getFeeRates(): Promise { - try { - logger.info(`[BlockChairClient.getFeeRates] start:`); - const response = await this.get(`/stats`); - logger.info( - `[BlockChairClient.getFeeRates] response: ${JSON.stringify(response)}`, - ); - return { - [Config.defaultFeeRate]: response.data.suggested_transaction_fee_per_byte_sat, - }; - } catch (error) { - logger.info(`[BlockChairClient.getFeeRates] error: ${error.message}`); - throw compactError(error, DataClientError); - } - } - - async sendTransaction( - signedTransaction: string, - ): Promise { - try { - const response = await this.post( - `/push/transaction`, - { - data: signedTransaction, - }, - ); - - logger.info( - `[BlockChairClient.sendTransaction] response: ${JSON.stringify( - response, - )}`, - ); - - return response.data.transaction_hash; - } catch (error) { - logger.info(`[BlockChairClient.sendTransaction] error: ${error.message}`); - throw compactError(error, DataClientError); - } - } - - async getTransactionStatus( - txHash: string, - ): Promise { - try { - const response = await this.getTxDashboardData(txHash); - - let status = TransactionStatus.Pending; - - if ( - typeof response.data === 'object' && - Object.prototype.hasOwnProperty.call(response.data, txHash) - ) { - const isInMempool = response.data[txHash].transaction.block_id === -1; - - status = isInMempool - ? TransactionStatus.Pending - : TransactionStatus.Confirmed; - } - - return { - status: status, - }; - } catch (error) { - logger.info( - `[BlockChairClient.getTransactionStatus] error: ${error.message}`, - ); - throw compactError(error, DataClientError); - } - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts index 964385e8..f251003c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts @@ -3,8 +3,8 @@ import { networks } from 'bitcoinjs-lib'; import { generateAccounts, - generateBlockChairBroadcastTransactionResp, - generateBlockChairGetUtxosResp, + generateFormattedUtxos, + generateQuickNodeSendRawTransactionResp, } from '../../../test/utils'; import { Caip2Asset } from '../../constants'; import { FeeRate, TransactionStatus } from './constants'; @@ -129,13 +129,7 @@ describe('BtcOnChainService', () => { const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(1); const sender = accounts[0].address; - const mockResponse = generateBlockChairGetUtxosResp(sender, 10); - const utxos = mockResponse.data[sender].utxo.map((utxo) => ({ - block: utxo.block_id, - txHash: utxo.transaction_hash, - index: utxo.index, - value: utxo.value, - })); + const utxos = generateFormattedUtxos(sender, 10); getUtxosSpy.mockResolvedValue(utxos); @@ -207,14 +201,14 @@ describe('BtcOnChainService', () => { const { instance, sendTransactionSpy } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); - const resp = generateBlockChairBroadcastTransactionResp(); - sendTransactionSpy.mockResolvedValue(resp.data.transaction_hash); + const resp = generateQuickNodeSendRawTransactionResp(); + sendTransactionSpy.mockResolvedValue(resp.result); const result = await txService.broadcastTransaction(signedTransaction); expect(sendTransactionSpy).toHaveBeenCalledWith(signedTransaction); expect(result).toStrictEqual({ - transactionId: resp.data.transaction_hash, + transactionId: resp.result, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts index d566669d..1a458977 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts @@ -2,8 +2,8 @@ import { BtcMethod, type KeyringAccount } from '@metamask/keyring-api'; import { v4 as uuidV4 } from 'uuid'; import { - generateBlockChairBroadcastTransactionResp, - generateBlockChairGetUtxosResp, + generateQuickNodeSendRawTransactionResp, + generateFormattedUtxos, } from '../../../test/utils'; import type { Utxo } from '../../bitcoin/chain'; import { BtcOnChainService } from '../../bitcoin/chain'; @@ -88,27 +88,12 @@ export function createMockGetDataForTransactionResp( minVal = 10000, maxVal = 100000, ) { - const mockResponse = generateBlockChairGetUtxosResp( - address, - counter, - minVal, - maxVal, - ); + const utxos = generateFormattedUtxos(address, counter, minVal, maxVal); - let total = 0; - const data = mockResponse.data[address].utxo.map((utxo) => { - const { value } = utxo; - total += value; - return { - block: utxo.block_id, - txHash: utxo.transaction_hash, - index: utxo.index, - value, - }; - }); + const total = utxos.reduce((acc, utxo) => acc + utxo.value, 0); return { - data, + data: utxos, total, }; } @@ -364,6 +349,8 @@ export class SendBitcoinTest extends EstimateFeeTest { alertDialogSpy: jest.SpyInstance; + #broadCastTxResp: string | null; + constructor(testCase: SendBitcoinCreateOption) { super(testCase); const { broadcastTransactionSpy } = createMockChainApiFactory(); @@ -408,7 +395,10 @@ export class SendBitcoinTest extends EstimateFeeTest { } get broadCastTxResp() { - return generateBlockChairBroadcastTransactionResp().data.transaction_hash; + if (!this.#broadCastTxResp) { + this.#broadCastTxResp = generateQuickNodeSendRawTransactionResp().result; + } + return this.#broadCastTxResp; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts index 76ec0d64..0b14043d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts @@ -104,7 +104,7 @@ describe('startSendTransactionFlow', () => { expect(helper.generateSendFlowSpy).toHaveBeenCalledTimes(1); expect(helper.upsertRequestSpy).toHaveBeenCalledTimes(4); expect(transactionTx).toStrictEqual({ - txId: '0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098', + txId: helper.broadCastTxResp, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json deleted file mode 100644 index 936642f6..00000000 --- a/merged-packages/bitcoin-wallet-snap/test/fixtures/blockchair.json +++ /dev/null @@ -1,210 +0,0 @@ -{ - "getBalanceResp": { - "data": {} - }, - "getStatsResp": { - "data": { - "blocks": 2628955, - "transactions": 87249891, - "outputs": 236025835, - "circulation": 2099566938801961, - "blocks_24h": 19893, - "transactions_24h": 2311448, - "difficulty": 16384, - "volume_24h": 132646837532010, - "mempool_transactions": 146, - "mempool_size": 79331, - "mempool_tps": 0, - "mempool_total_fee_usd": 0, - "best_block_height": 2628954, - "best_block_hash": "0000000000000305719556770fef78ba95b2ed376d0ce4d17f6a91f69d6cfb76", - "best_block_time": "2024-04-23 11:03:12", - "blockchain_size": 37448283346, - "average_transaction_fee_24h": 9691, - "inflation_24h": 24283444779, - "median_transaction_fee_24h": 8146, - "cdd_24h": 202902.24909198366, - "mempool_outputs": 1704, - "largest_transaction_24h": { - "hash": "e2f2c5483fc5e11fcc20a2221a784d2d8d3e26e15a8a2eb346b9a5b65682e001", - "value_usd": 0 - }, - "hashrate_24h": "17592186044416", - "inflation_usd_24h": 0, - "average_transaction_fee_usd_24h": 0, - "median_transaction_fee_usd_24h": 0, - "market_price_usd": 0, - "market_price_btc": 0, - "market_price_usd_change_24h_percentage": 0, - "market_cap_usd": 0, - "market_dominance_percentage": 0, - "next_retarget_time_estimate": "2024-04-23 11:52:16", - "next_difficulty_estimate": 5461, - "suggested_transaction_fee_per_byte_sat": 1, - "hodling_addresses": 11723857 - } - }, - "getUtxoResp": { - "data": { - "tb1qlq94vt9uh07fwunsgdyycpkv24uev05ywjua0r": { - "address": { - "type": "witness_v0_scripthash", - "script_hex": "0014f80b562cbcbbfc97727043484c06cc5579963e84", - "balance": 2374, - "balance_usd": 0, - "received": 7174, - "received_usd": 0, - "spent": 4800, - "spent_usd": 0, - "output_count": 5, - "unspent_output_count": 2, - "first_seen_receiving": "2024-04-12 05:29:15", - "last_seen_receiving": "2024-04-12 07:00:08", - "first_seen_spending": "2024-04-12 05:49:16", - "last_seen_spending": "2024-04-12 07:00:08", - "scripthash_type": null, - "transaction_count": null - }, - "transactions": [], - "utxo": [ - { - "block_id": 2586047, - "transaction_hash": "5c38297331e1dcdb42db691f435d9d119f228054d5f39c88e74329bef5513d77", - "index": 0, - "value": 600 - }, - { - "block_id": 2586041, - "transaction_hash": "6bea08c31f0b4da537f73400ac9da99b34458b22ec9fcf5184944e3a1796a8db", - "index": 1, - "value": 1774 - } - ] - } - } - }, - "broadcastTransactionResp": { - "data": { - "transaction_hash": "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098" - } - }, - "getDashboardTransaction": { - "data": { - "1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08": { - "transaction": { - "block_id": 2817600, - "id": 137371353, - "hash": "1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08", - "date": "2024-05-25", - "time": "2024-05-25 07:47:54", - "size": 192, - "weight": 438, - "version": 2, - "lock_time": 0, - "is_coinbase": false, - "has_witness": true, - "input_count": 1, - "output_count": 1, - "input_total": 6864, - "input_total_usd": 0, - "output_total": 500, - "output_total_usd": 0, - "fee": 6364, - "fee_usd": 0, - "fee_per_kb": 33145.832, - "fee_per_kb_usd": 0, - "fee_per_kwu": 14529.681, - "fee_per_kwu_usd": 0, - "cdd_total": 7.7915933333333e-5, - "is_rbf": true - }, - "inputs": [ - { - "block_id": 2817323, - "transaction_id": 137179822, - "index": 1, - "transaction_hash": "db78f36fc939d054a37210eed56ddc04be030c0f73c9029fe86ff14f1ebedf61", - "date": "2024-05-24", - "time": "2024-05-24 04:33:18", - "value": 6864, - "value_usd": 0, - "recipient": "tb1qlhkuysju47s642834n7f3tyk67mvnt2cfd9r7y", - "type": "witness_v0_keyhash", - "script_hex": "0014fdedc2425cafa1aaa8f1acfc98ac96d7b6c9ad58", - "is_from_coinbase": false, - "is_spendable": null, - "is_spent": true, - "spending_block_id": 2817600, - "spending_transaction_id": 137371353, - "spending_index": 0, - "spending_transaction_hash": "1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08", - "spending_date": "2024-05-25", - "spending_time": "2024-05-25 07:47:54", - "spending_value_usd": 0, - "spending_sequence": 4294967293, - "spending_signature_hex": "", - "spending_witness": "3045022100b8ab2eff6dde1ba3e4acac8c384bb67ec84d46c7eec55ccfdbacfe3cb8d77b910220733b3013de1a19e543cc48e204af7f0382c9548e73f0a6042dbc72a39c65b70d01,0217c65c157253d09d80e7f49c7595ae5cc82a31d3e787e3019a0260125a55b0d1", - "lifespan": 98076, - "cdd": 0 - } - ], - "outputs": [ - { - "block_id": 2817600, - "transaction_id": 137371353, - "index": 0, - "transaction_hash": "1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08", - "date": "2024-05-25", - "time": "2024-05-25 07:47:54", - "value": 500, - "value_usd": 0, - "recipient": "tb1qlhkuysju47s642834n7f3tyk67mvnt2cfd9r7y", - "type": "witness_v0_keyhash", - "script_hex": "0014fdedc2425cafa1aaa8f1acfc98ac96d7b6c9ad58", - "is_from_coinbase": false, - "is_spendable": null, - "is_spent": false, - "spending_block_id": null, - "spending_transaction_id": null, - "spending_index": null, - "spending_transaction_hash": null, - "spending_date": null, - "spending_time": null, - "spending_value_usd": null, - "spending_sequence": null, - "spending_signature_hex": null, - "spending_witness": null, - "lifespan": null, - "cdd": null - } - ] - } - }, - "context": { - "code": 200, - "source": "D", - "results": 1, - "state": 2818512, - "market_price_usd": 68030, - "cache": { - "live": true, - "duration": 20, - "since": "2024-05-29 07:44:19", - "until": "2024-05-29 07:44:39", - "time": null - }, - "api": { - "version": "2.0.95-ie", - "last_major_update": "2022-11-07 02:00:00", - "next_major_update": "2023-11-12 02:00:00", - "documentation": "https://blockchair.com/api/docs", - "notice": "Please note that on November 12th, 2023 public support for the following blockchains will be dropped: Mixin, Ethereum Testnet (Goerli)" - }, - "servers": "API4,TBTC3", - "time": 2.340773820877075, - "render_time": 0.04655718803405762, - "full_time": 2.387331008911133, - "request_cost": 1 - } - } -} diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index 064a2ef0..f4a2f000 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -7,7 +7,6 @@ import ECPairFactory from 'ecpair'; import { v4 as uuidV4 } from 'uuid'; import { Caip2ChainId } from '../src/constants'; -import blockChairData from './fixtures/blockchair.json'; import quickNodeData from './fixtures/quicknode.json'; /* eslint-disable */ @@ -204,131 +203,6 @@ export function createMockBip32Instance( const randomNum = (max) => Math.floor(Math.random() * max); -/** - * Method to generate blockchair getBalance resp by addresses. - * - * @param addresses - Array of address in string. - * @returns An array of blockchair getBalance response. - */ -export function generateBlockChairGetBalanceResp(addresses: string[]) { - const template = blockChairData.getBalanceResp; - const resp: typeof template = { ...template, data: {} }; - for (const address of addresses) { - resp.data[address] = randomNum(1000000); - } - return resp; -} - -/** - * Method to generate blockchair getUtxos resp by address. - * - * @param address - address in string. - * @param utxosCount - utxos count. - * @param minAmount - min amount of each utxo value. - * @param maxAmount - max amount of each utxo value. - * @returns An array of blockchair getUtxos response. - */ -export function generateBlockChairGetUtxosResp( - address: string, - utxosCount: number, - minAmount = 0, - maxAmount = 1000000, -) { - const template = blockChairData.getUtxoResp; - const data = { ...template.data.tb1qlq94vt9uh07fwunsgdyycpkv24uev05ywjua0r }; - let idx = -1; - const resp = { - data: { - [address]: { - ...data, - utxo: Array.from({ length: utxosCount }, () => { - idx += 1; - return { - block_id: randomNum(1000000), - transaction_hash: randomNum(1000000) - .toString(16) - .padStart( - template.data.tb1qlq94vt9uh07fwunsgdyycpkv24uev05ywjua0r.utxo[0] - .transaction_hash.length, - '0', - ), - index: idx, - value: Math.max(randomNum(maxAmount), minAmount), - }; - }), - }, - }, - }; - return resp; -} - -/** - * Method to generate blockchair getStats resp. - * - * @returns A blockchair getStats resp. - */ -export function generateBlockChairGetStatsResp() { - const template = blockChairData.getStatsResp; - const resp: typeof template = { ...template }; - Object.entries(template.data).forEach(([key, value]) => { - if (typeof value === 'number') { - if (value === 0) { - resp.data[key] = randomNum(100); - } - resp.data[key] = randomNum(value); - } - }); - resp.data.suggested_transaction_fee_per_byte_sat = randomNum(20); - return resp; -} - -/** - * Method to generate blockchair BroadcastTransaction resp. - * - * @returns An txn id of blockchair BroadcastTransaction response. - */ -export function generateBlockChairBroadcastTransactionResp() { - const template = blockChairData.broadcastTransactionResp; - const resp: typeof template = { ...template }; - return resp; -} - -/** - * Method to generate blockchair transaction dashboards resp. - * - * @param txHash - Transaction hash of the transaction. - * @param txnBlockHeight - Block height of the transaction. - * @param txnBlockHeight - Block height of the last block. - * @param lastBlockHeight - Block height of the last block. - * @param confirmed - Confirm status of the transaction. - * @returns A blockchair transaction dashboards resp. - */ -export function generateBlockChairTransactionDashboard( - txHash: string, - txnBlockHeight: number, - lastBlockHeight: number, - confirmed: boolean, -) { - const template = blockChairData.getDashboardTransaction; - const data = Object.values(template.data)[0]; - const resp = { - data: { - [txHash]: { - ...data, - transaction: { - ...data.transaction, - block_id: confirmed ? txnBlockHeight : -1, - }, - }, - }, - context: { - ...template.context, - state: lastBlockHeight, - }, - }; - return resp; -} - /** * Generate QuickNode bb_getaddress response by address. * @@ -383,7 +257,6 @@ export function generateQuickNodeGetUtxosResp( value: Math.max(minAmount, randomNum(maxAmount)).toString(), height: 100000 + idx, confirmations: Math.max(minConfirmations, randomNum(maxConfirmations)), - }; }); return data; @@ -500,33 +373,32 @@ export function generateRandomTransactionId() { } /** - * Method to generate formatted utxos with blockchair resp. + * Method to generate formatted utxos with QuickNode resp. * - * @param address - the utxos owner address. - * @param utxoCnt - count of the utxo to be generated. - * @param minAmt - min amount of the utxo array. - * @param maxAmt - max amount of the utxo array. + * @param _address - the utxos owner address (deprecated). + * @param utxosCount - count of the utxo to be generated. + * @param minAmount - min amount of the utxo array. + * @param maxAmount - max amount of the utxo array. * @returns An formatted utxo array. */ export function generateFormattedUtxos( - address: string, - utxoCnt: number, - minAmt?: number, - maxAmt?: number, + _address: string, + utxosCount: number, + minAmount?: number, + maxAmount?: number, ) { - const rawUtxos = generateBlockChairGetUtxosResp( - address, - utxoCnt, - minAmt, - maxAmt, - ); - const formattedUtxos = rawUtxos.data[address].utxo.map((utxo) => ({ - block: utxo.block_id, - txHash: utxo.transaction_hash, - index: utxo.index, - value: utxo.value, + return generateQuickNodeGetUtxosResp( + { + utxosCount, + minAmount, + maxAmount, + } + ).result.map((utxo) => ({ + block: utxo.height, + txHash: utxo.txid, + index: utxo.vout, + value: parseInt(utxo.value, 10), })); - return formattedUtxos; } /* eslint-disable */ From 75fa1e2a328d2276011e48bf881110dcadc51e8f Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 24 Oct 2024 17:09:26 +0200 Subject: [PATCH 157/362] chore: remove localhost from the permissions (#322) --- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 75307a61..ecabe76f 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "lesX1zzjdQbizWj5usj/HHAbopV5iAWhjTByE1lyILo=", + "shasum": "iB1xejcsUM0CgXQHQWPJwJty3J8rmiZIub+DfOTrsx0=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -18,7 +18,6 @@ } }, "initialConnections": { - "http://localhost:3000": {}, "https://portfolio.metamask.io": {}, "https://portfolio-builds.metafi-dev.codefi.network": {}, "https://dev.portfolio.metamask.io": {}, @@ -31,7 +30,6 @@ }, "endowment:keyring": { "allowedOrigins": [ - "http://localhost:3000", "https://portfolio.metamask.io", "https://portfolio-builds.metafi-dev.codefi.network", "https://dev.portfolio.metamask.io", From 15d6744afd7289ea2b054b9292fbdcd8dd3b480a Mon Sep 17 00:00:00 2001 From: Daniel Rocha <68558152+danroc@users.noreply.github.com> Date: Fri, 25 Oct 2024 15:32:38 +0200 Subject: [PATCH 158/362] chore: remove localhost from permissions (#324) --- merged-packages/bitcoin-wallet-snap/src/permissions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/permissions.ts index 35d68174..94365c79 100644 --- a/merged-packages/bitcoin-wallet-snap/src/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/permissions.ts @@ -40,7 +40,6 @@ const allowedOrigins = [ 'https://portfolio.metamask.io', 'https://portfolio-builds.metafi-dev.codefi.network', 'https://dev.portfolio.metamask.io', - 'http://localhost:3000', 'https://ramps-dev.portfolio.metamask.io', ]; From 42b6c8720d88c9384214d9ed9d041f23b0755a5e Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 24 Oct 2024 17:09:26 +0200 Subject: [PATCH 159/362] chore: remove localhost from the permissions (#322) --- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 75307a61..ecabe76f 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "lesX1zzjdQbizWj5usj/HHAbopV5iAWhjTByE1lyILo=", + "shasum": "iB1xejcsUM0CgXQHQWPJwJty3J8rmiZIub+DfOTrsx0=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -18,7 +18,6 @@ } }, "initialConnections": { - "http://localhost:3000": {}, "https://portfolio.metamask.io": {}, "https://portfolio-builds.metafi-dev.codefi.network": {}, "https://dev.portfolio.metamask.io": {}, @@ -31,7 +30,6 @@ }, "endowment:keyring": { "allowedOrigins": [ - "http://localhost:3000", "https://portfolio.metamask.io", "https://portfolio-builds.metafi-dev.codefi.network", "https://dev.portfolio.metamask.io", From f6cf2fa393bcc772b6141d4a68b94fe789457368 Mon Sep 17 00:00:00 2001 From: Daniel Rocha <68558152+danroc@users.noreply.github.com> Date: Fri, 25 Oct 2024 15:32:38 +0200 Subject: [PATCH 160/362] chore: remove localhost from permissions (#324) --- merged-packages/bitcoin-wallet-snap/src/permissions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/permissions.ts index 35d68174..94365c79 100644 --- a/merged-packages/bitcoin-wallet-snap/src/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/permissions.ts @@ -40,7 +40,6 @@ const allowedOrigins = [ 'https://portfolio.metamask.io', 'https://portfolio-builds.metafi-dev.codefi.network', 'https://dev.portfolio.metamask.io', - 'http://localhost:3000', 'https://ramps-dev.portfolio.metamask.io', ]; From 5db00c70a72e55582354fb7b9d2c25a79a5a83c3 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 28 Oct 2024 19:29:35 +0000 Subject: [PATCH 161/362] 0.8.2 --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 61efd17d..391db8f4 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.2] + +### Fixed + +- Remove `localhost` from permissions ([#324](https://github.com/MetaMask/snap-bitcoin-wallet/pull/324)), ([#322](https://github.com/MetaMask/snap-bitcoin-wallet/pull/322)) + ## [0.8.1] ### Fixed @@ -226,7 +232,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.1...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.2...HEAD +[0.8.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.1...v0.8.2 [0.8.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.0...v0.8.1 [0.8.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.6.1...v0.7.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index ef100c62..9c84980c 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.8.1", + "version": "0.8.2", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index ecabe76f..c67871e4 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.8.1", + "version": "0.8.2", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "iB1xejcsUM0CgXQHQWPJwJty3J8rmiZIub+DfOTrsx0=", + "shasum": "VF5xB5NJEC3cL/T81hpK8iE7hg6N6n/1gM7iVY6h2Cw=", "location": { "npm": { "filePath": "dist/bundle.js", From aa21dcfba0a58ad738ac6756eb32ee10c69e7455 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Mon, 4 Nov 2024 11:34:45 +0100 Subject: [PATCH 162/362] chore: resolve conflicts --- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index c67871e4..84f4d431 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "VF5xB5NJEC3cL/T81hpK8iE7hg6N6n/1gM7iVY6h2Cw=", + "shasum": "iB1xejcsUM0CgXQHQWPJwJty3J8rmiZIub+DfOTrsx0=", "location": { "npm": { "filePath": "dist/bundle.js", From 16bbe9a9c255c37de2fa5ea4f0b54a0f2b23dafa Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Wed, 6 Nov 2024 20:00:44 +0800 Subject: [PATCH 163/362] fix: ui paper cuts (#343) * fix: add empty space to align title * fix: btc icon in send amount --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/ui/components/SendFlowHeader.tsx | 5 +++-- .../src/ui/components/SendForm.tsx | 2 +- .../bitcoin-wallet-snap/src/ui/images/bitcoin.svg | 14 ++++++++++++++ .../bitcoin-wallet-snap/src/ui/images/btc.svg | 12 ------------ .../src/ui/images/empty-space.svg | 3 +++ 6 files changed, 22 insertions(+), 16 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/images/bitcoin.svg delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/images/btc.svg create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/images/empty-space.svg diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 84f4d431..3178e07b 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "iB1xejcsUM0CgXQHQWPJwJty3J8rmiZIub+DfOTrsx0=", + "shasum": "vmePCqA6i2v0k+TUWn66ik83antn2ZI9MoyChuecbew=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx index acbbb5eb..08790932 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx @@ -1,6 +1,7 @@ import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; -import { Box, Button, Heading, Icon, Text } from '@metamask/snaps-sdk/jsx'; +import { Box, Button, Heading, Icon, Image } from '@metamask/snaps-sdk/jsx'; +import emptySpace from '../images/empty-space.svg'; import { SendFormNames } from './SendForm'; /** @@ -27,6 +28,6 @@ export const SendFlowHeader: SnapComponent = ({ {heading} - +
); diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx index d8796075..473ed2fc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx @@ -11,7 +11,7 @@ import { } from '@metamask/snaps-sdk/jsx'; import type { SendFlowParams } from '../../stateManagement'; -import btcIcon from '../images/btc-halo.svg'; +import btcIcon from '../images/bitcoin.svg'; import jazzicon3 from '../images/jazzicon3.svg'; import type { AccountWithBalance } from '../types'; import { AssetType } from '../types'; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/images/bitcoin.svg b/merged-packages/bitcoin-wallet-snap/src/ui/images/bitcoin.svg new file mode 100644 index 00000000..a3a355aa --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/images/bitcoin.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/images/btc.svg b/merged-packages/bitcoin-wallet-snap/src/ui/images/btc.svg deleted file mode 100644 index f5889766..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/images/btc.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/images/empty-space.svg b/merged-packages/bitcoin-wallet-snap/src/ui/images/empty-space.svg new file mode 100644 index 00000000..ddd505d0 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/images/empty-space.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 5f2ea0fa865a1e2f165f0094d9e476a681588f1e Mon Sep 17 00:00:00 2001 From: Daniel Rocha <68558152+danroc@users.noreply.github.com> Date: Thu, 7 Nov 2024 11:17:37 +0100 Subject: [PATCH 164/362] chore: add comment explaining empty space for header text alignment (#344) --- .../bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx index 08790932..d540593d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx @@ -28,6 +28,10 @@ export const SendFlowHeader: SnapComponent = ({ {heading} + {/* FIXME: This empty space is needed to center-align the header text. + * The Snap UI centers the text within its container, but the container + * itself is misaligned in the header due to the back arrow. + */} ); From 51a703b2a01e1800d95ee10661f31cc1be9a1a2b Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Tue, 12 Nov 2024 21:21:52 +0800 Subject: [PATCH 165/362] fix: update transaction time to 30 min (#346) --- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/ui/components/TransactionSummary.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 3178e07b..45b72667 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "vmePCqA6i2v0k+TUWn66ik83antn2ZI9MoyChuecbew=", + "shasum": "oHTF4iRO8Irnq6sCRT4GqqLnj2CufU0AfwbGHO7DBM8=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/TransactionSummary.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/TransactionSummary.tsx index c35fcf80..35621264 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/TransactionSummary.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/TransactionSummary.tsx @@ -63,7 +63,7 @@ export const TransactionSummary: SnapComponent = ({ /> - 30m + 30 min Date: Tue, 19 Nov 2024 18:01:21 +0800 Subject: [PATCH 166/362] fix: update incorrect CAIP number for BTC asset (#325) --- .../src/bitcoin/chain/service.test.ts | 12 ++++++------ .../src/bitcoin/chain/service.ts | 8 ++++---- merged-packages/bitcoin-wallet-snap/src/config.ts | 4 ++-- .../bitcoin-wallet-snap/src/constants.ts | 2 +- .../bitcoin-wallet-snap/src/keyring.test.ts | 10 +++++----- .../src/rpcs/__tests__/helper.ts | 10 +++++----- .../src/rpcs/get-rates-and-balances.ts | 4 ++-- .../src/rpcs/start-send-transaction-flow.test.ts | 14 +++++++------- .../bitcoin-wallet-snap/src/ui/utils.ts | 6 +++--- .../bitcoin-wallet-snap/src/utils/rates.ts | 4 ++-- 10 files changed, 37 insertions(+), 37 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts index f251003c..f22e62da 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts @@ -6,7 +6,7 @@ import { generateFormattedUtxos, generateQuickNodeSendRawTransactionResp, } from '../../../test/utils'; -import { Caip2Asset } from '../../constants'; +import { Caip19Asset } from '../../constants'; import { FeeRate, TransactionStatus } from './constants'; import type { IDataClient } from './data-client'; import { BtcOnChainServiceError } from './exceptions'; @@ -73,13 +73,13 @@ describe('BtcOnChainService', () => { }, {}), ); - const result = await txService.getBalances(addresses, [Caip2Asset.TBtc]); + const result = await txService.getBalances(addresses, [Caip19Asset.TBtc]); expect(getBalanceSpy).toHaveBeenCalledWith(addresses); Object.values(result.balances).forEach((assetBalances) => { expect(assetBalances).toStrictEqual({ - [Caip2Asset.TBtc]: { + [Caip19Asset.TBtc]: { amount: BigInt(100), }, }); @@ -93,7 +93,7 @@ describe('BtcOnChainService', () => { const addresses = accounts.map((account) => account.address); await expect( - txService.getBalances(addresses, [Caip2Asset.TBtc, Caip2Asset.Btc]), + txService.getBalances(addresses, [Caip19Asset.TBtc, Caip19Asset.Btc]), ).rejects.toThrow('Only one asset is supported'); }); @@ -104,7 +104,7 @@ describe('BtcOnChainService', () => { const addresses = accounts.map((account) => account.address); await expect( - txService.getBalances(addresses, [Caip2Asset.Btc]), + txService.getBalances(addresses, [Caip19Asset.Btc]), ).rejects.toThrow('Invalid asset'); }); @@ -118,7 +118,7 @@ describe('BtcOnChainService', () => { const addresses = accounts.map((account) => account.address); await expect( - txService.getBalances(addresses, [Caip2Asset.TBtc]), + txService.getBalances(addresses, [Caip19Asset.TBtc]), ).rejects.toThrow('Invalid asset'); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index 9f4544f4..aa848a38 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -1,7 +1,7 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; -import { Caip2Asset } from '../../constants'; +import { Caip19Asset } from '../../constants'; import { compactError } from '../../utils'; import type { FeeRate, TransactionStatus } from './constants'; import type { IDataClient } from './data-client'; @@ -83,12 +83,12 @@ export class BtcOnChainService { throw new BtcOnChainServiceError('Only one asset is supported'); } - const allowedAssets = new Set(Object.values(Caip2Asset)); + const allowedAssets = new Set(Object.values(Caip19Asset)); if ( !allowedAssets.has(assets[0]) || - (this.network === networks.testnet && assets[0] !== Caip2Asset.TBtc) || - (this.network === networks.bitcoin && assets[0] !== Caip2Asset.Btc) + (this.network === networks.testnet && assets[0] !== Caip19Asset.TBtc) || + (this.network === networks.bitcoin && assets[0] !== Caip19Asset.Btc) ) { throw new BtcOnChainServiceError('Invalid asset'); } diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 5a0d21d3..3f1f1785 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -1,5 +1,5 @@ import { FeeRate } from './bitcoin/chain/constants'; -import { Caip2ChainId, Caip2Asset } from './constants'; +import { Caip2ChainId, Caip19Asset } from './constants'; export type SnapChainConfig = { onChainService: { @@ -41,7 +41,7 @@ export const Config: SnapChainConfig = { defaultAccountType: 'bip122:p2wpkh', }, availableNetworks: Object.values(Caip2ChainId), - availableAssets: Object.values(Caip2Asset), + availableAssets: Object.values(Caip19Asset), defaultFeeRate: FeeRate.Medium, unit: 'BTC', explorer: { diff --git a/merged-packages/bitcoin-wallet-snap/src/constants.ts b/merged-packages/bitcoin-wallet-snap/src/constants.ts index 7bb50f1d..b3de4926 100644 --- a/merged-packages/bitcoin-wallet-snap/src/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/src/constants.ts @@ -5,7 +5,7 @@ export enum Caip2ChainId { Testnet = 'bip122:000000000933ea01ad0ee984209779ba', } -export enum Caip2Asset { +export enum Caip19Asset { Btc = 'bip122:000000000019d6689c085ae165831e93/slip44:0', TBtc = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0', } diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts index 7dc03bfa..2630c165 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts @@ -6,7 +6,7 @@ import { v4 as uuidv4 } from 'uuid'; import { generateAccounts } from '../test/utils'; import { BtcAccount, BtcWallet, ScriptType } from './bitcoin/wallet'; import { Config } from './config'; -import { Caip2Asset, Caip2ChainId } from './constants'; +import { Caip19Asset, Caip2ChainId } from './constants'; import { AccountNotFoundError, MethodNotImplementedError } from './exceptions'; import { Factory } from './factory'; import type { CreateAccountOptions } from './keyring'; @@ -356,7 +356,7 @@ describe('BtcKeyring', () => { txId: 'txid', }); getBalanceRpcSpy.mockResolvedValue({ - [Caip2Asset.TBtc]: { + [Caip19Asset.TBtc]: { amount: '1', unit: Config.unit, }, @@ -523,7 +523,7 @@ describe('BtcKeyring', () => { hdPath: sender.hdPath, }); - const assets = [Caip2Asset.TBtc]; + const assets = [Caip19Asset.TBtc]; const expectedResp = assets.reduce((acc, asset) => { acc[asset] = { amount: '1', @@ -534,7 +534,7 @@ describe('BtcKeyring', () => { getBalanceRpcSpy.mockResolvedValue(expectedResp); - await keyring.getAccountBalances(keyringAccount.id, [Caip2Asset.TBtc]); + await keyring.getAccountBalances(keyringAccount.id, [Caip19Asset.TBtc]); expect(getBalanceRpcSpy).toHaveBeenCalledWith(expect.any(BtcAccount), { scope: caip2ChainId, @@ -549,7 +549,7 @@ describe('BtcKeyring', () => { const account = generateAccounts(1)[0]; await expect( - keyring.getAccountBalances(account.id, [Caip2Asset.TBtc]), + keyring.getAccountBalances(account.id, [Caip19Asset.TBtc]), ).rejects.toThrow(Error); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts index 1a458977..b84cbe6e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts @@ -9,7 +9,7 @@ import type { Utxo } from '../../bitcoin/chain'; import { BtcOnChainService } from '../../bitcoin/chain'; import type { BtcAccount, BtcWallet } from '../../bitcoin/wallet'; import { Config } from '../../config'; -import { Caip2Asset } from '../../constants'; +import { Caip19Asset } from '../../constants'; import { Factory } from '../../factory'; import type { SendFlowRequest } from '../../stateManagement'; import { KeyringStateManager } from '../../stateManagement'; @@ -471,10 +471,10 @@ export class StartSendTransactionFlowTest extends SendBitcoinTest { this.getBalancesSpy.mockResolvedValue({ balances: { [this.keyringAccount.address]: { - [Caip2Asset.TBtc]: { + [Caip19Asset.TBtc]: { amount: '100000000', }, - [Caip2Asset.Btc]: { + [Caip19Asset.Btc]: { amount: '100000000', }, }, @@ -488,10 +488,10 @@ export class StartSendTransactionFlowTest extends SendBitcoinTest { }, balances: { value: { - [Caip2Asset.TBtc]: { + [Caip19Asset.TBtc]: { amount: '1', }, - [Caip2Asset.Btc]: { + [Caip19Asset.Btc]: { amount: '1', }, error: '', diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.ts index 4b44c78b..849def62 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.ts @@ -1,10 +1,10 @@ import type { BtcAccount } from '../bitcoin/wallet'; -import type { Caip2Asset } from '../constants'; +import type { Caip19Asset } from '../constants'; import { getRates } from '../utils/rates'; import { getBalances } from './get-balances'; export type GetRatesAndBalancesParams = { - asset: Caip2Asset; + asset: Caip19Asset; scope: string; btcAccount: BtcAccount; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts index 0b14043d..e72fffd8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts @@ -1,4 +1,4 @@ -import { Caip2Asset, Caip2ChainId } from '../constants'; +import { Caip19Asset, Caip2ChainId } from '../constants'; import { AccountNotFoundError, SendFlowRequestNotFoundError, @@ -47,8 +47,8 @@ describe('startSendTransactionFlow', () => { getBalanceAndRatesSpy.mockResolvedValue({ balances: { value: { - [Caip2Asset.Btc]: { amount: '1' }, - [Caip2Asset.TBtc]: { amount: '1' }, + [Caip19Asset.Btc]: { amount: '1' }, + [Caip19Asset.TBtc]: { amount: '1' }, }, error: '', }, @@ -116,8 +116,8 @@ describe('startSendTransactionFlow', () => { getBalanceAndRatesSpy.mockResolvedValue({ balances: { value: { - [Caip2Asset.Btc]: { amount: '1' }, - [Caip2Asset.TBtc]: { amount: '1' }, + [Caip19Asset.Btc]: { amount: '1' }, + [Caip19Asset.TBtc]: { amount: '1' }, }, error: '', }, @@ -189,8 +189,8 @@ describe('startSendTransactionFlow', () => { getBalanceAndRatesSpy.mockResolvedValue({ balances: { value: { - [Caip2Asset.Btc]: { amount: '1' }, - [Caip2Asset.TBtc]: { amount: '1' }, + [Caip19Asset.Btc]: { amount: '1' }, + [Caip19Asset.TBtc]: { amount: '1' }, }, error: '', }, diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts b/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts index 5983ab63..9843b525 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts @@ -4,7 +4,7 @@ import validate, { Network } from 'bitcoin-address-validation'; import { v4 as uuidv4 } from 'uuid'; import { - Caip2Asset, + Caip19Asset, Caip2ChainId, Caip2ChainIdToNetworkName, } from '../constants'; @@ -339,8 +339,8 @@ export async function sendBitcoinParamsToSendFlowParams( * @param scope - The scope of the network (mainnet or testnet). * @returns The asset type corresponding to the scope. */ -export function getAssetTypeFromScope(scope: string): Caip2Asset { - return scope === Caip2ChainId.Mainnet ? Caip2Asset.Btc : Caip2Asset.TBtc; +export function getAssetTypeFromScope(scope: string): Caip19Asset { + return scope === Caip2ChainId.Mainnet ? Caip19Asset.Btc : Caip19Asset.TBtc; } /** diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/rates.ts b/merged-packages/bitcoin-wallet-snap/src/utils/rates.ts index 5d2d393a..0fdd9016 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/rates.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/rates.ts @@ -1,8 +1,8 @@ -import type { Caip2Asset } from '../constants'; +import type { Caip19Asset } from '../constants'; import { CurrencyRatesNotAvailableError } from '../exceptions'; import { getRatesFromMetamask } from './snap'; -export const getRates = async (_asset: Caip2Asset): Promise => { +export const getRates = async (_asset: Caip19Asset): Promise => { // _asset is not used because the only supported asset is 'btc' for now. const ratesResult = await getRatesFromMetamask('btc'); From b1aa214a1498dba0fb6a11121d2c335b9febac1c Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 20 Nov 2024 18:03:02 +0800 Subject: [PATCH 167/362] refactor(QuickNode): support multiple addresses for method `getUtxos` (#299) * chore: remove blockchair * fix: lint * chore: enable get utxos to support multiple address * fix: comment --- .../bitcoin/chain/clients/quicknode.test.ts | 62 +++++++++++-------- .../src/bitcoin/chain/clients/quicknode.ts | 56 ++++++++++------- .../src/bitcoin/chain/data-client.ts | 4 +- .../src/bitcoin/chain/service.test.ts | 6 +- .../src/bitcoin/chain/service.ts | 8 +-- .../src/rpcs/estimate-fee.ts | 2 +- .../src/rpcs/get-max-spendable-balance.ts | 2 +- .../src/rpcs/send-bitcoin.ts | 2 +- 8 files changed, 79 insertions(+), 63 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts index 1d98bc9e..67e17b5c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts @@ -19,6 +19,7 @@ import type { BtcAccount } from '../../wallet'; import { BtcAccountDeriver, BtcWallet } from '../../wallet'; import { TransactionStatus } from '../constants'; import { DataClientError } from '../exceptions'; +import type { Utxo } from '../service'; import { NoFeeRateError, QuickNodeClient } from './quicknode'; import type { QuickNodeEstimateFeeResponse, @@ -81,6 +82,11 @@ describe('QuickNodeClient', () => { }; }; + const createAccountAddresses = async (network: Network, count: number) => { + const { accounts } = await createAccounts(network, count); + return accounts.map((account) => account.address); + }; + const createQuickNodeClient = (network: Network) => { return new MockQuickNodeClient({ network, @@ -245,8 +251,7 @@ describe('QuickNodeClient', () => { it('returns balances', async () => { const { fetchSpy } = createMockFetch(); const network = networks.testnet; - const { accounts } = await createAccounts(network, 5); - const addresses = accounts.map((account) => account.address); + const addresses = await createAccountAddresses(network, 5); const expectedResult = {}; for (const address of addresses) { @@ -269,8 +274,7 @@ describe('QuickNodeClient', () => { // This case should never happen, but to ensure the test is 100% covered, hence we mock the processBatch to not process any request it('assigns 0 balance to the address if it cannot be found in the hashmap', async () => { const network = networks.testnet; - const { accounts } = await createAccounts(network, 5); - const addresses = accounts.map((account) => account.address); + const addresses = await createAccountAddresses(network, 5); jest.spyOn(asyncUtils, 'processBatch').mockReturnThis(); @@ -288,8 +292,7 @@ describe('QuickNodeClient', () => { it('throws DataClientError if the api response is invalid', async () => { const { fetchSpy } = createMockFetch(); const network = networks.testnet; - const { accounts } = await createAccounts(network, 5); - const addresses = accounts.map((account) => account.address); + const addresses = await createAccountAddresses(network, 5); mockErrorResponse({ fetchSpy, @@ -424,36 +427,41 @@ describe('QuickNodeClient', () => { it('returns utxos', async () => { const { fetchSpy } = createMockFetch(); const network = networks.testnet; - const { - accounts: [{ address }], - } = await createAccounts(network, 1); - const mockResponse = generateQuickNodeGetUtxosResp({ - utxosCount: 10, - }); - const expectedResult = mockResponse.result.map((utxo) => ({ - block: utxo.height, - txHash: utxo.txid, - index: utxo.vout, - value: parseInt(utxo.value, 10), - })); + const addresses = await createAccountAddresses(network, 5); + let expectedResult: Utxo[] = []; - mockApiSuccessResponse({ - fetchSpy, - mockResponse, - }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ of addresses) { + const mockResponse = generateQuickNodeGetUtxosResp({ + utxosCount: 10, + }); + + expectedResult = expectedResult.concat( + mockResponse.result.map((utxo) => ({ + block: utxo.height, + txHash: utxo.txid, + index: utxo.vout, + value: parseInt(utxo.value, 10), + })), + ); + + mockApiSuccessResponse({ + fetchSpy, + mockResponse, + }); + } const client = createQuickNodeClient(network); - const result = await client.getUtxos(address); + const result = await client.getUtxos(addresses); expect(result).toStrictEqual(expectedResult); + expect(fetchSpy).toHaveBeenCalledTimes(addresses.length); }); it('throws DataClientError if the api response is invalid', async () => { const { fetchSpy } = createMockFetch(); const network = networks.testnet; - const { - accounts: [{ address }], - } = await createAccounts(network, 1); + const addresses = await createAccountAddresses(network, 1); mockErrorResponse({ fetchSpy, @@ -461,7 +469,7 @@ describe('QuickNodeClient', () => { const client = createQuickNodeClient(network); - await expect(client.getUtxos(address)).rejects.toThrow(DataClientError); + await expect(client.getUtxos(addresses)).rejects.toThrow(DataClientError); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts index fa08abc0..1bb1d1f1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts @@ -24,6 +24,7 @@ import type { DataClientGetFeeRatesResp, } from '../data-client'; import { DataClientError } from '../exceptions'; +import type { Utxo } from '../service'; import type { QuickNodeGetMempoolResponse } from './quicknode.types'; import { type QuickNodeClientOptions, @@ -191,34 +192,41 @@ export class QuickNodeClient extends ApiClient implements IDataClient { } async getUtxos( - address: string, + addresses: string[], includeUnconfirmed?: boolean, ): Promise { - assert(address, BtcP2wpkhAddressStruct); + assert(addresses, array(BtcP2wpkhAddressStruct)); - const response = await this.submitJsonRPCRequest( - { - request: { - method: 'bb_getutxos', - params: [ - address, - { - confirmed: !includeUnconfirmed, - }, - ], - }, - responseStruct: QuickNodeGetUtxosResponseStruct, - }, - ); + const utxos: Utxo[] = []; + + await processBatch(addresses, async (address) => { + const response = + await this.submitJsonRPCRequest({ + request: { + method: 'bb_getutxos', + params: [ + address, + { + confirmed: !includeUnconfirmed, + }, + ], + }, + responseStruct: QuickNodeGetUtxosResponseStruct, + }); + + response.result.forEach((utxo) => { + utxos.push({ + block: utxo.height, + txHash: utxo.txid, + index: utxo.vout, + // `utxo.value` will be returned as sats. + // It is safe to use `number` in Bitcoin rather than `BigInt`, max sats will not exceed 2100000000000000 (which fits in 64 bit). + value: parseInt(utxo.value, 10), + }); + }); + }); - return response.result.map((utxo) => ({ - block: utxo.height, - txHash: utxo.txid, - index: utxo.vout, - // the utxo.value will be return as sats - // it is safe to use number in bitcoin rather than big int, due to max sats will not exceed 2100000000000000 - value: parseInt(utxo.value, 10), - })); + return utxos; } async getFeeRates(): Promise { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts index eeb2982e..e0937be0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts @@ -28,12 +28,12 @@ export type IDataClient = { /** * Gets the UTXOs for a Bitcoin address. * - * @param {string} address - The Bitcoin address to query. + * @param {string[]} addresses - An array of Bitcoin addresses to query. * @param {boolean} [includeUnconfirmed] - Whether or not to include unconfirmed UTXOs in the response. Defaults to false. * @returns {Promise} A promise that resolves to an array of UTXOs. */ getUtxos( - address: string, + addresses: string[], includeUnconfirmed?: boolean, ): Promise; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts index f22e62da..9f067db4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts @@ -133,9 +133,9 @@ describe('BtcOnChainService', () => { getUtxosSpy.mockResolvedValue(utxos); - const result = await txService.getDataForTransaction(sender); + const result = await txService.getDataForTransaction([sender]); - expect(getUtxosSpy).toHaveBeenCalledWith(sender); + expect(getUtxosSpy).toHaveBeenCalledWith([sender]); expect(result).toStrictEqual({ data: { utxos, @@ -151,7 +151,7 @@ describe('BtcOnChainService', () => { getUtxosSpy.mockRejectedValue(new Error('error')); - await expect(txService.getDataForTransaction(sender)).rejects.toThrow( + await expect(txService.getDataForTransaction([sender])).rejects.toThrow( BtcOnChainServiceError, ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index aa848a38..5bf653f9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -148,14 +148,14 @@ export class BtcOnChainService { } /** - * Gets the required metadata to build a transaction for the given address and transaction intent. + * Gets the required metadata to build a transaction for the given addresses and transaction intent. * - * @param address - The address to build the transaction for. + * @param addresses - The addresses to build the transaction for. * @returns A promise that resolves to a `TransactionData` object. */ - async getDataForTransaction(address: string): Promise { + async getDataForTransaction(addresses: string[]): Promise { try { - const data = await this._dataClient.getUtxos(address); + const data = await this._dataClient.getUtxos(addresses); return { data: { utxos: data, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts index edfa45c5..500ddfbe 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts @@ -72,7 +72,7 @@ export async function estimateFee(params: EstimateFeeParams) { const { data: { utxos }, - } = await chainApi.getDataForTransaction(account.address); + } = await chainApi.getDataForTransaction([account.address]); // TODO: change this to use the first address from account when we support multi-addresses per accounts // We do not need the real recipient address when estimating the fees, so we just use our account's address here diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.ts index 42a9612f..994558c8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.ts @@ -79,7 +79,7 @@ export async function getMaxSpendableBalance( const { data: { utxos }, - } = await chainApi.getDataForTransaction(account.address); + } = await chainApi.getDataForTransaction([account.address]); let spendable = BigInt(0); let estimatedFee = BigInt(0); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.ts index 86ba2897..2dfafd91 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.ts @@ -93,7 +93,7 @@ export async function sendBitcoin( const { data: { utxos }, - } = await chainApi.getDataForTransaction(account.address); + } = await chainApi.getDataForTransaction([account.address]); const txResp = await wallet.createTransaction(account, recipients, { utxos, From 818677897fd8a457bad590ede4f02174ea49dfc8 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 20 Nov 2024 22:21:23 +0800 Subject: [PATCH 168/362] refactor(getBalances): simplify the logic in RPC `getBalances` (#336) * chore: refactor get balance RPC * fix: lint * fix: resolve comment --- .../src/bitcoin/chain/data-client.ts | 4 +- .../src/bitcoin/chain/service.test.ts | 65 +++---- .../src/bitcoin/chain/service.ts | 38 ++-- .../src/rpcs/__tests__/helper.ts | 30 +++- .../src/rpcs/get-balances.test.ts | 167 +++++++----------- .../src/rpcs/get-balances.ts | 42 ++--- 6 files changed, 157 insertions(+), 189 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts index e0937be0..5b8d6bd2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts @@ -20,10 +20,10 @@ export type IDataClient = { /** * Gets the balances for a set of Bitcoin addresses. * - * @param {string[]} address - An array of Bitcoin addresses to query. + * @param {string[]} addresses - An array of Bitcoin addresses to query. * @returns {Promise} A promise that resolves to a record of addresses and their corresponding balances. */ - getBalances(address: string[]): Promise; + getBalances(addresses: string[]): Promise; /** * Gets the UTXOs for a Bitcoin address. diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts index 9f067db4..34c40cd6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts @@ -61,14 +61,16 @@ describe('BtcOnChainService', () => { }; describe('getBalance', () => { - it('calls getBalances with readClient', async () => { + it('returns balances', async () => { const { instance, getBalanceSpy } = createMockDataClient(); const { instance: txService } = createMockBtcService(instance); const accounts = generateAccounts(2); const addresses = accounts.map((account) => account.address); + const balanceForEachAddress = 100; + getBalanceSpy.mockResolvedValue( addresses.reduce((acc, address) => { - acc[address] = 100; + acc[address] = balanceForEachAddress; return acc; }, {}), ); @@ -76,13 +78,12 @@ describe('BtcOnChainService', () => { const result = await txService.getBalances(addresses, [Caip19Asset.TBtc]); expect(getBalanceSpy).toHaveBeenCalledWith(addresses); - - Object.values(result.balances).forEach((assetBalances) => { - expect(assetBalances).toStrictEqual({ + expect(result).toStrictEqual({ + balances: { [Caip19Asset.TBtc]: { - amount: BigInt(100), + amount: BigInt(balanceForEachAddress) * BigInt(addresses.length), }, - }); + }, }); }); @@ -97,30 +98,32 @@ describe('BtcOnChainService', () => { ).rejects.toThrow('Only one asset is supported'); }); - it('throws `Invalid asset` error if the BTC asset is given and current network is testnet network', async () => { - const { instance } = createMockDataClient(); - const { instance: txService } = createMockBtcService(instance); - const accounts = generateAccounts(2); - const addresses = accounts.map((account) => account.address); - - await expect( - txService.getBalances(addresses, [Caip19Asset.Btc]), - ).rejects.toThrow('Invalid asset'); - }); - - it('throws `Invalid asset` error if the TBTC asset is given and current network is bitcoin network', async () => { - const { instance } = createMockDataClient(); - const { instance: txService } = createMockBtcService( - instance, - networks.bitcoin, - ); - const accounts = generateAccounts(2); - const addresses = accounts.map((account) => account.address); - - await expect( - txService.getBalances(addresses, [Caip19Asset.TBtc]), - ).rejects.toThrow('Invalid asset'); - }); + it.each([ + { + assetName: 'BTC', + asset: Caip19Asset.Btc, + network: networks.testnet, + networkName: 'testnet', + }, + { + assetName: 'TBTC', + asset: Caip19Asset.TBtc, + network: networks.bitcoin, + networkName: 'mainnet', + }, + ])( + 'throws `Invalid asset` error if the asset is $assetName and current network is $networkName', + async ({ asset, network }) => { + const { instance } = createMockDataClient(); + const { instance: txService } = createMockBtcService(instance, network); + const accounts = generateAccounts(2); + const addresses = accounts.map((account) => account.address); + + await expect(txService.getBalances(addresses, [asset])).rejects.toThrow( + 'Invalid asset', + ); + }, + ); }); describe('getUtxos', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index 5bf653f9..fbce6893 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -17,9 +17,7 @@ export type Balance = { export type AssetBalances = { balances: { - [address: string]: { - [asset: string]: Balance; - }; + [asset: string]: Balance; }; }; @@ -68,7 +66,7 @@ export class BtcOnChainService { } /** - * Gets the balances for multiple addresses and multiple assets. + * Gets the BTC balances from addresses. * * @param addresses - An array of addresses to fetch the balances for. * @param assets - An array of assets to fetch the balances of. @@ -83,29 +81,31 @@ export class BtcOnChainService { throw new BtcOnChainServiceError('Only one asset is supported'); } - const allowedAssets = new Set(Object.values(Caip19Asset)); + const asset = assets[0]; if ( - !allowedAssets.has(assets[0]) || - (this.network === networks.testnet && assets[0] !== Caip19Asset.TBtc) || - (this.network === networks.bitcoin && assets[0] !== Caip19Asset.Btc) + (this.network === networks.testnet && asset !== Caip19Asset.TBtc) || + (this.network === networks.bitcoin && asset !== Caip19Asset.Btc) ) { throw new BtcOnChainServiceError('Invalid asset'); } - const balance = await this._dataClient.getBalances(addresses); + const balances = await this._dataClient.getBalances(addresses); - return addresses.reduce( - (acc: AssetBalances, address: string) => { - acc.balances[address] = { - [assets[0]]: { - amount: BigInt(balance[address]), - }, - }; - return acc; - }, - { balances: {} }, + // Sum up all balances of each addresses (assuming there belonging to the same + // account). + const amount = Object.values(balances).reduce( + (acc: bigint, balance) => acc + BigInt(balance), + BigInt(0), ); + + return { + balances: { + [asset]: { + amount, + }, + }, + }; } catch (error) { throw compactError(error, BtcOnChainServiceError); } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts index b84cbe6e..d52ea6aa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts @@ -98,6 +98,32 @@ export function createMockGetDataForTransactionResp( }; } +/** + * Create a mock wallet. + * + * @param caip2ChainId - The Caip2 Chain ID. + * @returns The `BtcWallet` object. + */ +export function createMockWallet(caip2ChainId: string) { + return Factory.createWallet(caip2ChainId); +} + +/** + * Create a mock sender account. + * + * @param wallet - The `BtcWallet` object. + * @param index - The index of the account to be derived. Default is 0. + * @param type - The type of the account. Default is `Config.wallet.defaultAccountType`. + * @returns The `BtcAccount` object. + */ +export async function createMockSender( + wallet: BtcWallet, + index = 0, + type: string = Config.wallet.defaultAccountType, +) { + return wallet.unlock(index, type); +} + /** * Create a mock `keyringAccount`. * @@ -239,8 +265,8 @@ export class AccountTest { async setup() { const { caip2ChainId } = this.testCase; - this.wallet = Factory.createWallet(caip2ChainId); - this.sender = await this.wallet.unlock(0, Config.wallet.defaultAccountType); + this.wallet = createMockWallet(caip2ChainId); + this.sender = await createMockSender(this.wallet); const { keyringAccount, getWalletSpy } = await createMockKeyringAccount( this.sender, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts index 24945874..1f7445d0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts @@ -1,171 +1,132 @@ -import { BtcMethod, type KeyringAccount } from '@metamask/keyring-api'; import { InvalidParamsError } from '@metamask/snaps-sdk'; -import { networks } from 'bitcoinjs-lib'; -import { v4 as uuidv4 } from 'uuid'; -import { generateAccounts } from '../../test/utils'; -import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; import { Config } from '../config'; -import { Caip2ChainId } from '../constants'; -import { createMockChainApiFactory } from './__tests__/helper'; +import { Caip19Asset, Caip2ChainId } from '../constants'; +import { satsToBtc } from '../utils'; +import { + createMockChainApiFactory, + createMockSender, + createMockWallet, +} from './__tests__/helper'; import { getBalances } from './get-balances'; jest.mock('../utils/logger'); jest.mock('../utils/snap'); describe('getBalances', () => { - const asset = Config.availableAssets[0]; + const tBtc = Caip19Asset.TBtc; + const btc = Caip19Asset.Btc; - const createMockDeriver = (network) => { - const rootSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getRoot'); - const childSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getChild'); + const createMockAccount = async (caip2ChainId: string) => { + const wallet = createMockWallet(caip2ChainId); + const sender = await createMockSender(wallet); return { - instance: new BtcAccountDeriver(network), - rootSpy, - childSpy, + sender, }; }; - const createMockAccount = async (network, caip2ChainId) => { - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const sender = await wallet.unlock(0, Config.wallet.defaultAccountType); - const keyringAccount = { - type: sender.type, - id: uuidv4(), - address: sender.address, - options: { - scope: caip2ChainId, - index: sender.index, + const prepareGetBalances = async () => { + const caip2ChainId = Caip2ChainId.Testnet; + const { getBalancesSpy } = createMockChainApiFactory(); + const { sender } = await createMockAccount(caip2ChainId); + const addresses = [sender.address]; + + const mockGetBalanceResp = { + balances: { + [tBtc]: { + amount: BigInt(100), + }, }, - methods: [`${BtcMethod.SendBitcoin}`], }; - const walletData = { - account: keyringAccount as unknown as KeyringAccount, - hdPath: sender.hdPath, - index: sender.index, - scope: caip2ChainId, - }; + getBalancesSpy.mockResolvedValue(mockGetBalanceResp); return { - keyringAccount, - walletData, + getBalancesSpy, + caip2ChainId, sender, + addresses, + mockGetBalanceResp, }; }; - it('gets balances', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); - - const { walletData, sender } = await createMockAccount( - network, + it('gets the balances', async () => { + const { + getBalancesSpy, caip2ChainId, - ); - - const addresses = [walletData.account.address]; - const mockResp = { - balances: addresses.reduce((acc, address) => { - acc[address] = { - [asset]: { - amount: BigInt(100), - }, - }; - return acc; - }, {}), - }; + addresses, + sender, + mockGetBalanceResp, + } = await prepareGetBalances(); const expected = { - [asset]: { - amount: '0.00000100', + [tBtc]: { + amount: satsToBtc(mockGetBalanceResp.balances[tBtc].amount), unit: Config.unit, }, }; - getBalancesSpy.mockResolvedValue(mockResp); - const result = await getBalances(sender, { - scope: walletData.scope, - assets: [asset], + scope: caip2ChainId, + assets: [tBtc], }); - expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [asset]); + expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [tBtc]); expect(result).toStrictEqual(expected); }); - it('gets balances of the request account only', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); - const accounts = generateAccounts(10); - const { walletData, sender } = await createMockAccount( - network, + it('assign 0 balance if the given asset can not be found from the account', async () => { + const { + getBalancesSpy, caip2ChainId, - ); - - const addresses = [walletData.account.address]; - const mockResp = { - balances: [ - ...addresses, - ...accounts.map((account) => account.address), - ].reduce((acc, address) => { - acc[address] = { - [asset]: { - amount: BigInt(100), - }, - 'some-asset': { - amount: BigInt(100), - }, - }; - return acc; - }, {}), - }; + addresses, + sender, + mockGetBalanceResp, + } = await prepareGetBalances(); + // Getting BTC and tBTC at the same time should never really happen, but + // we have to simulate this case to test the behavior of the function. const expected = { - [asset]: { - amount: '0.00000100', + [tBtc]: { + amount: satsToBtc(mockGetBalanceResp.balances[tBtc].amount), + unit: Config.unit, + }, + [btc]: { + amount: satsToBtc(0), unit: Config.unit, }, }; - getBalancesSpy.mockResolvedValue(mockResp); - const result = await getBalances(sender, { - scope: Caip2ChainId.Testnet, - assets: [asset], + scope: caip2ChainId, + assets: [tBtc, btc], }); - expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [asset]); + expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [tBtc, btc]); expect(result).toStrictEqual(expected); }); it('throws `Fail to get the balances` when transaction status fetch failed', async () => { - const network = networks.testnet; - const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); - const { sender } = await createMockAccount(network, caip2ChainId); + const { getBalancesSpy, caip2ChainId, sender } = await prepareGetBalances(); getBalancesSpy.mockRejectedValue(new Error('error')); await expect( getBalances(sender, { - scope: Caip2ChainId.Testnet, - assets: [asset], + scope: caip2ChainId, + assets: [tBtc], }), ).rejects.toThrow(`Fail to get the balances`); }); it('throws `Request params is invalid` when request parameter is not correct', async () => { - const network = networks.testnet; const caip2ChainId = Caip2ChainId.Testnet; - const { sender } = await createMockAccount(network, caip2ChainId); + const { sender } = await createMockAccount(caip2ChainId); await expect( getBalances(sender, { - scope: Caip2ChainId.Testnet, + scope: caip2ChainId, assets: ['some-asset'], }), ).rejects.toThrow(InvalidParamsError); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts index 80d0a6e4..1aefd2b7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts @@ -52,42 +52,20 @@ export async function getBalances( const chainApi = Factory.createOnChainServiceProvider(scope); const addresses = [account.address]; - const addressesSet = new Set(addresses); - const assetsSet = new Set(assets); const balances = await chainApi.getBalances(addresses, assets); - const balancesVals = Object.entries(balances.balances); - const balancesMap = new Map(); - - for (const [address, assetBalances] of balancesVals) { - if (!addressesSet.has(address)) { - continue; - } - for (const asset in assetBalances) { - if (!assetsSet.has(asset)) { - continue; - } - - const { amount } = assetBalances[asset]; - let currentAmount = balancesMap.get(asset); - if (currentAmount) { - currentAmount += amount; - } - - balancesMap.set(asset, currentAmount ?? amount); - } - } + const resp = {}; + + assets.forEach((asset) => { + // If we cannot find the asset, we fallback to an amount of 0. + const amount = balances.balances[asset]?.amount ?? BigInt(0); - const resp = Object.fromEntries( - [...balancesMap.entries()].map(([asset, amount]) => [ - asset, - { - amount: satsToBtc(amount), - unit: Config.unit, - }, - ]), - ); + resp[asset] = { + amount: satsToBtc(amount), + unit: Config.unit, + }; + }); validateResponse(resp, GetBalancesResponseStruct); From bc623d94516d6c17a5f00020d9080cebf520700d Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 20 Nov 2024 22:32:02 +0800 Subject: [PATCH 169/362] feat(SimpleHash): add `SimpleHash` dataclient (#284) * chore: data client abstraction * chore: update js docs * Update api-client.ts * feat: add simple hash client * chore: update error text * chore: fix test * chore: address PR comment * chore: add comment * chore: update simplehash * chore: update simplehash test * chore: update simplehash comment * chore: fix return type * chore: rebase for main * chore: add reference doc * fix: incorrect name in simplehash test * chore: update test * fix: comment * fix: resolve comment --- .../bitcoin/chain/clients/__tests__/helper.ts | 71 ++++++++ .../bitcoin/chain/clients/simplehash.test.ts | 163 ++++++++++++++++++ .../src/bitcoin/chain/clients/simplehash.ts | 117 +++++++++++++ .../bitcoin/chain/clients/simplehash.types.ts | 22 +++ .../src/bitcoin/chain/data-client.ts | 14 ++ .../test/fixtures/simplehash.json | 12 ++ .../bitcoin-wallet-snap/test/utils.ts | 53 ++++-- 7 files changed, 442 insertions(+), 10 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/__tests__/helper.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.types.ts create mode 100644 merged-packages/bitcoin-wallet-snap/test/fixtures/simplehash.json diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/__tests__/helper.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/__tests__/helper.ts new file mode 100644 index 00000000..7d6cd60f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/__tests__/helper.ts @@ -0,0 +1,71 @@ +import type { Json } from '@metamask/snaps-sdk'; +import type { Network } from 'bitcoinjs-lib'; + +import { Config } from '../../../../config'; +import type { BtcAccount } from '../../../wallet'; +import { BtcAccountDeriver, BtcWallet } from '../../../wallet'; + +export const createMockFetch = (): jest.SpyInstance => { + const fetchSpy = jest.fn(); + // eslint-disable-next-line no-restricted-globals + Object.defineProperty(global, 'fetch', { + // Allow `fetch` to be redefined in the global scope + writable: true, + value: fetchSpy, + }); + + return fetchSpy; +}; + +export const mockErrorResponse = ({ + fetchSpy, + isOk = true, + status = 200, + statusText = 'error', + errorResp = { + result: null, + error: null, + id: null, + }, +}: { + fetchSpy: jest.SpyInstance; + isOk?: boolean; + status?: number; + statusText?: string; + errorResp?: Record; +}): void => { + fetchSpy.mockResolvedValueOnce({ + ok: isOk, + status, + statusText, + json: jest.fn().mockResolvedValue(errorResp), + }); +}; + +export const mockApiSuccessResponse = ({ + fetchSpy, + mockResponse, +}: { + fetchSpy: jest.SpyInstance; + mockResponse: unknown; +}): void => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue(mockResponse), + }); +}; + +export const createAccounts = async ( + network: Network, + count: number, +): Promise => { + const wallet = new BtcWallet(new BtcAccountDeriver(network), network); + + const accounts: BtcAccount[] = []; + for (let i = 0; i < count; i++) { + accounts.push(await wallet.unlock(i, Config.wallet.defaultAccountType)); + } + + return accounts; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.test.ts new file mode 100644 index 00000000..806dd740 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.test.ts @@ -0,0 +1,163 @@ +import { networks } from 'bitcoinjs-lib'; +import { StructError } from 'superstruct'; + +import { generateSimpleHashWalletAssetsByAddressResp } from '../../../../test/utils'; +import type { Utxo } from '../service'; +import { + createMockFetch, + mockApiSuccessResponse, + mockErrorResponse, + createAccounts, +} from './__tests__/helper'; +import { SimpleHashClient } from './simplehash'; +import type { SimpleHashWalletAssetsByUtxoResponse } from './simplehash.types'; + +jest.mock('../../../utils/logger'); +jest.mock('../../../utils/snap'); + +describe('SimpleHashClient', () => { + class MockSimpleHashClient extends SimpleHashClient { + public outputToTxHashAndVout(output: string): [string, number] { + return super.outputToTxHashAndVout(output); + } + } + + const createSimpleHashClient = () => { + return new MockSimpleHashClient({ + apiKey: 'API-KEY', + }); + }; + + const createAccountAddresses = async (count: number) => { + const network = networks.bitcoin; + const accounts = await createAccounts(network, count); + return accounts.map((account) => account.address); + }; + + describe('filterUtxos', () => { + const mockSuccessResponse = ({ + address, + fetchSpy, + }: { + address: string; + fetchSpy: jest.SpyInstance; + }) => { + const mockResponse = generateSimpleHashWalletAssetsByAddressResp( + address, + 10, + ); + + mockApiSuccessResponse({ + fetchSpy, + mockResponse, + }); + + return mockResponse; + }; + + const extractUtxosFromApiResponse = ({ + apiResponse, + client, + }: { + apiResponse: SimpleHashWalletAssetsByUtxoResponse; + client: MockSimpleHashClient; + }) => { + return apiResponse.utxos.map((utxo) => { + const [txHash, vout] = client.outputToTxHashAndVout(utxo.output); + return { + txHash, + index: vout, + value: utxo.value, + block: utxo.block_number, + }; + }); + }; + + it('returns filtered utxos', async () => { + const fetchSpy = createMockFetch(); + const addresses = await createAccountAddresses(5); + const client = createSimpleHashClient(); + + let expectedUtxos: Utxo[] = []; + for (const address of addresses) { + const mockResponse = mockSuccessResponse({ + address, + fetchSpy, + }); + expectedUtxos = expectedUtxos.concat( + extractUtxosFromApiResponse({ apiResponse: mockResponse, client }), + ); + } + + const result = await client.filterUtxos(addresses, []); + + expect(result).toStrictEqual(expectedUtxos); + expect(fetchSpy).toHaveBeenCalledTimes(addresses.length); + }); + + it('deduplicates the query addresses to prevent duplicated UTXOs being returned', async () => { + const fetchSpy = createMockFetch(); + const [address] = await createAccountAddresses(1); + const client = createSimpleHashClient(); + + const mockResponse = mockSuccessResponse({ + address, + fetchSpy, + }); + const expectedUtxos = extractUtxosFromApiResponse({ + apiResponse: mockResponse, + client, + }); + + // Having duplicated addresses to test if the client deduplicates the addresses + const addresses = [address, address, address]; + const result = await client.filterUtxos(addresses, []); + + expect(result).toStrictEqual(expectedUtxos); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('throws superstruct error if any of the given addresses is not a valid bitcoin address', async () => { + const addresses = await createAccountAddresses(1); + const client = createSimpleHashClient(); + + await expect( + client.filterUtxos([...addresses, 'invalid address'], []), + ).rejects.toThrow(StructError); + }); + + it('throws `API response error` if the http status is not 200', async () => { + const fetchSpy = createMockFetch(); + const addresses = await createAccountAddresses(1); + + mockErrorResponse({ + fetchSpy, + status: 500, + }); + + const client = createSimpleHashClient(); + + await expect(client.filterUtxos(addresses, [])).rejects.toThrow( + `API response error`, + ); + }); + + it('throws `Unexpected response from API client` if the API response is unexpected', async () => { + const fetchSpy = createMockFetch(); + const addresses = await createAccountAddresses(1); + + mockApiSuccessResponse({ + fetchSpy, + mockResponse: { + invalidResponse: 'response', + }, + }); + + const client = createSimpleHashClient(); + + await expect(client.filterUtxos(addresses, [])).rejects.toThrow( + `Unexpected response from API client`, + ); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.ts new file mode 100644 index 00000000..553d1648 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.ts @@ -0,0 +1,117 @@ +import { BtcP2wpkhAddressStruct } from '@metamask/keyring-api'; +import { array, assert, type Struct } from 'superstruct'; + +import { processBatch } from '../../../utils'; +import type { HttpHeaders, HttpResponse } from '../api-client'; +import { ApiClient, HttpMethod } from '../api-client'; +import type { ISatsProtectionDataClient } from '../data-client'; +import type { Utxo } from '../service'; +import type { + SimpleHashClientOptions, + SimpleHashWalletAssetsByUtxoResponse, +} from './simplehash.types'; +import { SimpleHashWalletAssetsByUtxoResponseStruct } from './simplehash.types'; + +export class SimpleHashClient + extends ApiClient + implements ISatsProtectionDataClient +{ + readonly apiClientName = 'SimpleHashClient'; + + // Simplehash API does not support testnet, only mainnet is supported. + // reference: https://docs.simplehash.com/reference/supported-chains-testnets + readonly baseUrl = `https://api.simplehash.com/api/v0`; + + protected readonly _options: SimpleHashClientOptions; + + constructor(options: SimpleHashClientOptions) { + super(); + this._options = options; + } + + protected getApiUrl(endpoint: `/${string}`): string { + const url = new URL(`${this.baseUrl}${endpoint}`); + return url.toString(); + } + + protected getHttpHeaders(): HttpHeaders { + return { + 'X-API-KEY': this._options.apiKey, + }; + } + + protected async getResponse( + response: HttpResponse, + ): Promise { + // For successful requests, Simplehash will return a 200 status code. + // Any other status code should be considered an error. + if (response.status !== 200) { + throw new Error(`API response error`); + } + + return await super.getResponse(response); + } + + protected async submitGetApiRequest({ + endpoint, + responseStruct, + requestName, + }: { + endpoint: `/${string}`; + responseStruct: Struct; + requestName: string; + }): Promise { + return await super.submitHttpRequest({ + request: this.buildHttpRequest({ + method: HttpMethod.Get, + url: this.getApiUrl(endpoint), + headers: this.getHttpHeaders(), + }), + responseStruct, + requestName, + }); + } + + // An output is the combination of the transaction hash and the vout, serving as a unique identifier for an UTXO + // e.g 123456789558bd40a14d1cc2f42f5e0476a34ab8589bdc84f65b4eb305b9b925:0 + // Transaction hash is the first part before the colon, and the index/vout is the second part after the colon. + protected outputToTxHashAndVout(output: string): [string, number] { + const [txHash, vout] = output.split(':'); + return [txHash, parseInt(vout, 10)]; + } + + // The API returns UTXOs that does not contain Inscriptions, Rare Sats, and Runes, + // which eliminates the need for UTXO filtering. + // As a result, the argument _utxos will be disregarded, and the UTXOs can be directly returned from this API. + async filterUtxos(addresses: string[], _utxos: Utxo[]): Promise { + // A safeguard to deduplicate the addresses and prevent duplicated UTXOs returned by the API. + const uniqueAddresses = Array.from(new Set(addresses)); + assert(uniqueAddresses, array(BtcP2wpkhAddressStruct)); + + const utxos: Utxo[] = []; + + await processBatch(uniqueAddresses, async (address: string) => { + // API reference: https://docs.simplehash.com/reference/bitcoin_assets_grouped_by_utxo + const result = + await this.submitGetApiRequest({ + endpoint: `/custom/wallet_assets_by_utxo/${address}?without_inscriptions_runes_raresats=1`, + responseStruct: SimpleHashWalletAssetsByUtxoResponseStruct, + requestName: 'wallet_assets_by_utxo', + }); + + for (const utxo of result.utxos) { + const [txHash, vout] = this.outputToTxHashAndVout(utxo.output); + // The UTXO will not be duplicated, + // therefore we are safe to store the UTXO into an array. + utxos.push({ + txHash, + index: vout, + value: utxo.value, + block: utxo.block_number, + }); + } + }); + + return utxos; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.types.ts new file mode 100644 index 00000000..2714eeda --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.types.ts @@ -0,0 +1,22 @@ +import type { Infer } from 'superstruct'; +import { array, number, object, string } from 'superstruct'; + +export type SimpleHashClientOptions = { + apiKey: string; +}; + +export const SimpleHashWalletAssetsByUtxoResponseStruct = object({ + count: number(), + utxos: array( + object({ + output: string(), + value: number(), + // eslint-disable-next-line @typescript-eslint/naming-convention + block_number: number(), + }), + ), +}); + +export type SimpleHashWalletAssetsByUtxoResponse = Infer< + typeof SimpleHashWalletAssetsByUtxoResponseStruct +>; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts index 5b8d6bd2..249096b7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts @@ -60,3 +60,17 @@ export type IDataClient = { */ sendTransaction(tx: string): Promise; }; + +/** + * This interface defines the methods available on a data client for sats protection. + */ +export type ISatsProtectionDataClient = { + /** + * Filters UTXOs that contain Inscriptions, Runes or Rare Sats. + * + * @param {string[]} address - An array of Bitcoin addresses to query. + * @param {Utxo[]} utxos - An array of UTXOs to filter. + * @returns {Promise} A promise that resolves to the filtered UTXOs. + */ + filterUtxos(address: string[], utxos: Utxo[]): Promise; +}; diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/simplehash.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/simplehash.json new file mode 100644 index 00000000..f7e55ec0 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/test/fixtures/simplehash.json @@ -0,0 +1,12 @@ +{ + "walletAssetsByAddress": { + "count": 1, + "utxos": [ + { + "output": "xxxxxxx:1", + "block_number": 999999, + "value": 999999 + } + ] + } +} diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts index f4a2f000..31a5d460 100644 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/test/utils.ts @@ -8,6 +8,7 @@ import { v4 as uuidV4 } from 'uuid'; import { Caip2ChainId } from '../src/constants'; import quickNodeData from './fixtures/quicknode.json'; +import simpleHashData from './fixtures/simplehash.json'; /* eslint-disable */ @@ -325,12 +326,12 @@ export function generateQuickNodeEstimatefeeResp( * @param params.minrelaytxfee - Minimum relay fee in BTC/kB for transactions. * @returns A QuickNode get mempool info response. */ -export function generateQuickNodeMempoolResp( { +export function generateQuickNodeMempoolResp({ mempoolminfee = Math.max(1000, randomNum(10000)), minrelaytxfee, -} : { - mempoolminfee?: number - minrelaytxfee?: number +}: { + mempoolminfee?: number; + minrelaytxfee?: number; }) { const template = quickNodeData.getmempoolinfo; const data = { @@ -358,18 +359,50 @@ export function generateQuickNodeSendRawTransactionResp() { return data; } +/** + * Generate SimpleHash wallet_assets_by_utxo response. + * + * @param count - The number of utxo to generate. + * @returns A SimpleHash wallet_assets_by_utxo response. + */ +export function generateSimpleHashWalletAssetsByAddressResp(address: string, count: number) { + const template = simpleHashData.walletAssetsByAddress; + const utxos = Array.from({ length: count }, (idx: number) => { + return { + output: `${generateTransactionId(Number(address) + idx)}:${randomNum(100)}`, + value: randomNum(1000000), + block_number: randomNum(1000000), + }; + }); + + return { + ...template, + utxos, + count: utxos.length + }; +} + +/** + * Generate a 64 long hex transaction id. + * + * @returns A 64 long hex transaction id. + */ +export function generateTransactionId(id: number) { + return id + .toString(16) + .padStart( + 64, + '0', + ); +} + /** * Generate a random 64 long hex transaction id. * * @returns A 64 long hex transaction id. */ export function generateRandomTransactionId() { - return randomNum(100000000) - .toString(16) - .padStart( - 64, - '0', - ) + return generateTransactionId(randomNum(100000000)); } /** From 4bd20422446a34d87913c08ba0cb1817000cf565 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 26 Nov 2024 17:17:04 +0800 Subject: [PATCH 170/362] feat(Sats-Protection): integrate sats protection with `SimpleHash` (#337) * chore: data client abstraction * chore: update js docs * Update api-client.ts * feat: add simple hash client * chore: update error text * chore: fix test * chore: address PR comment * chore: add comment * chore: update simplehash * chore: update simplehash test * chore: update simplehash comment * chore: fix return type * chore: rebase for main * chore: add reference doc * fix: incorrect name in simplehash test * chore: update test * chore: remove blockchair * fix: lint * chore: enable get utxos to support multiple address * chore: add sats protection * chore: update sats protection implementation * chore: update wallet.ts * chore: set satsProtection option in BtcOnChainService constructor * chore: refactor get balance RPC * chore: add test for sats protection * fix: add snap config * chore: update lint * fix: lint * chore: fix name * fix: resolve comment * fix: add simplehash secret to the CICD pipeline * fix: resolve review comment * chore: add config utils to detect sats protection * chore: lint * fix: config test --- .../bitcoin-wallet-snap/.env.example | 3 + merged-packages/bitcoin-wallet-snap/README.md | 3 + .../bitcoin-wallet-snap/snap.config.ts | 1 + .../src/bitcoin/chain/data-client.ts | 2 +- .../src/bitcoin/chain/service.test.ts | 259 ++++++++++++++---- .../src/bitcoin/chain/service.ts | 71 ++++- .../bitcoin-wallet-snap/src/config.ts | 42 ++- .../bitcoin-wallet-snap/src/factory.test.ts | 40 ++- .../bitcoin-wallet-snap/src/factory.ts | 33 ++- .../src/rpcs/__tests__/helper.ts | 6 + .../src/utils/config.test.ts | 46 ++++ .../bitcoin-wallet-snap/src/utils/config.ts | 16 ++ 12 files changed, 429 insertions(+), 93 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/config.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/config.ts diff --git a/merged-packages/bitcoin-wallet-snap/.env.example b/merged-packages/bitcoin-wallet-snap/.env.example index dfe7ef2e..005b5844 100644 --- a/merged-packages/bitcoin-wallet-snap/.env.example +++ b/merged-packages/bitcoin-wallet-snap/.env.example @@ -9,3 +9,6 @@ QUICKNODE_TESTNET_ENDPOINT= # Description: Environment variables for the Mainnet endpoint of QuickNode # Required: true QUICKNODE_MAINNET_ENDPOINT= +# Description: Environment variables for the SimpleHash API key +# Required: true +SIMPLEHASH_API_KEY= diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index 3f043a65..7a5808ce 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -13,6 +13,9 @@ QUICKNODE_TESTNET_ENDPOINT= # Description: Environment variables for the Mainnet endpoint of QuickNode # Required: true QUICKNODE_MAINNET_ENDPOINT= +# Description: Environment variables for the SimpleHash API key +# Required: true +SIMPLEHASH_API_KEY= ``` ## Rpcs: diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts index 8b7b79ea..b47647e6 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.config.ts +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -15,6 +15,7 @@ const config: SnapConfig = { LOG_LEVEL: process.env.LOG_LEVEL, QUICKNODE_MAINNET_ENDPOINT: process.env.QUICKNODE_MAINNET_ENDPOINT, QUICKNODE_TESTNET_ENDPOINT: process.env.QUICKNODE_TESTNET_ENDPOINT, + SIMPLEHASH_API_KEY: process.env.SIMPLEHASH_API_KEY, /* eslint-disable */ }, polyfills: true, diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts index 249096b7..1beb2e9a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts @@ -62,7 +62,7 @@ export type IDataClient = { }; /** - * This interface defines the methods available on a data client for sats protection. + * This interface defines the methods available on a data client for Sats Protection. */ export type ISatsProtectionDataClient = { /** diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts index 34c40cd6..a1e47142 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts @@ -8,7 +8,7 @@ import { } from '../../../test/utils'; import { Caip19Asset } from '../../constants'; import { FeeRate, TransactionStatus } from './constants'; -import type { IDataClient } from './data-client'; +import type { IDataClient, ISatsProtectionDataClient } from './data-client'; import { BtcOnChainServiceError } from './exceptions'; import { BtcOnChainService } from './service'; @@ -16,14 +16,15 @@ jest.mock('../../utils/logger'); describe('BtcOnChainService', () => { const createMockDataClient = () => { - const getBalanceSpy = jest.fn(); + const getBalancesSpy = jest.fn(); const getUtxosSpy = jest.fn(); const getFeeRatesSpy = jest.fn(); const getTransactionStatusSpy = jest.fn(); const sendTransactionSpy = jest.fn(); + const filterUtxosSpy = jest.fn(); class MockReadDataClient implements IDataClient { - getBalances = getBalanceSpy; + getBalances = getBalancesSpy; getUtxos = getUtxosSpy; @@ -34,9 +35,15 @@ describe('BtcOnChainService', () => { sendTransaction = sendTransactionSpy; } + class MockSatsProtectionClient implements ISatsProtectionDataClient { + filterUtxos = filterUtxosSpy; + } + return { - instance: new MockReadDataClient(), - getBalanceSpy, + dataClient: new MockReadDataClient(), + satsProtectionClient: new MockSatsProtectionClient(), + filterUtxosSpy, + getBalancesSpy, getUtxosSpy, getFeeRatesSpy, getTransactionStatusSpy, @@ -46,55 +53,145 @@ describe('BtcOnChainService', () => { const createMockBtcService = ( dataClient?: IDataClient, + satsProtectionClient?: ISatsProtectionDataClient, network: Network = networks.testnet, ) => { - const instance = new BtcOnChainService( - dataClient ?? createMockDataClient().instance, + const { + dataClient: _dataClient, + satsProtectionClient: _satsProtectionDataClient, + } = createMockDataClient(); + + class MockBtcOnChainService extends BtcOnChainService { + isSatsProtectionEnabled() { + return super.isSatsProtectionEnabled(); + } + } + + const isSatsProtectionEnabledSpy = jest.spyOn( + MockBtcOnChainService.prototype, + 'isSatsProtectionEnabled', + ); + + const service = new MockBtcOnChainService( + { + dataClient: dataClient ?? _dataClient, + satsProtectionDataClient: + satsProtectionClient ?? _satsProtectionDataClient, + }, { network, }, ); return { - instance, + isSatsProtectionEnabledSpy, + service, }; }; - describe('getBalance', () => { - it('returns balances', async () => { - const { instance, getBalanceSpy } = createMockDataClient(); - const { instance: txService } = createMockBtcService(instance); - const accounts = generateAccounts(2); + describe('getBalances', () => { + const prepareGetBalances = ( + network: Network = networks.testnet, + isSatsProtectionEnabled = false, + ) => { + const { + dataClient, + satsProtectionClient, + getBalancesSpy, + filterUtxosSpy, + } = createMockDataClient(); + + const { service, isSatsProtectionEnabledSpy } = createMockBtcService( + dataClient, + satsProtectionClient, + network, + ); + + const accounts = generateAccounts(10); const addresses = accounts.map((account) => account.address); - const balanceForEachAddress = 100; + let totalBalanceFromGetBalances = BigInt(0); + let totalBalanceFromUtxos = BigInt(0); + + isSatsProtectionEnabledSpy.mockReturnValue(isSatsProtectionEnabled); - getBalanceSpy.mockResolvedValue( + getBalancesSpy.mockResolvedValue( addresses.reduce((acc, address) => { - acc[address] = balanceForEachAddress; + totalBalanceFromGetBalances += BigInt(1000); + acc[address] = BigInt(1000); return acc; }, {}), ); - const result = await txService.getBalances(addresses, [Caip19Asset.TBtc]); + // As we are not returning the UTXOs per address, + // we only need to simulate the UTXOs of the first address + const utxos = generateFormattedUtxos(addresses[0], 2, 500, 500); + filterUtxosSpy.mockResolvedValue(utxos); + totalBalanceFromUtxos = utxos.reduce( + (acc, utxo) => acc + BigInt(utxo.value), + BigInt(0), + ); + + return { + service, + addresses, + getBalancesSpy, + filterUtxosSpy, + totalBalanceFromGetBalances, + totalBalanceFromUtxos, + }; + }; + + it('returns total balances if Sats Protection is disabled', async () => { + const { + service, + addresses, + getBalancesSpy, + filterUtxosSpy, + totalBalanceFromGetBalances, + } = prepareGetBalances(networks.testnet, false); + + const result = await service.getBalances(addresses, [Caip19Asset.TBtc]); + + expect(getBalancesSpy).toHaveBeenCalledWith(addresses); + expect(filterUtxosSpy).not.toHaveBeenCalled(); - expect(getBalanceSpy).toHaveBeenCalledWith(addresses); expect(result).toStrictEqual({ balances: { [Caip19Asset.TBtc]: { - amount: BigInt(balanceForEachAddress) * BigInt(addresses.length), + amount: totalBalanceFromGetBalances, + }, + }, + }); + }); + + it('returns sats protected balances if Sats Protection is enabled', async () => { + const { + service, + addresses, + getBalancesSpy, + filterUtxosSpy, + totalBalanceFromUtxos, + } = prepareGetBalances(networks.bitcoin, true); + + const result = await service.getBalances(addresses, [Caip19Asset.Btc]); + + expect(filterUtxosSpy).toHaveBeenCalledWith(addresses, []); + expect(getBalancesSpy).not.toHaveBeenCalled(); + + expect(result).toStrictEqual({ + balances: { + [Caip19Asset.Btc]: { + amount: totalBalanceFromUtxos, }, }, }); }); it('throws `Only one asset is supported` error if the given asset more than 1', async () => { - const { instance } = createMockDataClient(); - const { instance: txService } = createMockBtcService(instance); - const accounts = generateAccounts(2); - const addresses = accounts.map((account) => account.address); + const { service, addresses } = prepareGetBalances(); await expect( - txService.getBalances(addresses, [Caip19Asset.TBtc, Caip19Asset.Btc]), + service.getBalances(addresses, [Caip19Asset.TBtc, Caip19Asset.Btc]), ).rejects.toThrow('Only one asset is supported'); }); @@ -114,12 +211,9 @@ describe('BtcOnChainService', () => { ])( 'throws `Invalid asset` error if the asset is $assetName and current network is $networkName', async ({ asset, network }) => { - const { instance } = createMockDataClient(); - const { instance: txService } = createMockBtcService(instance, network); - const accounts = generateAccounts(2); - const addresses = accounts.map((account) => account.address); + const { service, addresses } = prepareGetBalances(network); - await expect(txService.getBalances(addresses, [asset])).rejects.toThrow( + await expect(service.getBalances(addresses, [asset])).rejects.toThrow( 'Invalid asset', ); }, @@ -127,18 +221,64 @@ describe('BtcOnChainService', () => { }); describe('getUtxos', () => { - it('calls getUtxos with readClient', async () => { - const { instance, getUtxosSpy } = createMockDataClient(); - const { instance: txService } = createMockBtcService(instance); - const accounts = generateAccounts(1); - const sender = accounts[0].address; - const utxos = generateFormattedUtxos(sender, 10); + const prepareGetUtxos = ( + network: Network = networks.testnet, + isSatsProtectionEnabled = false, + ) => { + const { dataClient, satsProtectionClient, getUtxosSpy, filterUtxosSpy } = + createMockDataClient(); + + const { service, isSatsProtectionEnabledSpy } = createMockBtcService( + dataClient, + satsProtectionClient, + network, + ); + + const accounts = generateAccounts(2); + const addresses = accounts.map((account) => account.address); + isSatsProtectionEnabledSpy.mockReturnValue(isSatsProtectionEnabled); + + // As we are not returning the UTXOs per address, + // we only need to simulate the UTXOs of the first address + const utxos = generateFormattedUtxos(addresses[0], 10); getUtxosSpy.mockResolvedValue(utxos); + filterUtxosSpy.mockResolvedValue(utxos); + + return { + service, + addresses, + getUtxosSpy, + filterUtxosSpy, + utxos, + }; + }; - const result = await txService.getDataForTransaction([sender]); + it('returns all UTXOs if Sats Protection is disabled', async () => { + const { service, addresses, getUtxosSpy, filterUtxosSpy, utxos } = + prepareGetUtxos(networks.testnet, false); - expect(getUtxosSpy).toHaveBeenCalledWith([sender]); + const result = await service.getDataForTransaction(addresses); + + expect(getUtxosSpy).toHaveBeenCalledWith(addresses); + expect(filterUtxosSpy).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + data: { + utxos, + }, + }); + }); + + it('returns UTXOs that does not contain Inscriptions, Rare Sats, and Runes if Sats Protection is enabled', async () => { + const { service, addresses, getUtxosSpy, filterUtxosSpy, utxos } = + prepareGetUtxos(networks.bitcoin, true); + + const result = await service.getDataForTransaction(addresses); + + // If Sats Protection is enabled, we are calling `filterUtxos` instead + // of `getUtxos`. + expect(filterUtxosSpy).toHaveBeenCalledWith(addresses, []); + expect(getUtxosSpy).not.toHaveBeenCalled(); expect(result).toStrictEqual({ data: { utxos, @@ -147,14 +287,11 @@ describe('BtcOnChainService', () => { }); it('throws error if readClient fail', async () => { - const { instance, getUtxosSpy } = createMockDataClient(); - const { instance: txService } = createMockBtcService(instance); - const accounts = generateAccounts(1); - const sender = accounts[0].address; + const { service, addresses, getUtxosSpy } = prepareGetUtxos(); getUtxosSpy.mockRejectedValue(new Error('error')); - await expect(txService.getDataForTransaction([sender])).rejects.toThrow( + await expect(service.getDataForTransaction(addresses)).rejects.toThrow( BtcOnChainServiceError, ); }); @@ -162,14 +299,14 @@ describe('BtcOnChainService', () => { describe('getFeeRates', () => { it('return getFeeRates result', async () => { - const { instance, getFeeRatesSpy } = createMockDataClient(); - const { instance: txMgr } = createMockBtcService(instance); + const { dataClient, getFeeRatesSpy } = createMockDataClient(); + const { service } = createMockBtcService(dataClient); getFeeRatesSpy.mockResolvedValue({ [FeeRate.Fast]: 1, [FeeRate.Medium]: 2, }); - const result = await txMgr.getFeeRates(); + const result = await service.getFeeRates(); expect(getFeeRatesSpy).toHaveBeenCalledTimes(1); expect(result).toStrictEqual({ @@ -187,12 +324,14 @@ describe('BtcOnChainService', () => { }); it('throws BtcOnChainServiceError error if another error was thrown', async () => { - const { instance, getFeeRatesSpy } = createMockDataClient(); - const { instance: txMgr } = createMockBtcService(instance); + const { dataClient, getFeeRatesSpy } = createMockDataClient(); + const { service } = createMockBtcService(dataClient); getFeeRatesSpy.mockRejectedValue(new Error('error')); - await expect(txMgr.getFeeRates()).rejects.toThrow(BtcOnChainServiceError); + await expect(service.getFeeRates()).rejects.toThrow( + BtcOnChainServiceError, + ); }); }); @@ -201,13 +340,13 @@ describe('BtcOnChainService', () => { '02000000000101ec81faa8b57add4c8fb3958dd8f04667f5cd829a7b94199f4400be9e52cda0760000000000ffffffff015802000000000000160014f80b562cbcbbfc97727043484c06cc5579963e8402473044022011ec3f7ea7a7cac7cb891a1ea498d94ca3cd082339b9b2620ba5421ca7cbdf3d022062f34411d6aa5335c2bd7ff4c940adb962e9509133b86a2d97996552fd811f2c012102ceea82614fdb14871ef881498c55c5dbdc24b4633d29b42040dd18b4285540f500000000'; it('calls sendTransaction with writeClient', async () => { - const { instance, sendTransactionSpy } = createMockDataClient(); - const { instance: txService } = createMockBtcService(instance); + const { dataClient, sendTransactionSpy } = createMockDataClient(); + const { service } = createMockBtcService(dataClient); const resp = generateQuickNodeSendRawTransactionResp(); sendTransactionSpy.mockResolvedValue(resp.result); - const result = await txService.broadcastTransaction(signedTransaction); + const result = await service.broadcastTransaction(signedTransaction); expect(sendTransactionSpy).toHaveBeenCalledWith(signedTransaction); expect(result).toStrictEqual({ @@ -216,12 +355,12 @@ describe('BtcOnChainService', () => { }); it('throws BtcOnChainServiceErrorr if write client execute fail', async () => { - const { instance, sendTransactionSpy } = createMockDataClient(); - const { instance: txService } = createMockBtcService(instance); + const { dataClient, sendTransactionSpy } = createMockDataClient(); + const { service } = createMockBtcService(dataClient); sendTransactionSpy.mockRejectedValue(new Error('error')); await expect( - txService.broadcastTransaction(signedTransaction), + service.broadcastTransaction(signedTransaction), ).rejects.toThrow(BtcOnChainServiceError); }); }); @@ -231,13 +370,13 @@ describe('BtcOnChainService', () => { '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; it('return getTransactionStatus result', async () => { - const { instance, getTransactionStatusSpy } = createMockDataClient(); - const { instance: txMgr } = createMockBtcService(instance); + const { dataClient, getTransactionStatusSpy } = createMockDataClient(); + const { service } = createMockBtcService(dataClient); getTransactionStatusSpy.mockResolvedValue({ status: TransactionStatus.Confirmed, }); - const result = await txMgr.getTransactionStatus(txHash); + const result = await service.getTransactionStatus(txHash); expect(getTransactionStatusSpy).toHaveBeenCalledWith(txHash); expect(result).toStrictEqual({ @@ -246,12 +385,12 @@ describe('BtcOnChainService', () => { }); it('throws BtcOnChainServiceError error if another error was thrown', async () => { - const { instance, getTransactionStatusSpy } = createMockDataClient(); - const { instance: txMgr } = createMockBtcService(instance); + const { dataClient, getTransactionStatusSpy } = createMockDataClient(); + const { service } = createMockBtcService(dataClient); getTransactionStatusSpy.mockRejectedValue(new Error('error')); - await expect(txMgr.getTransactionStatus(txHash)).rejects.toThrow( + await expect(service.getTransactionStatus(txHash)).rejects.toThrow( BtcOnChainServiceError, ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index fbce6893..b17be62a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -3,8 +3,9 @@ import { networks } from 'bitcoinjs-lib'; import { Caip19Asset } from '../../constants'; import { compactError } from '../../utils'; +import { isSatsProtectionEnabled } from '../../utils/config'; import type { FeeRate, TransactionStatus } from './constants'; -import type { IDataClient } from './data-client'; +import type { IDataClient, ISatsProtectionDataClient } from './data-client'; import { BtcOnChainServiceError } from './exceptions'; export type TransactionStatusData = { @@ -51,13 +52,24 @@ export type BtcOnChainServiceOptions = { network: Network; }; +export type BtcOnChainServiceClients = { + dataClient: IDataClient; + satsProtectionDataClient: ISatsProtectionDataClient; +}; + export class BtcOnChainService { protected readonly _dataClient: IDataClient; + protected readonly _satsProtectionDataClient: ISatsProtectionDataClient; + protected readonly _options: BtcOnChainServiceOptions; - constructor(dataClient: IDataClient, options: BtcOnChainServiceOptions) { + constructor( + { dataClient, satsProtectionDataClient }: BtcOnChainServiceClients, + options: BtcOnChainServiceOptions, + ) { this._dataClient = dataClient; + this._satsProtectionDataClient = satsProtectionDataClient; this._options = options; } @@ -90,19 +102,12 @@ export class BtcOnChainService { throw new BtcOnChainServiceError('Invalid asset'); } - const balances = await this._dataClient.getBalances(addresses); - - // Sum up all balances of each addresses (assuming there belonging to the same - // account). - const amount = Object.values(balances).reduce( - (acc: bigint, balance) => acc + BigInt(balance), - BigInt(0), - ); + const balance = await this.getSpendableBalance(addresses); return { balances: { [asset]: { - amount, + amount: balance, }, }, }; @@ -155,10 +160,9 @@ export class BtcOnChainService { */ async getDataForTransaction(addresses: string[]): Promise { try { - const data = await this._dataClient.getUtxos(addresses); return { data: { - utxos: data, + utxos: await this.getSpendableUtxos(addresses), }, }; } catch (error) { @@ -166,6 +170,47 @@ export class BtcOnChainService { } } + /** + * Get the spendable UTXOs that does not contains Inscription, Runes or Rare Sats. + * + * @param addresses - An array of Bitcoin addresses to query. + * @returns A promise that resolves to the filtered UTXOs. + */ + protected async getSpendableUtxos(addresses: string[]): Promise { + if (this.isSatsProtectionEnabled()) { + // FIXME: SimpleHash provider does return the filtered UTXOs directly, + // so it is not necessary to give the list of UTXOs to filter (hence the `[]`). + // This logic may change if we change our provider. + return await this._satsProtectionDataClient.filterUtxos(addresses, []); + } + return await this._dataClient.getUtxos(addresses); + } + + /** + * Get the spendable balance that does not contain Inscription, Runes or Rare Sats. + * + * @param addresses - An array of Bitcoin addresses to query. + * @returns A promise that resolves to the spendable BTC balance. + */ + protected async getSpendableBalance(addresses: string[]): Promise { + if (this.isSatsProtectionEnabled()) { + // There is no API to get the spendable balance directly, so + // we need to get the spendable UTXOs and sum the values. + const utxos = await this.getSpendableUtxos(addresses); + return utxos.reduce((acc, utxo) => acc + BigInt(utxo.value), BigInt(0)); + } + + const balances = await this._dataClient.getBalances(addresses); + return Object.values(balances).reduce( + (acc, balance) => acc + BigInt(balance), + BigInt(0), + ); + } + + protected isSatsProtectionEnabled(): boolean { + return isSatsProtectionEnabled(this.network); + } + /** * Broadcasts a signed transaction on the blockchain network. * diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 3f1f1785..803c886a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -1,12 +1,24 @@ import { FeeRate } from './bitcoin/chain/constants'; import { Caip2ChainId, Caip19Asset } from './constants'; +export enum ApiClient { + QuickNode = 'QuickNode', + SimpleHash = 'SimpleHash', +} + export type SnapChainConfig = { onChainService: { - dataClient: { - options: { - mainnetEndpoint: string | undefined; - testnetEndpoint: string | undefined; + apiClient: { + [ApiClient.QuickNode]: { + options: { + mainnetEndpoint: string | undefined; + testnetEndpoint: string | undefined; + }; + }; + [ApiClient.SimpleHash]: { + options: { + apiKey: string | undefined; + }; }; }; }; @@ -23,16 +35,25 @@ export type SnapChainConfig = { }; logLevel: string; defaultConfirmationThreshold: number; + defaultSatsProtectionEnabled: boolean; }; export const Config: SnapChainConfig = { onChainService: { - dataClient: { - options: { - // eslint-disable-next-line no-restricted-globals - testnetEndpoint: process.env.QUICKNODE_TESTNET_ENDPOINT, - // eslint-disable-next-line no-restricted-globals - mainnetEndpoint: process.env.QUICKNODE_MAINNET_ENDPOINT, + apiClient: { + [ApiClient.QuickNode]: { + options: { + // eslint-disable-next-line no-restricted-globals + testnetEndpoint: process.env.QUICKNODE_TESTNET_ENDPOINT, + // eslint-disable-next-line no-restricted-globals + mainnetEndpoint: process.env.QUICKNODE_MAINNET_ENDPOINT, + }, + }, + [ApiClient.SimpleHash]: { + options: { + // eslint-disable-next-line no-restricted-globals + apiKey: process.env.SIMPLEHASH_API_KEY, + }, }, }, }, @@ -55,4 +76,5 @@ export const Config: SnapChainConfig = { logLevel: process.env.LOG_LEVEL ?? '0', // the number of confirmations required for a transaction to be considered confirmed defaultConfirmationThreshold: 6, + defaultSatsProtectionEnabled: true, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/factory.test.ts index 04be7996..99ef3215 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.test.ts @@ -1,7 +1,8 @@ import { BtcOnChainService } from './bitcoin/chain'; import { QuickNodeClient } from './bitcoin/chain/clients/quicknode'; +import { SimpleHashClient } from './bitcoin/chain/clients/simplehash'; import { BtcWallet } from './bitcoin/wallet'; -import { Config } from './config'; +import { ApiClient, Config } from './config'; import { Caip2ChainId } from './constants'; import { Factory } from './factory'; @@ -9,6 +10,7 @@ describe('Factory', () => { describe('createOnChainServiceProvider', () => { it('creates BtcOnChainService instance', () => { jest.spyOn(Factory, 'createQuickNodeClient').mockReturnThis(); + jest.spyOn(Factory, 'createSimpleHashClient').mockReturnThis(); const instance = Factory.createOnChainServiceProvider( Caip2ChainId.Testnet, @@ -19,15 +21,18 @@ describe('Factory', () => { }); describe('createQuickNodeClient', () => { + const quickNodeConfig = + Config.onChainService.apiClient[ApiClient.QuickNode]; + afterEach(() => { - Config.onChainService.dataClient.options = { + quickNodeConfig.options = { mainnetEndpoint: undefined, testnetEndpoint: undefined, }; }); it('creates CreateQuickNodeClient instance', () => { - Config.onChainService.dataClient.options = { + quickNodeConfig.options = { mainnetEndpoint: 'http://mainnetEndpoint', testnetEndpoint: 'http://testnetEndpoint', }; @@ -38,7 +43,7 @@ describe('Factory', () => { }); it('throws `QuickNode endpoints have not been configured` error if the endpoints have not been provided', () => { - Config.onChainService.dataClient.options = { + quickNodeConfig.options = { mainnetEndpoint: undefined, testnetEndpoint: undefined, }; @@ -49,6 +54,33 @@ describe('Factory', () => { }); }); + describe('createSimpleHashClient', () => { + const simpleHashConfig = + Config.onChainService.apiClient[ApiClient.SimpleHash]; + + afterEach(() => { + simpleHashConfig.options = { + apiKey: undefined, + }; + }); + + it('creates createSimpleHashClient instance', () => { + simpleHashConfig.options = { + apiKey: 'API_KEY', + }; + + const instance = Factory.createSimpleHashClient(); + + expect(instance).toBeInstanceOf(SimpleHashClient); + }); + + it('throws `Simplehash API key has not been configured` error if the API key has not been provided', () => { + expect(() => Factory.createSimpleHashClient()).toThrow( + 'Simplehash API key has not been configured', + ); + }); + }); + describe('createWallet', () => { it('creates BtcWallet instance', () => { const instance = Factory.createWallet(Caip2ChainId.Testnet); diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts index 02dbfef0..8d1da55b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.ts @@ -1,22 +1,32 @@ import { BtcOnChainService } from './bitcoin/chain'; import { QuickNodeClient } from './bitcoin/chain/clients/quicknode'; +import { SimpleHashClient } from './bitcoin/chain/clients/simplehash'; import { BtcAccountDeriver, BtcWallet, getBtcNetwork } from './bitcoin/wallet'; -import { Config } from './config'; +import { ApiClient, Config } from './config'; export class Factory { static createOnChainServiceProvider(scope: string): BtcOnChainService { const btcNetwork = getBtcNetwork(scope); - return new BtcOnChainService(Factory.createQuickNodeClient(scope), { - network: btcNetwork, - }); + const quickNodeClient = Factory.createQuickNodeClient(scope); + const simpleHashClient = Factory.createSimpleHashClient(); + + return new BtcOnChainService( + { + dataClient: quickNodeClient, + satsProtectionDataClient: simpleHashClient, + }, + { + network: btcNetwork, + }, + ); } static createQuickNodeClient(scope: string): QuickNodeClient { const btcNetwork = getBtcNetwork(scope); const { mainnetEndpoint, testnetEndpoint } = - Config.onChainService.dataClient.options; + Config.onChainService.apiClient[ApiClient.QuickNode].options; if (!mainnetEndpoint || !testnetEndpoint) { throw new Error('QuickNode endpoints have not been configured'); @@ -29,6 +39,19 @@ export class Factory { }); } + static createSimpleHashClient(): SimpleHashClient { + const { apiKey } = + Config.onChainService.apiClient[ApiClient.SimpleHash].options; + + if (!apiKey) { + throw new Error('Simplehash API key has not been configured'); + } + + return new SimpleHashClient({ + apiKey, + }); + } + static createWallet(scope: string): BtcWallet { const btcNetwork = getBtcNetwork(scope); return new BtcWallet(new BtcAccountDeriver(btcNetwork), btcNetwork); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts index d52ea6aa..9304bab4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts @@ -46,11 +46,17 @@ export function createMockChainApiFactory() { ); const createQuickNodeClientSpy = jest.spyOn(Factory, 'createQuickNodeClient'); + const createSimpleHashClientSpy = jest.spyOn( + Factory, + 'createSimpleHashClient', + ); createQuickNodeClientSpy.mockReturnThis(); + createSimpleHashClientSpy.mockReturnThis(); return { createQuickNodeClientSpy, + createSimpleHashClientSpy, getDataForTransactionSpy, broadcastTransactionSpy, getTransactionStatusSpy, diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/config.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/config.test.ts new file mode 100644 index 00000000..e5167c53 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/config.test.ts @@ -0,0 +1,46 @@ +import { networks } from 'bitcoinjs-lib'; + +import { Config } from '../config'; +import { isSatsProtectionEnabled } from './config'; + +describe('isSatsProtectionEnabled', () => { + const { defaultSatsProtectionEnabled } = Config; + + afterEach(() => { + Config.defaultSatsProtectionEnabled = defaultSatsProtectionEnabled; + }); + + it.each([ + { + network: networks.bitcoin, + networkName: 'mainnet', + satsProtection: true, + expected: true, + }, + { + network: networks.bitcoin, + networkName: 'mainnet', + satsProtection: false, + expected: false, + }, + { + network: networks.testnet, + networkName: 'testnet', + satsProtection: true, + expected: false, + }, + { + network: networks.testnet, + networkName: 'testnet', + satsProtection: false, + expected: false, + }, + ])( + 'return $expected when: satsProtection - $satsProtection, network - $networkName', + async ({ network, satsProtection, expected }) => { + Config.defaultSatsProtectionEnabled = satsProtection; + + expect(isSatsProtectionEnabled(network)).toBe(expected); + }, + ); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/config.ts b/merged-packages/bitcoin-wallet-snap/src/utils/config.ts new file mode 100644 index 00000000..ca49e75c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/config.ts @@ -0,0 +1,16 @@ +import type { Network } from 'bitcoinjs-lib'; +import { networks } from 'bitcoinjs-lib'; + +import { Config } from '../config'; + +/** + * Determines if Sats Protection is enabled for the given network. + * + * @param network - The network for which to determine if Sats Protection is enabled. + * @returns `true` if Sats Protection is enabled, `false` otherwise. + */ +export function isSatsProtectionEnabled(network: Network): boolean { + // Safeguard to only allow Sats Protection on mainnet (since SimpleHash + // does not support testnet for this use case). + return Config.defaultSatsProtectionEnabled && network === networks.bitcoin; +} From 96c14f8c6cd2133b43abe7ed6d59d65489df6026 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Wed, 27 Nov 2024 08:20:14 +0800 Subject: [PATCH 171/362] refactor: use update context instead of persisting request (#345) * refactor: use update context instead of persisting request * fix: return early if interface is not yet ready * fix: update manifest * fix: lint * fix: remove unused * fix: use TransactionStatus and update comments * fix: revert name back to updatedSendFlowRequest * fix: add comment about total --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/index.tsx | 10 - .../src/rpcs/__tests__/helper.ts | 13 +- .../rpcs/start-send-transaction-flow.test.ts | 40 +--- .../src/rpcs/start-send-transaction-flow.ts | 53 ++--- .../send-bitcoin-controller.test.ts | 188 +++++++----------- .../ui/controller/send-bitcoin-controller.ts | 148 ++++++-------- .../src/ui/render-interfaces.tsx | 23 ++- .../bitcoin-wallet-snap/src/ui/types.ts | 1 + .../bitcoin-wallet-snap/src/ui/utils.test.ts | 25 ++- .../bitcoin-wallet-snap/src/ui/utils.ts | 49 ++--- 11 files changed, 234 insertions(+), 318 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 45b72667..3428a12f 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "oHTF4iRO8Irnq6sCRT4GqqLnj2CufU0AfwbGHO7DBM8=", + "shasum": "nC8YvZEOXM42HhdVQT4XNe02fICTXfZoXZKsSxcs+48=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/index.tsx b/merged-packages/bitcoin-wallet-snap/src/index.tsx index f09d9e20..ba4f8e07 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/index.tsx @@ -123,23 +123,13 @@ export const onUserInput: OnUserInputHandler = async ({ event, context, }) => { - const { requestId } = context as SendFlowContext; const state = await snap.request({ method: 'snap_getInterfaceState', params: { id }, }); - const stateManager = new KeyringStateManager(true); - const request = await stateManager.getRequest(requestId); - - if (!request) { - throw new Error('Request not found'); - } - if (isSendFormEvent(event)) { const sendBitcoinController = new SendBitcoinController({ - stateManager, - request, context: context as SendFlowContext, interfaceId: id, }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts index 9304bab4..a3667611 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts @@ -12,7 +12,7 @@ import { Config } from '../../config'; import { Caip19Asset } from '../../constants'; import { Factory } from '../../factory'; import type { SendFlowRequest } from '../../stateManagement'; -import { KeyringStateManager } from '../../stateManagement'; +import { KeyringStateManager, TransactionStatus } from '../../stateManagement'; import * as renderInterfaces from '../../ui/render-interfaces'; import * as snapUtils from '../../utils/snap'; import { generateDefaultSendFlowRequest } from '../../utils/transaction'; @@ -499,7 +499,6 @@ export class StartSendTransactionFlowTest extends SendBitcoinTest { this.generateSendFlowSpy.mockResolvedValue(sendFlowRequest); this.updateSendFlowSpy.mockResolvedValue(true); - this.generateConfirmationReviewInterfaceSpy.mockResolvedValue(true); this.getBalancesSpy.mockResolvedValue({ balances: { [this.keyringAccount.address]: { @@ -541,10 +540,16 @@ export class StartSendTransactionFlowTest extends SendBitcoinTest { } async setupGetRequest(sendFlowRequest: SendFlowRequest): Promise { - this.getRequestSpy.mockReset().mockResolvedValue(sendFlowRequest); + this.snapRequestSpy.mockReset().mockResolvedValue(sendFlowRequest); } async rejectSnapRequest(): Promise { - this.snapRequestSpy.mockResolvedValue(false); + this.createSendUIDialogMock.mockResolvedValue({ + status: TransactionStatus.Rejected, + } as SendFlowRequest); + } + + async setupResolvedConfirmationReview(request: SendFlowRequest) { + this.createSendUIDialogMock.mockResolvedValue(request); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts index e72fffd8..1b009090 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts @@ -1,8 +1,5 @@ import { Caip19Asset, Caip2ChainId } from '../constants'; -import { - AccountNotFoundError, - SendFlowRequestNotFoundError, -} from '../exceptions'; +import { AccountNotFoundError } from '../exceptions'; import { TransactionStatus } from '../stateManagement'; import { AssetType } from '../ui/types'; import { generateSendBitcoinParams } from '../ui/utils'; @@ -94,7 +91,7 @@ describe('startSendTransactionFlow', () => { error: '', }, }; - await helper.setupGetRequest(mockRequestWithCorrectValues); + await helper.setupResolvedConfirmationReview(mockRequestWithCorrectValues); const transactionTx = await startSendTransactionFlow({ account: keyringAccount.id, @@ -102,7 +99,7 @@ describe('startSendTransactionFlow', () => { }); expect(helper.generateSendFlowSpy).toHaveBeenCalledTimes(1); - expect(helper.upsertRequestSpy).toHaveBeenCalledTimes(4); + expect(helper.upsertRequestSpy).toHaveBeenCalledTimes(1); expect(transactionTx).toStrictEqual({ txId: helper.broadCastTxResp, }); @@ -110,9 +107,7 @@ describe('startSendTransactionFlow', () => { it('throws an error when the user rejects the transaction', async () => { const helper = await prepareStartSendTransactionFlow(mockScope); - const { keyringAccount, getBalanceAndRatesSpy, createSendUIDialogMock } = - helper; - createSendUIDialogMock.mockResolvedValue(false); + const { keyringAccount, getBalanceAndRatesSpy } = helper; getBalanceAndRatesSpy.mockResolvedValue({ balances: { value: { @@ -182,31 +177,4 @@ describe('startSendTransactionFlow', () => { }), ).rejects.toThrow(AccountNotFoundError); }); - - it('throws an error when send flow request is not found', async () => { - const helper = await prepareStartSendTransactionFlow(mockScope); - const { keyringAccount, getBalanceAndRatesSpy } = helper; - getBalanceAndRatesSpy.mockResolvedValue({ - balances: { - value: { - [Caip19Asset.Btc]: { amount: '1' }, - [Caip19Asset.TBtc]: { amount: '1' }, - }, - error: '', - }, - rates: { - value: 62000, - error: '', - }, - }); - // @ts-expect-error - We are testing the error case - await helper.setupGetRequest(null); - - await expect( - startSendTransactionFlow({ - account: keyringAccount.id, - scope: mockScope, - }), - ).rejects.toThrow(SendFlowRequestNotFoundError); - }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts index 13a2f36e..59adafd6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts @@ -3,12 +3,9 @@ import { enums, nonempty, object, string } from 'superstruct'; import { TxValidationError } from '../bitcoin/wallet'; import { Caip2ChainId } from '../constants'; -import { - AccountNotFoundError, - isSnapException, - SendFlowRequestNotFoundError, -} from '../exceptions'; +import { AccountNotFoundError, isSnapException } from '../exceptions'; import { Factory } from '../factory'; +import type { SendFlowRequest } from '../stateManagement'; import { KeyringStateManager, TransactionStatus } from '../stateManagement'; import { generateSendFlow, updateSendFlow } from '../ui/render-interfaces'; import { @@ -72,8 +69,6 @@ export async function startSendTransactionFlow( scope, }); - await stateManager.upsertRequest(sendFlowRequest); - // This will be awaited later on the flow in order to display the UI as soon as possible. // If we don't, then the UI will be displayed after the balances and rates call are finished. const sendFlowPromise = createSendUIDialog(sendFlowRequest.interfaceId); @@ -101,7 +96,6 @@ export async function startSendTransactionFlow( sendFlowRequest.balance.amount = balances.value; sendFlowRequest.balance.fiat = btcToFiat(balances.value, rates.value); sendFlowRequest.rates = rates.value; - await stateManager.upsertRequest(sendFlowRequest); await updateSendFlow({ request: { @@ -109,45 +103,28 @@ export async function startSendTransactionFlow( }, }); - const sendFlowResult = await sendFlowPromise; + // The dialog resolves into the send flow request that has been confirmed by the user + const updatedSendFlowRequest = (await sendFlowPromise) as SendFlowRequest; - if (!sendFlowResult) { - await stateManager.removeRequest(sendFlowRequest.id); + if (updatedSendFlowRequest.status === TransactionStatus.Rejected) { throw new UserRejectedRequestError() as unknown as Error; } - // Get the latest send flow request from the state manager - // this has been updated via onInputHandler - const updatedSendFlowRequest = await stateManager.getRequest( - sendFlowRequest.id, - ); - - if (!updatedSendFlowRequest) { - throw new SendFlowRequestNotFoundError(); - } - const sendBitcoinParams = generateSendBitcoinParams( walletData.scope, updatedSendFlowRequest, ); - sendFlowRequest.transaction = sendBitcoinParams; - sendFlowRequest.status = TransactionStatus.Confirmed; - await stateManager.upsertRequest(sendFlowRequest); - - let tx; - try { - tx = await sendBitcoin(btcAccount, scope, { - ...sendFlowRequest.transaction, - scope, - }); - - sendFlowRequest.txId = tx.txId; - } catch (error) { - await stateManager.removeRequest(sendFlowRequest.id); - throw error; - } + updatedSendFlowRequest.transaction = sendBitcoinParams; + updatedSendFlowRequest.status = TransactionStatus.Confirmed; + + const tx = await sendBitcoin(btcAccount, scope, { + ...updatedSendFlowRequest.transaction, + scope, + }); + + updatedSendFlowRequest.txId = tx.txId; - await stateManager.upsertRequest(sendFlowRequest); + await stateManager.upsertRequest(updatedSendFlowRequest); return tx; } catch (error) { logger.error('Failed to start send transaction flow', error); diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts index f5a63647..15e2db1d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts @@ -7,11 +7,12 @@ import { v4 as uuidv4 } from 'uuid'; import { Caip2ChainId } from '../../constants'; import { estimateFee, getMaxSpendableBalance } from '../../rpcs'; -import { KeyringStateManager, TransactionStatus } from '../../stateManagement'; +import type { SendFlowRequest } from '../../stateManagement'; +import { TransactionStatus } from '../../stateManagement'; import { generateDefaultSendFlowRequest } from '../../utils/transaction'; import { SendFormNames } from '../components/SendForm'; import { updateSendFlow } from '../render-interfaces'; -import type { SendFlowContext, SendFormState } from '../types'; +import type { SendFormState } from '../types'; import { AssetType } from '../types'; import { SendBitcoinController, @@ -50,21 +51,12 @@ const mockAccount = { methods: [`${BtcMethod.SendBitcoin}`], }; -const mockContext: SendFlowContext = { - accounts: [{ id: 'account1' } as KeyringAccount], - scope: mockScope, - requestId: mockRequestId, -}; - -const createMockStateManager = () => { - const stateManager = new KeyringStateManager(); - const upsertRequestSpy = jest - .spyOn(stateManager, 'upsertRequest') - .mockResolvedValue(undefined); - +const createMockContext = (request: SendFlowRequest) => { return { - instance: stateManager, - upsertRequestSpy, + accounts: [{ id: 'account1' } as KeyringAccount], + scope: mockScope, + requestId: mockRequestId, + request, }; }; @@ -192,12 +184,9 @@ describe('SendBitcoinController', () => { accountSelector: '', }; - const { instance: stateManager, upsertRequestSpy } = - createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); @@ -211,7 +200,6 @@ describe('SendBitcoinController', () => { }, }; - expect(upsertRequestSpy).toHaveBeenCalledWith(expectedRequest); expect(updateSendFlow).toHaveBeenCalledWith({ request: expectedRequest, }); @@ -236,11 +224,9 @@ describe('SendBitcoinController', () => { accountSelector: '', }; - const { instance: stateManager } = createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); @@ -268,11 +254,9 @@ describe('SendBitcoinController', () => { accountSelector: '', }; - const { instance: stateManager } = createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); @@ -306,11 +290,9 @@ describe('SendBitcoinController', () => { ); mockRequest.status = TransactionStatus.Review; - const { instance: stateManager } = createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); @@ -319,7 +301,7 @@ describe('SendBitcoinController', () => { mockContext, mockFormState, ); - expect(controller.request).toStrictEqual({ + expect(controller.context.request).toStrictEqual({ ...mockRequest, recipient: { address: mockAddress, @@ -328,7 +310,7 @@ describe('SendBitcoinController', () => { }, }); expect(updateSendFlow).toHaveBeenCalledWith({ - request: controller.request, + request: controller.context.request, }); }); @@ -346,12 +328,9 @@ describe('SendBitcoinController', () => { mockInterfaceId, ); mockRequest.status = TransactionStatus.Review; - - const { instance: stateManager } = createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); @@ -360,7 +339,7 @@ describe('SendBitcoinController', () => { mockContext, mockFormState, ); - expect(controller.request).toStrictEqual({ + expect(controller.context.request).toStrictEqual({ ...mockRequest, recipient: { address: mockAddress, @@ -369,7 +348,7 @@ describe('SendBitcoinController', () => { }, }); expect(updateSendFlow).toHaveBeenCalledWith({ - request: controller.request, + request: controller.context.request, }); }); }); @@ -389,12 +368,9 @@ describe('SendBitcoinController', () => { mockInterfaceId, ); mockRequest.selectedCurrency = AssetType.BTC; - - const { instance: stateManager } = createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); @@ -409,12 +385,12 @@ describe('SendBitcoinController', () => { mockFormState, ); - expect(controller.request.amount.amount).toBe(mockAmount); - expect(controller.request.amount.fiat).toBeDefined(); - expect(controller.request.fees.amount).toBe('0.0001'); - expect(controller.request.total.amount).toBeDefined(); + expect(controller.context.request.amount.amount).toBe(mockAmount); + expect(controller.context.request.amount.fiat).toBeDefined(); + expect(controller.context.request.fees.amount).toBe('0.0001'); + expect(controller.context.request.total.amount).toBeDefined(); expect(updateSendFlow).toHaveBeenCalledWith({ - request: controller.request, + request: controller.context.request, }); }); @@ -433,12 +409,9 @@ describe('SendBitcoinController', () => { ); mockRequest.selectedCurrency = AssetType.FIAT; mockRequest.rates = '60000'; - - const { instance: stateManager } = createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); @@ -453,12 +426,12 @@ describe('SendBitcoinController', () => { mockFormState, ); - expect(controller.request.amount.amount).toBe('0.00166667'); - expect(controller.request.amount.fiat).toBe(mockAmount); - expect(controller.request.fees.amount).toBe('0.0001'); - expect(controller.request.total.amount).toBeDefined(); + expect(controller.context.request.amount.amount).toBe('0.00166667'); + expect(controller.context.request.amount.fiat).toBe(mockAmount); + expect(controller.context.request.fees.amount).toBe('0.0001'); + expect(controller.context.request.total.amount).toBeDefined(); expect(updateSendFlow).toHaveBeenCalledWith({ - request: controller.request, + request: controller.context.request, }); }); @@ -475,12 +448,9 @@ describe('SendBitcoinController', () => { mockRequestId, mockInterfaceId, ); - - const { instance: stateManager } = createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); @@ -491,9 +461,9 @@ describe('SendBitcoinController', () => { mockFormState, ); - expect(controller.request.amount.valid).toBe(false); + expect(controller.context.request.amount.valid).toBe(false); expect(updateSendFlow).toHaveBeenCalledWith({ - request: controller.request, + request: controller.context.request, }); }); @@ -511,12 +481,9 @@ describe('SendBitcoinController', () => { mockInterfaceId, ); mockRequest.selectedCurrency = AssetType.BTC; - - const { instance: stateManager } = createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); @@ -531,9 +498,11 @@ describe('SendBitcoinController', () => { mockFormState, ); - expect(controller.request.fees.error).toBe('Fee estimation error'); + expect(controller.context.request.fees.error).toBe( + 'Fee estimation error', + ); expect(updateSendFlow).toHaveBeenCalledWith({ - request: controller.request, + request: controller.context.request, }); }); }); @@ -552,18 +521,15 @@ describe('SendBitcoinController', () => { mockInterfaceId, ); mockRequest.status = TransactionStatus.Review; - - const { instance: stateManager } = createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); await controller.handleButtonEvent(SendFormNames.HeaderBack); - expect(controller.request.status).toBe(TransactionStatus.Draft); - expect(controller.request).toStrictEqual({ + expect(controller.context.request.status).toBe(TransactionStatus.Draft); + expect(controller.context.request).toStrictEqual({ ...mockRequest, status: TransactionStatus.Draft, }); @@ -578,18 +544,17 @@ describe('SendBitcoinController', () => { mockInterfaceId, ); mockRequest.status = TransactionStatus.Draft; - - const { instance: stateManager } = createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); await controller.handleButtonEvent(SendFormNames.HeaderBack); - expect(controller.request.status).toBe(TransactionStatus.Rejected); - expect(controller.request).toStrictEqual({ + expect(controller.context.request.status).toBe( + TransactionStatus.Rejected, + ); + expect(controller.context.request).toStrictEqual({ ...mockRequest, status: TransactionStatus.Rejected, }); @@ -597,7 +562,7 @@ describe('SendBitcoinController', () => { method: 'snap_resolveInterface', params: { id: controller.interfaceId, - value: false, + value: controller.context.request, }, }); }); @@ -610,16 +575,14 @@ describe('SendBitcoinController', () => { mockInterfaceId, ); mockRequest.recipient.address = 'address'; - const { instance: stateManager } = createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); await controller.handleButtonEvent(SendFormNames.Clear); - expect(controller.request.recipient.address).toBe(''); + expect(controller.context.request.recipient.address).toBe(''); }); it('should handle "Cancel" button event', async () => { @@ -629,16 +592,16 @@ describe('SendBitcoinController', () => { mockRequestId, mockInterfaceId, ); - const { instance: stateManager } = createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); await controller.handleButtonEvent(SendFormNames.Cancel); - expect(controller.request.status).toBe(TransactionStatus.Rejected); + expect(controller.context.request.status).toBe( + TransactionStatus.Rejected, + ); }); it('should handle "SwapCurrencyDisplay" button event', async () => { @@ -648,11 +611,9 @@ describe('SendBitcoinController', () => { mockRequestId, mockInterfaceId, ); - const { instance: stateManager } = createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); @@ -661,8 +622,7 @@ describe('SendBitcoinController', () => { ...mockRequest, selectedCurrency: AssetType.FIAT, }; - expect(controller.request.selectedCurrency).toBe(AssetType.FIAT); - expect(stateManager.upsertRequest).toHaveBeenCalledWith(expectedResult); + expect(controller.context.request.selectedCurrency).toBe(AssetType.FIAT); expect(updateSendFlow).toHaveBeenCalledWith({ request: expectedResult, flushToAddress: false, @@ -677,11 +637,9 @@ describe('SendBitcoinController', () => { mockRequestId, mockInterfaceId, ); - const { instance: stateManager } = createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); @@ -690,8 +648,7 @@ describe('SendBitcoinController', () => { ...mockRequest, status: TransactionStatus.Review, }; - expect(controller.request.status).toBe(TransactionStatus.Review); - expect(stateManager.upsertRequest).toHaveBeenCalledWith(expectedResult); + expect(controller.context.request.status).toBe(TransactionStatus.Review); expect(mockDisplayConfirmationReview).toHaveBeenCalledWith({ request: expectedResult, }); @@ -705,11 +662,9 @@ describe('SendBitcoinController', () => { mockInterfaceId, ); mockRequest.status = TransactionStatus.Review; - const { instance: stateManager } = createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); @@ -718,13 +673,12 @@ describe('SendBitcoinController', () => { ...mockRequest, status: TransactionStatus.Signed, }; - expect(controller.request.status).toBe(TransactionStatus.Signed); - expect(stateManager.upsertRequest).toHaveBeenCalledWith(expectedResult); + expect(controller.context.request.status).toBe(TransactionStatus.Signed); expect(snap.request).toHaveBeenCalledWith({ method: 'snap_resolveInterface', params: { id: expectedResult.interfaceId, - value: true, + value: controller.context.request, }, }); }); @@ -736,11 +690,9 @@ describe('SendBitcoinController', () => { mockRequestId, mockInterfaceId, ); - const { instance: stateManager } = createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); @@ -750,26 +702,25 @@ describe('SendBitcoinController', () => { fee: { amount: '0.0001' }, }; - jest.spyOn(controller, 'persistRequest').mockResolvedValue(undefined); (getMaxSpendableBalance as jest.Mock).mockResolvedValue( mockMaxSpendableBalance, ); await controller.handleButtonEvent(SendFormNames.SetMax); - expect(controller.request.amount.amount).toBe( + expect(controller.context.request.amount.amount).toBe( mockMaxSpendableBalance.balance.amount, ); - expect(controller.request.fees.amount).toBe( + expect(controller.context.request.fees.amount).toBe( mockMaxSpendableBalance.fee.amount, ); - expect(controller.request.total.amount).toBe( + expect(controller.context.request.total.amount).toBe( new BigNumber(mockMaxSpendableBalance.balance.amount) .plus(new BigNumber(mockMaxSpendableBalance.fee.amount)) .toString(), ); expect(updateSendFlow).toHaveBeenCalledWith({ - request: controller.request, + request: controller.context.request, currencySwitched: true, }); }); @@ -781,28 +732,25 @@ describe('SendBitcoinController', () => { mockRequestId, mockInterfaceId, ); - const { instance: stateManager } = createMockStateManager(); + const mockContext = createMockContext(mockRequest); const controller = new SendBitcoinController({ - stateManager, - request: mockRequest, context: mockContext, interfaceId: mockInterfaceId, }); - jest.spyOn(controller, 'persistRequest').mockResolvedValue(undefined); (getMaxSpendableBalance as jest.Mock).mockRejectedValue( new Error('Error fetching max amount'), ); await controller.handleButtonEvent(SendFormNames.SetMax); - expect(controller.request.amount.error).toBe( + expect(controller.context.request.amount.error).toBe( 'Error fetching max amount: Error fetching max amount', ); - expect(controller.request.fees.loading).toBe(false); + expect(controller.context.request.fees.loading).toBe(false); expect(updateSendFlow).toHaveBeenCalledWith({ - request: controller.request, + request: controller.context.request, currencySwitched: true, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.ts b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.ts index 7b6fb5b6..b2db07c7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.ts @@ -20,36 +20,21 @@ export const isSendFormEvent = (event: UserInputEvent): boolean => { export class SendBitcoinController { protected stateManager: KeyringStateManager; - request: SendFlowRequest; - context: SendFlowContext; interfaceId: string; constructor({ - stateManager, - request, context, interfaceId, }: { - stateManager: KeyringStateManager; - request: SendFlowRequest; context: SendFlowContext; interfaceId: string; }) { - this.stateManager = stateManager; - this.request = request; this.context = context; this.interfaceId = interfaceId; } - async persistRequest(request: Partial) { - await this.stateManager.upsertRequest({ - ...this.request, - ...request, - }); - } - async handleEvent( event: UserInputEvent, context: SendFlowContext, @@ -82,75 +67,82 @@ export class SendBitcoinController { context: SendFlowContext, formState: SendFormState, ): Promise { - formValidation(formState, context, this.request); + // If there isn't an interfaceId, return early because the interface is not ready. + if (!this.context.request.interfaceId) { + return; + } + + formValidation(formState, context, this.context.request); switch (eventName) { case SendFormNames.To: { - this.request.recipient.address = formState.to; - this.request.recipient.valid = Boolean(!this.request.recipient.error); - await this.persistRequest(this.request); + this.context.request.recipient.address = formState.to; + this.context.request.recipient.valid = Boolean( + !this.context.request.recipient.error, + ); await updateSendFlow({ - request: this.request, + request: this.context.request, }); break; } case SendFormNames.Amount: { - if (this.request.amount.error) { + if (this.context.request.amount.error) { await updateSendFlow({ - request: this.request, + request: this.context.request, }); return; } - this.request.amount.valid = Boolean(!this.request.amount.error); - this.request.fees.loading = true; + this.context.request.amount.valid = Boolean( + !this.context.request.amount.error, + ); + this.context.request.fees.loading = true; // show loading state for fees await updateSendFlow({ - request: this.request, + request: this.context.request, }); - if (this.request.selectedCurrency === AssetType.BTC) { - this.request.amount.amount = formState.amount; - this.request.amount.fiat = btcToFiat( + if (this.context.request.selectedCurrency === AssetType.BTC) { + this.context.request.amount.amount = formState.amount; + this.context.request.amount.fiat = btcToFiat( formState.amount, - this.request.rates, + this.context.request.rates, ); } else { - this.request.amount.fiat = formState.amount; - this.request.amount.amount = fiatToBtc( + this.context.request.amount.fiat = formState.amount; + this.context.request.amount.amount = fiatToBtc( formState.amount, - this.request.rates, + this.context.request.rates, ); } try { const estimates = await estimateFee({ account: this.context.accounts[0].id, - amount: this.request.amount.amount, + amount: this.context.request.amount.amount, }); - this.request.fees = { - fiat: btcToFiat(estimates.fee.amount, this.request.rates), + this.context.request.fees = { + fiat: btcToFiat(estimates.fee.amount, this.context.request.rates), amount: estimates.fee.amount, loading: false, error: '', }; - this.request.total = validateTotal( - this.request.amount.amount, + this.context.request.total = validateTotal( + this.context.request.amount.amount, estimates.fee.amount, - this.request.balance.amount, - this.request.rates, + this.context.request.balance.amount, + this.context.request.rates, ); } catch (feeError) { - this.request.fees = { + this.context.request.fees = { fiat: '', amount: '', loading: false, error: feeError.message, }; } - await this.persistRequest(this.request); await updateSendFlow({ - request: this.request, + request: this.context.request, }); break; } @@ -162,67 +154,60 @@ export class SendBitcoinController { async handleButtonEvent(eventName: SendFormNames): Promise { switch (eventName) { case SendFormNames.HeaderBack: { - if (this.request.status === TransactionStatus.Review) { - this.request.status = TransactionStatus.Draft; - await this.persistRequest(this.request); + if (this.context.request.status === TransactionStatus.Review) { + this.context.request.status = TransactionStatus.Draft; return await updateSendFlow({ - request: this.request, + request: this.context.request, flushToAddress: false, backEventTriggered: true, }); - } else if (this.request.status === TransactionStatus.Draft) { - this.request.status = TransactionStatus.Rejected; - await this.persistRequest(this.request); - return await this.resolveInterface(false); + } else if (this.context.request.status === TransactionStatus.Draft) { + this.context.request.status = TransactionStatus.Rejected; + return await this.resolveInterface(this.context.request); } throw new Error('Invalid state'); } case SendFormNames.Clear: - this.request.recipient = { + this.context.request.recipient = { address: '', error: '', valid: false, }; - await this.persistRequest(this.request); return await updateSendFlow({ - request: this.request, + request: this.context.request, flushToAddress: true, }); case SendFormNames.Cancel: case SendFormNames.Close: { - this.request.status = TransactionStatus.Rejected; - await this.persistRequest(this.request); - await this.resolveInterface(false); + this.context.request.status = TransactionStatus.Rejected; + await this.resolveInterface(this.context.request); return null; } case SendFormNames.SwapCurrencyDisplay: { - this.request.selectedCurrency = - this.request.selectedCurrency === AssetType.BTC + this.context.request.selectedCurrency = + this.context.request.selectedCurrency === AssetType.BTC ? AssetType.FIAT : AssetType.BTC; - await this.persistRequest(this.request); return await updateSendFlow({ - request: this.request, + request: this.context.request, flushToAddress: false, currencySwitched: true, }); } case SendFormNames.Review: { - this.request.status = TransactionStatus.Review; - await this.persistRequest(this.request); - await displayConfirmationReview({ request: this.request }); + this.context.request.status = TransactionStatus.Review; + await displayConfirmationReview({ request: this.context.request }); return null; } case SendFormNames.Send: { - this.request.status = TransactionStatus.Signed; - await this.persistRequest(this.request); - await this.resolveInterface(true); + this.context.request.status = TransactionStatus.Signed; + await this.resolveInterface(this.context.request); return null; } case SendFormNames.SetMax: { - this.request.fees.loading = true; + this.context.request.fees.loading = true; await updateSendFlow({ - request: this.request, + request: this.context.request, }); return await this.handleSetMax(); } @@ -231,7 +216,7 @@ export class SendBitcoinController { } } - async resolveInterface(value: boolean): Promise { + async resolveInterface(value: SendFlowRequest): Promise { await snap.request({ method: 'snap_resolveInterface', params: { @@ -247,38 +232,37 @@ export class SendBitcoinController { account: this.context.accounts[0].id, }); if (new BigNumber(maxAmount.balance.amount).lte(new BigNumber(0))) { - this.request.amount.error = 'Fees exceed max sendable amount'; - this.request.fees.loading = false; + this.context.request.amount.error = 'Fees exceed max sendable amount'; + this.context.request.fees.loading = false; } else { - this.request.amount = { + this.context.request.amount = { amount: maxAmount.balance.amount, - fiat: btcToFiat(maxAmount.balance.amount, this.request.rates), + fiat: btcToFiat(maxAmount.balance.amount, this.context.request.rates), error: '', valid: true, }; - this.request.fees = { + this.context.request.fees = { amount: maxAmount.fee.amount, - fiat: btcToFiat(maxAmount.fee.amount, this.request.rates), + fiat: btcToFiat(maxAmount.fee.amount, this.context.request.rates), loading: false, error: '', }; - this.request.total = validateTotal( + this.context.request.total = validateTotal( maxAmount.balance.amount, maxAmount.fee.amount, - this.request.balance.amount, - this.request.rates, + this.context.request.balance.amount, + this.context.request.rates, ); } } catch (error) { - this.request.amount.error = `Error fetching max amount: ${ + this.context.request.amount.error = `Error fetching max amount: ${ error.message as string }`; - this.request.fees.loading = false; + this.context.request.fees.loading = false; } - await this.persistRequest(this.request); return await updateSendFlow({ - request: this.request, + request: this.context.request, currencySwitched: true, }); } diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx index f69bb0db..effde643 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx @@ -1,6 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; -import type { SendFlowRequest } from '../stateManagement'; +import { TransactionStatus, type SendFlowRequest } from '../stateManagement'; import { generateDefaultSendFlowParams, generateDefaultSendFlowRequest, @@ -37,6 +37,15 @@ export async function generateSendFlow({ requestId, accounts: [account], scope, + request: { + id: requestId, + interfaceId: '', // to be set in the next update + account, + scope, + transaction: {}, + status: TransactionStatus.Draft, + ...sendFlowProps, + }, }, }, }); @@ -79,6 +88,12 @@ export async function updateSendFlow({ backEventTriggered={backEventTriggered} /> ), + context: { + requestId: request.id, + accounts: [request.account], + scope: request.scope, + request, + }, }, }); } @@ -120,6 +135,12 @@ export async function displayConfirmationReview({ params: { id: request.interfaceId, ui: , + context: { + requestId: request.id, + accounts: [request.account], + scope: request.scope, + request, + }, }, }); } diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/types.ts b/merged-packages/bitcoin-wallet-snap/src/ui/types.ts index 3b0c9c3b..bd228f06 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/types.ts @@ -62,6 +62,7 @@ export type SendFlowContext = { accounts: AccountWithBalance[]; scope: string; requestId: string; + request: SendFlowRequest; }; export type AccountWithBalance = KeyringAccount & { balance?: Currency }; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts b/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts index 10d4cefc..f3b9348b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts @@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid'; import { Caip2ChainId, Caip2ChainIdToNetworkName } from '../constants'; import type { SendBitcoinParams } from '../rpcs'; +import { TransactionStatus } from '../stateManagement'; import { generateDefaultSendFlowRequest } from '../utils/transaction'; import { AssetType, SendFormError } from './types'; import { @@ -392,11 +393,22 @@ describe('utils', () => { it('should validate form correctly with valid data', () => { const formState = { + accountSelector: 'testAccount', amount: '0.1', to: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', }; const request = { - total: { amount: '', fiat: '' }, + id: 'test-id', + interfaceId: 'test-interface-id', + account: mockAccount, + scope: Caip2ChainId.Mainnet, + status: TransactionStatus.Draft, + transaction: { + recipients: {}, + replaceable: true, + dryrun: false, + }, + total: { amount: '', fiat: '', error: '', valid: false }, recipient: { address: '', error: '', valid: false }, amount: { amount: '', fiat: '', error: '', valid: false }, fees: { amount: '', fiat: '', loading: false, error: '' }, @@ -404,10 +416,14 @@ describe('utils', () => { rates, balance: { amount: balance, fiat: '62000.00' }, }; - // @ts-expect-error test only request params and not the whole object - const result = formValidation(formState, context, request); + const result = formValidation( + formState, + { ...context, request, accounts: [mockAccount], requestId: 'test-id' }, + request, + ); expect(result).toStrictEqual({ + ...request, recipient: { address: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', error: '', @@ -423,9 +439,12 @@ describe('utils', () => { selectedCurrency: AssetType.BTC, rates, balance: { amount: balance, fiat: '62000.00' }, + // We are only validating the inputs here. total: { amount: '', fiat: '', + error: '', + valid: false, }, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts b/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts index 9843b525..22e1855a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts @@ -30,32 +30,35 @@ export function formValidation( context: SendFlowContext, request: SendFlowRequest, ): SendFlowRequest { - // reset errors - request.amount.error = ''; - request.recipient.error = ''; - request.fees.error = ''; + // We only validate the values that have changed + // If we validate all the values on every change there can be a race condition. - const formAmount = formState.amount ?? '0'; - const cryptoAmount = - request.selectedCurrency === AssetType.BTC - ? formAmount - : fiatToBtc(formAmount, request.rates); + const { amount, to } = formState; - request.recipient = validateRecipient(formState.to, context.scope); - request.amount = validateAmount( - cryptoAmount, - request.balance.amount, - request.rates, - ); + if (amount !== request.amount.amount) { + const formAmount = formState.amount ?? '0'; + const cryptoAmount = + request.selectedCurrency === AssetType.BTC + ? formAmount + : fiatToBtc(formAmount, request.rates); + request.amount = validateAmount( + cryptoAmount, + request.balance.amount, + request.rates, + ); + // Reset the fees if the amount is invalid + if (request.amount.error) { + request.fees = { + amount: '', + fiat: '', + loading: false, + error: '', + }; + } + } - // Reset the fees if the amount is invalid - if (request.amount.error) { - request.fees = { - amount: '', - fiat: '', - loading: false, - error: '', - }; + if (to !== request.recipient.address) { + request.recipient = validateRecipient(to, context.scope); } return request; From 9350bf74452bcaf85771fe4ce0782e8d4ee919de Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 28 Nov 2024 00:16:32 +0800 Subject: [PATCH 172/362] chore: add sats protection tooltip UI (#349) * chore: data client abstraction * chore: update js docs * Update api-client.ts * feat: add simple hash client * chore: update error text * chore: fix test * chore: address PR comment * chore: add comment * chore: update simplehash * chore: update simplehash test * chore: update simplehash comment * chore: fix return type * chore: rebase for main * chore: add reference doc * fix: incorrect name in simplehash test * chore: update test * chore: remove blockchair * fix: lint * chore: enable get utxos to support multiple address * chore: add sats protection * chore: update sats protection implementation * chore: update wallet.ts * chore: set satsProtection option in BtcOnChainService constructor * chore: refactor get balance RPC * chore: add test for sats protection * fix: add snap config * chore: update lint * fix: lint * chore: fix name * fix: resolve comment * fix: add simplehash secret to the CICD pipeline * fix: resolve review comment * chore: add config utils to detect sats protection * chore: add Sats Protections Tooltip * chore: lint * chore: add component description * chore: update var name comment * fix: config test * fix: lint --- .../src/stateManagement.ts | 1 + .../ui/components/SatsProtectionToolTip.tsx | 26 +++++++++++++++++++ .../src/ui/components/SendForm.tsx | 14 +++++++++- .../src/ui/render-interfaces.tsx | 3 +-- .../bitcoin-wallet-snap/src/ui/utils.test.ts | 1 + .../bitcoin-wallet-snap/src/ui/utils.ts | 3 +-- .../src/utils/transaction.ts | 8 +++--- 7 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/SatsProtectionToolTip.tsx diff --git a/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts b/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts index b4b79fdb..233f9c0f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts +++ b/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts @@ -35,6 +35,7 @@ export type BaseRequestState = { }; export type SendFlowParams = { + scope: string; selectedCurrency: AssetType; recipient: { address: string; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SatsProtectionToolTip.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SatsProtectionToolTip.tsx new file mode 100644 index 00000000..0a4fd130 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/SatsProtectionToolTip.tsx @@ -0,0 +1,26 @@ +import { + Icon, + Text, + Tooltip, + type SnapComponent, +} from '@metamask/snaps-sdk/jsx'; + +/** + * A component that shows a tooltip for SATs protection. + * + * @returns The SatsProtectionToolTip component. + */ +export const SatsProtectionToolTip: SnapComponent = () => { + return ( + + MetaMask is protecting your Ordinials, Rare SATs, and Runes to be send + in Bitcoin Transactions. + + } + > + + + ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx index 473ed2fc..ab8f3164 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx @@ -10,12 +10,15 @@ import { type SnapComponent, } from '@metamask/snaps-sdk/jsx'; +import { getBtcNetwork } from '../../bitcoin/wallet'; import type { SendFlowParams } from '../../stateManagement'; +import { isSatsProtectionEnabled } from '../../utils/config'; import btcIcon from '../images/bitcoin.svg'; import jazzicon3 from '../images/jazzicon3.svg'; import type { AccountWithBalance } from '../types'; import { AssetType } from '../types'; import { AccountSelector as AccountSelectorComponent } from './AccountSelector'; +import { SatsProtectionToolTip } from './SatsProtectionToolTip'; export enum SendFormNames { Amount = 'amount', @@ -51,6 +54,7 @@ export type SendFormProps = { flushToAddress?: boolean; currencySwitched: boolean; backEventTriggered: boolean; + scope: string; }; const getAmountFrom = ( @@ -74,6 +78,7 @@ const getAmountFrom = ( * @param props.total - The total amount including fees. * @param props.currencySwitched - Whether the currency display has been switched. * @param props.backEventTriggered - Whether the back event has been triggered. + * @param props.scope - The CAIP-2 Chain ID. * @returns The SendForm component. */ export const SendForm: SnapComponent = ({ @@ -87,6 +92,7 @@ export const SendForm: SnapComponent = ({ total, currencySwitched, backEventTriggered, + scope, }) => { const showRecipientError = recipient.address.length > 0 && !recipient.error; const amountToDisplay = @@ -134,8 +140,14 @@ export const SendForm: SnapComponent = ({ alignment={balance.fiat ? 'space-between' : 'end'} > {Boolean(balance.fiat) && ( - {`Balance: $${balance.fiat.toLocaleLowerCase()}`} + + {`Balance: $${balance.fiat.toLocaleLowerCase()}`} + {Boolean(isSatsProtectionEnabled(getBtcNetwork(scope))) && ( + + )} + )} + diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx index effde643..8fcd6845 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx @@ -21,7 +21,7 @@ export async function generateSendFlow({ scope, }: GenerateSendFlowParams): Promise { const requestId = uuidv4(); - const sendFlowProps = generateDefaultSendFlowParams(); + const sendFlowProps = generateDefaultSendFlowParams(scope); const interfaceId = await snap.request({ method: 'snap_createInterface', params: { @@ -41,7 +41,6 @@ export async function generateSendFlow({ id: requestId, interfaceId: '', // to be set in the next update account, - scope, transaction: {}, status: TransactionStatus.Draft, ...sendFlowProps, diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts b/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts index f3b9348b..9a786c7e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts @@ -285,6 +285,7 @@ describe('utils', () => { error: '', }, selectedCurrency: AssetType.BTC, + scope, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts b/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts index 22e1855a..ead574cd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts @@ -262,7 +262,6 @@ export async function generateSendFlowRequest( const sendFlowRequest = { id: uuidv4(), account: wallet.account, - scope: wallet.scope, transaction: sendBitcoinParams, interfaceId: '', status: status ?? TransactionStatus.Draft, @@ -301,7 +300,7 @@ export async function sendBitcoinParamsToSendFlowParams( rates: string, balance: string, ): Promise { - const defaultParams = generateDefaultSendFlowParams(); + const defaultParams = generateDefaultSendFlowParams(scope); // This is safe because we validate the recipient in `validateRecipient` if it is not defined. const recipient = Object.keys(params.recipients)[0]; const amount = params.recipients[recipient]; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/transaction.ts b/merged-packages/bitcoin-wallet-snap/src/utils/transaction.ts index 04d56d07..0b8aa905 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/transaction.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/transaction.ts @@ -5,7 +5,9 @@ import { TransactionStatus, type SendFlowParams } from '../stateManagement'; import { AssetType } from '../ui/types'; import { generateSendBitcoinParams } from '../ui/utils'; -export const generateDefaultSendFlowParams = (): SendFlowParams => { +export const generateDefaultSendFlowParams = ( + scope: string, +): SendFlowParams => { return { selectedCurrency: AssetType.BTC, recipient: { @@ -36,6 +38,7 @@ export const generateDefaultSendFlowParams = (): SendFlowParams => { error: '', valid: false, }, + scope, }; }; @@ -49,10 +52,9 @@ export const generateDefaultSendFlowRequest = ( id: requestId, interfaceId, account, - scope, transaction: generateSendBitcoinParams(scope), status: TransactionStatus.Draft, // Send flow params - ...generateDefaultSendFlowParams(), + ...generateDefaultSendFlowParams(scope), }; }; From e8ab521bea11173280571f7d5d89602b79f4c6a6 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Thu, 28 Nov 2024 21:55:12 +0800 Subject: [PATCH 173/362] fix: allow send to happen even when rates are not available (#350) * fix: allow send to happen even when rates are not available. * fix: update manifest * fix: total amount * fix: create new helper * fix: handle empty amount * fix: balance string * refactor: rename function * fix: lint * fix: trim white spaces for displayEmptyStringIfAmountNotAvailableOrEmptyAmount * Update packages/snap/src/rpcs/get-rates-and-balances.ts Co-authored-by: Charly Chevalier --------- Co-authored-by: Charly Chevalier --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/keyring.ts | 6 +- .../src/rpcs/get-rates-and-balances.test.ts | 4 -- .../src/rpcs/get-rates-and-balances.ts | 8 +-- .../src/rpcs/start-send-transaction-flow.ts | 4 -- .../src/ui/components/AccountSelector.tsx | 8 ++- .../src/ui/components/ReviewTransaction.tsx | 29 +++++++-- .../src/ui/components/SendForm.tsx | 42 +++++++----- .../src/ui/components/TransactionSummary.tsx | 7 +- .../bitcoin-wallet-snap/src/ui/utils.ts | 65 +++++++++++++++---- 10 files changed, 123 insertions(+), 52 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 3428a12f..09e4f96e 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "nC8YvZEOXM42HhdVQT4XNe02fICTXfZoXZKsSxcs+48=", + "shasum": "ALHwGzPZjMcLBlH/71NmQD9f8ArOCKFXoqliS/FvYS0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index 160c50c6..3ace1af3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -315,10 +315,8 @@ export class BtcKeyring implements Keyring { btcAccount: account, }); - if (rates.error || balances.error) { - throw new Error( - `Error fetching rates and balances: ${rates.error ?? balances.error}`, - ); + if (balances.error) { + throw new Error(`Error fetching balances: ${balances.error}`); } const sendFlowRequest = await generateSendFlowRequest( diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.test.ts index 8ba8a498..9d36ed34 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.test.ts @@ -36,7 +36,6 @@ describe('getRatesAndBalances', () => { expect(result).toStrictEqual({ rates: { value: 'mockRates', - error: '', }, balances: { value: 'mockBalance', @@ -56,7 +55,6 @@ describe('getRatesAndBalances', () => { expect(result).toStrictEqual({ rates: { value: undefined, - error: 'Rates error: Rates error', }, balances: { value: 'mockBalance', @@ -74,7 +72,6 @@ describe('getRatesAndBalances', () => { expect(result).toStrictEqual({ rates: { value: 'mockRates', - error: '', }, balances: { value: undefined, @@ -92,7 +89,6 @@ describe('getRatesAndBalances', () => { expect(result).toStrictEqual({ rates: { value: undefined, - error: 'Rates error: Rates error', }, balances: { value: undefined, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.ts index 849def62..0152f31d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.ts @@ -24,7 +24,6 @@ export async function createRatesAndBalances({ btcAccount, }: GetRatesAndBalancesParams) { const errors = { - rates: '', balances: '', }; let rates; @@ -36,9 +35,9 @@ export async function createRatesAndBalances({ ]); if (ratesResult.status === 'fulfilled') { - rates = ratesResult.value; - } else { - errors.rates = `Rates error: ${ratesResult.reason.message as string}`; + // Rates are "optional" here (hence the ''). If they are not available, no conversion + // will be done. + rates = ratesResult.value ?? ''; } if (balancesResult.status === 'fulfilled') { @@ -56,7 +55,6 @@ export async function createRatesAndBalances({ return { rates: { value: rates, - error: errors.rates, }, balances: { value: balances, diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts index 59adafd6..1ceb5ef6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts @@ -81,10 +81,6 @@ export async function startSendTransactionFlow( const errors: string[] = []; - if (rates.error) { - errors.push(rates.error); - } - if (balances.error) { errors.push(balances.error); } diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/AccountSelector.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/AccountSelector.tsx index 502e26e0..688abaeb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/AccountSelector.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/AccountSelector.tsx @@ -10,6 +10,7 @@ import { import { shortenAddress } from '../../utils'; import jazzicon1 from '../images/jazzicon1.svg'; import type { Currency } from '../types'; +import { displayEmptyStringIfAmountNotAvailableOrEmptyAmount } from '../utils'; /** * The props for the {@link AccountSelector} component. @@ -58,7 +59,12 @@ export const AccountSelector: SnapComponent = ({ : loadingMessage } extra={ - balance?.amount ? `$${balance.fiat.toString()}` : loadingMessage + balance?.fiat + ? `${displayEmptyStringIfAmountNotAvailableOrEmptyAmount( + balance.fiat, + '$', + )}` + : loadingMessage } title={'Bitcoin Account'} /> diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/ReviewTransaction.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/ReviewTransaction.tsx index 413e7423..bb6ca229 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/ReviewTransaction.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/ReviewTransaction.tsx @@ -18,7 +18,10 @@ import type { CaipAccountId } from '@metamask/utils'; import { BaseExplorerUrl, Caip2ChainId } from '../../constants'; import type { SendFlowRequest } from '../../stateManagement'; import btcIcon from '../images/btc-halo.svg'; -import { getNetworkNameFromScope } from '../utils'; +import { + displayEmptyStringIfAmountNotAvailableOrEmptyAmount, + getNetworkNameFromScope, +} from '../utils'; import { SendFlowHeader } from './SendFlowHeader'; import { SendFormNames } from './SendForm'; @@ -69,7 +72,13 @@ export const ReviewTransaction: SnapComponent = ({ - + @@ -92,10 +101,22 @@ export const ReviewTransaction: SnapComponent = ({ {txSpeed} - + - + {Boolean(recipient.error) && ( diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx index ab8f3164..0aac9c24 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx @@ -17,6 +17,7 @@ import btcIcon from '../images/bitcoin.svg'; import jazzicon3 from '../images/jazzicon3.svg'; import type { AccountWithBalance } from '../types'; import { AssetType } from '../types'; +import { amountNotAvailable } from '../utils'; import { AccountSelector as AccountSelectorComponent } from './AccountSelector'; import { SatsProtectionToolTip } from './SatsProtectionToolTip'; @@ -51,6 +52,7 @@ export type SendFormProps = { selectedCurrency: SendFlowParams['selectedCurrency']; recipient: SendFlowParams['recipient']; total: SendFlowParams['total']; + rates: SendFlowParams['rates']; flushToAddress?: boolean; currencySwitched: boolean; backEventTriggered: boolean; @@ -77,6 +79,7 @@ const getAmountFrom = ( * @param props.recipient - The recipient details including address and validation status. * @param props.total - The total amount including fees. * @param props.currencySwitched - Whether the currency display has been switched. + * @param props.rates - The exchange rates for the selected currency. * @param props.backEventTriggered - Whether the back event has been triggered. * @param props.scope - The CAIP-2 Chain ID. * @returns The SendForm component. @@ -90,6 +93,7 @@ export const SendForm: SnapComponent = ({ amount, recipient, total, + rates, currencySwitched, backEventTriggered, scope, @@ -107,6 +111,9 @@ export const SendForm: SnapComponent = ({ addressToDisplay = ''; } + // Fiat might not be available if rates are still loading or cannot be fetched. + const fiatNotAvailable = amountNotAvailable(balance.fiat); + return (
= ({ placeholder="Enter amount to send" value={amountToDisplay} /> - - - {selectedCurrency === AssetType.FIAT ? 'USD' : selectedCurrency} - - - + {Boolean(rates) && ( + + + {selectedCurrency === AssetType.FIAT ? 'USD' : selectedCurrency} + + + + )} - {Boolean(balance.fiat) && ( - - {`Balance: $${balance.fiat.toLocaleLowerCase()}`} - {Boolean(isSatsProtectionEnabled(getBtcNetwork(scope))) && ( - - )} - - )} + + + {`Balance: + ${fiatNotAvailable ? `${balance.amount} BTC` : `$${balance.fiat}`}`} + + {Boolean(isSatsProtectionEnabled(getBtcNetwork(scope))) && ( + + )} +
); }; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/TransactionSummary.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/TransactionSummary.tsx index c81e409c..0edb34c9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/TransactionSummary.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/ui/components/TransactionSummary.tsx @@ -9,6 +9,7 @@ import { } from '@metamask/snaps-sdk/jsx'; import type { SendFlowRequest } from '../../stateManagement'; +import { getTranslator } from '../../utils/locale'; import { displayEmptyStringIfAmountNotAvailableOrEmptyAmount } from '../utils'; /** @@ -34,12 +35,14 @@ export const TransactionSummary: SnapComponent = ({ fees, total, }) => { + const t = getTranslator(); + if (fees.loading) { return (
- Preparing transaction + {t('preparingTransaction')}
); @@ -48,7 +51,7 @@ export const TransactionSummary: SnapComponent = ({ if (fees.error) { return (
- + {fees.error}
@@ -57,16 +60,16 @@ export const TransactionSummary: SnapComponent = ({ return (
- + - - 30 min + + {t('estimatedTransactionSpeed')} - + { }; const result = formValidation( formState, - { ...context, request, accounts: [mockAccount], requestId: 'test-id' }, + { + ...context, + request, + accounts: [mockAccount], + requestId: 'test-id', + }, request, ); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/locale.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/locale.test.ts new file mode 100644 index 00000000..78d39ab6 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/locale.test.ts @@ -0,0 +1,63 @@ +/* eslint-disable no-restricted-globals */ +import { + loadLocale, + getUserLocale, + getTranslator, + getUserLocalePreference, +} from './locale'; + +const mockSnapRequest = jest.fn(); + +jest.mock('../../locales/en.json', () => ({ + messages: { greeting: { message: 'Hello' } }, +})); + +(global as any).snap = { + request: mockSnapRequest.mockResolvedValue({ locale: 'en' }), +}; + +describe('locale utils', () => { + describe('getUserLocale', () => { + it("returns the locale messages for the user's preferred locale", async () => { + const locale = await getUserLocalePreference(); + expect(locale).toStrictEqual({ greeting: { message: 'Hello' } }); + }); + + it("returns the default locale messages if the user's preferred locale is not available", async () => { + mockSnapRequest.mockRejectedValue(new Error('Locale not found')); + + const locale = await getUserLocalePreference(); + expect(locale).toStrictEqual({ greeting: { message: 'Hello' } }); + }); + }); + + describe('loadLocale', () => { + it('loads and set the user locale', async () => { + await loadLocale(); + const locale = getUserLocale(); + expect(locale).toStrictEqual({ greeting: { message: 'Hello' } }); + }); + + it('loads and set the default locale if user locale is not available', async () => { + mockSnapRequest.mockRejectedValue(new Error('Locale not found')); + + await loadLocale(); + const locale = getUserLocale(); + expect(locale).toStrictEqual({ greeting: { message: 'Hello' } }); + }); + }); + + describe('getTranslator', () => { + it('translates keys to user locale messages', async () => { + await loadLocale(); + const translate = getTranslator(); + expect(translate('greeting')).toBe('Hello'); + }); + + it('returns the key wrapped in curly braces if translation is not found', async () => { + await loadLocale(); + const translate = getTranslator(); + expect(translate('nonexistent')).toBe('{nonexistent}'); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/locale.ts b/merged-packages/bitcoin-wallet-snap/src/utils/locale.ts new file mode 100644 index 00000000..b9d51ab5 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/locale.ts @@ -0,0 +1,59 @@ +import { acquireLock } from './lock'; + +const localeSetterLock = acquireLock(true); +let locale: Locale; + +/** + * Loads the user's locale preferences and sets the locale variable. + */ +export async function loadLocale() { + await localeSetterLock.runExclusive(async () => { + locale = await getUserLocalePreference(); + }); +} + +/** + * Retrieves the current user locale. + * + * @returns The current user locale. + */ +export function getUserLocale() { + return locale; +} + +export type Locale = Record< + string, + { + message: string; + } +>; + +/** + * Retrieves the user's locale preferences. + * + * @returns A promise that resolves to the user's locale messages. + */ +export async function getUserLocalePreference(): Promise { + try { + const { locale: userLocale } = await snap.request({ + method: 'snap_getPreferences', + }); + return (await import(`../../locales/${userLocale}.json`)).messages; + } catch { + return (await import(`../../locales/en.json`)).messages; + } +} + +export type Translator = (string) => string; + +/** + * Returns a translator function that translates keys to user locale messages. + * + * @returns A function that translates keys to user locale messages. + */ +export function getTranslator(): Translator { + const userLocale = getUserLocale(); + return (key: string) => { + return userLocale[key]?.message ?? `{${key}}`; + }; +} diff --git a/merged-packages/bitcoin-wallet-snap/tsconfig.json b/merged-packages/bitcoin-wallet-snap/tsconfig.json index b5eb0cac..d1f36018 100644 --- a/merged-packages/bitcoin-wallet-snap/tsconfig.json +++ b/merged-packages/bitcoin-wallet-snap/tsconfig.json @@ -10,5 +10,11 @@ "jsx": "react-jsx", "jsxImportSource": "@metamask/snaps-sdk" }, - "include": ["**/*.ts", "**/*.tsx", "src/**/*.tsx", "src/index.tsx"] + "include": [ + "**/*.ts", + "**/*.tsx", + "src/**/*.tsx", + "src/index.tsx", + "locales/*.json" + ] } From 3c00518ab794a3ebd264e8da8966d76e62c706a4 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Wed, 11 Dec 2024 22:01:57 +0800 Subject: [PATCH 177/362] feat: fee rate caching (#358) * feat: initial cache * feat: add cache to factory * fix: convert fee to a serializable value * fix: tests * feat: add tests for cache * feat: cache manager * fix: cache update * refactor: serializable fees * fix: lint * Apply suggestions from code review Co-authored-by: Charly Chevalier * fix: lint * Apply suggestions from code review Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * fix: address comments --------- Co-authored-by: Charly Chevalier Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/bitcoin/chain/service.test.ts | 170 +++++++++++++++++- .../src/bitcoin/chain/service.ts | 30 +++- .../bitcoin-wallet-snap/src/cacheManager.ts | 145 +++++++++++++++ .../bitcoin-wallet-snap/src/config.ts | 2 + .../bitcoin-wallet-snap/src/factory.ts | 7 + .../src/stateManagement.test.ts | 9 +- .../src/utils/snap-state.test.ts | 15 +- .../src/utils/snap-state.ts | 12 +- .../src/utils/snap.test.ts | 6 +- .../bitcoin-wallet-snap/src/utils/snap.ts | 17 +- 11 files changed, 396 insertions(+), 19 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/cacheManager.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 1a54d2b2..12470898 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "Bl+a5aM+rDEbsCtHggdxqvE+umeMHi2mQhcV7EVbPqo=", + "shasum": "azC6Vh58ow9PBdc+ndBdPRdviFnUpgd5ZX6hN/SZGsI=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts index a1e47142..4750b342 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts @@ -6,7 +6,13 @@ import { generateFormattedUtxos, generateQuickNodeSendRawTransactionResp, } from '../../../test/utils'; +import { + CacheStateManager, + SerializableFees, + CachedValue, +} from '../../cacheManager'; import { Caip19Asset } from '../../constants'; +import { getCaip2ChainId } from '../wallet'; import { FeeRate, TransactionStatus } from './constants'; import type { IDataClient, ISatsProtectionDataClient } from './data-client'; import { BtcOnChainServiceError } from './exceptions'; @@ -22,7 +28,6 @@ describe('BtcOnChainService', () => { const getTransactionStatusSpy = jest.fn(); const sendTransactionSpy = jest.fn(); const filterUtxosSpy = jest.fn(); - class MockReadDataClient implements IDataClient { getBalances = getBalancesSpy; @@ -51,9 +56,23 @@ describe('BtcOnChainService', () => { }; }; + const createMockCacheStateManager = () => { + const cachedStateManager = new CacheStateManager(); + const getFeeRateSpy = jest.spyOn(cachedStateManager, 'getFeeRate'); + const setFeeRateSpy = jest + .spyOn(cachedStateManager, 'setFeeRate') + .mockResolvedValue(); + return { + instance: cachedStateManager, + getFeeRateSpy, + setFeeRateSpy, + }; + }; + const createMockBtcService = ( dataClient?: IDataClient, satsProtectionClient?: ISatsProtectionDataClient, + cacheStateManager?: CacheStateManager, network: Network = networks.testnet, ) => { const { @@ -61,6 +80,8 @@ describe('BtcOnChainService', () => { satsProtectionClient: _satsProtectionDataClient, } = createMockDataClient(); + const { instance: _cacheStateManager } = createMockCacheStateManager(); + class MockBtcOnChainService extends BtcOnChainService { isSatsProtectionEnabled() { return super.isSatsProtectionEnabled(); @@ -77,6 +98,7 @@ describe('BtcOnChainService', () => { dataClient: dataClient ?? _dataClient, satsProtectionDataClient: satsProtectionClient ?? _satsProtectionDataClient, + cacheStateManager: cacheStateManager ?? _cacheStateManager, }, { network, @@ -101,9 +123,12 @@ describe('BtcOnChainService', () => { filterUtxosSpy, } = createMockDataClient(); + const { instance: cacheStateManager } = createMockCacheStateManager(); + const { service, isSatsProtectionEnabledSpy } = createMockBtcService( dataClient, satsProtectionClient, + cacheStateManager, network, ); @@ -228,9 +253,12 @@ describe('BtcOnChainService', () => { const { dataClient, satsProtectionClient, getUtxosSpy, filterUtxosSpy } = createMockDataClient(); + const { instance: cacheStateManager } = createMockCacheStateManager(); + const { service, isSatsProtectionEnabledSpy } = createMockBtcService( dataClient, satsProtectionClient, + cacheStateManager, network, ); @@ -333,6 +361,146 @@ describe('BtcOnChainService', () => { BtcOnChainServiceError, ); }); + + describe('feeRateCache', () => { + it('returns the cached value if available', async () => { + const { dataClient } = createMockDataClient(); + const { instance: cacheStateManager, getFeeRateSpy } = + createMockCacheStateManager(); + const { service } = createMockBtcService( + dataClient, + undefined, + cacheStateManager, + ); + + const cachedFees = new CachedValue( + new SerializableFees( + { + fees: [ + { + type: FeeRate.Fast, + rate: BigInt(1), + }, + { + type: FeeRate.Medium, + rate: BigInt(2), + }, + ], + }, + 10 * 1000, // expires in 10 seconds + ), + ); + getFeeRateSpy.mockResolvedValue(cachedFees); + + const result = await service.getFeeRates(); + + expect(getFeeRateSpy).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual(cachedFees.value.valueOf()); + }); + + it('fetches new fee rates if cache is expired', async () => { + const { dataClient, getFeeRatesSpy } = createMockDataClient(); + const { + instance: cacheStateManager, + getFeeRateSpy, + setFeeRateSpy, + } = createMockCacheStateManager(); + const { service } = createMockBtcService( + dataClient, + undefined, + cacheStateManager, + ); + + const expiredFees = { + fees: [ + { + type: FeeRate.Fast, + rate: BigInt(123), + }, + { + type: FeeRate.Medium, + rate: BigInt(456), + }, + ], + }; + + getFeeRateSpy.mockResolvedValue({ + value: expiredFees, + expiration: Date.now() - 10 * 1000, // Expired 10 seconds ago. + isExpired: () => true, + } as unknown as CachedValue); + + const newFees = { + fees: [ + { + type: FeeRate.Fast, + rate: BigInt(1), + }, + { + type: FeeRate.Medium, + rate: BigInt(2), + }, + ], + }; + + getFeeRatesSpy.mockResolvedValue({ + [FeeRate.Fast]: 1, + [FeeRate.Medium]: 2, + }); + + const result = await service.getFeeRates(); + + expect(getFeeRateSpy).toHaveBeenCalledTimes(1); + expect(setFeeRateSpy).toHaveBeenCalledWith( + getCaip2ChainId(service.network), + newFees, + ); + expect(result).toStrictEqual(newFees); + }); + + it('fetches new fee rates if cache is not available', async () => { + const { dataClient, getFeeRatesSpy } = createMockDataClient(); + const { + instance: cacheStateManager, + getFeeRateSpy, + setFeeRateSpy, + } = createMockCacheStateManager(); + const { service } = createMockBtcService( + dataClient, + undefined, + cacheStateManager, + ); + + getFeeRateSpy.mockResolvedValue(null); + + const newFees = { + fees: [ + { + type: FeeRate.Fast, + rate: BigInt(1), + }, + { + type: FeeRate.Medium, + rate: BigInt(2), + }, + ], + }; + + getFeeRatesSpy.mockResolvedValue({ + [FeeRate.Fast]: 1, + [FeeRate.Medium]: 2, + }); + + const result = await service.getFeeRates(); + + expect(getFeeRateSpy).toHaveBeenCalledTimes(1); + expect(setFeeRateSpy).toHaveBeenCalledWith( + getCaip2ChainId(service.network), + newFees, + ); + expect(result).toStrictEqual(newFees); + }); + }); }); describe('broadcastTransaction', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts index b17be62a..6e4658a1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts @@ -1,9 +1,11 @@ import type { Network } from 'bitcoinjs-lib'; import { networks } from 'bitcoinjs-lib'; +import type { CacheStateManager } from '../../cacheManager'; import { Caip19Asset } from '../../constants'; import { compactError } from '../../utils'; import { isSatsProtectionEnabled } from '../../utils/config'; +import { getCaip2ChainId } from '../wallet'; import type { FeeRate, TransactionStatus } from './constants'; import type { IDataClient, ISatsProtectionDataClient } from './data-client'; import { BtcOnChainServiceError } from './exceptions'; @@ -55,6 +57,7 @@ export type BtcOnChainServiceOptions = { export type BtcOnChainServiceClients = { dataClient: IDataClient; satsProtectionDataClient: ISatsProtectionDataClient; + cacheStateManager: CacheStateManager; }; export class BtcOnChainService { @@ -62,14 +65,21 @@ export class BtcOnChainService { protected readonly _satsProtectionDataClient: ISatsProtectionDataClient; + protected readonly _cacheStateManager: CacheStateManager; + protected readonly _options: BtcOnChainServiceOptions; constructor( - { dataClient, satsProtectionDataClient }: BtcOnChainServiceClients, + { + dataClient, + satsProtectionDataClient, + cacheStateManager, + }: BtcOnChainServiceClients, options: BtcOnChainServiceOptions, ) { this._dataClient = dataClient; this._satsProtectionDataClient = satsProtectionDataClient; + this._cacheStateManager = cacheStateManager; this._options = options; } @@ -122,10 +132,17 @@ export class BtcOnChainService { * @returns A promise that resolves to a `Fees` object. */ async getFeeRates(): Promise { + const cachedValue = await this._cacheStateManager.getFeeRate( + getCaip2ChainId(this.network), + ); + + if (cachedValue && !cachedValue.isExpired()) { + return cachedValue.value.valueOf(); + } + try { const result = await this._dataClient.getFeeRates(); - - return { + const fees = { fees: Object.entries(result).map( ([key, value]: [key: FeeRate, value: number]) => ({ type: key, @@ -133,6 +150,13 @@ export class BtcOnChainService { }), ), }; + + await this._cacheStateManager.setFeeRate( + getCaip2ChainId(this.network), + fees, + ); + + return fees; } catch (error) { throw compactError(error, BtcOnChainServiceError); } diff --git a/merged-packages/bitcoin-wallet-snap/src/cacheManager.ts b/merged-packages/bitcoin-wallet-snap/src/cacheManager.ts new file mode 100644 index 00000000..73a444f2 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/cacheManager.ts @@ -0,0 +1,145 @@ +import type { Fees, Fee } from './bitcoin/chain'; +import { DefaultCacheTtl } from './config'; +import { Caip2ChainId } from './constants'; +import { SnapStateManager, logger, compactError } from './utils'; + +export type SerializedFee = Omit & { + rate: string; +}; + +export type SerializedFees = { + fees: SerializedFee[]; + expiration: number; +}; + +type WithExpiration = Value & { expiration: number }; + +export type ISerializable = { + data: Data; + + serialize(): SerializeData; + deserialize(serializeData: SerializeData): void; +}; + +export class SerializableFees + implements ISerializable, SerializedFees> +{ + data: WithExpiration = { + fees: [], + expiration: 0, + }; + + constructor(data: Fees = { fees: [] }, expiresIn = DefaultCacheTtl) { + this.data = { + fees: data.fees, + expiration: expiresIn ?? Date.now() + DefaultCacheTtl, + }; + } + + static fromSerialized(serializeData: SerializedFees): SerializableFees { + const fees = new SerializableFees(); + fees.deserialize(serializeData); + return fees; + } + + valueOf() { + return this.data; + } + + serialize() { + return { + fees: this.data.fees.map((fee) => ({ + ...fee, + rate: fee.rate.toString(), + })), + expiration: this.data.expiration, + }; + } + + deserialize(serializeData: SerializedFees): void { + Object.entries(serializeData.fees).forEach(([key, value]) => { + const fee = value as { type: string; rate: string }; + this.data.fees[key] = { + type: fee.type, + rate: BigInt(fee.rate), + expiration: serializeData.expiration, + }; + }); + } +} + +export type SerializedCacheState = { + feeRate: Record; +}; + +export type CacheState = { + feeRate: Record>; +}; + +export class CachedValue { + value: ValueType; + + readonly expiredAt: number; + + // Will be expired by default if no `expiredAt` is given. + constructor(value: ValueType, expiredAt?: number) { + this.value = value; + this.expiredAt = expiredAt ?? Date.now() + DefaultCacheTtl; + } + + isExpired() { + return this.expiredAt <= Date.now(); + } +} + +export class CacheStateManager extends SnapStateManager { + constructor() { + super({ encrypted: false }); + } + + protected override async get(): Promise { + return super.get().then((persistedState: SerializedCacheState) => { + return ( + persistedState || { + feeRate: { + [Caip2ChainId.Mainnet]: { + fees: [], + expiration: 0, + }, + [Caip2ChainId.Testnet]: { + fees: [], + expiration: 0, + }, + }, + } + ); + }); + } + + async getFeeRate( + scope: Caip2ChainId, + ): Promise | null> { + try { + const state = await this.get(); + const serializedFee = state.feeRate[scope]; + const fee = new CachedValue( + SerializableFees.fromSerialized(serializedFee), + serializedFee.expiration, + ); + return fee; + } catch (error) { + logger.warn('Failed to get fee rate', error); + return null; + } + } + + async setFeeRate(scope: Caip2ChainId, value: Fees): Promise { + try { + await this.update(async (state: SerializedCacheState) => { + state.feeRate[scope] = new SerializableFees(value).serialize(); + }); + } catch (error) { + throw compactError(error, Error); + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 803c886a..9bb768da 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -78,3 +78,5 @@ export const Config: SnapChainConfig = { defaultConfirmationThreshold: 6, defaultSatsProtectionEnabled: true, }; + +export const DefaultCacheTtl = 60 * 1000; diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts index 8d1da55b..290adaee 100644 --- a/merged-packages/bitcoin-wallet-snap/src/factory.ts +++ b/merged-packages/bitcoin-wallet-snap/src/factory.ts @@ -2,6 +2,7 @@ import { BtcOnChainService } from './bitcoin/chain'; import { QuickNodeClient } from './bitcoin/chain/clients/quicknode'; import { SimpleHashClient } from './bitcoin/chain/clients/simplehash'; import { BtcAccountDeriver, BtcWallet, getBtcNetwork } from './bitcoin/wallet'; +import { CacheStateManager } from './cacheManager'; import { ApiClient, Config } from './config'; export class Factory { @@ -10,11 +11,13 @@ export class Factory { const quickNodeClient = Factory.createQuickNodeClient(scope); const simpleHashClient = Factory.createSimpleHashClient(); + const cachedStateManager = Factory.createCachedStateManager(); return new BtcOnChainService( { dataClient: quickNodeClient, satsProtectionDataClient: simpleHashClient, + cacheStateManager: cachedStateManager, }, { network: btcNetwork, @@ -56,4 +59,8 @@ export class Factory { const btcNetwork = getBtcNetwork(scope); return new BtcWallet(new BtcAccountDeriver(btcNetwork), btcNetwork); } + + static createCachedStateManager(): CacheStateManager { + return new CacheStateManager(); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts b/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts index 5b9ce8c2..086eb1ed 100644 --- a/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts @@ -273,11 +273,14 @@ describe('KeyringStateManager', () => { expect(getDataSpy).toHaveBeenCalledTimes(1); expect(setDataSpy).toHaveBeenCalledWith( expect.objectContaining({ - wallets: expect.objectContaining({ - [accToUpdate.id]: expect.objectContaining({ - account: accToUpdate, + data: expect.objectContaining({ + wallets: expect.objectContaining({ + [accToUpdate.id]: expect.objectContaining({ + account: accToUpdate, + }), }), }), + encrypted: true, }), ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts index 88e86244..14fa496d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts @@ -31,11 +31,12 @@ type MockExecuteTransactionInput = { describe('SnapStateManager', () => { const createMockStateManager = ( createLock?: boolean, + encrypted?: boolean, ) => { const updateDataSpy = jest.fn(); class MockSnapStateManager extends SnapStateManager { constructor() { - super(createLock); + super({ createLock, encrypted }); } async getData() { @@ -85,7 +86,12 @@ describe('SnapStateManager', () => { }; const createMockState = (initState: MockState) => { - const setStateDataFn = async (data: MockState) => { + const setStateDataFn = async ({ + data, + }: { + data: MockState; + encrypted: boolean; + }) => { initState.transaction = [...data.transaction]; initState.transactionDetails = Object.entries( data.transactionDetails, @@ -223,7 +229,10 @@ describe('SnapStateManager', () => { expect(readSpy).toHaveBeenCalledTimes(1); expect(writeSpy).toHaveBeenCalledTimes(1); - expect(writeSpy).toHaveBeenCalledWith(testcase.state); + expect(writeSpy).toHaveBeenCalledWith({ + data: testcase.state, + encrypted: true, + }); expect(testcase.state.transaction).toHaveLength(2); expect(updateDataSpy).toHaveBeenCalledTimes(1); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts index ff23cc83..1edffc24 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts @@ -18,7 +18,12 @@ export abstract class SnapStateManager { #transaction: Transaction; - constructor(createLock = false) { + #encryptedMode: boolean; + + constructor({ + createLock = false, + encrypted = true, + }: { createLock?: boolean; encrypted?: boolean } = {}) { this.mtx = acquireLock(createLock); this.#transaction = { id: undefined, @@ -27,14 +32,15 @@ export abstract class SnapStateManager { isRollingBack: false, hasCommitted: false, }; + this.#encryptedMode = encrypted; } protected async get(): Promise { - return getStateData(); + return getStateData(this.#encryptedMode); } protected async set(state: State): Promise { - return setStateData(state); + return setStateData({ data: state, encrypted: this.#encryptedMode }); } protected async update( diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts index 780cd74f..b84110f0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts @@ -65,12 +65,13 @@ describe('getStateData', () => { }; spy.mockResolvedValue(testcase.state); - const result = await snapUtil.getStateData(); + const result = await snapUtil.getStateData(true); expect(spy).toHaveBeenCalledWith({ method: 'snap_manageState', params: { operation: 'get', + encrypted: true, }, }); @@ -92,13 +93,14 @@ describe('setStateData', () => { }, }; - await snapUtil.setStateData(testcase.state); + await snapUtil.setStateData({ data: testcase.state, encrypted: true }); expect(spy).toHaveBeenCalledWith({ method: 'snap_manageState', params: { operation: 'update', newState: testcase.state, + encrypted: true, }, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts index 50463294..9ec3dc1b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts @@ -78,13 +78,15 @@ export async function alertDialog( /** * Retrieves the current state data. * + * @param encrypted - A boolean indicating whether the state data is encrypted. * @returns A Promise that resolves to the current state data. */ -export async function getStateData(): Promise { +export async function getStateData(encrypted: boolean): Promise { return (await snap.request({ method: 'snap_manageState', params: { operation: 'get', + encrypted, }, })) as unknown as State; } @@ -92,14 +94,23 @@ export async function getStateData(): Promise { /** * Sets the current state data to the specified data. * - * @param data - The new state data to set. + * @param data - An object containing the new state data and encryption flag. + * @param data.data - The new state data to set. + * @param data.encrypted - A boolean indicating whether the state data is encrypted. */ -export async function setStateData(data: State) { +export async function setStateData({ + data, + encrypted, +}: { + data: State; + encrypted: boolean; +}): Promise { await snap.request({ method: 'snap_manageState', params: { operation: 'update', newState: data as unknown as Record, + encrypted, }, }); } From d6d2fe5dfaa2e796e5e8c407ec246a518bce1c64 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Wed, 8 Jan 2025 18:41:51 +0100 Subject: [PATCH 178/362] feat!: add `scopes` field to `KeyringAccount` (#364) * chore: bump keyring-api to ^12.0.0 + use new split packages * feat: use scope from options when creating account * chore: bump keyring-api to ^13.0.0 * fix: fix tests + add snaps-sdk resolution * chore: lint * fix: fix assertScopeIsSupported * fix: fix more tests * chore: bump keyring-snap-sdk to 1.1.0 --- .../bitcoin-wallet-snap/package.json | 3 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/bitcoin/wallet/utils/network.ts | 14 ++++++++ .../bitcoin-wallet-snap/src/index.test.tsx | 7 ++-- .../bitcoin-wallet-snap/src/index.tsx | 2 +- .../bitcoin-wallet-snap/src/keyring.test.ts | 5 +-- .../bitcoin-wallet-snap/src/keyring.ts | 33 ++++++++++++------- .../send-bitcoin-controller.test.ts | 3 +- .../bitcoin-wallet-snap/src/ui/utils.test.ts | 3 +- 9 files changed, 51 insertions(+), 21 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index d1cc254a..0ae1b3e3 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -43,7 +43,8 @@ "@metamask/eslint-config-nodejs": "^12.1.0", "@metamask/eslint-config-typescript": "^12.1.0", "@metamask/key-tree": "^9.1.2", - "@metamask/keyring-api": "^9.0.0", + "@metamask/keyring-api": "^13.0.0", + "@metamask/keyring-snap-sdk": "^1.1.0", "@metamask/snaps-cli": "^6.5.0", "@metamask/snaps-jest": "^8.6.0", "@metamask/snaps-sdk": "^6.9.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 12470898..a054eb05 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "azC6Vh58ow9PBdc+ndBdPRdviFnUpgd5ZX6hN/SZGsI=", + "shasum": "HWtxhLpGScP/B/pUIE8ReC5KafcNSTIhYzj6r2jQwZk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.ts index 7b8b4f25..7745221a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.ts +++ b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.ts @@ -3,6 +3,20 @@ import { networks } from 'bitcoinjs-lib'; import { Caip2ChainId } from '../../../constants'; +/** + * Asserts a scope (CAIP-2 Chain ID) is supported. + * + * @param scope - A CAIP-2 Chain ID (scope). + * @throws An error if an invalid network is provided. + */ +export function assertScopeIsSupported(scope: string) { + const scopes = Object.values(Caip2ChainId) as string[]; + + if (!scopes.includes(scope)) { + throw new Error(`Invalid scope, must be one of: ${scopes.join(', ')}`); + } +} + /** * Gets a bitcoinjs-lib network instance based on a given CAIP-2 Chain ID. * diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.tsx b/merged-packages/bitcoin-wallet-snap/src/index.test.tsx index 4e90b4ee..4957d6ad 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.tsx @@ -1,4 +1,5 @@ import * as keyringApi from '@metamask/keyring-api'; +import * as keyringSnapSdk from '@metamask/keyring-snap-sdk'; import { type JsonRpcRequest, SnapError, @@ -15,8 +16,8 @@ import * as getTxStatusRpc from './rpcs/get-transaction-status'; jest.mock('./utils/logger'); -jest.mock('@metamask/keyring-api', () => ({ - ...jest.requireActual('@metamask/keyring-api'), +jest.mock('@metamask/keyring-snap-sdk', () => ({ + ...jest.requireActual('@metamask/keyring-snap-sdk'), handleKeyringRequest: jest.fn(), })); @@ -119,7 +120,7 @@ describe('onRpcRequest', () => { describe('onKeyringRequest', () => { const createMockHandleKeyringRequest = () => { const handleKeyringRequestSpy = jest.spyOn( - keyringApi, + keyringSnapSdk, 'handleKeyringRequest', ); return { handler: handleKeyringRequestSpy }; diff --git a/merged-packages/bitcoin-wallet-snap/src/index.tsx b/merged-packages/bitcoin-wallet-snap/src/index.tsx index 7ccc7349..c7d9fffb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/index.tsx @@ -1,4 +1,4 @@ -import { handleKeyringRequest } from '@metamask/keyring-api'; +import { handleKeyringRequest } from '@metamask/keyring-snap-sdk'; import { type OnRpcRequestHandler, type OnKeyringRequestHandler, diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts index f4f6467b..a995497a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.test.ts @@ -18,8 +18,8 @@ import { KeyringStateManager } from './stateManagement'; jest.mock('./utils/logger'); jest.mock('./utils/snap'); -jest.mock('@metamask/keyring-api', () => ({ - ...jest.requireActual('@metamask/keyring-api'), +jest.mock('@metamask/keyring-snap-sdk', () => ({ + ...jest.requireActual('@metamask/keyring-snap-sdk'), emitSnapKeyringEvent: jest.fn(), })); @@ -157,6 +157,7 @@ describe('BtcKeyring', () => { address: keyringAccount.address, options: keyringAccount.options, methods: keyringAccount.methods, + scopes: [caip2ChainId], }, hdPath: sender.hdPath, index: sender.index, diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index 3ace1af3..ffb5d3e5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -1,7 +1,6 @@ import { BtcMethod, KeyringEvent, - emitSnapKeyringEvent, type Keyring, type KeyringAccount, type KeyringRequest, @@ -9,6 +8,7 @@ import { type Balance, type CaipAssetType, } from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import { MethodNotFoundError, UnauthorizedError, @@ -19,7 +19,11 @@ import type { Infer } from 'superstruct'; import { assert, object, StructError } from 'superstruct'; import { v4 as uuidv4 } from 'uuid'; -import { type BtcAccount, type BtcWallet } from './bitcoin/wallet'; +import { + assertScopeIsSupported, + type BtcAccount, + type BtcWallet, +} from './bitcoin/wallet'; import { Config } from './config'; import { Caip2ChainId } from './constants'; import { AccountNotFoundError, MethodNotImplementedError } from './exceptions'; @@ -85,7 +89,10 @@ export class BtcKeyring implements Keyring { try { assert(options, CreateAccountOptionsStruct); - const wallet = this.getBtcWallet(options.scope); + const { scope } = options; + assertScopeIsSupported(scope); + + const wallet = this.getBtcWallet(scope); // TODO: Create account with index 0 for now for phase 1 scope, update to use increment index later const index = this._options.defaultIndex; @@ -99,7 +106,7 @@ export class BtcKeyring implements Keyring { ); const keyringAccount = this.newKeyringAccount(account, { - scope: options.scope, + scope, index, }); @@ -114,7 +121,7 @@ export class BtcKeyring implements Keyring { account: keyringAccount, hdPath: account.hdPath, index: account.index, - scope: options.scope, + scope, }); await this.#emitEvent(KeyringEvent.AccountCreated, { @@ -268,17 +275,21 @@ export class BtcKeyring implements Keyring { protected newKeyringAccount( account: BtcAccount, - options?: CreateAccountOptions, + options: CreateAccountOptions, ): KeyringAccount { + const { scope } = options; + // This should be done by the caller already, but to future-proof this we double-check it here too. + assertScopeIsSupported(scope); + return { - type: account.type, + // Assuming that `account.type` is aligned with `KeyringAccount['type']`. + type: account.type as KeyringAccount['type'], id: uuidv4(), address: account.address, - options: { - ...options, - }, + options, + scopes: [scope], methods: this._methods, - } as unknown as KeyringAccount; + }; } protected getKeyringAccountNameSuggestion( diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts index 15e2db1d..d28bdf7e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts @@ -1,5 +1,5 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import { BtcAccountType, BtcMethod } from '@metamask/keyring-api'; +import { BtcAccountType, BtcMethod, BtcScopes } from '@metamask/keyring-api'; import type { UserInputEvent } from '@metamask/snaps-sdk'; import { UserInputEventType } from '@metamask/snaps-sdk'; import BigNumber from 'bignumber.js'; @@ -49,6 +49,7 @@ const mockAccount = { index: '1', }, methods: [`${BtcMethod.SendBitcoin}`], + scopes: [BtcScopes.Mainnet], }; const createMockContext = (request: SendFlowRequest) => { diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts b/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts index 2d246a6a..4b961dfc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts @@ -1,5 +1,5 @@ import { expect } from '@jest/globals'; -import { BtcAccountType, BtcMethod } from '@metamask/keyring-api'; +import { BtcAccountType, BtcMethod, BtcScopes } from '@metamask/keyring-api'; import { BigNumber } from 'bignumber.js'; import { v4 as uuidv4 } from 'uuid'; @@ -36,6 +36,7 @@ const mockAccount = { index: '1', }, methods: [`${BtcMethod.SendBitcoin}`], + scopes: [BtcScopes.Mainnet], }; describe('utils', () => { From 0bbe6816ea94ce0933fbb84422960ffb53788012 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Jan 2025 20:20:34 +0000 Subject: [PATCH 179/362] 0.9.0 (#385) * 0.9.0 * chore: update changelogs + yarn build * chore: wording --------- Co-authored-by: github-actions Co-authored-by: Charly Chevalier --- .../bitcoin-wallet-snap/CHANGELOG.md | 26 ++++++++++++++++++- .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 +-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 391db8f4..c4e63dbf 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.0] + +### Added + +- Add localized messages ([#348](https://github.com/MetaMask/snap-bitcoin-wallet/pull/348)) +- Add basic Sats protection support ([#337](https://github.com/MetaMask/snap-bitcoin-wallet/pull/337)), ([#284](https://github.com/MetaMask/snap-bitcoin-wallet/pull/284)), ([#349](https://github.com/MetaMask/snap-bitcoin-wallet/pull/349)) + - Using SimpleHash service. + +### Changed + +- **BREAKING:** Provide scopes field to `KeyringAccount` during account creation ([#364](https://github.com/MetaMask/snap-bitcoin-wallet/pull/364)) + - Bump `@metamask/keyring-api` from `^8.1.3` to `^13.0.0`. + - Compatible with `@metamask/eth-snap-keyring@^7.1.0`. +- Support for fee rate caching ([#358](https://github.com/MetaMask/snap-bitcoin-wallet/pull/358)) +- Use Snap UI update context instead of persisting request ([#345](https://github.com/MetaMask/snap-bitcoin-wallet/pull/345)) + - Making the Snap UI slighty more responsive and faster. +- Remove support of Blockchair service ([#298](https://github.com/MetaMask/snap-bitcoin-wallet/pull/298)) + +### Fixed + +- Various UI fixes ([#356](https://github.com/MetaMask/snap-bitcoin-wallet/pull/356)), ([#357](https://github.com/MetaMask/snap-bitcoin-wallet/pull/357)), ([#346](https://github.com/MetaMask/snap-bitcoin-wallet/pull/346)), ([#344](https://github.com/MetaMask/snap-bitcoin-wallet/pull/344)), ([#343](https://github.com/MetaMask/snap-bitcoin-wallet/pull/343)) +- Allow send to happen even when rates are not available ([#350](https://github.com/MetaMask/snap-bitcoin-wallet/pull/350)) + ## [0.8.2] ### Fixed @@ -232,7 +255,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.2...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...HEAD +[0.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.2...v0.9.0 [0.8.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.1...v0.8.2 [0.8.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.0...v0.8.1 [0.8.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.7.0...v0.8.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 0ae1b3e3..bbc62669 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.8.2", + "version": "0.9.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index a054eb05..4b4487d4 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.8.2", + "version": "0.9.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "HWtxhLpGScP/B/pUIE8ReC5KafcNSTIhYzj6r2jQwZk=", + "shasum": "Rp0babTxWVsnYiTRbsBhm5JOpT5Vt5bJ25/je6yyGkI=", "location": { "npm": { "filePath": "dist/bundle.js", From 1fc07c179571b2d059ea09edbbf1872ec6196f83 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 9 Jan 2025 20:29:54 +0100 Subject: [PATCH 180/362] feat: bdk create account (#361) * BDK create account * clean * clean * add all account types * idempotency per derivation path * create account works * lint * lint * move requests to file * add use cases * tests for keyringHandler * test pass * final refactor * linting * tests pass * all tests pass * linting * config types * use bdk-wasm from dario_nakamoto * builds * prettier * all done * waiting on BDK deployment * use bitcoindevkit * lint * add docs * add docs * Apply suggestions from code review Co-authored-by: Charly Chevalier Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * error in account purposes * use private fields * add reverse map * remove allowJS * Update packages/snap/src/repositories/SnapAccountRepository.ts Co-authored-by: Charly Chevalier * lint * type account type * mock v3 * interface for snap client and move interface of repository * lint * platformVersion * fails to run tests * use yarn.lock from main * heap size * heap size --------- Co-authored-by: Charly Chevalier Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> --- .../bitcoin-wallet-snap/.env.example | 5 + .../bitcoin-wallet-snap/.prettierignore | 1 - .../bitcoin-wallet-snap/package.json | 4 +- .../bitcoin-wallet-snap/snap.config.ts | 9 + .../bitcoin-wallet-snap/snap.manifest.json | 43 +++-- .../bitcoin-wallet-snap/src/configv2.ts | 34 ++++ .../src/entities/account.ts | 96 ++++++++++ .../src/entities/config.ts | 22 +++ .../bitcoin-wallet-snap/src/entities/index.ts | 2 + .../bitcoin-wallet-snap/src/entities/snap.ts | 39 ++++ .../src/handlers/KeyringHandler.test.ts | 177 ++++++++++++++++++ .../src/handlers/KeyringHandler.ts | 125 +++++++++++++ .../bitcoin-wallet-snap/src/handlers/caip2.ts | 40 ++++ .../bitcoin-wallet-snap/src/handlers/index.ts | 2 + .../src/handlers/mapping.ts | 15 ++ .../bitcoin-wallet-snap/src/index.test.tsx | 4 + .../bitcoin-wallet-snap/src/index.tsx | 34 +++- .../src/infra/BdkAccountAdapter.ts | 85 +++++++++ .../src/infra/SnapClientAdapter.ts | 52 +++++ .../bitcoin-wallet-snap/src/infra/index.ts | 2 + .../src/store/BdkAccountRepository.test.ts | 119 ++++++++++++ .../src/store/BdkAccountRepository.ts | 71 +++++++ .../bitcoin-wallet-snap/src/store/index.ts | 1 + .../src/usecases/AccountUseCases.test.ts | 127 +++++++++++++ .../src/usecases/AccountUseCases.ts | 69 +++++++ .../bitcoin-wallet-snap/src/usecases/index.ts | 1 + 26 files changed, 1155 insertions(+), 24 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/configv2.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/account.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/config.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/snap.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/caip2.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/mapping.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/store/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/usecases/AccountUseCases.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/usecases/AccountUseCases.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/usecases/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/.env.example b/merged-packages/bitcoin-wallet-snap/.env.example index 005b5844..01528ebd 100644 --- a/merged-packages/bitcoin-wallet-snap/.env.example +++ b/merged-packages/bitcoin-wallet-snap/.env.example @@ -12,3 +12,8 @@ QUICKNODE_MAINNET_ENDPOINT= # Description: Environment variables for the SimpleHash API key # Required: true SIMPLEHASH_API_KEY= +# Description: Version of the keyring +# Possible Options: v1, v2 +# Default: v1 +# Required: false +KEYRING_VERSION=v1 \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/.prettierignore b/merged-packages/bitcoin-wallet-snap/.prettierignore index e17a8756..a60030e3 100644 --- a/merged-packages/bitcoin-wallet-snap/.prettierignore +++ b/merged-packages/bitcoin-wallet-snap/.prettierignore @@ -1,3 +1,2 @@ dist/ coverage/ -snap.manifest.json diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index bbc62669..a61b9753 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -55,6 +55,7 @@ "bignumber.js": "9.1.2", "bip32": "4.0.0", "bitcoin-address-validation": "^2.2.3", + "bitcoindevkit": "^0.1.1", "bitcoinjs-lib": "6.1.5", "coinselect": "3.1.13", "concurrently": "^9.1.0", @@ -69,13 +70,14 @@ "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-promise": "^6.1.1", "jest": "^29.5.0", + "jest-mock-extended": "^3.0.7", "jest-transform-stub": "2.0.0", "prettier": "^2.7.1", "rimraf": "^3.0.2", "superstruct": "^1.0.3", "ts-jest": "^29.1.0", "typescript": "^4.7.4", - "uuid": "^9.0.1" + "uuid": "^11.0.3" }, "packageManager": "yarn@3.2.1", "engines": { diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts index b47647e6..dd949c6b 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.config.ts +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -16,9 +16,18 @@ const config: SnapConfig = { QUICKNODE_MAINNET_ENDPOINT: process.env.QUICKNODE_MAINNET_ENDPOINT, QUICKNODE_TESTNET_ENDPOINT: process.env.QUICKNODE_TESTNET_ENDPOINT, SIMPLEHASH_API_KEY: process.env.SIMPLEHASH_API_KEY, + DEFAULT_NETWORK: process.env.DEFAULT_NETWORK, + DEFAULT_ADDRESS_TYPE: process.env.DEFAULT_ADDRESS_TYPE, + ESPLORA_PROVIDER_BITCOIN: process.env.ESPLORA_PROVIDER_BITCOIN, + ESPLORA_PROVIDER_TESTNET: process.env.ESPLORA_PROVIDER_TESTNET, + ESPLORA_PROVIDER_TESTNET4: process.env.ESPLORA_PROVIDER_TESTNET4, + ESPLORA_PROVIDER_SIGNET: process.env.ESPLORA_PROVIDER_SIGNET, + ESPLORA_PROVIDER_REGTEST: process.env.ESPLORA_PROVIDER_REGTEST, + KEYRING_VERSION: process.env.KEYRING_VERSION, /* eslint-disable */ }, polyfills: true, + experimental: { wasm: true }, }; export default config; diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 4b4487d4..695e0364 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "Rp0babTxWVsnYiTRbsBhm5JOpT5Vt5bJ25/je6yyGkI=", + "shasum": "iolhp9W44Ell8HT4Ct6DrA2QKi3tXp7UmbXPKU5YVfo=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -16,9 +16,7 @@ "registry": "https://registry.npmjs.org/" } }, - "locales": [ - "locales/en.json" - ] + "locales": ["locales/en.json"] }, "initialConnections": { "https://portfolio.metamask.io": {}, @@ -27,6 +25,7 @@ "https://ramps-dev.portfolio.metamask.io": {} }, "initialPermissions": { + "endowment:webassembly": {}, "endowment:rpc": { "dapps": true, "snaps": false @@ -41,19 +40,35 @@ }, "snap_getBip32Entropy": [ { - "path": [ - "m", - "84'", - "0'" - ], + "path": ["m", "44'", "0'"], "curve": "secp256k1" }, { - "path": [ - "m", - "84'", - "1'" - ], + "path": ["m", "44'", "1'"], + "curve": "secp256k1" + }, + { + "path": ["m", "49'", "0'"], + "curve": "secp256k1" + }, + { + "path": ["m", "49'", "1'"], + "curve": "secp256k1" + }, + { + "path": ["m", "84'", "0'"], + "curve": "secp256k1" + }, + { + "path": ["m", "84'", "1'"], + "curve": "secp256k1" + }, + { + "path": ["m", "86'", "0'"], + "curve": "secp256k1" + }, + { + "path": ["m", "86'", "1'"], "curve": "secp256k1" } ], diff --git a/merged-packages/bitcoin-wallet-snap/src/configv2.ts b/merged-packages/bitcoin-wallet-snap/src/configv2.ts new file mode 100644 index 00000000..a61082ca --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/configv2.ts @@ -0,0 +1,34 @@ +/* eslint-disable no-restricted-globals */ + +import type { SnapConfig } from './entities'; +import { Caip2AddressType, Caip2ChainId } from './handlers'; + +// ConfigV2 exists temporarily to avoid modifying the old config object before it is removed. +export const ConfigV2: SnapConfig = { + encrypt: false, + // Defaults to v1 to use the "original" keyring. + keyringVersion: process.env.KEYRING_VERSION ?? 'v1', + accounts: { + index: 0, + defaultNetwork: process.env.DEFAULT_NETWORK ?? Caip2ChainId.Bitcoin, + defaultAddressType: + process.env.DEFAULT_ADDRESS_TYPE ?? Caip2AddressType.P2wpkh, + }, + chain: { + parallelRequests: 1, + stopGap: 10, + url: { + bitcoin: + process.env.ESPLORA_PROVIDER_BITCOIN ?? 'https://blockstream.info/api', + testnet: + process.env.ESPLORA_PROVIDER_TESTNET ?? + 'https://blockstream.info/testnet/api', + testnet4: + process.env.ESPLORA_PROVIDER_TESTNET4 ?? + 'https://mempool.space/testnet4/api/v1', + signet: + process.env.ESPLORA_PROVIDER_SIGNET ?? 'https://mutinynet.com/api', + regtest: process.env.ESPLORA_PROVIDER_REGTEST ?? 'https://localhost:3000', + }, + }, +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts new file mode 100644 index 00000000..e0b3d3fe --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -0,0 +1,96 @@ +import type { + AddressInfo, + AddressType, + Balance, + ChangeSet, + Network, +} from 'bitcoindevkit'; + +/** + * A Bitcoin account. + */ +export type BitcoinAccount = { + /** + * The id of the account. + */ + id: string; + + /** + * The suggested name of the account. + */ + suggestedName: string; + + /** + * The balance of the account. + */ + balance: Balance; + + /** + * The address type of the account. + */ + addressType: AddressType; + + /** + * The network of the account. + */ + network: Network; + + /** + * Get an address at a given index. + * @param index + * @returns the address + */ + peekAddress(index: number): AddressInfo; + + /** + * Get the next unused address. This will reveal a new address if there is no unused address. + * Note that the account needs to be persisted for this operation to be idempotent. + * @returns the address + */ + nextUnusedAddress(): AddressInfo; + + /** + * Reveal the next address. + * Note that the account needs to be persisted for this operation to be idempotent. + * @returns the address + */ + revealNextAddress(): AddressInfo; + + /** + * Get the change set. + * @returns the change set + */ + takeStaged(): ChangeSet | undefined; +}; + +/** + * BitcoinAccountRepository is a repository that manages Bitcoin accounts. + */ +export type BitcoinAccountRepository = { + /** + * Get an account by its id. + * @param id + * @returns the account or null if it does not exist + */ + get(id: string): Promise; + + /** + * Get an account by its derivation path. + * @param derivationPath + * @returns the account or null if it does not exist + */ + getByDerivationPath(derivationPath: string[]): Promise; + + /** + * Insert a new account. + * @param derivationPath + * @param network + * @param addressType + * @returns the new account + */ + insert( + derivationPath: string[], + network: Network, + addressType: AddressType, + ): Promise; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts new file mode 100644 index 00000000..1131b18b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -0,0 +1,22 @@ +import type { Network } from 'bitcoindevkit'; + +export type SnapConfig = { + encrypt: boolean; + accounts: AccountsConfig; + chain: ChainConfig; + keyringVersion: string; +}; + +export type AccountsConfig = { + index: number; + defaultNetwork: string; + defaultAddressType: string; +}; + +export type ChainConfig = { + parallelRequests: number; + stopGap: number; + url: { + [network in Network]: string; + }; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts new file mode 100644 index 00000000..f3622376 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts @@ -0,0 +1,2 @@ +export * from './account'; +export * from './config'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts new file mode 100644 index 00000000..cb6ab905 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -0,0 +1,39 @@ +import type { JsonSLIP10Node, SLIP10Node } from '@metamask/key-tree'; + +export type SnapState = { + accounts: { + derivationPaths: Record; + wallets: Record; + }; +}; + +/** + * The SnapClient represents the MetaMask Snap state and manages the BIP-32 entropy from the Wallet SRP. + */ +export type SnapClient = { + /** + * Get the Snap state. + * @returns The Snap state. + */ + get(): Promise; + + /** + * Set the Snap state. + * @param newState - The new state. + */ + set(newState: SnapState): Promise; + + /** + * Get the private SLIP10 for a given derivation path from the Snap SRP. + * @param derivationPath - The derivation path. + * @returns The private SLIP10 node. + */ + getPrivateEntropy(derivationPath: string[]): Promise; + + /** + * Get the public SLIP10 for a given derivation path from the Snap SRP. + * @param derivationPath - The derivation path. + * @returns The public SLIP10 node. + */ + getPublicEntropy(derivationPath: string[]): Promise; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts new file mode 100644 index 00000000..62cc0824 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -0,0 +1,177 @@ +import { BtcMethod, KeyringEvent } from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import { mock } from 'jest-mock-extended'; +import { assert } from 'superstruct'; + +import type { BitcoinAccount, AccountsConfig } from '../entities'; +import type { AccountUseCases } from '../usecases/AccountUseCases'; +import { getProvider } from '../utils'; +import { + caip2ToNetwork, + caip2ToAddressType, + Caip2ChainId, + Caip2AddressType, +} from './caip2'; +import { KeyringHandler, CreateAccountRequest } from './KeyringHandler'; + +jest.mock('../utils', () => ({ + getProvider: jest.fn(), +})); + +jest.mock('@metamask/keyring-snap-sdk', () => ({ + ...jest.requireActual('@metamask/keyring-snap-sdk'), + emitSnapKeyringEvent: jest.fn(), +})); + +jest.mock('superstruct', () => ({ + ...jest.requireActual('superstruct'), + assert: jest.fn(), +})); + +describe('KeyringHandler', () => { + const mockAccounts = mock(); + const mockConfig: AccountsConfig = { + index: 0, + defaultNetwork: Caip2ChainId.Bitcoin, + defaultAddressType: Caip2AddressType.P2wpkh, + }; + const mockAccount = { + id: 'some-id', + addressType: caip2ToAddressType[mockConfig.defaultAddressType], + suggestedName: 'My Bitcoin Account', + network: 'bitcoin', + nextUnusedAddress: () => ({ address: 'bc1qaddress...' }), + } as unknown as BitcoinAccount; + + let handler: KeyringHandler; + + beforeEach(() => { + handler = new KeyringHandler(mockAccounts, mockConfig); + }); + + describe('createAccount', () => { + beforeEach(() => { + (getProvider as jest.Mock).mockReturnValue({}); + (emitSnapKeyringEvent as jest.Mock).mockResolvedValue(undefined); + }); + + it('creates a new account with default config when no options are passed', async () => { + mockAccounts.createAccount.mockResolvedValue(mockAccount); + const expectedKeyringAccount = { + id: 'some-id', + type: mockConfig.defaultAddressType, + scopes: [Caip2ChainId.Bitcoin], + address: 'bc1qaddress...', + options: {}, + methods: [BtcMethod.SendBitcoin], + }; + + const result = await handler.createAccount(); + expect(assert).toHaveBeenCalledWith({}, CreateAccountRequest); + expect(mockAccounts.createAccount).toHaveBeenCalledWith( + caip2ToNetwork[mockConfig.defaultNetwork], + caip2ToAddressType[mockConfig.defaultAddressType], + ); + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + {}, + KeyringEvent.AccountCreated, + { + account: expectedKeyringAccount, + accountNameSuggestion: mockAccount.suggestedName, + }, + ); + expect(result).toStrictEqual(expectedKeyringAccount); + }); + + it('respects provided scope and addressType', async () => { + mockAccounts.createAccount.mockResolvedValue(mockAccount); + + const options = { + scope: Caip2ChainId.Signet, + addressType: Caip2AddressType.P2pkh, + }; + await handler.createAccount(options); + + expect(assert).toHaveBeenCalledWith(options, CreateAccountRequest); + expect(mockAccounts.createAccount).toHaveBeenCalledWith( + caip2ToNetwork[Caip2ChainId.Signet], + caip2ToAddressType[Caip2AddressType.P2pkh], + ); + }); + + it('propagates errors from createAccount', async () => { + const error = new Error(); + mockAccounts.createAccount.mockRejectedValue(error); + + await expect(handler.createAccount()).rejects.toThrow(error); + expect(mockAccounts.createAccount).toHaveBeenCalled(); + expect(emitSnapKeyringEvent).not.toHaveBeenCalled(); + }); + + it('propagates errors from emitSnapKeyringEvent', async () => { + const error = new Error(); + mockAccounts.createAccount.mockResolvedValue(mockAccount); + (emitSnapKeyringEvent as jest.Mock).mockRejectedValue(error); + + await expect(handler.createAccount()).rejects.toThrow(error); + expect(mockAccounts.createAccount).toHaveBeenCalled(); + expect(emitSnapKeyringEvent).toHaveBeenCalled(); + }); + }); + + describe('unimplemented methods', () => { + const errMsg = 'Method not implemented.'; + + it('listAccounts should throw', async () => { + await expect(handler.listAccounts()).rejects.toThrow(errMsg); + }); + + it('getAccount should throw', async () => { + await expect(handler.getAccount('some-id')).rejects.toThrow(errMsg); + }); + + it('getAccountBalances should throw', async () => { + await expect(handler.getAccountBalances('some-id', [])).rejects.toThrow( + errMsg, + ); + }); + + it('filterAccountChains should throw', async () => { + await expect(handler.filterAccountChains('some-id', [])).rejects.toThrow( + errMsg, + ); + }); + + it('updateAccount should throw', async () => { + await expect(handler.updateAccount({} as any)).rejects.toThrow(errMsg); + }); + + it('deleteAccount should throw', async () => { + await expect(handler.deleteAccount('some-id')).rejects.toThrow(errMsg); + }); + + it('exportAccount should throw', async () => { + await expect(handler.exportAccount('some-id')).rejects.toThrow(errMsg); + }); + + it('listRequests should throw', async () => { + await expect(handler.listRequests()).rejects.toThrow(errMsg); + }); + + it('getRequest should throw', async () => { + await expect(handler.getRequest('some-id')).rejects.toThrow(errMsg); + }); + + it('submitRequest should throw', async () => { + await expect(handler.submitRequest({} as any)).rejects.toThrow(errMsg); + }); + + it('approveRequest should throw', async () => { + await expect(handler.approveRequest({} as any)).rejects.toThrow(errMsg); + }); + + it('rejectRequest should throw', async () => { + await expect(handler.rejectRequest({} as any)).rejects.toThrow(errMsg); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts new file mode 100644 index 00000000..a8df6c74 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -0,0 +1,125 @@ +import { KeyringEvent, BtcMethod } from '@metamask/keyring-api'; +import type { + KeyringAccountData, + Keyring, + KeyringAccount, + KeyringRequest, + KeyringResponse, + Balance, + CaipAssetType, +} from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import type { Json } from '@metamask/utils'; +import { assert, enums, object, optional } from 'superstruct'; + +import type { BitcoinAccount, AccountsConfig } from '../entities'; +import type { AccountUseCases } from '../usecases/AccountUseCases'; +import { getProvider } from '../utils'; +import { + addressTypeToCaip2, + Caip2AddressType, + Caip2ChainId, + caip2ToAddressType, + caip2ToNetwork, + networkToCaip2, +} from './caip2'; + +export const CreateAccountRequest = object({ + scope: optional(enums(Object.values(Caip2ChainId))), + addressType: optional(enums(Object.values(Caip2AddressType))), +}); + +// TODO: enable when all methods are implemented +/* eslint-disable @typescript-eslint/no-unused-vars */ + +export class KeyringHandler implements Keyring { + readonly #accounts: AccountUseCases; + + readonly #config: AccountsConfig; + + constructor(accounts: AccountUseCases, config: AccountsConfig) { + this.#accounts = accounts; + this.#config = config; + } + + async listAccounts(): Promise { + throw new Error('Method not implemented.'); + } + + async getAccount(id: string): Promise { + throw new Error('Method not implemented.'); + } + + async createAccount( + opts: Record = {}, + ): Promise { + assert(opts, CreateAccountRequest); + + const account = await this.#accounts.createAccount( + caip2ToNetwork[opts.scope ?? this.#config.defaultNetwork], + caip2ToAddressType[opts.addressType ?? this.#config.defaultAddressType], + ); + + const keyringAccount = this.#toKeyringAccount(account); + await emitSnapKeyringEvent(getProvider(), KeyringEvent.AccountCreated, { + account: keyringAccount, + accountNameSuggestion: account.suggestedName, + }); + + return keyringAccount; + } + + async getAccountBalances( + id: string, + assets: CaipAssetType[], + ): Promise> { + throw new Error('Method not implemented.'); + } + + async filterAccountChains(id: string, chains: string[]): Promise { + throw new Error('Method not implemented.'); + } + + async updateAccount(account: KeyringAccount): Promise { + throw new Error('Method not implemented.'); + } + + async deleteAccount(id: string): Promise { + throw new Error('Method not implemented.'); + } + + async exportAccount(id: string): Promise { + throw new Error('Method not implemented.'); + } + + async listRequests(): Promise { + throw new Error('Method not implemented.'); + } + + async getRequest(id: string): Promise { + throw new Error('Method not implemented.'); + } + + async submitRequest(request: KeyringRequest): Promise { + throw new Error('Method not implemented.'); + } + + async approveRequest(id: string, data?: Record): Promise { + throw new Error('Method not implemented.'); + } + + async rejectRequest(id: string): Promise { + throw new Error('Method not implemented.'); + } + + #toKeyringAccount(account: BitcoinAccount): KeyringAccount { + return { + type: addressTypeToCaip2[account.addressType] as KeyringAccount['type'], + scopes: [networkToCaip2[account.network]], + id: account.id, + address: account.nextUnusedAddress().address, + options: {}, + methods: [BtcMethod.SendBitcoin], + }; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/caip2.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/caip2.ts new file mode 100644 index 00000000..33e7d02f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/caip2.ts @@ -0,0 +1,40 @@ +import type { AddressType, Network } from 'bitcoindevkit'; + +import { reverseMapping } from './mapping'; + +export enum Caip2ChainId { + Bitcoin = 'bip122:000000000019d6689c085ae165831e93', + Testnet = 'bip122:000000000933ea01ad0ee984209779ba', + Testnet4 = 'bip122:00000000da84f2bafbbc53dee25a72ae', + Signet = 'bip122:00000008819873e925422c1ff0f99f7c', + // We cannot predict the genesis block with regtest, so we use a custom identifier + // in this case: + Regtest = 'bip122:regtest', +} + +export enum Caip2AddressType { + P2pkh = 'bip122:p2pkh', + P2sh = 'bip122:p2sh', + P2wsh = 'bip122:p2wsh', + P2wpkh = 'bip122:p2wpkh', + P2tr = 'bip122:p2tr', +} + +export const caip2ToNetwork: Record = { + [Caip2ChainId.Bitcoin]: 'bitcoin', + [Caip2ChainId.Testnet]: 'testnet', + [Caip2ChainId.Testnet4]: 'testnet4', + [Caip2ChainId.Signet]: 'signet', + [Caip2ChainId.Regtest]: 'regtest', +}; + +export const caip2ToAddressType: Record = { + [Caip2AddressType.P2pkh]: 'p2pkh', + [Caip2AddressType.P2sh]: 'p2sh', + [Caip2AddressType.P2wsh]: 'p2wsh', + [Caip2AddressType.P2wpkh]: 'p2wpkh', + [Caip2AddressType.P2tr]: 'p2tr', +}; + +export const networkToCaip2 = reverseMapping(caip2ToNetwork); +export const addressTypeToCaip2 = reverseMapping(caip2ToAddressType); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts new file mode 100644 index 00000000..e0bbacb1 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts @@ -0,0 +1,2 @@ +export * from './KeyringHandler'; +export { Caip2AddressType, Caip2ChainId } from './caip2'; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mapping.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mapping.ts new file mode 100644 index 00000000..6dc67491 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mapping.ts @@ -0,0 +1,15 @@ +/** + * Reverse a map object. + * + * @param map - The map to reverse. + * @returns New reversed map. + */ +export function reverseMapping< + From extends string | number | symbol, + // eslint-disable-next-line @typescript-eslint/naming-convention + To extends string | number | symbol, +>(map: Record) { + return Object.fromEntries( + Object.entries(map).map(([from, to]) => [to, from]), + ) as Record; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.tsx b/merged-packages/bitcoin-wallet-snap/src/index.test.tsx index 4957d6ad..e0e407df 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/index.test.tsx @@ -15,6 +15,10 @@ import * as estimateFeeRpc from './rpcs/estimate-fee'; import * as getTxStatusRpc from './rpcs/get-transaction-status'; jest.mock('./utils/logger'); +jest.mock('bitcoindevkit', () => ({})); +jest.mock('@metamask/keyring-api', () => ({ + ...jest.requireActual('@metamask/keyring-api'), +})); jest.mock('@metamask/keyring-snap-sdk', () => ({ ...jest.requireActual('@metamask/keyring-snap-sdk'), diff --git a/merged-packages/bitcoin-wallet-snap/src/index.tsx b/merged-packages/bitcoin-wallet-snap/src/index.tsx index c7d9fffb..5edd9534 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/index.tsx @@ -1,3 +1,4 @@ +import type { Keyring } from '@metamask/keyring-api'; import { handleKeyringRequest } from '@metamask/keyring-snap-sdk'; import { type OnRpcRequestHandler, @@ -10,6 +11,9 @@ import { } from '@metamask/snaps-sdk'; import { Config } from './config'; +import { ConfigV2 } from './configv2'; +import { KeyringHandler } from './handlers/KeyringHandler'; +import { SnapClientAdapter } from './infra'; import { BtcKeyring } from './keyring'; import { InternalRpcMethod, originPermissions } from './permissions'; import type { @@ -25,14 +29,30 @@ import { import type { StartSendTransactionFlowParams } from './rpcs/start-send-transaction-flow'; import { startSendTransactionFlow } from './rpcs/start-send-transaction-flow'; import { KeyringStateManager } from './stateManagement'; +import { BdkAccountRepository } from './store/BdkAccountRepository'; import { isSendFormEvent, SendBitcoinController, } from './ui/controller/send-bitcoin-controller'; import type { SendFlowContext, SendFormState } from './ui/types'; +import { AccountUseCases } from './usecases'; import { isSnapRpcError, logger } from './utils'; import { loadLocale } from './utils/locale'; +logger.logLevel = parseInt(Config.logLevel, 10); + +let keyring: Keyring; +if (ConfigV2.keyringVersion === 'v2') { + // Infra layer + const store = new SnapClientAdapter(ConfigV2.encrypt); + // Data layer + const repository = new BdkAccountRepository(store); + // Business layer + const useCases = new AccountUseCases(repository, ConfigV2.accounts.index); + // Application layer + keyring = new KeyringHandler(useCases, ConfigV2.accounts); +} + export const validateOrigin = (origin: string, method: string): void => { if (!origin) { // eslint-disable-next-line @typescript-eslint/no-throw-literal @@ -48,8 +68,6 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request, }): Promise => { - logger.logLevel = parseInt(Config.logLevel, 10); - await loadLocale(); try { @@ -94,17 +112,17 @@ export const onKeyringRequest: OnKeyringRequestHandler = async ({ origin, request, }): Promise => { - logger.logLevel = parseInt(Config.logLevel, 10); - await loadLocale(); try { validateOrigin(origin, request.method); - const keyring = new BtcKeyring(new KeyringStateManager(), { - defaultIndex: Config.wallet.defaultAccountIndex, - origin, - }); + if (!keyring) { + keyring = new BtcKeyring(new KeyringStateManager(), { + defaultIndex: Config.wallet.defaultAccountIndex, + origin, + }); + } return (await handleKeyringRequest( keyring, diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts new file mode 100644 index 00000000..f664899a --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -0,0 +1,85 @@ +import type { + AddressInfo, + AddressType, + Balance, + DescriptorPair, + Network, +} from 'bitcoindevkit'; +import { Wallet, ChangeSet } from 'bitcoindevkit'; + +import type { BitcoinAccount } from '../entities'; + +export class BdkAccountAdapter implements BitcoinAccount { + readonly #id: string; + + readonly #wallet: Wallet; + + constructor(id: string, wallet: Wallet) { + this.#id = id; + this.#wallet = wallet; + } + + static create( + id: string, + descriptors: DescriptorPair, + network: Network, + ): BdkAccountAdapter { + return new BdkAccountAdapter(id, Wallet.create(network, descriptors)); + } + + static load(id: string, walletData: string): BdkAccountAdapter { + const changeSet = ChangeSet.from_json(walletData); + return new BdkAccountAdapter(id, Wallet.load(changeSet)); + } + + get id(): string { + return this.#id; + } + + get suggestedName(): string { + switch (this.#wallet.network()) { + case 'bitcoin': + return 'Bitcoin Account'; + case 'testnet': + return 'Bitcoin Testnet Account'; + default: + // Leave it blank to fallback to auto-suggested name on the extension side + return ''; + } + } + + get balance(): Balance { + return this.#wallet.balance(); + } + + get addressType(): AddressType { + const addressType = this.peekAddress(0).address_type; + if (!addressType) { + throw new Error( + 'unknown, non-standard or related to the future witness version.', + ); + } + + return addressType; + } + + get network(): Network { + return this.#wallet.network(); + } + + peekAddress(index: number): AddressInfo { + return this.#wallet.peek_address('external', index); + } + + nextUnusedAddress(): AddressInfo { + return this.#wallet.next_unused_address('external'); + } + + revealNextAddress(): AddressInfo { + return this.#wallet.reveal_next_address('external'); + } + + takeStaged(): ChangeSet | undefined { + return this.#wallet.take_staged(); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts new file mode 100644 index 00000000..47e3e7e8 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -0,0 +1,52 @@ +import type { JsonSLIP10Node } from '@metamask/key-tree'; +import { SLIP10Node } from '@metamask/key-tree'; + +import type { SnapClient, SnapState } from '../entities/snap'; + +export class SnapClientAdapter implements SnapClient { + readonly #encrypt: boolean; + + constructor(encrypt = false) { + this.#encrypt = encrypt; + } + + async get(): Promise { + const state = await snap.request({ + method: 'snap_manageState', + params: { + operation: 'get', + encrypted: this.#encrypt, + }, + }); + + return ( + (state as SnapState) ?? { accounts: { derivationPaths: {}, wallets: {} } } + ); + } + + async set(newState: SnapState): Promise { + await snap.request({ + method: 'snap_manageState', + params: { + operation: 'update', + newState, + encrypted: this.#encrypt, + }, + }); + } + + async getPrivateEntropy(derivationPath: string[]): Promise { + return await snap.request({ + method: 'snap_getBip32Entropy', + params: { + path: derivationPath, + curve: 'secp256k1', + }, + }); + } + + async getPublicEntropy(derivationPath: string[]): Promise { + const slip10 = await this.getPrivateEntropy(derivationPath); + return (await SLIP10Node.fromJSON(slip10)).neuter(); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts new file mode 100644 index 00000000..be733df0 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts @@ -0,0 +1,2 @@ +export * from './BdkAccountAdapter'; +export * from './SnapClientAdapter'; diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts new file mode 100644 index 00000000..00d34649 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts @@ -0,0 +1,119 @@ +import type { SLIP10Node } from '@metamask/key-tree'; +import { mock } from 'jest-mock-extended'; + +import type { BitcoinAccount } from '../entities'; +import type { SnapClient } from '../entities/snap'; +import { BdkAccountAdapter } from '../infra'; +import { BdkAccountRepository } from './BdkAccountRepository'; + +// TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('bitcoindevkit', () => { + return { + slip10_to_extended: jest.fn().mockReturnValue('mock-extended'), + xpub_to_descriptor: jest + .fn() + .mockReturnValue({ external: 'ext-desc', internal: 'int-desc' }), + }; +}); + +jest.mock('../infra/BdkAccountAdapter', () => ({ + BdkAccountAdapter: { + load: jest.fn(), + create: jest.fn().mockReturnValue({ + takeStaged: () => ({ to_json: () => '{"mywallet": "mywalletdata"}' }), + }), + }, +})); + +jest.mock('uuid', () => ({ v4: () => 'mock-uuid' })); + +describe('BdkAccountRepository', () => { + let repo: BdkAccountRepository; + const mockSnapClient = mock(); + + beforeEach(() => { + repo = new BdkAccountRepository(mockSnapClient); + }); + + describe('get', () => { + it('returns null if account not found', async () => { + mockSnapClient.get.mockResolvedValue({ + accounts: { derivationPaths: {}, wallets: {} }, + }); + + const result = await repo.get('non-existent-id'); + expect(result).toBeNull(); + expect(BdkAccountAdapter.load).not.toHaveBeenCalled(); + }); + + it('returns loaded account if found', async () => { + mockSnapClient.get.mockResolvedValue({ + accounts: { + derivationPaths: {}, + wallets: { 'some-id': '{}' }, + }, + }); + + const mockAccount = {} as BitcoinAccount; + (BdkAccountAdapter.load as jest.Mock).mockReturnValue(mockAccount); + + const result = await repo.get('some-id'); + expect(result).toBe(mockAccount); + expect(BdkAccountAdapter.load).toHaveBeenCalledWith('some-id', '{}'); + }); + }); + + describe('getByDerivationPath', () => { + it('returns null if derivation path not mapped', async () => { + mockSnapClient.get.mockResolvedValue({ + accounts: { derivationPaths: {}, wallets: {} }, + }); + + const result = await repo.getByDerivationPath(['m', "84'", "0'", "0'"]); + expect(result).toBeNull(); + }); + + it('returns account if derivation path exists', async () => { + const derivationPath = ['m', "84'", "0'", "0'"]; + mockSnapClient.get.mockResolvedValue({ + accounts: { + derivationPaths: { [derivationPath.join('/')]: 'some-id' }, + wallets: { 'some-id': '{}' }, + }, + }); + + const mockAccount = {} as BitcoinAccount; + (BdkAccountAdapter.load as jest.Mock).mockReturnValue(mockAccount); + + const result = await repo.getByDerivationPath(derivationPath); + expect(result).toBe(mockAccount); + }); + }); + + describe('insert', () => { + it('inserts a new account with xpub', async () => { + const derivationPath = ['m', "84'", "0'", "0'"]; + mockSnapClient.get.mockResolvedValue({ + accounts: { derivationPaths: {}, wallets: {} }, + }); + mockSnapClient.getPublicEntropy.mockResolvedValue({ + masterFingerprint: 0xdeadbeef, + } as unknown as SLIP10Node); + + const mockAccount = { + takeStaged: () => ({ to_json: () => '{}' }), + } as unknown as BitcoinAccount; + (BdkAccountAdapter.create as jest.Mock).mockReturnValue(mockAccount); + + await repo.insert(derivationPath, 'bitcoin', 'p2wpkh'); + + expect(mockSnapClient.set).toHaveBeenCalledWith({ + accounts: { + derivationPaths: { [derivationPath.join('/')]: 'mock-uuid' }, + wallets: { 'mock-uuid': '{}' }, + }, + }); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts new file mode 100644 index 00000000..4d8bbcde --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts @@ -0,0 +1,71 @@ +// TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 +/* eslint-disable camelcase */ + +import type { AddressType, Network } from 'bitcoindevkit'; +import { slip10_to_extended, xpub_to_descriptor } from 'bitcoindevkit'; +import { v4 } from 'uuid'; + +import type { BitcoinAccountRepository, BitcoinAccount } from '../entities'; +import type { SnapClient } from '../entities/snap'; +import { BdkAccountAdapter } from '../infra'; + +export class BdkAccountRepository implements BitcoinAccountRepository { + protected readonly _store: SnapClient; + + constructor(store: SnapClient) { + this._store = store; + } + + async get(id: string): Promise { + const state = await this._store.get(); + const walletData = state.accounts.wallets[id]; + if (!walletData) { + return null; + } + + return BdkAccountAdapter.load(id, walletData); + } + + async getByDerivationPath( + derivationPath: string[], + ): Promise { + const derivationPathId = derivationPath.join('/'); + const state = await this._store.get(); + + const id = state.accounts.derivationPaths[derivationPathId]; + if (!id) { + return null; + } + + return this.get(id); + } + + async insert( + derivationPath: string[], + network: Network, + addressType: AddressType, + ): Promise { + const slip10 = await this._store.getPublicEntropy(derivationPath); + const id = v4(); + const fingerprint = ( + slip10.masterFingerprint ?? slip10.parentFingerprint + ).toString(16); + + const xpub = slip10_to_extended(slip10, network); + const descriptors = xpub_to_descriptor( + xpub, + fingerprint, + network, + addressType, + ); + + const account = BdkAccountAdapter.create(id, descriptors, network); + + const state = await this._store.get(); + state.accounts.derivationPaths[derivationPath.join('/')] = id; + state.accounts.wallets[id] = account.takeStaged()?.to_json() ?? ''; + await this._store.set(state); + + return account; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/store/index.ts b/merged-packages/bitcoin-wallet-snap/src/store/index.ts new file mode 100644 index 00000000..1ea245da --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/index.ts @@ -0,0 +1 @@ +export * from './BdkAccountRepository'; diff --git a/merged-packages/bitcoin-wallet-snap/src/usecases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/usecases/AccountUseCases.test.ts new file mode 100644 index 00000000..6813bf7c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/usecases/AccountUseCases.test.ts @@ -0,0 +1,127 @@ +import type { AddressType, Network } from 'bitcoindevkit'; +import { mock } from 'jest-mock-extended'; + +import type { BitcoinAccount, BitcoinAccountRepository } from '../entities'; +import { AccountUseCases } from './AccountUseCases'; + +jest.mock('../utils/logger'); + +describe('AccountUseCases', () => { + let useCases: AccountUseCases; + const mockRepository = mock(); + const accountIndex = 0; + + beforeEach(() => { + useCases = new AccountUseCases(mockRepository, accountIndex); + }); + + describe('createAccount', () => { + const network: Network = 'bitcoin'; + const addressType: AddressType = 'p2wpkh'; + const mockAccount = mock(); + + beforeEach(() => { + mockRepository.insert.mockResolvedValue(mockAccount); + }); + + it.each([ + { tAddressType: 'p2pkh', purpose: "44'" }, + { tAddressType: 'p2sh', purpose: "49'" }, + { tAddressType: 'p2wsh', purpose: "45'" }, + { tAddressType: 'p2wpkh', purpose: "84'" }, + { tAddressType: 'p2tr', purpose: "86'" }, + ] as { tAddressType: AddressType; purpose: string }[])( + 'creates an account of type: %s', + async ({ tAddressType, purpose }) => { + const derivationPath = ['m', purpose, "0'", `${accountIndex}'`]; + + await useCases.createAccount(network, tAddressType); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( + derivationPath, + ); + expect(mockRepository.insert).toHaveBeenCalledWith( + derivationPath, + network, + tAddressType, + ); + }, + ); + + it.each([ + { tNetwork: 'bitcoin', coinType: "0'" }, + { tNetwork: 'testnet', coinType: "1'" }, + { tNetwork: 'testnet4', coinType: "1'" }, + { tNetwork: 'signet', coinType: "1'" }, + { tNetwork: 'regtest', coinType: "1'" }, + ] as { tNetwork: Network; coinType: string }[])( + 'should create an account on network: %s', + async ({ tNetwork, coinType }) => { + const expectedDerivationPath = [ + 'm', + "84'", + coinType, + `${accountIndex}'`, + ]; + + await useCases.createAccount(tNetwork, addressType); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( + expectedDerivationPath, + ); + expect(mockRepository.insert).toHaveBeenCalledWith( + expectedDerivationPath, + tNetwork, + addressType, + ); + }, + ); + + it('returns an existing account if one already exists', async () => { + const mockExistingAccount = mock(); + mockRepository.getByDerivationPath.mockResolvedValue(mockExistingAccount); + + const result = await useCases.createAccount(network, addressType); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.insert).not.toHaveBeenCalled(); + expect(result).toBe(mockExistingAccount); + }); + + it('creates a new account if one does not exist', async () => { + mockRepository.getByDerivationPath.mockResolvedValue(null); + + const result = await useCases.createAccount(network, addressType); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.insert).toHaveBeenCalled(); + + expect(result).toBe(mockAccount); + }); + + it('propagates an error if getByDerivationPath throws', async () => { + const error = new Error(); + mockRepository.getByDerivationPath.mockRejectedValue(error); + + await expect(useCases.createAccount(network, addressType)).rejects.toBe( + error, + ); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.insert).not.toHaveBeenCalled(); + }); + + it('propagates an error if insert throws', async () => { + const error = new Error(); + mockRepository.getByDerivationPath.mockResolvedValue(null); + mockRepository.insert.mockRejectedValue(error); + + await expect(useCases.createAccount(network, addressType)).rejects.toBe( + error, + ); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.insert).toHaveBeenCalled(); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/usecases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/usecases/AccountUseCases.ts new file mode 100644 index 00000000..9f598ac9 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/usecases/AccountUseCases.ts @@ -0,0 +1,69 @@ +import type { AddressType, Network } from 'bitcoindevkit'; + +import type { BitcoinAccount, BitcoinAccountRepository } from '../entities'; +import { logger } from '../utils'; + +const addressTypeToPurpose: Record = { + p2pkh: "44'", + p2sh: "49'", + p2wsh: "45'", + p2wpkh: "84'", + p2tr: "86'", +}; + +const networkToCoinType: Record = { + bitcoin: "0'", + testnet: "1'", + testnet4: "1'", + signet: "1'", + regtest: "1'", +}; + +export class AccountUseCases { + protected readonly _repository: BitcoinAccountRepository; + + protected readonly _accountIndex: number; + + constructor(repository: BitcoinAccountRepository, accountIndex: number) { + this._repository = repository; + this._accountIndex = accountIndex; + } + + async createAccount( + network: Network, + addressType: AddressType, + ): Promise { + logger.debug( + 'Creating new Bitcoin account. Network: %o. addressType: %o,', + network, + addressType, + ); + + const derivationPath = [ + 'm', + addressTypeToPurpose[addressType], + networkToCoinType[network], + `${this._accountIndex}'`, + ]; + + // Idempotent account creation + ensures only one account per derivation path + const account = await this._repository.getByDerivationPath(derivationPath); + if (account) { + logger.warn('Bitcoin account already exists: %s', account.id); + return account; + } + + const newAccount = await this._repository.insert( + derivationPath, + network, + addressType, + ); + + logger.info( + 'Bitcoin account created successfully: %s. derivationPath: %s', + newAccount.id, + derivationPath.join('/'), + ); + return newAccount; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/usecases/index.ts b/merged-packages/bitcoin-wallet-snap/src/usecases/index.ts new file mode 100644 index 00000000..6d0217a9 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/usecases/index.ts @@ -0,0 +1 @@ +export * from './AccountUseCases'; From 3532333faafde2e6bb68070421724a1cf2656062 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jan 2025 13:34:51 +0100 Subject: [PATCH 181/362] build(deps): bump the snaps group across 1 directory with 10 updates (#377) * build(deps): bump the snaps group across 1 directory with 10 updates Bumps the snaps group with 3 updates in the / directory: [@metamask/snaps-cli](https://github.com/MetaMask/snaps), [@metamask/snaps-jest](https://github.com/MetaMask/snaps) and [@metamask/snaps-sdk](https://github.com/MetaMask/snaps). Updates `@metamask/snaps-cli` from 6.5.0 to 6.6.0 - [Release notes](https://github.com/MetaMask/snaps/releases) - [Commits](https://github.com/MetaMask/snaps/compare/@metamask/snaps-cli@6.5.0...@metamask/snaps-cli@6.6.0) Updates `@metamask/snaps-jest` from 8.6.0 to 8.9.0 - [Release notes](https://github.com/MetaMask/snaps/releases) - [Commits](https://github.com/MetaMask/snaps/compare/@metamask/snaps-jest@8.6.0...@metamask/snaps-jest@8.9.0) Updates `@metamask/snaps-sdk` from 6.9.0 to 6.14.0 - [Release notes](https://github.com/MetaMask/snaps/releases) - [Commits](https://github.com/MetaMask/snaps/compare/@metamask/snaps-sdk@6.9.0...@metamask/snaps-sdk@6.14.0) Updates `@metamask/snaps-controllers` from 9.11.1 to 9.16.0 - [Release notes](https://github.com/MetaMask/snaps/releases) - [Commits](https://github.com/MetaMask/snaps/compare/@metamask/snaps-controllers@9.11.1...@metamask/snaps-controllers@9.16.0) Updates `@metamask/snaps-execution-environments` from 6.9.1 to 6.11.0 - [Release notes](https://github.com/MetaMask/snaps/releases) - [Commits](https://github.com/MetaMask/snaps/compare/@metamask/snaps-execution-environments@6.9.1...@metamask/snaps-execution-environments@6.11.0) Updates `@metamask/snaps-registry` from 3.2.1 to 3.2.2 - [Release notes](https://github.com/MetaMask/snaps-registry/releases) - [Changelog](https://github.com/MetaMask/snaps-registry/blob/main/CHANGELOG.md) - [Commits](https://github.com/MetaMask/snaps-registry/compare/v3.2.1...v3.2.2) Updates `@metamask/snaps-rpc-methods` from 11.5.0 to 11.8.0 - [Release notes](https://github.com/MetaMask/snaps/releases) - [Commits](https://github.com/MetaMask/snaps/compare/@metamask/snaps-rpc-methods@11.5.0...@metamask/snaps-rpc-methods@11.8.0) Updates `@metamask/snaps-simulation` from 1.2.0 to 1.5.0 - [Release notes](https://github.com/MetaMask/snaps/releases) - [Commits](https://github.com/MetaMask/snaps/compare/@metamask/snaps-simulation@1.2.0...@metamask/snaps-simulation@1.5.0) Updates `@metamask/snaps-utils` from 8.4.1 to 8.7.0 - [Release notes](https://github.com/MetaMask/snaps/releases) - [Commits](https://github.com/MetaMask/snaps/compare/@metamask/snaps-utils@8.4.1...@metamask/snaps-utils@8.7.0) Updates `@metamask/snaps-webpack-plugin` from 4.1.2 to 4.2.0 - [Release notes](https://github.com/MetaMask/snaps/releases) - [Commits](https://github.com/MetaMask/snaps/compare/@metamask/snaps-webpack-plugin@4.1.2...@metamask/snaps-webpack-plugin@4.2.0) --- updated-dependencies: - dependency-name: "@metamask/snaps-cli" dependency-type: direct:development update-type: version-update:semver-minor dependency-group: snaps - dependency-name: "@metamask/snaps-jest" dependency-type: direct:development update-type: version-update:semver-minor dependency-group: snaps - dependency-name: "@metamask/snaps-sdk" dependency-type: direct:development update-type: version-update:semver-minor dependency-group: snaps - dependency-name: "@metamask/snaps-controllers" dependency-type: indirect update-type: version-update:semver-minor dependency-group: snaps - dependency-name: "@metamask/snaps-execution-environments" dependency-type: indirect update-type: version-update:semver-minor dependency-group: snaps - dependency-name: "@metamask/snaps-registry" dependency-type: indirect update-type: version-update:semver-patch dependency-group: snaps - dependency-name: "@metamask/snaps-rpc-methods" dependency-type: indirect update-type: version-update:semver-minor dependency-group: snaps - dependency-name: "@metamask/snaps-simulation" dependency-type: indirect update-type: version-update:semver-minor dependency-group: snaps - dependency-name: "@metamask/snaps-utils" dependency-type: indirect update-type: version-update:semver-minor dependency-group: snaps - dependency-name: "@metamask/snaps-webpack-plugin" dependency-type: indirect update-type: version-update:semver-minor dependency-group: snaps ... Signed-off-by: dependabot[bot] * ts expect error * assertion type --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Dario Anongba Varela --- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 3 ++- merged-packages/bitcoin-wallet-snap/src/utils/snap.ts | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 695e0364..08e8f441 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "iolhp9W44Ell8HT4Ct6DrA2QKi3tXp7UmbXPKU5YVfo=", + "shasum": "xTv4NeeDdmnm6e3tXRfmqBFigjNK5LOXHu1boYB16iA=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -78,5 +78,6 @@ "snap_dialog": {}, "snap_getPreferences": {} }, + "platformVersion": "6.14.0", "manifestVersion": "0.1" } diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts index 9ec3dc1b..69d882d1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts @@ -139,12 +139,11 @@ export async function createSendUIDialog(interfaceId: string) { export async function getRatesFromMetamask( currency: string, ): Promise { - return (await snap.request({ - // @ts-expect-error TODO: snaps will fix this type error + return await snap.request({ method: 'snap_getCurrencyRate', params: { // @ts-expect-error TODO: snaps will fix this type error currency, }, - })) as GetCurrencyRateResult; + }); } From 07aa0fe5a59c164389cd48ef0521c707f281f06f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jan 2025 13:54:43 +0100 Subject: [PATCH 182/362] build(deps-dev): bump @metamask/key-tree from 9.1.2 to 10.0.2 (#376) * build(deps-dev): bump @metamask/key-tree from 9.1.2 to 10.0.2 Bumps [@metamask/key-tree](https://github.com/MetaMask/key-tree) from 9.1.2 to 10.0.2. - [Release notes](https://github.com/MetaMask/key-tree/releases) - [Changelog](https://github.com/MetaMask/key-tree/blob/main/CHANGELOG.md) - [Commits](https://github.com/MetaMask/key-tree/compare/v9.1.2...v10.0.2) --- updated-dependencies: - dependency-name: "@metamask/key-tree" dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * build(deps-dev): bump @metamask/key-tree from 9.1.2 to 10.0.2 Bumps [@metamask/key-tree](https://github.com/MetaMask/key-tree) from 9.1.2 to 10.0.2. - [Release notes](https://github.com/MetaMask/key-tree/releases) - [Changelog](https://github.com/MetaMask/key-tree/blob/main/CHANGELOG.md) - [Commits](https://github.com/MetaMask/key-tree/compare/v9.1.2...v10.0.2) --- updated-dependencies: - dependency-name: "@metamask/key-tree" dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Dario Anongba Varela --- 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 a61b9753..11a48c0a 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -42,7 +42,7 @@ "@metamask/eslint-config-jest": "^12.1.0", "@metamask/eslint-config-nodejs": "^12.1.0", "@metamask/eslint-config-typescript": "^12.1.0", - "@metamask/key-tree": "^9.1.2", + "@metamask/key-tree": "^10.0.2", "@metamask/keyring-api": "^13.0.0", "@metamask/keyring-snap-sdk": "^1.1.0", "@metamask/snaps-cli": "^6.5.0", From ab869cd6081f2f0002bf149d2d7785976ca72776 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 13 Jan 2025 14:05:47 +0100 Subject: [PATCH 183/362] feat: bdk get account balances (#378) * BDK create account * clean * clean * add all account types * idempotency per derivation path * create account works * lint * lint * move requests to file * feat: get balance * getBalances working * add use cases * tests for keyringHandler * test pass * final refactor * linting * tests pass * all tests pass * linting * config types * use bdk-wasm from dario_nakamoto * builds * prettier * all done * waiting on BDK deployment * use bitcoindevkit * lint * add docs * add docs * merge create account * tests pass * done * create name * done * rebase with main * align code * yarn.lock * move event emitting to snap client * use btcScopes * comments addresses --- .../bitcoin-wallet-snap/src/configv2.ts | 6 +- .../src/entities/account.ts | 35 ++- .../bitcoin-wallet-snap/src/entities/chain.ts | 17 ++ .../bitcoin-wallet-snap/src/entities/index.ts | 1 + .../bitcoin-wallet-snap/src/entities/snap.ts | 17 ++ .../src/handlers/KeyringHandler.test.ts | 140 ++++++---- .../src/handlers/KeyringHandler.ts | 44 ++- .../src/handlers/caip19.ts | 17 ++ .../bitcoin-wallet-snap/src/handlers/caip2.ts | 23 +- .../bitcoin-wallet-snap/src/handlers/index.ts | 2 +- .../bitcoin-wallet-snap/src/index.tsx | 17 +- .../src/infra/BdkAccountAdapter.ts | 27 +- .../src/infra/EsploraClientAdapter.ts | 43 +++ .../src/infra/SnapClientAdapter.ts | 18 ++ .../bitcoin-wallet-snap/src/infra/index.ts | 1 + .../src/store/BdkAccountRepository.test.ts | 76 ++++- .../src/store/BdkAccountRepository.ts | 42 ++- .../src/use-cases/AccountUseCases.test.ts | 260 ++++++++++++++++++ .../src/use-cases/AccountUseCases.ts | 119 ++++++++ .../src/{usecases => use-cases}/index.ts | 0 .../src/usecases/AccountUseCases.test.ts | 127 --------- .../src/usecases/AccountUseCases.ts | 69 ----- 22 files changed, 789 insertions(+), 312 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/chain.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/caip19.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts rename merged-packages/bitcoin-wallet-snap/src/{usecases => use-cases}/index.ts (100%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/usecases/AccountUseCases.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/usecases/AccountUseCases.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/configv2.ts b/merged-packages/bitcoin-wallet-snap/src/configv2.ts index a61082ca..92e56fa3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/configv2.ts +++ b/merged-packages/bitcoin-wallet-snap/src/configv2.ts @@ -1,7 +1,9 @@ /* eslint-disable no-restricted-globals */ +import { BtcScopes } from '@metamask/keyring-api'; + import type { SnapConfig } from './entities'; -import { Caip2AddressType, Caip2ChainId } from './handlers'; +import { Caip2AddressType } from './handlers'; // ConfigV2 exists temporarily to avoid modifying the old config object before it is removed. export const ConfigV2: SnapConfig = { @@ -10,7 +12,7 @@ export const ConfigV2: SnapConfig = { keyringVersion: process.env.KEYRING_VERSION ?? 'v1', accounts: { index: 0, - defaultNetwork: process.env.DEFAULT_NETWORK ?? Caip2ChainId.Bitcoin, + defaultNetwork: process.env.DEFAULT_NETWORK ?? BtcScopes.Mainnet, defaultAddressType: process.env.DEFAULT_ADDRESS_TYPE ?? Caip2AddressType.P2wpkh, }, diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index e0b3d3fe..24de0d53 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -1,9 +1,12 @@ import type { + FullScanRequest, + SyncRequest, AddressInfo, AddressType, Balance, - ChangeSet, Network, + Update, + ChangeSet, } from 'bitcoindevkit'; /** @@ -31,10 +34,15 @@ export type BitcoinAccount = { addressType: AddressType; /** - * The network of the account. + * The network in which the account operates. */ network: Network; + /** + * Whether the account has already performed a full scan. + */ + isScanned: boolean; + /** * Get an address at a given index. * @param index @@ -56,6 +64,23 @@ export type BitcoinAccount = { */ revealNextAddress(): AddressInfo; + /** + * Start a full scan. + * @returns the full scan request + */ + startFullScan(): FullScanRequest; + + /** + * Start a sync with revealed scripts. + * @returns the sync request + */ + startSync(): SyncRequest; + + /** + * Apply an update to the account. + */ + applyUpdate(update: Update): void; + /** * Get the change set. * @returns the change set @@ -93,4 +118,10 @@ export type BitcoinAccountRepository = { network: Network, addressType: AddressType, ): Promise; + + /** + * Update an account. + * @param account + */ + update(account: BitcoinAccount): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts b/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts new file mode 100644 index 00000000..77dd10d3 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts @@ -0,0 +1,17 @@ +import type { BitcoinAccount } from './account'; + +export type BlockchainClient = { + /** + * Perform a full scan operation on the account. + * Note that this operation modifies the account in place. + * @param account - the account to full scan. + */ + fullScan(account: BitcoinAccount): Promise; + + /** + * Perform a sync operation on the account. + * Note that this operation modifies the account in place. + * @param account - the account to sync. + */ + sync(account: BitcoinAccount): Promise; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts index f3622376..80838aff 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts @@ -1,2 +1,3 @@ export * from './account'; export * from './config'; +export * from './chain'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index cb6ab905..a858754c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -1,4 +1,6 @@ import type { JsonSLIP10Node, SLIP10Node } from '@metamask/key-tree'; +import type { KeyringAccount } from '@metamask/keyring-api'; +import type { SnapsProvider } from '@metamask/snaps-sdk'; export type SnapState = { accounts: { @@ -11,6 +13,11 @@ export type SnapState = { * The SnapClient represents the MetaMask Snap state and manages the BIP-32 entropy from the Wallet SRP. */ export type SnapClient = { + /** + * The snap global provider instance + */ + provider: SnapsProvider; + /** * Get the Snap state. * @returns The Snap state. @@ -36,4 +43,14 @@ export type SnapClient = { * @returns The public SLIP10 node. */ getPublicEntropy(derivationPath: string[]): Promise; + + /** + * Emits an event notifying the extension of a newly created account + * @param keyringAccount - The new Keyring account. + * @param name - The account suggested name. + */ + emitAccountCreatedEvent( + keyringAccount: KeyringAccount, + name: string, + ): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 62cc0824..39f17441 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -1,28 +1,14 @@ -import { BtcMethod, KeyringEvent } from '@metamask/keyring-api'; -import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import { BtcMethod, BtcScopes } from '@metamask/keyring-api'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; import type { BitcoinAccount, AccountsConfig } from '../entities'; -import type { AccountUseCases } from '../usecases/AccountUseCases'; -import { getProvider } from '../utils'; -import { - caip2ToNetwork, - caip2ToAddressType, - Caip2ChainId, - Caip2AddressType, -} from './caip2'; +import type { SnapClient } from '../entities/snap'; +import type { AccountUseCases } from '../use-cases/AccountUseCases'; +import { Caip19Asset } from './caip19'; +import { caip2ToNetwork, caip2ToAddressType, Caip2AddressType } from './caip2'; import { KeyringHandler, CreateAccountRequest } from './KeyringHandler'; -jest.mock('../utils', () => ({ - getProvider: jest.fn(), -})); - -jest.mock('@metamask/keyring-snap-sdk', () => ({ - ...jest.requireActual('@metamask/keyring-snap-sdk'), - emitSnapKeyringEvent: jest.fn(), -})); - jest.mock('superstruct', () => ({ ...jest.requireActual('superstruct'), assert: jest.fn(), @@ -30,15 +16,20 @@ jest.mock('superstruct', () => ({ describe('KeyringHandler', () => { const mockAccounts = mock(); + const mockSnapClient = mock(); const mockConfig: AccountsConfig = { index: 0, - defaultNetwork: Caip2ChainId.Bitcoin, + defaultNetwork: BtcScopes.Mainnet, defaultAddressType: Caip2AddressType.P2wpkh, }; + + // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 + /* eslint-disable @typescript-eslint/naming-convention */ const mockAccount = { id: 'some-id', addressType: caip2ToAddressType[mockConfig.defaultAddressType], suggestedName: 'My Bitcoin Account', + balance: { trusted_spendable: { to_btc: () => 1 } }, network: 'bitcoin', nextUnusedAddress: () => ({ address: 'bc1qaddress...' }), } as unknown as BitcoinAccount; @@ -46,21 +37,16 @@ describe('KeyringHandler', () => { let handler: KeyringHandler; beforeEach(() => { - handler = new KeyringHandler(mockAccounts, mockConfig); + handler = new KeyringHandler(mockAccounts, mockSnapClient, mockConfig); }); describe('createAccount', () => { - beforeEach(() => { - (getProvider as jest.Mock).mockReturnValue({}); - (emitSnapKeyringEvent as jest.Mock).mockResolvedValue(undefined); - }); - it('creates a new account with default config when no options are passed', async () => { - mockAccounts.createAccount.mockResolvedValue(mockAccount); + mockAccounts.create.mockResolvedValue(mockAccount); const expectedKeyringAccount = { id: 'some-id', type: mockConfig.defaultAddressType, - scopes: [Caip2ChainId.Bitcoin], + scopes: [BtcScopes.Mainnet], address: 'bc1qaddress...', options: {}, methods: [BtcMethod.SendBitcoin], @@ -68,72 +54,110 @@ describe('KeyringHandler', () => { const result = await handler.createAccount(); expect(assert).toHaveBeenCalledWith({}, CreateAccountRequest); - expect(mockAccounts.createAccount).toHaveBeenCalledWith( + expect(mockAccounts.create).toHaveBeenCalledWith( caip2ToNetwork[mockConfig.defaultNetwork], caip2ToAddressType[mockConfig.defaultAddressType], ); - expect(emitSnapKeyringEvent).toHaveBeenCalledWith( - {}, - KeyringEvent.AccountCreated, - { - account: expectedKeyringAccount, - accountNameSuggestion: mockAccount.suggestedName, - }, + expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalledWith( + expectedKeyringAccount, + mockAccount.suggestedName, ); expect(result).toStrictEqual(expectedKeyringAccount); }); - it('respects provided scope and addressType', async () => { - mockAccounts.createAccount.mockResolvedValue(mockAccount); + it('respects provided provided scope and addressType', async () => { + mockAccounts.create.mockResolvedValue(mockAccount); const options = { - scope: Caip2ChainId.Signet, + scope: BtcScopes.Signet, addressType: Caip2AddressType.P2pkh, }; await handler.createAccount(options); expect(assert).toHaveBeenCalledWith(options, CreateAccountRequest); - expect(mockAccounts.createAccount).toHaveBeenCalledWith( - caip2ToNetwork[Caip2ChainId.Signet], + expect(mockAccounts.create).toHaveBeenCalledWith( + caip2ToNetwork[BtcScopes.Signet], caip2ToAddressType[Caip2AddressType.P2pkh], ); }); it('propagates errors from createAccount', async () => { const error = new Error(); - mockAccounts.createAccount.mockRejectedValue(error); + mockAccounts.create.mockRejectedValue(error); await expect(handler.createAccount()).rejects.toThrow(error); - expect(mockAccounts.createAccount).toHaveBeenCalled(); - expect(emitSnapKeyringEvent).not.toHaveBeenCalled(); + expect(mockAccounts.create).toHaveBeenCalled(); + expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); }); it('propagates errors from emitSnapKeyringEvent', async () => { const error = new Error(); - mockAccounts.createAccount.mockResolvedValue(mockAccount); - (emitSnapKeyringEvent as jest.Mock).mockRejectedValue(error); + mockAccounts.create.mockResolvedValue(mockAccount); + mockSnapClient.emitAccountCreatedEvent.mockRejectedValue(error); await expect(handler.createAccount()).rejects.toThrow(error); - expect(mockAccounts.createAccount).toHaveBeenCalled(); - expect(emitSnapKeyringEvent).toHaveBeenCalled(); + expect(mockAccounts.create).toHaveBeenCalled(); + expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalled(); }); }); - describe('unimplemented methods', () => { - const errMsg = 'Method not implemented.'; + describe('getAccountBalances', () => { + it('synchronizes the account before getting the balance', async () => { + mockAccounts.synchronize.mockResolvedValue(mockAccount); + const expectedResponse = { + [Caip19Asset.Bitcoin]: { + amount: '1', + unit: 'BTC', + }, + }; - it('listAccounts should throw', async () => { - await expect(handler.listAccounts()).rejects.toThrow(errMsg); + const result = await handler.getAccountBalances(mockAccount.id); + expect(mockAccounts.synchronize).toHaveBeenCalledWith(mockAccount.id); + expect(result).toStrictEqual(expectedResponse); }); - it('getAccount should throw', async () => { - await expect(handler.getAccount('some-id')).rejects.toThrow(errMsg); - }); + it('propagates errors from synchronize', async () => { + const error = new Error(); + mockAccounts.synchronize.mockRejectedValue(error); - it('getAccountBalances should throw', async () => { - await expect(handler.getAccountBalances('some-id', [])).rejects.toThrow( - errMsg, + await expect(handler.getAccountBalances(mockAccount.id)).rejects.toThrow( + error, ); + expect(mockAccounts.synchronize).toHaveBeenCalled(); + }); + }); + + describe('getAccount', () => { + it('gets account', async () => { + mockAccounts.get.mockResolvedValue(mockAccount); + const expectedKeyringAccount = { + id: 'some-id', + type: mockConfig.defaultAddressType, + scopes: [BtcScopes.Mainnet], + address: 'bc1qaddress...', + options: {}, + methods: [BtcMethod.SendBitcoin], + }; + + const result = await handler.getAccount('some-id'); + expect(mockAccounts.get).toHaveBeenCalledWith('some-id'); + expect(result).toStrictEqual(expectedKeyringAccount); + }); + + it('propagates errors from get', async () => { + const error = new Error(); + mockAccounts.get.mockRejectedValue(error); + + await expect(handler.getAccount('some-id')).rejects.toThrow(error); + expect(mockAccounts.get).toHaveBeenCalled(); + }); + }); + + describe('unimplemented methods', () => { + const errMsg = 'Method not implemented.'; + + it('listAccounts should throw', async () => { + await expect(handler.listAccounts()).rejects.toThrow(errMsg); }); it('filterAccountChains should throw', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index a8df6c74..2a530815 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -1,4 +1,4 @@ -import { KeyringEvent, BtcMethod } from '@metamask/keyring-api'; +import { BtcMethod, BtcScopes } from '@metamask/keyring-api'; import type { KeyringAccountData, Keyring, @@ -8,24 +8,23 @@ import type { Balance, CaipAssetType, } from '@metamask/keyring-api'; -import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import type { Json } from '@metamask/utils'; import { assert, enums, object, optional } from 'superstruct'; import type { BitcoinAccount, AccountsConfig } from '../entities'; -import type { AccountUseCases } from '../usecases/AccountUseCases'; -import { getProvider } from '../utils'; +import type { SnapClient } from '../entities/snap'; +import type { AccountUseCases } from '../use-cases/AccountUseCases'; +import { networkToCaip19 } from './caip19'; import { addressTypeToCaip2, Caip2AddressType, - Caip2ChainId, caip2ToAddressType, caip2ToNetwork, networkToCaip2, } from './caip2'; export const CreateAccountRequest = object({ - scope: optional(enums(Object.values(Caip2ChainId))), + scope: optional(enums(Object.values(BtcScopes))), addressType: optional(enums(Object.values(Caip2AddressType))), }); @@ -35,11 +34,18 @@ export const CreateAccountRequest = object({ export class KeyringHandler implements Keyring { readonly #accounts: AccountUseCases; + readonly #snapClient: SnapClient; + readonly #config: AccountsConfig; - constructor(accounts: AccountUseCases, config: AccountsConfig) { + constructor( + accounts: AccountUseCases, + snapClient: SnapClient, + config: AccountsConfig, + ) { this.#accounts = accounts; this.#config = config; + this.#snapClient = snapClient; } async listAccounts(): Promise { @@ -47,7 +53,8 @@ export class KeyringHandler implements Keyring { } async getAccount(id: string): Promise { - throw new Error('Method not implemented.'); + const account = await this.#accounts.get(id); + return this.#toKeyringAccount(account); } async createAccount( @@ -55,25 +62,32 @@ export class KeyringHandler implements Keyring { ): Promise { assert(opts, CreateAccountRequest); - const account = await this.#accounts.createAccount( + const account = await this.#accounts.create( caip2ToNetwork[opts.scope ?? this.#config.defaultNetwork], caip2ToAddressType[opts.addressType ?? this.#config.defaultAddressType], ); const keyringAccount = this.#toKeyringAccount(account); - await emitSnapKeyringEvent(getProvider(), KeyringEvent.AccountCreated, { - account: keyringAccount, - accountNameSuggestion: account.suggestedName, - }); + await this.#snapClient.emitAccountCreatedEvent( + keyringAccount, + account.suggestedName, + ); return keyringAccount; } async getAccountBalances( id: string, - assets: CaipAssetType[], ): Promise> { - throw new Error('Method not implemented.'); + const account = await this.#accounts.synchronize(id); + const balance = account.balance.trusted_spendable.to_btc().toString(); + + return { + [networkToCaip19[account.network]]: { + amount: balance, + unit: 'BTC', + }, + }; } async filterAccountChains(id: string, chains: string[]): Promise { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/caip19.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/caip19.ts new file mode 100644 index 00000000..867ec492 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/caip19.ts @@ -0,0 +1,17 @@ +import type { Network } from 'bitcoindevkit'; + +export enum Caip19Asset { + Bitcoin = 'bip122:000000000019d6689c085ae165831e93/slip44:0', + Testnet = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0', + Testnet4 = 'bip122:00000000da84f2bafbbc53dee25a72ae/slip44:0', + Signet = 'bip122:00000008819873e925422c1ff0f99f7c/slip44:0', + Regtest = 'bip122:regtest/slip44:0', +} + +export const networkToCaip19: Record = { + bitcoin: Caip19Asset.Bitcoin, + testnet: Caip19Asset.Testnet, + testnet4: Caip19Asset.Testnet4, + signet: Caip19Asset.Signet, + regtest: Caip19Asset.Regtest, +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/caip2.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/caip2.ts index 33e7d02f..d6cce46b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/caip2.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/caip2.ts @@ -1,17 +1,8 @@ +import { BtcScopes } from '@metamask/keyring-api'; import type { AddressType, Network } from 'bitcoindevkit'; import { reverseMapping } from './mapping'; -export enum Caip2ChainId { - Bitcoin = 'bip122:000000000019d6689c085ae165831e93', - Testnet = 'bip122:000000000933ea01ad0ee984209779ba', - Testnet4 = 'bip122:00000000da84f2bafbbc53dee25a72ae', - Signet = 'bip122:00000008819873e925422c1ff0f99f7c', - // We cannot predict the genesis block with regtest, so we use a custom identifier - // in this case: - Regtest = 'bip122:regtest', -} - export enum Caip2AddressType { P2pkh = 'bip122:p2pkh', P2sh = 'bip122:p2sh', @@ -20,12 +11,12 @@ export enum Caip2AddressType { P2tr = 'bip122:p2tr', } -export const caip2ToNetwork: Record = { - [Caip2ChainId.Bitcoin]: 'bitcoin', - [Caip2ChainId.Testnet]: 'testnet', - [Caip2ChainId.Testnet4]: 'testnet4', - [Caip2ChainId.Signet]: 'signet', - [Caip2ChainId.Regtest]: 'regtest', +export const caip2ToNetwork: Record = { + [BtcScopes.Mainnet]: 'bitcoin', + [BtcScopes.Testnet]: 'testnet', + [BtcScopes.Testnet4]: 'testnet4', + [BtcScopes.Signet]: 'signet', + [BtcScopes.Regtest]: 'regtest', }; export const caip2ToAddressType: Record = { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts index e0bbacb1..3cead159 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts @@ -1,2 +1,2 @@ export * from './KeyringHandler'; -export { Caip2AddressType, Caip2ChainId } from './caip2'; +export { Caip2AddressType } from './caip2'; diff --git a/merged-packages/bitcoin-wallet-snap/src/index.tsx b/merged-packages/bitcoin-wallet-snap/src/index.tsx index 5edd9534..e3eac06b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/index.tsx @@ -13,7 +13,7 @@ import { import { Config } from './config'; import { ConfigV2 } from './configv2'; import { KeyringHandler } from './handlers/KeyringHandler'; -import { SnapClientAdapter } from './infra'; +import { SnapClientAdapter, EsploraClientAdapter } from './infra'; import { BtcKeyring } from './keyring'; import { InternalRpcMethod, originPermissions } from './permissions'; import type { @@ -35,7 +35,7 @@ import { SendBitcoinController, } from './ui/controller/send-bitcoin-controller'; import type { SendFlowContext, SendFormState } from './ui/types'; -import { AccountUseCases } from './usecases'; +import { AccountUseCases } from './use-cases'; import { isSnapRpcError, logger } from './utils'; import { loadLocale } from './utils/locale'; @@ -44,13 +44,18 @@ logger.logLevel = parseInt(Config.logLevel, 10); let keyring: Keyring; if (ConfigV2.keyringVersion === 'v2') { // Infra layer - const store = new SnapClientAdapter(ConfigV2.encrypt); + const snapClient = new SnapClientAdapter(ConfigV2.encrypt); + const chainClient = new EsploraClientAdapter(ConfigV2.chain); // Data layer - const repository = new BdkAccountRepository(store); + const repository = new BdkAccountRepository(snapClient); // Business layer - const useCases = new AccountUseCases(repository, ConfigV2.accounts.index); + const useCases = new AccountUseCases( + repository, + chainClient, + ConfigV2.accounts.index, + ); // Application layer - keyring = new KeyringHandler(useCases, ConfigV2.accounts); + keyring = new KeyringHandler(useCases, snapClient, ConfigV2.accounts); } export const validateOrigin = (origin: string, method: string): void => { diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index f664899a..2c6255ea 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -3,9 +3,13 @@ import type { AddressType, Balance, DescriptorPair, + FullScanRequest, Network, + SyncRequest, + Update, + ChangeSet, } from 'bitcoindevkit'; -import { Wallet, ChangeSet } from 'bitcoindevkit'; +import { Wallet } from 'bitcoindevkit'; import type { BitcoinAccount } from '../entities'; @@ -27,9 +31,8 @@ export class BdkAccountAdapter implements BitcoinAccount { return new BdkAccountAdapter(id, Wallet.create(network, descriptors)); } - static load(id: string, walletData: string): BdkAccountAdapter { - const changeSet = ChangeSet.from_json(walletData); - return new BdkAccountAdapter(id, Wallet.load(changeSet)); + static load(id: string, walletData: ChangeSet): BdkAccountAdapter { + return new BdkAccountAdapter(id, Wallet.load(walletData)); } get id(): string { @@ -67,6 +70,10 @@ export class BdkAccountAdapter implements BitcoinAccount { return this.#wallet.network(); } + get isScanned(): boolean { + return this.#wallet.latest_checkpoint().height > 0; + } + peekAddress(index: number): AddressInfo { return this.#wallet.peek_address('external', index); } @@ -79,6 +86,18 @@ export class BdkAccountAdapter implements BitcoinAccount { return this.#wallet.reveal_next_address('external'); } + startFullScan(): FullScanRequest { + return this.#wallet.start_full_scan(); + } + + startSync(): SyncRequest { + return this.#wallet.start_sync_with_revealed_spks(); + } + + applyUpdate(update: Update) { + return this.#wallet.apply_update(update); + } + takeStaged(): ChangeSet | undefined { return this.#wallet.take_staged(); } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts new file mode 100644 index 00000000..43dc2009 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts @@ -0,0 +1,43 @@ +import type { Network } from 'bitcoindevkit'; +import { EsploraClient } from 'bitcoindevkit'; + +import type { BitcoinAccount, ChainConfig } from '../entities'; +import type { BlockchainClient } from '../entities/chain'; + +export class EsploraClientAdapter implements BlockchainClient { + // Should be a Repository but we don't support custom networks so we can save in memory from config values + readonly #clients: Record; + + readonly #config: ChainConfig; + + constructor(config: ChainConfig) { + this.#clients = { + bitcoin: new EsploraClient(config.url.bitcoin), + testnet: new EsploraClient(config.url.testnet), + testnet4: new EsploraClient(config.url.testnet4), + signet: new EsploraClient(config.url.signet), + regtest: new EsploraClient(config.url.regtest), + }; + + this.#config = config; + } + + async fullScan(account: BitcoinAccount) { + const request = account.startFullScan(); + const update = await this.#clients[account.network].full_scan( + request, + this.#config.stopGap, + this.#config.parallelRequests, + ); + account.applyUpdate(update); + } + + async sync(account: BitcoinAccount) { + const request = account.startSync(); + const update = await this.#clients[account.network].sync( + request, + this.#config.parallelRequests, + ); + account.applyUpdate(update); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 47e3e7e8..771945bc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -1,5 +1,9 @@ import type { JsonSLIP10Node } from '@metamask/key-tree'; import { SLIP10Node } from '@metamask/key-tree'; +import type { KeyringAccount } from '@metamask/keyring-api'; +import { KeyringEvent } from '@metamask/keyring-api'; +import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; +import type { SnapsProvider } from '@metamask/snaps-sdk'; import type { SnapClient, SnapState } from '../entities/snap'; @@ -10,6 +14,10 @@ export class SnapClientAdapter implements SnapClient { this.#encrypt = encrypt; } + get provider(): SnapsProvider { + return snap; + } + async get(): Promise { const state = await snap.request({ method: 'snap_manageState', @@ -49,4 +57,14 @@ export class SnapClientAdapter implements SnapClient { const slip10 = await this.getPrivateEntropy(derivationPath); return (await SLIP10Node.fromJSON(slip10)).neuter(); } + + async emitAccountCreatedEvent( + keyringAccount: KeyringAccount, + name: string, + ): Promise { + return emitSnapKeyringEvent(snap, KeyringEvent.AccountCreated, { + account: keyringAccount, + accountNameSuggestion: name, + }); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts index be733df0..2941639b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts @@ -1,2 +1,3 @@ export * from './BdkAccountAdapter'; export * from './SnapClientAdapter'; +export * from './EsploraClientAdapter'; diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts index 00d34649..8c0613df 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts @@ -1,4 +1,5 @@ import type { SLIP10Node } from '@metamask/key-tree'; +import { ChangeSet } from 'bitcoindevkit'; import { mock } from 'jest-mock-extended'; import type { BitcoinAccount } from '../entities'; @@ -10,6 +11,9 @@ import { BdkAccountRepository } from './BdkAccountRepository'; /* eslint-disable @typescript-eslint/naming-convention */ jest.mock('bitcoindevkit', () => { return { + ChangeSet: { + from_json: jest.fn(), + }, slip10_to_extended: jest.fn().mockReturnValue('mock-extended'), xpub_to_descriptor: jest .fn() @@ -51,16 +55,20 @@ describe('BdkAccountRepository', () => { mockSnapClient.get.mockResolvedValue({ accounts: { derivationPaths: {}, - wallets: { 'some-id': '{}' }, + wallets: { 'some-id': '{"mywallet": "data"}' }, }, }); const mockAccount = {} as BitcoinAccount; (BdkAccountAdapter.load as jest.Mock).mockReturnValue(mockAccount); + (ChangeSet.from_json as jest.Mock).mockReturnValue({ mywallet: 'data' }); const result = await repo.get('some-id'); expect(result).toBe(mockAccount); - expect(BdkAccountAdapter.load).toHaveBeenCalledWith('some-id', '{}'); + expect(ChangeSet.from_json).toHaveBeenCalledWith('{"mywallet": "data"}'); + expect(BdkAccountAdapter.load).toHaveBeenCalledWith('some-id', { + mywallet: 'data', + }); }); }); @@ -116,4 +124,68 @@ describe('BdkAccountRepository', () => { }); }); }); + + describe('update', () => { + it('updates the account when staged changes exist', async () => { + // Initial store state with existing account + mockSnapClient.get.mockResolvedValue({ + accounts: { + derivationPaths: { "m/84'/0'/0'": 'some-id' }, + wallets: { 'some-id': '{"original":"data"}' }, + }, + }); + + // Mock account that returns a staged changeset + const mockAccount = mock(); + mockAccount.id = 'some-id'; + const staged = { + merge: jest.fn(), + to_json: jest.fn().mockReturnValue('{"merged":"data"}'), + } as unknown as ChangeSet; + mockAccount.takeStaged.mockReturnValue(staged); + + await repo.update(mockAccount); + + expect(staged.merge).toHaveBeenCalled(); + expect(mockSnapClient.set).toHaveBeenCalledWith({ + accounts: { + derivationPaths: { "m/84'/0'/0'": 'some-id' }, + wallets: { 'some-id': '{"merged":"data"}' }, + }, + }); + }); + + it('does nothing if account has no staged changes', async () => { + mockSnapClient.get.mockResolvedValue({ + accounts: { + derivationPaths: { "m/84'/0'/0'": 'some-id' }, + wallets: { 'some-id': '{"original":"data"}' }, + }, + }); + + const mockAccount = mock(); + mockAccount.id = 'some-id'; + mockAccount.takeStaged.mockReturnValue(undefined); + + await repo.update(mockAccount); + + expect(mockSnapClient.set).not.toHaveBeenCalled(); + }); + + it('throws an error if account does not exist in store', async () => { + mockSnapClient.get.mockResolvedValue({ + accounts: { + derivationPaths: {}, + wallets: {}, + }, + }); + + const mockAccount = mock(); + mockAccount.id = 'non-existent-id'; + + await expect(repo.update(mockAccount)).rejects.toThrow( + 'Inconsistent state: account not found for update', + ); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts index 4d8bbcde..948b5b9e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts @@ -2,7 +2,11 @@ /* eslint-disable camelcase */ import type { AddressType, Network } from 'bitcoindevkit'; -import { slip10_to_extended, xpub_to_descriptor } from 'bitcoindevkit'; +import { + ChangeSet, + slip10_to_extended, + xpub_to_descriptor, +} from 'bitcoindevkit'; import { v4 } from 'uuid'; import type { BitcoinAccountRepository, BitcoinAccount } from '../entities'; @@ -10,27 +14,27 @@ import type { SnapClient } from '../entities/snap'; import { BdkAccountAdapter } from '../infra'; export class BdkAccountRepository implements BitcoinAccountRepository { - protected readonly _store: SnapClient; + readonly #snapClient: SnapClient; - constructor(store: SnapClient) { - this._store = store; + constructor(snapClient: SnapClient) { + this.#snapClient = snapClient; } async get(id: string): Promise { - const state = await this._store.get(); + const state = await this.#snapClient.get(); const walletData = state.accounts.wallets[id]; if (!walletData) { return null; } - return BdkAccountAdapter.load(id, walletData); + return BdkAccountAdapter.load(id, ChangeSet.from_json(walletData)); } async getByDerivationPath( derivationPath: string[], ): Promise { const derivationPathId = derivationPath.join('/'); - const state = await this._store.get(); + const state = await this.#snapClient.get(); const id = state.accounts.derivationPaths[derivationPathId]; if (!id) { @@ -45,7 +49,7 @@ export class BdkAccountRepository implements BitcoinAccountRepository { network: Network, addressType: AddressType, ): Promise { - const slip10 = await this._store.getPublicEntropy(derivationPath); + const slip10 = await this.#snapClient.getPublicEntropy(derivationPath); const id = v4(); const fingerprint = ( slip10.masterFingerprint ?? slip10.parentFingerprint @@ -61,11 +65,29 @@ export class BdkAccountRepository implements BitcoinAccountRepository { const account = BdkAccountAdapter.create(id, descriptors, network); - const state = await this._store.get(); + const state = await this.#snapClient.get(); state.accounts.derivationPaths[derivationPath.join('/')] = id; state.accounts.wallets[id] = account.takeStaged()?.to_json() ?? ''; - await this._store.set(state); + await this.#snapClient.set(state); return account; } + + async update(account: BitcoinAccount): Promise { + const state = await this.#snapClient.get(); + const walletData = state.accounts.wallets[account.id]; + if (!walletData) { + throw new Error('Inconsistent state: account not found for update'); + } + + const newWalletData = account.takeStaged(); + if (!newWalletData) { + // Nothing to update + return; + } + + newWalletData.merge(ChangeSet.from_json(walletData)); + state.accounts.wallets[account.id] = newWalletData.to_json(); + await this.#snapClient.set(state); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts new file mode 100644 index 00000000..bc86bf2b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -0,0 +1,260 @@ +import type { AddressType, Network } from 'bitcoindevkit'; +import { mock } from 'jest-mock-extended'; + +import type { + BitcoinAccount, + BitcoinAccountRepository, + BlockchainClient, +} from '../entities'; +import { AccountUseCases } from './AccountUseCases'; + +jest.mock('../utils/logger'); + +describe('AccountUseCases', () => { + let useCases: AccountUseCases; + const mockRepository = mock(); + const mockChain = mock(); + const accountIndex = 0; + + beforeEach(() => { + useCases = new AccountUseCases(mockRepository, mockChain, accountIndex); + }); + + describe('get', () => { + it('returns account', async () => { + const mockAccount = mock(); + mockAccount.id = 'some-id'; + + mockRepository.get.mockResolvedValue(mockAccount); + + const result = await useCases.get('some-id'); + + expect(mockRepository.get).toHaveBeenCalledWith('some-id'); + expect(result).toBe(mockAccount); + }); + + it('throws Error if account is not found', async () => { + mockRepository.get.mockResolvedValue(null); + + await expect(useCases.get('some-id')).rejects.toThrow( + 'Account not found: some-id', + ); + + expect(mockRepository.get).toHaveBeenCalledWith('some-id'); + }); + + it('propagates an error if the repository get fails', async () => { + const mockAccount = mock(); + mockAccount.id = 'some-id'; + + const error = new Error('Get failed'); + mockRepository.get.mockRejectedValue(error); + + await expect(useCases.synchronize('some-id')).rejects.toBe(error); + + expect(mockRepository.get).toHaveBeenCalledWith('some-id'); + }); + }); + + describe('create', () => { + const network: Network = 'bitcoin'; + const addressType: AddressType = 'p2wpkh'; + const mockAccount = mock(); + + beforeEach(() => { + mockRepository.insert.mockResolvedValue(mockAccount); + }); + + it.each([ + { tAddressType: 'p2pkh', purpose: "44'" }, + { tAddressType: 'p2sh', purpose: "49'" }, + { tAddressType: 'p2wsh', purpose: "45'" }, + { tAddressType: 'p2wpkh', purpose: "84'" }, + { tAddressType: 'p2tr', purpose: "86'" }, + ] as { tAddressType: AddressType; purpose: string }[])( + 'creates an account of type: %s', + async ({ tAddressType, purpose }) => { + const derivationPath = ['m', purpose, "0'", `${accountIndex}'`]; + + await useCases.create(network, tAddressType); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( + derivationPath, + ); + expect(mockRepository.insert).toHaveBeenCalledWith( + derivationPath, + network, + tAddressType, + ); + }, + ); + + it.each([ + { tNetwork: 'bitcoin', coinType: "0'" }, + { tNetwork: 'testnet', coinType: "1'" }, + { tNetwork: 'testnet4', coinType: "1'" }, + { tNetwork: 'signet', coinType: "1'" }, + { tNetwork: 'regtest', coinType: "1'" }, + ] as { tNetwork: Network; coinType: string }[])( + 'should create an account on network: %s', + async ({ tNetwork, coinType }) => { + const expectedDerivationPath = [ + 'm', + "84'", + coinType, + `${accountIndex}'`, + ]; + + await useCases.create(tNetwork, addressType); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( + expectedDerivationPath, + ); + expect(mockRepository.insert).toHaveBeenCalledWith( + expectedDerivationPath, + tNetwork, + addressType, + ); + }, + ); + + it('returns an existing account if one already exists', async () => { + const mockExistingAccount = mock(); + mockRepository.getByDerivationPath.mockResolvedValue(mockExistingAccount); + + const result = await useCases.create(network, addressType); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.insert).not.toHaveBeenCalled(); + expect(result).toBe(mockExistingAccount); + }); + + it('creates a new account if one does not exist', async () => { + mockRepository.getByDerivationPath.mockResolvedValue(null); + + const result = await useCases.create(network, addressType); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.insert).toHaveBeenCalled(); + + expect(result).toBe(mockAccount); + }); + + it('propagates an error if getByDerivationPath throws', async () => { + const error = new Error(); + mockRepository.getByDerivationPath.mockRejectedValue(error); + + await expect(useCases.create(network, addressType)).rejects.toBe(error); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.insert).not.toHaveBeenCalled(); + }); + + it('propagates an error if insert throws', async () => { + const error = new Error(); + mockRepository.getByDerivationPath.mockResolvedValue(null); + mockRepository.insert.mockRejectedValue(error); + + await expect(useCases.create(network, addressType)).rejects.toBe(error); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.insert).toHaveBeenCalled(); + }); + }); + + describe('synchronize', () => { + it('throws Error if account is not found', async () => { + mockRepository.get.mockResolvedValue(null); + + await expect(useCases.synchronize('some-id')).rejects.toThrow( + 'Account not found: some-id', + ); + + expect(mockRepository.get).toHaveBeenCalledWith('some-id'); + expect(mockChain.sync).not.toHaveBeenCalled(); + expect(mockChain.fullScan).not.toHaveBeenCalled(); + expect(mockRepository.update).not.toHaveBeenCalled(); + }); + + it('performs a regular sync if the account is already scanned', async () => { + const mockAccount = mock(); + mockAccount.id = 'some-id'; + mockAccount.isScanned = true; + + mockRepository.get.mockResolvedValue(mockAccount); + + const result = await useCases.synchronize('some-id'); + + expect(mockRepository.get).toHaveBeenCalledWith('some-id'); + expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); + expect(mockChain.fullScan).not.toHaveBeenCalled(); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(result).toBe(mockAccount); + }); + + it('performs a full scan if the account is not scanned', async () => { + const mockAccount = mock(); + mockAccount.id = 'some-id'; + mockAccount.isScanned = false; + + mockRepository.get.mockResolvedValue(mockAccount); + + const result = await useCases.synchronize('some-id'); + + expect(mockRepository.get).toHaveBeenCalledWith('some-id'); + expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); + expect(mockChain.sync).not.toHaveBeenCalled(); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(result).toBe(mockAccount); + }); + + it('propagates an error if the chain sync fails', async () => { + const mockAccount = mock(); + mockAccount.id = 'some-id'; + mockAccount.isScanned = true; + + mockRepository.get.mockResolvedValue(mockAccount); + const error = new Error('Sync failed'); + mockChain.sync.mockRejectedValue(error); + + await expect(useCases.synchronize('some-id')).rejects.toBe(error); + + expect(mockRepository.get).toHaveBeenCalledWith('some-id'); + expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); + expect(mockRepository.update).not.toHaveBeenCalled(); + }); + + it('propagates an error if the chain full scan fails', async () => { + const mockAccount = mock(); + mockAccount.id = 'some-id'; + mockAccount.isScanned = false; + + mockRepository.get.mockResolvedValue(mockAccount); + const error = new Error('Full scan failed'); + mockChain.fullScan.mockRejectedValue(error); + + await expect(useCases.synchronize('some-id')).rejects.toBe(error); + + expect(mockRepository.get).toHaveBeenCalledWith('some-id'); + expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); + expect(mockRepository.update).not.toHaveBeenCalled(); + }); + + it('propagates an error if the repository update fails', async () => { + const mockAccount = mock(); + mockAccount.id = 'some-id'; + mockAccount.isScanned = true; + + mockRepository.get.mockResolvedValue(mockAccount); + mockChain.sync.mockResolvedValue(); + const error = new Error('Update failed'); + mockRepository.update.mockRejectedValue(error); + + await expect(useCases.synchronize('some-id')).rejects.toBe(error); + + expect(mockRepository.get).toHaveBeenCalledWith('some-id'); + expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts new file mode 100644 index 00000000..a75c9db5 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -0,0 +1,119 @@ +import type { AddressType, Network } from 'bitcoindevkit'; + +import type { + BitcoinAccount, + BitcoinAccountRepository, + BlockchainClient, +} from '../entities'; +import { logger } from '../utils'; + +const addressTypeToPurpose: Record = { + p2pkh: "44'", + p2sh: "49'", + p2wsh: "45'", + p2wpkh: "84'", + p2tr: "86'", +}; + +const networkToCoinType: Record = { + bitcoin: "0'", + testnet: "1'", + testnet4: "1'", + signet: "1'", + regtest: "1'", +}; + +export class AccountUseCases { + readonly #repository: BitcoinAccountRepository; + + readonly #chain: BlockchainClient; + + readonly #accountIndex: number; + + constructor( + repository: BitcoinAccountRepository, + chain: BlockchainClient, + accountIndex: number, + ) { + this.#repository = repository; + this.#chain = chain; + this.#accountIndex = accountIndex; + } + + async get(id: string): Promise { + logger.trace('Fetching account. ID: %s', id); + + const account = await this.#repository.get(id); + if (!account) { + throw new Error(`Account not found: ${id}`); + } + + logger.debug('Account found: %s', account.id); + return account; + } + + async create( + network: Network, + addressType: AddressType, + ): Promise { + logger.debug( + 'Creating new Bitcoin account. Network: %o. addressType: %o,', + network, + addressType, + ); + + const derivationPath = [ + 'm', + addressTypeToPurpose[addressType], + networkToCoinType[network], + `${this.#accountIndex}'`, + ]; + + // Idempotent account creation + ensures only one account per derivation path + const account = await this.#repository.getByDerivationPath(derivationPath); + if (account) { + logger.warn('Bitcoin account already exists: %s', account.id); + return account; + } + + const newAccount = await this.#repository.insert( + derivationPath, + network, + addressType, + ); + + logger.info( + 'Bitcoin account created successfully: %s. derivationPath: %s', + newAccount.id, + derivationPath.join('/'), + ); + return newAccount; + } + + /** + * Synchronize an account with the blockchain and update its state. + * @param id - The account id. + * @returns The updated account. + */ + async synchronize(id: string): Promise { + logger.debug('Synchronizing account. ID: %s', id); + + const account = await this.#repository.get(id); + if (!account) { + throw new Error(`Account not found: ${id}`); + } + + // If the account is already scanned, we just sync it, otherwise we do a full scan. + if (account.isScanned) { + await this.#chain.sync(account); + } else { + logger.info('Performing initial full scan: %s', account.id); + await this.#chain.fullScan(account); + } + + await this.#repository.update(account); + + logger.info('Account synchronized successfully: %s', account.id); + return account; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/usecases/index.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/usecases/index.ts rename to merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/usecases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/usecases/AccountUseCases.test.ts deleted file mode 100644 index 6813bf7c..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/usecases/AccountUseCases.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { AddressType, Network } from 'bitcoindevkit'; -import { mock } from 'jest-mock-extended'; - -import type { BitcoinAccount, BitcoinAccountRepository } from '../entities'; -import { AccountUseCases } from './AccountUseCases'; - -jest.mock('../utils/logger'); - -describe('AccountUseCases', () => { - let useCases: AccountUseCases; - const mockRepository = mock(); - const accountIndex = 0; - - beforeEach(() => { - useCases = new AccountUseCases(mockRepository, accountIndex); - }); - - describe('createAccount', () => { - const network: Network = 'bitcoin'; - const addressType: AddressType = 'p2wpkh'; - const mockAccount = mock(); - - beforeEach(() => { - mockRepository.insert.mockResolvedValue(mockAccount); - }); - - it.each([ - { tAddressType: 'p2pkh', purpose: "44'" }, - { tAddressType: 'p2sh', purpose: "49'" }, - { tAddressType: 'p2wsh', purpose: "45'" }, - { tAddressType: 'p2wpkh', purpose: "84'" }, - { tAddressType: 'p2tr', purpose: "86'" }, - ] as { tAddressType: AddressType; purpose: string }[])( - 'creates an account of type: %s', - async ({ tAddressType, purpose }) => { - const derivationPath = ['m', purpose, "0'", `${accountIndex}'`]; - - await useCases.createAccount(network, tAddressType); - - expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( - derivationPath, - ); - expect(mockRepository.insert).toHaveBeenCalledWith( - derivationPath, - network, - tAddressType, - ); - }, - ); - - it.each([ - { tNetwork: 'bitcoin', coinType: "0'" }, - { tNetwork: 'testnet', coinType: "1'" }, - { tNetwork: 'testnet4', coinType: "1'" }, - { tNetwork: 'signet', coinType: "1'" }, - { tNetwork: 'regtest', coinType: "1'" }, - ] as { tNetwork: Network; coinType: string }[])( - 'should create an account on network: %s', - async ({ tNetwork, coinType }) => { - const expectedDerivationPath = [ - 'm', - "84'", - coinType, - `${accountIndex}'`, - ]; - - await useCases.createAccount(tNetwork, addressType); - - expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( - expectedDerivationPath, - ); - expect(mockRepository.insert).toHaveBeenCalledWith( - expectedDerivationPath, - tNetwork, - addressType, - ); - }, - ); - - it('returns an existing account if one already exists', async () => { - const mockExistingAccount = mock(); - mockRepository.getByDerivationPath.mockResolvedValue(mockExistingAccount); - - const result = await useCases.createAccount(network, addressType); - - expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); - expect(mockRepository.insert).not.toHaveBeenCalled(); - expect(result).toBe(mockExistingAccount); - }); - - it('creates a new account if one does not exist', async () => { - mockRepository.getByDerivationPath.mockResolvedValue(null); - - const result = await useCases.createAccount(network, addressType); - - expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); - expect(mockRepository.insert).toHaveBeenCalled(); - - expect(result).toBe(mockAccount); - }); - - it('propagates an error if getByDerivationPath throws', async () => { - const error = new Error(); - mockRepository.getByDerivationPath.mockRejectedValue(error); - - await expect(useCases.createAccount(network, addressType)).rejects.toBe( - error, - ); - - expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); - expect(mockRepository.insert).not.toHaveBeenCalled(); - }); - - it('propagates an error if insert throws', async () => { - const error = new Error(); - mockRepository.getByDerivationPath.mockResolvedValue(null); - mockRepository.insert.mockRejectedValue(error); - - await expect(useCases.createAccount(network, addressType)).rejects.toBe( - error, - ); - - expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); - expect(mockRepository.insert).toHaveBeenCalled(); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/usecases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/usecases/AccountUseCases.ts deleted file mode 100644 index 9f598ac9..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/usecases/AccountUseCases.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { AddressType, Network } from 'bitcoindevkit'; - -import type { BitcoinAccount, BitcoinAccountRepository } from '../entities'; -import { logger } from '../utils'; - -const addressTypeToPurpose: Record = { - p2pkh: "44'", - p2sh: "49'", - p2wsh: "45'", - p2wpkh: "84'", - p2tr: "86'", -}; - -const networkToCoinType: Record = { - bitcoin: "0'", - testnet: "1'", - testnet4: "1'", - signet: "1'", - regtest: "1'", -}; - -export class AccountUseCases { - protected readonly _repository: BitcoinAccountRepository; - - protected readonly _accountIndex: number; - - constructor(repository: BitcoinAccountRepository, accountIndex: number) { - this._repository = repository; - this._accountIndex = accountIndex; - } - - async createAccount( - network: Network, - addressType: AddressType, - ): Promise { - logger.debug( - 'Creating new Bitcoin account. Network: %o. addressType: %o,', - network, - addressType, - ); - - const derivationPath = [ - 'm', - addressTypeToPurpose[addressType], - networkToCoinType[network], - `${this._accountIndex}'`, - ]; - - // Idempotent account creation + ensures only one account per derivation path - const account = await this._repository.getByDerivationPath(derivationPath); - if (account) { - logger.warn('Bitcoin account already exists: %s', account.id); - return account; - } - - const newAccount = await this._repository.insert( - derivationPath, - network, - addressType, - ); - - logger.info( - 'Bitcoin account created successfully: %s. derivationPath: %s', - newAccount.id, - derivationPath.join('/'), - ); - return newAccount; - } -} From 8dd46cc29879986ce34ce610647cafa24e2f7f6a Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 13 Jan 2025 15:41:08 +0100 Subject: [PATCH 184/362] chore: upgrade yarn to v4 (#389) * yarn v4 * manifest untouched * setup-node v4 * checkout v4 * corepack * corepack * add cache back * validate changelog * add resolutions back * yarn lock --- merged-packages/bitcoin-wallet-snap/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 11a48c0a..566ff41a 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -79,9 +79,9 @@ "typescript": "^4.7.4", "uuid": "^11.0.3" }, - "packageManager": "yarn@3.2.1", + "packageManager": "yarn@4.5.1", "engines": { - "node": ">=18.6.0" + "node": ">= 20" }, "publishConfig": { "access": "public", From e0f7a22880e5621d732c4109ba97b0497103f603 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 15 Jan 2025 15:28:37 +0100 Subject: [PATCH 185/362] test: integration tests (#382) * BDK create account * clean * clean * add all account types * idempotency per derivation path * create account works * lint * lint * move requests to file * add use cases * tests for keyringHandler * test pass * final refactor * linting * tests pass * all tests pass * linting * config types * use bdk-wasm from dario_nakamoto * builds * prettier * all done * waiting on BDK deployment * use bitcoindevkit * lint * add docs * add docs * integration tests * integration tests * done * errors lint * align * lint * restore errors * revert errors index * install docker compose * running in ci * use scopes * tests pass * remove cache failing in ci * add keyring env var to CI * run in regtest * corepack * sleep --- .../jest.integration.config.js | 4 + .../bitcoin-wallet-snap/package.json | 3 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/configv2.ts | 4 +- .../test/integration/docker-compose.yml | 13 ++ .../test/integration/init-esplora.sh | 17 ++ .../test/integration/run-integration.sh | 43 +++++ .../test/integration/snap.test.ts | 166 ++++++++++++++++++ 8 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/jest.integration.config.js create mode 100644 merged-packages/bitcoin-wallet-snap/test/integration/docker-compose.yml create mode 100755 merged-packages/bitcoin-wallet-snap/test/integration/init-esplora.sh create mode 100755 merged-packages/bitcoin-wallet-snap/test/integration/run-integration.sh create mode 100644 merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/jest.integration.config.js b/merged-packages/bitcoin-wallet-snap/jest.integration.config.js new file mode 100644 index 00000000..3c83af0a --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/jest.integration.config.js @@ -0,0 +1,4 @@ +module.exports = { + preset: '@metamask/snaps-jest', + testMatch: ['**/integration/**/*.test.ts'], +}; diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 566ff41a..0319ff7c 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -31,7 +31,8 @@ "prepublishOnly": "mm-snap manifest", "serve": "mm-snap serve", "start": "concurrently \"mm-snap watch\" \"yarn build:locale:watch\"", - "test": "jest --passWithNoTests" + "test": "jest --passWithNoTests", + "test:integration": "./test/integration/run-integration.sh" }, "devDependencies": { "@babel/preset-typescript": "^7.23.3", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 08e8f441..d00afb08 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "xTv4NeeDdmnm6e3tXRfmqBFigjNK5LOXHu1boYB16iA=", + "shasum": "uidSGSI8JIemsBnCWNp5sPMPQZ7dLZRd6k+b8vzCrng=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/configv2.ts b/merged-packages/bitcoin-wallet-snap/src/configv2.ts index 92e56fa3..320022fd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/configv2.ts +++ b/merged-packages/bitcoin-wallet-snap/src/configv2.ts @@ -30,7 +30,9 @@ export const ConfigV2: SnapConfig = { 'https://mempool.space/testnet4/api/v1', signet: process.env.ESPLORA_PROVIDER_SIGNET ?? 'https://mutinynet.com/api', - regtest: process.env.ESPLORA_PROVIDER_REGTEST ?? 'https://localhost:3000', + regtest: + process.env.ESPLORA_PROVIDER_REGTEST ?? + 'http://localhost:8094/regtest/api', }, }, }; diff --git a/merged-packages/bitcoin-wallet-snap/test/integration/docker-compose.yml b/merged-packages/bitcoin-wallet-snap/test/integration/docker-compose.yml new file mode 100644 index 00000000..3c1c6bcd --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/test/integration/docker-compose.yml @@ -0,0 +1,13 @@ +services: + esplora: + image: blockstream/esplora + container_name: esplora + ports: + - "50001:50001" + - "8094:80" + volumes: + - ./init-esplora.sh:/init-esplora.sh + entrypoint: + - bash + - -c + - "/srv/explorer/run.sh bitcoin-regtest explorer" diff --git a/merged-packages/bitcoin-wallet-snap/test/integration/init-esplora.sh b/merged-packages/bitcoin-wallet-snap/test/integration/init-esplora.sh new file mode 100755 index 00000000..de73aed1 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/test/integration/init-esplora.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -e + +cli -regtest loadwallet default || true +MINER_ADDRESS=$(cli -regtest getnewaddress) +cli -regtest generatetoaddress 100 "$MINER_ADDRESS" + +RECEIVER_ADDRESS="bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t" + +AMOUNT=500.0 +TXID=$(cli -regtest -rpcwallet=default sendtoaddress "$RECEIVER_ADDRESS" $AMOUNT) +echo "Transaction sent. TXID: $TXID" + +echo "Mining 10 blocks to confirm transaction..." +cli -regtest generatetoaddress 10 "$MINER_ADDRESS" + +echo "Setup complete. Funds sent to $RECEIVER_ADDRESS." \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/test/integration/run-integration.sh b/merged-packages/bitcoin-wallet-snap/test/integration/run-integration.sh new file mode 100755 index 00000000..4c5a03d7 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/test/integration/run-integration.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +set -e + +echo "Starting Docker services..." +docker-compose -f test/integration/docker-compose.yml up -d + +# Check if Docker services started successfully +if [ $? -ne 0 ]; then + echo "Error: Failed to start Docker services" + exit 1 +fi + +echo "Docker services started successfully." + +# Show Docker service status +docker-compose -f test/integration/docker-compose.yml ps + +echo "Waiting for Esplora to be ready..." +sleep 10 + +# Transfer funds to test address +docker exec esplora bash /init-esplora.sh + +echo "Running integration tests..." +jest --config jest.integration.config.js + +if [ $? -eq 0 ]; then + echo "Tests completed successfully." +else + echo "Tests failed." + exit 1 +fi + +echo "Stopping Docker services..." +docker-compose -f test/integration/docker-compose.yml down + +if [ $? -ne 0 ]; then + echo "Error: Failed to stop Docker services." + exit 1 +fi + +echo "Docker services stopped successfully." \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts b/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts new file mode 100644 index 00000000..33b29288 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts @@ -0,0 +1,166 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import { BtcMethod, BtcScopes } from '@metamask/keyring-api'; +import type { Snap } from '@metamask/snaps-jest'; +import { installSnap } from '@metamask/snaps-jest'; + +import { Caip2AddressType } from '../../src/handlers'; +import { Caip19Asset } from '../../src/handlers/caip19'; + +describe('Bitcoin Snap', () => { + let snap: Snap; + const accounts: Record = {}; + const origin = 'metamask'; + + it('installs the Snap', async () => { + snap = await installSnap({ + options: { + secretRecoveryPhrase: + 'journey embrace permit coil indoor stereo welcome maid movie easy clock spider tent slush bright luxury awake waste legal modify awkward answer acid goose', + }, + }); + }); + + it('creates a default account', async () => { + snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); + + const response = await snap.onKeyringRequest({ + origin, + method: 'keyring_createAccount', + params: { + options: {}, + }, + }); + + expect(response).toRespondWith({ + type: Caip2AddressType.P2wpkh, + id: expect.anything(), + address: 'bc1q832zlt4tgnqy88vd20mazw77dlt0j0wf2naw8q', + options: {}, + scopes: [BtcScopes.Mainnet], + methods: [BtcMethod.SendBitcoin], + }); + }); + + it.each([ + { + addressType: Caip2AddressType.P2wpkh, + scope: BtcScopes.Mainnet, + expectedAddress: 'bc1q832zlt4tgnqy88vd20mazw77dlt0j0wf2naw8q', + }, + { + addressType: Caip2AddressType.P2wpkh, + scope: BtcScopes.Regtest, // Use Regtest instead of Testnet for our tests + expectedAddress: 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t', + }, + { + addressType: Caip2AddressType.P2pkh, + scope: BtcScopes.Mainnet, + expectedAddress: '15feVv7kK3z7jxA4RZZzY7Fwdu3yqFwzcT', + }, + { + addressType: Caip2AddressType.P2pkh, + scope: BtcScopes.Testnet, + expectedAddress: 'mjPQaLkhZN3MxsYN8Nebzwevuz8vdTaRCq', + }, + { + addressType: Caip2AddressType.P2sh, + scope: BtcScopes.Mainnet, + expectedAddress: '3QVSaDYjxEh4L3K24eorrQjfVxPAKJMys2', + }, + { + addressType: Caip2AddressType.P2sh, + scope: BtcScopes.Testnet, + expectedAddress: '2NBG623WvXp1zxKB6gK2mnMe2mSDCur5qRU', + }, + { + addressType: Caip2AddressType.P2tr, + scope: BtcScopes.Mainnet, + expectedAddress: + 'bc1p4rue37y0v9snd4z3fvw43d29u97qxf9j3fva72xy2t7hekg24dzsaz40mz', + }, + { + addressType: Caip2AddressType.P2tr, + scope: BtcScopes.Testnet, + expectedAddress: + 'tb1pwwjax3vpq6h69965hcr22vkpm4qdvyu2pz67wyj8eagp9vxkcz0q0ya20h', + }, + ])( + 'creates an account: %s', + async ({ addressType, scope, expectedAddress }) => { + snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); + + const response = await snap.onKeyringRequest({ + origin, + method: 'keyring_createAccount', + params: { + options: { scope, addressType }, + }, + }); + + expect(response).toRespondWith({ + type: addressType, + id: expect.anything(), + address: expectedAddress, + options: {}, + scopes: [scope], + methods: [BtcMethod.SendBitcoin], + }); + + if ('result' in response.response) { + accounts[`${addressType}:${scope}`] = response.response + .result as KeyringAccount; + } + }, + ); + + it('returns the same account if already exists', async () => { + snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); + + const response = await snap.onKeyringRequest({ + origin, + method: 'keyring_createAccount', + params: { + options: { + scope: BtcScopes.Mainnet, + addressType: Caip2AddressType.P2wpkh, + }, + }, + }); + + expect(response).toRespondWith( + accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Mainnet}`], + ); + }); + + it('gets a Bitcoin account', async () => { + const response = await snap.onKeyringRequest({ + origin, + method: 'keyring_getAccount', + params: { + id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, + }, + }); + + expect(response).toRespondWith( + accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`], + ); + }); + + it('gets the balance of an account', async () => { + const response = await snap.onKeyringRequest({ + origin, + method: 'keyring_getAccountBalances', + params: { + id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, + assets: [Caip19Asset.Regtest], + }, + }); + + expect(response).toRespondWith({ + [Caip19Asset.Regtest]: { + amount: '500', + unit: 'BTC', + }, + }); + }); +}); From d32ffb201e3e30ca8827746216bfa66ecbbf40ac Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 21 Jan 2025 10:03:34 +0100 Subject: [PATCH 186/362] feat: initial state on install (#379) * BDK create account * clean * clean * add all account types * idempotency per derivation path * create account works * lint * lint * move requests to file * feat: get balance * getBalances working * add use cases * tests for keyringHandler * test pass * final refactor * linting * tests pass * all tests pass * linting * config types * use bdk-wasm from dario_nakamoto * builds * prettier * all done * waiting on BDK deployment * use bitcoindevkit * lint * add docs * add docs * merge create account * tests pass * done * create name * done * create first account on install * onInstall * integration tests * integration tests * done * errors lint * align * lint * restore errors * revert errors index * install docker compose * running in ci * use scopes * tests pass * remove cache failing in ci * add keyring env var to CI * run in regtest * corepack * sleep * default account * done * shasum * rebase * rebase * onInstall * test onInstall * test onInstall * comments addressed * renaming * lint * manifest shasum --- .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 3 +- .../bitcoin-wallet-snap/src/configv2.ts | 9 ++- .../src/entities/account.ts | 5 -- .../src/entities/config.ts | 6 +- .../bitcoin-wallet-snap/src/entities/snap.ts | 13 ++-- .../src/handlers/KeyringHandler.test.ts | 54 ++------------- .../src/handlers/KeyringHandler.ts | 66 +++++-------------- .../src/handlers/keyring-account.ts | 21 ++++++ .../bitcoin-wallet-snap/src/index.tsx | 31 ++++++++- .../src/infra/BdkAccountAdapter.ts | 12 ---- .../src/infra/SnapClientAdapter.ts | 24 +++++-- .../src/use-cases/AccountUseCases.test.ts | 44 +++++++++++-- .../src/use-cases/AccountUseCases.ts | 18 +++-- .../test/integration/snap.test.ts | 24 +------ 15 files changed, 158 insertions(+), 174 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/keyring-account.ts diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 0319ff7c..1428fba0 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -47,7 +47,7 @@ "@metamask/keyring-api": "^13.0.0", "@metamask/keyring-snap-sdk": "^1.1.0", "@metamask/snaps-cli": "^6.5.0", - "@metamask/snaps-jest": "^8.6.0", + "@metamask/snaps-jest": "^8.11.0", "@metamask/snaps-sdk": "^6.9.0", "@metamask/utils": "^9.1.0", "@typescript-eslint/eslint-plugin": "^5.42.1", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index d00afb08..fb817e21 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "uidSGSI8JIemsBnCWNp5sPMPQZ7dLZRd6k+b8vzCrng=", + "shasum": "Sf4xVOq4eIjimwhRcF6TlGA5VYRfEgBiTP7BrgcbsRk=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -72,6 +72,7 @@ "curve": "secp256k1" } ], + "endowment:lifecycle-hooks": {}, "endowment:network-access": {}, "snap_manageAccounts": {}, "snap_manageState": {}, diff --git a/merged-packages/bitcoin-wallet-snap/src/configv2.ts b/merged-packages/bitcoin-wallet-snap/src/configv2.ts index 320022fd..6c3f750f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/configv2.ts +++ b/merged-packages/bitcoin-wallet-snap/src/configv2.ts @@ -1,9 +1,8 @@ /* eslint-disable no-restricted-globals */ -import { BtcScopes } from '@metamask/keyring-api'; +import type { AddressType, Network } from 'bitcoindevkit'; import type { SnapConfig } from './entities'; -import { Caip2AddressType } from './handlers'; // ConfigV2 exists temporarily to avoid modifying the old config object before it is removed. export const ConfigV2: SnapConfig = { @@ -12,9 +11,9 @@ export const ConfigV2: SnapConfig = { keyringVersion: process.env.KEYRING_VERSION ?? 'v1', accounts: { index: 0, - defaultNetwork: process.env.DEFAULT_NETWORK ?? BtcScopes.Mainnet, - defaultAddressType: - process.env.DEFAULT_ADDRESS_TYPE ?? Caip2AddressType.P2wpkh, + defaultNetwork: (process.env.DEFAULT_NETWORK ?? 'bitcoin') as Network, + defaultAddressType: (process.env.DEFAULT_ADDRESS_TYPE ?? + 'p2wpkh') as AddressType, }, chain: { parallelRequests: 1, diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index 24de0d53..d27d8ae8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -18,11 +18,6 @@ export type BitcoinAccount = { */ id: string; - /** - * The suggested name of the account. - */ - suggestedName: string; - /** * The balance of the account. */ diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts index 1131b18b..c073370c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -1,4 +1,4 @@ -import type { Network } from 'bitcoindevkit'; +import type { AddressType, Network } from 'bitcoindevkit'; export type SnapConfig = { encrypt: boolean; @@ -9,8 +9,8 @@ export type SnapConfig = { export type AccountsConfig = { index: number; - defaultNetwork: string; - defaultAddressType: string; + defaultNetwork: Network; + defaultAddressType: AddressType; }; export type ChainConfig = { diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index a858754c..6ce170d9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -1,7 +1,8 @@ import type { JsonSLIP10Node, SLIP10Node } from '@metamask/key-tree'; -import type { KeyringAccount } from '@metamask/keyring-api'; import type { SnapsProvider } from '@metamask/snaps-sdk'; +import type { BitcoinAccount } from './account'; + export type SnapState = { accounts: { derivationPaths: Record; @@ -45,12 +46,8 @@ export type SnapClient = { getPublicEntropy(derivationPath: string[]): Promise; /** - * Emits an event notifying the extension of a newly created account - * @param keyringAccount - The new Keyring account. - * @param name - The account suggested name. + * Emits an event notifying the extension of a newly created Bitcoin account + * @param account - The Bitcoin account. */ - emitAccountCreatedEvent( - keyringAccount: KeyringAccount, - name: string, - ): Promise; + emitAccountCreatedEvent(account: BitcoinAccount): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 39f17441..1d17f5e0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -2,8 +2,7 @@ import { BtcMethod, BtcScopes } from '@metamask/keyring-api'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; -import type { BitcoinAccount, AccountsConfig } from '../entities'; -import type { SnapClient } from '../entities/snap'; +import type { BitcoinAccount } from '../entities'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; import { Caip19Asset } from './caip19'; import { caip2ToNetwork, caip2ToAddressType, Caip2AddressType } from './caip2'; @@ -16,18 +15,12 @@ jest.mock('superstruct', () => ({ describe('KeyringHandler', () => { const mockAccounts = mock(); - const mockSnapClient = mock(); - const mockConfig: AccountsConfig = { - index: 0, - defaultNetwork: BtcScopes.Mainnet, - defaultAddressType: Caip2AddressType.P2wpkh, - }; // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 /* eslint-disable @typescript-eslint/naming-convention */ const mockAccount = { id: 'some-id', - addressType: caip2ToAddressType[mockConfig.defaultAddressType], + addressType: 'p2wpkh', suggestedName: 'My Bitcoin Account', balance: { trusted_spendable: { to_btc: () => 1 } }, network: 'bitcoin', @@ -37,34 +30,10 @@ describe('KeyringHandler', () => { let handler: KeyringHandler; beforeEach(() => { - handler = new KeyringHandler(mockAccounts, mockSnapClient, mockConfig); + handler = new KeyringHandler(mockAccounts); }); describe('createAccount', () => { - it('creates a new account with default config when no options are passed', async () => { - mockAccounts.create.mockResolvedValue(mockAccount); - const expectedKeyringAccount = { - id: 'some-id', - type: mockConfig.defaultAddressType, - scopes: [BtcScopes.Mainnet], - address: 'bc1qaddress...', - options: {}, - methods: [BtcMethod.SendBitcoin], - }; - - const result = await handler.createAccount(); - expect(assert).toHaveBeenCalledWith({}, CreateAccountRequest); - expect(mockAccounts.create).toHaveBeenCalledWith( - caip2ToNetwork[mockConfig.defaultNetwork], - caip2ToAddressType[mockConfig.defaultAddressType], - ); - expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalledWith( - expectedKeyringAccount, - mockAccount.suggestedName, - ); - expect(result).toStrictEqual(expectedKeyringAccount); - }); - it('respects provided provided scope and addressType', async () => { mockAccounts.create.mockResolvedValue(mockAccount); @@ -85,19 +54,10 @@ describe('KeyringHandler', () => { const error = new Error(); mockAccounts.create.mockRejectedValue(error); - await expect(handler.createAccount()).rejects.toThrow(error); - expect(mockAccounts.create).toHaveBeenCalled(); - expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); - }); - - it('propagates errors from emitSnapKeyringEvent', async () => { - const error = new Error(); - mockAccounts.create.mockResolvedValue(mockAccount); - mockSnapClient.emitAccountCreatedEvent.mockRejectedValue(error); - - await expect(handler.createAccount()).rejects.toThrow(error); + await expect( + handler.createAccount({ options: { scopes: [BtcScopes.Mainnet] } }), + ).rejects.toThrow(error); expect(mockAccounts.create).toHaveBeenCalled(); - expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalled(); }); }); @@ -132,7 +92,7 @@ describe('KeyringHandler', () => { mockAccounts.get.mockResolvedValue(mockAccount); const expectedKeyringAccount = { id: 'some-id', - type: mockConfig.defaultAddressType, + type: Caip2AddressType.P2wpkh, scopes: [BtcScopes.Mainnet], address: 'bc1qaddress...', options: {}, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 2a530815..b1ec595b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -1,4 +1,4 @@ -import { BtcMethod, BtcScopes } from '@metamask/keyring-api'; +import { BtcScopes } from '@metamask/keyring-api'; import type { KeyringAccountData, Keyring, @@ -11,20 +11,13 @@ import type { import type { Json } from '@metamask/utils'; import { assert, enums, object, optional } from 'superstruct'; -import type { BitcoinAccount, AccountsConfig } from '../entities'; -import type { SnapClient } from '../entities/snap'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; import { networkToCaip19 } from './caip19'; -import { - addressTypeToCaip2, - Caip2AddressType, - caip2ToAddressType, - caip2ToNetwork, - networkToCaip2, -} from './caip2'; +import { Caip2AddressType, caip2ToAddressType, caip2ToNetwork } from './caip2'; +import { snapToKeyringAccount } from './keyring-account'; export const CreateAccountRequest = object({ - scope: optional(enums(Object.values(BtcScopes))), + scope: enums(Object.values(BtcScopes)), addressType: optional(enums(Object.values(Caip2AddressType))), }); @@ -32,20 +25,10 @@ export const CreateAccountRequest = object({ /* eslint-disable @typescript-eslint/no-unused-vars */ export class KeyringHandler implements Keyring { - readonly #accounts: AccountUseCases; + readonly #accountsUseCases: AccountUseCases; - readonly #snapClient: SnapClient; - - readonly #config: AccountsConfig; - - constructor( - accounts: AccountUseCases, - snapClient: SnapClient, - config: AccountsConfig, - ) { - this.#accounts = accounts; - this.#config = config; - this.#snapClient = snapClient; + constructor(accounts: AccountUseCases) { + this.#accountsUseCases = accounts; } async listAccounts(): Promise { @@ -53,33 +36,25 @@ export class KeyringHandler implements Keyring { } async getAccount(id: string): Promise { - const account = await this.#accounts.get(id); - return this.#toKeyringAccount(account); + const account = await this.#accountsUseCases.get(id); + return snapToKeyringAccount(account); } - async createAccount( - opts: Record = {}, - ): Promise { + async createAccount(opts: Record): Promise { assert(opts, CreateAccountRequest); - const account = await this.#accounts.create( - caip2ToNetwork[opts.scope ?? this.#config.defaultNetwork], - caip2ToAddressType[opts.addressType ?? this.#config.defaultAddressType], - ); - - const keyringAccount = this.#toKeyringAccount(account); - await this.#snapClient.emitAccountCreatedEvent( - keyringAccount, - account.suggestedName, + const account = await this.#accountsUseCases.create( + caip2ToNetwork[opts.scope], + opts.addressType ? caip2ToAddressType[opts.addressType] : undefined, ); - return keyringAccount; + return snapToKeyringAccount(account); } async getAccountBalances( id: string, ): Promise> { - const account = await this.#accounts.synchronize(id); + const account = await this.#accountsUseCases.synchronize(id); const balance = account.balance.trusted_spendable.to_btc().toString(); return { @@ -125,15 +100,4 @@ export class KeyringHandler implements Keyring { async rejectRequest(id: string): Promise { throw new Error('Method not implemented.'); } - - #toKeyringAccount(account: BitcoinAccount): KeyringAccount { - return { - type: addressTypeToCaip2[account.addressType] as KeyringAccount['type'], - scopes: [networkToCaip2[account.network]], - id: account.id, - address: account.nextUnusedAddress().address, - options: {}, - methods: [BtcMethod.SendBitcoin], - }; - } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/keyring-account.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/keyring-account.ts new file mode 100644 index 00000000..c76ee6a5 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/keyring-account.ts @@ -0,0 +1,21 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import { BtcMethod } from '@metamask/keyring-api'; + +import type { BitcoinAccount } from '../entities'; +import { addressTypeToCaip2, networkToCaip2 } from './caip2'; + +/** + * Maps a Bitcoin Account to a Keyring Account. + * @param account - The Bitcoin account. + * @returns The Keyring account. + */ +export function snapToKeyringAccount(account: BitcoinAccount): KeyringAccount { + return { + type: addressTypeToCaip2[account.addressType] as KeyringAccount['type'], + scopes: [networkToCaip2[account.network]], + id: account.id, + address: account.nextUnusedAddress().address, + options: {}, + methods: [BtcMethod.SendBitcoin], + }; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/index.tsx b/merged-packages/bitcoin-wallet-snap/src/index.tsx index e3eac06b..3d81bfdc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/index.tsx @@ -1,5 +1,6 @@ import type { Keyring } from '@metamask/keyring-api'; import { handleKeyringRequest } from '@metamask/keyring-snap-sdk'; +import type { OnInstallHandler } from '@metamask/snaps-sdk'; import { type OnRpcRequestHandler, type OnKeyringRequestHandler, @@ -42,6 +43,7 @@ import { loadLocale } from './utils/locale'; logger.logLevel = parseInt(Config.logLevel, 10); let keyring: Keyring; +let accountsUseCases: AccountUseCases; if (ConfigV2.keyringVersion === 'v2') { // Infra layer const snapClient = new SnapClientAdapter(ConfigV2.encrypt); @@ -49,13 +51,14 @@ if (ConfigV2.keyringVersion === 'v2') { // Data layer const repository = new BdkAccountRepository(snapClient); // Business layer - const useCases = new AccountUseCases( + accountsUseCases = new AccountUseCases( + snapClient, repository, chainClient, - ConfigV2.accounts.index, + ConfigV2.accounts, ); // Application layer - keyring = new KeyringHandler(useCases, snapClient, ConfigV2.accounts); + keyring = new KeyringHandler(accountsUseCases); } export const validateOrigin = (origin: string, method: string): void => { @@ -69,6 +72,28 @@ export const validateOrigin = (origin: string, method: string): void => { } }; +export const onInstall: OnInstallHandler = async () => { + try { + // No need for a handler given the lack of request + if (accountsUseCases) { + await accountsUseCases.create( + ConfigV2.accounts.defaultNetwork, + ConfigV2.accounts.defaultAddressType, + ); + } + } catch (error) { + let snapError = error; + + if (!isSnapRpcError(error)) { + snapError = new SnapError(error); + } + logger.error( + `onInstall error: ${JSON.stringify(snapError.toJSON(), null, 2)}`, + ); + throw snapError; + } +}; + export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request, diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index 2c6255ea..39d41063 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -39,18 +39,6 @@ export class BdkAccountAdapter implements BitcoinAccount { return this.#id; } - get suggestedName(): string { - switch (this.#wallet.network()) { - case 'bitcoin': - return 'Bitcoin Account'; - case 'testnet': - return 'Bitcoin Testnet Account'; - default: - // Leave it blank to fallback to auto-suggested name on the extension side - return ''; - } - } - get balance(): Balance { return this.#wallet.balance(); } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 771945bc..caf4b3cc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -1,11 +1,12 @@ import type { JsonSLIP10Node } from '@metamask/key-tree'; import { SLIP10Node } from '@metamask/key-tree'; -import type { KeyringAccount } from '@metamask/keyring-api'; import { KeyringEvent } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import type { SnapsProvider } from '@metamask/snaps-sdk'; +import type { BitcoinAccount } from '../entities'; import type { SnapClient, SnapState } from '../entities/snap'; +import { snapToKeyringAccount } from '../handlers/keyring-account'; export class SnapClientAdapter implements SnapClient { readonly #encrypt: boolean; @@ -58,13 +59,22 @@ export class SnapClientAdapter implements SnapClient { return (await SLIP10Node.fromJSON(slip10)).neuter(); } - async emitAccountCreatedEvent( - keyringAccount: KeyringAccount, - name: string, - ): Promise { + async emitAccountCreatedEvent(account: BitcoinAccount): Promise { + const suggestedName = () => { + switch (account.network) { + case 'bitcoin': + return 'Bitcoin Account'; + case 'testnet': + return 'Bitcoin Testnet Account'; + default: + // Leave it blank to fallback to auto-suggested name on the extension side + return ''; + } + }; + return emitSnapKeyringEvent(snap, KeyringEvent.AccountCreated, { - account: keyringAccount, - accountNameSuggestion: name, + account: snapToKeyringAccount(account), + accountNameSuggestion: suggestedName(), }); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index bc86bf2b..e5317070 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -2,22 +2,35 @@ import type { AddressType, Network } from 'bitcoindevkit'; import { mock } from 'jest-mock-extended'; import type { + AccountsConfig, BitcoinAccount, BitcoinAccountRepository, BlockchainClient, } from '../entities'; +import type { SnapClient } from '../entities/snap'; import { AccountUseCases } from './AccountUseCases'; jest.mock('../utils/logger'); describe('AccountUseCases', () => { let useCases: AccountUseCases; + + const mockSnapClient = mock(); const mockRepository = mock(); const mockChain = mock(); - const accountIndex = 0; + const accountsConfig: AccountsConfig = { + index: 0, + defaultAddressType: 'p2wpkh', + defaultNetwork: 'bitcoin', + }; beforeEach(() => { - useCases = new AccountUseCases(mockRepository, mockChain, accountIndex); + useCases = new AccountUseCases( + mockSnapClient, + mockRepository, + mockChain, + accountsConfig, + ); }); describe('get', () => { @@ -74,7 +87,7 @@ describe('AccountUseCases', () => { ] as { tAddressType: AddressType; purpose: string }[])( 'creates an account of type: %s', async ({ tAddressType, purpose }) => { - const derivationPath = ['m', purpose, "0'", `${accountIndex}'`]; + const derivationPath = ['m', purpose, "0'", `${accountsConfig.index}'`]; await useCases.create(network, tAddressType); @@ -86,6 +99,9 @@ describe('AccountUseCases', () => { network, tAddressType, ); + expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalledWith( + mockAccount, + ); }, ); @@ -102,7 +118,7 @@ describe('AccountUseCases', () => { 'm', "84'", coinType, - `${accountIndex}'`, + `${accountsConfig.index}'`, ]; await useCases.create(tNetwork, addressType); @@ -115,6 +131,9 @@ describe('AccountUseCases', () => { tNetwork, addressType, ); + expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalledWith( + mockAccount, + ); }, ); @@ -126,6 +145,8 @@ describe('AccountUseCases', () => { expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.insert).not.toHaveBeenCalled(); + expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); + expect(result).toBe(mockExistingAccount); }); @@ -136,6 +157,7 @@ describe('AccountUseCases', () => { expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.insert).toHaveBeenCalled(); + expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalled(); expect(result).toBe(mockAccount); }); @@ -148,6 +170,7 @@ describe('AccountUseCases', () => { expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.insert).not.toHaveBeenCalled(); + expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); }); it('propagates an error if insert throws', async () => { @@ -159,6 +182,19 @@ describe('AccountUseCases', () => { expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.insert).toHaveBeenCalled(); + expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); + }); + + it('propagates an error if emitAccountCreatedEvent throws', async () => { + const error = new Error(); + mockRepository.getByDerivationPath.mockResolvedValue(null); + mockSnapClient.emitAccountCreatedEvent.mockRejectedValue(error); + + await expect(useCases.create(network, addressType)).rejects.toBe(error); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.insert).toHaveBeenCalled(); + expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalled(); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index a75c9db5..ebc5bf84 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -1,10 +1,12 @@ import type { AddressType, Network } from 'bitcoindevkit'; import type { + AccountsConfig, BitcoinAccount, BitcoinAccountRepository, BlockchainClient, } from '../entities'; +import type { SnapClient } from '../entities/snap'; import { logger } from '../utils'; const addressTypeToPurpose: Record = { @@ -24,20 +26,24 @@ const networkToCoinType: Record = { }; export class AccountUseCases { + readonly #snapClient: SnapClient; + readonly #repository: BitcoinAccountRepository; readonly #chain: BlockchainClient; - readonly #accountIndex: number; + readonly #accountConfig: AccountsConfig; constructor( + snapClient: SnapClient, repository: BitcoinAccountRepository, chain: BlockchainClient, - accountIndex: number, + accountConfig: AccountsConfig, ) { + this.#snapClient = snapClient; this.#repository = repository; this.#chain = chain; - this.#accountIndex = accountIndex; + this.#accountConfig = accountConfig; } async get(id: string): Promise { @@ -54,7 +60,7 @@ export class AccountUseCases { async create( network: Network, - addressType: AddressType, + addressType: AddressType = this.#accountConfig.defaultAddressType, ): Promise { logger.debug( 'Creating new Bitcoin account. Network: %o. addressType: %o,', @@ -66,7 +72,7 @@ export class AccountUseCases { 'm', addressTypeToPurpose[addressType], networkToCoinType[network], - `${this.#accountIndex}'`, + `${this.#accountConfig.index}'`, ]; // Idempotent account creation + ensures only one account per derivation path @@ -82,6 +88,8 @@ export class AccountUseCases { addressType, ); + await this.#snapClient.emitAccountCreatedEvent(newAccount); + logger.info( 'Bitcoin account created successfully: %s. derivationPath: %s', newAccount.id, diff --git a/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts b/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts index 33b29288..fbc6d552 100644 --- a/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts +++ b/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts @@ -11,34 +11,14 @@ describe('Bitcoin Snap', () => { const accounts: Record = {}; const origin = 'metamask'; - it('installs the Snap', async () => { + it('installs the Snap and creates a default account', async () => { snap = await installSnap({ options: { secretRecoveryPhrase: 'journey embrace permit coil indoor stereo welcome maid movie easy clock spider tent slush bright luxury awake waste legal modify awkward answer acid goose', }, }); - }); - - it('creates a default account', async () => { - snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); - - const response = await snap.onKeyringRequest({ - origin, - method: 'keyring_createAccount', - params: { - options: {}, - }, - }); - - expect(response).toRespondWith({ - type: Caip2AddressType.P2wpkh, - id: expect.anything(), - address: 'bc1q832zlt4tgnqy88vd20mazw77dlt0j0wf2naw8q', - options: {}, - scopes: [BtcScopes.Mainnet], - methods: [BtcMethod.SendBitcoin], - }); + await snap.onInstall(); }); it.each([ From 57813d6e0a9c0fcc292b7be286bb5efce3a88bc3 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 21 Jan 2025 13:42:26 +0100 Subject: [PATCH 187/362] feat: bdk list accounts (#394) * BDK create account * clean * clean * add all account types * idempotency per derivation path * create account works * lint * lint * move requests to file * feat: get balance * getBalances working * add use cases * tests for keyringHandler * test pass * final refactor * linting * tests pass * all tests pass * linting * config types * use bdk-wasm from dario_nakamoto * builds * prettier * all done * waiting on BDK deployment * use bitcoindevkit * lint * add docs * add docs * merge create account * tests pass * done * create name * done * create first account on install * onInstall * integration tests * integration tests * done * errors lint * align * lint * restore errors * revert errors index * install docker compose * running in ci * use scopes * tests pass * remove cache failing in ci * add keyring env var to CI * run in regtest * corepack * sleep * default account * done * shasum * rebase * rebase * done * esplora * add filterAccountChains * onInstall * test onInstall * test onInstall * Update packages/snap/src/handlers/KeyringHandler.test.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * Update packages/snap/src/use-cases/AccountUseCases.test.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * comments addressed * renaming * lint * manifest shasum * done --------- Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/entities/account.ts | 6 ++ .../src/handlers/KeyringHandler.test.ts | 66 ++++++++++++++++--- .../src/handlers/KeyringHandler.ts | 14 +++- .../src/store/BdkAccountRepository.test.ts | 26 ++++++++ .../src/store/BdkAccountRepository.ts | 9 +++ .../src/use-cases/AccountUseCases.test.ts | 26 +++++++- .../src/use-cases/AccountUseCases.ts | 11 +++- .../test/integration/snap.test.ts | 25 +++++++ 9 files changed, 170 insertions(+), 15 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index fb817e21..6dc15f2d 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "Sf4xVOq4eIjimwhRcF6TlGA5VYRfEgBiTP7BrgcbsRk=", + "shasum": "fHYmwBL5kiJGtnQVKv1wIyUQO1cEy9wsIcorxNnnUfM=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index d27d8ae8..51290673 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -94,6 +94,12 @@ export type BitcoinAccountRepository = { */ get(id: string): Promise; + /** + * Get all accounts. + * @returns the list of accounts + */ + getAll(): Promise; + /** * Get an account by its derivation path. * @param derivationPath diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 1d17f5e0..68ca257b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -113,19 +113,69 @@ describe('KeyringHandler', () => { }); }); - describe('unimplemented methods', () => { - const errMsg = 'Method not implemented.'; + describe('listAccounts', () => { + it('lists accounts', async () => { + mockAccounts.list.mockResolvedValue([mockAccount]); + const expectedKeyringAccounts = [ + { + id: 'some-id', + type: Caip2AddressType.P2wpkh, + scopes: [BtcScopes.Mainnet], + address: 'bc1qaddress...', + options: {}, + methods: [BtcMethod.SendBitcoin], + }, + ]; - it('listAccounts should throw', async () => { - await expect(handler.listAccounts()).rejects.toThrow(errMsg); + const result = await handler.listAccounts(); + expect(mockAccounts.list).toHaveBeenCalled(); + expect(result).toStrictEqual(expectedKeyringAccounts); }); - it('filterAccountChains should throw', async () => { - await expect(handler.filterAccountChains('some-id', [])).rejects.toThrow( - errMsg, - ); + it('propagates errors from list', async () => { + const error = new Error(); + mockAccounts.list.mockRejectedValue(error); + + await expect(handler.listAccounts()).rejects.toThrow(error); + expect(mockAccounts.list).toHaveBeenCalled(); + }); + }); + + describe('filterAccountChains', () => { + it('includes chain if account network corresponds', async () => { + mockAccounts.get.mockResolvedValue(mockAccount); + + const result = await handler.filterAccountChains('some-id', [ + BtcScopes.Mainnet, + ]); + expect(mockAccounts.get).toHaveBeenCalledWith('some-id'); + expect(result).toStrictEqual([BtcScopes.Mainnet]); + }); + + it('does not include chain if account network does not correspond', async () => { + mockAccounts.get.mockResolvedValue(mockAccount); + + const result = await handler.filterAccountChains('some-id', [ + BtcScopes.Testnet, + ]); + expect(mockAccounts.get).toHaveBeenCalledWith('some-id'); + expect(result).toStrictEqual([]); }); + it('propagates errors from get', async () => { + const error = new Error(); + mockAccounts.get.mockRejectedValue(error); + + await expect( + handler.filterAccountChains('some-id', [BtcScopes.Mainnet]), + ).rejects.toThrow(error); + expect(mockAccounts.get).toHaveBeenCalled(); + }); + }); + + describe('unimplemented methods', () => { + const errMsg = 'Method not implemented.'; + it('updateAccount should throw', async () => { await expect(handler.updateAccount({} as any)).rejects.toThrow(errMsg); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index b1ec595b..2bc2579a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -13,7 +13,12 @@ import { assert, enums, object, optional } from 'superstruct'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; import { networkToCaip19 } from './caip19'; -import { Caip2AddressType, caip2ToAddressType, caip2ToNetwork } from './caip2'; +import { + Caip2AddressType, + caip2ToAddressType, + caip2ToNetwork, + networkToCaip2, +} from './caip2'; import { snapToKeyringAccount } from './keyring-account'; export const CreateAccountRequest = object({ @@ -32,7 +37,8 @@ export class KeyringHandler implements Keyring { } async listAccounts(): Promise { - throw new Error('Method not implemented.'); + const accounts = await this.#accountsUseCases.list(); + return accounts.map(snapToKeyringAccount); } async getAccount(id: string): Promise { @@ -66,7 +72,9 @@ export class KeyringHandler implements Keyring { } async filterAccountChains(id: string, chains: string[]): Promise { - throw new Error('Method not implemented.'); + const account = await this.#accountsUseCases.get(id); + const accountChain = networkToCaip2[account.network]; + return chains.includes(accountChain) ? [accountChain] : []; } async updateAccount(account: KeyringAccount): Promise { diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts index 8c0613df..92366d99 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts @@ -72,6 +72,32 @@ describe('BdkAccountRepository', () => { }); }); + describe('getAll', () => { + it('returns all accounts', async () => { + mockSnapClient.get.mockResolvedValue({ + accounts: { + derivationPaths: {}, + wallets: { + 'some-id': '{"foo":"bar"}', + 'another-id': '{"hello":"world"}', + }, + }, + }); + + const mockAccount1 = {} as BitcoinAccount; + const mockAccount2 = {} as BitcoinAccount; + + (ChangeSet.from_json as jest.Mock).mockImplementation((json) => json); + (BdkAccountAdapter.load as jest.Mock) + .mockReturnValueOnce(mockAccount1) + .mockReturnValueOnce(mockAccount2); + + const result = await repo.getAll(); + expect(result).toStrictEqual([mockAccount1, mockAccount2]); + expect(BdkAccountAdapter.load).toHaveBeenCalledTimes(2); + }); + }); + describe('getByDerivationPath', () => { it('returns null if derivation path not mapped', async () => { mockSnapClient.get.mockResolvedValue({ diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts index 948b5b9e..b502a582 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts @@ -30,6 +30,15 @@ export class BdkAccountRepository implements BitcoinAccountRepository { return BdkAccountAdapter.load(id, ChangeSet.from_json(walletData)); } + async getAll(): Promise { + const state = await this.#snapClient.get(); + const walletsData = state.accounts.wallets; + + return Object.entries(walletsData).map(([id, walletData]) => + BdkAccountAdapter.load(id, ChangeSet.from_json(walletData)), + ); + } + async getByDerivationPath( derivationPath: string[], ): Promise { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index e5317070..27a7b39a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -63,12 +63,34 @@ describe('AccountUseCases', () => { const error = new Error('Get failed'); mockRepository.get.mockRejectedValue(error); - await expect(useCases.synchronize('some-id')).rejects.toBe(error); + await expect(useCases.get('some-id')).rejects.toBe(error); expect(mockRepository.get).toHaveBeenCalledWith('some-id'); }); }); + describe('list', () => { + it('returns accounts', async () => { + const mockAccount = mock(); + + mockRepository.getAll.mockResolvedValue([mockAccount]); + + const result = await useCases.list(); + + expect(mockRepository.getAll).toHaveBeenCalled(); + expect(result).toStrictEqual([mockAccount]); + }); + + it('propagates an error if the repository getAll fails', async () => { + const error = new Error('Get failed'); + mockRepository.getAll.mockRejectedValue(error); + + await expect(useCases.list()).rejects.toBe(error); + + expect(mockRepository.getAll).toHaveBeenCalled(); + }); + }); + describe('create', () => { const network: Network = 'bitcoin'; const addressType: AddressType = 'p2wpkh'; @@ -145,7 +167,7 @@ describe('AccountUseCases', () => { expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.insert).not.toHaveBeenCalled(); - expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); + expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalled(); expect(result).toBe(mockExistingAccount); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index ebc5bf84..955b2dc5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -46,6 +46,15 @@ export class AccountUseCases { this.#accountConfig = accountConfig; } + async list(): Promise { + logger.trace('Listing accounts'); + + const accounts = await this.#repository.getAll(); + + logger.debug('Accounts listed successfully'); + return accounts; + } + async get(id: string): Promise { logger.trace('Fetching account. ID: %s', id); @@ -78,7 +87,7 @@ export class AccountUseCases { // Idempotent account creation + ensures only one account per derivation path const account = await this.#repository.getByDerivationPath(derivationPath); if (account) { - logger.warn('Bitcoin account already exists: %s', account.id); + await this.#snapClient.emitAccountCreatedEvent(account); return account; } diff --git a/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts b/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts index fbc6d552..28e999b9 100644 --- a/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts +++ b/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts @@ -19,6 +19,22 @@ describe('Bitcoin Snap', () => { }, }); await snap.onInstall(); + + const response = await snap.onKeyringRequest({ + origin, + method: 'keyring_listAccounts', + }); + + expect(response).toRespondWith([ + { + type: Caip2AddressType.P2wpkh, + id: expect.anything(), + address: 'bc1q832zlt4tgnqy88vd20mazw77dlt0j0wf2naw8q', + options: {}, + scopes: [BtcScopes.Mainnet], + methods: [BtcMethod.SendBitcoin], + }, + ]); }); it.each([ @@ -126,6 +142,15 @@ describe('Bitcoin Snap', () => { ); }); + it('lists all Bitcoin accounts', async () => { + const response = await snap.onKeyringRequest({ + origin, + method: 'keyring_listAccounts', + }); + + expect(response).toRespondWith(Object.values(accounts)); + }); + it('gets the balance of an account', async () => { const response = await snap.onKeyringRequest({ origin, From 436af35fb0041f8c1cdddf59c5e60b5169299955 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 21 Jan 2025 18:42:10 +0100 Subject: [PATCH 188/362] feat: bdk remove account (#393) * BDK create account * clean * clean * add all account types * idempotency per derivation path * create account works * lint * lint * move requests to file * feat: get balance * getBalances working * add use cases * tests for keyringHandler * test pass * final refactor * linting * tests pass * all tests pass * linting * config types * use bdk-wasm from dario_nakamoto * builds * prettier * all done * waiting on BDK deployment * use bitcoindevkit * lint * add docs * add docs * merge create account * tests pass * done * create name * done * create first account on install * onInstall * integration tests * integration tests * done * errors lint * align * lint * restore errors * revert errors index * install docker compose * running in ci * use scopes * tests pass * remove cache failing in ci * add keyring env var to CI * run in regtest * corepack * sleep * default account * done * need rebase * remove account * done * revert esplora * shasum * rebase * rebase * onInstall * test onInstall * test onInstall * comments addressed * renaming * throw when account not found * lint * manifest shasum * should not throw --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/entities/account.ts | 7 ++ .../bitcoin-wallet-snap/src/entities/snap.ts | 6 ++ .../src/handlers/KeyringHandler.test.ts | 19 ++++- .../src/handlers/KeyringHandler.ts | 2 +- .../src/infra/SnapClientAdapter.ts | 6 ++ .../src/store/BdkAccountRepository.test.ts | 27 ++++++ .../src/store/BdkAccountRepository.ts | 22 +++++ .../src/use-cases/AccountUseCases.test.ts | 82 +++++++++++++++++++ .../src/use-cases/AccountUseCases.ts | 21 +++++ .../test/integration/run-integration.sh | 1 - .../test/integration/snap.test.ts | 44 ++++++++++ 12 files changed, 232 insertions(+), 7 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 6dc15f2d..c242e21a 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "fHYmwBL5kiJGtnQVKv1wIyUQO1cEy9wsIcorxNnnUfM=", + "shasum": "8BljvvVS1LYhoZ69ZfSmaBRwUe8Jqa9HVakJ8D0gx6g=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index 51290673..3fa151f7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -125,4 +125,11 @@ export type BitcoinAccountRepository = { * @param account */ update(account: BitcoinAccount): Promise; + + /** + * Delete an account. + * @param id + * @returns true if the account has been deleted. + */ + delete(id: string): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index 6ce170d9..e8e565a3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -50,4 +50,10 @@ export type SnapClient = { * @param account - The Bitcoin account. */ emitAccountCreatedEvent(account: BitcoinAccount): Promise; + + /** + * Emits an event notifying the extension of a deleted Bitcoin account + * @param account - The Bitcoin account id. + */ + emitAccountDeletedEvent(id: string): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 68ca257b..e8b82105 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -173,6 +173,21 @@ describe('KeyringHandler', () => { }); }); + describe('deleteAccount', () => { + it('deletes account', async () => { + await handler.deleteAccount('some-id'); + expect(mockAccounts.delete).toHaveBeenCalledWith('some-id'); + }); + + it('propagates errors from delete', async () => { + const error = new Error(); + mockAccounts.delete.mockRejectedValue(error); + + await expect(handler.deleteAccount('some-id')).rejects.toThrow(error); + expect(mockAccounts.delete).toHaveBeenCalled(); + }); + }); + describe('unimplemented methods', () => { const errMsg = 'Method not implemented.'; @@ -180,10 +195,6 @@ describe('KeyringHandler', () => { await expect(handler.updateAccount({} as any)).rejects.toThrow(errMsg); }); - it('deleteAccount should throw', async () => { - await expect(handler.deleteAccount('some-id')).rejects.toThrow(errMsg); - }); - it('exportAccount should throw', async () => { await expect(handler.exportAccount('some-id')).rejects.toThrow(errMsg); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 2bc2579a..cf974e7a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -82,7 +82,7 @@ export class KeyringHandler implements Keyring { } async deleteAccount(id: string): Promise { - throw new Error('Method not implemented.'); + await this.#accountsUseCases.delete(id); } async exportAccount(id: string): Promise { diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index caf4b3cc..8c70f5ba 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -77,4 +77,10 @@ export class SnapClientAdapter implements SnapClient { accountNameSuggestion: suggestedName(), }); } + + async emitAccountDeletedEvent(id: string): Promise { + return emitSnapKeyringEvent(snap, KeyringEvent.AccountDeleted, { + id, + }); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts index 92366d99..912df98f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts @@ -214,4 +214,31 @@ describe('BdkAccountRepository', () => { ); }); }); + + describe('delete', () => { + it('does nothing if account not found', async () => { + mockSnapClient.get.mockResolvedValue({ + accounts: { derivationPaths: {}, wallets: {} }, + }); + + await repo.delete('non-existent-id'); + + expect(mockSnapClient.set).not.toHaveBeenCalled(); + }); + + it('removes wallet data and derivation path from store if present', async () => { + mockSnapClient.get.mockResolvedValue({ + accounts: { + derivationPaths: { "m/84'/0'/0'": 'some-id' }, + wallets: { 'some-id': '{"wallet":"data"}' }, + }, + }); + + await repo.delete('some-id'); + + expect(mockSnapClient.set).toHaveBeenCalledWith({ + accounts: { derivationPaths: {}, wallets: {} }, + }); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts index b502a582..4b6d742a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts @@ -99,4 +99,26 @@ export class BdkAccountRepository implements BitcoinAccountRepository { state.accounts.wallets[account.id] = newWalletData.to_json(); await this.#snapClient.set(state); } + + async delete(id: string): Promise { + const state = await this.#snapClient.get(); + const walletData = state.accounts.wallets[id]; + if (!walletData) { + return; + } + + delete state.accounts.wallets[id]; + + // Find the path in derivationPaths that points to this id and remove it + for (const [path, existingId] of Object.entries( + state.accounts.derivationPaths, + )) { + if (existingId === id) { + delete state.accounts.derivationPaths[path]; + break; + } + } + + await this.#snapClient.set(state); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 27a7b39a..a93a7a9a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -315,4 +315,86 @@ describe('AccountUseCases', () => { expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); }); }); + + describe('delete', () => { + it('throws error if account is not found', async () => { + mockRepository.get.mockResolvedValue(null); + + await expect(useCases.delete('non-existent-id')).rejects.toThrow( + 'Account not found: non-existent-id', + ); + + expect(mockRepository.get).toHaveBeenCalledWith('non-existent-id'); + expect(mockSnapClient.emitAccountDeletedEvent).not.toHaveBeenCalled(); + expect(mockRepository.delete).not.toHaveBeenCalled(); + }); + + it('throws error if account is the default account', async () => { + const defaultAccount = mock(); + defaultAccount.id = 'default-id'; + defaultAccount.addressType = accountsConfig.defaultAddressType; + defaultAccount.network = accountsConfig.defaultNetwork; + + mockRepository.get.mockResolvedValue(defaultAccount); + + await expect(useCases.delete('default-id')).rejects.toThrow( + 'Default Bitcoin account cannot be removed', + ); + + expect(mockRepository.get).toHaveBeenCalledWith('default-id'); + expect(mockSnapClient.emitAccountDeletedEvent).not.toHaveBeenCalled(); + expect(mockRepository.delete).not.toHaveBeenCalled(); + }); + + it('removes account if not default', async () => { + const mockAccount = mock(); + mockAccount.id = 'some-id'; + mockAccount.addressType = 'p2wpkh'; + mockAccount.network = 'testnet'; + + mockRepository.get.mockResolvedValue(mockAccount); + + await useCases.delete(mockAccount.id); + + expect(mockRepository.get).toHaveBeenCalledWith(mockAccount.id); + expect(mockSnapClient.emitAccountDeletedEvent).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockRepository.delete).toHaveBeenCalledWith(mockAccount.id); + }); + + it('propagates an error if the event emitting fails', async () => { + const mockAccount = mock(); + mockAccount.id = 'some-id'; + mockAccount.addressType = 'p2wpkh'; + mockAccount.network = 'testnet'; + const error = new Error('Event emit failed'); + + mockRepository.get.mockResolvedValue(mockAccount); + mockSnapClient.emitAccountDeletedEvent.mockRejectedValue(error); + + await expect(useCases.delete(mockAccount.id)).rejects.toBe(error); + + expect(mockRepository.get).toHaveBeenCalled(); + expect(mockSnapClient.emitAccountDeletedEvent).toHaveBeenCalled(); + expect(mockRepository.delete).not.toHaveBeenCalled(); + }); + + it('propagates an error if the repository fails', async () => { + const mockAccount = mock(); + mockAccount.id = 'some-id'; + mockAccount.addressType = 'p2wpkh'; + mockAccount.network = 'testnet'; + const error = new Error('Delete failed'); + + mockRepository.get.mockResolvedValue(mockAccount); + mockRepository.delete.mockRejectedValue(error); + + await expect(useCases.delete(mockAccount.id)).rejects.toBe(error); + + expect(mockRepository.get).toHaveBeenCalled(); + expect(mockSnapClient.emitAccountDeletedEvent).toHaveBeenCalled(); + expect(mockRepository.delete).toHaveBeenCalled(); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 955b2dc5..a40cd6dc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -133,4 +133,25 @@ export class AccountUseCases { logger.info('Account synchronized successfully: %s', account.id); return account; } + + async delete(id: string): Promise { + logger.debug('Deleting account. ID: %s', id); + + const account = await this.#repository.get(id); + if (!account) { + throw new Error(`Account not found: ${id}`); + } + + if ( + account.addressType === this.#accountConfig.defaultAddressType && + account.network === this.#accountConfig.defaultNetwork + ) { + throw new Error('Default Bitcoin account cannot be removed'); + } + + await this.#snapClient.emitAccountDeletedEvent(id); + await this.#repository.delete(id); + + logger.info('Account deleted successfully: %s', account.id); + } } diff --git a/merged-packages/bitcoin-wallet-snap/test/integration/run-integration.sh b/merged-packages/bitcoin-wallet-snap/test/integration/run-integration.sh index 4c5a03d7..817dc0c9 100755 --- a/merged-packages/bitcoin-wallet-snap/test/integration/run-integration.sh +++ b/merged-packages/bitcoin-wallet-snap/test/integration/run-integration.sh @@ -29,7 +29,6 @@ if [ $? -eq 0 ]; then echo "Tests completed successfully." else echo "Tests failed." - exit 1 fi echo "Stopping Docker services..." diff --git a/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts b/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts index 28e999b9..39ccd057 100644 --- a/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts +++ b/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts @@ -168,4 +168,48 @@ describe('Bitcoin Snap', () => { }, }); }); + + it('removes an account', async () => { + const { id } = accounts[`${Caip2AddressType.P2pkh}:${BtcScopes.Mainnet}`]; + + let response = await snap.onKeyringRequest({ + origin, + method: 'keyring_deleteAccount', + params: { + id, + }, + }); + + expect(response).toRespondWith(null); + + response = await snap.onKeyringRequest({ + origin, + method: 'keyring_getAccount', + params: { + id, + }, + }); + + expect(response).toRespondWithError({ + code: -32603, + message: `Account not found: ${id}`, + stack: expect.anything(), + }); + }); + + it('fails to remove the default account', async () => { + const response = await snap.onKeyringRequest({ + origin, + method: 'keyring_deleteAccount', + params: { + id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Mainnet}`].id, + }, + }); + + expect(response).toRespondWithError({ + code: -32603, + message: 'Default Bitcoin account cannot be removed', + stack: expect.anything(), + }); + }); }); From 1147295a353b26b07b2195522245570855163d77 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Sat, 25 Jan 2025 00:23:45 +0800 Subject: [PATCH 189/362] fix: typo (#402) * fix: typo * fix: typo and update manifest --- merged-packages/bitcoin-wallet-snap/locales/en.json | 2 +- merged-packages/bitcoin-wallet-snap/messages.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/locales/en.json b/merged-packages/bitcoin-wallet-snap/locales/en.json index e332bc85..1741d84d 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/en.json +++ b/merged-packages/bitcoin-wallet-snap/locales/en.json @@ -86,7 +86,7 @@ "message": "Error" }, "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinials, Rare SATs, and Runes to be send in Bitcoin Transactions." + "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." } } } diff --git a/merged-packages/bitcoin-wallet-snap/messages.json b/merged-packages/bitcoin-wallet-snap/messages.json index 4da07a79..c4bab0fd 100644 --- a/merged-packages/bitcoin-wallet-snap/messages.json +++ b/merged-packages/bitcoin-wallet-snap/messages.json @@ -86,6 +86,6 @@ "message": "Error" }, "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinials, Rare SATs, and Runes to be send in Bitcoin Transactions." + "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." } } diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index c242e21a..b3cdad00 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "8BljvvVS1LYhoZ69ZfSmaBRwUe8Jqa9HVakJ8D0gx6g=", + "shasum": "Po2WCpjK4vNVbS7SMk8XRfNPc4l+NqdsgVY3F5/it+8=", "location": { "npm": { "filePath": "dist/bundle.js", From a5caaf9fc437610f3b430985d54d74b75d6fa9a5 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 28 Jan 2025 15:59:03 +0100 Subject: [PATCH 190/362] feat: list account transactions and assets (#405) * done * manifest --- .../bitcoin-wallet-snap/package.json | 10 ++-- .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../src/handlers/KeyringHandler.test.ts | 60 ++++++++++++------- .../src/handlers/KeyringHandler.ts | 32 ++++------ 4 files changed, 57 insertions(+), 49 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 1428fba0..79e435f4 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -44,12 +44,12 @@ "@metamask/eslint-config-nodejs": "^12.1.0", "@metamask/eslint-config-typescript": "^12.1.0", "@metamask/key-tree": "^10.0.2", - "@metamask/keyring-api": "^13.0.0", - "@metamask/keyring-snap-sdk": "^1.1.0", - "@metamask/snaps-cli": "^6.5.0", + "@metamask/keyring-api": "^15.0.0", + "@metamask/keyring-snap-sdk": "^2.1.0", + "@metamask/snaps-cli": "^6.6.1", "@metamask/snaps-jest": "^8.11.0", - "@metamask/snaps-sdk": "^6.9.0", - "@metamask/utils": "^9.1.0", + "@metamask/snaps-sdk": "^6.16.0", + "@metamask/utils": "^11.0.1", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", "async-mutex": "^0.3.2", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index b3cdad00..66be8442 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "Po2WCpjK4vNVbS7SMk8XRfNPc4l+NqdsgVY3F5/it+8=", + "shasum": "7CppukMFl7zAKC6tqv9pfbCBuss3n6qJeK2vTicD244=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -79,6 +79,6 @@ "snap_dialog": {}, "snap_getPreferences": {} }, - "platformVersion": "6.14.0", + "platformVersion": "6.16.0", "manifestVersion": "0.1" } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index e8b82105..6cda36e9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -1,4 +1,5 @@ import { BtcMethod, BtcScopes } from '@metamask/keyring-api'; +import type { Network } from 'bitcoindevkit'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; @@ -188,35 +189,48 @@ describe('KeyringHandler', () => { }); }); - describe('unimplemented methods', () => { - const errMsg = 'Method not implemented.'; + describe('listAccountAssets', () => { + it.each([ + { tNetwork: 'bitcoin', caip19: Caip19Asset.Bitcoin }, + { tNetwork: 'testnet', caip19: Caip19Asset.Testnet }, + { tNetwork: 'testnet4', caip19: Caip19Asset.Testnet4 }, + { tNetwork: 'signet', caip19: Caip19Asset.Signet }, + { tNetwork: 'regtest', caip19: Caip19Asset.Regtest }, + ] as { tNetwork: Network; caip19: Caip19Asset }[])( + 'list assets for account: %s', + async ({ tNetwork, caip19 }) => { + mockAccounts.get.mockResolvedValue({ + network: tNetwork, + } as unknown as BitcoinAccount); + + const result = await handler.listAccountAssets('some-id'); + + expect(mockAccounts.get).toHaveBeenCalledWith('some-id'); + expect(result).toStrictEqual([caip19]); + }, + ); - it('updateAccount should throw', async () => { - await expect(handler.updateAccount({} as any)).rejects.toThrow(errMsg); - }); - - it('exportAccount should throw', async () => { - await expect(handler.exportAccount('some-id')).rejects.toThrow(errMsg); - }); - - it('listRequests should throw', async () => { - await expect(handler.listRequests()).rejects.toThrow(errMsg); - }); - - it('getRequest should throw', async () => { - await expect(handler.getRequest('some-id')).rejects.toThrow(errMsg); - }); + it('propagates errors from get', async () => { + const error = new Error(); + mockAccounts.get.mockRejectedValue(error); - it('submitRequest should throw', async () => { - await expect(handler.submitRequest({} as any)).rejects.toThrow(errMsg); + await expect(handler.listAccountAssets('some-id')).rejects.toThrow(error); + expect(mockAccounts.get).toHaveBeenCalled(); }); + }); - it('approveRequest should throw', async () => { - await expect(handler.approveRequest({} as any)).rejects.toThrow(errMsg); + describe('listAccountTransactions', () => { + it('returns empty list', async () => { + const result = await handler.listAccountTransactions(); + expect(result).toStrictEqual({ data: [], next: null }); }); + }); - it('rejectRequest should throw', async () => { - await expect(handler.rejectRequest({} as any)).rejects.toThrow(errMsg); + describe('updateAccount', () => { + it('throws not supported', async () => { + await expect(handler.updateAccount({} as any)).rejects.toThrow( + 'Method not supported.', + ); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index cf974e7a..7df3fec1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -1,12 +1,14 @@ import { BtcScopes } from '@metamask/keyring-api'; import type { - KeyringAccountData, Keyring, KeyringAccount, KeyringRequest, KeyringResponse, Balance, CaipAssetType, + CaipAssetTypeOrId, + Paginated, + Transaction, } from '@metamask/keyring-api'; import type { Json } from '@metamask/utils'; import { assert, enums, object, optional } from 'superstruct'; @@ -77,35 +79,27 @@ export class KeyringHandler implements Keyring { return chains.includes(accountChain) ? [accountChain] : []; } - async updateAccount(account: KeyringAccount): Promise { - throw new Error('Method not implemented.'); + async updateAccount(_: KeyringAccount): Promise { + throw new Error('Method not supported.'); } async deleteAccount(id: string): Promise { await this.#accountsUseCases.delete(id); } - async exportAccount(id: string): Promise { - throw new Error('Method not implemented.'); - } - - async listRequests(): Promise { - throw new Error('Method not implemented.'); + async listAccountAssets(id: string): Promise { + const account = await this.#accountsUseCases.get(id); + return [networkToCaip19[account.network]]; } - async getRequest(id: string): Promise { - throw new Error('Method not implemented.'); + async listAccountTransactions(): Promise> { + return { + data: [], + next: null, + }; } async submitRequest(request: KeyringRequest): Promise { throw new Error('Method not implemented.'); } - - async approveRequest(id: string, data?: Record): Promise { - throw new Error('Method not implemented.'); - } - - async rejectRequest(id: string): Promise { - throw new Error('Method not implemented.'); - } } From 26e45e08caaf4c26af1716616bca0ad26a98bfe8 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 29 Jan 2025 15:41:38 +0100 Subject: [PATCH 191/362] fix: missing permissions for transactions and assets (#408) * fix missing permissions * done * docker --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/permissions.ts | 2 + .../test/integration/snap.test.ts | 47 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 66be8442..39770ab3 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "7CppukMFl7zAKC6tqv9pfbCBuss3n6qJeK2vTicD244=", + "shasum": "04gfJKr+mIiyAdLwmlVhxIBVJeZ51TX9KB8y3Tz1864=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/permissions.ts index 94365c79..28b58280 100644 --- a/merged-packages/bitcoin-wallet-snap/src/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/permissions.ts @@ -22,6 +22,8 @@ const dappPermissions = new Set([ const metamaskPermissions = new Set([ // Keyring methods KeyringRpcMethod.ListAccounts, + KeyringRpcMethod.ListAccountTransactions, + KeyringRpcMethod.ListAccountAssets, KeyringRpcMethod.GetAccount, KeyringRpcMethod.CreateAccount, KeyringRpcMethod.FilterAccountChains, diff --git a/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts b/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts index 39ccd057..0b9612d2 100644 --- a/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts +++ b/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts @@ -212,4 +212,51 @@ describe('Bitcoin Snap', () => { stack: expect.anything(), }); }); + + it('returns empty list for account transactions', async () => { + const response = await snap.onKeyringRequest({ + origin, + method: 'keyring_listAccountTransactions', + params: { + id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, + pagination: { limit: 10, next: null }, + }, + }); + + expect(response).toRespondWith({ + data: [], + next: null, + }); + }); + + it.each([ + { + addressType: Caip2AddressType.P2wpkh, + scope: BtcScopes.Mainnet, + expectedAssets: [Caip19Asset.Bitcoin], + }, + { + addressType: Caip2AddressType.P2wpkh, + scope: BtcScopes.Regtest, + expectedAssets: [Caip19Asset.Regtest], + }, + { + addressType: Caip2AddressType.P2tr, + scope: BtcScopes.Testnet, + expectedAssets: [Caip19Asset.Testnet], + }, + ])( + 'lists account assets: %s', + async ({ addressType, scope, expectedAssets }) => { + const response = await snap.onKeyringRequest({ + origin, + method: 'keyring_listAccountAssets', + params: { + id: accounts[`${addressType}:${scope}`].id, + }, + }); + + expect(response).toRespondWith(expectedAssets); + }, + ); }); From f1ce7d334bcbc92ad654b38cb111e1397ed8439d Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 3 Feb 2025 15:25:26 +0100 Subject: [PATCH 192/362] feat: cron job (#407) * done * manifest * done * before rebase * fix missing permissions * done * docekr * log debug * Update packages/snap/test/integration/snap.test.ts Co-authored-by: Charly Chevalier * apply comments * move to use cases * synchronize --------- Co-authored-by: Charly Chevalier --- .../bitcoin-wallet-snap/snap.manifest.json | 14 +++- .../src/entities/currency.ts | 16 ++++ .../bitcoin-wallet-snap/src/entities/index.ts | 1 + .../src/handlers/CronHandler.test.ts | 29 ++++++++ .../src/handlers/CronHandler.ts | 19 +++++ .../src/handlers/KeyringHandler.test.ts | 14 ++-- .../src/handlers/KeyringHandler.ts | 5 +- .../bitcoin-wallet-snap/src/handlers/index.ts | 1 + .../src/handlers/keyring-account.ts | 2 +- .../bitcoin-wallet-snap/src/index.tsx | 34 +++++++-- .../src/use-cases/AccountUseCases.test.ts | 47 +++++++++++- .../src/use-cases/AccountUseCases.ts | 36 +++++++-- .../test/integration/snap.test.ts | 73 +++++++++++-------- 13 files changed, 231 insertions(+), 60 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/currency.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 39770ab3..204af421 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "04gfJKr+mIiyAdLwmlVhxIBVJeZ51TX9KB8y3Tz1864=", + "shasum": "w0SWCvX3NDkdvwN/r7hyiUp692lKrm8HhmIRaVN3uJ8=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -77,7 +77,17 @@ "snap_manageAccounts": {}, "snap_manageState": {}, "snap_dialog": {}, - "snap_getPreferences": {} + "snap_getPreferences": {}, + "endowment:cronjob": { + "jobs": [ + { + "expression": "* * * * *", + "request": { + "method": "synchronize" + } + } + ] + } }, "platformVersion": "6.16.0", "manifestVersion": "0.1" diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts b/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts new file mode 100644 index 00000000..3dfe0b83 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts @@ -0,0 +1,16 @@ +import type { Network } from 'bitcoindevkit'; + +export enum CurrencyUnit { + Bitcoin = 'BTC', + Testnet = 'tBTC', + Signet = 'sBTC', + Regtest = 'rBTC', +} + +export const networkToCurrencyUnit: Record = { + bitcoin: CurrencyUnit.Bitcoin, + testnet: CurrencyUnit.Testnet, + testnet4: CurrencyUnit.Testnet, + signet: CurrencyUnit.Signet, + regtest: CurrencyUnit.Regtest, +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts index 80838aff..234308dd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts @@ -1,3 +1,4 @@ export * from './account'; export * from './config'; export * from './chain'; +export * from './currency'; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts new file mode 100644 index 00000000..d7b55a12 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -0,0 +1,29 @@ +import { mock } from 'jest-mock-extended'; + +import type { AccountUseCases } from '../use-cases/AccountUseCases'; +import { CronHandler } from './CronHandler'; + +describe('CronHandler', () => { + const mockAccountUseCases = mock(); + let handler: CronHandler; + + beforeEach(() => { + handler = new CronHandler(mockAccountUseCases); + }); + + describe('route', () => { + it('synchronizes all accounts', async () => { + await handler.route('synchronize'); + + expect(mockAccountUseCases.synchronizeAll).toHaveBeenCalled(); + }); + + it('propagates errors from synchronizeAll', async () => { + const error = new Error(); + mockAccountUseCases.synchronizeAll.mockRejectedValue(error); + + await expect(handler.route('synchronize')).rejects.toThrow(error); + expect(mockAccountUseCases.synchronizeAll).toHaveBeenCalled(); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts new file mode 100644 index 00000000..b6b8a90f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -0,0 +1,19 @@ +import type { AccountUseCases } from '../use-cases/AccountUseCases'; + +export class CronHandler { + readonly #accountsUseCases: AccountUseCases; + + constructor(accounts: AccountUseCases) { + this.#accountsUseCases = accounts; + } + + async route(method: string): Promise { + switch (method) { + case 'synchronize': { + return await this.#accountsUseCases.synchronizeAll(); + } + default: + throw new Error('Method not found.'); + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 6cda36e9..3c63eeaf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -25,7 +25,7 @@ describe('KeyringHandler', () => { suggestedName: 'My Bitcoin Account', balance: { trusted_spendable: { to_btc: () => 1 } }, network: 'bitcoin', - nextUnusedAddress: () => ({ address: 'bc1qaddress...' }), + peekAddress: () => ({ address: 'bc1qaddress...' }), } as unknown as BitcoinAccount; let handler: KeyringHandler; @@ -63,8 +63,8 @@ describe('KeyringHandler', () => { }); describe('getAccountBalances', () => { - it('synchronizes the account before getting the balance', async () => { - mockAccounts.synchronize.mockResolvedValue(mockAccount); + it('gets the account balance', async () => { + mockAccounts.get.mockResolvedValue(mockAccount); const expectedResponse = { [Caip19Asset.Bitcoin]: { amount: '1', @@ -73,18 +73,18 @@ describe('KeyringHandler', () => { }; const result = await handler.getAccountBalances(mockAccount.id); - expect(mockAccounts.synchronize).toHaveBeenCalledWith(mockAccount.id); + expect(mockAccounts.get).toHaveBeenCalledWith(mockAccount.id); expect(result).toStrictEqual(expectedResponse); }); - it('propagates errors from synchronize', async () => { + it('propagates errors from get', async () => { const error = new Error(); - mockAccounts.synchronize.mockRejectedValue(error); + mockAccounts.get.mockRejectedValue(error); await expect(handler.getAccountBalances(mockAccount.id)).rejects.toThrow( error, ); - expect(mockAccounts.synchronize).toHaveBeenCalled(); + expect(mockAccounts.get).toHaveBeenCalled(); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 7df3fec1..12b5e414 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -13,6 +13,7 @@ import type { import type { Json } from '@metamask/utils'; import { assert, enums, object, optional } from 'superstruct'; +import { networkToCurrencyUnit } from '../entities'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; import { networkToCaip19 } from './caip19'; import { @@ -62,13 +63,13 @@ export class KeyringHandler implements Keyring { async getAccountBalances( id: string, ): Promise> { - const account = await this.#accountsUseCases.synchronize(id); + const account = await this.#accountsUseCases.get(id); const balance = account.balance.trusted_spendable.to_btc().toString(); return { [networkToCaip19[account.network]]: { amount: balance, - unit: 'BTC', + unit: networkToCurrencyUnit[account.network], }, }; } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts index 3cead159..dad361d8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts @@ -1,2 +1,3 @@ export * from './KeyringHandler'; +export * from './CronHandler'; export { Caip2AddressType } from './caip2'; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/keyring-account.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/keyring-account.ts index c76ee6a5..1ad91455 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/keyring-account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/keyring-account.ts @@ -14,7 +14,7 @@ export function snapToKeyringAccount(account: BitcoinAccount): KeyringAccount { type: addressTypeToCaip2[account.addressType] as KeyringAccount['type'], scopes: [networkToCaip2[account.network]], id: account.id, - address: account.nextUnusedAddress().address, + address: account.peekAddress(0).address, options: {}, methods: [BtcMethod.SendBitcoin], }; diff --git a/merged-packages/bitcoin-wallet-snap/src/index.tsx b/merged-packages/bitcoin-wallet-snap/src/index.tsx index 3d81bfdc..0959c6e3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/index.tsx @@ -1,6 +1,6 @@ import type { Keyring } from '@metamask/keyring-api'; import { handleKeyringRequest } from '@metamask/keyring-snap-sdk'; -import type { OnInstallHandler } from '@metamask/snaps-sdk'; +import type { OnCronjobHandler, OnInstallHandler } from '@metamask/snaps-sdk'; import { type OnRpcRequestHandler, type OnKeyringRequestHandler, @@ -13,7 +13,7 @@ import { import { Config } from './config'; import { ConfigV2 } from './configv2'; -import { KeyringHandler } from './handlers/KeyringHandler'; +import { KeyringHandler, CronHandler } from './handlers'; import { SnapClientAdapter, EsploraClientAdapter } from './infra'; import { BtcKeyring } from './keyring'; import { InternalRpcMethod, originPermissions } from './permissions'; @@ -42,7 +42,8 @@ import { loadLocale } from './utils/locale'; logger.logLevel = parseInt(Config.logLevel, 10); -let keyring: Keyring; +let keyringHandler: Keyring; +let cronHandler: CronHandler; let accountsUseCases: AccountUseCases; if (ConfigV2.keyringVersion === 'v2') { // Infra layer @@ -58,7 +59,8 @@ if (ConfigV2.keyringVersion === 'v2') { ConfigV2.accounts, ); // Application layer - keyring = new KeyringHandler(accountsUseCases); + keyringHandler = new KeyringHandler(accountsUseCases); + cronHandler = new CronHandler(accountsUseCases); } export const validateOrigin = (origin: string, method: string): void => { @@ -94,6 +96,24 @@ export const onInstall: OnInstallHandler = async () => { } }; +export const onCronjob: OnCronjobHandler = async ({ request }) => { + try { + if (cronHandler) { + await cronHandler.route(request.method); + } + } catch (error) { + let snapError = error; + + if (!isSnapRpcError(error)) { + snapError = new SnapError(error); + } + logger.error( + `onCronjob error: ${JSON.stringify(snapError.toJSON(), null, 2)}`, + ); + throw snapError; + } +}; + export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request, @@ -147,15 +167,15 @@ export const onKeyringRequest: OnKeyringRequestHandler = async ({ try { validateOrigin(origin, request.method); - if (!keyring) { - keyring = new BtcKeyring(new KeyringStateManager(), { + if (!keyringHandler) { + keyringHandler = new BtcKeyring(new KeyringStateManager(), { defaultIndex: Config.wallet.defaultAccountIndex, origin, }); } return (await handleKeyringRequest( - keyring, + keyringHandler, request, )) as unknown as Promise; } catch (error) { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index a93a7a9a..b14764d7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -241,13 +241,12 @@ describe('AccountUseCases', () => { mockRepository.get.mockResolvedValue(mockAccount); - const result = await useCases.synchronize('some-id'); + await useCases.synchronize('some-id'); expect(mockRepository.get).toHaveBeenCalledWith('some-id'); expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); expect(mockChain.fullScan).not.toHaveBeenCalled(); expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); - expect(result).toBe(mockAccount); }); it('performs a full scan if the account is not scanned', async () => { @@ -257,13 +256,12 @@ describe('AccountUseCases', () => { mockRepository.get.mockResolvedValue(mockAccount); - const result = await useCases.synchronize('some-id'); + await useCases.synchronize('some-id'); expect(mockRepository.get).toHaveBeenCalledWith('some-id'); expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); expect(mockChain.sync).not.toHaveBeenCalled(); expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); - expect(result).toBe(mockAccount); }); it('propagates an error if the chain sync fails', async () => { @@ -316,6 +314,47 @@ describe('AccountUseCases', () => { }); }); + describe('synchronizeAll', () => { + const mockAccounts = [ + { + id: 'id-1', + isScanned: true, + }, + { + id: 'id-2', + }, + ] as BitcoinAccount[]; + + it('synchronizes all accounts', async () => { + mockRepository.getAll.mockResolvedValue(mockAccounts); + + await useCases.synchronizeAll(); + + expect(mockRepository.getAll).toHaveBeenCalled(); + expect(mockChain.sync).toHaveBeenCalledWith(mockAccounts[0]); + expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccounts[1]); + }); + + it('propagates errors from getAll', async () => { + const error = new Error(); + mockRepository.getAll.mockRejectedValue(error); + + await expect(useCases.synchronizeAll()).rejects.toThrow(error); + expect(mockRepository.getAll).toHaveBeenCalled(); + }); + + it('do not propagate errors when synchonize fails', async () => { + const error = new Error(); + mockRepository.getAll.mockResolvedValue(mockAccounts); + mockChain.sync.mockRejectedValue(error); + + await useCases.synchronizeAll(); + + expect(mockRepository.getAll).toHaveBeenCalled(); + expect(mockChain.sync).toHaveBeenCalled(); + }); + }); + describe('delete', () => { it('throws error if account is not found', async () => { mockRepository.get.mockResolvedValue(null); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index a40cd6dc..4d7e04bb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -87,6 +87,7 @@ export class AccountUseCases { // Idempotent account creation + ensures only one account per derivation path const account = await this.#repository.getByDerivationPath(derivationPath); if (account) { + logger.debug('Account already exists. ID: %s,', account.id); await this.#snapClient.emitAccountCreatedEvent(account); return account; } @@ -112,14 +113,42 @@ export class AccountUseCases { * @param id - The account id. * @returns The updated account. */ - async synchronize(id: string): Promise { - logger.debug('Synchronizing account. ID: %s', id); + async synchronize(id: string): Promise { + logger.trace('Synchronizing account. ID: %s', id); const account = await this.#repository.get(id); if (!account) { throw new Error(`Account not found: ${id}`); } + await this.#synchronize(account); + + logger.debug('Account synchronized successfully: %s', account.id); + } + + async synchronizeAll(): Promise { + logger.trace('Synchronizing all accounts'); + + // accounts cannot be empty by assertion. + const accounts = await this.#repository.getAll(); + const results = await Promise.allSettled( + accounts.map(async (account) => this.#synchronize(account)), + ); + + results.forEach((result, index) => { + if (result.status === 'rejected') { + logger.error( + `Account failed to sync. ID: %s. Error: %o`, + accounts[index].id, + result.reason, + ); + } + }); + + logger.debug('Accounts synchronized successfully'); + } + + async #synchronize(account: BitcoinAccount): Promise { // If the account is already scanned, we just sync it, otherwise we do a full scan. if (account.isScanned) { await this.#chain.sync(account); @@ -129,9 +158,6 @@ export class AccountUseCases { } await this.#repository.update(account); - - logger.info('Account synchronized successfully: %s', account.id); - return account; } async delete(id: string): Promise { diff --git a/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts b/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts index 0b9612d2..7f288cf2 100644 --- a/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts +++ b/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts @@ -3,21 +3,24 @@ import { BtcMethod, BtcScopes } from '@metamask/keyring-api'; import type { Snap } from '@metamask/snaps-jest'; import { installSnap } from '@metamask/snaps-jest'; +import { CurrencyUnit } from '../../src/entities'; import { Caip2AddressType } from '../../src/handlers'; import { Caip19Asset } from '../../src/handlers/caip19'; describe('Bitcoin Snap', () => { - let snap: Snap; const accounts: Record = {}; const origin = 'metamask'; + let snap: Snap; - it('installs the Snap and creates a default account', async () => { + it('installs the Snap and creates the default account', async () => { snap = await installSnap({ options: { secretRecoveryPhrase: 'journey embrace permit coil indoor stereo welcome maid movie easy clock spider tent slush bright luxury awake waste legal modify awkward answer acid goose', }, }); + + snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); await snap.onInstall(); const response = await snap.onKeyringRequest({ @@ -29,12 +32,41 @@ describe('Bitcoin Snap', () => { { type: Caip2AddressType.P2wpkh, id: expect.anything(), - address: 'bc1q832zlt4tgnqy88vd20mazw77dlt0j0wf2naw8q', + address: 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t', options: {}, - scopes: [BtcScopes.Mainnet], + scopes: [BtcScopes.Regtest], methods: [BtcMethod.SendBitcoin], }, ]); + + if ('result' in response.response) { + const [defaultAccount] = response.response.result as KeyringAccount[]; + accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`] = + defaultAccount; + } + }); + + it('synchronize accounts via cronjob', async () => { + // IMPORTANT: The order of this test is important because the cron job synchronizes all accounts, doing external requests to backends + // for mainnet, testnet and signet. Ideally avoid synchronizing accounts outside of regtest as that is tested in e2e. + + await snap.onCronjob({ method: 'synchronize' }); + + const response = await snap.onKeyringRequest({ + origin, + method: 'keyring_getAccountBalances', + params: { + id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, + assets: [Caip19Asset.Regtest], + }, + }); + + expect(response).toRespondWith({ + [Caip19Asset.Regtest]: { + amount: expect.stringMatching(/^[1-9]\d*$/u), // non-zero positive number + unit: CurrencyUnit.Regtest, + }, + }); }); it.each([ @@ -43,11 +75,6 @@ describe('Bitcoin Snap', () => { scope: BtcScopes.Mainnet, expectedAddress: 'bc1q832zlt4tgnqy88vd20mazw77dlt0j0wf2naw8q', }, - { - addressType: Caip2AddressType.P2wpkh, - scope: BtcScopes.Regtest, // Use Regtest instead of Testnet for our tests - expectedAddress: 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t', - }, { addressType: Caip2AddressType.P2pkh, scope: BtcScopes.Mainnet, @@ -128,21 +155,21 @@ describe('Bitcoin Snap', () => { ); }); - it('gets a Bitcoin account', async () => { + it('gets an account', async () => { const response = await snap.onKeyringRequest({ origin, method: 'keyring_getAccount', params: { - id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, + id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Mainnet}`].id, }, }); expect(response).toRespondWith( - accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`], + accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Mainnet}`], ); }); - it('lists all Bitcoin accounts', async () => { + it('lists all accounts', async () => { const response = await snap.onKeyringRequest({ origin, method: 'keyring_listAccounts', @@ -151,24 +178,6 @@ describe('Bitcoin Snap', () => { expect(response).toRespondWith(Object.values(accounts)); }); - it('gets the balance of an account', async () => { - const response = await snap.onKeyringRequest({ - origin, - method: 'keyring_getAccountBalances', - params: { - id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, - assets: [Caip19Asset.Regtest], - }, - }); - - expect(response).toRespondWith({ - [Caip19Asset.Regtest]: { - amount: '500', - unit: 'BTC', - }, - }); - }); - it('removes an account', async () => { const { id } = accounts[`${Caip2AddressType.P2pkh}:${BtcScopes.Mainnet}`]; @@ -202,7 +211,7 @@ describe('Bitcoin Snap', () => { origin, method: 'keyring_deleteAccount', params: { - id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Mainnet}`].id, + id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, }, }); From b08e2623371287145ba66386de925483fa6601c0 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 12 Feb 2025 15:21:47 +0100 Subject: [PATCH 193/362] feat: bdk send flow (#411) * done * manifest * done * before rebase * fix missing permissions * done * docekr * log debug * slit for userInput * display send form * display and fetch rates * logic done * update form * button handling send form * max not setting * no errors custom forms * buttons done * send form done * waiting for bdk * send form done * done * read PR and small fixes * refactor a bit * placeholder * small fixes before tests * some tests * account use cases * tests done * test fix * Update packages/snap/src/entities/account.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * Update packages/snap/src/entities/account.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * Update packages/snap/src/entities/account.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * Update packages/snap/src/entities/chain.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * Update packages/snap/src/entities/chain.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * Update packages/snap/src/use-cases/AccountUseCases.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * indentation level --------- Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> --- .../bitcoin-wallet-snap/.eslintrc.js | 2 +- .../bitcoin-wallet-snap/package.json | 4 +- .../bitcoin-wallet-snap/snap.config.ts | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../bitcoin-wallet-snap/src/configv2.ts | 1 + .../src/entities/account.ts | 33 +++++ .../bitcoin-wallet-snap/src/entities/chain.ts | 16 ++ .../src/entities/config.ts | 2 + .../src/entities/currency.ts | 1 + .../bitcoin-wallet-snap/src/entities/index.ts | 3 + .../src/entities/send-form.ts | 65 ++++++++ .../bitcoin-wallet-snap/src/entities/snap.ts | 68 ++++++++- .../src/entities/transaction.ts | 5 + .../src/handlers/KeyringHandler.test.ts | 2 +- .../src/handlers/KeyringHandler.ts | 2 +- .../src/handlers/RpcHandler.test.ts | 86 +++++++++++ .../src/handlers/RpcHandler.ts | 51 +++++++ .../bitcoin-wallet-snap/src/handlers/index.ts | 2 + .../src/images/bitcoin.svg | 14 ++ .../src/images/empty-space.svg | 3 + .../src/{index.test.tsx => index.test.ts} | 0 .../src/{index.tsx => index.ts} | 25 +++- .../src/infra/BdkAccountAdapter.ts | 47 +++++- .../src/infra/EsploraClientAdapter.ts | 10 +- .../src/infra/SnapClientAdapter.ts | 88 ++++++++++- .../src/infra/jsx/SendFormView.tsx | 13 ++ .../src/infra/jsx/index.ts | 1 + .../src/store/BdkAccountRepository.test.ts | 64 +++++++- .../src/store/BdkAccountRepository.ts | 48 +++++- .../src/store/JSXSendFormRepository.test.tsx | 90 +++++++++++ .../src/store/JSXSendFormRepository.tsx | 59 ++++++++ .../bitcoin-wallet-snap/src/store/index.ts | 1 + .../src/use-cases/AccountUseCases.test.ts | 140 +++++++++++++++++- .../src/use-cases/AccountUseCases.ts | 39 ++++- .../src/use-cases/SendFormUseCases.test.ts | 93 ++++++++++++ .../src/use-cases/SendFormUseCases.ts | 67 +++++++++ .../src/use-cases/index.ts | 1 + .../bitcoin-wallet-snap/tsconfig.json | 2 +- 38 files changed, 1115 insertions(+), 39 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/send-form.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/images/bitcoin.svg create mode 100644 merged-packages/bitcoin-wallet-snap/src/images/empty-space.svg rename merged-packages/bitcoin-wallet-snap/src/{index.test.tsx => index.test.ts} (100%) rename merged-packages/bitcoin-wallet-snap/src/{index.tsx => index.ts} (88%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.test.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.ts diff --git a/merged-packages/bitcoin-wallet-snap/.eslintrc.js b/merged-packages/bitcoin-wallet-snap/.eslintrc.js index 500f0457..0a4b6985 100644 --- a/merged-packages/bitcoin-wallet-snap/.eslintrc.js +++ b/merged-packages/bitcoin-wallet-snap/.eslintrc.js @@ -26,7 +26,7 @@ module.exports = { }, { - files: ['*.test.ts'], + files: ['*.test.ts', '*.test.tsx'], rules: { '@typescript-eslint/unbound-method': 'off', }, diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 79e435f4..ce9d0540 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -48,7 +48,7 @@ "@metamask/keyring-snap-sdk": "^2.1.0", "@metamask/snaps-cli": "^6.6.1", "@metamask/snaps-jest": "^8.11.0", - "@metamask/snaps-sdk": "^6.16.0", + "@metamask/snaps-sdk": "^6.17.1", "@metamask/utils": "^11.0.1", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", @@ -56,7 +56,7 @@ "bignumber.js": "9.1.2", "bip32": "4.0.0", "bitcoin-address-validation": "^2.2.3", - "bitcoindevkit": "^0.1.1", + "bitcoindevkit": "^0.1.3", "bitcoinjs-lib": "6.1.5", "coinselect": "3.1.13", "concurrently": "^9.1.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts index dd949c6b..87534266 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.config.ts +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -6,7 +6,7 @@ require('dotenv').config(); const config: SnapConfig = { bundler: 'webpack', - input: resolve(__dirname, 'src/index.tsx'), + input: resolve(__dirname, 'src/index.ts'), server: { port: 8080, }, diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 204af421..d1a8cabb 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "w0SWCvX3NDkdvwN/r7hyiUp692lKrm8HhmIRaVN3uJ8=", + "shasum": "XCykz9Nq//wnpC26QQYS7efJEZ5dIzZxNku7FqCoG7s=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -89,6 +89,6 @@ ] } }, - "platformVersion": "6.16.0", + "platformVersion": "6.17.1", "manifestVersion": "0.1" } diff --git a/merged-packages/bitcoin-wallet-snap/src/configv2.ts b/merged-packages/bitcoin-wallet-snap/src/configv2.ts index 6c3f750f..bae86924 100644 --- a/merged-packages/bitcoin-wallet-snap/src/configv2.ts +++ b/merged-packages/bitcoin-wallet-snap/src/configv2.ts @@ -18,6 +18,7 @@ export const ConfigV2: SnapConfig = { chain: { parallelRequests: 1, stopGap: 10, + targetBlocksConfirmation: 3, url: { bitcoin: process.env.ESPLORA_PROVIDER_BITCOIN ?? 'https://blockstream.info/api', diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index 3fa151f7..ec08ff31 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -7,6 +7,8 @@ import type { Network, Update, ChangeSet, + Psbt, + Transaction, } from 'bitcoindevkit'; /** @@ -81,6 +83,30 @@ export type BitcoinAccount = { * @returns the change set */ takeStaged(): ChangeSet | undefined; + + /** + * Create a new PSBT. + * @param feeRate - The fee rate in sats/vb + * @param recipient. - The recipient address + * @param amount. - The amount to send in sats + * @returns the PSBT + */ + buildTx(feeRate: number, recipient: string, amount: string): Psbt; + + /** + * Create a new PSBT by draining the wallet inputs. + * @param feeRate. - The fee rate in sats/vb + * @param recipient. - The recipient address + * @returns the PSBT + */ + drainTo(feeRate: number, recipient: string): Psbt; + + /** + * Sign a PSBT with all the registered signers + * @param psbt - The PSBT to be signed. + * @returns the signed transaction + */ + sign(psbt: Psbt): Transaction; }; /** @@ -94,6 +120,13 @@ export type BitcoinAccountRepository = { */ get(id: string): Promise; + /** + * Get an account by its id with signing capabilities + * @param id - Account's id. + * @returns the account or null if it does not exist + */ + getWithSigner(id: string): Promise; + /** * Get all accounts. * @returns the list of accounts diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts b/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts index 77dd10d3..c3147dc4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts @@ -1,3 +1,5 @@ +import type { FeeEstimates, Network, Transaction } from 'bitcoindevkit'; + import type { BitcoinAccount } from './account'; export type BlockchainClient = { @@ -14,4 +16,18 @@ export type BlockchainClient = { * @param account - the account to sync. */ sync(account: BitcoinAccount): Promise; + + /** + * Broadcast the signed transaction to the network. + * @param network - Network where the signed transaction will be broadcasted. + * @param transaction - Transaction to broadcast. + */ + broadcast(network: Network, transaction: Transaction): Promise; + + /** + * Fetch fee estimates from the chain indexer. + * @param network - Network to fetch the fees from. + * @returns the map of fee estimates + */ + getFeeEstimates(network: Network): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts index c073370c..fd801274 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -19,4 +19,6 @@ export type ChainConfig = { url: { [network in Network]: string; }; + // Temporary config to set the expected confirmation target, should eventually be chosen by the user + targetBlocksConfirmation: number; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts b/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts index 3dfe0b83..935f0f0b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts @@ -5,6 +5,7 @@ export enum CurrencyUnit { Testnet = 'tBTC', Signet = 'sBTC', Regtest = 'rBTC', + Fiat = '$', } export const networkToCurrencyUnit: Record = { diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts index 234308dd..3f3e90f0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts @@ -2,3 +2,6 @@ export * from './account'; export * from './config'; export * from './chain'; export * from './currency'; +export * from './send-form'; +export * from './transaction'; +export * from './snap'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-form.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-form.ts new file mode 100644 index 00000000..1e636786 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-form.ts @@ -0,0 +1,65 @@ +import type { Network } from 'bitcoindevkit'; + +import type { BitcoinAccount } from './account'; +import type { CurrencyUnit } from './currency'; + +export const SENDFORM_NAME = 'sendForm'; + +export type SendFormContext = { + account: string; + network: Network; + balance: string; + feeRate: number; + currency: CurrencyUnit; + fiatRate?: number; + recipient?: string; + amount?: string; + fee?: string; + drain?: boolean; + errors: { + tx?: string; + recipient?: string; + amount?: string; + }; +}; + +export enum SendFormEvent { + Amount = 'amount', + Recipient = 'recipient', + ClearRecipient = 'clearRecipient', + Review = 'review', + Cancel = 'cancel', + HeaderBack = 'headerBack', + SetMax = 'max', +} + +export type SendFormState = { + recipient: string; + amount: string; +}; + +/** + * SendFormRepository is a repository that manages Bitcoin Send forms. + */ +export type SendFormRepository = { + /** + * Get the form state. + * @param id - the form ID + * @returns the form state + */ + getState(id: string): Promise; + + /** + * Insert a new form interface. + * @param context - the form context + * @returns the form ID + */ + insert(account: BitcoinAccount, feeRate: number): Promise; + + /** + * Update a form interface. + * @param id - the form ID + * @param context - the form context + */ + update(id: string, context: SendFormContext): Promise; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index e8e565a3..d4023304 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -1,7 +1,9 @@ import type { JsonSLIP10Node, SLIP10Node } from '@metamask/key-tree'; -import type { SnapsProvider } from '@metamask/snaps-sdk'; +import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import type { Json } from '@metamask/utils'; import type { BitcoinAccount } from './account'; +import type { CurrencyUnit } from './currency'; export type SnapState = { accounts: { @@ -14,11 +16,6 @@ export type SnapState = { * The SnapClient represents the MetaMask Snap state and manages the BIP-32 entropy from the Wallet SRP. */ export type SnapClient = { - /** - * The snap global provider instance - */ - provider: SnapsProvider; - /** * Get the Snap state. * @returns The Snap state. @@ -46,14 +43,69 @@ export type SnapClient = { getPublicEntropy(derivationPath: string[]): Promise; /** - * Emits an event notifying the extension of a newly created Bitcoin account + * Emit an event notifying the extension of a newly created Bitcoin account * @param account - The Bitcoin account. */ emitAccountCreatedEvent(account: BitcoinAccount): Promise; /** - * Emits an event notifying the extension of a deleted Bitcoin account + * Emit an event notifying the extension of a deleted Bitcoin account * @param account - The Bitcoin account id. */ emitAccountDeletedEvent(id: string): Promise; + + /** + * Create a User Interface. + * @param ui - The UI Component. + * @param context - The Interface context. + * @returns the interface ID + */ + createInterface( + ui: ComponentOrElement, + context: Record, + ): Promise; + + /** + * Update a User Interface. + * @param id - The interface id. + * @param ui - The user interface. + * @param context - The Interface context. + */ + updateInterface( + id: string, + ui: ComponentOrElement, + context: Record, + ): Promise; + + /** + * Display a User Interface. + * @param id - The interface id. + * @returns the resolved value or null. + */ + displayInterface(id: string): Promise; + + /** + * Resolve a User Interface. + * @param id - The interface id. + * @param value - The resolved value. + */ + resolveInterface(id: string, value: Json): Promise; + + /** + * Get the state of an interface. + * @param id - The interface id. + * @param field - The field to return from the state. + * @returns the interface state value or undefined. + */ + getInterfaceState( + id: string, + field: string, + ): Promise; + + /** + * Retrieve the currency rate. + * @param currency - The currency unit. + * @returns A Promise that resolves to the currency rate. + */ + getCurrencyRate(currency: CurrencyUnit): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts b/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts new file mode 100644 index 00000000..56f09f38 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts @@ -0,0 +1,5 @@ +export type TransactionRequest = { + recipient: string; + amount?: string; + feeRate: number; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 3c63eeaf..bb4a1e8a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -228,7 +228,7 @@ describe('KeyringHandler', () => { describe('updateAccount', () => { it('throws not supported', async () => { - await expect(handler.updateAccount({} as any)).rejects.toThrow( + await expect(handler.updateAccount()).rejects.toThrow( 'Method not supported.', ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 12b5e414..286e0e91 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -80,7 +80,7 @@ export class KeyringHandler implements Keyring { return chains.includes(accountChain) ? [accountChain] : []; } - async updateAccount(_: KeyringAccount): Promise { + async updateAccount(): Promise { throw new Error('Method not supported.'); } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts new file mode 100644 index 00000000..922063ad --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -0,0 +1,86 @@ +import { mock } from 'jest-mock-extended'; +import { assert } from 'superstruct'; + +import type { TransactionRequest } from '../entities'; +import { InternalRpcMethod } from '../permissions'; +import type { SendFormUseCases } from '../use-cases'; +import type { AccountUseCases } from '../use-cases/AccountUseCases'; +import { CreateSendFormRequest, RpcHandler } from './RpcHandler'; + +jest.mock('superstruct', () => ({ + ...jest.requireActual('superstruct'), + assert: jest.fn(), +})); + +describe('RpcHandler', () => { + const mockSendFormUseCases = mock(); + const mockAccountsUseCases = mock(); + const mockTxRequest = mock(); + + let handler: RpcHandler; + + beforeEach(() => { + handler = new RpcHandler(mockSendFormUseCases, mockAccountsUseCases); + }); + + describe('route', () => { + it('throws error if missing params', async () => { + await expect(handler.route('method')).rejects.toThrow('Missing params'); + }); + + it('throws error if unrecognized method', async () => { + await expect(handler.route('randomMethod', {})).rejects.toThrow( + 'Method not found: randomMethod', + ); + }); + }); + + describe('executeSendFlow', () => { + const params = { + account: 'account-id', + }; + + it('executes startSendTransactionFlow', async () => { + mockSendFormUseCases.display.mockResolvedValue(mockTxRequest); + mockAccountsUseCases.send.mockResolvedValue('txId'); + + const result = await handler.route( + InternalRpcMethod.StartSendTransactionFlow, + params, + ); + + expect(assert).toHaveBeenCalledWith(params, CreateSendFormRequest); + expect(mockSendFormUseCases.display).toHaveBeenCalledWith('account-id'); + expect(mockAccountsUseCases.send).toHaveBeenCalledWith( + 'account-id', + mockTxRequest, + ); + expect(result).toStrictEqual({ txId: 'txId' }); + }); + + it('propagates errors from display', async () => { + const error = new Error(); + mockSendFormUseCases.display.mockRejectedValue(error); + + await expect( + handler.route(InternalRpcMethod.StartSendTransactionFlow, params), + ).rejects.toThrow(error); + + expect(mockSendFormUseCases.display).toHaveBeenCalled(); + expect(mockAccountsUseCases.send).not.toHaveBeenCalled(); + }); + + it('propagates errors from send', async () => { + const error = new Error(); + mockSendFormUseCases.display.mockResolvedValue(mockTxRequest); + mockAccountsUseCases.send.mockRejectedValue(error); + + await expect( + handler.route(InternalRpcMethod.StartSendTransactionFlow, params), + ).rejects.toThrow(error); + + expect(mockSendFormUseCases.display).toHaveBeenCalled(); + expect(mockAccountsUseCases.send).toHaveBeenCalled(); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts new file mode 100644 index 00000000..efc80ad6 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -0,0 +1,51 @@ +import { BtcScopes } from '@metamask/keyring-api'; +import type { Json, JsonRpcParams } from '@metamask/utils'; +import { assert, enums, object, optional, string } from 'superstruct'; + +import { InternalRpcMethod } from '../permissions'; +import type { AccountUseCases, SendFormUseCases } from '../use-cases'; + +export const CreateSendFormRequest = object({ + account: string(), + scope: optional(enums(Object.values(BtcScopes))), // We don't use the scope but need to define it for validation +}); + +type SendTransactionResponse = { + txId: string; +}; + +export class RpcHandler { + readonly #sendFormUseCases: SendFormUseCases; + + readonly #accountUseCases: AccountUseCases; + + constructor(sendForm: SendFormUseCases, accounts: AccountUseCases) { + this.#sendFormUseCases = sendForm; + this.#accountUseCases = accounts; + } + + async route(method: string, params?: JsonRpcParams): Promise { + if (!params) { + throw new Error('Missing params'); + } + + switch (method) { + case InternalRpcMethod.StartSendTransactionFlow: { + return this.#executeSendFlow(params); + } + + default: + throw new Error(`Method not found: ${method}`); + } + } + + async #executeSendFlow( + params: JsonRpcParams, + ): Promise { + assert(params, CreateSendFormRequest); + + const txRequest = await this.#sendFormUseCases.display(params.account); + const txId = await this.#accountUseCases.send(params.account, txRequest); + return { txId }; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts index dad361d8..098a1688 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts @@ -1,3 +1,5 @@ export * from './KeyringHandler'; export * from './CronHandler'; +export * from './RpcHandler'; + export { Caip2AddressType } from './caip2'; diff --git a/merged-packages/bitcoin-wallet-snap/src/images/bitcoin.svg b/merged-packages/bitcoin-wallet-snap/src/images/bitcoin.svg new file mode 100644 index 00000000..a3a355aa --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/images/bitcoin.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/merged-packages/bitcoin-wallet-snap/src/images/empty-space.svg b/merged-packages/bitcoin-wallet-snap/src/images/empty-space.svg new file mode 100644 index 00000000..ddd505d0 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/images/empty-space.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.tsx b/merged-packages/bitcoin-wallet-snap/src/index.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/index.test.tsx rename to merged-packages/bitcoin-wallet-snap/src/index.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/index.tsx b/merged-packages/bitcoin-wallet-snap/src/index.ts similarity index 88% rename from merged-packages/bitcoin-wallet-snap/src/index.tsx rename to merged-packages/bitcoin-wallet-snap/src/index.ts index 0959c6e3..a0e0b1cf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -13,7 +13,7 @@ import { import { Config } from './config'; import { ConfigV2 } from './configv2'; -import { KeyringHandler, CronHandler } from './handlers'; +import { KeyringHandler, CronHandler, RpcHandler } from './handlers'; import { SnapClientAdapter, EsploraClientAdapter } from './infra'; import { BtcKeyring } from './keyring'; import { InternalRpcMethod, originPermissions } from './permissions'; @@ -30,13 +30,13 @@ import { import type { StartSendTransactionFlowParams } from './rpcs/start-send-transaction-flow'; import { startSendTransactionFlow } from './rpcs/start-send-transaction-flow'; import { KeyringStateManager } from './stateManagement'; -import { BdkAccountRepository } from './store/BdkAccountRepository'; +import { BdkAccountRepository, JSXSendFormRepository } from './store'; import { isSendFormEvent, SendBitcoinController, } from './ui/controller/send-bitcoin-controller'; import type { SendFlowContext, SendFormState } from './ui/types'; -import { AccountUseCases } from './use-cases'; +import { AccountUseCases, SendFormUseCases } from './use-cases'; import { isSnapRpcError, logger } from './utils'; import { loadLocale } from './utils/locale'; @@ -44,23 +44,34 @@ logger.logLevel = parseInt(Config.logLevel, 10); let keyringHandler: Keyring; let cronHandler: CronHandler; +let rpcHandler: RpcHandler; let accountsUseCases: AccountUseCases; if (ConfigV2.keyringVersion === 'v2') { // Infra layer const snapClient = new SnapClientAdapter(ConfigV2.encrypt); const chainClient = new EsploraClientAdapter(ConfigV2.chain); // Data layer - const repository = new BdkAccountRepository(snapClient); + const accountRepository = new BdkAccountRepository(snapClient); + const sendFormRepository = new JSXSendFormRepository(snapClient); + // Business layer accountsUseCases = new AccountUseCases( snapClient, - repository, + accountRepository, chainClient, ConfigV2.accounts, ); + const sendFormUseCases = new SendFormUseCases( + snapClient, + accountRepository, + sendFormRepository, + chainClient, + ConfigV2.chain.targetBlocksConfirmation, + ); // Application layer keyringHandler = new KeyringHandler(accountsUseCases); cronHandler = new CronHandler(accountsUseCases); + rpcHandler = new RpcHandler(sendFormUseCases, accountsUseCases); } export const validateOrigin = (origin: string, method: string): void => { @@ -125,6 +136,10 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ validateOrigin(origin, method); + if (rpcHandler) { + return await rpcHandler.route(method, request.params); + } + switch (method) { case InternalRpcMethod.GetTransactionStatus: return await getTransactionStatus( diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index 39d41063..af931de7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -8,8 +8,10 @@ import type { SyncRequest, Update, ChangeSet, + Psbt, + Transaction, } from 'bitcoindevkit'; -import { Wallet } from 'bitcoindevkit'; +import { FeeRate, Recipient, Address, Amount, Wallet } from 'bitcoindevkit'; import type { BitcoinAccount } from '../entities'; @@ -28,10 +30,25 @@ export class BdkAccountAdapter implements BitcoinAccount { descriptors: DescriptorPair, network: Network, ): BdkAccountAdapter { - return new BdkAccountAdapter(id, Wallet.create(network, descriptors)); + return new BdkAccountAdapter( + id, + Wallet.create(network, descriptors.external, descriptors.internal), + ); } - static load(id: string, walletData: ChangeSet): BdkAccountAdapter { + static load( + id: string, + walletData: ChangeSet, + descriptors?: DescriptorPair, + ): BdkAccountAdapter { + // Load with signer + if (descriptors) { + return new BdkAccountAdapter( + id, + Wallet.load(walletData, descriptors.external, descriptors.internal), + ); + } + return new BdkAccountAdapter(id, Wallet.load(walletData)); } @@ -89,4 +106,28 @@ export class BdkAccountAdapter implements BitcoinAccount { takeStaged(): ChangeSet | undefined { return this.#wallet.take_staged(); } + + buildTx(feeRate: number, recipient: string, amount: string): Psbt { + const fee = new FeeRate(BigInt(feeRate)); + const to = new Recipient( + Address.new(recipient, this.network), + Amount.from_sat(BigInt(amount)), + ); + return this.#wallet.build_tx(fee, [to]); + } + + drainTo(feeRate: number, recipient: string): Psbt { + const fee = new FeeRate(BigInt(feeRate)); + const to = Address.new(recipient, this.network); + return this.#wallet.drain_to(fee, to); + } + + sign(psbt: Psbt): Transaction { + const success = this.#wallet.sign(psbt); + if (!success) { + throw new Error('failed to sign PSBT'); + } + + return psbt.extract_tx(); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts index 43dc2009..097577c7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts @@ -1,4 +1,4 @@ -import type { Network } from 'bitcoindevkit'; +import type { FeeEstimates, Network, Transaction } from 'bitcoindevkit'; import { EsploraClient } from 'bitcoindevkit'; import type { BitcoinAccount, ChainConfig } from '../entities'; @@ -40,4 +40,12 @@ export class EsploraClientAdapter implements BlockchainClient { ); account.applyUpdate(update); } + + async broadcast(network: Network, transaction: Transaction): Promise { + await this.#clients[network].broadcast(transaction); + } + + async getFeeEstimates(network: Network): Promise { + return this.#clients[network].get_fee_estimates(); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 8c70f5ba..348916c8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -2,9 +2,14 @@ import type { JsonSLIP10Node } from '@metamask/key-tree'; import { SLIP10Node } from '@metamask/key-tree'; import { KeyringEvent } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; -import type { SnapsProvider } from '@metamask/snaps-sdk'; +import type { + AvailableCurrency, + ComponentOrElement, + Json, + SnapsProvider, +} from '@metamask/snaps-sdk'; -import type { BitcoinAccount } from '../entities'; +import type { BitcoinAccount, CurrencyUnit } from '../entities'; import type { SnapClient, SnapState } from '../entities/snap'; import { snapToKeyringAccount } from '../handlers/keyring-account'; @@ -63,9 +68,14 @@ export class SnapClientAdapter implements SnapClient { const suggestedName = () => { switch (account.network) { case 'bitcoin': - return 'Bitcoin Account'; + return 'Bitcoin'; case 'testnet': - return 'Bitcoin Testnet Account'; + case 'testnet4': + return 'Bitcoin Testnet'; + case 'signet': + return 'Bitcoin Signet'; + case 'regtest': + return 'Bitcoin Regtest'; default: // Leave it blank to fallback to auto-suggested name on the extension side return ''; @@ -83,4 +93,74 @@ export class SnapClientAdapter implements SnapClient { id, }); } + + async createInterface( + ui: ComponentOrElement, + context: Record, + ): Promise { + return await snap.request({ + method: 'snap_createInterface', + params: { + ui, + context, + }, + }); + } + + async updateInterface( + id: string, + ui: ComponentOrElement, + context: Record, + ): Promise { + await snap.request({ + method: 'snap_updateInterface', + params: { + id, + ui, + context, + }, + }); + } + + async displayInterface(id: string): Promise { + return (await snap.request({ + method: 'snap_dialog', + params: { + id, + }, + })) as unknown as ResolveType; + } + + async getInterfaceState( + id: string, + field: string, + ): Promise { + const result = await snap.request({ + method: 'snap_getInterfaceState', + params: { id }, + }); + + return result[field] as unknown as InterfaceStateType; + } + + async resolveInterface(id: string, value: Json): Promise { + await snap.request({ + method: 'snap_resolveInterface', + params: { + id, + value, + }, + }); + } + + async getCurrencyRate(currency: CurrencyUnit): Promise { + const result = await snap.request({ + method: 'snap_getCurrencyRate', + params: { + currency: currency as unknown as AvailableCurrency, + }, + }); + + return result?.conversionRate; + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx new file mode 100644 index 00000000..0ac019b1 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx @@ -0,0 +1,13 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Container, Text } from '@metamask/snaps-sdk/jsx'; + +import type { SendFormContext } from '../../entities'; + +// Empty for now just to separate the work in smaller PRs +export const SendFormView: SnapComponent = () => { + return ( + + Empty placeholder + + ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/index.ts new file mode 100644 index 00000000..c87905be --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/index.ts @@ -0,0 +1 @@ +export * from './SendFormView'; diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts index 912df98f..215bcca9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts @@ -2,8 +2,7 @@ import type { SLIP10Node } from '@metamask/key-tree'; import { ChangeSet } from 'bitcoindevkit'; import { mock } from 'jest-mock-extended'; -import type { BitcoinAccount } from '../entities'; -import type { SnapClient } from '../entities/snap'; +import type { BitcoinAccount, SnapClient } from '../entities'; import { BdkAccountAdapter } from '../infra'; import { BdkAccountRepository } from './BdkAccountRepository'; @@ -18,6 +17,9 @@ jest.mock('bitcoindevkit', () => { xpub_to_descriptor: jest .fn() .mockReturnValue({ external: 'ext-desc', internal: 'int-desc' }), + xpriv_to_descriptor: jest + .fn() + .mockReturnValue({ external: 'ext-desc', internal: 'int-desc' }), }; }); @@ -48,7 +50,6 @@ describe('BdkAccountRepository', () => { const result = await repo.get('non-existent-id'); expect(result).toBeNull(); - expect(BdkAccountAdapter.load).not.toHaveBeenCalled(); }); it('returns loaded account if found', async () => { @@ -125,6 +126,63 @@ describe('BdkAccountRepository', () => { }); }); + describe('getWithSigner', () => { + it('returns null if account not found', async () => { + mockSnapClient.get.mockResolvedValue({ + accounts: { derivationPaths: {}, wallets: {} }, + }); + const result = await repo.getWithSigner('non-existent-id'); + expect(result).toBeNull(); + }); + + it('throws error if derivation path not found', async () => { + const walletData = '{"mywallet":"data"}'; + mockSnapClient.get.mockResolvedValue({ + accounts: { + derivationPaths: {}, + wallets: { 'some-id': walletData }, + }, + }); + await expect(repo.getWithSigner('some-id')).rejects.toThrow( + 'Inconsistent state. No derivation path found for account some-id', + ); + }); + + it('returns account with signer if account exists', async () => { + const derivationPath = "m/84'/0'/0'"; + const walletData = '{"mywallet":"data"}'; + mockSnapClient.get.mockResolvedValue({ + accounts: { + derivationPaths: { [derivationPath]: 'some-id' }, + wallets: { 'some-id': walletData }, + }, + }); + const slip10Node = { + masterFingerprint: 0xdeadbeef, + } as unknown as SLIP10Node; + mockSnapClient.getPrivateEntropy.mockResolvedValue(slip10Node); + const mockAccount = mock({ + network: 'bitcoin', + addressType: 'p2wpkh', + }); + const mockAccountWithSigner = mock(); + + (BdkAccountAdapter.load as jest.Mock) + .mockReturnValueOnce(mockAccount) + .mockReturnValueOnce(mockAccountWithSigner); + + const result = await repo.getWithSigner('some-id'); + + expect(mockSnapClient.getPrivateEntropy).toHaveBeenCalledWith([ + 'm', + "84'", + "0'", + "0'", + ]); + expect(result).toBe(mockAccountWithSigner); + }); + }); + describe('insert', () => { it('inserts a new account with xpub', async () => { const derivationPath = ['m', "84'", "0'", "0'"]; diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts index 4b6d742a..0d4ebac2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts @@ -5,12 +5,16 @@ import type { AddressType, Network } from 'bitcoindevkit'; import { ChangeSet, slip10_to_extended, + xpriv_to_descriptor, xpub_to_descriptor, } from 'bitcoindevkit'; import { v4 } from 'uuid'; -import type { BitcoinAccountRepository, BitcoinAccount } from '../entities'; -import type { SnapClient } from '../entities/snap'; +import type { + BitcoinAccountRepository, + BitcoinAccount, + SnapClient, +} from '../entities'; import { BdkAccountAdapter } from '../infra'; export class BdkAccountRepository implements BitcoinAccountRepository { @@ -30,6 +34,46 @@ export class BdkAccountRepository implements BitcoinAccountRepository { return BdkAccountAdapter.load(id, ChangeSet.from_json(walletData)); } + async getWithSigner(id: string): Promise { + const state = await this.#snapClient.get(); + const walletData = state.accounts.wallets[id]; + if (!walletData) { + return null; + } + const account = BdkAccountAdapter.load(id, ChangeSet.from_json(walletData)); + + const derivationPath = Object.entries(state.accounts.derivationPaths).find( + ([, walletId]) => walletId === id, + ); + + // Should never occur by assertion. It is a critical inconsistent state error that should be caught in integration tests + if (!derivationPath) { + throw new Error( + `Inconsistent state. No derivation path found for account ${id}`, + ); + } + + const slip10 = await this.#snapClient.getPrivateEntropy( + derivationPath[0].split('/'), + ); + const fingerprint = ( + slip10.masterFingerprint ?? slip10.parentFingerprint + ).toString(16); + const xpriv = slip10_to_extended(slip10, account.network); + const descriptors = xpriv_to_descriptor( + xpriv, + fingerprint, + account.network, + account.addressType, + ); + + return BdkAccountAdapter.load( + id, + ChangeSet.from_json(walletData), + descriptors, + ); + } + async getAll(): Promise { const state = await this.#snapClient.get(); const walletsData = state.accounts.wallets; diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.test.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.test.tsx new file mode 100644 index 00000000..28a059d9 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.test.tsx @@ -0,0 +1,90 @@ +import { mock } from 'jest-mock-extended'; + +import type { SnapClient, BitcoinAccount, SendFormContext } from '../entities'; +import { SENDFORM_NAME, CurrencyUnit } from '../entities'; +import { SendFormView } from '../infra/jsx'; +import { JSXSendFormRepository } from './JSXSendFormRepository'; + +describe('JSXSendFormRepository', () => { + const mockSnapClient = mock(); + let repo: JSXSendFormRepository; + + beforeEach(() => { + repo = new JSXSendFormRepository(mockSnapClient); + }); + + describe('getState', () => { + it('returns state when found', async () => { + const state = { foo: 'bar' }; + mockSnapClient.getInterfaceState.mockResolvedValue(state); + + const result = await repo.getState('test-id'); + + expect(mockSnapClient.getInterfaceState).toHaveBeenCalledWith( + 'test-id', + SENDFORM_NAME, + ); + expect(result).toEqual(state); + }); + + it('throws error if state is missing', async () => { + mockSnapClient.getInterfaceState.mockResolvedValue(null); + + await expect(repo.getState('test-id')).rejects.toThrow( + 'Missing state from Send Form', + ); + }); + }); + + describe('insert', () => { + const feeRate = 10; + const account = mock({ + id: 'acc-id', + network: 'bitcoin', + // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 + /* eslint-disable @typescript-eslint/naming-convention */ + balance: { trusted_spendable: { to_sat: () => BigInt(1234) } }, + }); + + it('creates interface with correct context', async () => { + const btcRate = 100000; + mockSnapClient.getCurrencyRate.mockResolvedValue(btcRate); + mockSnapClient.createInterface.mockResolvedValue('interface-id'); + + const result = await repo.insert(account, feeRate); + + const expectedContext: SendFormContext = { + balance: '1234', + currency: CurrencyUnit.Bitcoin, + account: 'acc-id', + network: 'bitcoin', + feeRate, + errors: {}, + fiatRate: btcRate, + }; + expect(mockSnapClient.getCurrencyRate).toHaveBeenCalledWith( + CurrencyUnit.Bitcoin, + ); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + , + expectedContext, + ); + expect(result).toBe('interface-id'); + }); + }); + + describe('update', () => { + it('updates interface with context', async () => { + const id = 'interface-id'; + const context = mock(); + + await repo.update(id, context); + + expect(mockSnapClient.updateInterface).toHaveBeenCalledWith( + id, + , + context, + ); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.tsx new file mode 100644 index 00000000..4c1f3bcd --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.tsx @@ -0,0 +1,59 @@ +import type { BitcoinAccount } from '../entities'; +import { + networkToCurrencyUnit, + SENDFORM_NAME, + type SendFormContext, + type SendFormRepository, + type SendFormState, + type SnapClient, +} from '../entities'; +import { SendFormView } from '../infra/jsx'; + +export class JSXSendFormRepository implements SendFormRepository { + readonly #snapClient: SnapClient; + + constructor(snapClient: SnapClient) { + this.#snapClient = snapClient; + } + + async getState(id: string): Promise { + const state = await this.#snapClient.getInterfaceState( + id, + SENDFORM_NAME, + ); + // Should never occur by assertion. It is a critical inconsistent state error that should be caught in integration tests + if (!state) { + throw new Error('Missing state from Send Form'); + } + + return state; + } + + async insert(account: BitcoinAccount, feeRate: number): Promise { + const currency = networkToCurrencyUnit[account.network]; + const context: SendFormContext = { + balance: account.balance.trusted_spendable.to_sat().toString(), + currency, + account: account.id, + network: account.network, + feeRate, + errors: {}, + }; + + // TODO: Fetch fiat/fee rates from state and refresh on updates + context.fiatRate = await this.#snapClient.getCurrencyRate(currency); + + return this.#snapClient.createInterface( + , + context, + ); + } + + async update(id: string, context: SendFormContext): Promise { + return this.#snapClient.updateInterface( + id, + , + context, + ); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/store/index.ts b/merged-packages/bitcoin-wallet-snap/src/store/index.ts index 1ea245da..f18336e8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/index.ts @@ -1 +1,2 @@ export * from './BdkAccountRepository'; +export * from './JSXSendFormRepository'; diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index b14764d7..7e14c04d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -1,4 +1,5 @@ -import type { AddressType, Network } from 'bitcoindevkit'; +import type { Transaction } from 'bitcoindevkit'; +import { type AddressType, type Network, type Psbt } from 'bitcoindevkit'; import { mock } from 'jest-mock-extended'; import type { @@ -6,8 +7,8 @@ import type { BitcoinAccount, BitcoinAccountRepository, BlockchainClient, + SnapClient, } from '../entities'; -import type { SnapClient } from '../entities/snap'; import { AccountUseCases } from './AccountUseCases'; jest.mock('../utils/logger'); @@ -436,4 +437,139 @@ describe('AccountUseCases', () => { expect(mockRepository.delete).toHaveBeenCalled(); }); }); + + describe('send', () => { + const requestWithAmount = { + amount: '1000', + feeRate: 10, + recipient: 'recipient-address', + }; + const requestDrain = { + feeRate: 10, + recipient: 'recipient-address', + }; + + const mockPsbt = mock(); + const mockTransaction = mock({ + // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 + /* eslint-disable @typescript-eslint/naming-convention */ + compute_txid: jest.fn(), + }); + const mockAccount = mock({ + network: 'bitcoin', + buildTx: jest.fn(), + drainTo: jest.fn(), + sign: jest.fn(), + }); + + it('throws error if account is not found', async () => { + mockRepository.getWithSigner.mockResolvedValue(null); + + await expect( + useCases.send('non-existent-id', requestWithAmount), + ).rejects.toThrow('Account not found: non-existent-id'); + }); + + it('sends transaction', async () => { + mockRepository.getWithSigner.mockResolvedValue(mockAccount); + mockAccount.buildTx.mockReturnValue(mockPsbt); + mockAccount.sign.mockReturnValue(mockTransaction); + mockTransaction.compute_txid.mockReturnValue('txid-123'); + + const txId = await useCases.send('account-id', requestWithAmount); + + expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); + expect(mockAccount.buildTx).toHaveBeenCalledWith( + requestWithAmount.feeRate, + requestWithAmount.recipient, + requestWithAmount.amount, + ); + expect(mockAccount.sign).toHaveBeenCalledWith(mockPsbt); + expect(mockChain.broadcast).toHaveBeenCalledWith( + mockAccount.network, + mockTransaction, + ); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(mockTransaction.compute_txid).toHaveBeenCalled(); + expect(txId).toBe('txid-123'); + }); + + it('sends a drain transaction when amount is not provided', async () => { + mockRepository.getWithSigner.mockResolvedValue(mockAccount); + mockAccount.drainTo.mockReturnValue(mockPsbt); + mockAccount.sign.mockReturnValue(mockTransaction); + mockTransaction.compute_txid.mockReturnValue('txid-123'); + + const txId = await useCases.send('account-id', requestDrain); + + expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); + expect(mockAccount.drainTo).toHaveBeenCalledWith( + requestDrain.feeRate, + requestDrain.recipient, + ); + expect(mockAccount.sign).toHaveBeenCalledWith(mockPsbt); + expect(mockChain.broadcast).toHaveBeenCalledWith( + mockAccount.network, + mockTransaction, + ); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(mockTransaction.compute_txid).toHaveBeenCalled(); + expect(txId).toBe('txid-123'); + }); + + it('propagates an error if getWithSigner fails', async () => { + const error = new Error('getWithSigner failed'); + mockRepository.getWithSigner.mockRejectedValueOnce(error); + + await expect(useCases.send('account-id', requestWithAmount)).rejects.toBe( + error, + ); + }); + + it('propagates an error if buildTx fails', async () => { + const error = new Error('buildTx failed'); + mockRepository.getWithSigner.mockResolvedValue(mockAccount); + mockAccount.buildTx.mockImplementation(() => { + throw error; + }); + + await expect(useCases.send('account-id', requestWithAmount)).rejects.toBe( + error, + ); + }); + + it('propagates an error if drainTo fails', async () => { + const error = new Error('drainTo failed'); + mockRepository.getWithSigner.mockResolvedValue(mockAccount); + mockAccount.drainTo.mockImplementation(() => { + throw error; + }); + + await expect(useCases.send('account-id', requestDrain)).rejects.toBe( + error, + ); + }); + + it('propagates an error if broadcast fails', async () => { + mockRepository.getWithSigner.mockResolvedValue(mockAccount); + + const error = new Error('broadcast failed'); + mockChain.broadcast.mockRejectedValueOnce(error); + + await expect(useCases.send('account-id', requestWithAmount)).rejects.toBe( + error, + ); + }); + + it('propagates an error if update fails', async () => { + mockRepository.getWithSigner.mockResolvedValue(mockAccount); + + const error = new Error('update failed'); + mockRepository.update.mockRejectedValue(error); + + await expect(useCases.send('account-id', requestWithAmount)).rejects.toBe( + error, + ); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 4d7e04bb..da2b5890 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -1,12 +1,13 @@ -import type { AddressType, Network } from 'bitcoindevkit'; +import type { AddressType, Network, Psbt } from 'bitcoindevkit'; import type { AccountsConfig, BitcoinAccount, BitcoinAccountRepository, BlockchainClient, + TransactionRequest, + SnapClient, } from '../entities'; -import type { SnapClient } from '../entities/snap'; import { logger } from '../utils'; const addressTypeToPurpose: Record = { @@ -180,4 +181,38 @@ export class AccountUseCases { logger.info('Account deleted successfully: %s', account.id); } + + async send(id: string, request: TransactionRequest): Promise { + logger.debug('Sending transaction. ID: %s. Request: %o', id, request); + + const account = await this.#repository.getWithSigner(id); + if (!account) { + throw new Error(`Account not found: ${id}`); + } + + let psbt: Psbt; + // If no amount is specified at this point, it is a drain transaction + if (request.amount) { + psbt = account.buildTx( + request.feeRate, + request.recipient, + request.amount, + ); + } else { + psbt = account.drainTo(request.feeRate, request.recipient); + } + + const tx = account.sign(psbt); + await this.#chain.broadcast(account.network, tx); + await this.#repository.update(account); + + const txId = tx.compute_txid(); + logger.info( + 'Transaction sent successfully: %s. Account: %s, Network: %s', + txId, + id, + account.network, + ); + return txId; + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.test.ts new file mode 100644 index 00000000..e3a8e2ed --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.test.ts @@ -0,0 +1,93 @@ +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; +import type { FeeEstimates } from 'bitcoindevkit'; +import { mock } from 'jest-mock-extended'; + +import type { + BitcoinAccount, + BitcoinAccountRepository, + BlockchainClient, + SendFormRepository, + SnapClient, + TransactionRequest, +} from '../entities'; +import { SendFormUseCases } from './SendFormUseCases'; + +jest.mock('../utils/logger'); + +describe('SendFormUseCases', () => { + let useCases: SendFormUseCases; + + const mockSnapClient = mock(); + const mockAccountRepository = mock(); + const mockSendFormRepository = mock(); + const mockChain = mock(); + const targetBlocksConfirmation = 3; + const mockAccount = mock({ network: 'bitcoin' }); + const mockFeeEstimates = mock({ get: jest.fn() }); + const mockTxRequest = mock(); + + beforeEach(() => { + useCases = new SendFormUseCases( + mockSnapClient, + mockAccountRepository, + mockSendFormRepository, + mockChain, + targetBlocksConfirmation, + ); + }); + + describe('display', () => { + it('throws error if account not found', async () => { + mockAccountRepository.get.mockResolvedValue(null); + await expect(useCases.display('non-existent-account')).rejects.toThrow( + 'Account not found', + ); + }); + + it('throws error if fee rate is missing', async () => { + mockAccountRepository.get.mockResolvedValue(mockAccount); + mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); + mockFeeEstimates.get.mockReturnValue(undefined); + + await expect(useCases.display('account-id')).rejects.toThrow( + 'Failed to fetch fee rates', + ); + }); + + it('throws UserRejectedRequestError if displayInterface returns null', async () => { + mockAccountRepository.get.mockResolvedValue(mockAccount); + mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); + mockFeeEstimates.get.mockReturnValue(5); + mockSendFormRepository.insert.mockResolvedValue('form-id'); + mockSnapClient.displayInterface.mockResolvedValue(null); + + await expect(useCases.display('account-id')).rejects.toThrow( + UserRejectedRequestError, + ); + }); + + it('displays Send form and returns transaction request when resolved', async () => { + mockAccountRepository.get.mockResolvedValue(mockAccount); + mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); + mockFeeEstimates.get.mockReturnValue(5); + mockSendFormRepository.insert.mockResolvedValue('form-id'); + mockSnapClient.displayInterface.mockResolvedValue(mockTxRequest); + + const result = await useCases.display('account-id'); + + expect(mockAccountRepository.get).toHaveBeenCalledWith('account-id'); + expect(mockChain.getFeeEstimates).toHaveBeenCalledWith( + mockAccount.network, + ); + expect(mockFeeEstimates.get).toHaveBeenCalledWith( + targetBlocksConfirmation, + ); + expect(mockSendFormRepository.insert).toHaveBeenCalledWith( + mockAccount, + 5, + ); + expect(mockSnapClient.displayInterface).toHaveBeenCalledWith('form-id'); + expect(result).toStrictEqual(mockTxRequest); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.ts new file mode 100644 index 00000000..1ba1b18d --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.ts @@ -0,0 +1,67 @@ +import { UserRejectedRequestError } from '@metamask/snaps-sdk'; + +import type { + BitcoinAccountRepository, + SendFormRepository, + SnapClient, + BlockchainClient, + TransactionRequest, +} from '../entities'; +import { logger } from '../utils'; + +export class SendFormUseCases { + readonly #snapClient: SnapClient; + + readonly #accountRepository: BitcoinAccountRepository; + + readonly #sendFormRepository: SendFormRepository; + + readonly #chainClient: BlockchainClient; + + readonly #targetBlocksConfirmation: number; + + constructor( + snapClient: SnapClient, + accountRepository: BitcoinAccountRepository, + sendFormrepository: SendFormRepository, + chainClient: BlockchainClient, + targetBlocksConfirmation: number, + ) { + this.#snapClient = snapClient; + this.#accountRepository = accountRepository; + this.#sendFormRepository = sendFormrepository; + this.#chainClient = chainClient; + this.#targetBlocksConfirmation = targetBlocksConfirmation; + } + + async display(accountId: string): Promise { + logger.debug('Displaying Send form. Account: %s', accountId); + + const account = await this.#accountRepository.get(accountId); + if (!account) { + throw new Error('Account not found'); + } + + // TODO: Fetch fee rates from state and refresh on updates + const feeEstimates = await this.#chainClient.getFeeEstimates( + account.network, + ); + const feeRate = feeEstimates.get(this.#targetBlocksConfirmation); + if (!feeRate) { + throw new Error('Failed to fetch fee rates'); + } + + const formId = await this.#sendFormRepository.insert(account, feeRate); + + // Blocks and waits for user actions + const request = await this.#snapClient.displayInterface( + formId, + ); + if (!request) { + throw new UserRejectedRequestError() as unknown as Error; + } + + logger.info('Send form resolved successfully: %s', formId); + return request; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts index 6d0217a9..25631f5a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts @@ -1 +1,2 @@ export * from './AccountUseCases'; +export * from './SendFormUseCases'; diff --git a/merged-packages/bitcoin-wallet-snap/tsconfig.json b/merged-packages/bitcoin-wallet-snap/tsconfig.json index d1f36018..0f1ac346 100644 --- a/merged-packages/bitcoin-wallet-snap/tsconfig.json +++ b/merged-packages/bitcoin-wallet-snap/tsconfig.json @@ -14,7 +14,7 @@ "**/*.ts", "**/*.tsx", "src/**/*.tsx", - "src/index.tsx", + "src/index.ts", "locales/*.json" ] } From e2edf5b2daf79411e3136eb4f0e140cd420c440e Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 18 Feb 2025 13:03:13 +0100 Subject: [PATCH 194/362] bdk: send flow user interfaces (#413) * done * manifest * done * before rebase * fix missing permissions * done * docekr * log debug * slit for userInput * display send form * display and fetch rates * logic done * update form * button handling send form * max not setting * no errors custom forms * buttons done * send form done * waiting for bdk * send form done * done * read PR and small fixes * refactor a bit * placeholder * small fixes before tests * some tests * account use cases * tests done * test fix * rebase from send flow * tests done * lint and build * tests pass * Update packages/snap/src/entities/account.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * Update packages/snap/src/entities/account.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * Update packages/snap/src/entities/account.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * Update packages/snap/src/entities/chain.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * Update packages/snap/src/entities/chain.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * Update packages/snap/src/use-cases/AccountUseCases.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * indentation level * rebase * integration tests * move components * build * add review screen * rename and not delete * correct call on user input * add old files back to move * remove old * test * rename attempt again * avoid renaming for now * tests * done * cleanup * Update packages/snap/src/handlers/RpcHandler.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * Update packages/snap/src/handlers/RpcHandler.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * Update packages/snap/src/handlers/UserInputHandler.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * Update packages/snap/src/use-cases/SendFormUseCases.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * Update packages/snap/src/use-cases/SendFormUseCases.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * Update packages/snap/src/index.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * Update packages/snap/src/index.ts Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> * comments addressed * use from in component --------- Co-authored-by: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> --- .../bitcoin-wallet-snap/locales/en.json | 23 +- .../bitcoin-wallet-snap/messages.json | 23 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/configv2.ts | 2 + .../src/entities/config.ts | 3 + .../src/entities/send-form.ts | 62 ++- .../bitcoin-wallet-snap/src/entities/snap.ts | 4 +- .../src/handlers/RpcHandler.test.ts | 18 +- .../src/handlers/RpcHandler.ts | 10 +- .../src/handlers/UserInputHandler.test.ts | 124 ++++++ .../src/handlers/UserInputHandler.ts | 59 +++ .../bitcoin-wallet-snap/src/handlers/index.ts | 2 + .../src/images/btc-halo.svg | 38 ++ .../bitcoin-wallet-snap/src/index.ts | 68 ++- .../src/infra/BdkAccountAdapter.ts | 4 +- .../src/infra/SnapClientAdapter.ts | 18 +- .../src/infra/jsx/ReviewTransactionView.tsx | 111 +++++ .../src/infra/jsx/SendFormView.tsx | 46 +- .../jsx/components/HeadingWithReturn.tsx | 26 ++ .../src/infra/jsx/components/SendForm.tsx | 71 ++++ .../jsx/components/TransactionSummary.tsx | 53 +++ .../src/infra/jsx/components/index.ts | 3 + .../src/infra/jsx/format.ts | 27 ++ .../src/infra/jsx/index.ts | 1 + .../src/store/JSXSendFormRepository.test.tsx | 79 ++-- .../src/store/JSXSendFormRepository.tsx | 50 ++- .../src/use-cases/SendFormUseCases.test.ts | 393 ++++++++++++++++-- .../src/use-cases/SendFormUseCases.ts | 226 +++++++++- .../test/integration/snap.test.ts | 123 +++++- 29 files changed, 1503 insertions(+), 166 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/images/btc-halo.svg create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/HeadingWithReturn.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts diff --git a/merged-packages/bitcoin-wallet-snap/locales/en.json b/merged-packages/bitcoin-wallet-snap/locales/en.json index 1741d84d..72c23ddb 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/en.json +++ b/merged-packages/bitcoin-wallet-snap/locales/en.json @@ -16,8 +16,8 @@ "from": { "message": "From" }, - "toAccount": { - "message": "To Account" + "toAddress": { + "message": "To Address" }, "fromAccount": { "message": "From Account" @@ -40,8 +40,8 @@ "network": { "message": "Network" }, - "estimatedTransactionSpeed": { - "message": "30 min" + "minutes": { + "message": "min" }, "transactionSpeed": { "message": "Transaction Speed" @@ -49,11 +49,14 @@ "transactionSpeedTooltip": { "message": "The estimated time of the transaction" }, - "networkFee": { - "message": "Network Fee" + "transactionFee": { + "message": "Transaction Fee" }, - "networkFeeToolTip": { - "message": "The estimated network fee" + "feeRate": { + "message": "Fee rate" + }, + "transactionFeeTooltip": { + "message": "The total transaction fee" }, "total": { "message": "Total" @@ -67,13 +70,13 @@ "sendAmount": { "message": "Send Amount" }, - "amountToSendPlaceholder": { + "amountPlaceholder": { "message": "Enter amount to send" }, "max": { "message": "Max" }, - "receivingAddressPlaceholder": { + "recipientPlaceholder": { "message": "Enter receiving address" }, "validAddress": { diff --git a/merged-packages/bitcoin-wallet-snap/messages.json b/merged-packages/bitcoin-wallet-snap/messages.json index c4bab0fd..1c0435cc 100644 --- a/merged-packages/bitcoin-wallet-snap/messages.json +++ b/merged-packages/bitcoin-wallet-snap/messages.json @@ -16,8 +16,8 @@ "from": { "message": "From" }, - "toAccount": { - "message": "To Account" + "toAddress": { + "message": "To Address" }, "fromAccount": { "message": "From Account" @@ -40,8 +40,8 @@ "network": { "message": "Network" }, - "estimatedTransactionSpeed": { - "message": "30 min" + "minutes": { + "message": "min" }, "transactionSpeed": { "message": "Transaction Speed" @@ -49,11 +49,14 @@ "transactionSpeedTooltip": { "message": "The estimated time of the transaction" }, - "networkFee": { - "message": "Network Fee" + "transactionFee": { + "message": "Transaction Fee" }, - "networkFeeToolTip": { - "message": "The estimated network fee" + "feeRate": { + "message": "Fee rate" + }, + "transactionFeeTooltip": { + "message": "The total transaction fee" }, "total": { "message": "Total" @@ -67,13 +70,13 @@ "sendAmount": { "message": "Send Amount" }, - "amountToSendPlaceholder": { + "amountPlaceholder": { "message": "Enter amount to send" }, "max": { "message": "Max" }, - "receivingAddressPlaceholder": { + "recipientPlaceholder": { "message": "Enter receiving address" }, "validAddress": { diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index d1a8cabb..5b15ac2f 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "XCykz9Nq//wnpC26QQYS7efJEZ5dIzZxNku7FqCoG7s=", + "shasum": "4M8mRztjmU3Mcf/4OHLb6yBdy9npd1Uevm2VPeMgw90=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/configv2.ts b/merged-packages/bitcoin-wallet-snap/src/configv2.ts index bae86924..3823007e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/configv2.ts +++ b/merged-packages/bitcoin-wallet-snap/src/configv2.ts @@ -35,4 +35,6 @@ export const ConfigV2: SnapConfig = { 'http://localhost:8094/regtest/api', }, }, + targetBlocksConfirmation: 3, + fallbackFeeRate: 5.0, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts index fd801274..c6fe6152 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -5,6 +5,9 @@ export type SnapConfig = { accounts: AccountsConfig; chain: ChainConfig; keyringVersion: string; + // Temporary config to set the expected confirmation target, should eventually be chosen by the user + targetBlocksConfirmation: number; + fallbackFeeRate: number; }; export type AccountsConfig = { diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-form.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-form.ts index 1e636786..67f0b4f8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/send-form.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-form.ts @@ -1,3 +1,4 @@ +import type { CurrencyRate } from '@metamask/snaps-sdk'; import type { Network } from 'bitcoindevkit'; import type { BitcoinAccount } from './account'; @@ -6,12 +7,15 @@ import type { CurrencyUnit } from './currency'; export const SENDFORM_NAME = 'sendForm'; export type SendFormContext = { - account: string; + account: { + id: string; + address: string; // FIXME: Address should not be needed to identify an account + }; network: Network; balance: string; feeRate: number; currency: CurrencyUnit; - fiatRate?: number; + fiatRate?: CurrencyRate; recipient?: string; amount?: string; fee?: string; @@ -27,9 +31,8 @@ export enum SendFormEvent { Amount = 'amount', Recipient = 'recipient', ClearRecipient = 'clearRecipient', - Review = 'review', + Confirm = 'confirm', Cancel = 'cancel', - HeaderBack = 'headerBack', SetMax = 'max', } @@ -38,28 +41,61 @@ export type SendFormState = { amount: string; }; +export type ReviewTransactionContext = { + from: string; + network: Network; + feeRate: number; + currency: CurrencyUnit; + fiatRate?: CurrencyRate; + recipient: string; + amount: string; + fee: string; + + /** + * Used to repopulate the send form if the user decides to go back in the flow + * Optional when sending directly without form. + */ + sendForm?: SendFormContext; +}; + +export enum ReviewTransactionEvent { + Send = 'send', + HeaderBack = 'headerBack', +} + /** - * SendFormRepository is a repository that manages Bitcoin Send forms. + * SendFlowRepository is a repository that manages Bitcoin Send flow interfaces. */ -export type SendFormRepository = { +export type SendFlowRepository = { /** * Get the form state. - * @param id - the form ID + * @param id - the interface ID * @returns the form state */ getState(id: string): Promise; /** - * Insert a new form interface. + * Insert a new send form interface. * @param context - the form context - * @returns the form ID + * @returns the interface ID */ - insert(account: BitcoinAccount, feeRate: number): Promise; + insertForm( + account: BitcoinAccount, + feeRate: number, + fiatRate?: CurrencyRate, + ): Promise; /** - * Update a form interface. - * @param id - the form ID + * Update an interface to the send form view. + * @param id - the interface ID * @param context - the form context */ - update(id: string, context: SendFormContext): Promise; + updateForm(id: string, context: SendFormContext): Promise; + + /** + * Update an interface to the review transaction view. + * @param id - the interface ID + * @param context - the review transaction context + */ + updateReview(id: string, context: ReviewTransactionContext): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index d4023304..304e042f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -1,5 +1,5 @@ import type { JsonSLIP10Node, SLIP10Node } from '@metamask/key-tree'; -import type { ComponentOrElement } from '@metamask/snaps-sdk'; +import type { ComponentOrElement, CurrencyRate } from '@metamask/snaps-sdk'; import type { Json } from '@metamask/utils'; import type { BitcoinAccount } from './account'; @@ -107,5 +107,5 @@ export type SnapClient = { * @param currency - The currency unit. * @returns A Promise that resolves to the currency rate. */ - getCurrencyRate(currency: CurrencyUnit): Promise; + getCurrencyRate(currency: CurrencyUnit): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index 922063ad..38a05052 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -3,7 +3,7 @@ import { assert } from 'superstruct'; import type { TransactionRequest } from '../entities'; import { InternalRpcMethod } from '../permissions'; -import type { SendFormUseCases } from '../use-cases'; +import type { SendFlowUseCases } from '../use-cases'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; import { CreateSendFormRequest, RpcHandler } from './RpcHandler'; @@ -13,14 +13,14 @@ jest.mock('superstruct', () => ({ })); describe('RpcHandler', () => { - const mockSendFormUseCases = mock(); + const mockSendFlowUseCases = mock(); const mockAccountsUseCases = mock(); const mockTxRequest = mock(); let handler: RpcHandler; beforeEach(() => { - handler = new RpcHandler(mockSendFormUseCases, mockAccountsUseCases); + handler = new RpcHandler(mockSendFlowUseCases, mockAccountsUseCases); }); describe('route', () => { @@ -41,7 +41,7 @@ describe('RpcHandler', () => { }; it('executes startSendTransactionFlow', async () => { - mockSendFormUseCases.display.mockResolvedValue(mockTxRequest); + mockSendFlowUseCases.display.mockResolvedValue(mockTxRequest); mockAccountsUseCases.send.mockResolvedValue('txId'); const result = await handler.route( @@ -50,7 +50,7 @@ describe('RpcHandler', () => { ); expect(assert).toHaveBeenCalledWith(params, CreateSendFormRequest); - expect(mockSendFormUseCases.display).toHaveBeenCalledWith('account-id'); + expect(mockSendFlowUseCases.display).toHaveBeenCalledWith('account-id'); expect(mockAccountsUseCases.send).toHaveBeenCalledWith( 'account-id', mockTxRequest, @@ -60,26 +60,26 @@ describe('RpcHandler', () => { it('propagates errors from display', async () => { const error = new Error(); - mockSendFormUseCases.display.mockRejectedValue(error); + mockSendFlowUseCases.display.mockRejectedValue(error); await expect( handler.route(InternalRpcMethod.StartSendTransactionFlow, params), ).rejects.toThrow(error); - expect(mockSendFormUseCases.display).toHaveBeenCalled(); + expect(mockSendFlowUseCases.display).toHaveBeenCalled(); expect(mockAccountsUseCases.send).not.toHaveBeenCalled(); }); it('propagates errors from send', async () => { const error = new Error(); - mockSendFormUseCases.display.mockResolvedValue(mockTxRequest); + mockSendFlowUseCases.display.mockResolvedValue(mockTxRequest); mockAccountsUseCases.send.mockRejectedValue(error); await expect( handler.route(InternalRpcMethod.StartSendTransactionFlow, params), ).rejects.toThrow(error); - expect(mockSendFormUseCases.display).toHaveBeenCalled(); + expect(mockSendFlowUseCases.display).toHaveBeenCalled(); expect(mockAccountsUseCases.send).toHaveBeenCalled(); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index efc80ad6..b32c0ed9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -3,7 +3,7 @@ import type { Json, JsonRpcParams } from '@metamask/utils'; import { assert, enums, object, optional, string } from 'superstruct'; import { InternalRpcMethod } from '../permissions'; -import type { AccountUseCases, SendFormUseCases } from '../use-cases'; +import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; export const CreateSendFormRequest = object({ account: string(), @@ -15,12 +15,12 @@ type SendTransactionResponse = { }; export class RpcHandler { - readonly #sendFormUseCases: SendFormUseCases; + readonly #sendFlowUseCases: SendFlowUseCases; readonly #accountUseCases: AccountUseCases; - constructor(sendForm: SendFormUseCases, accounts: AccountUseCases) { - this.#sendFormUseCases = sendForm; + constructor(sendFlow: SendFlowUseCases, accounts: AccountUseCases) { + this.#sendFlowUseCases = sendFlow; this.#accountUseCases = accounts; } @@ -44,7 +44,7 @@ export class RpcHandler { ): Promise { assert(params, CreateSendFormRequest); - const txRequest = await this.#sendFormUseCases.display(params.account); + const txRequest = await this.#sendFlowUseCases.display(params.account); const txId = await this.#accountUseCases.send(params.account, txRequest); return { txId }; } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts new file mode 100644 index 00000000..e83e0be7 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts @@ -0,0 +1,124 @@ +import type { UserInputEvent } from '@metamask/snaps-sdk'; +import { UserInputEventType } from '@metamask/snaps-sdk'; +import { mock } from 'jest-mock-extended'; + +import type { SendFormContext } from '../entities'; +import { ReviewTransactionEvent, SendFormEvent } from '../entities'; +import type { SendFlowUseCases } from '../use-cases'; +import { UserInputHandler } from './UserInputHandler'; + +describe('UserInputHandler', () => { + const mockSendFlowUseCases = mock(); + const mockContext = mock(); + + let handler: UserInputHandler; + + beforeEach(() => { + handler = new UserInputHandler(mockSendFlowUseCases); + }); + + describe('route', () => { + it('throws error if missing context', async () => { + await expect( + handler.route( + 'interface-id', + { type: UserInputEventType.ButtonClickEvent } as UserInputEvent, + null, + ), + ).rejects.toThrow('Missing context'); + }); + + it('throws error if missing event name', async () => { + await expect( + handler.route( + 'interface-id', + { type: UserInputEventType.ButtonClickEvent } as UserInputEvent, + mockContext, + ), + ).rejects.toThrow('Missing event name'); + }); + + it('throws on unsupported event', async () => { + await expect( + handler.route( + 'interface-id', + { + type: UserInputEventType.ButtonClickEvent, + name: 'randomEvent', + }, + mockContext, + ), + ).rejects.toThrow('Unsupported event: randomEvent'); + }); + }); + + describe('update send form', () => { + it('executes on supported event', async () => { + await handler.route( + 'interface-id', + { + type: UserInputEventType.ButtonClickEvent, + name: SendFormEvent.ClearRecipient, + }, + mockContext, + ); + + expect(mockSendFlowUseCases.updateForm).toHaveBeenCalledWith( + 'interface-id', + SendFormEvent.ClearRecipient, + mockContext, + ); + }); + + it('propagates errors from updateForm', async () => { + const error = new Error(); + mockSendFlowUseCases.updateForm.mockRejectedValue(error); + + await expect( + handler.route( + 'interface-id', + { + type: UserInputEventType.ButtonClickEvent, + name: SendFormEvent.ClearRecipient, + }, + mockContext, + ), + ).rejects.toThrow(error); + }); + }); + + describe('update transaction review', () => { + it('executes on supported event', async () => { + await handler.route( + 'interface-id', + { + type: UserInputEventType.ButtonClickEvent, + name: ReviewTransactionEvent.Send, + }, + mockContext, + ); + + expect(mockSendFlowUseCases.updateReview).toHaveBeenCalledWith( + 'interface-id', + ReviewTransactionEvent.Send, + mockContext, + ); + }); + + it('propagates errors from updateReview', async () => { + const error = new Error(); + mockSendFlowUseCases.updateReview.mockRejectedValue(error); + + await expect( + handler.route( + 'interface-id', + { + type: UserInputEventType.ButtonClickEvent, + name: ReviewTransactionEvent.Send, + }, + mockContext, + ), + ).rejects.toThrow(error); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts new file mode 100644 index 00000000..808bc8c1 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts @@ -0,0 +1,59 @@ +import type { Json, UserInputEvent } from '@metamask/snaps-sdk'; + +import type { ReviewTransactionContext } from '../entities'; +import { + ReviewTransactionEvent, + type SendFormContext, + SendFormEvent, +} from '../entities'; +import type { SendFlowUseCases } from '../use-cases'; + +export class UserInputHandler { + readonly #sendFlowUseCases: SendFlowUseCases; + + constructor(sendFlow: SendFlowUseCases) { + this.#sendFlowUseCases = sendFlow; + } + + async route( + interfaceId: string, + event: UserInputEvent, + context: Record | null, + ): Promise { + if (!context) { + throw new Error('Missing context'); + } + + if (!event.name) { + throw new Error('Missing event name'); + } + + if (this.#isSendFormEvent(event.name)) { + // Cast context to SendFormContext + return this.#sendFlowUseCases.updateForm( + interfaceId, + event.name, + context as SendFormContext, + ); + } else if (this.#isReviewTransactionEvent(event.name)) { + // Cast context to the appropriate type for review + return this.#sendFlowUseCases.updateReview( + interfaceId, + event.name, + context as ReviewTransactionContext, + ); + } + + throw new Error(`Unsupported event: ${event.name}`); + } + + #isSendFormEvent(name: string): name is SendFormEvent { + return Object.values(SendFormEvent).includes(name as SendFormEvent); + } + + #isReviewTransactionEvent(name: string): name is ReviewTransactionEvent { + return Object.values(ReviewTransactionEvent).includes( + name as ReviewTransactionEvent, + ); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts index 098a1688..9cb7b300 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts @@ -1,5 +1,7 @@ export * from './KeyringHandler'; export * from './CronHandler'; export * from './RpcHandler'; +export * from './UserInputHandler'; export { Caip2AddressType } from './caip2'; +export { Caip19Asset } from './caip19'; diff --git a/merged-packages/bitcoin-wallet-snap/src/images/btc-halo.svg b/merged-packages/bitcoin-wallet-snap/src/images/btc-halo.svg new file mode 100644 index 00000000..69a14a20 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/images/btc-halo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index a0e0b1cf..b3fec56e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -13,7 +13,12 @@ import { import { Config } from './config'; import { ConfigV2 } from './configv2'; -import { KeyringHandler, CronHandler, RpcHandler } from './handlers'; +import { + KeyringHandler, + CronHandler, + UserInputHandler, + RpcHandler, +} from './handlers'; import { SnapClientAdapter, EsploraClientAdapter } from './infra'; import { BtcKeyring } from './keyring'; import { InternalRpcMethod, originPermissions } from './permissions'; @@ -30,13 +35,13 @@ import { import type { StartSendTransactionFlowParams } from './rpcs/start-send-transaction-flow'; import { startSendTransactionFlow } from './rpcs/start-send-transaction-flow'; import { KeyringStateManager } from './stateManagement'; -import { BdkAccountRepository, JSXSendFormRepository } from './store'; +import { BdkAccountRepository, JSXSendFlowRepository } from './store'; import { isSendFormEvent, SendBitcoinController, } from './ui/controller/send-bitcoin-controller'; import type { SendFlowContext, SendFormState } from './ui/types'; -import { AccountUseCases, SendFormUseCases } from './use-cases'; +import { AccountUseCases, SendFlowUseCases } from './use-cases'; import { isSnapRpcError, logger } from './utils'; import { loadLocale } from './utils/locale'; @@ -45,6 +50,7 @@ logger.logLevel = parseInt(Config.logLevel, 10); let keyringHandler: Keyring; let cronHandler: CronHandler; let rpcHandler: RpcHandler; +let userInputHandler: UserInputHandler; let accountsUseCases: AccountUseCases; if (ConfigV2.keyringVersion === 'v2') { // Infra layer @@ -52,7 +58,7 @@ if (ConfigV2.keyringVersion === 'v2') { const chainClient = new EsploraClientAdapter(ConfigV2.chain); // Data layer const accountRepository = new BdkAccountRepository(snapClient); - const sendFormRepository = new JSXSendFormRepository(snapClient); + const sendFlowRepository = new JSXSendFlowRepository(snapClient); // Business layer accountsUseCases = new AccountUseCases( @@ -61,17 +67,19 @@ if (ConfigV2.keyringVersion === 'v2') { chainClient, ConfigV2.accounts, ); - const sendFormUseCases = new SendFormUseCases( + const sendFlowUseCases = new SendFlowUseCases( snapClient, accountRepository, - sendFormRepository, + sendFlowRepository, chainClient, - ConfigV2.chain.targetBlocksConfirmation, + ConfigV2.targetBlocksConfirmation, + ConfigV2.fallbackFeeRate, ); // Application layer keyringHandler = new KeyringHandler(accountsUseCases); cronHandler = new CronHandler(accountsUseCases); - rpcHandler = new RpcHandler(sendFormUseCases, accountsUseCases); + rpcHandler = new RpcHandler(sendFlowUseCases, accountsUseCases); + userInputHandler = new UserInputHandler(sendFlowUseCases); } export const validateOrigin = (origin: string, method: string): void => { @@ -213,20 +221,36 @@ export const onUserInput: OnUserInputHandler = async ({ }) => { await loadLocale(); - const state = await snap.request({ - method: 'snap_getInterfaceState', - params: { id }, - }); - - if (isSendFormEvent(event)) { - const sendBitcoinController = new SendBitcoinController({ - context: context as SendFlowContext, - interfaceId: id, - }); - await sendBitcoinController.handleEvent( - event, - context as SendFlowContext, - state.sendForm as SendFormState, + try { + if (!userInputHandler) { + const state = await snap.request({ + method: 'snap_getInterfaceState', + params: { id }, + }); + + if (isSendFormEvent(event)) { + const sendBitcoinController = new SendBitcoinController({ + context: context as SendFlowContext, + interfaceId: id, + }); + return await sendBitcoinController.handleEvent( + event, + context as SendFlowContext, + state.sendForm as SendFormState, + ); + } + } + + return userInputHandler.route(id, event, context); + } catch (error) { + let snapError = error; + + if (!isSnapRpcError(error)) { + snapError = new SnapError(error); + } + logger.error( + `onUserInput error: ${JSON.stringify(snapError.toJSON(), null, 2)}`, ); + throw snapError; } }; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index af931de7..a464f4c3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -108,7 +108,7 @@ export class BdkAccountAdapter implements BitcoinAccount { } buildTx(feeRate: number, recipient: string, amount: string): Psbt { - const fee = new FeeRate(BigInt(feeRate)); + const fee = new FeeRate(BigInt(Math.floor(feeRate))); const to = new Recipient( Address.new(recipient, this.network), Amount.from_sat(BigInt(amount)), @@ -117,7 +117,7 @@ export class BdkAccountAdapter implements BitcoinAccount { } drainTo(feeRate: number, recipient: string): Psbt { - const fee = new FeeRate(BigInt(feeRate)); + const fee = new FeeRate(BigInt(Math.floor(feeRate))); const to = Address.new(recipient, this.network); return this.#wallet.drain_to(fee, to); } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 348916c8..88e42a1e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -5,12 +5,13 @@ import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import type { AvailableCurrency, ComponentOrElement, + CurrencyRate, Json, SnapsProvider, } from '@metamask/snaps-sdk'; -import type { BitcoinAccount, CurrencyUnit } from '../entities'; -import type { SnapClient, SnapState } from '../entities/snap'; +import type { BitcoinAccount, SnapClient, SnapState } from '../entities'; +import { CurrencyUnit } from '../entities'; import { snapToKeyringAccount } from '../handlers/keyring-account'; export class SnapClientAdapter implements SnapClient { @@ -153,14 +154,21 @@ export class SnapClientAdapter implements SnapClient { }); } - async getCurrencyRate(currency: CurrencyUnit): Promise { - const result = await snap.request({ + async getCurrencyRate( + currency: CurrencyUnit, + ): Promise { + // TODO: Remove when fix implemented: https://github.com/MetaMask/accounts-planning/issues/832 + if (currency !== CurrencyUnit.Bitcoin) { + return undefined; + } + + const rate = await snap.request({ method: 'snap_getCurrencyRate', params: { currency: currency as unknown as AvailableCurrency, }, }); - return result?.conversionRate; + return rate ?? undefined; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx new file mode 100644 index 00000000..816b3ca9 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx @@ -0,0 +1,111 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { + Box, + Button, + Container, + Footer, + Heading, + Row, + Section, + Text, + Value, + Image, + Address, +} from '@metamask/snaps-sdk/jsx'; +import type { CaipAccountId } from '@metamask/utils'; + +import { ConfigV2 } from '../../configv2'; +import type { ReviewTransactionContext } from '../../entities'; +import { ReviewTransactionEvent } from '../../entities'; +import { networkToCaip2 } from '../../handlers/caip2'; +import btcIcon from '../../images/btc-halo.svg'; +import { getTranslator } from '../../utils/locale'; +import { HeadingWithReturn } from './components'; +import { displayAmount, displayFiatAmount } from './format'; + +export const ReviewTransactionView: SnapComponent = ( + props, +) => { + const t = getTranslator(); + const { amount, fee, currency, fiatRate, feeRate, recipient, network, from } = + props; + + const total = BigInt(amount) + BigInt(fee); + + return ( + + + + + + + + + {`${t('sending')} ${displayAmount( + BigInt(amount), + currency, + )}`} + {t('reviewTransactionWarning')} + + +
+ +
+
+ + + + +
+
+
+ +
+ + + {`${ConfigV2.targetBlocksConfirmation * 10} ${t('minutes')}`} + + + + + + + {`${feeRate} sat/vb`} + + + + +
+
+ +
+ +
+
+ ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx index 0ac019b1..547ddef8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx @@ -1,13 +1,51 @@ import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; -import { Container, Text } from '@metamask/snaps-sdk/jsx'; +import { + Box, + Button, + Container, + Footer, + Row, + Text, +} from '@metamask/snaps-sdk/jsx'; import type { SendFormContext } from '../../entities'; +import { SendFormEvent } from '../../entities'; +import { getTranslator } from '../../utils/locale'; +import { HeadingWithReturn, SendForm, TransactionSummary } from './components'; + +export const SendFormView: SnapComponent = (props) => { + const t = getTranslator(); -// Empty for now just to separate the work in smaller PRs -export const SendFormView: SnapComponent = () => { return ( - Empty placeholder + + + + + + {props.errors.tx !== undefined && ( + + {props.errors.tx} + + )} + + {props.fee !== undefined && props.amount !== undefined && ( + + )} + + +
+ +
); }; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/HeadingWithReturn.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/HeadingWithReturn.tsx new file mode 100644 index 00000000..20ad570a --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/HeadingWithReturn.tsx @@ -0,0 +1,26 @@ +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Box, Button, Heading, Icon, Image } from '@metamask/snaps-sdk/jsx'; + +import emptySpace from '../../../images/empty-space.svg'; + +export type HeadingWithReturnProps = { + heading: string; + returnButtonName: string; +}; + +export const HeadingWithReturn: SnapComponent = ({ + heading, + returnButtonName, +}) => ( + + + {heading} + {/* FIXME: This empty space is needed to center-align the header text. + * The Snap UI centers the text within its container, but the container + * itself is misaligned in the header due to the back arrow. + */} + + +); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx new file mode 100644 index 00000000..1a397060 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx @@ -0,0 +1,71 @@ +import { + Box, + Button, + Field, + Form, + Icon, + Image, + Input, + Text, + type SnapComponent, +} from '@metamask/snaps-sdk/jsx'; + +import type { SendFormContext } from '../../../entities'; +import { SENDFORM_NAME, SendFormEvent } from '../../../entities'; +import btcIcon from '../../../images/bitcoin.svg'; +import { getTranslator } from '../../../utils/locale'; +import { displayAmount } from '../format'; + +export const SendForm: SnapComponent = ({ + currency, + balance, + amount, + recipient, + errors, +}) => { + const t = getTranslator(); + + const validAddress = Boolean(recipient && !errors.recipient); + + return ( +
+ + + + + + + + + + {`${t('balance')}: ${displayAmount(BigInt(balance), currency)}`} + + + + + + + + {Boolean(recipient) && ( + + + + )} + + {validAddress && } +
+ ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx new file mode 100644 index 00000000..a4c091da --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx @@ -0,0 +1,53 @@ +import type { CurrencyRate } from '@metamask/snaps-sdk'; +import { + Row, + Section, + Text, + Value, + type SnapComponent, +} from '@metamask/snaps-sdk/jsx'; + +import { ConfigV2 } from '../../../configv2'; +import type { CurrencyUnit } from '../../../entities'; +import { getTranslator } from '../../../utils/locale'; +import { displayAmount, displayFiatAmount } from '../format'; + +type TransactionSummaryProps = { + currency: CurrencyUnit; + fiatRate?: CurrencyRate; + amount: string; + fee: string; +}; + +export const TransactionSummary: SnapComponent = ({ + fee, + amount, + currency, + fiatRate, +}) => { + const t = getTranslator(); + + const total = BigInt(amount) + BigInt(fee); + + return ( +
+ + + + + + {`${ConfigV2.targetBlocksConfirmation * 10} ${t('minutes')}`} + + + + + +
+ ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/index.ts new file mode 100644 index 00000000..5976a7ac --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/index.ts @@ -0,0 +1,3 @@ +export * from './SendForm'; +export * from './TransactionSummary'; +export * from './HeadingWithReturn'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts new file mode 100644 index 00000000..26533a8e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts @@ -0,0 +1,27 @@ +import type { CurrencyRate } from '@metamask/snaps-sdk'; +import { Amount } from 'bitcoindevkit'; + +import type { CurrencyUnit } from '../../entities'; + +export const displayAmount = ( + amountSats: bigint, + currency?: CurrencyUnit, +): string => { + const amount = Amount.from_sat(amountSats).to_btc(); + if (currency) { + return `${amount} ${currency}`; + } + + return amount.toString(); +}; + +export const displayFiatAmount = ( + amount: bigint, + fiatRate?: CurrencyRate, +): string => { + return fiatRate + ? `${((Number(amount) * fiatRate.conversionRate) / 1e8).toFixed(2)} ${ + fiatRate.currency + }` + : ''; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/index.ts index c87905be..34d7abff 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/index.ts @@ -1 +1,2 @@ export * from './SendFormView'; +export * from './ReviewTransactionView'; diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.test.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.test.tsx index 28a059d9..b716611b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.test.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.test.tsx @@ -1,16 +1,27 @@ +import type { AddressInfo } from 'bitcoindevkit'; import { mock } from 'jest-mock-extended'; -import type { SnapClient, BitcoinAccount, SendFormContext } from '../entities'; -import { SENDFORM_NAME, CurrencyUnit } from '../entities'; -import { SendFormView } from '../infra/jsx'; -import { JSXSendFormRepository } from './JSXSendFormRepository'; +import type { + SnapClient, + SendFormContext, + ReviewTransactionContext, + BitcoinAccount, +} from '../entities'; +import { CurrencyUnit, SENDFORM_NAME } from '../entities'; +import { ReviewTransactionView, SendFormView } from '../infra/jsx'; +import { JSXSendFlowRepository } from './JSXSendFormRepository'; -describe('JSXSendFormRepository', () => { +jest.mock('../infra/jsx', () => ({ + SendFormView: jest.fn(), + ReviewTransactionView: jest.fn(), +})); + +describe('JSXSendFlowRepository', () => { const mockSnapClient = mock(); - let repo: JSXSendFormRepository; + let repo: JSXSendFlowRepository; beforeEach(() => { - repo = new JSXSendFormRepository(mockSnapClient); + repo = new JSXSendFlowRepository(mockSnapClient); }); describe('getState', () => { @@ -36,35 +47,40 @@ describe('JSXSendFormRepository', () => { }); }); - describe('insert', () => { + describe('insertForm', () => { const feeRate = 10; - const account = mock({ + const mockAccount = mock({ id: 'acc-id', network: 'bitcoin', // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 /* eslint-disable @typescript-eslint/naming-convention */ balance: { trusted_spendable: { to_sat: () => BigInt(1234) } }, + peekAddress: jest.fn(), }); + const fiatRate = { + currency: 'USD', + conversionRate: 100000, + conversionDate: 2025, + }; it('creates interface with correct context', async () => { - const btcRate = 100000; - mockSnapClient.getCurrencyRate.mockResolvedValue(btcRate); mockSnapClient.createInterface.mockResolvedValue('interface-id'); - - const result = await repo.insert(account, feeRate); - + mockAccount.peekAddress.mockReturnValue({ + address: 'myAddress', + } as AddressInfo); const expectedContext: SendFormContext = { balance: '1234', currency: CurrencyUnit.Bitcoin, - account: 'acc-id', + account: { id: 'acc-id', address: 'myAddress' }, network: 'bitcoin', feeRate, errors: {}, - fiatRate: btcRate, + fiatRate, }; - expect(mockSnapClient.getCurrencyRate).toHaveBeenCalledWith( - CurrencyUnit.Bitcoin, - ); + + const result = await repo.insertForm(mockAccount, feeRate, fiatRate); + + expect(mockAccount.peekAddress).toHaveBeenCalledWith(0); expect(mockSnapClient.createInterface).toHaveBeenCalledWith( , expectedContext, @@ -73,17 +89,32 @@ describe('JSXSendFormRepository', () => { }); }); - describe('update', () => { + describe('updateForm', () => { + it('updates interface with context', async () => { + const id = 'interface-id'; + const mockContext = mock({}); + + await repo.updateForm(id, mockContext); + + expect(mockSnapClient.updateInterface).toHaveBeenCalledWith( + id, + , + mockContext, + ); + }); + }); + + describe('updateReview', () => { it('updates interface with context', async () => { const id = 'interface-id'; - const context = mock(); + const mockContext = mock({}); - await repo.update(id, context); + await repo.updateReview(id, mockContext); expect(mockSnapClient.updateInterface).toHaveBeenCalledWith( id, - , - context, + , + mockContext, ); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.tsx index 4c1f3bcd..1569dc91 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.tsx @@ -1,15 +1,17 @@ -import type { BitcoinAccount } from '../entities'; -import { - networkToCurrencyUnit, - SENDFORM_NAME, - type SendFormContext, - type SendFormRepository, - type SendFormState, - type SnapClient, +import type { CurrencyRate } from '@metamask/snaps-sdk'; + +import type { + SendFormContext, + SendFlowRepository, + SendFormState, + SnapClient, + ReviewTransactionContext, + BitcoinAccount, } from '../entities'; -import { SendFormView } from '../infra/jsx'; +import { networkToCurrencyUnit, SENDFORM_NAME } from '../entities'; +import { ReviewTransactionView, SendFormView } from '../infra/jsx'; -export class JSXSendFormRepository implements SendFormRepository { +export class JSXSendFlowRepository implements SendFlowRepository { readonly #snapClient: SnapClient; constructor(snapClient: SnapClient) { @@ -29,31 +31,43 @@ export class JSXSendFormRepository implements SendFormRepository { return state; } - async insert(account: BitcoinAccount, feeRate: number): Promise { - const currency = networkToCurrencyUnit[account.network]; + async insertForm( + account: BitcoinAccount, + feeRate: number, + fiatRate?: CurrencyRate, + ): Promise { const context: SendFormContext = { balance: account.balance.trusted_spendable.to_sat().toString(), - currency, - account: account.id, + currency: networkToCurrencyUnit[account.network], + account: { id: account.id, address: account.peekAddress(0).address }, // FIXME: Address should not be needed here network: account.network, feeRate, + fiatRate, errors: {}, }; - // TODO: Fetch fiat/fee rates from state and refresh on updates - context.fiatRate = await this.#snapClient.getCurrencyRate(currency); - return this.#snapClient.createInterface( , context, ); } - async update(id: string, context: SendFormContext): Promise { + async updateForm(id: string, context: SendFormContext): Promise { return this.#snapClient.updateInterface( id, , context, ); } + + async updateReview( + id: string, + context: ReviewTransactionContext, + ): Promise { + return this.#snapClient.updateInterface( + id, + , + context, + ); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.test.ts index e3a8e2ed..6b319d8b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.test.ts @@ -1,42 +1,71 @@ +import type { CurrencyRate } from '@metamask/snaps-sdk'; import { UserRejectedRequestError } from '@metamask/snaps-sdk'; -import type { FeeEstimates } from 'bitcoindevkit'; +import type { Psbt, FeeEstimates } from 'bitcoindevkit'; +import { Address, Amount } from 'bitcoindevkit'; import { mock } from 'jest-mock-extended'; import type { + SendFormContext, BitcoinAccount, BitcoinAccountRepository, BlockchainClient, - SendFormRepository, + SendFlowRepository, SnapClient, TransactionRequest, + ReviewTransactionContext, } from '../entities'; -import { SendFormUseCases } from './SendFormUseCases'; +import { + ReviewTransactionEvent, + CurrencyUnit, + SendFormEvent, +} from '../entities'; +import { SendFlowUseCases } from './SendFormUseCases'; + +// TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('bitcoindevkit', () => { + return { + Address: { + new: jest.fn(), + }, + Amount: { + from_btc: jest.fn(), + }, + }; +}); jest.mock('../utils/logger'); -describe('SendFormUseCases', () => { - let useCases: SendFormUseCases; +describe('SendFlowUseCases', () => { + let useCases: SendFlowUseCases; const mockSnapClient = mock(); const mockAccountRepository = mock(); - const mockSendFormRepository = mock(); + const mockSendFlowRepository = mock(); const mockChain = mock(); const targetBlocksConfirmation = 3; - const mockAccount = mock({ network: 'bitcoin' }); + const fallbackFeeRate = 5.0; + const mockAccount = mock({ + network: 'bitcoin', + drainTo: jest.fn(), + buildTx: jest.fn(), + }); const mockFeeEstimates = mock({ get: jest.fn() }); const mockTxRequest = mock(); + const mockCurrencyRate = mock(); beforeEach(() => { - useCases = new SendFormUseCases( + useCases = new SendFlowUseCases( mockSnapClient, mockAccountRepository, - mockSendFormRepository, + mockSendFlowRepository, mockChain, targetBlocksConfirmation, + fallbackFeeRate, ); }); - describe('display', () => { + describe('displayForm', () => { it('throws error if account not found', async () => { mockAccountRepository.get.mockResolvedValue(null); await expect(useCases.display('non-existent-account')).rejects.toThrow( @@ -44,21 +73,11 @@ describe('SendFormUseCases', () => { ); }); - it('throws error if fee rate is missing', async () => { - mockAccountRepository.get.mockResolvedValue(mockAccount); - mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); - mockFeeEstimates.get.mockReturnValue(undefined); - - await expect(useCases.display('account-id')).rejects.toThrow( - 'Failed to fetch fee rates', - ); - }); - it('throws UserRejectedRequestError if displayInterface returns null', async () => { mockAccountRepository.get.mockResolvedValue(mockAccount); mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); mockFeeEstimates.get.mockReturnValue(5); - mockSendFormRepository.insert.mockResolvedValue('form-id'); + mockSendFlowRepository.insertForm.mockResolvedValue('interface-id'); mockSnapClient.displayInterface.mockResolvedValue(null); await expect(useCases.display('account-id')).rejects.toThrow( @@ -70,8 +89,9 @@ describe('SendFormUseCases', () => { mockAccountRepository.get.mockResolvedValue(mockAccount); mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); mockFeeEstimates.get.mockReturnValue(5); - mockSendFormRepository.insert.mockResolvedValue('form-id'); + mockSendFlowRepository.insertForm.mockResolvedValue('interface-id'); mockSnapClient.displayInterface.mockResolvedValue(mockTxRequest); + mockSnapClient.getCurrencyRate.mockResolvedValue(mockCurrencyRate); const result = await useCases.display('account-id'); @@ -79,15 +99,340 @@ describe('SendFormUseCases', () => { expect(mockChain.getFeeEstimates).toHaveBeenCalledWith( mockAccount.network, ); + expect(mockSnapClient.getCurrencyRate).toHaveBeenCalledWith( + CurrencyUnit.Bitcoin, + ); expect(mockFeeEstimates.get).toHaveBeenCalledWith( targetBlocksConfirmation, ); - expect(mockSendFormRepository.insert).toHaveBeenCalledWith( + expect(mockSendFlowRepository.insertForm).toHaveBeenCalledWith( mockAccount, 5, + mockCurrencyRate, + ); + expect(mockSnapClient.displayInterface).toHaveBeenCalledWith( + 'interface-id', ); - expect(mockSnapClient.displayInterface).toHaveBeenCalledWith('form-id'); expect(result).toStrictEqual(mockTxRequest); }); }); + + describe('updateForm', () => { + const mockContext: SendFormContext = { + account: { id: 'account-id', address: 'myAddress' }, + amount: '1000', + balance: '20000', + currency: CurrencyUnit.Bitcoin, + drain: false, + recipient: 'recipientAddress', + errors: { + recipient: 'invalid recipient', + tx: 'errors on tx', + amount: 'invalid amount', + }, + feeRate: 2.4, + network: 'bitcoin', + fee: '10', + fiatRate: { + currency: 'USD', + conversionRate: 100000, + conversionDate: 2025, + }, + }; + + it('throws error unrecognized event', async () => { + await expect( + useCases.updateForm( + 'interface-id', + 'randomEvent' as SendFormEvent, + mockContext, + ), + ).rejects.toThrow('Unrecognized event'); + }); + + it('resolves to null on Cancel', async () => { + await useCases.updateForm( + 'interface-id', + SendFormEvent.Cancel, + mockContext, + ); + expect(mockSnapClient.resolveInterface).toHaveBeenCalledWith( + 'interface-id', + null, + ); + }); + + it('clears state on ClearRecipient', async () => { + const expectedContext = { + ...mockContext, + recipient: undefined, + fee: undefined, + errors: { + ...mockContext.errors, + tx: undefined, + recipient: undefined, + }, + }; + + await useCases.updateForm( + 'interface-id', + SendFormEvent.ClearRecipient, + mockContext, + ); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + + it('throws error on Review if amount, recipient or fee are not defined', async () => { + await expect( + useCases.updateForm('interface-id', SendFormEvent.Confirm, { + ...mockContext, + recipient: undefined, + }), + ).rejects.toThrow('Inconsistent Send form context'); + + await expect( + useCases.updateForm('interface-id', SendFormEvent.Confirm, { + ...mockContext, + amount: undefined, + }), + ).rejects.toThrow('Inconsistent Send form context'); + + await expect( + useCases.updateForm('interface-id', SendFormEvent.Confirm, { + ...mockContext, + fee: undefined, + }), + ).rejects.toThrow('Inconsistent Send form context'); + }); + + it('updates interface to the transaction review on Confirm', async () => { + const expectedReviewContext: ReviewTransactionContext = { + from: mockContext.account.address, + network: mockContext.network, + amount: '1000', + recipient: 'recipientAddress', + feeRate: mockContext.feeRate, + fiatRate: mockContext.fiatRate, + currency: mockContext.currency, + fee: '10', + sendForm: mockContext, + }; + + await useCases.updateForm( + 'interface-id', + SendFormEvent.Confirm, + mockContext, + ); + expect(mockSendFlowRepository.updateReview).toHaveBeenCalledWith( + 'interface-id', + expectedReviewContext, + ); + }); + + it('clears state and set drain to true on SetMax', async () => { + // avoid computing the fee in this test + const testContext = { + ...mockContext, + recipient: undefined, + }; + const expectedContext = { + ...testContext, + drain: true, + amount: mockContext.balance, + fee: undefined, + errors: { + ...mockContext.errors, + tx: undefined, + amount: undefined, + }, + }; + + await useCases.updateForm( + 'interface-id', + SendFormEvent.SetMax, + testContext, + ); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + + it('sets recipient from state on Recipient', async () => { + (Address.new as jest.Mock).mockReturnValue({ + toString: () => 'newAddressValidated', + }); + + // avoid computing the fee in this test + const testContext = { + ...mockContext, + amount: undefined, + }; + + mockSendFlowRepository.getState.mockResolvedValue({ + recipient: 'newAddress', + amount: '', + }); + const expectedContext = { + ...testContext, + recipient: 'newAddressValidated', + errors: { + ...mockContext.errors, + tx: undefined, + recipient: undefined, + }, + }; + + await useCases.updateForm( + 'interface-id', + SendFormEvent.Recipient, + testContext, + ); + + expect(mockSendFlowRepository.getState).toHaveBeenCalledWith( + 'interface-id', + ); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + + it('sets amount from state on Amount', async () => { + (Amount.from_btc as jest.Mock).mockReturnValue({ + to_sat: () => BigInt('1111'), // Use different amount than state to verify that we get the result from the toString of the bigint + }); + + const testContext = { + ...mockContext, + recipient: undefined, // avoid computing the fee in this test + }; + + mockSendFlowRepository.getState.mockResolvedValue({ + recipient: '', + amount: '21000', + }); + const expectedContext = { + ...testContext, + drain: undefined, + fee: undefined, + amount: '1111', + errors: { + ...mockContext.errors, + tx: undefined, + amount: undefined, + }, + }; + + await useCases.updateForm( + 'interface-id', + SendFormEvent.Amount, + testContext, + ); + + expect(mockSendFlowRepository.getState).toHaveBeenCalledWith( + 'interface-id', + ); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + + it('computes the fee when amount and recipient are filled', async () => { + const mockPsbt = mock({ + fee: () => { + return { to_sat: () => BigInt(10) } as unknown as Amount; + }, + }); + mockAccountRepository.get.mockResolvedValue(mockAccount); + mockAccount.drainTo.mockReturnValue(mockPsbt); + + const expectedContext = { + ...mockContext, + drain: expect.anything(), + fee: '10', + amount: String(20000 - 10), + errors: expect.anything(), + }; + + await useCases.updateForm( + 'interface-id', + SendFormEvent.SetMax, + mockContext, + ); + + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + }); + + describe('updateReview', () => { + const mockContext: ReviewTransactionContext = { + from: 'myAddress', + network: 'bitcoin', + amount: '10000', + currency: CurrencyUnit.Bitcoin, + recipient: 'recipientAddress', + feeRate: 2.4, + fee: '10', + sendForm: {} as SendFormContext, + }; + + it('throws error unrecognized event', async () => { + await expect( + useCases.updateReview( + 'interface-id', + 'randomEvent' as ReviewTransactionEvent, + mockContext, + ), + ).rejects.toThrow('Unrecognized event'); + }); + + it('resolves to null on HeaderBack if missing send form in context', async () => { + await useCases.updateReview( + 'interface-id', + ReviewTransactionEvent.HeaderBack, + { ...mockContext, sendForm: undefined }, + ); + expect(mockSnapClient.resolveInterface).toHaveBeenCalledWith( + 'interface-id', + null, + ); + }); + + it('reverts interface back to send form if present in context', async () => { + await useCases.updateReview( + 'interface-id', + ReviewTransactionEvent.HeaderBack, + mockContext, + ); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + mockContext.sendForm, + ); + }); + + it('resolves to the transaction request on Send', async () => { + await useCases.updateReview( + 'interface-id', + ReviewTransactionEvent.Send, + mockContext, + ); + + expect(mockSnapClient.resolveInterface).toHaveBeenCalledWith( + 'interface-id', + { + amount: mockContext.amount, + recipient: mockContext.recipient, + feeRate: mockContext.feeRate, + }, + ); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.ts index 1ba1b18d..03ed1c93 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.ts @@ -1,41 +1,53 @@ import { UserRejectedRequestError } from '@metamask/snaps-sdk'; +import { Address, Amount } from 'bitcoindevkit'; import type { BitcoinAccountRepository, - SendFormRepository, + SendFlowRepository, SnapClient, BlockchainClient, TransactionRequest, + SendFormContext, + ReviewTransactionContext, +} from '../entities'; +import { + SendFormEvent, + ReviewTransactionEvent, + networkToCurrencyUnit, } from '../entities'; import { logger } from '../utils'; -export class SendFormUseCases { +export class SendFlowUseCases { readonly #snapClient: SnapClient; readonly #accountRepository: BitcoinAccountRepository; - readonly #sendFormRepository: SendFormRepository; + readonly #sendFlowRepository: SendFlowRepository; readonly #chainClient: BlockchainClient; readonly #targetBlocksConfirmation: number; + readonly #fallbackFeeRate: number; + constructor( snapClient: SnapClient, accountRepository: BitcoinAccountRepository, - sendFormrepository: SendFormRepository, + sendFlowRepository: SendFlowRepository, chainClient: BlockchainClient, targetBlocksConfirmation: number, + fallbackFeeRate: number, ) { this.#snapClient = snapClient; this.#accountRepository = accountRepository; - this.#sendFormRepository = sendFormrepository; + this.#sendFlowRepository = sendFlowRepository; this.#chainClient = chainClient; this.#targetBlocksConfirmation = targetBlocksConfirmation; + this.#fallbackFeeRate = fallbackFeeRate; } async display(accountId: string): Promise { - logger.debug('Displaying Send form. Account: %s', accountId); + logger.trace('Displaying Send form view. Account: %s', accountId); const account = await this.#accountRepository.get(accountId); if (!account) { @@ -46,22 +58,210 @@ export class SendFormUseCases { const feeEstimates = await this.#chainClient.getFeeEstimates( account.network, ); - const feeRate = feeEstimates.get(this.#targetBlocksConfirmation); - if (!feeRate) { - throw new Error('Failed to fetch fee rates'); - } + const feeRate = + feeEstimates.get(this.#targetBlocksConfirmation) ?? this.#fallbackFeeRate; + + // TODO: Fetch fiat/fee rates from state and refresh on updates + const fiatRate = await this.#snapClient.getCurrencyRate( + networkToCurrencyUnit[account.network], + ); - const formId = await this.#sendFormRepository.insert(account, feeRate); + const interfaceId = await this.#sendFlowRepository.insertForm( + account, + feeRate, + fiatRate, + ); // Blocks and waits for user actions const request = await this.#snapClient.displayInterface( - formId, + interfaceId, ); if (!request) { throw new UserRejectedRequestError() as unknown as Error; } - logger.info('Send form resolved successfully: %s', formId); + logger.debug('Transaction request generated successfully: %o', request); return request; } + + async updateForm( + id: string, + event: SendFormEvent, + context: SendFormContext, + ): Promise { + logger.trace('Updating Send form. ID: %s. Event: %s', id, event); + + switch (event) { + case SendFormEvent.Cancel: { + return await this.#snapClient.resolveInterface(id, null); + } + case SendFormEvent.ClearRecipient: { + const updatedContext = { ...context }; + delete updatedContext.recipient; + delete updatedContext.errors.recipient; + delete updatedContext.errors.tx; + delete updatedContext.fee; + + return await this.#sendFlowRepository.updateForm(id, updatedContext); + } + case SendFormEvent.Confirm: { + if (context.amount && context.recipient && context.fee) { + const reviewContext: ReviewTransactionContext = { + from: context.account.address, + network: context.network, + amount: context.amount, + recipient: context.recipient, + feeRate: context.feeRate, + fiatRate: context.fiatRate, + currency: context.currency, + fee: context.fee, + sendForm: context, + }; + return await this.#sendFlowRepository.updateReview(id, reviewContext); + } + throw new Error('Inconsistent Send form context'); + } + case SendFormEvent.SetMax: { + return this.#handleSetMax(id, context); + } + case SendFormEvent.Recipient: { + return this.#handleSetRecipient(id, context); + } + case SendFormEvent.Amount: { + return this.#handleSetAmount(id, context); + } + default: + throw new Error('Unrecognized event'); + } + } + + async updateReview( + id: string, + event: ReviewTransactionEvent, + context: ReviewTransactionContext, + ): Promise { + logger.trace('Updating transaction review. ID: %s. Event: %s', id, event); + + switch (event) { + case ReviewTransactionEvent.HeaderBack: { + // If we come from a send form, we display it again, otherwise we resolve the interface (reject) + if (context.sendForm) { + return this.#sendFlowRepository.updateForm(id, context.sendForm); + } + + return this.#snapClient.resolveInterface(id, null); + } + case ReviewTransactionEvent.Send: { + const { amount, feeRate, recipient } = context; + const txRequest: TransactionRequest = { + feeRate, + amount, + recipient, + }; + return this.#snapClient.resolveInterface(id, txRequest); + } + default: + throw new Error('Unrecognized event'); + } + } + + async #handleSetMax(id: string, context: SendFormContext): Promise { + let updatedContext: SendFormContext = { + ...context, + amount: context.balance, + drain: true, + }; + delete updatedContext.errors.amount; + delete updatedContext.errors.tx; + delete updatedContext.fee; + + updatedContext = await this.#computeFee(updatedContext); + return await this.#sendFlowRepository.updateForm(id, updatedContext); + } + + async #handleSetRecipient( + id: string, + context: SendFormContext, + ): Promise { + const formState = await this.#sendFlowRepository.getState(id); + + let updatedContext = { ...context }; + delete updatedContext.errors.recipient; + delete updatedContext.errors.tx; + + try { + updatedContext.recipient = Address.new( + formState.recipient, + context.network, + ).toString(); + updatedContext = await this.#computeFee(updatedContext); + } catch (error) { + updatedContext.errors = { + ...updatedContext.errors, + recipient: error instanceof Error ? error.message : String(error), + }; + } + + return await this.#sendFlowRepository.updateForm(id, updatedContext); + } + + async #handleSetAmount(id: string, context: SendFormContext): Promise { + const formState = await this.#sendFlowRepository.getState(id); + + let updatedContext = { ...context }; + delete updatedContext.errors.amount; + delete updatedContext.errors.tx; + delete updatedContext.fee; + delete updatedContext.drain; + + try { + // We expect amounts to be entered in Bitcoin + const amount = Amount.from_btc(Number(formState.amount)); + updatedContext.amount = amount.to_sat().toString(); + updatedContext = await this.#computeFee(updatedContext); + } catch (error) { + updatedContext.errors = { + ...updatedContext.errors, + amount: error instanceof Error ? error.message : String(error), + }; + } + + return await this.#sendFlowRepository.updateForm(id, updatedContext); + } + + async #computeFee(context: SendFormContext): Promise { + const { amount, recipient, drain } = context; + if (amount && recipient) { + const account = await this.#accountRepository.get(context.account.id); + if (!account) { + throw new Error('Account removed while sending'); + } + + try { + if (drain) { + const psbt = account.drainTo(context.feeRate, recipient); + const fee = psbt.fee().to_sat(); + const realAmount = BigInt(amount) - fee; + return { + ...context, + fee: fee.toString(), + amount: realAmount.toString(), + }; + } + + const psbt = account.buildTx(context.feeRate, recipient, amount); + return { ...context, fee: psbt.fee().to_sat().toString() }; + } catch (error) { + return { + ...context, + errors: { + ...context.errors, + tx: error instanceof Error ? error.message : String(error), + }, + }; + } + } + + return context; + } } diff --git a/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts b/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts index 7f288cf2..9843710c 100644 --- a/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts +++ b/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts @@ -1,11 +1,14 @@ import type { KeyringAccount } from '@metamask/keyring-api'; import { BtcMethod, BtcScopes } from '@metamask/keyring-api'; import type { Snap } from '@metamask/snaps-jest'; -import { installSnap } from '@metamask/snaps-jest'; +import { assertIsCustomDialog, installSnap } from '@metamask/snaps-jest'; -import { CurrencyUnit } from '../../src/entities'; -import { Caip2AddressType } from '../../src/handlers'; -import { Caip19Asset } from '../../src/handlers/caip19'; +import { + CurrencyUnit, + ReviewTransactionEvent, + SendFormEvent, +} from '../../src/entities'; +import { Caip2AddressType, Caip19Asset } from '../../src/handlers'; describe('Bitcoin Snap', () => { const accounts: Record = {}; @@ -268,4 +271,116 @@ describe('Bitcoin Snap', () => { expect(response).toRespondWith(expectedAssets); }, ); + + it('executes Send flow: happy path', async () => { + const response = snap.request({ + origin, + method: 'startSendTransactionFlow', + params: { + account: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, + }, + }); + + let ui = await response.getInterface(); + assertIsCustomDialog(ui); + + await ui.typeInField(SendFormEvent.Amount, '0.1'); + await ui.typeInField( + SendFormEvent.Recipient, + 'bcrt1qyvhf2epk9s659206lq3rdvtf07uq3t9e7xtjje', + ); + await ui.clickElement(SendFormEvent.Confirm); + + ui = await response.getInterface(); + await ui.clickElement(ReviewTransactionEvent.Send); + + const result = await response; + expect(result).toRespondWith({ txId: expect.any(String) }); + }); + + it('executes Send flow: happy path drain account', async () => { + const response = snap.request({ + origin, + method: 'startSendTransactionFlow', + params: { + account: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, + }, + }); + + let ui = await response.getInterface(); + assertIsCustomDialog(ui); + + await ui.clickElement(SendFormEvent.SetMax); + await ui.typeInField( + SendFormEvent.Recipient, + 'bcrt1qyvhf2epk9s659206lq3rdvtf07uq3t9e7xtjje', + ); + await ui.clickElement(SendFormEvent.Confirm); + + ui = await response.getInterface(); + await ui.clickElement(ReviewTransactionEvent.HeaderBack); + + ui = await response.getInterface(); + await ui.clickElement(SendFormEvent.Cancel); + + const result = await response; + expect(result).toRespondWithError({ + code: 4001, + message: 'User rejected the request.', + stack: expect.anything(), + }); + }); + + it('executes Send flow: cancel', async () => { + const response = snap.request({ + origin, + method: 'startSendTransactionFlow', + params: { + account: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, + }, + }); + + const ui = await response.getInterface(); + await ui.clickElement(SendFormEvent.Cancel); + + const result = await response; + expect(result).toRespondWithError({ + code: 4001, + message: 'User rejected the request.', + stack: expect.anything(), + }); + }); + + it('executes Send flow: revert back to send form', async () => { + const response = snap.request({ + origin, + method: 'startSendTransactionFlow', + params: { + account: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, + }, + }); + + let ui = await response.getInterface(); + assertIsCustomDialog(ui); + + await ui.typeInField(SendFormEvent.Amount, '0.1'); + await ui.typeInField( + SendFormEvent.Recipient, + 'bcrt1qyvhf2epk9s659206lq3rdvtf07uq3t9e7xtjje', + ); + await ui.clickElement(SendFormEvent.Confirm); + + ui = await response.getInterface(); + await ui.clickElement(ReviewTransactionEvent.HeaderBack); + + ui = await response.getInterface(); + await ui.clickElement(SendFormEvent.Cancel); + + const result = await response; + expect(result).toRespondWithError({ + code: 4001, + message: 'User rejected the request.', + stack: expect.anything(), + }); + }); }); From 5acb54f0c741f551e9a2206a36a4bfdc12b62f47 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Fri, 21 Feb 2025 21:12:40 +0100 Subject: [PATCH 195/362] feat: bdk utxo protection (#414) * add unspendable to tx builder * fetch assets * running * run only on new outputs * UTXO protection done * tests * done * all done * rename files * use v0.1.4 * from_string * rebase * rebase done * maximum number of pages * maximum number of pages * naming * Rename SimplehashClientAdapter to SimpleHashClientAdapter * plusplus --- .../bitcoin-wallet-snap/package.json | 4 +- .../bitcoin-wallet-snap/snap.config.ts | 1 + .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/configv2.ts | 8 +- .../src/entities/account.ts | 63 +++-- .../src/entities/config.ts | 10 +- .../bitcoin-wallet-snap/src/entities/index.ts | 3 +- .../src/entities/meta-protocols.ts | 21 ++ .../entities/{send-form.ts => send-flow.ts} | 0 .../bitcoin-wallet-snap/src/entities/snap.ts | 2 + .../src/entities/transaction.ts | 48 ++++ .../src/handlers/RpcHandler.test.ts | 7 +- .../src/handlers/RpcHandler.ts | 2 +- .../bitcoin-wallet-snap/src/index.ts | 3 + .../src/infra/BdkAccountAdapter.ts | 29 +-- .../src/infra/BdkTxBuilderAdapter.ts | 54 ++++ .../src/infra/SimpleHashClientAdapter.ts | 124 +++++++++ .../src/infra/SnapClientAdapter.ts | 4 +- .../bitcoin-wallet-snap/src/infra/index.ts | 1 + .../src/store/BdkAccountRepository.test.ts | 116 ++++++--- .../src/store/BdkAccountRepository.ts | 54 +++- ...est.tsx => JSXSendFlowRepository.test.tsx} | 2 +- ...pository.tsx => JSXSendFlowRepository.tsx} | 0 .../bitcoin-wallet-snap/src/store/index.ts | 2 +- .../src/use-cases/AccountUseCases.test.ts | 239 +++++++++++------- .../src/use-cases/AccountUseCases.ts | 93 ++++--- ...Cases.test.ts => SendFlowUseCases.test.ts} | 92 ++++++- ...endFormUseCases.ts => SendFlowUseCases.ts} | 14 +- .../src/use-cases/index.ts | 2 +- 29 files changed, 758 insertions(+), 242 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/meta-protocols.ts rename merged-packages/bitcoin-wallet-snap/src/entities/{send-form.ts => send-flow.ts} (100%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts rename merged-packages/bitcoin-wallet-snap/src/store/{JSXSendFormRepository.test.tsx => JSXSendFlowRepository.test.tsx} (98%) rename merged-packages/bitcoin-wallet-snap/src/store/{JSXSendFormRepository.tsx => JSXSendFlowRepository.tsx} (100%) rename merged-packages/bitcoin-wallet-snap/src/use-cases/{SendFormUseCases.test.ts => SendFlowUseCases.test.ts} (81%) rename merged-packages/bitcoin-wallet-snap/src/use-cases/{SendFormUseCases.ts => SendFlowUseCases.ts} (94%) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index ce9d0540..2f4bbcc8 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -50,13 +50,14 @@ "@metamask/snaps-jest": "^8.11.0", "@metamask/snaps-sdk": "^6.17.1", "@metamask/utils": "^11.0.1", + "@types/qs": "^6.9.18", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", "async-mutex": "^0.3.2", "bignumber.js": "9.1.2", "bip32": "4.0.0", "bitcoin-address-validation": "^2.2.3", - "bitcoindevkit": "^0.1.3", + "bitcoindevkit": "^0.1.4", "bitcoinjs-lib": "6.1.5", "coinselect": "3.1.13", "concurrently": "^9.1.0", @@ -74,6 +75,7 @@ "jest-mock-extended": "^3.0.7", "jest-transform-stub": "2.0.0", "prettier": "^2.7.1", + "qs": "^6.14.0", "rimraf": "^3.0.2", "superstruct": "^1.0.3", "ts-jest": "^29.1.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts index 87534266..2e42e92d 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.config.ts +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -24,6 +24,7 @@ const config: SnapConfig = { ESPLORA_PROVIDER_SIGNET: process.env.ESPLORA_PROVIDER_SIGNET, ESPLORA_PROVIDER_REGTEST: process.env.ESPLORA_PROVIDER_REGTEST, KEYRING_VERSION: process.env.KEYRING_VERSION, + SIMPLEHASH_BITCOIN: process.env.SIMPLEHASH_BITCOIN, /* eslint-disable */ }, polyfills: true, diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 5b15ac2f..eb255b7d 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "4M8mRztjmU3Mcf/4OHLb6yBdy9npd1Uevm2VPeMgw90=", + "shasum": "rcjcdN314Ub2IPOyQIfPqVy2KuXRpmB3z6kXHx73VC4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/configv2.ts b/merged-packages/bitcoin-wallet-snap/src/configv2.ts index 3823007e..0f676116 100644 --- a/merged-packages/bitcoin-wallet-snap/src/configv2.ts +++ b/merged-packages/bitcoin-wallet-snap/src/configv2.ts @@ -18,7 +18,6 @@ export const ConfigV2: SnapConfig = { chain: { parallelRequests: 1, stopGap: 10, - targetBlocksConfirmation: 3, url: { bitcoin: process.env.ESPLORA_PROVIDER_BITCOIN ?? 'https://blockstream.info/api', @@ -35,6 +34,13 @@ export const ConfigV2: SnapConfig = { 'http://localhost:8094/regtest/api', }, }, + simpleHash: { + apiKey: process.env.SIMPLEHASH_API_KEY, // Public test API key + url: { + bitcoin: + process.env.SIMPLEHASH_BITCOIN ?? `https://api.simplehash.com/api/v0`, + }, + }, targetBlocksConfirmation: 3, fallbackFeeRate: 5.0, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index ec08ff31..a91f58e7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -9,8 +9,12 @@ import type { ChangeSet, Psbt, Transaction, + LocalOutput, } from 'bitcoindevkit'; +import type { Inscription } from './meta-protocols'; +import type { TransactionBuilder } from './transaction'; + /** * A Bitcoin account. */ @@ -42,7 +46,7 @@ export type BitcoinAccount = { /** * Get an address at a given index. - * @param index + * @param index - derivation index. * @returns the address */ peekAddress(index: number): AddressInfo; @@ -79,27 +83,16 @@ export type BitcoinAccount = { applyUpdate(update: Update): void; /** - * Get the change set. + * Extract the change set if it exists. * @returns the change set */ takeStaged(): ChangeSet | undefined; /** - * Create a new PSBT. - * @param feeRate - The fee rate in sats/vb - * @param recipient. - The recipient address - * @param amount. - The amount to send in sats - * @returns the PSBT - */ - buildTx(feeRate: number, recipient: string, amount: string): Psbt; - - /** - * Create a new PSBT by draining the wallet inputs. - * @param feeRate. - The fee rate in sats/vb - * @param recipient. - The recipient address - * @returns the PSBT + * Returns a Transaction Builder. + * @returns the TxBuilder */ - drainTo(feeRate: number, recipient: string): Psbt; + buildTx(): TransactionBuilder; /** * Sign a PSBT with all the registered signers @@ -107,6 +100,18 @@ export type BitcoinAccount = { * @returns the signed transaction */ sign(psbt: Psbt): Transaction; + + /** + * Get the list of UTXOs + * @returns the list of UTXOs + */ + listUnspent(): LocalOutput[]; + + /** + * List all relevant outputs (includes both spent and unspent, confirmed and unconfirmed). + * @returns the list of outputs + */ + listOutput(): LocalOutput[]; }; /** @@ -115,14 +120,14 @@ export type BitcoinAccount = { export type BitcoinAccountRepository = { /** * Get an account by its id. - * @param id + * @param id - Account ID. * @returns the account or null if it does not exist */ get(id: string): Promise; /** * Get an account by its id with signing capabilities - * @param id - Account's id. + * @param id - Account ID. * @returns the account or null if it does not exist */ getWithSigner(id: string): Promise; @@ -135,16 +140,16 @@ export type BitcoinAccountRepository = { /** * Get an account by its derivation path. - * @param derivationPath + * @param derivationPath - derivation path. * @returns the account or null if it does not exist */ getByDerivationPath(derivationPath: string[]): Promise; /** * Insert a new account. - * @param derivationPath - * @param network - * @param addressType + * @param derivationPath - derivation index. + * @param network - network. + * @param addressType - address type. * @returns the new account */ insert( @@ -155,14 +160,22 @@ export type BitcoinAccountRepository = { /** * Update an account. - * @param account + * @param account - Bitcoin account. + * @param inscriptions - List of inscriptions. */ - update(account: BitcoinAccount): Promise; + update(account: BitcoinAccount, inscriptions?: Inscription[]): Promise; /** * Delete an account. - * @param id + * @param id - Account ID. * @returns true if the account has been deleted. */ delete(id: string): Promise; + + /** + * Get the list of frozen UTXO outpoints of an account. + * @param id - Account ID. + * @returns the frozen UTXO outpoints. + */ + getFrozenUTXOs(id: string): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts index c6fe6152..82c0cb7c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -5,6 +5,7 @@ export type SnapConfig = { accounts: AccountsConfig; chain: ChainConfig; keyringVersion: string; + simpleHash: SimpleHashConfig; // Temporary config to set the expected confirmation target, should eventually be chosen by the user targetBlocksConfirmation: number; fallbackFeeRate: number; @@ -22,6 +23,11 @@ export type ChainConfig = { url: { [network in Network]: string; }; - // Temporary config to set the expected confirmation target, should eventually be chosen by the user - targetBlocksConfirmation: number; +}; + +export type SimpleHashConfig = { + apiKey?: string; + url: { + [network in Network]?: string; + }; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts index 3f3e90f0..646e034b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts @@ -2,6 +2,7 @@ export * from './account'; export * from './config'; export * from './chain'; export * from './currency'; -export * from './send-form'; +export * from './send-flow'; export * from './transaction'; export * from './snap'; +export * from './meta-protocols'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/meta-protocols.ts b/merged-packages/bitcoin-wallet-snap/src/entities/meta-protocols.ts new file mode 100644 index 00000000..d42d2000 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/meta-protocols.ts @@ -0,0 +1,21 @@ +import type { BitcoinAccount } from './account'; + +export type Inscription = { + id: string; + number: number; + contentLength: number; + contentType: string; + satNumber: number; + satRarity: string; + location: string; + imageUrl?: string; +}; + +export type MetaProtocolsClient = { + /** + * Fetch the inscriptions of an account. + * @param account - the account to fetch assets from. + * @returns the list of inscriptions + */ + fetchInscriptions(account: BitcoinAccount): Promise; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-form.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/entities/send-form.ts rename to merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index 304e042f..076ea4cb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -4,11 +4,13 @@ import type { Json } from '@metamask/utils'; import type { BitcoinAccount } from './account'; import type { CurrencyUnit } from './currency'; +import type { Inscription } from './meta-protocols'; export type SnapState = { accounts: { derivationPaths: Record; wallets: Record; + inscriptions: Record; }; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts b/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts index 56f09f38..94ae16f8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts @@ -1,5 +1,53 @@ +import type { Psbt } from 'bitcoindevkit'; + export type TransactionRequest = { recipient: string; amount?: string; feeRate: number; + drain?: boolean; +}; + +/** + * A Bitcoin transaction builder. + */ +export type TransactionBuilder = { + /** + * Add a new recipient the PSBT. + * @param amount - The amount in satoshis + * @param recipientAddress - The recipient address + */ + addRecipient(amount: string, recipientAddress: string): TransactionBuilder; + + /** + * Set the PSBT fee rate. + * @param feeRate - The fee rate in sat/vb + */ + feeRate(feeRate: number): TransactionBuilder; + + /** + * Spend all the available inputs. This respects filters like `unspendable` and the change policy. + */ + drainWallet(): TransactionBuilder; + + /** + * Sets the address to *drain* excess coins to. + * + * Usually, when there are excess coins they are sent to a change address generated by the + * wallet. This option replaces the usual change address with an arbitrary `script_pubkey` of + * your choosing. + * @param address - The recipient address + */ + drainTo(address: string): TransactionBuilder; + + /** + * Set the list of unspendable UTXOs. These outpoints won't be selected by the coin selection algorithm. + * @param unspendable - The list of unspendable UTXO outpoints. + */ + unspendable(unspendable: string[]): TransactionBuilder; + + /** + * Creates the PSBT. + * @returns the PSBT + */ + finish(): Psbt; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index 38a05052..1371b990 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -1,3 +1,4 @@ +import type { Txid } from 'bitcoindevkit'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; @@ -42,7 +43,11 @@ describe('RpcHandler', () => { it('executes startSendTransactionFlow', async () => { mockSendFlowUseCases.display.mockResolvedValue(mockTxRequest); - mockAccountsUseCases.send.mockResolvedValue('txId'); + mockAccountsUseCases.send.mockResolvedValue( + mock({ + toString: jest.fn().mockReturnValue('txId'), + }), + ); const result = await handler.route( InternalRpcMethod.StartSendTransactionFlow, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index b32c0ed9..c260f68f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -46,6 +46,6 @@ export class RpcHandler { const txRequest = await this.#sendFlowUseCases.display(params.account); const txId = await this.#accountUseCases.send(params.account, txRequest); - return { txId }; + return { txId: txId.toString() }; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index b3fec56e..07874699 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -20,6 +20,7 @@ import { RpcHandler, } from './handlers'; import { SnapClientAdapter, EsploraClientAdapter } from './infra'; +import { SimpleHashClientAdapter } from './infra/SimpleHashClientAdapter'; import { BtcKeyring } from './keyring'; import { InternalRpcMethod, originPermissions } from './permissions'; import type { @@ -56,6 +57,7 @@ if (ConfigV2.keyringVersion === 'v2') { // Infra layer const snapClient = new SnapClientAdapter(ConfigV2.encrypt); const chainClient = new EsploraClientAdapter(ConfigV2.chain); + const metaProtocolsClient = new SimpleHashClientAdapter(ConfigV2.simpleHash); // Data layer const accountRepository = new BdkAccountRepository(snapClient); const sendFlowRepository = new JSXSendFlowRepository(snapClient); @@ -65,6 +67,7 @@ if (ConfigV2.keyringVersion === 'v2') { snapClient, accountRepository, chainClient, + metaProtocolsClient, ConfigV2.accounts, ); const sendFlowUseCases = new SendFlowUseCases( diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index a464f4c3..9bd7bd2c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -10,10 +10,12 @@ import type { ChangeSet, Psbt, Transaction, + LocalOutput, } from 'bitcoindevkit'; -import { FeeRate, Recipient, Address, Amount, Wallet } from 'bitcoindevkit'; +import { Wallet } from 'bitcoindevkit'; -import type { BitcoinAccount } from '../entities'; +import type { BitcoinAccount, TransactionBuilder } from '../entities'; +import { BdkTxBuilderAdapter } from './BdkTxBuilderAdapter'; export class BdkAccountAdapter implements BitcoinAccount { readonly #id: string; @@ -107,19 +109,8 @@ export class BdkAccountAdapter implements BitcoinAccount { return this.#wallet.take_staged(); } - buildTx(feeRate: number, recipient: string, amount: string): Psbt { - const fee = new FeeRate(BigInt(Math.floor(feeRate))); - const to = new Recipient( - Address.new(recipient, this.network), - Amount.from_sat(BigInt(amount)), - ); - return this.#wallet.build_tx(fee, [to]); - } - - drainTo(feeRate: number, recipient: string): Psbt { - const fee = new FeeRate(BigInt(Math.floor(feeRate))); - const to = Address.new(recipient, this.network); - return this.#wallet.drain_to(fee, to); + buildTx(): TransactionBuilder { + return new BdkTxBuilderAdapter(this.#wallet.build_tx(), this.network); } sign(psbt: Psbt): Transaction { @@ -130,4 +121,12 @@ export class BdkAccountAdapter implements BitcoinAccount { return psbt.extract_tx(); } + + listUnspent(): LocalOutput[] { + return this.#wallet.list_unspent(); + } + + listOutput(): LocalOutput[] { + return this.#wallet.list_output(); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts new file mode 100644 index 00000000..051f5d0a --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts @@ -0,0 +1,54 @@ +import type { Network, Psbt, TxBuilder } from 'bitcoindevkit'; +import { Outpoint, Address, Amount, FeeRate, Recipient } from 'bitcoindevkit'; + +import type { TransactionBuilder } from '../entities'; + +export class BdkTxBuilderAdapter implements TransactionBuilder { + #builder: TxBuilder; + + readonly #network: Network; + + constructor(builder: TxBuilder, network: Network) { + this.#builder = builder; + this.#network = network; + } + + addRecipient(amount: string, recipientAddress: string): TransactionBuilder { + const recipient = new Recipient( + Address.from_string(recipientAddress, this.#network), + Amount.from_sat(BigInt(amount)), + ); + this.#builder = this.#builder.add_recipient(recipient); + return this; + } + + feeRate(feeRate: number): BdkTxBuilderAdapter { + this.#builder = this.#builder.fee_rate( + new FeeRate(BigInt(Math.floor(feeRate))), + ); + return this; + } + + drainWallet(): BdkTxBuilderAdapter { + this.#builder = this.#builder.drain_wallet(); + return this; + } + + drainTo(address: string): BdkTxBuilderAdapter { + const to = Address.from_string(address, this.#network); + this.#builder = this.#builder.drain_to(to); + return this; + } + + unspendable(unspendable: string[]): BdkTxBuilderAdapter { + const outpoints = unspendable.map((outpoint) => + Outpoint.from_string(outpoint), + ); + this.#builder = this.#builder.unspendable(outpoints); + return this; + } + + finish(): Psbt { + return this.#builder.finish(); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts new file mode 100644 index 00000000..40acb02d --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts @@ -0,0 +1,124 @@ +import type { Json } from '@metamask/utils'; +import type { Network } from 'bitcoindevkit'; +import qs from 'qs'; + +import type { + BitcoinAccount, + Inscription, + MetaProtocolsClient, + SimpleHashConfig as SimplehHashConfig, +} from '../entities'; + +/* eslint-disable @typescript-eslint/naming-convention */ +type NFTResponse = { + next_cursor: string; + nfts: { + extra_metadata: { + ordinal_details: { + inscription_id: string; + inscription_number: number; + content_length: number; + content_type: string; + sat_number: number; + sat_name: string; + sat_rarity: string; + protocol_name: string | null; + protocol_content: Json[] | null; + location: string; + charms: string[] | null; + }; + image_original_url: string | null; + }; + }[]; +}; + +export class SimpleHashClientAdapter implements MetaProtocolsClient { + readonly #endpoints: Record; + + readonly #apiKey: string | undefined; + + constructor(config: SimplehHashConfig) { + this.#endpoints = { + bitcoin: config.url.bitcoin, + testnet: config.url.testnet, + testnet4: config.url.testnet4, + signet: config.url.signet, + regtest: config.url.regtest, + }; + this.#apiKey = config.apiKey; + } + + async fetchInscriptions(account: BitcoinAccount): Promise { + const endpoint = this.#endpoints[account.network]; + if (!endpoint) { + return []; + } + + const usedAddresses = new Set( + account + .listUnspent() + .map((utxo) => account.peekAddress(utxo.derivation_index)) + .toString(), + ); + + if (usedAddresses.size === 0) { + return []; + } + + let cursor: string | undefined; + const inscriptions: Inscription[] = []; + const MAX_PAGES = 5; + let pages = 0; + + do { + pages += 1; + if (pages > MAX_PAGES) { + console.warn(`Maximum page limit reached (${MAX_PAGES}).`); + break; + } + + const params = { + chains: 'bitcoin', + wallet_addresses: Array.from(usedAddresses), + limit: 50, + cursor, + }; + + const url = `${endpoint}/nfts/owners_v2?${qs.stringify(params, { + arrayFormat: 'comma', + })}`; + + const headers = this.#apiKey ? { 'X-API-KEY': this.#apiKey } : undefined; + const response = await fetch(url, { + method: 'GET', + headers, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch inscriptions: ${response.statusText}`); + } + + const data: NFTResponse = await response.json(); + + inscriptions.push( + ...data.nfts.map((nft) => { + const details = nft.extra_metadata.ordinal_details; + return { + id: details.inscription_id, + number: details.inscription_number, + contentLength: details.content_length, + contentType: details.content_type, + satNumber: details.sat_number, + satRarity: details.sat_rarity, + location: details.location, + imageUrl: nft.extra_metadata.image_original_url ?? undefined, + }; + }), + ); + + cursor = data.next_cursor; + } while (cursor); + + return inscriptions; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 88e42a1e..d5815a51 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -35,7 +35,9 @@ export class SnapClientAdapter implements SnapClient { }); return ( - (state as SnapState) ?? { accounts: { derivationPaths: {}, wallets: {} } } + (state as SnapState) ?? { + accounts: { derivationPaths: {}, wallets: {}, inscriptions: {} }, + } ); } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts index 2941639b..43a492c8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts @@ -1,3 +1,4 @@ export * from './BdkAccountAdapter'; export * from './SnapClientAdapter'; export * from './EsploraClientAdapter'; +export * from './SimpleHashClientAdapter'; diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts index 215bcca9..83f6bdd5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts @@ -2,7 +2,12 @@ import type { SLIP10Node } from '@metamask/key-tree'; import { ChangeSet } from 'bitcoindevkit'; import { mock } from 'jest-mock-extended'; -import type { BitcoinAccount, SnapClient } from '../entities'; +import type { + BitcoinAccount, + Inscription, + SnapClient, + SnapState, +} from '../entities'; import { BdkAccountAdapter } from '../infra'; import { BdkAccountRepository } from './BdkAccountRepository'; @@ -36,7 +41,9 @@ jest.mock('uuid', () => ({ v4: () => 'mock-uuid' })); describe('BdkAccountRepository', () => { let repo: BdkAccountRepository; + const mockSnapClient = mock(); + const mockState = mock(); beforeEach(() => { repo = new BdkAccountRepository(mockSnapClient); @@ -45,7 +52,7 @@ describe('BdkAccountRepository', () => { describe('get', () => { it('returns null if account not found', async () => { mockSnapClient.get.mockResolvedValue({ - accounts: { derivationPaths: {}, wallets: {} }, + accounts: { ...mockState.accounts, wallets: {} }, }); const result = await repo.get('non-existent-id'); @@ -53,12 +60,12 @@ describe('BdkAccountRepository', () => { }); it('returns loaded account if found', async () => { - mockSnapClient.get.mockResolvedValue({ + const state = mock({ accounts: { - derivationPaths: {}, wallets: { 'some-id': '{"mywallet": "data"}' }, }, }); + mockSnapClient.get.mockResolvedValue(state); const mockAccount = {} as BitcoinAccount; (BdkAccountAdapter.load as jest.Mock).mockReturnValue(mockAccount); @@ -75,15 +82,15 @@ describe('BdkAccountRepository', () => { describe('getAll', () => { it('returns all accounts', async () => { - mockSnapClient.get.mockResolvedValue({ + const state = mock({ accounts: { - derivationPaths: {}, wallets: { 'some-id': '{"foo":"bar"}', 'another-id': '{"hello":"world"}', }, }, }); + mockSnapClient.get.mockResolvedValue(state); const mockAccount1 = {} as BitcoinAccount; const mockAccount2 = {} as BitcoinAccount; @@ -102,7 +109,7 @@ describe('BdkAccountRepository', () => { describe('getByDerivationPath', () => { it('returns null if derivation path not mapped', async () => { mockSnapClient.get.mockResolvedValue({ - accounts: { derivationPaths: {}, wallets: {} }, + accounts: { ...mockState.accounts, derivationPaths: {} }, }); const result = await repo.getByDerivationPath(['m', "84'", "0'", "0'"]); @@ -111,12 +118,13 @@ describe('BdkAccountRepository', () => { it('returns account if derivation path exists', async () => { const derivationPath = ['m', "84'", "0'", "0'"]; - mockSnapClient.get.mockResolvedValue({ + const state = mock({ accounts: { derivationPaths: { [derivationPath.join('/')]: 'some-id' }, wallets: { 'some-id': '{}' }, }, }); + mockSnapClient.get.mockResolvedValue(state); const mockAccount = {} as BitcoinAccount; (BdkAccountAdapter.load as jest.Mock).mockReturnValue(mockAccount); @@ -129,7 +137,7 @@ describe('BdkAccountRepository', () => { describe('getWithSigner', () => { it('returns null if account not found', async () => { mockSnapClient.get.mockResolvedValue({ - accounts: { derivationPaths: {}, wallets: {} }, + accounts: { ...mockState.accounts, wallets: {} }, }); const result = await repo.getWithSigner('non-existent-id'); expect(result).toBeNull(); @@ -137,12 +145,13 @@ describe('BdkAccountRepository', () => { it('throws error if derivation path not found', async () => { const walletData = '{"mywallet":"data"}'; - mockSnapClient.get.mockResolvedValue({ + const state = mock({ accounts: { - derivationPaths: {}, wallets: { 'some-id': walletData }, }, }); + mockSnapClient.get.mockResolvedValue(state); + await expect(repo.getWithSigner('some-id')).rejects.toThrow( 'Inconsistent state. No derivation path found for account some-id', ); @@ -151,12 +160,13 @@ describe('BdkAccountRepository', () => { it('returns account with signer if account exists', async () => { const derivationPath = "m/84'/0'/0'"; const walletData = '{"mywallet":"data"}'; - mockSnapClient.get.mockResolvedValue({ + const state = mock({ accounts: { derivationPaths: { [derivationPath]: 'some-id' }, wallets: { 'some-id': walletData }, }, }); + mockSnapClient.get.mockResolvedValue(state); const slip10Node = { masterFingerprint: 0xdeadbeef, } as unknown as SLIP10Node; @@ -186,9 +196,14 @@ describe('BdkAccountRepository', () => { describe('insert', () => { it('inserts a new account with xpub', async () => { const derivationPath = ['m', "84'", "0'", "0'"]; - mockSnapClient.get.mockResolvedValue({ - accounts: { derivationPaths: {}, wallets: {} }, - }); + const state = { + accounts: { + derivationPaths: {}, + wallets: {}, + inscriptions: {}, + }, + }; + mockSnapClient.get.mockResolvedValue(state); mockSnapClient.getPublicEntropy.mockResolvedValue({ masterFingerprint: 0xdeadbeef, } as unknown as SLIP10Node); @@ -204,22 +219,22 @@ describe('BdkAccountRepository', () => { accounts: { derivationPaths: { [derivationPath.join('/')]: 'mock-uuid' }, wallets: { 'mock-uuid': '{}' }, + inscriptions: { 'mock-uuid': [] }, }, }); }); }); describe('update', () => { - it('updates the account when staged changes exist', async () => { - // Initial store state with existing account - mockSnapClient.get.mockResolvedValue({ + it('updates the account and inscriptions when staged changes exist', async () => { + const state = { accounts: { derivationPaths: { "m/84'/0'/0'": 'some-id' }, wallets: { 'some-id': '{"original":"data"}' }, + inscriptions: {}, }, - }); - - // Mock account that returns a staged changeset + }; + mockSnapClient.get.mockResolvedValue(state); const mockAccount = mock(); mockAccount.id = 'some-id'; const staged = { @@ -228,25 +243,26 @@ describe('BdkAccountRepository', () => { } as unknown as ChangeSet; mockAccount.takeStaged.mockReturnValue(staged); - await repo.update(mockAccount); + await repo.update(mockAccount, [{ id: 'myInscription' } as Inscription]); expect(staged.merge).toHaveBeenCalled(); expect(mockSnapClient.set).toHaveBeenCalledWith({ accounts: { derivationPaths: { "m/84'/0'/0'": 'some-id' }, + inscriptions: { 'some-id': [{ id: 'myInscription' }] }, wallets: { 'some-id': '{"merged":"data"}' }, }, }); }); it('does nothing if account has no staged changes', async () => { - mockSnapClient.get.mockResolvedValue({ + const state = mock({ accounts: { derivationPaths: { "m/84'/0'/0'": 'some-id' }, wallets: { 'some-id': '{"original":"data"}' }, }, }); - + mockSnapClient.get.mockResolvedValue(state); const mockAccount = mock(); mockAccount.id = 'some-id'; mockAccount.takeStaged.mockReturnValue(undefined); @@ -257,14 +273,12 @@ describe('BdkAccountRepository', () => { }); it('throws an error if account does not exist in store', async () => { + const mockAccount = mock(); + mockAccount.id = 'some-id'; + mockAccount.takeStaged.mockReturnValue(mock()); mockSnapClient.get.mockResolvedValue({ - accounts: { - derivationPaths: {}, - wallets: {}, - }, + accounts: { ...mockState.accounts, wallets: {} }, }); - - const mockAccount = mock(); mockAccount.id = 'non-existent-id'; await expect(repo.update(mockAccount)).rejects.toThrow( @@ -276,7 +290,7 @@ describe('BdkAccountRepository', () => { describe('delete', () => { it('does nothing if account not found', async () => { mockSnapClient.get.mockResolvedValue({ - accounts: { derivationPaths: {}, wallets: {} }, + accounts: { ...mockState.accounts, wallets: {} }, }); await repo.delete('non-existent-id'); @@ -284,19 +298,51 @@ describe('BdkAccountRepository', () => { expect(mockSnapClient.set).not.toHaveBeenCalled(); }); - it('removes wallet data and derivation path from store if present', async () => { - mockSnapClient.get.mockResolvedValue({ + it('removes wallet data from store', async () => { + const state = { accounts: { derivationPaths: { "m/84'/0'/0'": 'some-id' }, wallets: { 'some-id': '{"wallet":"data"}' }, + inscriptions: { 'some-id': [] }, }, - }); + }; + mockSnapClient.get.mockResolvedValue(state); await repo.delete('some-id'); expect(mockSnapClient.set).toHaveBeenCalledWith({ - accounts: { derivationPaths: {}, wallets: {} }, + accounts: { derivationPaths: {}, wallets: {}, inscriptions: {} }, }); }); }); + + describe('getFrozenUTXOs', () => { + it('returns empty array if account is not found', async () => { + mockSnapClient.get.mockResolvedValue({ + accounts: { ...mockState.accounts, inscriptions: {} }, + }); + + const result = await repo.getFrozenUTXOs('non-existent-id'); + expect(result).toStrictEqual([]); + }); + + it('returns the list of frozen UTXO outpoints', async () => { + const state = mock({ + accounts: { + inscriptions: { + 'some-id': [ + { location: 'txid1:vout:offset' }, + { location: 'txid2:vout:offset' }, + ], + }, + }, + }); + mockSnapClient.get.mockResolvedValue(state); + + const result = await repo.getFrozenUTXOs('some-id'); + + expect(mockSnapClient.get).toHaveBeenCalled(); + expect(result).toStrictEqual(['txid1:vout', 'txid2:vout']); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts index 0d4ebac2..56d8ea35 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts @@ -14,6 +14,7 @@ import type { BitcoinAccountRepository, BitcoinAccount, SnapClient, + Inscription, } from '../entities'; import { BdkAccountAdapter } from '../infra'; @@ -34,6 +35,16 @@ export class BdkAccountRepository implements BitcoinAccountRepository { return BdkAccountAdapter.load(id, ChangeSet.from_json(walletData)); } + async fetchInscriptions(id: string): Promise { + const state = await this.#snapClient.get(); + const inscriptions = state.accounts.inscriptions[id]; + if (!inscriptions) { + return null; + } + + return inscriptions; + } + async getWithSigner(id: string): Promise { const state = await this.#snapClient.get(); const walletData = state.accounts.wallets[id]; @@ -94,7 +105,12 @@ export class BdkAccountRepository implements BitcoinAccountRepository { return null; } - return this.get(id); + const walletData = state.accounts.wallets[id]; + if (!walletData) { + return null; + } + + return BdkAccountAdapter.load(id, ChangeSet.from_json(walletData)); } async insert( @@ -121,26 +137,33 @@ export class BdkAccountRepository implements BitcoinAccountRepository { const state = await this.#snapClient.get(); state.accounts.derivationPaths[derivationPath.join('/')] = id; state.accounts.wallets[id] = account.takeStaged()?.to_json() ?? ''; + state.accounts.inscriptions[id] = []; await this.#snapClient.set(state); return account; } - async update(account: BitcoinAccount): Promise { - const state = await this.#snapClient.get(); - const walletData = state.accounts.wallets[account.id]; - if (!walletData) { - throw new Error('Inconsistent state: account not found for update'); - } - + async update( + account: BitcoinAccount, + inscriptions?: Inscription[], + ): Promise { const newWalletData = account.takeStaged(); if (!newWalletData) { // Nothing to update return; } + const state = await this.#snapClient.get(); + const walletData = state.accounts.wallets[account.id]; + if (!walletData) { + throw new Error('Inconsistent state: account not found for update'); + } + newWalletData.merge(ChangeSet.from_json(walletData)); state.accounts.wallets[account.id] = newWalletData.to_json(); + if (inscriptions) { + state.accounts.inscriptions[account.id] = inscriptions; + } await this.#snapClient.set(state); } @@ -152,6 +175,7 @@ export class BdkAccountRepository implements BitcoinAccountRepository { } delete state.accounts.wallets[id]; + delete state.accounts.inscriptions[id]; // Find the path in derivationPaths that points to this id and remove it for (const [path, existingId] of Object.entries( @@ -165,4 +189,18 @@ export class BdkAccountRepository implements BitcoinAccountRepository { await this.#snapClient.set(state); } + + async getFrozenUTXOs(id: string): Promise { + const state = await this.#snapClient.get(); + const inscriptions = state.accounts.inscriptions[id]; + if (!inscriptions) { + return []; + } + + return inscriptions.map((inscription) => { + // format: :: + const [txid, vout] = inscription.location.split(':'); + return `${txid}:${vout}`; + }); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.test.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx similarity index 98% rename from merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.test.tsx rename to merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx index b716611b..ded4463d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.test.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx @@ -9,7 +9,7 @@ import type { } from '../entities'; import { CurrencyUnit, SENDFORM_NAME } from '../entities'; import { ReviewTransactionView, SendFormView } from '../infra/jsx'; -import { JSXSendFlowRepository } from './JSXSendFormRepository'; +import { JSXSendFlowRepository } from './JSXSendFlowRepository'; jest.mock('../infra/jsx', () => ({ SendFormView: jest.fn(), diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/store/JSXSendFormRepository.tsx rename to merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx diff --git a/merged-packages/bitcoin-wallet-snap/src/store/index.ts b/merged-packages/bitcoin-wallet-snap/src/store/index.ts index f18336e8..c23e9e14 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/index.ts @@ -1,2 +1,2 @@ export * from './BdkAccountRepository'; -export * from './JSXSendFormRepository'; +export * from './JSXSendFlowRepository'; diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 7e14c04d..32d154fd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -1,4 +1,4 @@ -import type { Transaction } from 'bitcoindevkit'; +import type { LocalOutput, Transaction, Txid } from 'bitcoindevkit'; import { type AddressType, type Network, type Psbt } from 'bitcoindevkit'; import { mock } from 'jest-mock-extended'; @@ -7,7 +7,10 @@ import type { BitcoinAccount, BitcoinAccountRepository, BlockchainClient, + Inscription, + MetaProtocolsClient, SnapClient, + TransactionRequest, } from '../entities'; import { AccountUseCases } from './AccountUseCases'; @@ -19,6 +22,7 @@ describe('AccountUseCases', () => { const mockSnapClient = mock(); const mockRepository = mock(); const mockChain = mock(); + const mockMetaProtocols = mock(); const accountsConfig: AccountsConfig = { index: 0, defaultAddressType: 'p2wpkh', @@ -30,6 +34,7 @@ describe('AccountUseCases', () => { mockSnapClient, mockRepository, mockChain, + mockMetaProtocols, accountsConfig, ); }); @@ -222,96 +227,121 @@ describe('AccountUseCases', () => { }); describe('synchronize', () => { - it('throws Error if account is not found', async () => { - mockRepository.get.mockResolvedValue(null); - - await expect(useCases.synchronize('some-id')).rejects.toThrow( - 'Account not found: some-id', - ); - - expect(mockRepository.get).toHaveBeenCalledWith('some-id'); - expect(mockChain.sync).not.toHaveBeenCalled(); - expect(mockChain.fullScan).not.toHaveBeenCalled(); - expect(mockRepository.update).not.toHaveBeenCalled(); + const mockAccount = mock({ + id: 'some-id', + isScanned: true, + listOutput: jest.fn(), }); - it('performs a regular sync if the account is already scanned', async () => { - const mockAccount = mock(); - mockAccount.id = 'some-id'; - mockAccount.isScanned = true; + beforeEach(() => { + mockAccount.listOutput.mockReturnValue([]); + }); + it('performs a regular sync', async () => { + mockAccount.listOutput.mockReturnValue([]); mockRepository.get.mockResolvedValue(mockAccount); - await useCases.synchronize('some-id'); + await useCases.synchronize(mockAccount); - expect(mockRepository.get).toHaveBeenCalledWith('some-id'); expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); - expect(mockChain.fullScan).not.toHaveBeenCalled(); + expect(mockAccount.listOutput).toHaveBeenCalledTimes(2); expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); }); - it('performs a full scan if the account is not scanned', async () => { - const mockAccount = mock(); - mockAccount.id = 'some-id'; - mockAccount.isScanned = false; - - mockRepository.get.mockResolvedValue(mockAccount); + it('performs a regular sync with assets', async () => { + const mockInscriptions = mock(); + mockAccount.listOutput + .mockReturnValueOnce([]) + .mockReturnValueOnce([mock()]); + mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); - await useCases.synchronize('some-id'); + await useCases.synchronize(mockAccount); - expect(mockRepository.get).toHaveBeenCalledWith('some-id'); - expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); - expect(mockChain.sync).not.toHaveBeenCalled(); - expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); + expect(mockAccount.listOutput).toHaveBeenCalledTimes(2); + expect(mockMetaProtocols.fetchInscriptions).toHaveBeenCalledWith( + mockAccount, + ); + expect(mockRepository.update).toHaveBeenCalledWith( + mockAccount, + mockInscriptions, + ); }); it('propagates an error if the chain sync fails', async () => { - const mockAccount = mock(); - mockAccount.id = 'some-id'; - mockAccount.isScanned = true; - - mockRepository.get.mockResolvedValue(mockAccount); const error = new Error('Sync failed'); mockChain.sync.mockRejectedValue(error); - await expect(useCases.synchronize('some-id')).rejects.toBe(error); + await expect(useCases.synchronize(mockAccount)).rejects.toBe(error); - expect(mockRepository.get).toHaveBeenCalledWith('some-id'); - expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); - expect(mockRepository.update).not.toHaveBeenCalled(); + expect(mockChain.sync).toHaveBeenCalled(); }); - it('propagates an error if the chain full scan fails', async () => { - const mockAccount = mock(); - mockAccount.id = 'some-id'; - mockAccount.isScanned = false; + it('propagates an error if the repository update fails', async () => { + mockChain.sync.mockResolvedValue(); + const error = new Error('Update failed'); + mockRepository.update.mockRejectedValue(error); - mockRepository.get.mockResolvedValue(mockAccount); + await expect(useCases.synchronize(mockAccount)).rejects.toBe(error); + + expect(mockChain.sync).toHaveBeenCalled(); + expect(mockRepository.update).toHaveBeenCalled(); + }); + }); + + describe('fullScan', () => { + const mockAccount = mock({ + id: 'some-id', + isScanned: false, + }); + const mockInscriptions = mock(); + + it('performs a full scan', async () => { + mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); + + await useCases.fullScan(mockAccount); + + expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); + expect(mockMetaProtocols.fetchInscriptions).toHaveBeenCalledWith( + mockAccount, + ); + expect(mockRepository.update).toHaveBeenCalledWith( + mockAccount, + mockInscriptions, + ); + }); + + it('propagates an error if the chain full scan fails', async () => { const error = new Error('Full scan failed'); mockChain.fullScan.mockRejectedValue(error); - await expect(useCases.synchronize('some-id')).rejects.toBe(error); + await expect(useCases.fullScan(mockAccount)).rejects.toBe(error); - expect(mockRepository.get).toHaveBeenCalledWith('some-id'); - expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); - expect(mockRepository.update).not.toHaveBeenCalled(); + expect(mockChain.fullScan).toHaveBeenCalled(); }); - it('propagates an error if the repository update fails', async () => { - const mockAccount = mock(); - mockAccount.id = 'some-id'; - mockAccount.isScanned = true; + it('propagates an error if fetchInscriptions fails', async () => { + mockChain.fullScan.mockResolvedValue(); + const error = new Error('fetchInscriptions failed'); + mockMetaProtocols.fetchInscriptions.mockRejectedValue(error); - mockRepository.get.mockResolvedValue(mockAccount); - mockChain.sync.mockResolvedValue(); + await expect(useCases.fullScan(mockAccount)).rejects.toBe(error); + + expect(mockChain.fullScan).toHaveBeenCalled(); + expect(mockMetaProtocols.fetchInscriptions).toHaveBeenCalled(); + }); + + it('propagates an error if the repository update fails', async () => { + mockChain.fullScan.mockResolvedValue(); + mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); const error = new Error('Update failed'); mockRepository.update.mockRejectedValue(error); - await expect(useCases.synchronize('some-id')).rejects.toBe(error); + await expect(useCases.fullScan(mockAccount)).rejects.toBe(error); - expect(mockRepository.get).toHaveBeenCalledWith('some-id'); - expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); - expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(mockChain.fullScan).toHaveBeenCalled(); + expect(mockMetaProtocols.fetchInscriptions).toHaveBeenCalled(); + expect(mockRepository.update).toHaveBeenCalled(); }); }); @@ -320,18 +350,22 @@ describe('AccountUseCases', () => { { id: 'id-1', isScanned: true, + listOutput: jest.fn(), }, { id: 'id-2', + listOutput: jest.fn(), }, - ] as BitcoinAccount[]; + ] as unknown as BitcoinAccount[]; it('synchronizes all accounts', async () => { mockRepository.getAll.mockResolvedValue(mockAccounts); + (mockAccounts[0].listOutput as jest.Mock).mockReturnValue([]); await useCases.synchronizeAll(); expect(mockRepository.getAll).toHaveBeenCalled(); + expect(mockAccounts[0].listOutput).toHaveBeenCalledTimes(2); expect(mockChain.sync).toHaveBeenCalledWith(mockAccounts[0]); expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccounts[1]); }); @@ -343,17 +377,6 @@ describe('AccountUseCases', () => { await expect(useCases.synchronizeAll()).rejects.toThrow(error); expect(mockRepository.getAll).toHaveBeenCalled(); }); - - it('do not propagate errors when synchonize fails', async () => { - const error = new Error(); - mockRepository.getAll.mockResolvedValue(mockAccounts); - mockChain.sync.mockRejectedValue(error); - - await useCases.synchronizeAll(); - - expect(mockRepository.getAll).toHaveBeenCalled(); - expect(mockChain.sync).toHaveBeenCalled(); - }); }); describe('delete', () => { @@ -439,29 +462,54 @@ describe('AccountUseCases', () => { }); describe('send', () => { - const requestWithAmount = { + const requestWithAmount: TransactionRequest = { amount: '1000', feeRate: 10, recipient: 'recipient-address', }; - const requestDrain = { + const requestDrain: TransactionRequest = { feeRate: 10, recipient: 'recipient-address', + drain: true, }; + const mockTxid = mock(); + const mockOutpoint = 'txid:vout'; const mockPsbt = mock(); const mockTransaction = mock({ // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 /* eslint-disable @typescript-eslint/naming-convention */ compute_txid: jest.fn(), }); + const mockTxBuilder = { + addRecipient: jest.fn(), + feeRate: jest.fn(), + drainTo: jest.fn(), + drainWallet: jest.fn(), + finish: jest.fn(), + unspendable: jest.fn(), + }; const mockAccount = mock({ network: 'bitcoin', buildTx: jest.fn(), - drainTo: jest.fn(), sign: jest.fn(), }); + beforeEach(() => { + mockTxBuilder.addRecipient.mockReturnThis(); + mockTxBuilder.feeRate.mockReturnThis(); + mockTxBuilder.drainTo.mockReturnThis(); + mockTxBuilder.drainWallet.mockReturnThis(); + mockTxBuilder.finish.mockReturnValue(mockPsbt); + mockTxBuilder.unspendable.mockReturnThis(); + }); + + it('throws error if both drain and amount are specified', async () => { + await expect( + useCases.send('non-existent-id', { ...requestWithAmount, drain: true }), + ).rejects.toThrow("Cannot specify both 'amount' and 'drain' options"); + }); + it('throws error if account is not found', async () => { mockRepository.getWithSigner.mockResolvedValue(null); @@ -472,18 +520,24 @@ describe('AccountUseCases', () => { it('sends transaction', async () => { mockRepository.getWithSigner.mockResolvedValue(mockAccount); - mockAccount.buildTx.mockReturnValue(mockPsbt); + mockAccount.buildTx.mockReturnValue(mockTxBuilder); + mockRepository.getFrozenUTXOs.mockResolvedValue([mockOutpoint]); mockAccount.sign.mockReturnValue(mockTransaction); - mockTransaction.compute_txid.mockReturnValue('txid-123'); + mockTransaction.compute_txid.mockReturnValue(mockTxid); const txId = await useCases.send('account-id', requestWithAmount); expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); - expect(mockAccount.buildTx).toHaveBeenCalledWith( + expect(mockAccount.buildTx).toHaveBeenCalled(); + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith( requestWithAmount.feeRate, - requestWithAmount.recipient, + ); + expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith( requestWithAmount.amount, + requestWithAmount.recipient, ); + expect(mockRepository.getFrozenUTXOs).toHaveBeenCalledWith('account-id'); + expect(mockTxBuilder.unspendable).toHaveBeenCalledWith([mockOutpoint]); expect(mockAccount.sign).toHaveBeenCalledWith(mockPsbt); expect(mockChain.broadcast).toHaveBeenCalledWith( mockAccount.network, @@ -491,22 +545,27 @@ describe('AccountUseCases', () => { ); expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); expect(mockTransaction.compute_txid).toHaveBeenCalled(); - expect(txId).toBe('txid-123'); + expect(txId).toBe(mockTxid); }); it('sends a drain transaction when amount is not provided', async () => { mockRepository.getWithSigner.mockResolvedValue(mockAccount); - mockAccount.drainTo.mockReturnValue(mockPsbt); + mockAccount.buildTx.mockReturnValue(mockTxBuilder); + mockRepository.getFrozenUTXOs.mockResolvedValue([mockOutpoint]); mockAccount.sign.mockReturnValue(mockTransaction); - mockTransaction.compute_txid.mockReturnValue('txid-123'); + mockTransaction.compute_txid.mockReturnValue(mockTxid); const txId = await useCases.send('account-id', requestDrain); expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); - expect(mockAccount.drainTo).toHaveBeenCalledWith( - requestDrain.feeRate, + expect(mockAccount.buildTx).toHaveBeenCalled(); + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith(requestDrain.feeRate); + expect(mockTxBuilder.drainWallet).toHaveBeenCalled(); + expect(mockTxBuilder.drainTo).toHaveBeenCalledWith( requestDrain.recipient, ); + expect(mockRepository.getFrozenUTXOs).toHaveBeenCalledWith('account-id'); + expect(mockTxBuilder.unspendable).toHaveBeenCalledWith([mockOutpoint]); expect(mockAccount.sign).toHaveBeenCalledWith(mockPsbt); expect(mockChain.broadcast).toHaveBeenCalledWith( mockAccount.network, @@ -514,7 +573,7 @@ describe('AccountUseCases', () => { ); expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); expect(mockTransaction.compute_txid).toHaveBeenCalled(); - expect(txId).toBe('txid-123'); + expect(txId).toBe(mockTxid); }); it('propagates an error if getWithSigner fails', async () => { @@ -538,20 +597,9 @@ describe('AccountUseCases', () => { ); }); - it('propagates an error if drainTo fails', async () => { - const error = new Error('drainTo failed'); - mockRepository.getWithSigner.mockResolvedValue(mockAccount); - mockAccount.drainTo.mockImplementation(() => { - throw error; - }); - - await expect(useCases.send('account-id', requestDrain)).rejects.toBe( - error, - ); - }); - it('propagates an error if broadcast fails', async () => { mockRepository.getWithSigner.mockResolvedValue(mockAccount); + mockAccount.buildTx.mockReturnValue(mockTxBuilder); const error = new Error('broadcast failed'); mockChain.broadcast.mockRejectedValueOnce(error); @@ -563,6 +611,7 @@ describe('AccountUseCases', () => { it('propagates an error if update fails', async () => { mockRepository.getWithSigner.mockResolvedValue(mockAccount); + mockAccount.buildTx.mockReturnValue(mockTxBuilder); const error = new Error('update failed'); mockRepository.update.mockRejectedValue(error); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index da2b5890..69c41892 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -1,4 +1,4 @@ -import type { AddressType, Network, Psbt } from 'bitcoindevkit'; +import type { AddressType, Network, Txid } from 'bitcoindevkit'; import type { AccountsConfig, @@ -7,6 +7,7 @@ import type { BlockchainClient, TransactionRequest, SnapClient, + MetaProtocolsClient, } from '../entities'; import { logger } from '../utils'; @@ -33,17 +34,21 @@ export class AccountUseCases { readonly #chain: BlockchainClient; + readonly #metaProtocols: MetaProtocolsClient; + readonly #accountConfig: AccountsConfig; constructor( snapClient: SnapClient, repository: BitcoinAccountRepository, chain: BlockchainClient, + metaProtocols: MetaProtocolsClient, accountConfig: AccountsConfig, ) { this.#snapClient = snapClient; this.#repository = repository; this.#chain = chain; + this.#metaProtocols = metaProtocols; this.#accountConfig = accountConfig; } @@ -109,31 +114,18 @@ export class AccountUseCases { return newAccount; } - /** - * Synchronize an account with the blockchain and update its state. - * @param id - The account id. - * @returns The updated account. - */ - async synchronize(id: string): Promise { - logger.trace('Synchronizing account. ID: %s', id); - - const account = await this.#repository.get(id); - if (!account) { - throw new Error(`Account not found: ${id}`); - } - - await this.#synchronize(account); - - logger.debug('Account synchronized successfully: %s', account.id); - } - async synchronizeAll(): Promise { logger.trace('Synchronizing all accounts'); - // accounts cannot be empty by assertion. const accounts = await this.#repository.getAll(); const results = await Promise.allSettled( - accounts.map(async (account) => this.#synchronize(account)), + accounts.map(async (account) => { + if (account.isScanned) { + return this.synchronize(account); + } + + return this.fullScan(account); + }), ); results.forEach((result, index) => { @@ -149,16 +141,35 @@ export class AccountUseCases { logger.debug('Accounts synchronized successfully'); } - async #synchronize(account: BitcoinAccount): Promise { - // If the account is already scanned, we just sync it, otherwise we do a full scan. - if (account.isScanned) { - await this.#chain.sync(account); + async synchronize(account: BitcoinAccount): Promise { + logger.trace('Synchronizing account. ID: %s', account.id); + + // Outputs are monotone, meaning they can only be added, like transactions. So we can be confident + // that a change on the balance cam only happen when new outputs appear. + const nOutputsBefore = account.listOutput().length; + await this.#chain.sync(account); + const nOutputsAfter = account.listOutput().length; + + // Sync assets only if new outputs exist. + if (nOutputsAfter > nOutputsBefore) { + const inscriptions = await this.#metaProtocols.fetchInscriptions(account); + await this.#repository.update(account, inscriptions); } else { - logger.info('Performing initial full scan: %s', account.id); - await this.#chain.fullScan(account); + await this.#repository.update(account); } - await this.#repository.update(account); + logger.debug('Account synchronized successfully: %s', account.id); + } + + async fullScan(account: BitcoinAccount): Promise { + logger.debug('Performing initial full scan: %s', account.id); + + await this.#chain.fullScan(account); + + const inscriptions = await this.#metaProtocols.fetchInscriptions(account); + await this.#repository.update(account, inscriptions); + + logger.debug('initial full scan performed successfully: %s', account.id); } async delete(id: string): Promise { @@ -182,26 +193,33 @@ export class AccountUseCases { logger.info('Account deleted successfully: %s', account.id); } - async send(id: string, request: TransactionRequest): Promise { + async send(id: string, request: TransactionRequest): Promise { logger.debug('Sending transaction. ID: %s. Request: %o', id, request); + if (request.drain && request.amount) { + throw new Error("Cannot specify both 'amount' and 'drain' options"); + } + const account = await this.#repository.getWithSigner(id); if (!account) { throw new Error(`Account not found: ${id}`); } - let psbt: Psbt; - // If no amount is specified at this point, it is a drain transaction + const builder = account.buildTx().feeRate(request.feeRate); + if (request.amount) { - psbt = account.buildTx( - request.feeRate, - request.recipient, - request.amount, - ); + builder.addRecipient(request.amount, request.recipient); + } else if (request.drain) { + builder.drainWallet().drainTo(request.recipient); } else { - psbt = account.drainTo(request.feeRate, request.recipient); + throw new Error("Either 'amount' or 'drain' must be specified"); } + // Make sure frozen UTXOs are not spent + const frozenUTXOs = await this.#repository.getFrozenUTXOs(id); + builder.unspendable(frozenUTXOs); + + const psbt = builder.finish(); const tx = account.sign(psbt); await this.#chain.broadcast(account.network, tx); await this.#repository.update(account); @@ -213,6 +231,7 @@ export class AccountUseCases { id, account.network, ); + return txId; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts similarity index 81% rename from merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.test.ts rename to merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 6b319d8b..409e6baf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -19,14 +19,14 @@ import { CurrencyUnit, SendFormEvent, } from '../entities'; -import { SendFlowUseCases } from './SendFormUseCases'; +import { SendFlowUseCases } from './SendFlowUseCases'; // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 /* eslint-disable @typescript-eslint/naming-convention */ jest.mock('bitcoindevkit', () => { return { Address: { - new: jest.fn(), + from_string: jest.fn(), }, Amount: { from_btc: jest.fn(), @@ -47,8 +47,8 @@ describe('SendFlowUseCases', () => { const fallbackFeeRate = 5.0; const mockAccount = mock({ network: 'bitcoin', - drainTo: jest.fn(), buildTx: jest.fn(), + sign: jest.fn(), }); const mockFeeEstimates = mock({ get: jest.fn() }); const mockTxRequest = mock(); @@ -118,6 +118,26 @@ describe('SendFlowUseCases', () => { }); describe('updateForm', () => { + const mockTxBuilder = { + addRecipient: jest.fn(), + feeRate: jest.fn(), + drainTo: jest.fn(), + drainWallet: jest.fn(), + finish: jest.fn(), + unspendable: jest.fn(), + }; + const mockPsbt = mock(); + + beforeEach(() => { + mockAccount.buildTx.mockReturnValue(mockTxBuilder); + mockTxBuilder.addRecipient.mockReturnThis(); + mockTxBuilder.feeRate.mockReturnThis(); + mockTxBuilder.drainTo.mockReturnThis(); + mockTxBuilder.drainWallet.mockReturnThis(); + mockTxBuilder.finish.mockReturnValue(mockPsbt); + mockTxBuilder.unspendable.mockReturnThis(); + }); + const mockContext: SendFormContext = { account: { id: 'account-id', address: 'myAddress' }, amount: '1000', @@ -262,7 +282,7 @@ describe('SendFlowUseCases', () => { }); it('sets recipient from state on Recipient', async () => { - (Address.new as jest.Mock).mockReturnValue({ + (Address.from_string as jest.Mock).mockReturnValue({ toString: () => 'newAddressValidated', }); @@ -342,18 +362,16 @@ describe('SendFlowUseCases', () => { ); }); - it('computes the fee when amount and recipient are filled', async () => { - const mockPsbt = mock({ - fee: () => { - return { to_sat: () => BigInt(10) } as unknown as Amount; - }, - }); + it('computes the fee when amount and recipient are filled: drain', async () => { + mockPsbt.fee.mockReturnValue({ + to_sat: () => BigInt(10), + } as unknown as Amount); mockAccountRepository.get.mockResolvedValue(mockAccount); - mockAccount.drainTo.mockReturnValue(mockPsbt); + mockAccountRepository.getFrozenUTXOs.mockResolvedValue(['txid:vout']); const expectedContext = { ...mockContext, - drain: expect.anything(), + drain: true, fee: '10', amount: String(20000 - 10), errors: expect.anything(), @@ -365,6 +383,56 @@ describe('SendFlowUseCases', () => { mockContext, ); + expect(mockAccountRepository.get).toHaveBeenCalledWith('account-id'); + expect(mockAccountRepository.getFrozenUTXOs).toHaveBeenCalledWith( + 'account-id', + ); + expect(mockTxBuilder.unspendable).toHaveBeenCalledWith(['txid:vout']); + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith( + expectedContext.feeRate, + ); + expect(mockTxBuilder.drainWallet).toHaveBeenCalled(); + expect(mockTxBuilder.drainTo).toHaveBeenCalledWith('recipientAddress'); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + + it('computes the fee when amount and recipient are filled', async () => { + (Address.from_string as jest.Mock).mockReturnValue({ + toString: () => 'newAddressValidated', + }); + mockPsbt.fee.mockReturnValue({ + to_sat: () => BigInt(10), + } as unknown as Amount); + mockAccountRepository.get.mockResolvedValue(mockAccount); + mockAccountRepository.getFrozenUTXOs.mockResolvedValue([]); + mockSendFlowRepository.getState.mockResolvedValue({ + recipient: 'newAddress', + amount: '', + }); + + const expectedContext = { + ...mockContext, + fee: '10', + recipient: 'newAddressValidated', + }; + + await useCases.updateForm( + 'interface-id', + SendFormEvent.Recipient, + mockContext, + ); + + expect(mockAccountRepository.get).toHaveBeenCalled(); + expect(mockAccountRepository.getFrozenUTXOs).toHaveBeenCalled(); + expect(mockTxBuilder.feeRate).toHaveBeenCalled(); + expect(mockTxBuilder.unspendable).toHaveBeenCalled(); + expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith( + mockContext.amount, + 'newAddressValidated', + ); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( 'interface-id', expectedContext, diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts similarity index 94% rename from merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.ts rename to merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index 03ed1c93..93ce13b0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFormUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -190,7 +190,7 @@ export class SendFlowUseCases { delete updatedContext.errors.tx; try { - updatedContext.recipient = Address.new( + updatedContext.recipient = Address.from_string( formState.recipient, context.network, ).toString(); @@ -236,10 +236,18 @@ export class SendFlowUseCases { if (!account) { throw new Error('Account removed while sending'); } + const frozenUTXOs = await this.#accountRepository.getFrozenUTXOs( + context.account.id, + ); try { + const builder = account + .buildTx() + .feeRate(context.feeRate) + .unspendable(frozenUTXOs); + if (drain) { - const psbt = account.drainTo(context.feeRate, recipient); + const psbt = builder.drainWallet().drainTo(recipient).finish(); const fee = psbt.fee().to_sat(); const realAmount = BigInt(amount) - fee; return { @@ -249,7 +257,7 @@ export class SendFlowUseCases { }; } - const psbt = account.buildTx(context.feeRate, recipient, amount); + const psbt = builder.addRecipient(amount, recipient).finish(); return { ...context, fee: psbt.fee().to_sat().toString() }; } catch (error) { return { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts index 25631f5a..65ed43bf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts @@ -1,2 +1,2 @@ export * from './AccountUseCases'; -export * from './SendFormUseCases'; +export * from './SendFlowUseCases'; From 8518892a079e254f4b37cb48d4c1535026f29f47 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 24 Feb 2025 14:52:06 +0100 Subject: [PATCH 196/362] feat: events + onAssetLookup (#416) * add unspendable to tx builder * fetch assets * running * run only on new outputs * UTXO protection done * tests * done * all done * rename files * done * use v0.1.4 * from_string * rebase * rebase done * done * lint + build * console log * fetch prices * done * Delete packages/snap/src/infra/SimplehashClientAdapter.ts * add file correct name * add log level to integration tests * comments addressed * bail integration tests * prettier * increase cores * restore ubuntu-latest * verbose integration tests * revert and try catch * try to cetch the error * still failing to catch problem * remove full scan on account creation * remove test options --- .../bitcoin-wallet-snap/package.json | 12 ++-- .../bitcoin-wallet-snap/snap.manifest.json | 13 +++- .../bitcoin-wallet-snap/src/entities/snap.ts | 8 ++- .../src/handlers/AssetsHandler.test.ts | 24 ++++++++ .../src/handlers/AssetsHandler.ts | 61 +++++++++++++++++++ .../src/handlers/KeyringHandler.test.ts | 21 +++---- .../src/handlers/KeyringHandler.ts | 4 +- .../src/handlers/RpcHandler.ts | 4 +- .../bitcoin-wallet-snap/src/handlers/caip2.ts | 14 ++--- .../bitcoin-wallet-snap/src/handlers/index.ts | 1 + .../bitcoin-wallet-snap/src/index.ts | 25 +++++++- .../src/infra/SnapClientAdapter.ts | 19 +++++- .../bitcoin-wallet-snap/src/keyring.ts | 3 +- .../send-bitcoin-controller.test.ts | 4 +- .../bitcoin-wallet-snap/src/ui/utils.test.ts | 4 +- .../src/use-cases/AccountUseCases.test.ts | 14 ++++- .../src/use-cases/AccountUseCases.ts | 3 + .../test/integration/snap.test.ts | 50 +++++++-------- 18 files changed, 220 insertions(+), 64 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 2f4bbcc8..6ee8702e 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -44,12 +44,12 @@ "@metamask/eslint-config-nodejs": "^12.1.0", "@metamask/eslint-config-typescript": "^12.1.0", "@metamask/key-tree": "^10.0.2", - "@metamask/keyring-api": "^15.0.0", - "@metamask/keyring-snap-sdk": "^2.1.0", - "@metamask/snaps-cli": "^6.6.1", - "@metamask/snaps-jest": "^8.11.0", - "@metamask/snaps-sdk": "^6.17.1", - "@metamask/utils": "^11.0.1", + "@metamask/keyring-api": "^17.2.0", + "@metamask/keyring-snap-sdk": "^3.0.1", + "@metamask/snaps-cli": "^6.7.0", + "@metamask/snaps-jest": "^8.12.0", + "@metamask/snaps-sdk": "^6.18.0", + "@metamask/utils": "^11.2.0", "@types/qs": "^6.9.18", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index eb255b7d..8da6ecfc 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "rcjcdN314Ub2IPOyQIfPqVy2KuXRpmB3z6kXHx73VC4=", + "shasum": "mqamRckPGCDBOcc4uz3o7vutlJo3QtGMfeqK3e57C1k=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -87,8 +87,17 @@ } } ] + }, + "endowment:assets": { + "scopes": [ + "bip122:000000000019d6689c085ae165831e93", + "bip122:000000000933ea01ad0ee984209779ba", + "bip122:00000000da84f2bafbbc53dee25a72ae", + "bip122:00000008819873e925422c1ff0f99f7c", + "bip122:regtest" + ] } }, - "platformVersion": "6.17.1", + "platformVersion": "6.18.0", "manifestVersion": "0.1" } diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index 076ea4cb..d8485e43 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -52,10 +52,16 @@ export type SnapClient = { /** * Emit an event notifying the extension of a deleted Bitcoin account - * @param account - The Bitcoin account id. + * @param id - The Bitcoin account id. */ emitAccountDeletedEvent(id: string): Promise; + /** + * Emit an event notifying the extension of updated balances + * @param account - The Bitcoin account. + */ + emitAccountBalancesUpdatedEvent(account: BitcoinAccount): Promise; + /** * Create a User Interface. * @param ui - The UI Component. diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts new file mode 100644 index 00000000..b5dc334b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts @@ -0,0 +1,24 @@ +import { AssetsHandler } from './AssetsHandler'; +import { Caip19Asset } from './caip19'; + +describe('AssetsHandler', () => { + let handler: AssetsHandler; + + beforeEach(() => { + handler = new AssetsHandler(); + }); + + describe('lookup', () => { + it('returns data for all networks', () => { + const result = handler.lookup(); + + expect(result.assets[Caip19Asset.Bitcoin]?.name).toBe('Bitcoin'); + expect(result.assets[Caip19Asset.Testnet]?.name).toBe('Testnet Bitcoin'); + expect(result.assets[Caip19Asset.Testnet4]?.name).toBe( + 'Testnet4 Bitcoin', + ); + expect(result.assets[Caip19Asset.Signet]?.name).toBe('Signet Bitcoin'); + expect(result.assets[Caip19Asset.Regtest]?.name).toBe('Regtest Bitcoin'); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts new file mode 100644 index 00000000..d13d6baf --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts @@ -0,0 +1,61 @@ +import type { + FungibleAssetMetadata, + OnAssetsLookupResponse, +} from '@metamask/snaps-sdk'; + +import { Caip19Asset } from './caip19'; + +export class AssetsHandler { + lookup(): OnAssetsLookupResponse { + const metadata = ( + name: string, + mainSymbol: string, + ): FungibleAssetMetadata => { + return { + fungible: true, + name, + units: [ + { + name: 'Bitcoin', + decimals: 8, + symbol: mainSymbol, + }, + { + name: 'CentiBitcoin', + decimals: 6, + symbol: 'cBTC', + }, + { + name: 'MilliBitcoin', + decimals: 5, + symbol: 'mBTC', + }, + { + name: 'Bit', + decimals: 2, + symbol: 'bits', + }, + { + name: 'Satoshi', + decimals: 0, + symbol: 'satoshi', + }, + ], + iconUrl: + 'https://upload.wikimedia.org/wikipedia/commons/4/46/Bitcoin.svg', + symbol: '₿', + }; + }; + + // Use the same denominations as Bitcoin for testnets but change the name and main unit symbol + return { + assets: { + [Caip19Asset.Bitcoin]: metadata('Bitcoin', 'BTC'), + [Caip19Asset.Testnet]: metadata('Testnet Bitcoin', 'tBTC'), + [Caip19Asset.Testnet4]: metadata('Testnet4 Bitcoin', 'tBTC'), + [Caip19Asset.Signet]: metadata('Signet Bitcoin', 'sBTC'), + [Caip19Asset.Regtest]: metadata('Regtest Bitcoin', 'rBTC'), + }, + }; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index bb4a1e8a..5e903eba 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -1,4 +1,4 @@ -import { BtcMethod, BtcScopes } from '@metamask/keyring-api'; +import { BtcMethod, BtcScope } from '@metamask/keyring-api'; import type { Network } from 'bitcoindevkit'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; @@ -37,16 +37,15 @@ describe('KeyringHandler', () => { describe('createAccount', () => { it('respects provided provided scope and addressType', async () => { mockAccounts.create.mockResolvedValue(mockAccount); - const options = { - scope: BtcScopes.Signet, + scope: BtcScope.Signet, addressType: Caip2AddressType.P2pkh, }; await handler.createAccount(options); expect(assert).toHaveBeenCalledWith(options, CreateAccountRequest); expect(mockAccounts.create).toHaveBeenCalledWith( - caip2ToNetwork[BtcScopes.Signet], + caip2ToNetwork[BtcScope.Signet], caip2ToAddressType[Caip2AddressType.P2pkh], ); }); @@ -56,7 +55,7 @@ describe('KeyringHandler', () => { mockAccounts.create.mockRejectedValue(error); await expect( - handler.createAccount({ options: { scopes: [BtcScopes.Mainnet] } }), + handler.createAccount({ options: { scopes: [BtcScope.Mainnet] } }), ).rejects.toThrow(error); expect(mockAccounts.create).toHaveBeenCalled(); }); @@ -94,7 +93,7 @@ describe('KeyringHandler', () => { const expectedKeyringAccount = { id: 'some-id', type: Caip2AddressType.P2wpkh, - scopes: [BtcScopes.Mainnet], + scopes: [BtcScope.Mainnet], address: 'bc1qaddress...', options: {}, methods: [BtcMethod.SendBitcoin], @@ -121,7 +120,7 @@ describe('KeyringHandler', () => { { id: 'some-id', type: Caip2AddressType.P2wpkh, - scopes: [BtcScopes.Mainnet], + scopes: [BtcScope.Mainnet], address: 'bc1qaddress...', options: {}, methods: [BtcMethod.SendBitcoin], @@ -147,17 +146,17 @@ describe('KeyringHandler', () => { mockAccounts.get.mockResolvedValue(mockAccount); const result = await handler.filterAccountChains('some-id', [ - BtcScopes.Mainnet, + BtcScope.Mainnet, ]); expect(mockAccounts.get).toHaveBeenCalledWith('some-id'); - expect(result).toStrictEqual([BtcScopes.Mainnet]); + expect(result).toStrictEqual([BtcScope.Mainnet]); }); it('does not include chain if account network does not correspond', async () => { mockAccounts.get.mockResolvedValue(mockAccount); const result = await handler.filterAccountChains('some-id', [ - BtcScopes.Testnet, + BtcScope.Testnet, ]); expect(mockAccounts.get).toHaveBeenCalledWith('some-id'); expect(result).toStrictEqual([]); @@ -168,7 +167,7 @@ describe('KeyringHandler', () => { mockAccounts.get.mockRejectedValue(error); await expect( - handler.filterAccountChains('some-id', [BtcScopes.Mainnet]), + handler.filterAccountChains('some-id', [BtcScope.Mainnet]), ).rejects.toThrow(error); expect(mockAccounts.get).toHaveBeenCalled(); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 286e0e91..cc3248dc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -1,4 +1,4 @@ -import { BtcScopes } from '@metamask/keyring-api'; +import { BtcScope } from '@metamask/keyring-api'; import type { Keyring, KeyringAccount, @@ -25,7 +25,7 @@ import { import { snapToKeyringAccount } from './keyring-account'; export const CreateAccountRequest = object({ - scope: enums(Object.values(BtcScopes)), + scope: enums(Object.values(BtcScope)), addressType: optional(enums(Object.values(Caip2AddressType))), }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index c260f68f..87ad29ee 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -1,4 +1,4 @@ -import { BtcScopes } from '@metamask/keyring-api'; +import { BtcScope } from '@metamask/keyring-api'; import type { Json, JsonRpcParams } from '@metamask/utils'; import { assert, enums, object, optional, string } from 'superstruct'; @@ -7,7 +7,7 @@ import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; export const CreateSendFormRequest = object({ account: string(), - scope: optional(enums(Object.values(BtcScopes))), // We don't use the scope but need to define it for validation + scope: optional(enums(Object.values(BtcScope))), // We don't use the scope but need to define it for validation }); type SendTransactionResponse = { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/caip2.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/caip2.ts index d6cce46b..db5f10bc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/caip2.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/caip2.ts @@ -1,4 +1,4 @@ -import { BtcScopes } from '@metamask/keyring-api'; +import { BtcScope } from '@metamask/keyring-api'; import type { AddressType, Network } from 'bitcoindevkit'; import { reverseMapping } from './mapping'; @@ -11,12 +11,12 @@ export enum Caip2AddressType { P2tr = 'bip122:p2tr', } -export const caip2ToNetwork: Record = { - [BtcScopes.Mainnet]: 'bitcoin', - [BtcScopes.Testnet]: 'testnet', - [BtcScopes.Testnet4]: 'testnet4', - [BtcScopes.Signet]: 'signet', - [BtcScopes.Regtest]: 'regtest', +export const caip2ToNetwork: Record = { + [BtcScope.Mainnet]: 'bitcoin', + [BtcScope.Testnet]: 'testnet', + [BtcScope.Testnet4]: 'testnet4', + [BtcScope.Signet]: 'signet', + [BtcScope.Regtest]: 'regtest', }; export const caip2ToAddressType: Record = { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts index 9cb7b300..c8172709 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts @@ -2,6 +2,7 @@ export * from './KeyringHandler'; export * from './CronHandler'; export * from './RpcHandler'; export * from './UserInputHandler'; +export * from './AssetsHandler'; export { Caip2AddressType } from './caip2'; export { Caip19Asset } from './caip19'; diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 07874699..7f5f8ab3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -1,6 +1,10 @@ import type { Keyring } from '@metamask/keyring-api'; import { handleKeyringRequest } from '@metamask/keyring-snap-sdk'; -import type { OnCronjobHandler, OnInstallHandler } from '@metamask/snaps-sdk'; +import type { + OnAssetsLookupHandler, + OnCronjobHandler, + OnInstallHandler, +} from '@metamask/snaps-sdk'; import { type OnRpcRequestHandler, type OnKeyringRequestHandler, @@ -18,6 +22,7 @@ import { CronHandler, UserInputHandler, RpcHandler, + AssetsHandler, } from './handlers'; import { SnapClientAdapter, EsploraClientAdapter } from './infra'; import { SimpleHashClientAdapter } from './infra/SimpleHashClientAdapter'; @@ -52,6 +57,7 @@ let keyringHandler: Keyring; let cronHandler: CronHandler; let rpcHandler: RpcHandler; let userInputHandler: UserInputHandler; +let assetsHandler: AssetsHandler; let accountsUseCases: AccountUseCases; if (ConfigV2.keyringVersion === 'v2') { // Infra layer @@ -83,6 +89,7 @@ if (ConfigV2.keyringVersion === 'v2') { cronHandler = new CronHandler(accountsUseCases); rpcHandler = new RpcHandler(sendFlowUseCases, accountsUseCases); userInputHandler = new UserInputHandler(sendFlowUseCases); + assetsHandler = new AssetsHandler(); } export const validateOrigin = (origin: string, method: string): void => { @@ -257,3 +264,19 @@ export const onUserInput: OnUserInputHandler = async ({ throw snapError; } }; + +export const onAssetsLookup: OnAssetsLookupHandler = async () => { + try { + return assetsHandler.lookup(); + } catch (error) { + let snapError = error; + + if (!isSnapRpcError(error)) { + snapError = new SnapError(error); + } + logger.error( + `onAssetsLookup error: ${JSON.stringify(snapError.toJSON(), null, 2)}`, + ); + throw snapError; + } +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index d5815a51..64823380 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -11,7 +11,8 @@ import type { } from '@metamask/snaps-sdk'; import type { BitcoinAccount, SnapClient, SnapState } from '../entities'; -import { CurrencyUnit } from '../entities'; +import { CurrencyUnit, networkToCurrencyUnit } from '../entities'; +import { networkToCaip19 } from '../handlers/caip19'; import { snapToKeyringAccount } from '../handlers/keyring-account'; export class SnapClientAdapter implements SnapClient { @@ -97,6 +98,22 @@ export class SnapClientAdapter implements SnapClient { }); } + async emitAccountBalancesUpdatedEvent( + account: BitcoinAccount, + ): Promise { + const balance = account.balance.trusted_spendable.to_btc().toString(); + return emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { + balances: { + [account.id]: { + [networkToCaip19[account.network]]: { + amount: balance, + unit: networkToCurrencyUnit[account.network], + }, + }, + }, + }); + } + async createInterface( ui: ComponentOrElement, context: Record, diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts index ffb5d3e5..f33254ff 100644 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ b/merged-packages/bitcoin-wallet-snap/src/keyring.ts @@ -1,3 +1,4 @@ +import type { BtcScope } from '@metamask/keyring-api'; import { BtcMethod, KeyringEvent, @@ -287,7 +288,7 @@ export class BtcKeyring implements Keyring { id: uuidv4(), address: account.address, options, - scopes: [scope], + scopes: [scope as BtcScope], methods: this._methods, }; } diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts index d28bdf7e..f77c90ea 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts @@ -1,5 +1,5 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import { BtcAccountType, BtcMethod, BtcScopes } from '@metamask/keyring-api'; +import { BtcAccountType, BtcMethod, BtcScope } from '@metamask/keyring-api'; import type { UserInputEvent } from '@metamask/snaps-sdk'; import { UserInputEventType } from '@metamask/snaps-sdk'; import BigNumber from 'bignumber.js'; @@ -49,7 +49,7 @@ const mockAccount = { index: '1', }, methods: [`${BtcMethod.SendBitcoin}`], - scopes: [BtcScopes.Mainnet], + scopes: [BtcScope.Mainnet], }; const createMockContext = (request: SendFlowRequest) => { diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts b/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts index 4b961dfc..20eb73ff 100644 --- a/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts @@ -1,5 +1,5 @@ import { expect } from '@jest/globals'; -import { BtcAccountType, BtcMethod, BtcScopes } from '@metamask/keyring-api'; +import { BtcAccountType, BtcMethod, BtcScope } from '@metamask/keyring-api'; import { BigNumber } from 'bignumber.js'; import { v4 as uuidv4 } from 'uuid'; @@ -36,7 +36,7 @@ const mockAccount = { index: '1', }, methods: [`${BtcMethod.SendBitcoin}`], - scopes: [BtcScopes.Mainnet], + scopes: [BtcScope.Mainnet], }; describe('utils', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 32d154fd..e28deab0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -248,7 +248,7 @@ describe('AccountUseCases', () => { expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); }); - it('performs a regular sync with assets', async () => { + it('performs a regular sync with assets and balance update event', async () => { const mockInscriptions = mock(); mockAccount.listOutput .mockReturnValueOnce([]) @@ -266,6 +266,9 @@ describe('AccountUseCases', () => { mockAccount, mockInscriptions, ); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount); }); it('propagates an error if the chain sync fails', async () => { @@ -309,6 +312,9 @@ describe('AccountUseCases', () => { mockAccount, mockInscriptions, ); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount); }); it('propagates an error if the chain full scan fails', async () => { @@ -544,6 +550,9 @@ describe('AccountUseCases', () => { mockTransaction, ); expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount); expect(mockTransaction.compute_txid).toHaveBeenCalled(); expect(txId).toBe(mockTxid); }); @@ -572,6 +581,9 @@ describe('AccountUseCases', () => { mockTransaction, ); expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount); expect(mockTransaction.compute_txid).toHaveBeenCalled(); expect(txId).toBe(mockTxid); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 69c41892..e8138233 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -154,6 +154,7 @@ export class AccountUseCases { if (nOutputsAfter > nOutputsBefore) { const inscriptions = await this.#metaProtocols.fetchInscriptions(account); await this.#repository.update(account, inscriptions); + await this.#snapClient.emitAccountBalancesUpdatedEvent(account); } else { await this.#repository.update(account); } @@ -168,6 +169,7 @@ export class AccountUseCases { const inscriptions = await this.#metaProtocols.fetchInscriptions(account); await this.#repository.update(account, inscriptions); + await this.#snapClient.emitAccountBalancesUpdatedEvent(account); logger.debug('initial full scan performed successfully: %s', account.id); } @@ -223,6 +225,7 @@ export class AccountUseCases { const tx = account.sign(psbt); await this.#chain.broadcast(account.network, tx); await this.#repository.update(account); + await this.#snapClient.emitAccountBalancesUpdatedEvent(account); const txId = tx.compute_txid(); logger.info( diff --git a/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts b/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts index 9843710c..44f4585b 100644 --- a/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts +++ b/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts @@ -1,5 +1,5 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import { BtcMethod, BtcScopes } from '@metamask/keyring-api'; +import { BtcMethod, BtcScope } from '@metamask/keyring-api'; import type { Snap } from '@metamask/snaps-jest'; import { assertIsCustomDialog, installSnap } from '@metamask/snaps-jest'; @@ -37,14 +37,14 @@ describe('Bitcoin Snap', () => { id: expect.anything(), address: 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t', options: {}, - scopes: [BtcScopes.Regtest], + scopes: [BtcScope.Regtest], methods: [BtcMethod.SendBitcoin], }, ]); if ('result' in response.response) { const [defaultAccount] = response.response.result as KeyringAccount[]; - accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`] = + accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`] = defaultAccount; } }); @@ -59,7 +59,7 @@ describe('Bitcoin Snap', () => { origin, method: 'keyring_getAccountBalances', params: { - id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, + id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id, assets: [Caip19Asset.Regtest], }, }); @@ -75,38 +75,38 @@ describe('Bitcoin Snap', () => { it.each([ { addressType: Caip2AddressType.P2wpkh, - scope: BtcScopes.Mainnet, + scope: BtcScope.Mainnet, expectedAddress: 'bc1q832zlt4tgnqy88vd20mazw77dlt0j0wf2naw8q', }, { addressType: Caip2AddressType.P2pkh, - scope: BtcScopes.Mainnet, + scope: BtcScope.Mainnet, expectedAddress: '15feVv7kK3z7jxA4RZZzY7Fwdu3yqFwzcT', }, { addressType: Caip2AddressType.P2pkh, - scope: BtcScopes.Testnet, + scope: BtcScope.Testnet, expectedAddress: 'mjPQaLkhZN3MxsYN8Nebzwevuz8vdTaRCq', }, { addressType: Caip2AddressType.P2sh, - scope: BtcScopes.Mainnet, + scope: BtcScope.Mainnet, expectedAddress: '3QVSaDYjxEh4L3K24eorrQjfVxPAKJMys2', }, { addressType: Caip2AddressType.P2sh, - scope: BtcScopes.Testnet, + scope: BtcScope.Testnet, expectedAddress: '2NBG623WvXp1zxKB6gK2mnMe2mSDCur5qRU', }, { addressType: Caip2AddressType.P2tr, - scope: BtcScopes.Mainnet, + scope: BtcScope.Mainnet, expectedAddress: 'bc1p4rue37y0v9snd4z3fvw43d29u97qxf9j3fva72xy2t7hekg24dzsaz40mz', }, { addressType: Caip2AddressType.P2tr, - scope: BtcScopes.Testnet, + scope: BtcScope.Testnet, expectedAddress: 'tb1pwwjax3vpq6h69965hcr22vkpm4qdvyu2pz67wyj8eagp9vxkcz0q0ya20h', }, @@ -147,14 +147,14 @@ describe('Bitcoin Snap', () => { method: 'keyring_createAccount', params: { options: { - scope: BtcScopes.Mainnet, + scope: BtcScope.Mainnet, addressType: Caip2AddressType.P2wpkh, }, }, }); expect(response).toRespondWith( - accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Mainnet}`], + accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Mainnet}`], ); }); @@ -163,12 +163,12 @@ describe('Bitcoin Snap', () => { origin, method: 'keyring_getAccount', params: { - id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Mainnet}`].id, + id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Mainnet}`].id, }, }); expect(response).toRespondWith( - accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Mainnet}`], + accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Mainnet}`], ); }); @@ -182,7 +182,7 @@ describe('Bitcoin Snap', () => { }); it('removes an account', async () => { - const { id } = accounts[`${Caip2AddressType.P2pkh}:${BtcScopes.Mainnet}`]; + const { id } = accounts[`${Caip2AddressType.P2pkh}:${BtcScope.Mainnet}`]; let response = await snap.onKeyringRequest({ origin, @@ -214,7 +214,7 @@ describe('Bitcoin Snap', () => { origin, method: 'keyring_deleteAccount', params: { - id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, + id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id, }, }); @@ -230,7 +230,7 @@ describe('Bitcoin Snap', () => { origin, method: 'keyring_listAccountTransactions', params: { - id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, + id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id, pagination: { limit: 10, next: null }, }, }); @@ -244,17 +244,17 @@ describe('Bitcoin Snap', () => { it.each([ { addressType: Caip2AddressType.P2wpkh, - scope: BtcScopes.Mainnet, + scope: BtcScope.Mainnet, expectedAssets: [Caip19Asset.Bitcoin], }, { addressType: Caip2AddressType.P2wpkh, - scope: BtcScopes.Regtest, + scope: BtcScope.Regtest, expectedAssets: [Caip19Asset.Regtest], }, { addressType: Caip2AddressType.P2tr, - scope: BtcScopes.Testnet, + scope: BtcScope.Testnet, expectedAssets: [Caip19Asset.Testnet], }, ])( @@ -277,7 +277,7 @@ describe('Bitcoin Snap', () => { origin, method: 'startSendTransactionFlow', params: { - account: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, + account: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id, }, }); @@ -303,7 +303,7 @@ describe('Bitcoin Snap', () => { origin, method: 'startSendTransactionFlow', params: { - account: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, + account: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id, }, }); @@ -336,7 +336,7 @@ describe('Bitcoin Snap', () => { origin, method: 'startSendTransactionFlow', params: { - account: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, + account: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id, }, }); @@ -356,7 +356,7 @@ describe('Bitcoin Snap', () => { origin, method: 'startSendTransactionFlow', params: { - account: accounts[`${Caip2AddressType.P2wpkh}:${BtcScopes.Regtest}`].id, + account: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id, }, }); From d4101e8d7e28216e7b84d3d12d6784a62109691e Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 27 Feb 2025 16:47:02 +0100 Subject: [PATCH 197/362] chore: remove unused dependencies (#417) * add unspendable to tx builder * fetch assets * running * run only on new outputs * UTXO protection done * tests * done * all done * rename files * done * remove old Snap and release * remove unused dependencies * buils and tests pass * buils and tests pass * use v0.1.4 * from_string * rebase * rebase done * done * lint + build * remove quicknode * Delete packages/snap/src/infra/SimplehashClientAdapter.ts * final alignment * integration test link * integration tests * back to blockstream * typo * do not log in integration tests * add possible options for network and address types * add log level 0 when releasing * comments applied * Update packages/snap/README.md Co-authored-by: Charly Chevalier --------- Co-authored-by: Charly Chevalier --- .../bitcoin-wallet-snap/.env.example | 55 +- merged-packages/bitcoin-wallet-snap/README.md | 132 +-- .../docker-compose.yml | 0 .../init-esplora.sh | 0 .../run-integration.sh | 6 +- .../snap.test.ts | 4 +- .../bitcoin-wallet-snap/jest.config.js | 31 - .../jest.integration.config.js | 2 +- .../bitcoin-wallet-snap/jest.setup.ts | 6 - .../bitcoin-wallet-snap/package.json | 10 +- .../bitcoin-wallet-snap/snap.config.ts | 15 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/bitcoin/chain/api-client.ts | 131 --- .../bitcoin/chain/clients/__tests__/helper.ts | 71 -- .../bitcoin/chain/clients/quicknode.test.ts | 598 -------------- .../src/bitcoin/chain/clients/quicknode.ts | 350 -------- .../bitcoin/chain/clients/quicknode.types.ts | 113 --- .../bitcoin/chain/clients/simplehash.test.ts | 163 ---- .../src/bitcoin/chain/clients/simplehash.ts | 117 --- .../bitcoin/chain/clients/simplehash.types.ts | 22 - .../src/bitcoin/chain/constants.ts | 11 - .../src/bitcoin/chain/data-client.ts | 76 -- .../src/bitcoin/chain/exceptions.ts | 5 - .../src/bitcoin/chain/index.ts | 3 - .../src/bitcoin/chain/service.test.ts | 566 ------------- .../src/bitcoin/chain/service.ts | 263 ------ .../src/bitcoin/wallet/account.test.ts | 80 -- .../src/bitcoin/wallet/account.ts | 140 ---- .../src/bitcoin/wallet/coin-select.test.ts | 95 --- .../src/bitcoin/wallet/coin-select.ts | 64 -- .../src/bitcoin/wallet/constants.ts | 43 - .../src/bitcoin/wallet/deriver.test.ts | 157 ---- .../src/bitcoin/wallet/deriver.ts | 102 --- .../src/bitcoin/wallet/exceptions.ts | 29 - .../src/bitcoin/wallet/index.ts | 12 - .../src/bitcoin/wallet/psbt.test.ts | 325 -------- .../src/bitcoin/wallet/psbt.ts | 258 ------ .../src/bitcoin/wallet/signer.test.ts | 71 -- .../src/bitcoin/wallet/signer.ts | 64 -- .../bitcoin/wallet/transaction-info.test.ts | 97 --- .../src/bitcoin/wallet/transaction-info.ts | 87 -- .../bitcoin/wallet/transaction-input.test.ts | 46 -- .../src/bitcoin/wallet/transaction-input.ts | 33 - .../bitcoin/wallet/transaction-output.test.ts | 34 - .../src/bitcoin/wallet/transaction-output.ts | 33 - .../src/bitcoin/wallet/utils/address.test.ts | 21 - .../src/bitcoin/wallet/utils/address.ts | 17 - .../src/bitcoin/wallet/utils/deriver.ts | 33 - .../src/bitcoin/wallet/utils/index.ts | 4 - .../src/bitcoin/wallet/utils/network.test.ts | 38 - .../src/bitcoin/wallet/utils/network.ts | 54 -- .../src/bitcoin/wallet/utils/payment.test.ts | 96 --- .../src/bitcoin/wallet/utils/payment.ts | 33 - .../src/bitcoin/wallet/utils/policy.test.ts | 16 - .../src/bitcoin/wallet/utils/policy.ts | 17 - .../src/bitcoin/wallet/wallet.test.ts | 434 ---------- .../src/bitcoin/wallet/wallet.ts | 286 ------- .../bitcoin-wallet-snap/src/cacheManager.ts | 145 ---- .../bitcoin-wallet-snap/src/config.ts | 107 +-- .../bitcoin-wallet-snap/src/configv2.ts | 46 -- .../bitcoin-wallet-snap/src/constants.ts | 21 - .../bitcoin-wallet-snap/src/entities/chain.ts | 8 + .../src/entities/config.ts | 2 +- .../src/{utils => entities}/error.ts | 0 .../bitcoin-wallet-snap/src/entities/index.ts | 2 + .../src/{utils => entities}/locale.test.ts | 0 .../src/{utils => entities}/locale.ts | 4 +- .../bitcoin-wallet-snap/src/exceptions.ts | 47 -- .../bitcoin-wallet-snap/src/factory.test.ts | 91 --- .../bitcoin-wallet-snap/src/factory.ts | 66 -- .../bitcoin-wallet-snap/src/index.test.ts | 273 ------- .../bitcoin-wallet-snap/src/index.ts | 178 ++-- .../src/infra/jsx/ReviewTransactionView.tsx | 12 +- .../src/infra/jsx/SendFormView.tsx | 2 +- .../jsx/components/HeadingWithReturn.tsx | 2 +- .../src/infra/jsx/components/SendForm.tsx | 4 +- .../jsx/components/TransactionSummary.tsx | 15 +- .../src/{ => infra/jsx}/images/bitcoin.svg | 0 .../src/{ => infra/jsx}/images/btc-halo.svg | 0 .../{ => infra/jsx}/images/empty-space.svg | 0 .../src/{utils => infra}/logger.test.ts | 0 .../src/{utils => infra}/logger.ts | 0 .../bitcoin-wallet-snap/src/keyring.test.ts | 598 -------------- .../bitcoin-wallet-snap/src/keyring.ts | 368 --------- .../bitcoin-wallet-snap/src/permissions.ts | 11 - .../src/rpcs/__tests__/helper.ts | 555 ------------- .../src/rpcs/estimate-fee.test.ts | 158 ---- .../src/rpcs/estimate-fee.ts | 129 --- .../src/rpcs/get-balances.test.ts | 134 ---- .../src/rpcs/get-balances.ts | 82 -- .../rpcs/get-max-spendable-balance.test.ts | 188 ----- .../src/rpcs/get-max-spendable-balance.ts | 163 ---- .../src/rpcs/get-rates-and-balances.test.ts | 99 --- .../src/rpcs/get-rates-and-balances.ts | 64 -- .../src/rpcs/get-transaction-status.test.ts | 71 -- .../src/rpcs/get-transaction-status.ts | 66 -- .../bitcoin-wallet-snap/src/rpcs/index.ts | 5 - .../src/rpcs/send-bitcoin.test.ts | 213 ----- .../src/rpcs/send-bitcoin.ts | 141 ---- .../rpcs/start-send-transaction-flow.test.ts | 180 ----- .../src/rpcs/start-send-transaction-flow.ts | 138 ---- .../src/stateManagement.test.ts | 690 ---------------- .../src/stateManagement.ts | 260 ------ .../src/ui/components/AccountSelector.tsx | 78 -- .../src/ui/components/ReviewTransaction.tsx | 138 ---- .../ui/components/SatsProtectionToolTip.tsx | 23 - .../src/ui/components/SendFlow.tsx | 83 -- .../src/ui/components/SendFlowFooter.tsx | 35 - .../src/ui/components/SendFlowHeader.tsx | 37 - .../src/ui/components/SendForm.tsx | 191 ----- .../src/ui/components/TransactionSummary.tsx | 82 -- .../src/ui/components/index.ts | 2 - .../send-bitcoin-controller.test.ts | 759 ------------------ .../ui/controller/send-bitcoin-controller.ts | 278 ------- .../src/ui/images/bitcoin.svg | 14 - .../src/ui/images/btc-halo.svg | 38 - .../src/ui/images/empty-space.svg | 3 - .../src/ui/images/jazzicon1.svg | 11 - .../src/ui/images/jazzicon2.svg | 11 - .../src/ui/images/jazzicon3.svg | 11 - .../src/ui/render-interfaces.tsx | 145 ---- .../bitcoin-wallet-snap/src/ui/types.ts | 85 -- .../bitcoin-wallet-snap/src/ui/utils.test.ts | 572 ------------- .../bitcoin-wallet-snap/src/ui/utils.ts | 401 --------- .../src/use-cases/AccountUseCases.test.ts | 5 +- .../src/use-cases/AccountUseCases.ts | 2 +- .../src/use-cases/SendFlowUseCases.test.ts | 5 +- .../src/use-cases/SendFlowUseCases.ts | 2 +- .../src/utils/__mocks__/logger.ts | 17 - .../src/utils/__mocks__/snap.ts | 36 - .../src/utils/account.test.ts | 87 -- .../bitcoin-wallet-snap/src/utils/account.ts | 26 - .../src/utils/async.test.ts | 11 - .../bitcoin-wallet-snap/src/utils/async.ts | 25 - .../src/utils/config.test.ts | 46 -- .../bitcoin-wallet-snap/src/utils/config.ts | 16 - .../src/utils/explorer.test.ts | 22 - .../bitcoin-wallet-snap/src/utils/explorer.ts | 29 - .../bitcoin-wallet-snap/src/utils/fee.test.ts | 49 -- .../bitcoin-wallet-snap/src/utils/fee.ts | 77 -- .../bitcoin-wallet-snap/src/utils/index.ts | 13 - .../src/utils/lock.test.ts | 21 - .../bitcoin-wallet-snap/src/utils/lock.ts | 16 - .../bitcoin-wallet-snap/src/utils/rates.ts | 13 - .../bitcoin-wallet-snap/src/utils/rpc.ts | 35 - .../src/utils/snap-state.test.ts | 610 -------------- .../src/utils/snap-state.ts | 170 ---- .../src/utils/snap.test.ts | 107 --- .../bitcoin-wallet-snap/src/utils/snap.ts | 149 ---- .../bitcoin-wallet-snap/src/utils/static.ts | 13 - .../src/utils/string.test.ts | 85 -- .../bitcoin-wallet-snap/src/utils/string.ts | 78 -- .../src/utils/superstruct.test.ts | 124 --- .../src/utils/superstruct.ts | 49 -- .../src/utils/transaction.ts | 60 -- .../src/utils/unit.test.ts | 82 -- .../bitcoin-wallet-snap/src/utils/unit.ts | 83 -- .../test/fixtures/quicknode.json | 88 -- .../test/fixtures/simplehash.json | 12 - .../test/snap-provider.mock.ts | 42 - .../bitcoin-wallet-snap/test/utils.ts | 437 ---------- 161 files changed, 181 insertions(+), 16467 deletions(-) rename merged-packages/bitcoin-wallet-snap/{test/integration => integration-test}/docker-compose.yml (100%) rename merged-packages/bitcoin-wallet-snap/{test/integration => integration-test}/init-esplora.sh (100%) rename merged-packages/bitcoin-wallet-snap/{test/integration => integration-test}/run-integration.sh (80%) rename merged-packages/bitcoin-wallet-snap/{test/integration => integration-test}/snap.test.ts (99%) delete mode 100644 merged-packages/bitcoin-wallet-snap/jest.setup.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/api-client.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/__tests__/helper.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.types.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.types.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/constants.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/deriver.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/cacheManager.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/configv2.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/constants.ts rename merged-packages/bitcoin-wallet-snap/src/{utils => entities}/error.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{utils => entities}/locale.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{utils => entities}/locale.ts (93%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/exceptions.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/factory.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/factory.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/index.test.ts rename merged-packages/bitcoin-wallet-snap/src/{ => infra/jsx}/images/bitcoin.svg (100%) rename merged-packages/bitcoin-wallet-snap/src/{ => infra/jsx}/images/btc-halo.svg (100%) rename merged-packages/bitcoin-wallet-snap/src/{ => infra/jsx}/images/empty-space.svg (100%) rename merged-packages/bitcoin-wallet-snap/src/{utils => infra}/logger.test.ts (100%) rename merged-packages/bitcoin-wallet-snap/src/{utils => infra}/logger.ts (100%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/keyring.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/keyring.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/stateManagement.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/AccountSelector.tsx delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/ReviewTransaction.tsx delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/SatsProtectionToolTip.tsx delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlow.tsx delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowFooter.tsx delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/TransactionSummary.tsx delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/components/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/images/bitcoin.svg delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/images/btc-halo.svg delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/images/empty-space.svg delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon1.svg delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon2.svg delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon3.svg delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/types.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/ui/utils.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/logger.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/account.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/account.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/async.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/async.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/config.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/config.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/explorer.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/explorer.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/fee.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/fee.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/index.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/lock.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/lock.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/rates.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/rpc.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/snap.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/static.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/string.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/transaction.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/unit.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/test/fixtures/quicknode.json delete mode 100644 merged-packages/bitcoin-wallet-snap/test/fixtures/simplehash.json delete mode 100644 merged-packages/bitcoin-wallet-snap/test/snap-provider.mock.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/test/utils.ts diff --git a/merged-packages/bitcoin-wallet-snap/.env.example b/merged-packages/bitcoin-wallet-snap/.env.example index 01528ebd..ade8110f 100644 --- a/merged-packages/bitcoin-wallet-snap/.env.example +++ b/merged-packages/bitcoin-wallet-snap/.env.example @@ -1,19 +1,46 @@ -# Description: Environment variables for Log Level, 0 does not log anything, 6 logs everything +# Log Level, 0 does not log anything, 6 logs everything # Possible Options: 0-6 # Default: 0 # Required: false LOG_LEVEL=6 -# Description: Environment variables for the Testnet endpoint of QuickNode -# Required: true -QUICKNODE_TESTNET_ENDPOINT= -# Description: Environment variables for the Mainnet endpoint of QuickNode -# Required: true -QUICKNODE_MAINNET_ENDPOINT= -# Description: Environment variables for the SimpleHash API key -# Required: true + +# Default Bitcoin network +# Possible options: bitcoin, testnet, testnet4, signet, regtest +# Default: bitcoin +# Required: false +DEFAULT_NETWORK= +# Default address type +# Possible options: p2pkh, p2sh, p2wpkh, p2wsh, p2tr +# Default: p2wpkh +# Required: false +DEFAULT_ADDRESS_TYPE= + +# Esplora endpoint for Bitcoin network +# Default: https://blockstream.info/api +# Required: false +ESPLORA_BITCOIN= +# Esplora endpoint for Testnet network +# Default: https://blockstream.info/testnet/api +# Required: false +ESPLORA_TESTNET= +# Esplora endpoint for Testnet4 network +# Default: https://mempool.space/testnet4/api/v1 +# Required: false +ESPLORA_TESTNET4= +# Esplora endpoint for Signet network +# Default: https://mutinynet.com/api +# Required: false +ESPLORA_SIGNET= +# Esplora endpoint for local regtest network +# Default: http://localhost:8094/regtest/api +# Required: false +ESPLORA_REGTEST= + +# SimpleHash endpoint for Bitcoin network +# Default: https://api.simplehash.com/api/v0 +# Required: false +SIMPLEHASH_BITCOIN= +# SimpleHash API key +# Default: No API key +# Required: false SIMPLEHASH_API_KEY= -# Description: Version of the keyring -# Possible Options: v1, v2 -# Default: v1 -# Required: false -KEYRING_VERSION=v1 \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index 7a5808ce..cd8d4026 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -1,26 +1,13 @@ -# Bitcoin Wallet Snap +# Bitcoin Snap ## Configuration Rename `.env.example` to `.env` Configurations are setup though `.env`, -```bash -LOG_LEVEL=6 -# Description: Environment variables for the Testnet endpoint of QuickNode -# Required: true -QUICKNODE_TESTNET_ENDPOINT= -# Description: Environment variables for the Mainnet endpoint of QuickNode -# Required: true -QUICKNODE_MAINNET_ENDPOINT= -# Description: Environment variables for the SimpleHash API key -# Required: true -SIMPLEHASH_API_KEY= -``` - -## Rpcs: +## API: -### API `keyring_createAccount` +### `keyring_createAccount` example: @@ -32,119 +19,10 @@ provider.request({ request: { method: 'keyring_createAccount', params: { - scope: 'bip122:000000000933ea01ad0ee984209779ba', // the CAIP-2 chain ID of bitcoin - }, - }, - }, -}); -``` - -### API `keyring_getAccountBalances` - -example: - -```typescript -provider.request({ - method: 'wallet_invokeKeyring', - params: { - snapId, - request: { - method: 'keyring_getAccountBalances', - params: { - account: 'dc06350a-82db-434b-b113-066135804f63', // the uuid account id of the current account - id: 'da40b782-e054-4260-9a6a-c8717a022f92', // an random uuid for this request - assets: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], // Caip-2 BTC Asset + scope: 'bip122:000000000933ea01ad0ee984209779ba', // the CAIP-2 chain ID of the network + addressType: 'bip122:p2wpkh', // the CAIP-like address type }, }, }, }); ``` - -### API `chain_getTransactionStatus` - -example: - -```typescript -provider.request({ - method: 'wallet_invokeSnap', - params: { - snapId, - request: { - method: 'chain_getTransactionStatus', - params: { - scope: 'bip122:000000000933ea01ad0ee984209779ba', // the CAIP-2 chain ID of bitcoin - transactionId: '5639078d-742e-4901-8993-bc25a5ef6161', // the txn id of an bitcoin transaction - }, - }, - }, -}); -``` - -### API `sendBitcoin` - -example: - -```typescript -provider.request({ - method: 'wallet_invokeKeyring', - params: { - snapId, - request: { - method: 'keyring_submitRequest', - params: { - account: 'dc06350a-82db-434b-b113-066135804f63', // the uuid account id of the current account - id: 'da40b782-e054-4260-9a6a-c8717a022f92', // an random uuid for this request - scope: 'bip122:000000000933ea01ad0ee984209779ba', // the CAIP-2 chain ID of bitcoin - request: { - method: 'sendBitcoin', - params: { - recipients: { - ['tb1qlhkuysju47s642834n7f3tyk67mvnt2cfd9r7y']: '0.00000500', - }, // the recipient struct to indicate how many BTC to be received for each recipient - replaceable: true, // an flag to opt-in RBF - dryrun: true, // an flag to enable similation of the transaction, without broadcast to network - }, - }, - }, - }, - }, -}); -``` - -### API `estimateFee` - -example: - -```typescript -provider.request({ - method: 'wallet_invokeSnap', - params: { - snapId, - request: { - method: 'estimateFee', - params: { - account: '9506e13e-d343-406f-8408-fad033ab7c0d', // the uuid account id of the current account - amount: '0.00001', // transaction amount in BTC - }, - }, - }, -}); -``` - -### API `getMaxSpendableBalance` - -example: - -```typescript -provider.request({ - method: 'wallet_invokeSnap', - params: { - snapId, - request: { - method: 'getMaxSpendableBalance', - params: { - account: "9506e13e-d343-406f-8408-fad033ab7c0d", // the uuid account id of the current account - }, - }, -}); -``` diff --git a/merged-packages/bitcoin-wallet-snap/test/integration/docker-compose.yml b/merged-packages/bitcoin-wallet-snap/integration-test/docker-compose.yml similarity index 100% rename from merged-packages/bitcoin-wallet-snap/test/integration/docker-compose.yml rename to merged-packages/bitcoin-wallet-snap/integration-test/docker-compose.yml diff --git a/merged-packages/bitcoin-wallet-snap/test/integration/init-esplora.sh b/merged-packages/bitcoin-wallet-snap/integration-test/init-esplora.sh similarity index 100% rename from merged-packages/bitcoin-wallet-snap/test/integration/init-esplora.sh rename to merged-packages/bitcoin-wallet-snap/integration-test/init-esplora.sh diff --git a/merged-packages/bitcoin-wallet-snap/test/integration/run-integration.sh b/merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh similarity index 80% rename from merged-packages/bitcoin-wallet-snap/test/integration/run-integration.sh rename to merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh index 817dc0c9..40de2097 100755 --- a/merged-packages/bitcoin-wallet-snap/test/integration/run-integration.sh +++ b/merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh @@ -3,7 +3,7 @@ set -e echo "Starting Docker services..." -docker-compose -f test/integration/docker-compose.yml up -d +docker-compose -f integration-test/docker-compose.yml up -d # Check if Docker services started successfully if [ $? -ne 0 ]; then @@ -14,7 +14,7 @@ fi echo "Docker services started successfully." # Show Docker service status -docker-compose -f test/integration/docker-compose.yml ps +docker-compose -f integration-test/docker-compose.yml ps echo "Waiting for Esplora to be ready..." sleep 10 @@ -32,7 +32,7 @@ else fi echo "Stopping Docker services..." -docker-compose -f test/integration/docker-compose.yml down +docker-compose -f integration-test/docker-compose.yml down if [ $? -ne 0 ]; then echo "Error: Failed to stop Docker services." diff --git a/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/snap.test.ts similarity index 99% rename from merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts rename to merged-packages/bitcoin-wallet-snap/integration-test/snap.test.ts index 44f4585b..ff29ab61 100644 --- a/merged-packages/bitcoin-wallet-snap/test/integration/snap.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/snap.test.ts @@ -7,8 +7,8 @@ import { CurrencyUnit, ReviewTransactionEvent, SendFormEvent, -} from '../../src/entities'; -import { Caip2AddressType, Caip19Asset } from '../../src/handlers'; +} from '../src/entities'; +import { Caip2AddressType, Caip19Asset } from '../src/handlers'; describe('Bitcoin Snap', () => { const accounts: Record = {}; diff --git a/merged-packages/bitcoin-wallet-snap/jest.config.js b/merged-packages/bitcoin-wallet-snap/jest.config.js index b2ee112b..05fed64e 100644 --- a/merged-packages/bitcoin-wallet-snap/jest.config.js +++ b/merged-packages/bitcoin-wallet-snap/jest.config.js @@ -3,37 +3,6 @@ module.exports = { transform: { '^.+\\.(t|j)sx?$': 'ts-jest', }, - - setupFilesAfterEnv: ['/jest.setup.ts'], restoreMocks: true, - resetMocks: true, - verbose: true, - testPathIgnorePatterns: ['/node_modules/', '/__mocks__/', '/__tests__/'], 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/**/__BAK__/**', - '!./src/**/__mocks__/**', - '!./src/**/__tests__/**', - '!./src/config/*.ts', - '!./src/**/type?(s).ts', - '!./src/**/exception?(s).ts', - '!./src/**/constant?(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'], - moduleNameMapper: { - '^.+.(svg)$': 'jest-transform-stub', - }, }; diff --git a/merged-packages/bitcoin-wallet-snap/jest.integration.config.js b/merged-packages/bitcoin-wallet-snap/jest.integration.config.js index 3c83af0a..297549b2 100644 --- a/merged-packages/bitcoin-wallet-snap/jest.integration.config.js +++ b/merged-packages/bitcoin-wallet-snap/jest.integration.config.js @@ -1,4 +1,4 @@ module.exports = { preset: '@metamask/snaps-jest', - testMatch: ['**/integration/**/*.test.ts'], + testMatch: ['**/integration-test/**/*.test.ts'], }; diff --git a/merged-packages/bitcoin-wallet-snap/jest.setup.ts b/merged-packages/bitcoin-wallet-snap/jest.setup.ts deleted file mode 100644 index c1c7b0fd..00000000 --- a/merged-packages/bitcoin-wallet-snap/jest.setup.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { MockSnapProvider } from './test/snap-provider.mock'; - -// eslint-disable-next-line no-restricted-globals -const globalAny: any = global; - -globalAny.snap = new MockSnapProvider(); diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 6ee8702e..7a09b7fa 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -32,12 +32,10 @@ "serve": "mm-snap serve", "start": "concurrently \"mm-snap watch\" \"yarn build:locale:watch\"", "test": "jest --passWithNoTests", - "test:integration": "./test/integration/run-integration.sh" + "test:integration": "./integration-test/run-integration.sh" }, "devDependencies": { "@babel/preset-typescript": "^7.23.3", - "@bitcoinerlab/secp256k1": "1.1.1", - "@jest/globals": "^29.5.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/eslint-config": "^12.2.0", "@metamask/eslint-config-jest": "^12.1.0", @@ -54,15 +52,9 @@ "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", "async-mutex": "^0.3.2", - "bignumber.js": "9.1.2", - "bip32": "4.0.0", - "bitcoin-address-validation": "^2.2.3", "bitcoindevkit": "^0.1.4", - "bitcoinjs-lib": "6.1.5", - "coinselect": "3.1.13", "concurrently": "^9.1.0", "dotenv": "^16.4.5", - "ecpair": "2.1.0", "eslint": "^8.45.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "~2.26.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts index 2e42e92d..9143ceb8 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.config.ts +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -13,18 +13,15 @@ const config: SnapConfig = { environment: { /* eslint-disable */ LOG_LEVEL: process.env.LOG_LEVEL, - QUICKNODE_MAINNET_ENDPOINT: process.env.QUICKNODE_MAINNET_ENDPOINT, - QUICKNODE_TESTNET_ENDPOINT: process.env.QUICKNODE_TESTNET_ENDPOINT, - SIMPLEHASH_API_KEY: process.env.SIMPLEHASH_API_KEY, DEFAULT_NETWORK: process.env.DEFAULT_NETWORK, DEFAULT_ADDRESS_TYPE: process.env.DEFAULT_ADDRESS_TYPE, - ESPLORA_PROVIDER_BITCOIN: process.env.ESPLORA_PROVIDER_BITCOIN, - ESPLORA_PROVIDER_TESTNET: process.env.ESPLORA_PROVIDER_TESTNET, - ESPLORA_PROVIDER_TESTNET4: process.env.ESPLORA_PROVIDER_TESTNET4, - ESPLORA_PROVIDER_SIGNET: process.env.ESPLORA_PROVIDER_SIGNET, - ESPLORA_PROVIDER_REGTEST: process.env.ESPLORA_PROVIDER_REGTEST, - KEYRING_VERSION: process.env.KEYRING_VERSION, + ESPLORA_BITCOIN: process.env.ESPLORA_BITCOIN, + ESPLORA_TESTNET: process.env.ESPLORA_TESTNET, + ESPLORA_TESTNET4: process.env.ESPLORA_TESTNET4, + ESPLORA_SIGNET: process.env.ESPLORA_SIGNET, + ESPLORA_REGTEST: process.env.ESPLORA_REGTEST, SIMPLEHASH_BITCOIN: process.env.SIMPLEHASH_BITCOIN, + SIMPLEHASH_API_KEY: process.env.SIMPLEHASH_API_KEY, /* eslint-disable */ }, polyfills: true, diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 8da6ecfc..25841460 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "mqamRckPGCDBOcc4uz3o7vutlJo3QtGMfeqK3e57C1k=", + "shasum": "UcY/75+Dj5afjkU4g+7CYF/Xd0oXTnrTl7Xp+kJ/YMM=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/api-client.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/api-client.ts deleted file mode 100644 index 082b7ed8..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/api-client.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { Json } from '@metamask/snaps-sdk'; -import type { Struct } from 'superstruct'; -import { mask } from 'superstruct'; - -import { compactError, logger } from '../../utils'; -import { DataClientError } from './exceptions'; - -export enum HttpMethod { - Get = 'GET', - Post = 'POST', -} - -export type HttpHeaders = Record; - -export type HttpRequest = { - url: string; - method: HttpMethod; - headers: HttpHeaders; - body?: string; -}; - -export type HttpResponse = globalThis.Response; - -export abstract class ApiClient { - /** - * The name of the API Client. - */ - abstract apiClientName: string; - - /** - * An internal method called internally by `submitRequest()` to verify and convert the HTTP response to the expected API response. - * - * @param response - The HTTP response to verify and convert. - * @returns A promise that resolves to the API response. - */ - protected async getResponse( - response: HttpResponse, - ): Promise { - try { - return (await response.json()) as unknown as ApiResponse; - } catch (error) { - throw new Error( - 'API response error: response body can not be deserialised.', - ); - } - } - - /** - * An internal method used to build the `HttpRequest` object. - * - * @param params - The request parameters. - * @param params.method - The HTTP method (GET or POST). - * @param params.headers - The HTTP headers. - * @param params.url - The request URL. - * @param [params.body] - The request body (optional). - * @returns A `HttpRequest` object. - */ - protected buildHttpRequest({ - method, - headers = {}, - url, - body, - }: { - method: HttpMethod; - headers?: HttpHeaders; - url: string; - body?: Json; - }): HttpRequest { - const request = { - url, - method, - headers: { - 'Content-Type': 'application/json', - ...headers, - }, - body: - method === HttpMethod.Post && body ? JSON.stringify(body) : undefined, - }; - - return request; - } - - /** - * An internal method used to submit the API request. - * - * @param params - The request parameters. - * @param [params.requestName] - The name of the request (optional). - * @param params.request - The `HttpRequest` object. - * @param params.responseStruct - The superstruct used to verify the API response. - * @returns A promise that resolves to a JSON object. - */ - protected async submitHttpRequest({ - requestName = '', - request, - responseStruct, - }: { - requestName?: string; - request: HttpRequest; - responseStruct: Struct; - }): Promise { - const logPrefix = `[${this.apiClientName}.${requestName}]`; - - try { - logger.debug(`${logPrefix} request: ${request.method}`); // Log HTTP method being used. - - const fetchRequest = { - method: request.method, - headers: request.headers, - body: request.body, - }; - - const httpResponse = await fetch(request.url, fetchRequest); - - const jsonResponse = await this.getResponse(httpResponse); - - logger.debug(`${logPrefix} response:`, JSON.stringify(jsonResponse)); - - // Safeguard to identify if the response has some unexpected changes from the API client - mask(jsonResponse, responseStruct, `Unexpected response from API client`); - - return jsonResponse; - } catch (error) { - logger.info( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `${logPrefix} error: ${error.message}`, - ); - - throw compactError(error, DataClientError); - } - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/__tests__/helper.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/__tests__/helper.ts deleted file mode 100644 index 7d6cd60f..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/__tests__/helper.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { Json } from '@metamask/snaps-sdk'; -import type { Network } from 'bitcoinjs-lib'; - -import { Config } from '../../../../config'; -import type { BtcAccount } from '../../../wallet'; -import { BtcAccountDeriver, BtcWallet } from '../../../wallet'; - -export const createMockFetch = (): jest.SpyInstance => { - const fetchSpy = jest.fn(); - // eslint-disable-next-line no-restricted-globals - Object.defineProperty(global, 'fetch', { - // Allow `fetch` to be redefined in the global scope - writable: true, - value: fetchSpy, - }); - - return fetchSpy; -}; - -export const mockErrorResponse = ({ - fetchSpy, - isOk = true, - status = 200, - statusText = 'error', - errorResp = { - result: null, - error: null, - id: null, - }, -}: { - fetchSpy: jest.SpyInstance; - isOk?: boolean; - status?: number; - statusText?: string; - errorResp?: Record; -}): void => { - fetchSpy.mockResolvedValueOnce({ - ok: isOk, - status, - statusText, - json: jest.fn().mockResolvedValue(errorResp), - }); -}; - -export const mockApiSuccessResponse = ({ - fetchSpy, - mockResponse, -}: { - fetchSpy: jest.SpyInstance; - mockResponse: unknown; -}): void => { - fetchSpy.mockResolvedValueOnce({ - ok: true, - status: 200, - json: jest.fn().mockResolvedValue(mockResponse), - }); -}; - -export const createAccounts = async ( - network: Network, - count: number, -): Promise => { - const wallet = new BtcWallet(new BtcAccountDeriver(network), network); - - const accounts: BtcAccount[] = []; - for (let i = 0; i < count; i++) { - accounts.push(await wallet.unlock(i, Config.wallet.defaultAccountType)); - } - - return accounts; -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts deleted file mode 100644 index 67e17b5c..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.test.ts +++ /dev/null @@ -1,598 +0,0 @@ -import type { Json } from '@metamask/utils'; -import type { Network } from 'bitcoinjs-lib'; -import { networks } from 'bitcoinjs-lib'; -import type { Struct } from 'superstruct'; -import { any } from 'superstruct'; - -import { - generateQuickNodeGetBalanceResp, - generateQuickNodeEstimatefeeResp, - generateQuickNodeGetUtxosResp, - generateQuickNodeGetRawTransactionResp, - generateQuickNodeSendRawTransactionResp, - generateQuickNodeMempoolResp, -} from '../../../../test/utils'; -import { Config } from '../../../config'; -import { btcToSats, logger, satsKvbToVb } from '../../../utils'; -import * as asyncUtils from '../../../utils/async'; -import type { BtcAccount } from '../../wallet'; -import { BtcAccountDeriver, BtcWallet } from '../../wallet'; -import { TransactionStatus } from '../constants'; -import { DataClientError } from '../exceptions'; -import type { Utxo } from '../service'; -import { NoFeeRateError, QuickNodeClient } from './quicknode'; -import type { - QuickNodeEstimateFeeResponse, - QuickNodeResponse, -} from './quicknode.types'; - -jest.mock('../../../utils/logger'); -jest.mock('../../../utils/snap'); - -describe('QuickNodeClient', () => { - const testnetEndpoint = 'https://api.quicknode.com/testnet'; - const mainnetEndpoint = 'https://api.quicknode.com/mainnet'; - - class MockQuickNodeClient extends QuickNodeClient { - async submitJsonRPCRequest({ - request, - responseStruct, - }: { - request: { - method: string; - params: Json; - }; - responseStruct: Struct; - }) { - return super.submitJsonRPCRequest({ - request, - responseStruct, - }); - } - - getPriorityMap() { - return this._priorityMap; - } - } - - const createMockFetch = () => { - const fetchSpy = jest.fn(); - // eslint-disable-next-line no-restricted-globals - Object.defineProperty(global, 'fetch', { - // Allow `fetch` to be redefined in the global scope - writable: true, - value: fetchSpy, - }); - - return { - fetchSpy, - }; - }; - - const createAccounts = async (network: Network, recipientCnt: number) => { - const wallet = new BtcWallet(new BtcAccountDeriver(network), network); - - const accounts: BtcAccount[] = []; - for (let i = 0; i < recipientCnt; i++) { - accounts.push(await wallet.unlock(i, Config.wallet.defaultAccountType)); - } - - return { - accounts, - }; - }; - - const createAccountAddresses = async (network: Network, count: number) => { - const { accounts } = await createAccounts(network, count); - return accounts.map((account) => account.address); - }; - - const createQuickNodeClient = (network: Network) => { - return new MockQuickNodeClient({ - network, - testnetEndpoint, - mainnetEndpoint, - }); - }; - - const mockErrorResponse = ({ - fetchSpy, - isOk = true, - status = 200, - statusText = 'error', - errorResp = { - result: null, - error: { - code: 1, - message: 'some error', - }, - id: null, - }, - }: { - fetchSpy: jest.SpyInstance; - isOk?: boolean; - status?: number; - statusText?: string; - errorResp?: Record; - }) => { - fetchSpy.mockResolvedValueOnce({ - ok: isOk, - status, - statusText, - json: jest.fn().mockResolvedValue(errorResp), - }); - }; - - const mockApiSuccessResponse = ({ - fetchSpy, - mockResponse, - }: { - fetchSpy: jest.SpyInstance; - mockResponse: unknown; - }) => { - fetchSpy.mockResolvedValueOnce({ - ok: true, - status: 200, - json: jest.fn().mockResolvedValue(mockResponse), - }); - }; - - describe('submitJsonRPCRequest', () => { - it('executes a request', async () => { - const { fetchSpy } = createMockFetch(); - const mockResponse = { - result: true, - }; - - mockApiSuccessResponse({ - fetchSpy, - mockResponse, - }); - - const postBody = { - method: 'testmethod', - params: [1], - }; - - const client = createQuickNodeClient(networks.testnet); - const result = await client.submitJsonRPCRequest({ - request: postBody, - responseStruct: any(), - }); - - expect(fetchSpy).toHaveBeenCalledWith(client.baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(postBody), - }); - - expect(result).toBe(mockResponse); - }); - - it('throws `API response error` error if the http status is not 200', async () => { - const { fetchSpy } = createMockFetch(); - - mockErrorResponse({ - fetchSpy, - status: 500, - errorResp: { - error: 'api error', - }, - }); - - const postBody = { - method: 'testmethod', - params: [1], - }; - - const client = createQuickNodeClient(networks.testnet); - - await expect( - client.submitJsonRPCRequest({ - request: postBody, - responseStruct: any(), - }), - ).rejects.toThrow( - // The error message will be JSON stringified, hence the quotes here. - 'API response error: "api error"', - ); - }); - - it('throws `API response error: response body can not be deserialised.` error if the response body can not be deserialised', async () => { - const { fetchSpy } = createMockFetch(); - - fetchSpy.mockResolvedValueOnce({ - ok: false, - status: 500, - json: jest.fn().mockRejectedValue(false), - }); - - const postBody = { - method: 'testmethod', - params: [1], - }; - - const client = createQuickNodeClient(networks.testnet); - - await expect( - client.submitJsonRPCRequest({ - request: postBody, - responseStruct: any(), - }), - ).rejects.toThrow( - 'API response error: response body can not be deserialised.', - ); - }); - }); - - describe('baseUrl', () => { - it('returns the testnet api endpoint', () => { - const client = createQuickNodeClient(networks.testnet); - - expect(client.baseUrl).toStrictEqual(testnetEndpoint); - }); - - it('returns the mainnet api endpoint', () => { - const client = createQuickNodeClient(networks.bitcoin); - - expect(client.baseUrl).toStrictEqual(mainnetEndpoint); - }); - - it('throws `Invalid network` error if the given network is not supported', () => { - const client = createQuickNodeClient(networks.regtest); - - expect(() => client.baseUrl).toThrow('Invalid network'); - }); - }); - - describe('getBalances', () => { - it('returns balances', async () => { - const { fetchSpy } = createMockFetch(); - const network = networks.testnet; - const addresses = await createAccountAddresses(network, 5); - - const expectedResult = {}; - for (const address of addresses) { - const mockResponse = generateQuickNodeGetBalanceResp(address); - - expectedResult[address] = parseInt(mockResponse.result.balance, 10); - - mockApiSuccessResponse({ - fetchSpy, - mockResponse, - }); - } - - const client = createQuickNodeClient(network); - const result = await client.getBalances(addresses); - - expect(result).toStrictEqual(expectedResult); - }); - - // This case should never happen, but to ensure the test is 100% covered, hence we mock the processBatch to not process any request - it('assigns 0 balance to the address if it cannot be found in the hashmap', async () => { - const network = networks.testnet; - const addresses = await createAccountAddresses(network, 5); - - jest.spyOn(asyncUtils, 'processBatch').mockReturnThis(); - - const client = createQuickNodeClient(network); - const result = await client.getBalances(addresses); - - const expectedEmptyBalances = addresses.reduce((acc, address) => { - acc[address] = 0; - return acc; - }, {}); - - expect(result).toStrictEqual(expectedEmptyBalances); - }); - - it('throws DataClientError if the api response is invalid', async () => { - const { fetchSpy } = createMockFetch(); - const network = networks.testnet; - const addresses = await createAccountAddresses(network, 5); - - mockErrorResponse({ - fetchSpy, - }); - - const client = createQuickNodeClient(network); - - await expect(client.getBalances(addresses)).rejects.toThrow( - DataClientError, - ); - }); - }); - - describe('getFeeRates', () => { - const mockEstimateFeeRate = ({ - fetchSpy, - mempoolminfee = 0.0001, - minrelaytxfee = 0.0001, - smartFee, - estimateFeeErrors = [], - }: { - fetchSpy: jest.SpyInstance; - mempoolminfee?: number; - minrelaytxfee?: number; - smartFee?: number; - estimateFeeErrors?: string[]; - }) => { - const mockMempoolInfoResponse = generateQuickNodeMempoolResp({ - mempoolminfee, - minrelaytxfee, - }); - const mockEstimateFeeResponse = generateQuickNodeEstimatefeeResp({ - feerate: smartFee, - }); - // Mock Mempool Info Response - mockApiSuccessResponse({ - fetchSpy, - mockResponse: mockMempoolInfoResponse, - }); - - let estimateFeeResponse: QuickNodeEstimateFeeResponse = - mockEstimateFeeResponse; - if (estimateFeeErrors && estimateFeeErrors.length > 0) { - estimateFeeResponse = { - ...mockEstimateFeeResponse, - result: { - ...mockEstimateFeeResponse.result, - errors: estimateFeeErrors, - }, - }; - } - - // Mock Estimate Fee Response - mockApiSuccessResponse({ - fetchSpy, - mockResponse: estimateFeeResponse, - }); - }; - - it('returns fee rates', async () => { - const { fetchSpy } = createMockFetch(); - const expectedFeeRateKvb = 0.0002; - mockEstimateFeeRate({ - fetchSpy, - smartFee: expectedFeeRateKvb, - }); - - const client = createQuickNodeClient(networks.testnet); - const result = await client.getFeeRates(); - - expect(result).toStrictEqual({ - [Config.defaultFeeRate]: Number( - satsKvbToVb(btcToSats(expectedFeeRateKvb.toString())), - ), - }); - }); - - it('does not throw any error if the fee rate is unavailable', async () => { - const { fetchSpy } = createMockFetch(); - const mempoolminfee = 0.0001; - const minrelaytxfee = 0.0001; - - mockEstimateFeeRate({ - fetchSpy, - smartFee: undefined, - minrelaytxfee, - mempoolminfee, - estimateFeeErrors: [NoFeeRateError], - }); - - const client = createQuickNodeClient(networks.testnet); - const target = client.getPriorityMap()[Config.defaultFeeRate]; - const result = await client.getFeeRates(); - - expect(logger.warn).toHaveBeenCalledWith( - `The feerate is unavailable on target block ${target}, use mempool data 'mempoolminfee' instead`, - ); - expect(result).toStrictEqual({ - [Config.defaultFeeRate]: Number( - satsKvbToVb(btcToSats(minrelaytxfee.toString())), - ), - }); - }); - - it('throws an error if the fee rate is unavailable and the api response is an unexpected error', async () => { - const { fetchSpy } = createMockFetch(); - mockEstimateFeeRate({ - fetchSpy, - smartFee: undefined, - estimateFeeErrors: ['Unexpected error'], - }); - - const client = createQuickNodeClient(networks.testnet); - - await expect(client.getFeeRates()).rejects.toThrow(DataClientError); - }); - - it('throws DataClientError if the api response is invalid', async () => { - const { fetchSpy } = createMockFetch(); - - mockErrorResponse({ - fetchSpy, - }); - - const client = createQuickNodeClient(networks.testnet); - - await expect(client.getFeeRates()).rejects.toThrow(DataClientError); - }); - }); - - describe('getUtxos', () => { - it('returns utxos', async () => { - const { fetchSpy } = createMockFetch(); - const network = networks.testnet; - const addresses = await createAccountAddresses(network, 5); - let expectedResult: Utxo[] = []; - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for (const _ of addresses) { - const mockResponse = generateQuickNodeGetUtxosResp({ - utxosCount: 10, - }); - - expectedResult = expectedResult.concat( - mockResponse.result.map((utxo) => ({ - block: utxo.height, - txHash: utxo.txid, - index: utxo.vout, - value: parseInt(utxo.value, 10), - })), - ); - - mockApiSuccessResponse({ - fetchSpy, - mockResponse, - }); - } - - const client = createQuickNodeClient(network); - const result = await client.getUtxos(addresses); - - expect(result).toStrictEqual(expectedResult); - expect(fetchSpy).toHaveBeenCalledTimes(addresses.length); - }); - - it('throws DataClientError if the api response is invalid', async () => { - const { fetchSpy } = createMockFetch(); - const network = networks.testnet; - const addresses = await createAccountAddresses(network, 1); - - mockErrorResponse({ - fetchSpy, - }); - - const client = createQuickNodeClient(network); - - await expect(client.getUtxos(addresses)).rejects.toThrow(DataClientError); - }); - }); - - describe('getTransactionStatus', () => { - const txid = - '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; - - it.each([ - Config.defaultConfirmationThreshold, - Config.defaultConfirmationThreshold + 1, - ])( - "returns `confirmed` if the transaction's confirmation number is $s", - async (confirmations: number) => { - const { fetchSpy } = createMockFetch(); - - const mockResponse = generateQuickNodeGetRawTransactionResp({ - txid, - confirmations, - }); - - mockApiSuccessResponse({ - fetchSpy, - mockResponse, - }); - - const client = createQuickNodeClient(networks.testnet); - const result = await client.getTransactionStatus(txid); - - expect(result).toStrictEqual({ - status: TransactionStatus.Confirmed, - }); - }, - ); - - it(`returns 'pending' if the transaction's confirmation number is < ${Config.defaultConfirmationThreshold}`, async () => { - const { fetchSpy } = createMockFetch(); - - const mockResponse = generateQuickNodeGetRawTransactionResp({ - txid, - confirmations: Config.defaultConfirmationThreshold - 1, - }); - - mockApiSuccessResponse({ - fetchSpy, - mockResponse, - }); - - const client = createQuickNodeClient(networks.testnet); - const result = await client.getTransactionStatus(txid); - - expect(result).toStrictEqual({ - status: TransactionStatus.Pending, - }); - }); - - it(`returns 'pending' if the transaction's confirmation number is undefined`, async () => { - const { fetchSpy } = createMockFetch(); - - const mockResponse = generateQuickNodeGetRawTransactionResp({ - txid, - confirmations: undefined, - }); - - mockApiSuccessResponse({ - fetchSpy, - mockResponse, - }); - - const client = createQuickNodeClient(networks.testnet); - const result = await client.getTransactionStatus(txid); - - expect(result).toStrictEqual({ - status: TransactionStatus.Pending, - }); - }); - - it('throws DataClientError if the api response is invalid', async () => { - const { fetchSpy } = createMockFetch(); - - mockErrorResponse({ - fetchSpy, - }); - - const client = createQuickNodeClient(networks.testnet); - - await expect(client.getTransactionStatus(txid)).rejects.toThrow( - DataClientError, - ); - }); - }); - - describe('sendTransaction', () => { - const signedTransaction = - '02000000000101ec81faa8b57add4c8fb3958dd8f04667f5cd829a7b94199f4400be9e52cda0760000000000ffffffff015802000000000000160014f80b562cbcbbfc97727043484c06cc5579963e8402473044022011ec3f7ea7a7cac7cb891a1ea498d94ca3cd082339b9b2620ba5421ca7cbdf3d022062f34411d6aa5335c2bd7ff4c940adb962e9509133b86a2d97996552fd811f2c012102ceea82614fdb14871ef881498c55c5dbdc24b4633d29b42040dd18b4285540f500000000'; - - it('broadcasts a transaction', async () => { - const { fetchSpy } = createMockFetch(); - const mockResponse = generateQuickNodeSendRawTransactionResp(); - const expectedResult = mockResponse.result; - - mockApiSuccessResponse({ - fetchSpy, - mockResponse, - }); - - const client = createQuickNodeClient(networks.testnet); - const result = await client.sendTransaction(signedTransaction); - - expect(result).toStrictEqual(expectedResult); - }); - - it('throws DataClientError if the api response is invalid', async () => { - const { fetchSpy } = createMockFetch(); - - mockErrorResponse({ - fetchSpy, - }); - - const client = createQuickNodeClient(networks.testnet); - - await expect(client.sendTransaction(signedTransaction)).rejects.toThrow( - DataClientError, - ); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts deleted file mode 100644 index 1bb1d1f1..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { BtcP2wpkhAddressStruct } from '@metamask/keyring-api'; -import type { Json } from '@metamask/snaps-sdk'; -import { networks } from 'bitcoinjs-lib'; -import type { Struct } from 'superstruct'; -import { array, assert } from 'superstruct'; - -import { Config } from '../../../config'; -import { - btcToSats, - getMinimumFeeRateInKvb, - logger, - processBatch, - satsKvbToVb, -} from '../../../utils'; -import type { HttpResponse } from '../api-client'; -import { ApiClient, HttpMethod } from '../api-client'; -import { FeeRate, TransactionStatus } from '../constants'; -import type { - IDataClient, - DataClientGetBalancesResp, - DataClientGetTxStatusResp, - DataClientGetUtxosResp, - DataClientSendTxResp, - DataClientGetFeeRatesResp, -} from '../data-client'; -import { DataClientError } from '../exceptions'; -import type { Utxo } from '../service'; -import type { QuickNodeGetMempoolResponse } from './quicknode.types'; -import { - type QuickNodeClientOptions, - type QuickNodeGetBalancesResponse, - type QuickNodeGetUtxosResponse, - type QuickNodeSendTransactionResponse, - type QuickNodeEstimateFeeResponse, - type QuickNodeGetTransaction, - type QuickNodeResponse, - QuickNodeGetBalancesResponseStruct, - QuickNodeGetUtxosResponseStruct, - QuickNodeSendTransactionResponseStruct, - QuickNodeGetTransactionStruct, - QuickNodeEstimateFeeResponseStruct, - QuickNodeGetMempoolStruct, -} from './quicknode.types'; - -const MAINNET_CONFIRMATION_TARGET = { - [FeeRate.Fast]: 1, - [FeeRate.Medium]: 2, - [FeeRate.Slow]: 3, -}; - -// FIXME: There's an issue right now with QuickNode for testnet. Looks like there is not enough -// data on testnet to be able to target the very first blocks. So workaround this, we just shift -// everything by 20 to be able to use their API -const TESTNET_CONFIRMATION_TARGET = { - [FeeRate.Fast]: 21, - [FeeRate.Medium]: 22, - [FeeRate.Slow]: 23, -}; - -export const NoFeeRateError = 'Insufficient data or no feerate found'; - -export class QuickNodeClient extends ApiClient implements IDataClient { - apiClientName = 'QuickNodeClient'; - - protected readonly _options: QuickNodeClientOptions; - - protected readonly _priorityMap: Record; - - constructor(options: QuickNodeClientOptions) { - super(); - const isMainnet = options.network === networks.bitcoin; - - this._options = options; - this._priorityMap = isMainnet - ? MAINNET_CONFIRMATION_TARGET - : TESTNET_CONFIRMATION_TARGET; - } - - get baseUrl(): string { - switch (this._options.network) { - case networks.bitcoin: - return this._options.mainnetEndpoint; - case networks.testnet: - return this._options.testnetEndpoint; - default: - throw new Error('Invalid network'); - } - } - - protected isErrorResponse( - response: ApiResponse, - ): boolean { - // Possible error response from QuickNode: - // - { result : null, error : "some error message" } - // - { result : null, error : { code: -8, message: "some error message" } } - // - { result : { error : "some error message" } } - // - empty - return ( - !response.result || - Object.prototype.hasOwnProperty.call(response.result, 'error') - ); - } - - protected formatError( - apiResponse: ApiResponse, - ): string { - return JSON.stringify(apiResponse.error); - } - - protected async getResponse( - response: HttpResponse, - ): Promise { - const apiResponse = await super.getResponse< - ApiResponse & QuickNodeResponse - >(response); - - // QuickNode returns 200 status code for successful requests, others are errors status code - if (response.status !== 200) { - throw new Error( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `API response error: ${this.formatError(apiResponse)}`, - ); - } - - // Safeguard to detect if the response is an error response, but they are not caught by the fetch error - if (this.isErrorResponse(apiResponse)) { - throw new Error(`Error response from quicknode`); - } - - return apiResponse; - } - - protected async submitJsonRPCRequest({ - request, - responseStruct, - }: { - request: { - method: string; - params: Json; - }; - responseStruct: Struct; - }): Promise { - return await this.submitHttpRequest({ - request: this.buildHttpRequest({ - method: HttpMethod.Post, - url: this.baseUrl, - body: request, - }), - responseStruct, - // Use the JSON-RPC method name as the requestName for underlying logging purposes - requestName: request.method, - }); - } - - async getBalances(addresses: string[]): Promise { - assert(addresses, array(BtcP2wpkhAddressStruct)); - - const addressBalanceMap = new Map(); - - await processBatch(addresses, async (address) => { - const response = - await this.submitJsonRPCRequest({ - // index 0 of the params refer to the account address, - // index 1 .details refer to the output flag: - // - 'basic' for basic address information - // - 'txids' to also include transaction IDs - // - 'txs' to include full transaction data - request: { - method: 'bb_getaddress', - params: [ - address, - { - details: 'basic', - }, - ], - }, - responseStruct: QuickNodeGetBalancesResponseStruct, - }); - - addressBalanceMap.set(address, parseInt(response.result.balance, 10)); - }); - - return addresses.reduce( - (data: DataClientGetBalancesResp, address: string) => { - // The hashmap should include the balance for each requested addresses - // but in case there are some behavior changes, we set the default balance to 0 - data[address] = addressBalanceMap.get(address) ?? 0; - return data; - }, - {}, - ); - } - - async getUtxos( - addresses: string[], - includeUnconfirmed?: boolean, - ): Promise { - assert(addresses, array(BtcP2wpkhAddressStruct)); - - const utxos: Utxo[] = []; - - await processBatch(addresses, async (address) => { - const response = - await this.submitJsonRPCRequest({ - request: { - method: 'bb_getutxos', - params: [ - address, - { - confirmed: !includeUnconfirmed, - }, - ], - }, - responseStruct: QuickNodeGetUtxosResponseStruct, - }); - - response.result.forEach((utxo) => { - utxos.push({ - block: utxo.height, - txHash: utxo.txid, - index: utxo.vout, - // `utxo.value` will be returned as sats. - // It is safe to use `number` in Bitcoin rather than `BigInt`, max sats will not exceed 2100000000000000 (which fits in 64 bit). - value: parseInt(utxo.value, 10), - }); - }); - }); - - return utxos; - } - - async getFeeRates(): Promise { - // There is no UX to allow end user to select the fee rate, - // hence we can just fetch the default fee rate. - const processItems = { - [Config.defaultFeeRate]: this._priorityMap[Config.defaultFeeRate], - }; - - const feeRates: Record = {}; - - const { - result: { mempoolminfee, minrelaytxfee }, - } = await this.getMempoolInfo(); - - // keep this batch process in case we have to switch to support multiple fee rates. - await processBatch( - Object.entries(processItems), - async ([feeRate, target]) => { - const { - result: { feerate, errors }, - } = await this.submitJsonRPCRequest({ - request: { - method: 'estimatesmartfee', - params: [target], - }, - responseStruct: QuickNodeEstimateFeeResponseStruct, - }); - - // When the feerate data is unavailable, - // the api response will look like: - // { - // "result": { - // "errors": ['Insufficient data or no feerate found'], - // "blocks": 2 - // }, - // "error": null, - // "id": null - // } - // In that case, we will use the mempool min fee instead. - if ( - Array.isArray(errors) && - errors.length === 1 && - errors[0] === NoFeeRateError - ) { - logger.warn( - `The feerate is unavailable on target block ${target}, use mempool data 'mempoolminfee' instead`, - ); - } else if (errors) { - throw new DataClientError( - `Failed to get fee rate from quicknode: ${JSON.stringify(errors)}`, - ); - } - - // A safeguard to ensure the feerate is not reject by the chain with the min requirement by mempool - const feeRateInBtcPerKvb = getMinimumFeeRateInKvb( - feerate ?? 0, - mempoolminfee, - minrelaytxfee, - ); - - // The fee rate will be returned in BTC/kvB unit (note the kilobyte here) - // e.g. 0.00005081 - // See: https://www.quicknode.com/docs/bitcoin/estimatesmartfee - // > Estimates the smart fee per **kilobyte** ... - feeRates[feeRate] = Number( - satsKvbToVb(btcToSats(feeRateInBtcPerKvb.toString())), - ); - }, - ); - - return feeRates; - } - - protected async getMempoolInfo(): Promise { - return await this.submitJsonRPCRequest({ - request: { - method: 'getmempoolinfo', - params: [], - }, - responseStruct: QuickNodeGetMempoolStruct, - }); - } - - async sendTransaction( - signedTransaction: string, - ): Promise { - const response = - await this.submitJsonRPCRequest({ - request: { - method: 'sendrawtransaction', - params: [signedTransaction], - }, - responseStruct: QuickNodeSendTransactionResponseStruct, - }); - return response.result; - } - - async getTransactionStatus(txid: string): Promise { - const response = await this.submitJsonRPCRequest({ - // index 0 of the params refer to the tx id, - // index 1 refer to the verbose flag, - // - 0: hex-encoded data - // - 1: JSON object - // - 2: JSON object with fee and prevout - request: { method: 'getrawtransaction', params: [txid, 1] }, - responseStruct: QuickNodeGetTransactionStruct, - }); - - // Bitcoin transaction is often considered secure after six confirmations - // reference: https://www.bitcoin.com/get-started/what-is-a-confirmation/#:~:text=Different%20cryptocurrencies%20require%20different%20numbers,secure%20after%20around%2030%20confirmations. - return { - status: - // If `confirmations` is not defined, then the transaction is "pending" in the memory pool. - response.result.confirmations && - response.result.confirmations >= Config.defaultConfirmationThreshold - ? TransactionStatus.Confirmed - : TransactionStatus.Pending, - }; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.types.ts deleted file mode 100644 index 5553a281..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/quicknode.types.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { Network } from 'bitcoinjs-lib'; -import type { Infer } from 'superstruct'; -import { array, boolean, number, object, optional, string } from 'superstruct'; - -export type QuickNodeClientOptions = { - network: Network; - // The endpoints will be setup via the environment variable - testnetEndpoint: string; - mainnetEndpoint: string; -}; - -export type QuickNodeError = { - error: null | { - code: string; - message: string; - }; -}; - -export type QuickNodeResponse = QuickNodeError & { - result: unknown; -}; - -export const QuickNodeGetBalancesResponseStruct = object({ - result: object({ - address: string(), - balance: string(), - totalReceived: string(), - totalSent: string(), - unconfirmedBalance: string(), - unconfirmedTxs: number(), - txs: number(), - }), -}); - -export type QuickNodeGetBalancesResponse = QuickNodeResponse & - Infer; - -export const QuickNodeGetUtxosResponseStruct = object({ - result: array( - object({ - txid: string(), - vout: number(), - value: string(), - height: number(), - confirmations: number(), - }), - ), -}); - -export type QuickNodeGetUtxosResponse = QuickNodeResponse & - Infer; - -export const QuickNodeSendTransactionResponseStruct = object({ - result: string(), -}); - -export type QuickNodeSendTransactionResponse = QuickNodeResponse & - Infer; - -export const QuickNodeEstimateFeeResponseStruct = object({ - result: object({ - blocks: number(), - // if the fee rate is not available, the field `feerate` will be omitted - feerate: optional(number()), - // if the fee rate is not available, the field `errors` will be present - // e.g errors: ["Insufficient data or no feerate found"] - errors: optional(array(string())), - }), -}); - -export type QuickNodeEstimateFeeResponse = QuickNodeResponse & - Infer; - -export const QuickNodeGetTransactionStruct = object({ - result: object({ - txid: string(), - hash: string(), - version: number(), - size: number(), - vsize: number(), - weight: number(), - locktime: number(), - // eslint-disable-next-line id-denylist - hex: string(), - // The following fields will not be set if the transaction is in the memory pool - blockhash: optional(string()), - confirmations: optional(number()), - time: optional(number()), - blocktime: optional(number()), - }), -}); - -export type QuickNodeGetTransaction = QuickNodeResponse & - Infer; - -// Reference: https://www.quicknode.com/docs/bitcoin/getmempoolinfo -export const QuickNodeGetMempoolStruct = object({ - result: object({ - loaded: boolean(), - size: number(), - bytes: number(), - usage: number(), - maxmempool: number(), - mempoolminfee: number(), - minrelaytxfee: number(), - unbroadcastcount: number(), - incrementalrelayfee: number(), - fullrbf: boolean(), - }), -}); - -export type QuickNodeGetMempoolResponse = QuickNodeResponse & - Infer; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.test.ts deleted file mode 100644 index 806dd740..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { networks } from 'bitcoinjs-lib'; -import { StructError } from 'superstruct'; - -import { generateSimpleHashWalletAssetsByAddressResp } from '../../../../test/utils'; -import type { Utxo } from '../service'; -import { - createMockFetch, - mockApiSuccessResponse, - mockErrorResponse, - createAccounts, -} from './__tests__/helper'; -import { SimpleHashClient } from './simplehash'; -import type { SimpleHashWalletAssetsByUtxoResponse } from './simplehash.types'; - -jest.mock('../../../utils/logger'); -jest.mock('../../../utils/snap'); - -describe('SimpleHashClient', () => { - class MockSimpleHashClient extends SimpleHashClient { - public outputToTxHashAndVout(output: string): [string, number] { - return super.outputToTxHashAndVout(output); - } - } - - const createSimpleHashClient = () => { - return new MockSimpleHashClient({ - apiKey: 'API-KEY', - }); - }; - - const createAccountAddresses = async (count: number) => { - const network = networks.bitcoin; - const accounts = await createAccounts(network, count); - return accounts.map((account) => account.address); - }; - - describe('filterUtxos', () => { - const mockSuccessResponse = ({ - address, - fetchSpy, - }: { - address: string; - fetchSpy: jest.SpyInstance; - }) => { - const mockResponse = generateSimpleHashWalletAssetsByAddressResp( - address, - 10, - ); - - mockApiSuccessResponse({ - fetchSpy, - mockResponse, - }); - - return mockResponse; - }; - - const extractUtxosFromApiResponse = ({ - apiResponse, - client, - }: { - apiResponse: SimpleHashWalletAssetsByUtxoResponse; - client: MockSimpleHashClient; - }) => { - return apiResponse.utxos.map((utxo) => { - const [txHash, vout] = client.outputToTxHashAndVout(utxo.output); - return { - txHash, - index: vout, - value: utxo.value, - block: utxo.block_number, - }; - }); - }; - - it('returns filtered utxos', async () => { - const fetchSpy = createMockFetch(); - const addresses = await createAccountAddresses(5); - const client = createSimpleHashClient(); - - let expectedUtxos: Utxo[] = []; - for (const address of addresses) { - const mockResponse = mockSuccessResponse({ - address, - fetchSpy, - }); - expectedUtxos = expectedUtxos.concat( - extractUtxosFromApiResponse({ apiResponse: mockResponse, client }), - ); - } - - const result = await client.filterUtxos(addresses, []); - - expect(result).toStrictEqual(expectedUtxos); - expect(fetchSpy).toHaveBeenCalledTimes(addresses.length); - }); - - it('deduplicates the query addresses to prevent duplicated UTXOs being returned', async () => { - const fetchSpy = createMockFetch(); - const [address] = await createAccountAddresses(1); - const client = createSimpleHashClient(); - - const mockResponse = mockSuccessResponse({ - address, - fetchSpy, - }); - const expectedUtxos = extractUtxosFromApiResponse({ - apiResponse: mockResponse, - client, - }); - - // Having duplicated addresses to test if the client deduplicates the addresses - const addresses = [address, address, address]; - const result = await client.filterUtxos(addresses, []); - - expect(result).toStrictEqual(expectedUtxos); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - - it('throws superstruct error if any of the given addresses is not a valid bitcoin address', async () => { - const addresses = await createAccountAddresses(1); - const client = createSimpleHashClient(); - - await expect( - client.filterUtxos([...addresses, 'invalid address'], []), - ).rejects.toThrow(StructError); - }); - - it('throws `API response error` if the http status is not 200', async () => { - const fetchSpy = createMockFetch(); - const addresses = await createAccountAddresses(1); - - mockErrorResponse({ - fetchSpy, - status: 500, - }); - - const client = createSimpleHashClient(); - - await expect(client.filterUtxos(addresses, [])).rejects.toThrow( - `API response error`, - ); - }); - - it('throws `Unexpected response from API client` if the API response is unexpected', async () => { - const fetchSpy = createMockFetch(); - const addresses = await createAccountAddresses(1); - - mockApiSuccessResponse({ - fetchSpy, - mockResponse: { - invalidResponse: 'response', - }, - }); - - const client = createSimpleHashClient(); - - await expect(client.filterUtxos(addresses, [])).rejects.toThrow( - `Unexpected response from API client`, - ); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.ts deleted file mode 100644 index 553d1648..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { BtcP2wpkhAddressStruct } from '@metamask/keyring-api'; -import { array, assert, type Struct } from 'superstruct'; - -import { processBatch } from '../../../utils'; -import type { HttpHeaders, HttpResponse } from '../api-client'; -import { ApiClient, HttpMethod } from '../api-client'; -import type { ISatsProtectionDataClient } from '../data-client'; -import type { Utxo } from '../service'; -import type { - SimpleHashClientOptions, - SimpleHashWalletAssetsByUtxoResponse, -} from './simplehash.types'; -import { SimpleHashWalletAssetsByUtxoResponseStruct } from './simplehash.types'; - -export class SimpleHashClient - extends ApiClient - implements ISatsProtectionDataClient -{ - readonly apiClientName = 'SimpleHashClient'; - - // Simplehash API does not support testnet, only mainnet is supported. - // reference: https://docs.simplehash.com/reference/supported-chains-testnets - readonly baseUrl = `https://api.simplehash.com/api/v0`; - - protected readonly _options: SimpleHashClientOptions; - - constructor(options: SimpleHashClientOptions) { - super(); - this._options = options; - } - - protected getApiUrl(endpoint: `/${string}`): string { - const url = new URL(`${this.baseUrl}${endpoint}`); - return url.toString(); - } - - protected getHttpHeaders(): HttpHeaders { - return { - 'X-API-KEY': this._options.apiKey, - }; - } - - protected async getResponse( - response: HttpResponse, - ): Promise { - // For successful requests, Simplehash will return a 200 status code. - // Any other status code should be considered an error. - if (response.status !== 200) { - throw new Error(`API response error`); - } - - return await super.getResponse(response); - } - - protected async submitGetApiRequest({ - endpoint, - responseStruct, - requestName, - }: { - endpoint: `/${string}`; - responseStruct: Struct; - requestName: string; - }): Promise { - return await super.submitHttpRequest({ - request: this.buildHttpRequest({ - method: HttpMethod.Get, - url: this.getApiUrl(endpoint), - headers: this.getHttpHeaders(), - }), - responseStruct, - requestName, - }); - } - - // An output is the combination of the transaction hash and the vout, serving as a unique identifier for an UTXO - // e.g 123456789558bd40a14d1cc2f42f5e0476a34ab8589bdc84f65b4eb305b9b925:0 - // Transaction hash is the first part before the colon, and the index/vout is the second part after the colon. - protected outputToTxHashAndVout(output: string): [string, number] { - const [txHash, vout] = output.split(':'); - return [txHash, parseInt(vout, 10)]; - } - - // The API returns UTXOs that does not contain Inscriptions, Rare Sats, and Runes, - // which eliminates the need for UTXO filtering. - // As a result, the argument _utxos will be disregarded, and the UTXOs can be directly returned from this API. - async filterUtxos(addresses: string[], _utxos: Utxo[]): Promise { - // A safeguard to deduplicate the addresses and prevent duplicated UTXOs returned by the API. - const uniqueAddresses = Array.from(new Set(addresses)); - assert(uniqueAddresses, array(BtcP2wpkhAddressStruct)); - - const utxos: Utxo[] = []; - - await processBatch(uniqueAddresses, async (address: string) => { - // API reference: https://docs.simplehash.com/reference/bitcoin_assets_grouped_by_utxo - const result = - await this.submitGetApiRequest({ - endpoint: `/custom/wallet_assets_by_utxo/${address}?without_inscriptions_runes_raresats=1`, - responseStruct: SimpleHashWalletAssetsByUtxoResponseStruct, - requestName: 'wallet_assets_by_utxo', - }); - - for (const utxo of result.utxos) { - const [txHash, vout] = this.outputToTxHashAndVout(utxo.output); - // The UTXO will not be duplicated, - // therefore we are safe to store the UTXO into an array. - utxos.push({ - txHash, - index: vout, - value: utxo.value, - block: utxo.block_number, - }); - } - }); - - return utxos; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.types.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.types.ts deleted file mode 100644 index 2714eeda..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/clients/simplehash.types.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Infer } from 'superstruct'; -import { array, number, object, string } from 'superstruct'; - -export type SimpleHashClientOptions = { - apiKey: string; -}; - -export const SimpleHashWalletAssetsByUtxoResponseStruct = object({ - count: number(), - utxos: array( - object({ - output: string(), - value: number(), - // eslint-disable-next-line @typescript-eslint/naming-convention - block_number: number(), - }), - ), -}); - -export type SimpleHashWalletAssetsByUtxoResponse = Infer< - typeof SimpleHashWalletAssetsByUtxoResponseStruct ->; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts deleted file mode 100644 index b07e9f71..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -export enum FeeRate { - Fast = 'fast', - Medium = 'medium', - Slow = 'slow', -} - -export enum TransactionStatus { - Confirmed = 'confirmed', - Pending = 'pending', - Failed = 'failed', -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts deleted file mode 100644 index 1beb2e9a..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/data-client.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { FeeRate } from './constants'; -import type { TransactionStatusData, Utxo } from './service'; - -export type DataClientGetBalancesResp = Record; - -export type DataClientGetUtxosResp = Utxo[]; - -export type DataClientGetFeeRatesResp = { - [key in FeeRate]?: number; -}; - -export type DataClientGetTxStatusResp = TransactionStatusData; - -export type DataClientSendTxResp = string; - -/** - * This interface defines the methods available on a data client for interacting with the Bitcoin blockchain. - */ -export type IDataClient = { - /** - * Gets the balances for a set of Bitcoin addresses. - * - * @param {string[]} addresses - An array of Bitcoin addresses to query. - * @returns {Promise} A promise that resolves to a record of addresses and their corresponding balances. - */ - getBalances(addresses: string[]): Promise; - - /** - * Gets the UTXOs for a Bitcoin address. - * - * @param {string[]} addresses - An array of Bitcoin addresses to query. - * @param {boolean} [includeUnconfirmed] - Whether or not to include unconfirmed UTXOs in the response. Defaults to false. - * @returns {Promise} A promise that resolves to an array of UTXOs. - */ - getUtxos( - addresses: string[], - includeUnconfirmed?: boolean, - ): Promise; - - /** - * Gets the fee rates for the Bitcoin network. - * - * @returns {Promise} A promise that resolves to an object containing fee rates for different ratios. - */ - getFeeRates(): Promise; - - /** - * Gets the status of a transaction given its hash. - * - * @param {string} txHash - The hash of the transaction to query. - * @returns {Promise} A promise that resolves to the transaction status data. - */ - getTransactionStatus(txHash: string): Promise; - - /** - * Sends a transaction to the Bitcoin network. - * - * @param {string} tx - The transaction to send. - * @returns {Promise} A promise that resolves to the transaction hash. - */ - sendTransaction(tx: string): Promise; -}; - -/** - * This interface defines the methods available on a data client for Sats Protection. - */ -export type ISatsProtectionDataClient = { - /** - * Filters UTXOs that contain Inscriptions, Runes or Rare Sats. - * - * @param {string[]} address - An array of Bitcoin addresses to query. - * @param {Utxo[]} utxos - An array of UTXOs to filter. - * @returns {Promise} A promise that resolves to the filtered UTXOs. - */ - filterUtxos(address: string[], utxos: Utxo[]): Promise; -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts deleted file mode 100644 index 54affb11..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/exceptions.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { CustomError } from '../../utils'; - -export class DataClientError extends CustomError {} - -export class BtcOnChainServiceError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts deleted file mode 100644 index 651c3bdb..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './exceptions'; -export * from './service'; -export * from './constants'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts deleted file mode 100644 index 4750b342..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.test.ts +++ /dev/null @@ -1,566 +0,0 @@ -import type { Network } from 'bitcoinjs-lib'; -import { networks } from 'bitcoinjs-lib'; - -import { - generateAccounts, - generateFormattedUtxos, - generateQuickNodeSendRawTransactionResp, -} from '../../../test/utils'; -import { - CacheStateManager, - SerializableFees, - CachedValue, -} from '../../cacheManager'; -import { Caip19Asset } from '../../constants'; -import { getCaip2ChainId } from '../wallet'; -import { FeeRate, TransactionStatus } from './constants'; -import type { IDataClient, ISatsProtectionDataClient } from './data-client'; -import { BtcOnChainServiceError } from './exceptions'; -import { BtcOnChainService } from './service'; - -jest.mock('../../utils/logger'); - -describe('BtcOnChainService', () => { - const createMockDataClient = () => { - const getBalancesSpy = jest.fn(); - const getUtxosSpy = jest.fn(); - const getFeeRatesSpy = jest.fn(); - const getTransactionStatusSpy = jest.fn(); - const sendTransactionSpy = jest.fn(); - const filterUtxosSpy = jest.fn(); - class MockReadDataClient implements IDataClient { - getBalances = getBalancesSpy; - - getUtxos = getUtxosSpy; - - getFeeRates = getFeeRatesSpy; - - getTransactionStatus = getTransactionStatusSpy; - - sendTransaction = sendTransactionSpy; - } - - class MockSatsProtectionClient implements ISatsProtectionDataClient { - filterUtxos = filterUtxosSpy; - } - - return { - dataClient: new MockReadDataClient(), - satsProtectionClient: new MockSatsProtectionClient(), - filterUtxosSpy, - getBalancesSpy, - getUtxosSpy, - getFeeRatesSpy, - getTransactionStatusSpy, - sendTransactionSpy, - }; - }; - - const createMockCacheStateManager = () => { - const cachedStateManager = new CacheStateManager(); - const getFeeRateSpy = jest.spyOn(cachedStateManager, 'getFeeRate'); - const setFeeRateSpy = jest - .spyOn(cachedStateManager, 'setFeeRate') - .mockResolvedValue(); - return { - instance: cachedStateManager, - getFeeRateSpy, - setFeeRateSpy, - }; - }; - - const createMockBtcService = ( - dataClient?: IDataClient, - satsProtectionClient?: ISatsProtectionDataClient, - cacheStateManager?: CacheStateManager, - network: Network = networks.testnet, - ) => { - const { - dataClient: _dataClient, - satsProtectionClient: _satsProtectionDataClient, - } = createMockDataClient(); - - const { instance: _cacheStateManager } = createMockCacheStateManager(); - - class MockBtcOnChainService extends BtcOnChainService { - isSatsProtectionEnabled() { - return super.isSatsProtectionEnabled(); - } - } - - const isSatsProtectionEnabledSpy = jest.spyOn( - MockBtcOnChainService.prototype, - 'isSatsProtectionEnabled', - ); - - const service = new MockBtcOnChainService( - { - dataClient: dataClient ?? _dataClient, - satsProtectionDataClient: - satsProtectionClient ?? _satsProtectionDataClient, - cacheStateManager: cacheStateManager ?? _cacheStateManager, - }, - { - network, - }, - ); - - return { - isSatsProtectionEnabledSpy, - service, - }; - }; - - describe('getBalances', () => { - const prepareGetBalances = ( - network: Network = networks.testnet, - isSatsProtectionEnabled = false, - ) => { - const { - dataClient, - satsProtectionClient, - getBalancesSpy, - filterUtxosSpy, - } = createMockDataClient(); - - const { instance: cacheStateManager } = createMockCacheStateManager(); - - const { service, isSatsProtectionEnabledSpy } = createMockBtcService( - dataClient, - satsProtectionClient, - cacheStateManager, - network, - ); - - const accounts = generateAccounts(10); - const addresses = accounts.map((account) => account.address); - let totalBalanceFromGetBalances = BigInt(0); - let totalBalanceFromUtxos = BigInt(0); - - isSatsProtectionEnabledSpy.mockReturnValue(isSatsProtectionEnabled); - - getBalancesSpy.mockResolvedValue( - addresses.reduce((acc, address) => { - totalBalanceFromGetBalances += BigInt(1000); - acc[address] = BigInt(1000); - return acc; - }, {}), - ); - - // As we are not returning the UTXOs per address, - // we only need to simulate the UTXOs of the first address - const utxos = generateFormattedUtxos(addresses[0], 2, 500, 500); - filterUtxosSpy.mockResolvedValue(utxos); - totalBalanceFromUtxos = utxos.reduce( - (acc, utxo) => acc + BigInt(utxo.value), - BigInt(0), - ); - - return { - service, - addresses, - getBalancesSpy, - filterUtxosSpy, - totalBalanceFromGetBalances, - totalBalanceFromUtxos, - }; - }; - - it('returns total balances if Sats Protection is disabled', async () => { - const { - service, - addresses, - getBalancesSpy, - filterUtxosSpy, - totalBalanceFromGetBalances, - } = prepareGetBalances(networks.testnet, false); - - const result = await service.getBalances(addresses, [Caip19Asset.TBtc]); - - expect(getBalancesSpy).toHaveBeenCalledWith(addresses); - expect(filterUtxosSpy).not.toHaveBeenCalled(); - - expect(result).toStrictEqual({ - balances: { - [Caip19Asset.TBtc]: { - amount: totalBalanceFromGetBalances, - }, - }, - }); - }); - - it('returns sats protected balances if Sats Protection is enabled', async () => { - const { - service, - addresses, - getBalancesSpy, - filterUtxosSpy, - totalBalanceFromUtxos, - } = prepareGetBalances(networks.bitcoin, true); - - const result = await service.getBalances(addresses, [Caip19Asset.Btc]); - - expect(filterUtxosSpy).toHaveBeenCalledWith(addresses, []); - expect(getBalancesSpy).not.toHaveBeenCalled(); - - expect(result).toStrictEqual({ - balances: { - [Caip19Asset.Btc]: { - amount: totalBalanceFromUtxos, - }, - }, - }); - }); - - it('throws `Only one asset is supported` error if the given asset more than 1', async () => { - const { service, addresses } = prepareGetBalances(); - - await expect( - service.getBalances(addresses, [Caip19Asset.TBtc, Caip19Asset.Btc]), - ).rejects.toThrow('Only one asset is supported'); - }); - - it.each([ - { - assetName: 'BTC', - asset: Caip19Asset.Btc, - network: networks.testnet, - networkName: 'testnet', - }, - { - assetName: 'TBTC', - asset: Caip19Asset.TBtc, - network: networks.bitcoin, - networkName: 'mainnet', - }, - ])( - 'throws `Invalid asset` error if the asset is $assetName and current network is $networkName', - async ({ asset, network }) => { - const { service, addresses } = prepareGetBalances(network); - - await expect(service.getBalances(addresses, [asset])).rejects.toThrow( - 'Invalid asset', - ); - }, - ); - }); - - describe('getUtxos', () => { - const prepareGetUtxos = ( - network: Network = networks.testnet, - isSatsProtectionEnabled = false, - ) => { - const { dataClient, satsProtectionClient, getUtxosSpy, filterUtxosSpy } = - createMockDataClient(); - - const { instance: cacheStateManager } = createMockCacheStateManager(); - - const { service, isSatsProtectionEnabledSpy } = createMockBtcService( - dataClient, - satsProtectionClient, - cacheStateManager, - network, - ); - - const accounts = generateAccounts(2); - const addresses = accounts.map((account) => account.address); - isSatsProtectionEnabledSpy.mockReturnValue(isSatsProtectionEnabled); - - // As we are not returning the UTXOs per address, - // we only need to simulate the UTXOs of the first address - const utxos = generateFormattedUtxos(addresses[0], 10); - - getUtxosSpy.mockResolvedValue(utxos); - filterUtxosSpy.mockResolvedValue(utxos); - - return { - service, - addresses, - getUtxosSpy, - filterUtxosSpy, - utxos, - }; - }; - - it('returns all UTXOs if Sats Protection is disabled', async () => { - const { service, addresses, getUtxosSpy, filterUtxosSpy, utxos } = - prepareGetUtxos(networks.testnet, false); - - const result = await service.getDataForTransaction(addresses); - - expect(getUtxosSpy).toHaveBeenCalledWith(addresses); - expect(filterUtxosSpy).not.toHaveBeenCalled(); - expect(result).toStrictEqual({ - data: { - utxos, - }, - }); - }); - - it('returns UTXOs that does not contain Inscriptions, Rare Sats, and Runes if Sats Protection is enabled', async () => { - const { service, addresses, getUtxosSpy, filterUtxosSpy, utxos } = - prepareGetUtxos(networks.bitcoin, true); - - const result = await service.getDataForTransaction(addresses); - - // If Sats Protection is enabled, we are calling `filterUtxos` instead - // of `getUtxos`. - expect(filterUtxosSpy).toHaveBeenCalledWith(addresses, []); - expect(getUtxosSpy).not.toHaveBeenCalled(); - expect(result).toStrictEqual({ - data: { - utxos, - }, - }); - }); - - it('throws error if readClient fail', async () => { - const { service, addresses, getUtxosSpy } = prepareGetUtxos(); - - getUtxosSpy.mockRejectedValue(new Error('error')); - - await expect(service.getDataForTransaction(addresses)).rejects.toThrow( - BtcOnChainServiceError, - ); - }); - }); - - describe('getFeeRates', () => { - it('return getFeeRates result', async () => { - const { dataClient, getFeeRatesSpy } = createMockDataClient(); - const { service } = createMockBtcService(dataClient); - getFeeRatesSpy.mockResolvedValue({ - [FeeRate.Fast]: 1, - [FeeRate.Medium]: 2, - }); - - const result = await service.getFeeRates(); - - expect(getFeeRatesSpy).toHaveBeenCalledTimes(1); - expect(result).toStrictEqual({ - fees: [ - { - type: FeeRate.Fast, - rate: BigInt(1), - }, - { - type: FeeRate.Medium, - rate: BigInt(2), - }, - ], - }); - }); - - it('throws BtcOnChainServiceError error if another error was thrown', async () => { - const { dataClient, getFeeRatesSpy } = createMockDataClient(); - const { service } = createMockBtcService(dataClient); - - getFeeRatesSpy.mockRejectedValue(new Error('error')); - - await expect(service.getFeeRates()).rejects.toThrow( - BtcOnChainServiceError, - ); - }); - - describe('feeRateCache', () => { - it('returns the cached value if available', async () => { - const { dataClient } = createMockDataClient(); - const { instance: cacheStateManager, getFeeRateSpy } = - createMockCacheStateManager(); - const { service } = createMockBtcService( - dataClient, - undefined, - cacheStateManager, - ); - - const cachedFees = new CachedValue( - new SerializableFees( - { - fees: [ - { - type: FeeRate.Fast, - rate: BigInt(1), - }, - { - type: FeeRate.Medium, - rate: BigInt(2), - }, - ], - }, - 10 * 1000, // expires in 10 seconds - ), - ); - getFeeRateSpy.mockResolvedValue(cachedFees); - - const result = await service.getFeeRates(); - - expect(getFeeRateSpy).toHaveBeenCalledTimes(1); - expect(result).toStrictEqual(cachedFees.value.valueOf()); - }); - - it('fetches new fee rates if cache is expired', async () => { - const { dataClient, getFeeRatesSpy } = createMockDataClient(); - const { - instance: cacheStateManager, - getFeeRateSpy, - setFeeRateSpy, - } = createMockCacheStateManager(); - const { service } = createMockBtcService( - dataClient, - undefined, - cacheStateManager, - ); - - const expiredFees = { - fees: [ - { - type: FeeRate.Fast, - rate: BigInt(123), - }, - { - type: FeeRate.Medium, - rate: BigInt(456), - }, - ], - }; - - getFeeRateSpy.mockResolvedValue({ - value: expiredFees, - expiration: Date.now() - 10 * 1000, // Expired 10 seconds ago. - isExpired: () => true, - } as unknown as CachedValue); - - const newFees = { - fees: [ - { - type: FeeRate.Fast, - rate: BigInt(1), - }, - { - type: FeeRate.Medium, - rate: BigInt(2), - }, - ], - }; - - getFeeRatesSpy.mockResolvedValue({ - [FeeRate.Fast]: 1, - [FeeRate.Medium]: 2, - }); - - const result = await service.getFeeRates(); - - expect(getFeeRateSpy).toHaveBeenCalledTimes(1); - expect(setFeeRateSpy).toHaveBeenCalledWith( - getCaip2ChainId(service.network), - newFees, - ); - expect(result).toStrictEqual(newFees); - }); - - it('fetches new fee rates if cache is not available', async () => { - const { dataClient, getFeeRatesSpy } = createMockDataClient(); - const { - instance: cacheStateManager, - getFeeRateSpy, - setFeeRateSpy, - } = createMockCacheStateManager(); - const { service } = createMockBtcService( - dataClient, - undefined, - cacheStateManager, - ); - - getFeeRateSpy.mockResolvedValue(null); - - const newFees = { - fees: [ - { - type: FeeRate.Fast, - rate: BigInt(1), - }, - { - type: FeeRate.Medium, - rate: BigInt(2), - }, - ], - }; - - getFeeRatesSpy.mockResolvedValue({ - [FeeRate.Fast]: 1, - [FeeRate.Medium]: 2, - }); - - const result = await service.getFeeRates(); - - expect(getFeeRateSpy).toHaveBeenCalledTimes(1); - expect(setFeeRateSpy).toHaveBeenCalledWith( - getCaip2ChainId(service.network), - newFees, - ); - expect(result).toStrictEqual(newFees); - }); - }); - }); - - describe('broadcastTransaction', () => { - const signedTransaction = - '02000000000101ec81faa8b57add4c8fb3958dd8f04667f5cd829a7b94199f4400be9e52cda0760000000000ffffffff015802000000000000160014f80b562cbcbbfc97727043484c06cc5579963e8402473044022011ec3f7ea7a7cac7cb891a1ea498d94ca3cd082339b9b2620ba5421ca7cbdf3d022062f34411d6aa5335c2bd7ff4c940adb962e9509133b86a2d97996552fd811f2c012102ceea82614fdb14871ef881498c55c5dbdc24b4633d29b42040dd18b4285540f500000000'; - - it('calls sendTransaction with writeClient', async () => { - const { dataClient, sendTransactionSpy } = createMockDataClient(); - const { service } = createMockBtcService(dataClient); - - const resp = generateQuickNodeSendRawTransactionResp(); - sendTransactionSpy.mockResolvedValue(resp.result); - - const result = await service.broadcastTransaction(signedTransaction); - - expect(sendTransactionSpy).toHaveBeenCalledWith(signedTransaction); - expect(result).toStrictEqual({ - transactionId: resp.result, - }); - }); - - it('throws BtcOnChainServiceErrorr if write client execute fail', async () => { - const { dataClient, sendTransactionSpy } = createMockDataClient(); - const { service } = createMockBtcService(dataClient); - sendTransactionSpy.mockRejectedValue(new Error('error')); - - await expect( - service.broadcastTransaction(signedTransaction), - ).rejects.toThrow(BtcOnChainServiceError); - }); - }); - - describe('getTransactionStatus', () => { - const txHash = - '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; - - it('return getTransactionStatus result', async () => { - const { dataClient, getTransactionStatusSpy } = createMockDataClient(); - const { service } = createMockBtcService(dataClient); - getTransactionStatusSpy.mockResolvedValue({ - status: TransactionStatus.Confirmed, - }); - - const result = await service.getTransactionStatus(txHash); - - expect(getTransactionStatusSpy).toHaveBeenCalledWith(txHash); - expect(result).toStrictEqual({ - status: TransactionStatus.Confirmed, - }); - }); - - it('throws BtcOnChainServiceError error if another error was thrown', async () => { - const { dataClient, getTransactionStatusSpy } = createMockDataClient(); - const { service } = createMockBtcService(dataClient); - - getTransactionStatusSpy.mockRejectedValue(new Error('error')); - - await expect(service.getTransactionStatus(txHash)).rejects.toThrow( - BtcOnChainServiceError, - ); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts deleted file mode 100644 index 6e4658a1..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/chain/service.ts +++ /dev/null @@ -1,263 +0,0 @@ -import type { Network } from 'bitcoinjs-lib'; -import { networks } from 'bitcoinjs-lib'; - -import type { CacheStateManager } from '../../cacheManager'; -import { Caip19Asset } from '../../constants'; -import { compactError } from '../../utils'; -import { isSatsProtectionEnabled } from '../../utils/config'; -import { getCaip2ChainId } from '../wallet'; -import type { FeeRate, TransactionStatus } from './constants'; -import type { IDataClient, ISatsProtectionDataClient } from './data-client'; -import { BtcOnChainServiceError } from './exceptions'; - -export type TransactionStatusData = { - status: TransactionStatus; -}; - -export type Balance = { - amount: bigint; -}; - -export type AssetBalances = { - balances: { - [asset: string]: Balance; - }; -}; - -export type Fee = { - type: FeeRate; - rate: bigint; -}; - -export type Fees = { - fees: Fee[]; -}; - -export type Utxo = { - block: number; - txHash: string; - index: number; - value: number; -}; - -export type TransactionData = { - data: { - utxos: Utxo[]; - }; -}; - -export type CommittedTransaction = { - transactionId: string; -}; - -export type BtcOnChainServiceOptions = { - network: Network; -}; - -export type BtcOnChainServiceClients = { - dataClient: IDataClient; - satsProtectionDataClient: ISatsProtectionDataClient; - cacheStateManager: CacheStateManager; -}; - -export class BtcOnChainService { - protected readonly _dataClient: IDataClient; - - protected readonly _satsProtectionDataClient: ISatsProtectionDataClient; - - protected readonly _cacheStateManager: CacheStateManager; - - protected readonly _options: BtcOnChainServiceOptions; - - constructor( - { - dataClient, - satsProtectionDataClient, - cacheStateManager, - }: BtcOnChainServiceClients, - options: BtcOnChainServiceOptions, - ) { - this._dataClient = dataClient; - this._satsProtectionDataClient = satsProtectionDataClient; - this._cacheStateManager = cacheStateManager; - this._options = options; - } - - get network(): Network { - return this._options.network; - } - - /** - * Gets the BTC balances from addresses. - * - * @param addresses - An array of addresses to fetch the balances for. - * @param assets - An array of assets to fetch the balances of. - * @returns A promise that resolves to an `AssetBalances` object. - */ - async getBalances( - addresses: string[], - assets: string[], - ): Promise { - try { - if (assets.length > 1) { - throw new BtcOnChainServiceError('Only one asset is supported'); - } - - const asset = assets[0]; - - if ( - (this.network === networks.testnet && asset !== Caip19Asset.TBtc) || - (this.network === networks.bitcoin && asset !== Caip19Asset.Btc) - ) { - throw new BtcOnChainServiceError('Invalid asset'); - } - - const balance = await this.getSpendableBalance(addresses); - - return { - balances: { - [asset]: { - amount: balance, - }, - }, - }; - } catch (error) { - throw compactError(error, BtcOnChainServiceError); - } - } - - /** - * Gets the fee rates of the network. - * - * @returns A promise that resolves to a `Fees` object. - */ - async getFeeRates(): Promise { - const cachedValue = await this._cacheStateManager.getFeeRate( - getCaip2ChainId(this.network), - ); - - if (cachedValue && !cachedValue.isExpired()) { - return cachedValue.value.valueOf(); - } - - try { - const result = await this._dataClient.getFeeRates(); - const fees = { - fees: Object.entries(result).map( - ([key, value]: [key: FeeRate, value: number]) => ({ - type: key, - rate: BigInt(value), - }), - ), - }; - - await this._cacheStateManager.setFeeRate( - getCaip2ChainId(this.network), - fees, - ); - - return fees; - } catch (error) { - throw compactError(error, BtcOnChainServiceError); - } - } - - /** - * Gets the status of a transaction with the given transaction hash. - * - * @param txHash - The transaction hash of the transaction to get the status of. - * @returns A promise that resolves to a `TransactionStatusData` object. - */ - async getTransactionStatus(txHash: string): Promise { - try { - return await this._dataClient.getTransactionStatus(txHash); - } catch (error) { - throw new BtcOnChainServiceError(error); - } - } - - /** - * Gets the required metadata to build a transaction for the given addresses and transaction intent. - * - * @param addresses - The addresses to build the transaction for. - * @returns A promise that resolves to a `TransactionData` object. - */ - async getDataForTransaction(addresses: string[]): Promise { - try { - return { - data: { - utxos: await this.getSpendableUtxos(addresses), - }, - }; - } catch (error) { - throw compactError(error, BtcOnChainServiceError); - } - } - - /** - * Get the spendable UTXOs that does not contains Inscription, Runes or Rare Sats. - * - * @param addresses - An array of Bitcoin addresses to query. - * @returns A promise that resolves to the filtered UTXOs. - */ - protected async getSpendableUtxos(addresses: string[]): Promise { - if (this.isSatsProtectionEnabled()) { - // FIXME: SimpleHash provider does return the filtered UTXOs directly, - // so it is not necessary to give the list of UTXOs to filter (hence the `[]`). - // This logic may change if we change our provider. - return await this._satsProtectionDataClient.filterUtxos(addresses, []); - } - return await this._dataClient.getUtxos(addresses); - } - - /** - * Get the spendable balance that does not contain Inscription, Runes or Rare Sats. - * - * @param addresses - An array of Bitcoin addresses to query. - * @returns A promise that resolves to the spendable BTC balance. - */ - protected async getSpendableBalance(addresses: string[]): Promise { - if (this.isSatsProtectionEnabled()) { - // There is no API to get the spendable balance directly, so - // we need to get the spendable UTXOs and sum the values. - const utxos = await this.getSpendableUtxos(addresses); - return utxos.reduce((acc, utxo) => acc + BigInt(utxo.value), BigInt(0)); - } - - const balances = await this._dataClient.getBalances(addresses); - return Object.values(balances).reduce( - (acc, balance) => acc + BigInt(balance), - BigInt(0), - ); - } - - protected isSatsProtectionEnabled(): boolean { - return isSatsProtectionEnabled(this.network); - } - - /** - * Broadcasts a signed transaction on the blockchain network. - * - * @param signedTransaction - A signed transaction string. - * @returns A promise that resolves to a `CommittedTransaction` object. - */ - async broadcastTransaction( - signedTransaction: string, - ): Promise { - try { - const transactionId = await this._dataClient.sendTransaction( - signedTransaction, - ); - return { - transactionId, - }; - } catch (error) { - throw compactError(error, BtcOnChainServiceError); - } - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - listTransactions() { - throw new Error('Method not implemented.'); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts deleted file mode 100644 index 3cb4dcf3..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -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 type { AccountSigner } from './signer'; -import * as utils from './utils/payment'; - -describe('BtcAccount', () => { - const createMockPaymentInstance = (address: string | undefined) => { - const getPaymentSpy = jest.spyOn(utils, 'getBtcPaymentInst'); - 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, - P2WPKHAccount.scriptType, - `bip122:${P2WPKHAccount.scriptType.toLowerCase()}`, - { sign: signerSpy } as unknown as AccountSigner, - ); - - 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'); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts deleted file mode 100644 index 0718c5e9..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/account.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { Network, Payment } from 'bitcoinjs-lib'; -import type { Buffer } from 'buffer'; - -import { hexToBuffer } from '../../utils'; -import type { StaticImplements } from '../../utils/static'; -import { ScriptType } from './constants'; -import type { AccountSigner } from './signer'; -import { getBtcPaymentInst } from './utils'; - -export type IStaticBtcAccount = { - path: string[]; - scriptType: ScriptType; - new ( - mfp: string, - index: number, - hdPath: string, - pubkey: string, - network: Network, - scriptType: ScriptType, - type: string, - signer: AccountSigner, - ): BtcAccount; -}; - -export abstract class BtcAccount { - #address: string; - - #payment: Payment; - - readonly network: Network; - - readonly scriptType: ScriptType; - - /** - * The master fingerprint of the derived node, as a string. - */ - readonly mfp: string; - - /** - * The index of the derived node, as a number. - */ - readonly index: number; - - /** - * The HD path of the account, as a string. - */ - readonly hdPath: string; - - /** - * The public key of the account, as a string. - */ - readonly pubkey: string; - - /** - * The type of the account, e.g. `bip122:p2pwh`, as a string. - */ - readonly type: string; - - /** - * The `IAccountSigner` object derived from the root node. - */ - readonly signer: AccountSigner; - - constructor( - mfp: string, - index: number, - hdPath: string, - pubkey: string, - network: Network, - scriptType: ScriptType, - type: string, - signer: AccountSigner, - ) { - this.mfp = mfp; - this.index = index; - this.hdPath = hdPath; - this.pubkey = pubkey; - this.network = network; - this.scriptType = scriptType; - this.signer = signer; - this.type = type; - } - - /** - * A getter function to return the corresponding account type's output script. - * - * @returns The corresponding account type's output script. - */ - get script(): Buffer { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this.payment.output!; - } - - /** - * A getter function to return the corresponding account type's address. - * - * @returns The corresponding account type's address. - */ - get address(): string { - if (!this.#address) { - if (!this.payment.address) { - throw new Error('Payment address is missing'); - } - this.#address = this.payment.address; - } - return this.#address; - } - - /** - * A getter function to return the corresponding account type's payment instance. - * - * @returns The corresponding account type's payment instance. - */ - get payment(): Payment { - if (!this.#payment) { - this.#payment = getBtcPaymentInst( - this.scriptType, - hexToBuffer(this.pubkey), - this.network, - ); - } - return this.#payment; - } -} - -export class P2WPKHAccount - extends BtcAccount - implements StaticImplements -{ - static readonly path = ['m', "84'", "0'"]; - - static readonly scriptType = ScriptType.P2wpkh; -} - -export class P2WPKHTestnetAccount - extends P2WPKHAccount - implements StaticImplements -{ - static readonly path = ['m', "84'", "1'"]; -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts deleted file mode 100644 index 3bf2c94b..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { networks, address as addressUtils } from 'bitcoinjs-lib'; - -import { generateFormattedUtxos } from '../../../test/utils'; -import { CoinSelectService } from './coin-select'; -import { ScriptType } from './constants'; -import { BtcAccountDeriver } from './deriver'; -import { TxInput } from './transaction-input'; -import { TxOutput } from './transaction-output'; -import { BtcWallet } from './wallet'; - -jest.mock('../../utils/snap'); - -describe('CoinSelectService', () => { - const createMockWallet = (network) => { - const instance = new BtcWallet(new BtcAccountDeriver(network), network); - return { - instance, - }; - }; - - const prepareCoinSlect = async ( - network, - outputVal = 1000, - inputMin = 2000, - inputMax = 1000000, - ) => { - const wallet = createMockWallet(network); - const sender = await wallet.instance.unlock(0, ScriptType.P2wpkh); - const receiver1 = await wallet.instance.unlock(1, ScriptType.P2wpkh); - const receiver2 = await wallet.instance.unlock(2, ScriptType.P2wpkh); - - const utxos = generateFormattedUtxos(sender.address, 2, inputMin, inputMax); - - const outputs = [ - new TxOutput( - outputVal, - receiver1.address, - addressUtils.toOutputScript(receiver1.address, network), - ), - ]; - - const inputs = utxos.map((utxo) => new TxInput(utxo, sender.script)); - - return { - sender, - receiver1, - receiver2, - utxos, - outputs, - inputs, - }; - }; - - describe('selectCoins', () => { - it('selects utxos', async () => { - const network = networks.testnet; - const { inputs, outputs, sender } = await prepareCoinSlect(network); - - const coinSelectService = new CoinSelectService(1); - - const result = coinSelectService.selectCoins( - inputs, - outputs, - new TxOutput(0, sender.address, sender.script), - ); - - expect(result.fee).toBeGreaterThan(1); - expect(result.change).toBeDefined(); - expect(result.inputs.length).toBeGreaterThan(0); - expect(result.outputs.length).toBeGreaterThan(0); - }); - - it('returns empty inputs and outputs when the provided UTXOs are insufficient', async () => { - const network = networks.testnet; - // Setup a test case where required utxos are greater than the available utxos - const { inputs, outputs, sender } = await prepareCoinSlect( - network, - 10000, - 500, - 500, - ); - - const coinSelectService = new CoinSelectService(1); - - const result = coinSelectService.selectCoins( - inputs, - outputs, - new TxOutput(0, sender.address, sender.script), - ); - - expect(result.inputs).toHaveLength(0); - expect(result.outputs).toHaveLength(0); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts deleted file mode 100644 index c8c150ac..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/coin-select.ts +++ /dev/null @@ -1,64 +0,0 @@ -import coinSelect from 'coinselect'; - -import type { TxInput } from './transaction-input'; -import type { TxOutput } from './transaction-output'; - -export type SelectionResult = { - change?: TxOutput; - fee: number; - inputs: TxInput[]; - outputs: TxOutput[]; -}; - -export class CoinSelectService { - protected readonly _feeRate: number; - - constructor(feeRate: number) { - this._feeRate = Math.round(feeRate); - } - - /** - * This function selects the UTXOs that will be used to pay for a transaction and its associated gas fee. - * - * @param inputs - An array of input objects. - * @param outputs - An array of output objects. - * @param changeTo - The change output object. - * @returns A SelectionResult object that includes the calculated transaction fee, selected inputs, outputs, and change (if any). - */ - selectCoins( - inputs: TxInput[], - outputs: TxOutput[], - changeTo: TxOutput, - ): SelectionResult { - const result = coinSelect(inputs, outputs, this._feeRate); - - const selectedResult: SelectionResult = { - fee: result.fee, - // CoinSelect returns undefined inputs when the provided UTXOs are insufficient, - // Hence, assign an empty array to standardize the return value - inputs: result.inputs ?? [], - outputs: [], - }; - - if (result.outputs) { - // Re-structure outputs to avoid depending on `coinSelect` output structure - for (const output of result.outputs) { - if (output.address) { - selectedResult.outputs.push(output); - } else { - // We only support 1 change output, so we do check if there are more than 1 - // and raise an error to avoid overwriting it - if (selectedResult.change !== undefined) { - throw new Error( - 'Unexpected error: found more than 1 change output', - ); - } - changeTo.value = output.value; - selectedResult.change = changeTo; - } - } - } - - return selectedResult; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/constants.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/constants.ts deleted file mode 100644 index 2d979a63..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/constants.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { BtcAccountType } from '@metamask/keyring-api'; - -export enum ScriptType { - P2pkh = 'p2pkh', - P2shP2wkh = 'p2sh-p2wpkh', - P2wpkh = 'p2wpkh', -} - -// We type it explicitly here, so that the compiler will complain about -// missing `BtcAccountType` enum members: -export const BtcAccountTypeToScriptType: Record = { - [BtcAccountType.P2wpkh]: ScriptType.P2wpkh, -}; - -// reference https://help.magiceden.io/en/articles/8665399-navigating-bitcoin-dust-understanding-limits-and-safeguarding-your-transactions-on-magic-eden -// "Dust" is defined in terms of dustRelayFee, -// which has units satoshis-per-kilobyte. -// If you'd pay more in fees than the value of the output -// to spend something, then we consider it dust. -// A typical spendable non-segwit txout is 34 bytes big, and will -// need a CTxIn of at least 148 bytes to spend: -// so dust is a spendable txout less than -// 182*dustRelayFee/1000 (in satoshis). -// 546 satoshis at the default rate of 3000 sat/kvB. -// A typical spendable segwit P2WPKH txout is 31 bytes big, and will -// need a CTxIn of at least 67 bytes to spend: -// so dust is a spendable txout less than -// 98*dustRelayFee/1000 (in satoshis). -// 294 satoshis at the default rate of 3000 sat/kvB. -export const DustLimit = { - /* eslint-disable */ - p2pkh: 546, - 'p2sh-p2wpkh': 540, - p2wpkh: 294, - /* eslint-disable */ -}; - -// Maximum weight in bytes for a standard transaction -export const MaxStandardTxWeight = 400000; - -// Default minimum fee rate in BTC/KvB -// To align with the response from RPC provider, we use BTC unit in KvB -export const DefaultTxMinFeeRateInBtcPerKvb = 0.0001; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts deleted file mode 100644 index 9dd6c524..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { type SLIP10NodeInterface } from '@metamask/key-tree'; -import { networks } from 'bitcoinjs-lib'; -import { Buffer } from 'buffer'; - -import * as snapUtils from '../../utils/snap'; -import * as strUtils from '../../utils/string'; -import { P2WPKHAccount } from './account'; -import { BtcAccountDeriver } from './deriver'; - -jest.mock('../../utils/snap'); - -describe('BtcAccountDeriver', () => { - const prepareBip32Deriver = async (network) => { - const deriver = new BtcAccountDeriver(network); - const bip32Deriver = await snapUtils.getBip32Deriver( - P2WPKHAccount.path, - deriver.curve, - ); - const pkBuffer = bip32Deriver.privateKeyBytes as unknown as Buffer; - const ccBuffer = bip32Deriver.chainCodeBytes as unknown as Buffer; - - return { - bip32Deriver, - deriver, - pkBuffer, - ccBuffer, - }; - }; - - describe('createBip32FromPrivateKey', () => { - it('returns an BIP32Interface', async () => { - const network = networks.testnet; - const { deriver, pkBuffer, ccBuffer } = await prepareBip32Deriver( - network, - ); - - const result = deriver.createBip32FromPrivateKey(pkBuffer, ccBuffer); - - expect(result.chainCode).toBeDefined(); - expect(result.chainCode).not.toBeNull(); - expect(result.privateKey).toBeDefined(); - expect(result.privateKey).not.toBeNull(); - expect(result.publicKey).toBeDefined(); - expect(result.publicKey).not.toBeNull(); - expect(result.depth).toBeDefined(); - expect(result.depth).not.toBeNull(); - expect(result.index).toBeDefined(); - expect(result.index).not.toBeNull(); - }); - - it('throws `Unable to construct BIP32 node from private key` if another error was thrown', async () => { - const network = networks.testnet; - const deriver = new BtcAccountDeriver(network); - const pkBuffer = Buffer.from(''); - const ccBuffer = Buffer.from(''); - - expect(() => - deriver.createBip32FromPrivateKey(pkBuffer, ccBuffer), - ).toThrow('Unable to construct BIP32 node from private key'); - }); - }); - - describe('getChild', () => { - it('returns an BIP32Interface', async () => { - const network = networks.testnet; - const { deriver, pkBuffer, ccBuffer } = await prepareBip32Deriver( - network, - ); - - const hdPath = [`m`, `0'`, `0`, `0`]; - - const node = deriver.createBip32FromPrivateKey(pkBuffer, ccBuffer); - - const result = await deriver.getChild(node, hdPath); - - expect(result.chainCode).toBeDefined(); - expect(result.chainCode).not.toBeNull(); - expect(result.privateKey).toBeDefined(); - expect(result.privateKey).not.toBeNull(); - expect(result.publicKey).toBeDefined(); - expect(result.publicKey).not.toBeNull(); - expect(result.depth).toBeDefined(); - expect(result.depth).not.toBeNull(); - expect(result.index).toBeDefined(); - expect(result.index).not.toBeNull(); - }); - - it.each(["m/1''/0/0", "m/-1'/0/0", "m/0'/-1/0", "m/0'/1a/0"])( - 'throws `Invalid index` if hdPath is invalid: %s', - async (path: string) => { - const network = networks.testnet; - const { deriver, pkBuffer, ccBuffer } = await prepareBip32Deriver( - network, - ); - const node = deriver.createBip32FromPrivateKey(pkBuffer, ccBuffer); - await expect(deriver.getChild(node, path.split('/'))).rejects.toThrow( - 'Invalid index', - ); - }, - ); - }); - - describe('getRoot', () => { - it('returns an BIP32Interface', async () => { - const network = networks.testnet; - const { deriver, pkBuffer, ccBuffer } = await prepareBip32Deriver( - network, - ); - - const result = await deriver.getRoot(P2WPKHAccount.path); - - expect(result.chainCode).toStrictEqual(ccBuffer); - expect(result.privateKey).toStrictEqual(pkBuffer); - }); - - it('throws error if private key is invalid', async () => { - const network = networks.testnet; - const spy = jest.spyOn(strUtils, 'hexToBuffer'); - spy.mockImplementation(() => { - throw new Error('error'); - }); - const { deriver } = await prepareBip32Deriver(network); - - await expect(deriver.getRoot(P2WPKHAccount.path)).rejects.toThrow( - 'error', - ); - }); - - it('throws error if chain code is invalid', async () => { - const network = networks.testnet; - const spy = jest.spyOn(strUtils, 'hexToBuffer'); - spy - .mockImplementationOnce((val: string) => Buffer.from(val, 'hex')) - .mockImplementationOnce(() => { - throw new Error('error'); - }); - const { deriver } = await prepareBip32Deriver(network); - - await expect(deriver.getRoot(P2WPKHAccount.path)).rejects.toThrow( - 'error', - ); - }); - - it('throws DeriverError if private key is missing', async () => { - const network = networks.testnet; - const deriver = new BtcAccountDeriver(network); - - jest - .spyOn(snapUtils, 'getBip32Deriver') - .mockResolvedValue({} as unknown as SLIP10NodeInterface); - - await expect(deriver.getRoot(P2WPKHAccount.path)).rejects.toThrow( - 'Deriver private key is missing', - ); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts deleted file mode 100644 index 4046500b..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/deriver.ts +++ /dev/null @@ -1,102 +0,0 @@ -import ecc from '@bitcoinerlab/secp256k1'; -import type { BIP32Interface, BIP32API } from 'bip32'; -import { BIP32Factory } from 'bip32'; -import { type Network } from 'bitcoinjs-lib'; -import type { Buffer } from 'buffer'; - -import { compactError, hexToBuffer, getBip32Deriver } from '../../utils'; -import { DeriverError } from './exceptions'; -import { deriveByPath } from './utils/deriver'; - -/** - * This class provides a mechanism for deriving Bitcoin accounts using BIP32. - */ -export class BtcAccountDeriver { - protected readonly _network: Network; - - protected readonly _bip32Api: BIP32API; - - /** - * The curve to use for account derivation. Defaults to 'secp256k1'. - */ - readonly curve: 'secp256k1' | 'ed25519' = 'secp256k1'; - - constructor(network: Network) { - this._bip32Api = BIP32Factory(ecc); - this._network = network; - } - - /** - * Gets the root node of a BIP32 account given a path. - * - * @param path - The BIP32 path to use for derivation. - * @returns The root node of the BIP32 account. - * @throws {DeriverError} Throws a DeriverError if the private key is missing or if the BIP32 node cannot be constructed from the private key. - */ - async getRoot(path: string[]): Promise { - try { - const deriver = await getBip32Deriver(path, this.curve); - - if (!deriver.privateKey) { - throw new DeriverError('Deriver private key is missing'); - } - - const privateKeyBuffer = hexToBuffer(deriver.privateKey); - const chainCodeBuffer = hexToBuffer(deriver.chainCode); - - const root = this.createBip32FromPrivateKey( - 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) { - throw compactError(error, DeriverError); - } - } - - /** - * Creates a BIP32 node from a private key and chain node. - * - * @param privateKey - The private key buffer. - * @param chainNode - The chain node buffer. - * @returns The BIP32 node. - * @throws {DeriverError} Throws a DeriverError if the BIP32 node cannot be constructed from the private key. - */ - createBip32FromPrivateKey( - privateKey: Buffer, - chainNode: Buffer, - ): BIP32Interface { - try { - return this._bip32Api.fromPrivateKey( - privateKey, - chainNode, - this._network, - ); - } catch (error) { - throw new DeriverError('Unable to construct BIP32 node from private key'); - } - } - - /** - * Gets a child node of a BIP32 account given an index. - * - * @param root - The root node of the BIP32 account. - * @param hdPath - The HD path to derive. - * @returns A promise that resolves to the child node. - */ - async getChild( - root: BIP32Interface, - hdPath: string[], - ): Promise { - return Promise.resolve(deriveByPath(root, hdPath)); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts deleted file mode 100644 index 29a98ec7..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/exceptions.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { CustomError } from '../../utils'; - -export class DeriverError extends CustomError {} - -export class WalletFactoryError extends CustomError {} - -export class WalletError extends CustomError {} - -export class TxValidationError extends WalletError {} - -export class TransactionDustError extends TxValidationError { - constructor(errMsg?: string) { - super(errMsg ?? 'Transaction amount too small'); - } -} - -export class InsufficientFundsError extends TxValidationError { - constructor(errMsg?: string) { - super(errMsg ?? 'Insufficient funds'); - } -} - -export class PsbtServiceError extends CustomError {} - -export class PsbtSigValidateError extends CustomError {} - -export class PsbtValidateError extends CustomError {} - -export class PsbtUpdateWithnessUtxoError extends CustomError {} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts deleted file mode 100644 index 206909fa..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export * from './exceptions'; -export * from './account'; -export * from './signer'; -export * from './deriver'; -export * from './wallet'; -export * from './coin-select'; -export * from './psbt'; -export * from './transaction-info'; -export * from './transaction-input'; -export * from './transaction-output'; -export * from './utils'; -export * from './constants'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts deleted file mode 100644 index 3fa4dca8..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { Psbt, Transaction, networks } from 'bitcoinjs-lib'; - -import { generateFormattedUtxos } from '../../../test/utils'; -import { hexToBuffer } from '../../utils'; -import { MaxStandardTxWeight, ScriptType } from './constants'; -import { BtcAccountDeriver } from './deriver'; -import { PsbtServiceError } from './exceptions'; -import { PsbtService } from './psbt'; -import { TxInput } from './transaction-input'; -import { TxOutput } from './transaction-output'; -import { BtcWallet } from './wallet'; - -jest.mock('../../utils/logger'); -jest.mock('../../utils/snap'); - -describe('PsbtService', () => { - const createMockWallet = (network) => { - const instance = new BtcWallet(new BtcAccountDeriver(network), network); - return { - instance, - }; - }; - - const preparePsbt = async (rbfOptIn = false, inputsCount = 2) => { - const network = networks.testnet; - const service = new PsbtService(network); - const wallet = createMockWallet(network); - const sender = await wallet.instance.unlock(0, ScriptType.P2wpkh); - const receiver1 = await wallet.instance.unlock(1, ScriptType.P2wpkh); - const receiver2 = await wallet.instance.unlock(2, ScriptType.P2wpkh); - const receivers = [receiver1, receiver2]; - const finalizeSpy = jest.spyOn(Psbt.prototype, 'finalizeAllInputs'); - const inputSpy = jest.spyOn(Psbt.prototype, 'addInput'); - const outputSpy = jest.spyOn(Psbt.prototype, 'addOutput'); - const signSpy = jest.spyOn(Psbt.prototype, 'signAllInputsHDAsync'); - const verifySpy = jest.spyOn( - Psbt.prototype, - 'validateSignaturesOfAllInputs', - ); - const transactionWeightSpy = jest.spyOn(Transaction.prototype, 'weight'); - const transactionHexSpy = jest.spyOn(Transaction.prototype, 'toHex'); - - const outputVal = 1000; - const fee = 500; - - const outputs = receivers.map( - (account) => new TxOutput(outputVal, account.address, account.script), - ); - const utxos = generateFormattedUtxos( - sender.address, - inputsCount, - outputVal * outputs.length + fee, - outputVal * outputs.length + fee, - ); - const inputs = utxos.map((utxo) => new TxInput(utxo, sender.script)); - - service.addOutputs(outputs); - - service.addInputs( - inputs, - rbfOptIn, - sender.hdPath, - hexToBuffer(sender.pubkey, false), - hexToBuffer(sender.mfp, false), - ); - - return { - service, - sender, - receivers, - finalizeSpy, - inputSpy, - outputSpy, - signSpy, - verifySpy, - inputs, - outputs, - transactionWeightSpy, - transactionHexSpy, - }; - }; - - describe('constructor', () => { - it('constructor with a new psbt object', async () => { - const network = networks.testnet; - - const service = new PsbtService(network); - - expect(service.psbt).toBeInstanceOf(Psbt); - }); - - it('constructor with an psbt base string', async () => { - const { service } = await preparePsbt(false, 2); - const psbtBase64 = service.toBase64(); - - const newService = PsbtService.fromBase64(networks.testnet, psbtBase64); - - expect(newService.psbt).toBeInstanceOf(Psbt); - }); - }); - - describe('addInputs', () => { - it('adds witnessUtxos to psbt object', async () => { - const { service, inputSpy, inputs, sender } = await preparePsbt( - false, - 10, - ); - - expect(service.psbt.txInputs).toHaveLength(10); - - for (let i = 0; i < service.psbt.txInputs.length; i++) { - expect(inputSpy).toHaveBeenNthCalledWith(i + 1, { - hash: inputs[i].txHash, - index: inputs[i].index, - witnessUtxo: { - script: inputs[i].script, - value: inputs[i].value, - }, - bip32Derivation: [ - { - masterFingerprint: hexToBuffer(sender.mfp, false), - path: sender.hdPath, - pubkey: hexToBuffer(sender.pubkey, false), - }, - ], - sequence: Transaction.DEFAULT_SEQUENCE, - }); - } - }); - - it('opt-ins RBF into the psbt input', async () => { - const { service, inputSpy, inputs, sender } = await preparePsbt(true); - - for (let i = 0; i < service.psbt.txInputs.length; i++) { - expect(inputSpy).toHaveBeenNthCalledWith(i + 1, { - hash: inputs[i].txHash, - index: inputs[i].index, - witnessUtxo: { - script: inputs[i].script, - value: inputs[i].value, - }, - bip32Derivation: [ - { - masterFingerprint: hexToBuffer(sender.mfp, false), - path: sender.hdPath, - pubkey: hexToBuffer(sender.pubkey, false), - }, - ], - sequence: Transaction.DEFAULT_SEQUENCE - 2, - }); - } - }); - - it('throws `Failed to add input in PSBT` error if adding inputs fails', async () => { - const { service, inputSpy, sender, inputs } = await preparePsbt(); - inputSpy.mockImplementation(() => { - throw new Error('error'); - }); - - expect(() => { - service.addInputs( - inputs, - false, - sender.hdPath, - hexToBuffer(sender.pubkey, false), - hexToBuffer(sender.mfp), - ); - }).toThrow('Failed to add input in PSBT'); - }); - }); - - describe('addOutputs', () => { - it('adds outputs to psbt object', async () => { - const { service, outputs, receivers } = await preparePsbt(); - expect(service.psbt.txOutputs).toHaveLength(outputs.length); - - for (let i = 0; i < service.psbt.txOutputs.length; i++) { - expect(service.psbt.txOutputs[i]).toHaveProperty( - 'script', - receivers[i].script, - ); - expect(service.psbt.txOutputs[i]).toHaveProperty( - 'value', - outputs[i].value, - ); - expect(service.psbt.txOutputs[i]).toHaveProperty( - 'address', - outputs[i].address, - ); - } - }); - - it('throws `Failed to add output in PSBT` error if adding outputs fails', async () => { - const { service, outputSpy, outputs } = await preparePsbt(); - outputSpy.mockImplementation(() => { - throw new Error('error'); - }); - - expect(() => service.addOutputs(outputs)).toThrow( - 'Failed to add output in PSBT', - ); - }); - }); - - describe('toBase64', () => { - it('returns base64 string of psbt object', async () => { - const network = networks.testnet; - const service = new PsbtService(network); - - const result = service.toBase64(); - - expect(result).toBe(service.psbt.toBase64()); - }); - - it('throws `Failed to output PSBT string` error if the operation failed', async () => { - const network = networks.testnet; - const service = new PsbtService(network); - - jest.spyOn(service.psbt, 'toBase64').mockImplementation(() => { - throw new Error('error'); - }); - - expect(() => service.toBase64()).toThrow('Failed to output PSBT string'); - }); - }); - - describe('signNVerify', () => { - it('signs all inputs with the given signer', async () => { - const { service, sender, signSpy, verifySpy } = await preparePsbt(); - - await service.signNVerify(sender.signer); - - expect(signSpy).toHaveBeenCalledWith(sender.signer); - expect(verifySpy).toHaveBeenCalledWith(expect.any(Function)); - }); - - it("throws `Invalid signature to sign the PSBT's inputs` error if signature is incorrect", async () => { - const { service, sender, verifySpy } = await preparePsbt(); - - verifySpy.mockReturnValue(false); - - await expect(service.signNVerify(sender.signer)).rejects.toThrow( - "Invalid signature to sign the PSBT's inputs", - ); - }); - }); - - describe('finalize', () => { - it('returns an transaction hex', async () => { - const { service, finalizeSpy, sender, transactionHexSpy } = - await preparePsbt(); - - await service.signNVerify(sender.signer); - - const txHex = service.finalize(); - - expect(txHex).not.toBeNull(); - expect(txHex).not.toBe(''); - expect(finalizeSpy).toHaveBeenCalled(); - expect(transactionHexSpy).toHaveBeenCalled(); - }); - - it('throws `Transaction is too large` error if the transaction weight is too large', async () => { - const { service, sender, transactionWeightSpy } = await preparePsbt(); - - await service.signNVerify(sender.signer); - - transactionWeightSpy.mockReturnValue(MaxStandardTxWeight + 1000); - - expect(() => service.finalize()).toThrow(`Transaction is too large`); - }); - - it('throws PsbtServiceError error if finalize operation failed', async () => { - const { service, sender, finalizeSpy } = await preparePsbt(); - - await service.signNVerify(sender.signer); - - finalizeSpy.mockImplementation(() => { - throw new Error('error'); - }); - - expect(() => service.finalize()).toThrow(PsbtServiceError); - }); - }); - - describe('signDummy', () => { - it('clones a psbt, then sign it and returns an PsbtService object', async () => { - const { service, sender } = await preparePsbt(); - - const signedService = await service.signDummy(sender.signer); - - expect(signedService).toBeInstanceOf(PsbtService); - }); - - it('throws `Failed to sign dummy in PSBT` error if signDummy is failed', async () => { - const { service, sender, finalizeSpy } = await preparePsbt(); - - finalizeSpy.mockImplementation(() => { - throw new Error('error'); - }); - - await expect(service.signDummy(sender.signer)).rejects.toThrow( - `Failed to sign dummy in PSBT`, - ); - }); - }); - - describe('getFee', () => { - it('extracts fee from the psbt', async () => { - const { service, sender } = await preparePsbt(); - - const signedService = await service.signDummy(sender.signer); - - const fee = signedService.getFee(); - - expect(fee).toBeGreaterThan(0); - }); - - it('throws `Failed to get fee from PSBT` error if getFee is failed', async () => { - const { service } = await preparePsbt(); - - expect(() => service.getFee()).toThrow(`Failed to get fee from PSBT`); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts deleted file mode 100644 index e3fd9b05..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/psbt.ts +++ /dev/null @@ -1,258 +0,0 @@ -import ecc from '@bitcoinerlab/secp256k1'; -import type { HDSignerAsync, Network } from 'bitcoinjs-lib'; -import { Psbt, Transaction } from 'bitcoinjs-lib'; -import type { Buffer } from 'buffer'; -import ECPairFactory from 'ecpair'; - -import { compactError, logger } from '../../utils'; -import { MaxStandardTxWeight } from './constants'; -import { PsbtServiceError } from './exceptions'; -import type { TxInput } from './transaction-input'; -import type { TxOutput } from './transaction-output'; - -const ECPair = ECPairFactory(ecc); - -export class PsbtService { - protected readonly _psbt: Psbt; - - protected readonly _network: Network; - - get psbt() { - return this._psbt; - } - - constructor(network: Network, psbt?: Psbt) { - if (psbt === undefined) { - this._psbt = new Psbt({ network }); - } else { - this._psbt = psbt; - } - this._network = network; - } - - /** - * Creates a new instance of the `PsbtService` class from a base64-encoded PSBT string and a network. - * - * @param network - The network to use for the PSBT. - * @param base64Psbt - The base64-encoded PSBT string. - * @returns A new instance of the `PsbtService` class. - */ - static fromBase64(network: Network, base64Psbt: string): PsbtService { - const psbt = Psbt.fromBase64(base64Psbt, { network }); - const service = new PsbtService(network, psbt); - return service; - } - - /** - * Adds an input to the PSBT. - * - * @param input - The transaction input to add. - * @param replaceable - Whether or not the transaction is replaceable. - * @param changeAddressHdPath - The HD path of the change address. - * @param changeAddressPubkey - The public key of the change address. - * @param changeAddressMfp - The master fingerprint of the change address. - * @throws {PsbtServiceError} If there was an error adding the input to the PSBT. - */ - addInput( - input: TxInput, - replaceable: boolean, - changeAddressHdPath: string, - changeAddressPubkey: Buffer, - changeAddressMfp: Buffer, - ) { - try { - this._psbt.addInput({ - hash: input.txHash, - index: input.index, - witnessUtxo: { - script: input.script, - value: input.value, - }, - // This is useful because as long as you store the masterFingerprint on - // the PSBT Creator's server, you can have the PSBT Creator do the heavy - // lifting with derivation from your m/84'/0'/0' xpub, (deriving only 0/0 ) - // and your signer just needs to pass in an HDSigner interface (ie. bip32 library) - bip32Derivation: [ - { - masterFingerprint: changeAddressMfp, - path: changeAddressHdPath, - pubkey: changeAddressPubkey, - }, - ], - - // reference : https://en.bitcoin.it/wiki/BIP_0125 - // A transaction is considered to have opted in to allowing replacement of itself if any of its inputs have an nSequence number less than (0xffffffff - 1). - // we use max sequence number - 2 to void conflicting with other possible uses of nSequence - sequence: replaceable - ? Transaction.DEFAULT_SEQUENCE - 2 - : Transaction.DEFAULT_SEQUENCE, - }); - } catch (error) { - logger.error('Failed to add input', error); - throw new PsbtServiceError('Failed to add input in PSBT'); - } - } - - /** - * Adds multiple inputs to the PSBT. - * - * @param inputs - An array of transaction inputs to add. - * @param replaceable - Whether or not the transactions are replaceable. - * @param changeAddressHdPath - The HD path of the change address. - * @param changeAddressPubkey - The public key of the change address. - * @param changeAddressMfp - The master fingerprint of the change address. - */ - addInputs( - inputs: TxInput[], - replaceable: boolean, - changeAddressHdPath: string, - changeAddressPubkey: Buffer, - changeAddressMfp: Buffer, - ) { - for (const input of inputs) { - this.addInput( - input, - replaceable, - changeAddressHdPath, - changeAddressPubkey, - changeAddressMfp, - ); - } - } - - /** - * Adds an output to the PSBT. - * - * @param output - The transaction output to add. - * @throws {PsbtServiceError} If there was an error adding the output to the PSBT. - */ - addOutput(output: TxOutput) { - try { - this._psbt.addOutput({ - script: output.script, - value: output.value, - }); - } catch (error) { - logger.error('Failed to add output', error); - throw new PsbtServiceError('Failed to add output in PSBT'); - } - } - - /** - * Adds multiple outputs to the PSBT. - * - * @param outputs - An array of transaction outputs to add. - */ - addOutputs(outputs: TxOutput[]) { - for (const output of outputs) { - this.addOutput(output); - } - } - - /** - * Gets the fee for the PSBT. - * - * @returns The fee for the PSBT. - * @throws {PsbtServiceError} If there was an error getting the fee from the PSBT. - */ - getFee(): number { - try { - return this._psbt.getFee(); - } catch (error) { - logger.error('Failed to get fee', error); - throw new PsbtServiceError('Failed to get fee from PSBT'); - } - } - - /** - * Signs all inputs in the PSBT with a dummy signature using an asynchronous signer. - * - * @param signer - The asynchronous signer to use. - * @returns A promise that resolves with a new `PsbtService` instance with the signed inputs. - * @throws {PsbtServiceError} If there was an error signing the inputs in the PSBT. - */ - async signDummy(signer: HDSignerAsync): Promise { - try { - const psbt = this._psbt.clone(); - await psbt.signAllInputsHDAsync(signer); - psbt.finalizeAllInputs(); - return new PsbtService(this._network, psbt); - } catch (error) { - logger.error('Failed to sign dummy', error); - throw new PsbtServiceError('Failed to sign dummy in PSBT'); - } - } - - /** - * Converts the PSBT to a base64-encoded string. - * - * @returns The base64-encoded string representation of the PSBT. - * @throws {PsbtServiceError} If there was an error converting the PSBT to a base64-encoded string. - */ - toBase64(): string { - try { - return this._psbt.toBase64(); - } catch (error) { - logger.error('Failed to convert to base64', error); - throw new PsbtServiceError('Failed to output PSBT string'); - } - } - - /** - * Signs all inputs in the PSBT and verifies that the signatures are valid using an asynchronous signer. - * - * @param signer - The asynchronous signer to use. - * @throws {PsbtServiceError} If there was an error signing or verifying the PSBT's inputs. - */ - async signNVerify(signer: HDSignerAsync) { - try { - // This function signAllInputsHDAsync is used to sign all inputs with the signer. - // When using the method signAllInputsHDAsync, it is important to note that the signer must derive from the root node as well as the finderprint. - // For further reference, please see the getHdSigner method in BtcWallet. - await this._psbt.signAllInputsHDAsync(signer); - - if ( - !this._psbt.validateSignaturesOfAllInputs( - (pubkey: Buffer, msghash: Buffer, signature: Buffer) => - this.validateInputs(pubkey, msghash, signature), - ) - ) { - throw new PsbtServiceError( - "Invalid signature to sign the PSBT's inputs", - ); - } - } catch (error) { - throw compactError(error, PsbtServiceError); - } - } - - /** - * Finalizes the PSBT and returns the resulting transaction in hexadecimal format. - * - * @returns The transaction in hexadecimal format. - * @throws {PsbtServiceError} If there was an error finalizing the PSBT. - */ - finalize(): string { - try { - this._psbt.finalizeAllInputs(); - - const txHex = this._psbt.extractTransaction().toHex(); - - const weight = this._psbt.extractTransaction().weight(); - - if (weight > MaxStandardTxWeight) { - throw new PsbtServiceError('Transaction is too large'); - } - - return txHex; - } catch (error) { - throw compactError(error, PsbtServiceError); - } - } - - protected validateInputs(pubkey: Buffer, msghash: Buffer, signature: Buffer) { - // As the signer is extract from root node, however, the pubkey is derived from the child node - // So, using the ECPair to verify the signature is easier - return ECPair.fromPublicKey(pubkey).verify(msghash, signature); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.test.ts deleted file mode 100644 index 5860d183..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -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 an Error if another error was thrown', async () => { - const network = networks.testnet; - const { instance: node, deriveHardenedSpy } = - createMockBip32Instance(network); - deriveHardenedSpy.mockReturnValue(new Error('error')); - - const instance = new AccountSigner(node); - - expect(() => instance.derivePath("m/0'/0")).toThrow( - 'Unable to derive 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/bitcoin/wallet/signer.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts deleted file mode 100644 index d14abda2..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/signer.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { BIP32Interface } from 'bip32'; -import type { HDSignerAsync } from 'bitcoinjs-lib'; -import type { Buffer } from 'buffer'; - -import { deriveByPath } from './utils/deriver'; - -/** - * An Signer object that defines methods and properties for signing transactions and verifying signatures in PSBT sign process. - */ -export class AccountSigner implements HDSignerAsync { - /** - * The public key of the current derived node used for verifying signatures, as a Buffer. - */ - readonly publicKey: Buffer; - - /** - * The fingerprint of the current derived node used for verifying signatures, as a 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; - } - - /** - * Derives a new `IAccountSigner` object using an HD path. - * - * @param path - The HD path in string format, e.g. `m'\0'\0`. - * @returns A new `IAccountSigner` object derived by the given path. - */ - derivePath(path: string): AccountSigner { - try { - const childNode = deriveByPath(this._node, path.split('/')); - return new AccountSigner(childNode, this.fingerprint); - } catch (error) { - throw new Error('Unable to derive path'); - } - } - - /** - * Signs a transaction hash. - * - * @param hash - The buffer containing the transaction hash to sign. - * @returns A promise that resolves to the signed result as a Buffer. - */ - async sign(hash: Buffer): Promise { - return this._node.sign(hash); - } - - /** - * Verifies a signature using the derived node of an `IAccountSigner` object. - * - * @param hash - The buffer containing the transaction hash. - * @param signature - The buffer containing the signature to verify. - * @returns A boolean indicating whether the signature is valid. - */ - verify(hash: Buffer, signature: Buffer): boolean { - return this._node.verify(hash, signature); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts deleted file mode 100644 index 0358cfab..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Buffer } from 'buffer'; - -import { generateAccounts } from '../../../test/utils'; -import { TxInfo } from './transaction-info'; -import { TxOutput } from './transaction-output'; - -describe('TxInfo', () => { - it('accumulated total and add `TxOutput[]` as `Recipient[]`', async () => { - const accounts = generateAccounts(5); - const addresses = accounts.map((account) => account.address); - const fee = 10000; - const feeRate = 100; - let total = fee; - const outputs: TxOutput[] = []; - const sender = addresses[0]; - - const info = new TxInfo(sender, feeRate); - info.txFee = fee; - - for (let i = 1; i < addresses.length; i++) { - total += 100000; - const output = new TxOutput(100000, addresses[i], Buffer.from('dummy')); - outputs.push(output); - } - - info.addRecipients(outputs); - - const expectedRecipients = outputs.map((recipient) => { - return { - address: recipient.address, - value: recipient.bigIntValue, - }; - }); - - expect(info.total).toBe(BigInt(total)); - expect(info.sender).toStrictEqual(sender); - expect(info.recipients).toHaveLength(expectedRecipients.length); - }); - - it('accumulated total with change', async () => { - const accounts = generateAccounts(5); - const addresses = accounts.map((account) => account.address); - const fee = 10000; - const feeRate = 100; - let total = fee; - const outputs: TxOutput[] = []; - const sender = addresses[0]; - - const info = new TxInfo(sender, feeRate); - info.txFee = fee; - - for (let i = 1; i < addresses.length; i++) { - total += 100000; - const output = new TxOutput(100000, addresses[i], Buffer.from('dummy')); - outputs.push(output); - } - - const change = new TxOutput(500, addresses[0], Buffer.from('dummy')); - - info.addRecipients(outputs); - info.addChange(change); - total += 500; - - expect(info.total).toBe(BigInt(total)); - expect(info.change).toBeDefined(); - }); - - it('converts number input to bigint', async () => { - const accounts = generateAccounts(1); - const addresses = accounts.map((account) => account.address); - const fee = 10000; - const feeRate = 100; - const sender = addresses[0]; - - const info = new TxInfo(sender, feeRate); - info.txFee = fee; - info.feeRate = feeRate; - - expect(info.txFee).toBe(BigInt(fee)); - expect(info.feeRate).toBe(BigInt(feeRate)); - }); - - it('does not convert bigint input to bigint', async () => { - const accounts = generateAccounts(1); - const addresses = accounts.map((account) => account.address); - const fee = 10000; - const feeRate = 100; - const sender = addresses[0]; - - const info = new TxInfo(sender, feeRate); - info.txFee = BigInt(fee); - info.feeRate = BigInt(feeRate); - - expect(info.txFee).toBe(BigInt(fee)); - expect(info.feeRate).toBe(BigInt(feeRate)); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts deleted file mode 100644 index 1f74c61e..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-info.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { TxOutput } from './transaction-output'; -import type { ITxInfo, Recipient } from './wallet'; - -export class TxInfo implements ITxInfo { - readonly sender: string; - - protected _change?: Recipient; - - protected _recipients: Recipient[]; - - protected _outputTotal: bigint; - - protected _txFee: bigint; - - protected _feeRate: bigint; - - constructor(sender: string, feeRate: number) { - this.feeRate = feeRate; - this.txFee = 0; - this.sender = sender; - - this._recipients = []; - this._outputTotal = BigInt(0); - } - - addRecipients(outputs: TxOutput[]): void { - for (const output of outputs) { - this.addRecipient(output); - } - } - - addRecipient(output: TxOutput): void { - this._outputTotal += output.bigIntValue; - - this._recipients.push({ - address: output.address, - value: output.bigIntValue, - }); - } - - addChange(change: TxOutput): void { - this._change = { - address: change.address, - value: change.bigIntValue, - }; - } - - set txFee(fee: bigint | number) { - if (typeof fee === 'number') { - this._txFee = BigInt(fee); - } else { - this._txFee = fee; - } - } - - get txFee(): bigint { - return this._txFee; - } - - set feeRate(fee: bigint | number) { - if (typeof fee === 'number') { - this._feeRate = BigInt(fee); - } else { - this._feeRate = fee; - } - } - - get feeRate(): bigint { - return this._feeRate; - } - - get total(): bigint { - return ( - this._outputTotal + - (this.change ? BigInt(this.change.value) : BigInt(0)) + - this.txFee - ); - } - - get recipients(): Recipient[] { - return this._recipients; - } - - get change(): Recipient | undefined { - return this._change; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts deleted file mode 100644 index e60c9cca..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { networks } from 'bitcoinjs-lib'; - -import { generateFormattedUtxos } from '../../../test/utils'; -import { ScriptType } from './constants'; -import { BtcAccountDeriver } from './deriver'; -import { TxInput } from './transaction-input'; -import { BtcWallet } from './wallet'; - -jest.mock('../../utils/snap'); - -describe('TxInput', () => { - const createMockWallet = (network) => { - const instance = new BtcWallet(new BtcAccountDeriver(network), network); - return { - instance, - }; - }; - - it('return correct property', async () => { - const wallet = createMockWallet(networks.testnet); - const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); - const { script } = account; - - const utxo = generateFormattedUtxos(account.address, 1)[0]; - - const input = new TxInput(utxo, script); - - expect(input.script).toStrictEqual(script); - expect(input.value).toStrictEqual(utxo.value); - expect(input.txHash).toStrictEqual(utxo.txHash); - expect(input.index).toStrictEqual(utxo.index); - expect(input.block).toStrictEqual(utxo.block); - }); - - it('return bigint val', async () => { - const wallet = createMockWallet(networks.testnet); - const account = await wallet.instance.unlock(0, ScriptType.P2wpkh); - const { script } = account; - - const utxo = generateFormattedUtxos(account.address, 1)[0]; - - const input = new TxInput(utxo, script); - - expect(input.bigIntValue).toStrictEqual(BigInt(utxo.value)); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts deleted file mode 100644 index e6b0bd1c..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-input.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Buffer } from 'buffer'; - -import type { Utxo } from '../chain'; - -export class TxInput { - protected _value: bigint; - - // consume by coinselect - readonly script: Buffer; - - readonly txHash: string; - - readonly index: number; - - readonly block: number; - - constructor(utxo: Utxo, script: Buffer) { - this.script = script; - this._value = BigInt(utxo.value); - this.index = utxo.index; - this.txHash = utxo.txHash; - this.block = utxo.block; - } - - // consume by coinselect - get value(): number { - return Number(this._value); - } - - get bigIntValue(): bigint { - return this._value; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts deleted file mode 100644 index 262f3d2e..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Buffer } from 'buffer'; - -import { generateAccounts } from '../../../test/utils'; -import { TxOutput } from './transaction-output'; - -describe('TxOutput', () => { - it('return correct property', () => { - const account = generateAccounts(1)[0]; - - const input = new TxOutput(10, account.address, Buffer.from('dummy')); - - expect(input.value).toBe(10); - expect(input.address).toStrictEqual(account.address); - expect(input.value).toBe(10); - }); - - it('sets output value with a number', () => { - const account = generateAccounts(1)[0]; - - const input = new TxOutput(10, account.address, Buffer.from('dummy')); - input.value = 20; - - expect(input.value).toBe(20); - }); - - it('sets output value with a bigint', () => { - const account = generateAccounts(1)[0]; - - const input = new TxOutput(10, account.address, Buffer.from('dummy')); - input.value = BigInt(20); - - expect(input.value).toBe(20); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts deleted file mode 100644 index b6758cec..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/transaction-output.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Buffer } from 'buffer'; - -export class TxOutput { - protected _value: bigint; - - // consume by conselect - readonly script: Buffer; - - readonly address: string; - - constructor(value: bigint | number, address: string, script: Buffer) { - this.value = value; - this.address = address; - this.script = script; - } - - // consume by conselect - get value(): number { - return Number(this._value); - } - - set value(value: bigint | number) { - if (typeof value === 'number') { - this._value = BigInt(value); - } else { - this._value = value; - } - } - - get bigIntValue(): bigint { - return this._value; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.test.ts deleted file mode 100644 index e241dfe5..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { networks } from 'bitcoinjs-lib'; -import { Buffer } from 'buffer'; - -import { getScriptForDestination } from './address'; - -describe('address', () => { - const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; - - describe('getScriptForDestination', () => { - it('returns a script', () => { - const val = getScriptForDestination(address, networks.testnet); - expect(val).toBeInstanceOf(Buffer); - }); - - it('throws `Destination address has no matching Script` error if the given address is invalid', () => { - expect(() => - getScriptForDestination('bad-address', networks.testnet), - ).toThrow(`Destination address has no matching Script`); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.ts deleted file mode 100644 index 6390032e..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/address.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { address as addressUtils, type Network } from 'bitcoinjs-lib'; - -/** - * Returns the script for a Bitcoin destination address. - * - * @param address - The Bitcoin destination address. - * @param network - The Bitcoin network. - * @returns The Bitcoin script for the destination address. - * @throws An error if the address does not have a matching script. - */ -export function getScriptForDestination(address: string, network: Network) { - try { - return addressUtils.toOutputScript(address, network); - } catch (error) { - throw new Error('Destination address has no matching Script'); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/deriver.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/deriver.ts deleted file mode 100644 index 00b69415..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/deriver.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { BIP32Interface } from 'bip32'; - -/** - * Derives a new BIP32Interface object using an HD path. - * - * @param rootNode - The root BIP32Interface object that derived from. - * @param path - The HD path, e.g. ["m","0'","0"]. - * @returns A new BIP32Interface object derived by the given path. - */ -export function deriveByPath( - rootNode: BIP32Interface, - path: string[], -): BIP32Interface { - let _path: string[] = path; - if (_path[0] === 'm') { - _path = _path.slice(1); - } - return _path.reduce((prevHd: BIP32Interface, indexStr: string) => { - const isHardened = indexStr.endsWith(`'`); - let _indexStr = indexStr; - - if (isHardened) { - _indexStr = _indexStr.slice(0, -1); - } - - if (!/^\d+$/u.test(_indexStr)) { - throw new Error(`Invalid index`); - } - - const index = parseInt(_indexStr, 10); - return isHardened ? prevHd.deriveHardened(index) : prevHd.derive(index); - }, rootNode); -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/index.ts deleted file mode 100644 index a57139bb..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './payment'; -export * from './network'; -export * from './policy'; -export * from './address'; diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.test.ts deleted file mode 100644 index e60ddc65..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { networks } from 'bitcoinjs-lib'; - -import { Caip2ChainId } from '../../../constants'; -import { getBtcNetwork, getCaip2ChainId } from './network'; - -describe('getBtcNetwork', () => { - it('returns bitcoin testnet network', () => { - const result = getBtcNetwork(Caip2ChainId.Testnet); - expect(result).toStrictEqual(networks.testnet); - }); - - it('returns bitcoin mainnet network', () => { - const result = getBtcNetwork(Caip2ChainId.Mainnet); - expect(result).toStrictEqual(networks.bitcoin); - }); - - it('throws `Invalid network` error if the given network is not support', () => { - expect(() => - getBtcNetwork('someInvalidNetwork' as unknown as Caip2ChainId), - ).toThrow('Invalid network'); - }); -}); - -describe('getCaip2ChainId', () => { - it('returns CAIP-2 testnet chain ID of bitcoin', () => { - const result = getCaip2ChainId(networks.testnet); - expect(result).toStrictEqual(Caip2ChainId.Testnet); - }); - - it('returns CAIP-2 mainnet chain ID of bitcoin', () => { - const result = getCaip2ChainId(networks.bitcoin); - expect(result).toStrictEqual(Caip2ChainId.Mainnet); - }); - - it('throws `Invalid network` error if the given network is not support', () => { - expect(() => getCaip2ChainId(networks.regtest)).toThrow('Invalid network'); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.ts deleted file mode 100644 index 7745221a..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/network.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Network } from 'bitcoinjs-lib'; -import { networks } from 'bitcoinjs-lib'; - -import { Caip2ChainId } from '../../../constants'; - -/** - * Asserts a scope (CAIP-2 Chain ID) is supported. - * - * @param scope - A CAIP-2 Chain ID (scope). - * @throws An error if an invalid network is provided. - */ -export function assertScopeIsSupported(scope: string) { - const scopes = Object.values(Caip2ChainId) as string[]; - - if (!scopes.includes(scope)) { - throw new Error(`Invalid scope, must be one of: ${scopes.join(', ')}`); - } -} - -/** - * Gets a bitcoinjs-lib network instance based on a given CAIP-2 Chain ID. - * - * @param caip2ChainId - The CAIP-2 Chain ID. - * @returns The instance of bitcoinjs-lib network. - * @throws An error if an invalid network is provided. - */ -export function getBtcNetwork(caip2ChainId: string) { - switch (caip2ChainId) { - case Caip2ChainId.Mainnet: - return networks.bitcoin; - case Caip2ChainId.Testnet: - return networks.testnet; - default: - throw new Error('Invalid network'); - } -} - -/** - * Gets a CAIP-2 Chain ID based on a given bitcoinjs-lib network instance. - * - * @param network - The instance of bitcoinjs-lib network. - * @returns The CAIP-2 Chain ID. - * @throws An error if an invalid network is provided. - */ -export function getCaip2ChainId(network: Network): Caip2ChainId { - switch (network) { - case networks.bitcoin: - return Caip2ChainId.Mainnet; - case networks.testnet: - return Caip2ChainId.Testnet; - default: - throw new Error('Invalid network'); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.test.ts deleted file mode 100644 index 7fbc90fd..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import * as biplib from 'bitcoinjs-lib'; -import { Buffer } from 'buffer'; - -import { ScriptType } from '../constants'; -import { getBtcPaymentInst } from './payment'; - -jest.mock('bitcoinjs-lib', () => { - return { - ...jest.requireActual('bitcoinjs-lib'), - payments: { - p2pkh: jest.fn(), - p2sh: jest.fn(), - p2wpkh: jest.fn(), - }, - }; -}); - -class MockPayment implements biplib.Payment {} - -const createMockPayment = (type) => { - const spy = jest.spyOn(biplib.payments, type); - spy.mockReturnValue(new MockPayment()); - return { - spy, - }; -}; - -describe('getBtcPaymentInst', () => { - it('returns P2pkh payment instance', () => { - const { spy } = createMockPayment('p2pkh'); - const pubkey = Buffer.from('pubkey', 'hex'); - - const result = getBtcPaymentInst( - 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 = getBtcPaymentInst( - 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 = getBtcPaymentInst( - 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(() => - getBtcPaymentInst( - 'sometype' as unknown as ScriptType, - pubkey, - biplib.networks.testnet, - ), - ).toThrow('Invalid script type'); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.ts deleted file mode 100644 index 53c89a47..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/payment.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { type Network, type Payment, payments } from 'bitcoinjs-lib'; -import type { Buffer } from 'buffer'; - -import { ScriptType } from '../constants'; - -/** - * Gets a bitcoinjs-lib payment instance based on a given bitcoin script type and public key. - * - * @param scriptType - The script type of the bitcoin account (P2pkh, P2shP2wkh, or P2wpkh). - * @param pubkey - The public key of the account. - * @param network - The bitcoinjs-lib network. - * @returns The instance of bitcoinjs-lib payment. - * @throws An error if an invalid script type is provided. - */ -export function getBtcPaymentInst( - scriptType: ScriptType, - pubkey: Buffer, - network: Network, -): Payment { - switch (scriptType) { - 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'); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.test.ts deleted file mode 100644 index db35d81e..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { DustLimit, ScriptType } from '../constants'; -import { isDust } from './policy'; - -describe('isDust', () => { - it('returns result', () => { - expect(isDust(DustLimit[ScriptType.P2wpkh] + 1, ScriptType.P2wpkh)).toBe( - false, - ); - expect( - isDust(BigInt(DustLimit[ScriptType.P2wpkh] + 1), ScriptType.P2wpkh), - ).toBe(false); - expect( - isDust(BigInt(DustLimit[ScriptType.P2wpkh] - 1), ScriptType.P2wpkh), - ).toBe(true); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.ts deleted file mode 100644 index 905ec2ec..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/utils/policy.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ScriptType } from '../constants'; -import { DustLimit } from '../constants'; - -/** - * Determines if a given amount is considered dust based on the hardcoded dust limit for a given script type. - * - * @param amt - The amount to compare. - * @param scriptType - The script type for which to calculate the dust limit. - * @returns A boolean indicating whether the amount is considered dust. - */ -export function isDust(amt: bigint | number, scriptType: ScriptType): boolean { - // TODO: Calculate dust threshold by network fee rate, rather than hardcoding it. - if (typeof amt === 'number') { - return amt < DustLimit[scriptType]; - } - return amt < BigInt(DustLimit[scriptType]); -} diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts deleted file mode 100644 index 721a2c3b..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.test.ts +++ /dev/null @@ -1,434 +0,0 @@ -import { networks } from 'bitcoinjs-lib'; -import { Buffer } from 'buffer'; - -import { generateFormattedUtxos } from '../../../test/utils'; -import { P2WPKHAccount, P2WPKHTestnetAccount } from './account'; -import { CoinSelectService } from './coin-select'; -import { DustLimit, ScriptType } from './constants'; -import { BtcAccountDeriver } from './deriver'; -import { WalletError } from './exceptions'; -import { PsbtService } from './psbt'; -import { TxInfo } from './transaction-info'; -import { TxInput } from './transaction-input'; -import { TxOutput } from './transaction-output'; -import { BtcWallet } from './wallet'; - -jest.mock('../../utils/snap'); -jest.mock('../../utils/logger'); - -describe('BtcWallet', () => { - const bip44Account = "0'"; - const bip44Change = '0'; - - const createMockDeriver = (network) => { - const rootSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getRoot'); - const childSpy = jest.spyOn(BtcAccountDeriver.prototype, 'getChild'); - - return { - instance: new BtcAccountDeriver(network), - rootSpy, - childSpy, - }; - }; - - const createMockWallet = (network) => { - const { instance: deriver, rootSpy, childSpy } = createMockDeriver(network); - - const instance = new BtcWallet(deriver, network); - return { - instance, - rootSpy, - childSpy, - }; - }; - - const createMockTxIntent = (address: string, amount: number) => { - return [ - { - address, - value: BigInt(amount), - }, - ]; - }; - - describe('unlock', () => { - const p2wpkhPathMainnet = P2WPKHAccount.path; - const p2wpkhPathTestnet = P2WPKHTestnetAccount.path; - - it('creates an `Account` object with different hd path on different network', async () => { - const { instance, rootSpy } = createMockWallet(networks.testnet); - const { instance: mainnetInstance, rootSpy: mainnetRootSpy } = - createMockWallet(networks.bitcoin); - - const idx = 0; - - const testnetAcc = await instance.unlock(idx); - const mainnetAcc = await mainnetInstance.unlock(idx); - - expect(mainnetRootSpy).toHaveBeenCalledWith(p2wpkhPathMainnet); - expect(rootSpy).toHaveBeenCalledWith(p2wpkhPathTestnet); - expect(testnetAcc.signer.publicKey).not.toStrictEqual( - mainnetAcc.signer.publicKey, - ); - }); - - it('creates an `Account` object with default type', async () => { - const network = networks.testnet; - const { rootSpy, childSpy, instance } = createMockWallet(network); - const idx = 0; - - const result = await instance.unlock(idx); - - expect(result).toBeInstanceOf(P2WPKHTestnetAccount); - expect(rootSpy).toHaveBeenCalledWith(p2wpkhPathTestnet); - expect(childSpy).toHaveBeenCalledWith(expect.any(Object), [ - `m`, - bip44Account, - bip44Change, - `${idx}`, - ]); - }); - - it('creates an `Account` object with type bip122:p2wpkh', async () => { - const network = networks.testnet; - const { rootSpy, childSpy, instance } = createMockWallet(network); - const idx = 0; - - const result = await instance.unlock(idx, `bip122:p2wpkh`); - - expect(result).toBeInstanceOf(P2WPKHTestnetAccount); - expect(rootSpy).toHaveBeenCalledWith(p2wpkhPathTestnet); - expect(childSpy).toHaveBeenCalledWith(expect.any(Object), [ - `m`, - bip44Account, - bip44Change, - `${idx}`, - ]); - }); - - it('creates an `Account` object with type `p2wpkh`', async () => { - const network = networks.testnet; - const { rootSpy, childSpy, instance } = createMockWallet(network); - const idx = 0; - - const result = await instance.unlock(idx, ScriptType.P2wpkh); - - expect(result).toBeInstanceOf(P2WPKHTestnetAccount); - expect(rootSpy).toHaveBeenCalledWith(p2wpkhPathTestnet); - expect(childSpy).toHaveBeenCalledWith(expect.any(Object), [ - `m`, - bip44Account, - bip44Change, - `${idx}`, - ]); - }); - - it('throws error if the account cannot be unlocked', async () => { - const network = networks.testnet; - const idx = 0; - const { instance } = createMockWallet(network); - - await expect(instance.unlock(idx, ScriptType.P2pkh)).rejects.toThrow( - WalletError, - ); - }); - - it('throws `Invalid network` error if the network is not supported', async () => { - const network = networks.regtest; - const idx = 0; - const { instance } = createMockWallet(network); - - await expect(instance.unlock(idx, ScriptType.P2wpkh)).rejects.toThrow( - `Invalid network`, - ); - }); - }); - - describe('createTransaction', () => { - it('creates an transaction with changes', async () => { - const network = networks.testnet; - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const account = await wallet.unlock(0, ScriptType.P2wpkh); - - const utxos = generateFormattedUtxos( - account.address, - 200, - 100000, - 100000, - ); - - const result = await wallet.createTransaction( - account, - createMockTxIntent(account.address, 100000), - { - utxos, - fee: 56, - replaceable: true, - }, - ); - - const info = result.txInfo; - const { recipients } = info; - const { change } = info; - - expect(recipients).toHaveLength(1); - expect(change).toBeDefined(); - expect(result).toStrictEqual({ - tx: expect.any(String), - txInfo: expect.any(TxInfo), - }); - }); - - it('creates an transaction without changes', async () => { - const network = networks.testnet; - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormattedUtxos(account.address, 200, 10000, 10000); - - const result = await wallet.createTransaction( - account, - createMockTxIntent(account.address, 100000), - { - utxos, - fee: 50, - replaceable: true, - }, - ); - - const info = result.txInfo; - const { recipients } = info; - const { change } = info; - - expect(recipients).toHaveLength(1); - expect(change).toBeUndefined(); - expect(result).toStrictEqual({ - tx: expect.any(String), - txInfo: expect.any(TxInfo), - }); - }); - - it('passes correct parameter to CoinSelectService', async () => { - const network = networks.testnet; - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormattedUtxos(account.address, 2); - const coinSelectServiceSpy = jest.spyOn( - CoinSelectService.prototype, - 'selectCoins', - ); - - await wallet.createTransaction( - account, - createMockTxIntent(account.address, DustLimit[account.scriptType] + 1), - { - utxos, - fee: 1, - replaceable: true, - }, - ); - - expect(coinSelectServiceSpy).toHaveBeenCalledWith( - expect.any(Array), - expect.any(Array), - expect.any(TxOutput), - ); - - for (const input of coinSelectServiceSpy.mock.calls[0][0]) { - expect(input).toBeInstanceOf(TxInput); - } - - for (const output of coinSelectServiceSpy.mock.calls[0][1]) { - expect(output).toBeInstanceOf(TxOutput); - } - }); - - it('uses `replaceable = true` if not provided', async () => { - const network = networks.testnet; - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const chgAccount = await wallet.unlock(0, ScriptType.P2wpkh); - const recipient = await wallet.unlock(1, ScriptType.P2wpkh); - const utxos = generateFormattedUtxos(chgAccount.address, 1, 10000, 10000); - const psbtSpy = jest.spyOn(PsbtService.prototype, 'addInputs'); - - await wallet.createTransaction( - chgAccount, - createMockTxIntent(recipient.address, 500), - { - utxos, - fee: 1, - }, - ); - - expect(psbtSpy).toHaveBeenCalledWith( - expect.any(Array), - true, - expect.any(String), - expect.any(Buffer), - expect.any(Buffer), - ); - }); - - it('remove dist change', async () => { - const network = networks.testnet; - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const chgAccount = await wallet.unlock(0, ScriptType.P2wpkh); - const recipient = await wallet.unlock(1, ScriptType.P2wpkh); - const utxos = generateFormattedUtxos(chgAccount.address, 2, 10000, 10000); - const coinSelectServiceSpy = jest.spyOn( - CoinSelectService.prototype, - 'selectCoins', - ); - - const selectionResult = { - change: new TxOutput( - DustLimit[chgAccount.scriptType] - 1, - chgAccount.address, - chgAccount.script, - ), - fee: 100, - inputs: utxos.map((utxo) => new TxInput(utxo, chgAccount.script)), - outputs: [new TxOutput(500, recipient.address, recipient.script)], - }; - - coinSelectServiceSpy.mockReturnValue(selectionResult); - - const result = await wallet.createTransaction( - chgAccount, - createMockTxIntent(recipient.address, 500), - { - utxos, - fee: 1, - replaceable: true, - }, - ); - - const info: TxInfo = result.txInfo as unknown as TxInfo; - - expect(info.txFee).toBe(BigInt(19500)); - expect(info.change).toBeUndefined(); - }); - - it('throws `Transaction amount too small` error if the transaction output is too small', async () => { - const network = networks.testnet; - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormattedUtxos(account.address, 2, 8000, 8000); - - await expect( - wallet.createTransaction( - account, - createMockTxIntent(account.address, 1), - { - utxos, - fee: 20, - replaceable: true, - }, - ), - ).rejects.toThrow('Transaction amount too small'); - }); - - it('throws `Insufficient funds` error if the given utxos total amount is insufficient', async () => { - const network = networks.testnet; - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const chgAccount = await wallet.unlock(0, ScriptType.P2wpkh); - const recipient = await wallet.unlock(1, ScriptType.P2wpkh); - const utxos = generateFormattedUtxos(chgAccount.address, 2, 1000, 1000); - - await expect( - wallet.createTransaction( - chgAccount, - createMockTxIntent(recipient.address, 50000), - { - utxos, - fee: 20, - replaceable: true, - }, - ), - ).rejects.toThrow('Insufficient funds'); - }); - }); - - describe('signTransaction', () => { - it('signs an transaction', async () => { - const network = networks.testnet; - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const account = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormattedUtxos(account.address, 2, 10000, 10000); - - const { tx } = await wallet.createTransaction( - account, - createMockTxIntent(account.address, DustLimit[account.scriptType] + 1), - { - utxos, - fee: 1, - replaceable: true, - }, - ); - - const sign = await wallet.signTransaction(account.signer, tx); - - expect(sign).not.toBeNull(); - expect(sign).not.toBe(''); - }); - }); - - describe('estimateFee', () => { - it('estimates fee', async () => { - const network = networks.testnet; - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - const chgAccount = await wallet.unlock(0, ScriptType.P2wpkh); - const utxos = generateFormattedUtxos( - chgAccount.address, - 10, - 10000, - 10000, - ); - const coinSelectServiceSpy = jest.spyOn( - CoinSelectService.prototype, - 'selectCoins', - ); - - const selectionResult = { - change: new TxOutput( - DustLimit[chgAccount.scriptType] - 1, - chgAccount.address, - chgAccount.script, - ), - fee: 100, - inputs: utxos.map((utxo) => new TxInput(utxo, chgAccount.script)), - outputs: [new TxOutput(500, chgAccount.address, chgAccount.script)], - }; - - coinSelectServiceSpy.mockReturnValue(selectionResult); - - const result = await wallet.estimateFee( - chgAccount, - createMockTxIntent( - chgAccount.address, - DustLimit[chgAccount.scriptType] + 1, - ), - { - utxos, - fee: 1, - }, - ); - - expect(coinSelectServiceSpy).toHaveBeenCalledWith( - expect.any(Array), - expect.any(Array), - expect.any(TxOutput), - ); - - expect(result).toStrictEqual(selectionResult); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts b/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts deleted file mode 100644 index 93317c93..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/bitcoin/wallet/wallet.ts +++ /dev/null @@ -1,286 +0,0 @@ -import type { BIP32Interface } from 'bip32'; -import { networks, type Network } from 'bitcoinjs-lib'; -import { type Buffer } from 'buffer'; - -import { bufferToString, compactError, hexToBuffer, logger } from '../../utils'; -import type { Utxo } from '../chain'; -import type { BtcAccount } from './account'; -import { - P2WPKHAccount, - P2WPKHTestnetAccount, - type IStaticBtcAccount, -} from './account'; -import type { SelectionResult } from './coin-select'; -import { CoinSelectService } from './coin-select'; -import { ScriptType } from './constants'; -import type { BtcAccountDeriver } from './deriver'; -import { - WalletError, - TransactionDustError, - InsufficientFundsError, -} from './exceptions'; -import { PsbtService } from './psbt'; -import { AccountSigner } from './signer'; -import { TxInfo } from './transaction-info'; -import { TxInput } from './transaction-input'; -import { TxOutput } from './transaction-output'; -import { isDust, getScriptForDestination } from './utils'; - -export type Recipient = { - address: string; - value: bigint; -}; - -export type Transaction = { - tx: string; - txInfo: ITxInfo; -}; - -export type ITxInfo = { - sender: string; - change?: Recipient; - recipients: Recipient[]; - total: bigint; - txFee: bigint; - feeRate: bigint; -}; - -export type CreateTransactionOptions = { - utxos: Utxo[]; - fee: number; - // - // BIP125 opt-in RBF flag, - // - replaceable?: boolean; -}; - -export class BtcWallet { - protected readonly _deriver: BtcAccountDeriver; - - protected readonly _network: Network; - - constructor(deriver: BtcAccountDeriver, network: Network) { - this._deriver = deriver; - this._network = network; - } - - /** - * Unlocks an account by index and script type. - * - * @param index - The index to derive from the node. - * @param type - The script type of the unlocked account, e.g. `bip122:p2pkh`. - * @returns A promise that resolves to a `BtcAccount` object. - */ - async unlock(index: number, type?: string): Promise { - try { - const AccountCtor = this.getAccountCtor(type ?? ScriptType.P2wpkh); - const childNodeHdPath = [`m`, `0'`, `0`, `${index}`]; - const rootNode = await this._deriver.getRoot(AccountCtor.path); - const childNode = await this._deriver.getChild(rootNode, childNodeHdPath); - - return new AccountCtor( - bufferToString(rootNode.fingerprint, 'hex'), - index, - childNodeHdPath.join('/'), - bufferToString(childNode.publicKey, 'hex'), - this._network, - AccountCtor.scriptType, - `bip122:${AccountCtor.scriptType.toLowerCase()}`, - this.getHdSigner(rootNode), - ); - } catch (error) { - throw compactError(error, WalletError); - } - } - - /** - * Creates a transaction using the given account, transaction intent, and options. - * - * @param account - The `IAccount` object to create the transaction. - * @param recipients - The transaction recipients. - * @param options - The options to use when creating the transaction. - * @returns A promise that resolves to an object containing the transaction hash and transaction info. - * @throws {TxValidationError} Throws a TxValidationError if there are insufficient funds to complete the transaction. - */ - async createTransaction( - account: BtcAccount, - recipients: Recipient[], - options: CreateTransactionOptions, - ): Promise { - const { - scriptType, - script: scriptOutput, - address, - hdPath, - pubkey, - mfp, - signer, - } = account; - - const inputs = this.createTxInput(options.utxos, scriptOutput); - const outputs = this.createTxOutput(recipients, scriptType); - // TODO: We could have the minimum fee rate coming from the parameter and use it here: - const feeRate = this.getFeeRate(options.fee); - - const selectionResult = this.selectCoins( - inputs, - outputs, - new TxOutput(0, address, scriptOutput), - feeRate, - ); - - if ( - selectionResult.inputs.length === 0 || - selectionResult.outputs.length === 0 - ) { - throw new InsufficientFundsError(); - } - - const psbtService = new PsbtService(this._network); - psbtService.addInputs( - selectionResult.inputs, - // Is now enabled by default (requirement for the Keyring API v9.0.0) - options.replaceable ?? true, - hdPath, - hexToBuffer(pubkey, false), - hexToBuffer(mfp, false), - ); - - const txInfo = new TxInfo(address, feeRate); - - for (const output of selectionResult.outputs) { - psbtService.addOutput(output); - txInfo.addRecipient(output); - } - - if (selectionResult.change) { - if (isDust(selectionResult.change.value, scriptType)) { - logger.warn( - '[BtcWallet.createTransaction] Change is too small, adding to fees', - ); - } else { - psbtService.addOutput(selectionResult.change); - txInfo.addChange(selectionResult.change); - } - } - - // Sign dummy transaction to extract the fee which is more accurate - const signedService = await psbtService.signDummy(signer); - txInfo.txFee = signedService.getFee(); - - return { - tx: psbtService.toBase64(), - txInfo, - }; - } - - /** - * Creates a transaction using the given account, transaction intent, and options. - * - * @param account - The `IAccount` object to create the transaction. - * @param recipients - The transaction recipients. - * @param options - The options to use when creating the transaction. - * @returns A promise that resolves to an object containing the transaction hash and transaction info. - */ - async estimateFee( - account: BtcAccount, - recipients: Recipient[], - options: CreateTransactionOptions, - ): Promise { - const { scriptType, script: scriptOutput } = account; - - const inputs = this.createTxInput(options.utxos, scriptOutput); - const outputs = this.createTxOutput(recipients, scriptType); - // TODO: We could have the minimum fee rate coming from the parameter and use it here: - const feeRate = this.getFeeRate(options.fee); - - return this.selectCoins( - inputs, - outputs, - new TxOutput(0, account.address, scriptOutput), - feeRate, - ); - } - - /** - * Signs a transaction by the given encoded transaction string. - * - * @param signer - The `AccountSigner` object to sign the transaction. - * @param tx - The encoded transaction string to convert back to a transaction. - * @returns A promise that resolves to a string of the signed transaction. - */ - async signTransaction(signer: AccountSigner, tx: string): Promise { - const psbtService = PsbtService.fromBase64(this._network, tx); - await psbtService.signNVerify(signer); - return psbtService.finalize(); - } - - protected getHdSigner(rootNode: BIP32Interface): AccountSigner { - return new AccountSigner(rootNode, rootNode.fingerprint); - } - - protected getAccountCtor(type: string): IStaticBtcAccount { - let scriptType = type; - if (type.includes('bip122:')) { - scriptType = type.split(':')[1]; - } - - switch (scriptType.toLowerCase()) { - case ScriptType.P2wpkh.toLowerCase(): - return this.getP2WPKHAccountCtorByNetwork(); - default: - throw new WalletError('Invalid script type'); - } - } - - protected createTxInput(utxos: Utxo[], scriptOutput: Buffer): TxInput[] { - return utxos.map((utxo) => new TxInput(utxo, scriptOutput)); - } - - protected createTxOutput( - recipients: Recipient[], - scriptType: ScriptType, - ): TxOutput[] { - return recipients.map((recipient) => { - if (isDust(recipient.value, scriptType)) { - throw new TransactionDustError(); - } - const destinationScriptOutput = getScriptForDestination( - recipient.address, - this._network, - ); - return new TxOutput( - recipient.value, - recipient.address, - destinationScriptOutput, - ); - }); - } - - protected selectCoins( - inputs: TxInput[], - outputs: TxOutput[], - change: TxOutput, - feeRate: number, - ): SelectionResult { - const coinSelectService = new CoinSelectService(feeRate); - return coinSelectService.selectCoins(inputs, outputs, change); - } - - protected getP2WPKHAccountCtorByNetwork(): IStaticBtcAccount { - switch (this._network) { - case networks.bitcoin: - return P2WPKHAccount; - case networks.testnet: - return P2WPKHTestnetAccount; - default: - throw new WalletError('Invalid network'); - } - } - - protected getFeeRate(feeRate: number) { - // READ THIS CAREFULLY: - // Do not ever accept a 0 fee rate, we need to ensure it is at least 1 - return Math.max(feeRate, 1); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/cacheManager.ts b/merged-packages/bitcoin-wallet-snap/src/cacheManager.ts deleted file mode 100644 index 73a444f2..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/cacheManager.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { Fees, Fee } from './bitcoin/chain'; -import { DefaultCacheTtl } from './config'; -import { Caip2ChainId } from './constants'; -import { SnapStateManager, logger, compactError } from './utils'; - -export type SerializedFee = Omit & { - rate: string; -}; - -export type SerializedFees = { - fees: SerializedFee[]; - expiration: number; -}; - -type WithExpiration = Value & { expiration: number }; - -export type ISerializable = { - data: Data; - - serialize(): SerializeData; - deserialize(serializeData: SerializeData): void; -}; - -export class SerializableFees - implements ISerializable, SerializedFees> -{ - data: WithExpiration = { - fees: [], - expiration: 0, - }; - - constructor(data: Fees = { fees: [] }, expiresIn = DefaultCacheTtl) { - this.data = { - fees: data.fees, - expiration: expiresIn ?? Date.now() + DefaultCacheTtl, - }; - } - - static fromSerialized(serializeData: SerializedFees): SerializableFees { - const fees = new SerializableFees(); - fees.deserialize(serializeData); - return fees; - } - - valueOf() { - return this.data; - } - - serialize() { - return { - fees: this.data.fees.map((fee) => ({ - ...fee, - rate: fee.rate.toString(), - })), - expiration: this.data.expiration, - }; - } - - deserialize(serializeData: SerializedFees): void { - Object.entries(serializeData.fees).forEach(([key, value]) => { - const fee = value as { type: string; rate: string }; - this.data.fees[key] = { - type: fee.type, - rate: BigInt(fee.rate), - expiration: serializeData.expiration, - }; - }); - } -} - -export type SerializedCacheState = { - feeRate: Record; -}; - -export type CacheState = { - feeRate: Record>; -}; - -export class CachedValue { - value: ValueType; - - readonly expiredAt: number; - - // Will be expired by default if no `expiredAt` is given. - constructor(value: ValueType, expiredAt?: number) { - this.value = value; - this.expiredAt = expiredAt ?? Date.now() + DefaultCacheTtl; - } - - isExpired() { - return this.expiredAt <= Date.now(); - } -} - -export class CacheStateManager extends SnapStateManager { - constructor() { - super({ encrypted: false }); - } - - protected override async get(): Promise { - return super.get().then((persistedState: SerializedCacheState) => { - return ( - persistedState || { - feeRate: { - [Caip2ChainId.Mainnet]: { - fees: [], - expiration: 0, - }, - [Caip2ChainId.Testnet]: { - fees: [], - expiration: 0, - }, - }, - } - ); - }); - } - - async getFeeRate( - scope: Caip2ChainId, - ): Promise | null> { - try { - const state = await this.get(); - const serializedFee = state.feeRate[scope]; - const fee = new CachedValue( - SerializableFees.fromSerialized(serializedFee), - serializedFee.expiration, - ); - return fee; - } catch (error) { - logger.warn('Failed to get fee rate', error); - return null; - } - } - - async setFeeRate(scope: Caip2ChainId, value: Fees): Promise { - try { - await this.update(async (state: SerializedCacheState) => { - state.feeRate[scope] = new SerializableFees(value).serialize(); - }); - } catch (error) { - throw compactError(error, Error); - } - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 9bb768da..7a786297 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -1,82 +1,39 @@ -import { FeeRate } from './bitcoin/chain/constants'; -import { Caip2ChainId, Caip19Asset } from './constants'; +/* eslint-disable no-restricted-globals */ -export enum ApiClient { - QuickNode = 'QuickNode', - SimpleHash = 'SimpleHash', -} +import type { AddressType, Network } from 'bitcoindevkit'; -export type SnapChainConfig = { - onChainService: { - apiClient: { - [ApiClient.QuickNode]: { - options: { - mainnetEndpoint: string | undefined; - testnetEndpoint: string | undefined; - }; - }; - [ApiClient.SimpleHash]: { - options: { - apiKey: string | undefined; - }; - }; - }; - }; - wallet: { - defaultAccountIndex: number; - defaultAccountType: string; - }; - availableNetworks: string[]; - availableAssets: string[]; - defaultFeeRate: FeeRate; - unit: string; - explorer: { - [network in string]: string; - }; - logLevel: string; - defaultConfirmationThreshold: number; - defaultSatsProtectionEnabled: boolean; -}; +import type { SnapConfig } from './entities'; -export const Config: SnapChainConfig = { - onChainService: { - apiClient: { - [ApiClient.QuickNode]: { - options: { - // eslint-disable-next-line no-restricted-globals - testnetEndpoint: process.env.QUICKNODE_TESTNET_ENDPOINT, - // eslint-disable-next-line no-restricted-globals - mainnetEndpoint: process.env.QUICKNODE_MAINNET_ENDPOINT, - }, - }, - [ApiClient.SimpleHash]: { - options: { - // eslint-disable-next-line no-restricted-globals - apiKey: process.env.SIMPLEHASH_API_KEY, - }, - }, - }, +export const Config: SnapConfig = { + logLevel: process.env.LOG_LEVEL ?? '3', + encrypt: false, + accounts: { + index: 0, + defaultNetwork: (process.env.DEFAULT_NETWORK ?? 'bitcoin') as Network, + defaultAddressType: (process.env.DEFAULT_ADDRESS_TYPE ?? + 'p2wpkh') as AddressType, }, - wallet: { - defaultAccountIndex: 0, - defaultAccountType: 'bip122:p2wpkh', + chain: { + parallelRequests: 1, + stopGap: 10, + url: { + bitcoin: process.env.ESPLORA_BITCOIN ?? 'https://blockstream.info/api', + testnet: + process.env.ESPLORA_TESTNET ?? 'https://blockstream.info/testnet/api', + testnet4: + process.env.ESPLORA_TESTNET4 ?? 'https://mempool.space/testnet4/api/v1', + signet: process.env.ESPLORA_SIGNET ?? 'https://mutinynet.com/api', + regtest: + process.env.ESPLORA_REGTEST ?? 'http://localhost:8094/regtest/api', + }, }, - availableNetworks: Object.values(Caip2ChainId), - availableAssets: Object.values(Caip19Asset), - defaultFeeRate: FeeRate.Medium, - unit: 'BTC', - explorer: { - // eslint-disable-next-line no-template-curly-in-string - [Caip2ChainId.Mainnet]: 'https://blockstream.info/address/${address}', - [Caip2ChainId.Testnet]: - // eslint-disable-next-line no-template-curly-in-string - 'https://blockstream.info/testnet/address/${address}', + simpleHash: { + apiKey: process.env.SIMPLEHASH_API_KEY, + url: { + bitcoin: + process.env.SIMPLEHASH_BITCOIN ?? `https://api.simplehash.com/api/v0`, + }, }, - // eslint-disable-next-line no-restricted-globals - logLevel: process.env.LOG_LEVEL ?? '0', - // the number of confirmations required for a transaction to be considered confirmed - defaultConfirmationThreshold: 6, - defaultSatsProtectionEnabled: true, + targetBlocksConfirmation: 3, + fallbackFeeRate: 5.0, }; - -export const DefaultCacheTtl = 60 * 1000; diff --git a/merged-packages/bitcoin-wallet-snap/src/configv2.ts b/merged-packages/bitcoin-wallet-snap/src/configv2.ts deleted file mode 100644 index 0f676116..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/configv2.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* eslint-disable no-restricted-globals */ - -import type { AddressType, Network } from 'bitcoindevkit'; - -import type { SnapConfig } from './entities'; - -// ConfigV2 exists temporarily to avoid modifying the old config object before it is removed. -export const ConfigV2: SnapConfig = { - encrypt: false, - // Defaults to v1 to use the "original" keyring. - keyringVersion: process.env.KEYRING_VERSION ?? 'v1', - accounts: { - index: 0, - defaultNetwork: (process.env.DEFAULT_NETWORK ?? 'bitcoin') as Network, - defaultAddressType: (process.env.DEFAULT_ADDRESS_TYPE ?? - 'p2wpkh') as AddressType, - }, - chain: { - parallelRequests: 1, - stopGap: 10, - url: { - bitcoin: - process.env.ESPLORA_PROVIDER_BITCOIN ?? 'https://blockstream.info/api', - testnet: - process.env.ESPLORA_PROVIDER_TESTNET ?? - 'https://blockstream.info/testnet/api', - testnet4: - process.env.ESPLORA_PROVIDER_TESTNET4 ?? - 'https://mempool.space/testnet4/api/v1', - signet: - process.env.ESPLORA_PROVIDER_SIGNET ?? 'https://mutinynet.com/api', - regtest: - process.env.ESPLORA_PROVIDER_REGTEST ?? - 'http://localhost:8094/regtest/api', - }, - }, - simpleHash: { - apiKey: process.env.SIMPLEHASH_API_KEY, // Public test API key - url: { - bitcoin: - process.env.SIMPLEHASH_BITCOIN ?? `https://api.simplehash.com/api/v0`, - }, - }, - targetBlocksConfirmation: 3, - fallbackFeeRate: 5.0, -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/constants.ts b/merged-packages/bitcoin-wallet-snap/src/constants.ts deleted file mode 100644 index b3de4926..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/constants.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { CaipChainId } from '@metamask/utils'; - -export enum Caip2ChainId { - Mainnet = 'bip122:000000000019d6689c085ae165831e93', - Testnet = 'bip122:000000000933ea01ad0ee984209779ba', -} - -export enum Caip19Asset { - Btc = 'bip122:000000000019d6689c085ae165831e93/slip44:0', - TBtc = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0', -} - -export const Caip2ChainIdToNetworkName: Record = { - [Caip2ChainId.Mainnet]: 'Bitcoin Mainnet', - [Caip2ChainId.Testnet]: 'Bitcoin Testnet', -}; - -export enum BaseExplorerUrl { - Mainnet = 'https://blockstream.info/address', - Testnet = 'https://blockstream.info/testnet/address', -} diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts b/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts index c3147dc4..6aa3637c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts @@ -2,6 +2,14 @@ import type { FeeEstimates, Network, Transaction } from 'bitcoindevkit'; import type { BitcoinAccount } from './account'; +export const BlockTime: Record = { + bitcoin: 10, + testnet: 10, + testnet4: 10, + signet: 0.5, + regtest: 0.5, +}; + export type BlockchainClient = { /** * Perform a full scan operation on the account. diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts index 82c0cb7c..51e9d30e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -1,10 +1,10 @@ import type { AddressType, Network } from 'bitcoindevkit'; export type SnapConfig = { + logLevel: string; encrypt: boolean; accounts: AccountsConfig; chain: ChainConfig; - keyringVersion: string; simpleHash: SimpleHashConfig; // Temporary config to set the expected confirmation target, should eventually be chosen by the user targetBlocksConfirmation: number; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/error.ts b/merged-packages/bitcoin-wallet-snap/src/entities/error.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/utils/error.ts rename to merged-packages/bitcoin-wallet-snap/src/entities/error.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts index 646e034b..6826d799 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts @@ -6,3 +6,5 @@ export * from './send-flow'; export * from './transaction'; export * from './snap'; export * from './meta-protocols'; +export * from './error'; +export * from './locale'; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/locale.test.ts b/merged-packages/bitcoin-wallet-snap/src/entities/locale.test.ts similarity index 100% rename from merged-packages/bitcoin-wallet-snap/src/utils/locale.test.ts rename to merged-packages/bitcoin-wallet-snap/src/entities/locale.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/locale.ts b/merged-packages/bitcoin-wallet-snap/src/entities/locale.ts similarity index 93% rename from merged-packages/bitcoin-wallet-snap/src/utils/locale.ts rename to merged-packages/bitcoin-wallet-snap/src/entities/locale.ts index b9d51ab5..3d721e0e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/utils/locale.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/locale.ts @@ -1,6 +1,6 @@ -import { acquireLock } from './lock'; +import { Mutex } from 'async-mutex'; -const localeSetterLock = acquireLock(true); +const localeSetterLock = new Mutex(); let locale: Locale; /** diff --git a/merged-packages/bitcoin-wallet-snap/src/exceptions.ts b/merged-packages/bitcoin-wallet-snap/src/exceptions.ts deleted file mode 100644 index 244981d0..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/exceptions.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { CustomError } from './utils'; - -export class AccountNotFoundError extends CustomError { - constructor(errMsg?: string) { - super(errMsg ?? `Account not found`); - } -} - -export class MethodNotImplementedError extends CustomError { - constructor(errMsg?: string) { - super(errMsg ?? `Method not implemented`); - } -} - -export class FeeRateUnavailableError extends CustomError { - constructor(errMsg?: string) { - super(errMsg ?? `No fee rates available`); - } -} - -export class SendFlowRequestNotFoundError extends CustomError { - constructor(errMsg?: string) { - super(errMsg ?? `Send flow request not found`); - } -} - -export class CurrencyRatesNotAvailableError extends CustomError { - constructor(errMsg?: string) { - super(errMsg ?? `Currency rates not available`); - } -} - -/** - * Determines if the given error is a Snap exception. - * - * @param error - The error instance to be checked. - * @returns A boolean indicating whether the error is a Snap . - */ -export function isSnapException(error: Error): boolean { - const errors = [ - AccountNotFoundError, - MethodNotImplementedError, - SendFlowRequestNotFoundError, - CurrencyRatesNotAvailableError, - ]; - return errors.some((errType) => error instanceof errType); -} diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.test.ts b/merged-packages/bitcoin-wallet-snap/src/factory.test.ts deleted file mode 100644 index 99ef3215..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/factory.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { BtcOnChainService } from './bitcoin/chain'; -import { QuickNodeClient } from './bitcoin/chain/clients/quicknode'; -import { SimpleHashClient } from './bitcoin/chain/clients/simplehash'; -import { BtcWallet } from './bitcoin/wallet'; -import { ApiClient, Config } from './config'; -import { Caip2ChainId } from './constants'; -import { Factory } from './factory'; - -describe('Factory', () => { - describe('createOnChainServiceProvider', () => { - it('creates BtcOnChainService instance', () => { - jest.spyOn(Factory, 'createQuickNodeClient').mockReturnThis(); - jest.spyOn(Factory, 'createSimpleHashClient').mockReturnThis(); - - const instance = Factory.createOnChainServiceProvider( - Caip2ChainId.Testnet, - ); - - expect(instance).toBeInstanceOf(BtcOnChainService); - }); - }); - - describe('createQuickNodeClient', () => { - const quickNodeConfig = - Config.onChainService.apiClient[ApiClient.QuickNode]; - - afterEach(() => { - quickNodeConfig.options = { - mainnetEndpoint: undefined, - testnetEndpoint: undefined, - }; - }); - - it('creates CreateQuickNodeClient instance', () => { - quickNodeConfig.options = { - mainnetEndpoint: 'http://mainnetEndpoint', - testnetEndpoint: 'http://testnetEndpoint', - }; - - const instance = Factory.createQuickNodeClient(Caip2ChainId.Testnet); - - expect(instance).toBeInstanceOf(QuickNodeClient); - }); - - it('throws `QuickNode endpoints have not been configured` error if the endpoints have not been provided', () => { - quickNodeConfig.options = { - mainnetEndpoint: undefined, - testnetEndpoint: undefined, - }; - - expect(() => Factory.createQuickNodeClient(Caip2ChainId.Testnet)).toThrow( - 'QuickNode endpoints have not been configured', - ); - }); - }); - - describe('createSimpleHashClient', () => { - const simpleHashConfig = - Config.onChainService.apiClient[ApiClient.SimpleHash]; - - afterEach(() => { - simpleHashConfig.options = { - apiKey: undefined, - }; - }); - - it('creates createSimpleHashClient instance', () => { - simpleHashConfig.options = { - apiKey: 'API_KEY', - }; - - const instance = Factory.createSimpleHashClient(); - - expect(instance).toBeInstanceOf(SimpleHashClient); - }); - - it('throws `Simplehash API key has not been configured` error if the API key has not been provided', () => { - expect(() => Factory.createSimpleHashClient()).toThrow( - 'Simplehash API key has not been configured', - ); - }); - }); - - describe('createWallet', () => { - it('creates BtcWallet instance', () => { - const instance = Factory.createWallet(Caip2ChainId.Testnet); - - expect(instance).toBeInstanceOf(BtcWallet); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/factory.ts b/merged-packages/bitcoin-wallet-snap/src/factory.ts deleted file mode 100644 index 290adaee..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/factory.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { BtcOnChainService } from './bitcoin/chain'; -import { QuickNodeClient } from './bitcoin/chain/clients/quicknode'; -import { SimpleHashClient } from './bitcoin/chain/clients/simplehash'; -import { BtcAccountDeriver, BtcWallet, getBtcNetwork } from './bitcoin/wallet'; -import { CacheStateManager } from './cacheManager'; -import { ApiClient, Config } from './config'; - -export class Factory { - static createOnChainServiceProvider(scope: string): BtcOnChainService { - const btcNetwork = getBtcNetwork(scope); - - const quickNodeClient = Factory.createQuickNodeClient(scope); - const simpleHashClient = Factory.createSimpleHashClient(); - const cachedStateManager = Factory.createCachedStateManager(); - - return new BtcOnChainService( - { - dataClient: quickNodeClient, - satsProtectionDataClient: simpleHashClient, - cacheStateManager: cachedStateManager, - }, - { - network: btcNetwork, - }, - ); - } - - static createQuickNodeClient(scope: string): QuickNodeClient { - const btcNetwork = getBtcNetwork(scope); - - const { mainnetEndpoint, testnetEndpoint } = - Config.onChainService.apiClient[ApiClient.QuickNode].options; - - if (!mainnetEndpoint || !testnetEndpoint) { - throw new Error('QuickNode endpoints have not been configured'); - } - - return new QuickNodeClient({ - network: btcNetwork, - mainnetEndpoint, - testnetEndpoint, - }); - } - - static createSimpleHashClient(): SimpleHashClient { - const { apiKey } = - Config.onChainService.apiClient[ApiClient.SimpleHash].options; - - if (!apiKey) { - throw new Error('Simplehash API key has not been configured'); - } - - return new SimpleHashClient({ - apiKey, - }); - } - - static createWallet(scope: string): BtcWallet { - const btcNetwork = getBtcNetwork(scope); - return new BtcWallet(new BtcAccountDeriver(btcNetwork), btcNetwork); - } - - static createCachedStateManager(): CacheStateManager { - return new CacheStateManager(); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/index.test.ts b/merged-packages/bitcoin-wallet-snap/src/index.test.ts deleted file mode 100644 index e0e407df..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/index.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -import * as keyringApi from '@metamask/keyring-api'; -import * as keyringSnapSdk from '@metamask/keyring-snap-sdk'; -import { - type JsonRpcRequest, - SnapError, - MethodNotFoundError, -} from '@metamask/snaps-sdk'; - -import { onRpcRequest, validateOrigin, onKeyringRequest } from '.'; -import * as entry from '.'; -import { Config } from './config'; -import { BtcKeyring } from './keyring'; -import { InternalRpcMethod, originPermissions } from './permissions'; -import * as estimateFeeRpc from './rpcs/estimate-fee'; -import * as getTxStatusRpc from './rpcs/get-transaction-status'; - -jest.mock('./utils/logger'); -jest.mock('bitcoindevkit', () => ({})); -jest.mock('@metamask/keyring-api', () => ({ - ...jest.requireActual('@metamask/keyring-api'), -})); - -jest.mock('@metamask/keyring-snap-sdk', () => ({ - ...jest.requireActual('@metamask/keyring-snap-sdk'), - handleKeyringRequest: jest.fn(), -})); - -describe('validateOrigin', () => { - it('does not throw error if the origin and method is in the allowed list', () => { - const [origin, methods]: [string, Set] = originPermissions - .entries() - .next().value; - const method = methods.values().next().value; - expect(() => validateOrigin(origin, method)).not.toThrow(SnapError); - }); - - it('throws `Origin not found` error if not origin is provided', () => { - expect(() => validateOrigin('', 'chain_getTransactionStatus')).toThrow( - 'Origin not found', - ); - }); - - it('throws `Permission denied` error if origin not in the allowed list', () => { - expect(() => validateOrigin('xyz', 'chain_getTransactionStatus')).toThrow( - 'Permission denied', - ); - }); - - it('throws `Permission denied` error if the method is not in the allowed list', () => { - const elm = originPermissions.entries().next().value; - expect(() => validateOrigin(elm[0], 'some_method')).toThrow( - 'Permission denied', - ); - }); -}); - -describe('onRpcRequest', () => { - const executeRequest = async ( - method = InternalRpcMethod.GetTransactionStatus, - ) => { - jest.spyOn(entry, 'validateOrigin').mockReturnThis(); - - return onRpcRequest({ - origin: 'https://portfolio.metamask.io', - request: { - method, - params: { - scope: Config.availableNetworks[0], - }, - } as unknown as JsonRpcRequest, - }); - }; - - it.each([ - { - rpcMethod: InternalRpcMethod.EstimateFee, - method: 'estimateFee', - mockRpcModule: estimateFeeRpc, - }, - { - rpcMethod: InternalRpcMethod.GetTransactionStatus, - method: 'getTransactionStatus', - mockRpcModule: getTxStatusRpc, - }, - ])( - 'executes the rpc request with method: $rpcKey', - async (testData: { - rpcMethod: InternalRpcMethod; - method: string; - mockRpcModule: any; - }) => { - const spy = jest.spyOn(testData.mockRpcModule, testData.method); - - spy.mockReturnThis(); - - await executeRequest(testData.rpcMethod); - - expect(spy).toHaveBeenCalled(); - }, - ); - - it('throws MethodNotFoundError if an method not found', async () => { - // test a method that is not handled from the `onRpcRequest` - await expect( - executeRequest('not_aMethod' as unknown as InternalRpcMethod), - ).rejects.toThrow(MethodNotFoundError); - }); - - it('throws SnapError if the request is failed to execute', async () => { - jest - .spyOn(getTxStatusRpc, 'getTransactionStatus') - .mockRejectedValue(new SnapError('error')); - await expect(executeRequest()).rejects.toThrow(SnapError); - }); - - it('throws SnapError if the error is not an instance of SnapError', async () => { - jest - .spyOn(getTxStatusRpc, 'getTransactionStatus') - .mockRejectedValue(new Error('error')); - await expect(executeRequest()).rejects.toThrow(SnapError); - }); -}); - -describe('onKeyringRequest', () => { - const createMockHandleKeyringRequest = () => { - const handleKeyringRequestSpy = jest.spyOn( - keyringSnapSdk, - 'handleKeyringRequest', - ); - return { handler: handleKeyringRequestSpy }; - }; - - const executeRequest = async () => { - return onKeyringRequest({ - origin: 'https://portfolio.metamask.io', - request: { - method: keyringApi.KeyringRpcMethod.ListAccounts, - params: { - scope: Config.availableNetworks[0], - }, - } as unknown as JsonRpcRequest, - }); - }; - - it('executes the rpc request', async () => { - const { handler } = createMockHandleKeyringRequest(); - - await executeRequest(); - - expect(handler).toHaveBeenCalledWith(expect.any(BtcKeyring), { - method: keyringApi.KeyringRpcMethod.ListAccounts, - params: { - scope: Config.availableNetworks[0], - }, - }); - }); - - it('does not throw a `Permission denied` error if the dapp origin requests a method that is on the allowed list', async () => { - const { handler } = createMockHandleKeyringRequest(); - handler.mockResolvedValue({}); - - for (const method of [ - keyringApi.KeyringRpcMethod.ListAccounts, - keyringApi.KeyringRpcMethod.GetAccount, - keyringApi.KeyringRpcMethod.GetAccountBalances, - keyringApi.KeyringRpcMethod.SubmitRequest, - InternalRpcMethod.GetTransactionStatus, - InternalRpcMethod.EstimateFee, - InternalRpcMethod.GetMaxSpendableBalance, - ]) { - const result = await onKeyringRequest({ - origin: 'https://portfolio.metamask.io', - request: { - method, - params: { - scope: Config.availableNetworks[0], - }, - } as unknown as JsonRpcRequest, - }); - expect(result).toStrictEqual({}); - } - }); - - it('does not throw a `Permission denied` error if the MetaMask origin requests a method that is on the allowed list', async () => { - const { handler } = createMockHandleKeyringRequest(); - handler.mockResolvedValue({}); - - for (const method of [ - keyringApi.KeyringRpcMethod.ListAccounts, - keyringApi.KeyringRpcMethod.GetAccount, - keyringApi.KeyringRpcMethod.CreateAccount, - keyringApi.KeyringRpcMethod.FilterAccountChains, - keyringApi.KeyringRpcMethod.DeleteAccount, - keyringApi.KeyringRpcMethod.GetAccountBalances, - ]) { - const result = await onKeyringRequest({ - origin: 'metamask', - request: { - method, - params: { - scope: Config.availableNetworks[0], - }, - } as unknown as JsonRpcRequest, - }); - expect(result).toStrictEqual({}); - } - }); - - it('throws SnapError if an error was thrown', async () => { - const { handler } = createMockHandleKeyringRequest(); - handler.mockRejectedValue(new Error('error')); - - await expect(executeRequest()).rejects.toThrow(SnapError); - }); - - it('throws SnapError if an SnapError was thrown', async () => { - const { handler } = createMockHandleKeyringRequest(); - handler.mockRejectedValue(new SnapError('error')); - - await expect(executeRequest()).rejects.toThrow(SnapError); - }); - - it('throws a `Permission denied` error if the dapp origin requests a method that is not on the allowed list', async () => { - const { handler } = createMockHandleKeyringRequest(); - handler.mockResolvedValue({}); - - for (const method of [ - keyringApi.KeyringRpcMethod.CreateAccount, - keyringApi.KeyringRpcMethod.FilterAccountChains, - keyringApi.KeyringRpcMethod.UpdateAccount, - keyringApi.KeyringRpcMethod.DeleteAccount, - keyringApi.KeyringRpcMethod.ListRequests, - keyringApi.KeyringRpcMethod.GetRequest, - keyringApi.KeyringRpcMethod.ApproveRequest, - keyringApi.KeyringRpcMethod.RejectRequest, - ]) { - await expect( - onKeyringRequest({ - origin: 'https://portfolio.metamask.io', - request: { - method, - params: { - scope: Config.availableNetworks[0], - }, - } as unknown as JsonRpcRequest, - }), - ).rejects.toThrow('Permission denied'); - } - }); - - it('throws a `Permission denied` error if the MetaMask origin requests a method that is not on the allowed list', async () => { - for (const method of [ - keyringApi.KeyringRpcMethod.ApproveRequest, - keyringApi.KeyringRpcMethod.RejectRequest, - keyringApi.KeyringRpcMethod.GetRequest, - keyringApi.KeyringRpcMethod.ListRequests, - keyringApi.KeyringRpcMethod.ExportAccount, - keyringApi.KeyringRpcMethod.UpdateAccount, - ]) { - await expect( - onKeyringRequest({ - origin: 'metamask', - request: { - method, - params: { - scope: Config.availableNetworks[0], - }, - } as unknown as JsonRpcRequest, - }), - ).rejects.toThrow('Permission denied'); - } - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 7f5f8ab3..34f0f92c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -1,4 +1,3 @@ -import type { Keyring } from '@metamask/keyring-api'; import { handleKeyringRequest } from '@metamask/keyring-snap-sdk'; import type { OnAssetsLookupHandler, @@ -12,11 +11,10 @@ import { type Json, UnauthorizedError, SnapError, - MethodNotFoundError, } from '@metamask/snaps-sdk'; import { Config } from './config'; -import { ConfigV2 } from './configv2'; +import { isSnapRpcError, loadLocale } from './entities'; import { KeyringHandler, CronHandler, @@ -24,73 +22,49 @@ import { RpcHandler, AssetsHandler, } from './handlers'; -import { SnapClientAdapter, EsploraClientAdapter } from './infra'; -import { SimpleHashClientAdapter } from './infra/SimpleHashClientAdapter'; -import { BtcKeyring } from './keyring'; -import { InternalRpcMethod, originPermissions } from './permissions'; -import type { - GetTransactionStatusParams, - EstimateFeeParams, - GetMaxSpendableBalanceParams, -} from './rpcs'; import { - getTransactionStatus, - estimateFee, - getMaxSpendableBalance, -} from './rpcs'; -import type { StartSendTransactionFlowParams } from './rpcs/start-send-transaction-flow'; -import { startSendTransactionFlow } from './rpcs/start-send-transaction-flow'; -import { KeyringStateManager } from './stateManagement'; + SnapClientAdapter, + EsploraClientAdapter, + SimpleHashClientAdapter, +} from './infra'; +import { logger } from './infra/logger'; +import { originPermissions } from './permissions'; import { BdkAccountRepository, JSXSendFlowRepository } from './store'; -import { - isSendFormEvent, - SendBitcoinController, -} from './ui/controller/send-bitcoin-controller'; -import type { SendFlowContext, SendFormState } from './ui/types'; import { AccountUseCases, SendFlowUseCases } from './use-cases'; -import { isSnapRpcError, logger } from './utils'; -import { loadLocale } from './utils/locale'; +// Infra layer logger.logLevel = parseInt(Config.logLevel, 10); - -let keyringHandler: Keyring; -let cronHandler: CronHandler; -let rpcHandler: RpcHandler; -let userInputHandler: UserInputHandler; -let assetsHandler: AssetsHandler; -let accountsUseCases: AccountUseCases; -if (ConfigV2.keyringVersion === 'v2') { - // Infra layer - const snapClient = new SnapClientAdapter(ConfigV2.encrypt); - const chainClient = new EsploraClientAdapter(ConfigV2.chain); - const metaProtocolsClient = new SimpleHashClientAdapter(ConfigV2.simpleHash); - // Data layer - const accountRepository = new BdkAccountRepository(snapClient); - const sendFlowRepository = new JSXSendFlowRepository(snapClient); - - // Business layer - accountsUseCases = new AccountUseCases( - snapClient, - accountRepository, - chainClient, - metaProtocolsClient, - ConfigV2.accounts, - ); - const sendFlowUseCases = new SendFlowUseCases( - snapClient, - accountRepository, - sendFlowRepository, - chainClient, - ConfigV2.targetBlocksConfirmation, - ConfigV2.fallbackFeeRate, - ); - // Application layer - keyringHandler = new KeyringHandler(accountsUseCases); - cronHandler = new CronHandler(accountsUseCases); - rpcHandler = new RpcHandler(sendFlowUseCases, accountsUseCases); - userInputHandler = new UserInputHandler(sendFlowUseCases); - assetsHandler = new AssetsHandler(); -} +const snapClient = new SnapClientAdapter(Config.encrypt); +const chainClient = new EsploraClientAdapter(Config.chain); +const metaProtocolsClient = new SimpleHashClientAdapter(Config.simpleHash); + +// Data layer +const accountRepository = new BdkAccountRepository(snapClient); +const sendFlowRepository = new JSXSendFlowRepository(snapClient); + +// Business layer +const accountsUseCases = new AccountUseCases( + snapClient, + accountRepository, + chainClient, + metaProtocolsClient, + Config.accounts, +); +const sendFlowUseCases = new SendFlowUseCases( + snapClient, + accountRepository, + sendFlowRepository, + chainClient, + Config.targetBlocksConfirmation, + Config.fallbackFeeRate, +); + +// Application layer +const keyringHandler = new KeyringHandler(accountsUseCases); +const cronHandler = new CronHandler(accountsUseCases); +const rpcHandler = new RpcHandler(sendFlowUseCases, accountsUseCases); +const userInputHandler = new UserInputHandler(sendFlowUseCases); +const assetsHandler = new AssetsHandler(); export const validateOrigin = (origin: string, method: string): void => { if (!origin) { @@ -105,13 +79,10 @@ export const validateOrigin = (origin: string, method: string): void => { export const onInstall: OnInstallHandler = async () => { try { - // No need for a handler given the lack of request - if (accountsUseCases) { - await accountsUseCases.create( - ConfigV2.accounts.defaultNetwork, - ConfigV2.accounts.defaultAddressType, - ); - } + await accountsUseCases.create( + Config.accounts.defaultNetwork, + Config.accounts.defaultAddressType, + ); } catch (error) { let snapError = error; @@ -127,9 +98,7 @@ export const onInstall: OnInstallHandler = async () => { export const onCronjob: OnCronjobHandler = async ({ request }) => { try { - if (cronHandler) { - await cronHandler.route(request.method); - } + await cronHandler.route(request.method); } catch (error) { let snapError = error; @@ -151,33 +120,8 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ try { const { method } = request; - validateOrigin(origin, method); - - if (rpcHandler) { - return await rpcHandler.route(method, request.params); - } - - switch (method) { - case InternalRpcMethod.GetTransactionStatus: - return await getTransactionStatus( - request.params as GetTransactionStatusParams, - ); - case InternalRpcMethod.EstimateFee: - return await estimateFee(request.params as EstimateFeeParams); - case InternalRpcMethod.GetMaxSpendableBalance: - return await getMaxSpendableBalance( - request.params as GetMaxSpendableBalanceParams, - ); - case InternalRpcMethod.StartSendTransactionFlow: { - return await startSendTransactionFlow( - request.params as StartSendTransactionFlowParams, - ); - } - - default: - throw new MethodNotFoundError() as unknown as Error; - } + return await rpcHandler.route(method, request.params); } catch (error) { let snapError = error; @@ -199,18 +143,7 @@ export const onKeyringRequest: OnKeyringRequestHandler = async ({ try { validateOrigin(origin, request.method); - - if (!keyringHandler) { - keyringHandler = new BtcKeyring(new KeyringStateManager(), { - defaultIndex: Config.wallet.defaultAccountIndex, - origin, - }); - } - - return (await handleKeyringRequest( - keyringHandler, - request, - )) as unknown as Promise; + return (await handleKeyringRequest(keyringHandler, request)) ?? null; } catch (error) { let snapError = error; @@ -232,25 +165,6 @@ export const onUserInput: OnUserInputHandler = async ({ await loadLocale(); try { - if (!userInputHandler) { - const state = await snap.request({ - method: 'snap_getInterfaceState', - params: { id }, - }); - - if (isSendFormEvent(event)) { - const sendBitcoinController = new SendBitcoinController({ - context: context as SendFlowContext, - interfaceId: id, - }); - return await sendBitcoinController.handleEvent( - event, - context as SendFlowContext, - state.sendForm as SendFormState, - ); - } - } - return userInputHandler.route(id, event, context); } catch (error) { let snapError = error; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx index 816b3ca9..2f1d45a7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx @@ -14,14 +14,14 @@ import { } from '@metamask/snaps-sdk/jsx'; import type { CaipAccountId } from '@metamask/utils'; -import { ConfigV2 } from '../../configv2'; +import { Config } from '../../config'; import type { ReviewTransactionContext } from '../../entities'; -import { ReviewTransactionEvent } from '../../entities'; +import { BlockTime, ReviewTransactionEvent } from '../../entities'; +import { getTranslator } from '../../entities/locale'; import { networkToCaip2 } from '../../handlers/caip2'; -import btcIcon from '../../images/btc-halo.svg'; -import { getTranslator } from '../../utils/locale'; import { HeadingWithReturn } from './components'; import { displayAmount, displayFiatAmount } from './format'; +import btcIcon from './images/btc-halo.svg'; export const ReviewTransactionView: SnapComponent = ( props, @@ -80,7 +80,9 @@ export const ReviewTransactionView: SnapComponent = ( tooltip={t('transactionSpeedTooltip')} > - {`${ConfigV2.targetBlocksConfirmation * 10} ${t('minutes')}`} + {`${Config.targetBlocksConfirmation * BlockTime[network]} ${t( + 'minutes', + )}`}
diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx index 547ddef8..8bfcfed0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx @@ -10,7 +10,7 @@ import { import type { SendFormContext } from '../../entities'; import { SendFormEvent } from '../../entities'; -import { getTranslator } from '../../utils/locale'; +import { getTranslator } from '../../entities/locale'; import { HeadingWithReturn, SendForm, TransactionSummary } from './components'; export const SendFormView: SnapComponent = (props) => { diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/HeadingWithReturn.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/HeadingWithReturn.tsx index 20ad570a..b60f39e2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/HeadingWithReturn.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/HeadingWithReturn.tsx @@ -1,7 +1,7 @@ import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; import { Box, Button, Heading, Icon, Image } from '@metamask/snaps-sdk/jsx'; -import emptySpace from '../../../images/empty-space.svg'; +import emptySpace from '../images/empty-space.svg'; export type HeadingWithReturnProps = { heading: string; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx index 1a397060..56955675 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx @@ -12,9 +12,9 @@ import { import type { SendFormContext } from '../../../entities'; import { SENDFORM_NAME, SendFormEvent } from '../../../entities'; -import btcIcon from '../../../images/bitcoin.svg'; -import { getTranslator } from '../../../utils/locale'; +import { getTranslator } from '../../../entities/locale'; import { displayAmount } from '../format'; +import btcIcon from '../images/bitcoin.svg'; export const SendForm: SnapComponent = ({ currency, diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx index a4c091da..bfabe350 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx @@ -6,10 +6,11 @@ import { Value, type SnapComponent, } from '@metamask/snaps-sdk/jsx'; +import type { Network } from 'bitcoindevkit'; -import { ConfigV2 } from '../../../configv2'; -import type { CurrencyUnit } from '../../../entities'; -import { getTranslator } from '../../../utils/locale'; +import { Config } from '../../../config'; +import { BlockTime, type CurrencyUnit } from '../../../entities'; +import { getTranslator } from '../../../entities/locale'; import { displayAmount, displayFiatAmount } from '../format'; type TransactionSummaryProps = { @@ -17,6 +18,7 @@ type TransactionSummaryProps = { fiatRate?: CurrencyRate; amount: string; fee: string; + network: Network; }; export const TransactionSummary: SnapComponent = ({ @@ -24,6 +26,7 @@ export const TransactionSummary: SnapComponent = ({ amount, currency, fiatRate, + network, }) => { const t = getTranslator(); @@ -38,9 +41,9 @@ export const TransactionSummary: SnapComponent = ({ /> - - {`${ConfigV2.targetBlocksConfirmation * 10} ${t('minutes')}`} - + {`${Config.targetBlocksConfirmation * BlockTime[network]} ${t( + 'minutes', + )}`} ({ - ...jest.requireActual('@metamask/keyring-snap-sdk'), - emitSnapKeyringEvent: jest.fn(), -})); - -jest.mock('./rpcs/get-rates-and-balances', () => ({ - createRatesAndBalances: () => - jest.fn().mockResolvedValue({ - rates: { - value: 'mockRates', - error: '', - }, - balances: { - value: 'mockBalance', - error: '', - }, - })(), -})); - -jest.mock('./utils/locale', () => ({ - ...jest.requireActual('./utils/locale'), - getTranslator: () => jest.fn().mockReturnValue('mock value'), -})); - -class MockBtcKeyring extends BtcKeyring { - // Mock protected method getKeyringAccountNameSuggestion to public for test purpose - public getKeyringAccountNameSuggestion(options?: CreateAccountOptions) { - return super.getKeyringAccountNameSuggestion(options); - } -} - -describe('BtcKeyring', () => { - const origin = 'http://localhost:3000'; - - const createMockWallet = () => { - const unlockSpy = jest.spyOn(BtcWallet.prototype, 'unlock'); - const signTransaction = jest.spyOn(BtcWallet.prototype, 'signTransaction'); - const createTransaction = jest.spyOn( - BtcWallet.prototype, - 'createTransaction', - ); - - return { - unlockSpy, - signTransaction, - createTransaction, - }; - }; - - const createMockStateMgr = () => { - const listAccountsSpy = jest.spyOn( - KeyringStateManager.prototype, - 'listAccounts', - ); - const addWalletSpy = jest.spyOn(KeyringStateManager.prototype, 'addWallet'); - const updateAccountSpy = jest.spyOn( - KeyringStateManager.prototype, - 'updateAccount', - ); - const removeAccountsSpy = jest.spyOn( - KeyringStateManager.prototype, - 'removeAccounts', - ); - const getAccountSpy = jest.spyOn( - KeyringStateManager.prototype, - 'getAccount', - ); - const getWalletSpy = jest.spyOn(KeyringStateManager.prototype, 'getWallet'); - - return { - instance: new KeyringStateManager(), - listAccountsSpy, - addWalletSpy, - removeAccountsSpy, - getAccountSpy, - updateAccountSpy, - getWalletSpy, - }; - }; - - const createMockKeyring = (stateMgr: KeyringStateManager) => { - const sendBitcoinSpy = jest.spyOn(sendBitcoinRpc, 'sendBitcoin'); - const getBalanceRpcSpy = jest.spyOn(getBalanceRpc, 'getBalances'); - - return { - instance: new MockBtcKeyring(stateMgr, { - defaultIndex: 0, - origin, - multiAccount: false, - }), - sendBitcoinSpy, - getBalanceRpcSpy, - }; - }; - - const createSender = async (caip2ChainId: string) => { - const wallet = Factory.createWallet(caip2ChainId); - const sender = await wallet.unlock(0, ScriptType.P2wpkh); - - const keyringAccount = { - type: sender.type, - id: uuidv4(), - address: sender.address, - options: { - scope: caip2ChainId, - index: sender.index, - }, - methods: [`${BtcMethod.SendBitcoin}`], - }; - return { - sender, - keyringAccount, - }; - }; - - describe('createAccount', () => { - it('creates account', async () => { - const { unlockSpy } = createMockWallet(); - const { instance: stateMgr, addWalletSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, keyringAccount } = await createSender(caip2ChainId); - - await keyring.createAccount({ - scope: caip2ChainId, - }); - - expect(unlockSpy).toHaveBeenCalledWith( - Config.wallet.defaultAccountIndex, - Config.wallet.defaultAccountType, - ); - - expect(addWalletSpy).toHaveBeenCalledWith({ - account: { - type: keyringAccount.type, - id: expect.any(String), - address: keyringAccount.address, - options: keyringAccount.options, - methods: keyringAccount.methods, - scopes: [caip2ChainId], - }, - hdPath: sender.hdPath, - index: sender.index, - scope: caip2ChainId, - }); - }); - - it('throws an Error if another Error was thrown', async () => { - const { unlockSpy } = createMockWallet(); - const { instance: stateMgr } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - unlockSpy.mockRejectedValue(new Error('error')); - const scope = Caip2ChainId.Testnet; - - await expect( - keyring.createAccount({ - scope, - }), - ).rejects.toThrow(Error); - }); - - it('throws `Invalid params to create an account` if the create options is invalid', async () => { - const { instance: stateMgr } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - - await expect( - keyring.createAccount({ - scope: 'invalid', - }), - ).rejects.toThrow(`Invalid params to create an account`); - }); - }); - - describe('filterAccountChains', () => { - it('returns the corresponding CAIP-2 Chain Id if the account exist', async () => { - const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const scope = Caip2ChainId.Testnet; - const { instance: keyring } = createMockKeyring(stateMgr); - const { sender, keyringAccount } = await createSender(scope); - - getWalletSpy.mockResolvedValue({ - account: keyringAccount as unknown as KeyringAccount, - index: 0, - scope, - hdPath: sender.hdPath, - }); - - const result = await keyring.filterAccountChains(keyringAccount.id, [ - scope, - ]); - - expect(result).toStrictEqual([scope]); - }); - - it('returns empty array if the account does not exist', async () => { - const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const scope = Caip2ChainId.Testnet; - const { instance: keyring } = createMockKeyring(stateMgr); - const { keyringAccount } = await createSender(scope); - - getWalletSpy.mockResolvedValue(null); - - const result = await keyring.filterAccountChains(keyringAccount.id, [ - scope, - ]); - - expect(result).toStrictEqual([]); - }); - - it('returns empty array if the account scope is not in `chains` list', async () => { - const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const scope = Caip2ChainId.Testnet; - const { instance: keyring } = createMockKeyring(stateMgr); - const { keyringAccount, sender } = await createSender(scope); - - getWalletSpy.mockResolvedValue({ - account: keyringAccount as unknown as KeyringAccount, - index: 0, - scope, - hdPath: sender.hdPath, - }); - - // Current account has been created for testnet, so requesting mainnet will yield an - // empty array: - const result = await keyring.filterAccountChains(keyringAccount.id, [ - Caip2ChainId.Mainnet, - ]); - - expect(result).toStrictEqual([]); - }); - }); - - describe('listAccounts', () => { - it('returns result', async () => { - const { instance: stateMgr, listAccountsSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const accounts = generateAccounts(10); - listAccountsSpy.mockResolvedValue(accounts); - - const result = await keyring.listAccounts(); - - expect(result).toStrictEqual(accounts); - expect(listAccountsSpy).toHaveBeenCalledTimes(1); - }); - - it('throws an Error if another Error was thrown', async () => { - const { instance: stateMgr, listAccountsSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - listAccountsSpy.mockRejectedValue(new Error('error')); - - await expect(keyring.listAccounts()).rejects.toThrow(Error); - }); - }); - - describe('getAccount', () => { - it('returns result', async () => { - const { instance: stateMgr, getAccountSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(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: stateMgr, getAccountSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(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 an Error if another Error was thrown', async () => { - const { instance: stateMgr, getAccountSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - getAccountSpy.mockRejectedValue(new Error('error')); - const accounts = generateAccounts(1); - - await expect(keyring.getAccount(accounts[0].id)).rejects.toThrow(Error); - }); - }); - - describe('updateAccount', () => { - it('throws `MethodNotImplementedError`', async () => { - const { instance: stateMgr } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const account = generateAccounts(1)[0]; - - await expect(keyring.updateAccount(account)).rejects.toThrow( - MethodNotImplementedError, - ); - }); - }); - - describe('deleteAccount', () => { - it('remove account', async () => { - const { instance: stateMgr, removeAccountsSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const account = generateAccounts(1)[0]; - removeAccountsSpy.mockReturnThis(); - - await keyring.deleteAccount(account.id); - - expect(removeAccountsSpy).toHaveBeenCalledWith([account.id]); - }); - - it('throws an Error if another Error was thrown', async () => { - const { instance: stateMgr, removeAccountsSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - removeAccountsSpy.mockRejectedValue(new Error('error')); - const account = generateAccounts(1)[0]; - - await expect(keyring.deleteAccount(account.id)).rejects.toThrow(Error); - }); - }); - - describe('submitRequest', () => { - it('calls SnapRpcHandler if the method support', async () => { - // Mocking user interaction - const caip2ChainId = Caip2ChainId.Testnet; - const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const { - instance: keyring, - sendBitcoinSpy, - getBalanceRpcSpy, - } = createMockKeyring(stateMgr); - const { sender, keyringAccount } = await createSender(caip2ChainId); - getWalletSpy.mockResolvedValue({ - account: keyringAccount as unknown as KeyringAccount, - index: sender.index, - scope: keyringAccount.options.scope, - hdPath: sender.hdPath, - }); - sendBitcoinSpy.mockResolvedValue({ - txId: 'txid', - }); - getBalanceRpcSpy.mockResolvedValue({ - [Caip19Asset.TBtc]: { - amount: '1', - unit: Config.unit, - }, - }); - - const params = { - scope: caip2ChainId, - recipients: { - bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah: '0.01', - bc1qf5n2h6mgelkls4497pkpemew55xpew90td2qae: '0.01', - }, - replaceable: true, - }; - - await keyring.submitRequest({ - id: keyringAccount.id, - scope: Caip2ChainId.Testnet, - account: keyringAccount.address, - request: { - method: `${BtcMethod.SendBitcoin}`, - params, - }, - }); - - expect(sendBitcoinSpy).toHaveBeenCalledWith( - expect.any(BtcAccount), - origin, - params, - ); - }); - - it('throws `AccountNotFoundError` if the account address is not match with the unlocked account', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const accFromState = generateAccounts(1)[0]; - - getWalletSpy.mockResolvedValue({ - account: accFromState as unknown as KeyringAccount, - index: accFromState.options.index, - scope: accFromState.options.scope, - hdPath: [`m`, `0'`, `0`, `0`].join('/'), - }); - - await expect( - keyring.submitRequest({ - id: uuidv4(), - scope: caip2ChainId, - account: accFromState.id, - request: { - method: `${BtcMethod.SendBitcoin}`, - }, - }), - ).rejects.toThrow(AccountNotFoundError); - }); - - it('throws `AccountNotFoundError` if the account id not found from state', async () => { - const { instance: stateMgr } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const account = generateAccounts(1)[0]; - - await expect( - keyring.submitRequest({ - id: uuidv4(), - scope: Caip2ChainId.Testnet, - account: account.id, - request: { - method: `${BtcMethod.SendBitcoin}`, - }, - }), - ).rejects.toThrow(AccountNotFoundError); - }); - - it("throws `Account's scope does not match with the request's scope` error if given scope is not match with the account", async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const { sender, keyringAccount } = await createSender(caip2ChainId); - getWalletSpy.mockResolvedValue({ - account: keyringAccount as unknown as KeyringAccount, - index: sender.index, - scope: keyringAccount.options.scope, - hdPath: sender.hdPath, - }); - - await expect( - keyring.submitRequest({ - id: uuidv4(), - scope: Caip2ChainId.Mainnet, - account: keyringAccount.id, - request: { - method: `${BtcMethod.SendBitcoin}`, - }, - }), - ).rejects.toThrow( - "Account's scope does not match with the request's scope", - ); - }); - - it('throws `MethodNotFoundError` if the method not support', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const { sender, keyringAccount } = await createSender(caip2ChainId); - // This method will not be dispatched in `handleSubmitRequest` and will throw a `MethodNotFoundError` if used - keyringAccount.methods = ['btc_notImplemented']; - - getWalletSpy.mockResolvedValue({ - account: keyringAccount as unknown as KeyringAccount, - index: sender.index, - scope: keyringAccount.options.scope, - hdPath: sender.hdPath, - }); - - await expect( - keyring.submitRequest({ - id: uuidv4(), - scope: caip2ChainId, - account: keyringAccount.id, - request: { - method: 'btc_notImplemented', - }, - }), - ).rejects.toThrow(MethodNotFoundError); - }); - - it('throws `UnauthorizedError` if the method is not allowed from the account', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - const { sender, keyringAccount } = await createSender(caip2ChainId); - - getWalletSpy.mockResolvedValue({ - account: keyringAccount as unknown as KeyringAccount, - index: sender.index, - scope: keyringAccount.options.scope, - hdPath: sender.hdPath, - }); - - await expect( - keyring.submitRequest({ - id: keyringAccount.id, - scope: caip2ChainId, - account: keyringAccount.address, - request: { - method: 'btc_methodDoesNotExist', - }, - }), - ).rejects.toThrow(UnauthorizedError); - }); - }); - - describe('getAccountBalances', () => { - it('executes `GetBalancesHandler` with correct parameter', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const { instance: keyring, getBalanceRpcSpy } = - createMockKeyring(stateMgr); - const { sender, keyringAccount } = await createSender(caip2ChainId); - getWalletSpy.mockResolvedValue({ - account: keyringAccount as unknown as KeyringAccount, - index: sender.index, - scope: keyringAccount.options.scope, - hdPath: sender.hdPath, - }); - - const assets = [Caip19Asset.TBtc]; - const expectedResp = assets.reduce((acc, asset) => { - acc[asset] = { - amount: '1', - unit: Config.unit, - }; - return acc; - }, {}); - - getBalanceRpcSpy.mockResolvedValue(expectedResp); - - await keyring.getAccountBalances(keyringAccount.id, [Caip19Asset.TBtc]); - - expect(getBalanceRpcSpy).toHaveBeenCalledWith(expect.any(BtcAccount), { - scope: caip2ChainId, - assets, - }); - }); - - it('throws an Error if another Error was thrown', async () => { - const { instance: stateMgr, getWalletSpy } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - getWalletSpy.mockRejectedValue(new Error('error')); - const account = generateAccounts(1)[0]; - - await expect( - keyring.getAccountBalances(account.id, [Caip19Asset.TBtc]), - ).rejects.toThrow(Error); - }); - }); - - describe('getKeyringAccountNameSuggestion', () => { - it.each([ - { - scope: Caip2ChainId.Mainnet, - accountName: 'Bitcoin Account', - }, - { - scope: Caip2ChainId.Testnet, - accountName: 'Bitcoin Testnet Account', - }, - ])( - 'returns "$accountName" if the Caip 2 ChainId is $scope', - ({ scope, accountName }: { scope: string; accountName: string }) => { - const { instance: stateMgr } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - - expect( - keyring.getKeyringAccountNameSuggestion({ - scope, - }), - ).toStrictEqual(accountName); - }, - ); - }); - - it('returns empty string if the Caip 2 ChainId is neither Testnet or Mainnet', () => { - const { instance: stateMgr } = createMockStateMgr(); - const { instance: keyring } = createMockKeyring(stateMgr); - - expect( - keyring.getKeyringAccountNameSuggestion({ - scope: 'Other Caip 2 Chain Id', - }), - ).toBe(''); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/keyring.ts b/merged-packages/bitcoin-wallet-snap/src/keyring.ts deleted file mode 100644 index f33254ff..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/keyring.ts +++ /dev/null @@ -1,368 +0,0 @@ -import type { BtcScope } from '@metamask/keyring-api'; -import { - BtcMethod, - KeyringEvent, - type Keyring, - type KeyringAccount, - type KeyringRequest, - type KeyringResponse, - type Balance, - type CaipAssetType, -} from '@metamask/keyring-api'; -import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; -import { - MethodNotFoundError, - UnauthorizedError, - UserRejectedRequestError, - type Json, -} from '@metamask/snaps-sdk'; -import type { Infer } from 'superstruct'; -import { assert, object, StructError } from 'superstruct'; -import { v4 as uuidv4 } from 'uuid'; - -import { - assertScopeIsSupported, - type BtcAccount, - type BtcWallet, -} from './bitcoin/wallet'; -import { Config } from './config'; -import { Caip2ChainId } from './constants'; -import { AccountNotFoundError, MethodNotImplementedError } from './exceptions'; -import { Factory } from './factory'; -import { getBalances, type SendBitcoinParams, sendBitcoin } from './rpcs'; -import { createRatesAndBalances } from './rpcs/get-rates-and-balances'; -import { - TransactionStatus, - type KeyringStateManager, - type Wallet, -} from './stateManagement'; -import { generateSendFlowRequest, getAssetTypeFromScope } from './ui/utils'; -import { - getProvider, - ScopeStruct, - logger, - verifyIfAccountValid, - createSendUIDialog, -} from './utils'; - -export type KeyringOptions = Record & { - defaultIndex: number; - multiAccount?: boolean; - origin: string; -}; - -export const CreateAccountOptionsStruct = object({ - scope: ScopeStruct, -}); - -export type CreateAccountOptions = Record & - Infer; - -export class BtcKeyring implements Keyring { - protected readonly _stateMgr: KeyringStateManager; - - protected readonly _options: KeyringOptions; - - protected readonly _methods = [`${BtcMethod.SendBitcoin}`]; - - constructor(stateMgr: KeyringStateManager, options: KeyringOptions) { - this._stateMgr = stateMgr; - this._options = options; - } - - async listAccounts(): Promise { - try { - return await this._stateMgr.listAccounts(); - } catch (error) { - throw new Error(error); - } - } - - async getAccount(id: string): Promise { - try { - return (await this._stateMgr.getAccount(id)) ?? undefined; - } catch (error) { - throw new Error(error); - } - } - - async createAccount(options?: CreateAccountOptions): Promise { - try { - assert(options, CreateAccountOptionsStruct); - - const { scope } = options; - assertScopeIsSupported(scope); - - const wallet = this.getBtcWallet(scope); - - // TODO: Create account with index 0 for now for phase 1 scope, update to use increment index later - const index = this._options.defaultIndex; - const type = Config.wallet.defaultAccountType; - - const account = await this.discoverAccount(wallet, index, type); - - logger.info( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `[BtcKeyring.createAccount] Account unlocked: ${account.address}`, - ); - - const keyringAccount = this.newKeyringAccount(account, { - scope, - index, - }); - - logger.info( - `[BtcKeyring.createAccount] Keyring account data: ${JSON.stringify( - keyringAccount, - )}`, - ); - - await this._stateMgr.withTransaction(async () => { - await this._stateMgr.addWallet({ - account: keyringAccount, - hdPath: account.hdPath, - index: account.index, - scope, - }); - - await this.#emitEvent(KeyringEvent.AccountCreated, { - account: keyringAccount, - accountNameSuggestion: this.getKeyringAccountNameSuggestion(options), - }); - }); - - return keyringAccount; - } catch (error) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.info(`[BtcKeyring.createAccount] Error: ${error.message}`); - if (error instanceof StructError) { - throw new Error('Invalid params to create an account'); - } - throw new Error(error); - } - } - - async filterAccountChains(id: string, chains: string[]): Promise { - const walletData = await this._stateMgr.getWallet(id); - return walletData && chains.includes(walletData.scope) - ? [walletData.scope] - : []; - } - - async updateAccount(_account: KeyringAccount): Promise { - throw new MethodNotImplementedError(); - } - - async deleteAccount(id: string): Promise { - try { - await this._stateMgr.withTransaction(async () => { - await this._stateMgr.removeAccounts([id]); - await this.#emitEvent(KeyringEvent.AccountDeleted, { id }); - }); - } catch (error) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.info(`[BtcKeyring.deleteAccount] Error: ${error.message}`); - throw new Error(error); - } - } - - async submitRequest(request: KeyringRequest): Promise { - return { - pending: false, - result: await this.handleSubmitRequest(request), - }; - } - - protected async handleSubmitRequest(request: KeyringRequest): Promise { - const { scope, account: id } = request; - const { method, params } = request.request; - - const walletData = await this.getWalletData(id); - - if (walletData.scope !== scope) { - throw new Error( - `Account's scope does not match with the request's scope`, - ); - } - - const wallet = this.getBtcWallet(walletData.scope); - const account = await this.discoverAccount( - wallet, - walletData.index, - walletData.account.type, - ); - - verifyIfAccountValid(account, walletData.account); - - this.verifyIfMethodValid(method, walletData.account); - - switch (method) { - case `${BtcMethod.SendBitcoin}`: { - return await this.handleSendBitcoin({ - scope: scope as Caip2ChainId, - walletData, - account, - params: params as SendBitcoinParams, - }); - } - default: - throw new MethodNotFoundError() as unknown as Error; - } - } - - async #emitEvent( - event: KeyringEvent, - data: Record, - ): Promise { - await emitSnapKeyringEvent(getProvider(), event, data); - } - - async getAccountBalances( - id: string, - assets: CaipAssetType[], - ): Promise> { - try { - const walletData = await this.getWalletData(id); - const wallet = this.getBtcWallet(walletData.scope); - const account = await this.discoverAccount( - wallet, - walletData.index, - walletData.account.type, - ); - - verifyIfAccountValid(account, walletData.account); - - return await getBalances(account, { - assets, - scope: walletData.scope, - }); - } catch (error) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.info(`[BtcKeyring.getAccountBalances] Error: ${error.message}`); - throw new Error(error); - } - } - - protected async getWalletData(id: string): Promise { - const walletData = await this._stateMgr.getWallet(id); - - if (!walletData) { - throw new AccountNotFoundError(); - } - - return walletData; - } - - protected getBtcWallet(scope: string): BtcWallet { - return Factory.createWallet(scope); - } - - protected async discoverAccount( - wallet: BtcWallet, - index: number, - type: string, - ): Promise { - return await wallet.unlock(index, type); - } - - protected verifyIfMethodValid( - method: string, - keyringAccount: KeyringAccount, - ): void { - if (!keyringAccount.methods.includes(method)) { - throw new UnauthorizedError(`Permission denied`) as unknown as Error; - } - } - - protected newKeyringAccount( - account: BtcAccount, - options: CreateAccountOptions, - ): KeyringAccount { - const { scope } = options; - // This should be done by the caller already, but to future-proof this we double-check it here too. - assertScopeIsSupported(scope); - - return { - // Assuming that `account.type` is aligned with `KeyringAccount['type']`. - type: account.type as KeyringAccount['type'], - id: uuidv4(), - address: account.address, - options, - scopes: [scope as BtcScope], - methods: this._methods, - }; - } - - protected getKeyringAccountNameSuggestion( - options?: CreateAccountOptions, - ): string { - switch (options?.scope) { - case Caip2ChainId.Mainnet: - return 'Bitcoin Account'; - case Caip2ChainId.Testnet: - return 'Bitcoin Testnet Account'; - - default: - // Leave it blank to fallback to auto-suggested name on the extension side - return ''; - } - } - - protected async handleSendBitcoin({ - scope, - walletData, - account, - params, - }: { - scope: Caip2ChainId; - walletData: Wallet; - account: BtcAccount; - params: SendBitcoinParams; - }): Promise { - const asset = getAssetTypeFromScope(scope); - - const { rates, balances } = await createRatesAndBalances({ - asset, - scope, - btcAccount: account, - }); - - if (balances.error) { - throw new Error(`Error fetching balances: ${balances.error}`); - } - - const sendFlowRequest = await generateSendFlowRequest( - walletData, - TransactionStatus.Review, - rates.value, - balances.value, - params, - ); - - await this._stateMgr.upsertRequest(sendFlowRequest); - const result = await createSendUIDialog(sendFlowRequest.id); - - if (!result) { - await this._stateMgr.removeRequest(sendFlowRequest.id); - throw new UserRejectedRequestError() as unknown as Error; - } - - // Get the latest send flow request from the state manager - // this has been updated via onInputHandler - await this._stateMgr.upsertRequest(sendFlowRequest); - try { - const tx = await sendBitcoin(account, this._options.origin, { - ...sendFlowRequest.transaction, - scope, - }); - - sendFlowRequest.txId = tx.txId; - await this._stateMgr.upsertRequest(sendFlowRequest); - return tx; - } catch (error) { - await this._stateMgr.removeRequest(sendFlowRequest.id); - - throw error; - } - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/permissions.ts index 28b58280..c67cf52b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/permissions.ts @@ -1,9 +1,6 @@ import { KeyringRpcMethod } from '@metamask/keyring-api'; export enum InternalRpcMethod { - GetTransactionStatus = 'chain_getTransactionStatus', - EstimateFee = 'estimateFee', - GetMaxSpendableBalance = 'getMaxSpendableBalance', StartSendTransactionFlow = 'startSendTransactionFlow', } @@ -13,10 +10,6 @@ const dappPermissions = new Set([ KeyringRpcMethod.GetAccount, KeyringRpcMethod.GetAccountBalances, KeyringRpcMethod.SubmitRequest, - // Internal methods - InternalRpcMethod.GetTransactionStatus, - InternalRpcMethod.EstimateFee, - InternalRpcMethod.GetMaxSpendableBalance, ]); const metamaskPermissions = new Set([ @@ -30,10 +23,6 @@ const metamaskPermissions = new Set([ KeyringRpcMethod.DeleteAccount, KeyringRpcMethod.GetAccountBalances, KeyringRpcMethod.SubmitRequest, - // Internal methods - InternalRpcMethod.GetTransactionStatus, - InternalRpcMethod.EstimateFee, - InternalRpcMethod.GetMaxSpendableBalance, // Internal methods with a UI InternalRpcMethod.StartSendTransactionFlow, ]); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts deleted file mode 100644 index a3667611..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/__tests__/helper.ts +++ /dev/null @@ -1,555 +0,0 @@ -import { BtcMethod, type KeyringAccount } from '@metamask/keyring-api'; -import { v4 as uuidV4 } from 'uuid'; - -import { - generateQuickNodeSendRawTransactionResp, - generateFormattedUtxos, -} from '../../../test/utils'; -import type { Utxo } from '../../bitcoin/chain'; -import { BtcOnChainService } from '../../bitcoin/chain'; -import type { BtcAccount, BtcWallet } from '../../bitcoin/wallet'; -import { Config } from '../../config'; -import { Caip19Asset } from '../../constants'; -import { Factory } from '../../factory'; -import type { SendFlowRequest } from '../../stateManagement'; -import { KeyringStateManager, TransactionStatus } from '../../stateManagement'; -import * as renderInterfaces from '../../ui/render-interfaces'; -import * as snapUtils from '../../utils/snap'; -import { generateDefaultSendFlowRequest } from '../../utils/transaction'; -import * as ratesAndBalances from '../get-rates-and-balances'; - -jest.mock('../../utils/snap'); - -/** - * Create Mock Chain API Factory. - * - * @returns The spy instances of the Chain API methods - `getBalances`, `getFeeRates`, `getDataForTransaction`, `getTransactionStatus`, `broadcastTransaction`. - */ -export function createMockChainApiFactory() { - const getBalancesSpy = jest.spyOn(BtcOnChainService.prototype, 'getBalances'); - - const getFeeRatesSpy = jest.spyOn(BtcOnChainService.prototype, 'getFeeRates'); - - const getDataForTransactionSpy = jest.spyOn( - BtcOnChainService.prototype, - 'getDataForTransaction', - ); - - const getTransactionStatusSpy = jest.spyOn( - BtcOnChainService.prototype, - 'getTransactionStatus', - ); - - const broadcastTransactionSpy = jest.spyOn( - BtcOnChainService.prototype, - 'broadcastTransaction', - ); - - const createQuickNodeClientSpy = jest.spyOn(Factory, 'createQuickNodeClient'); - const createSimpleHashClientSpy = jest.spyOn( - Factory, - 'createSimpleHashClient', - ); - - createQuickNodeClientSpy.mockReturnThis(); - createSimpleHashClientSpy.mockReturnThis(); - - return { - createQuickNodeClientSpy, - createSimpleHashClientSpy, - getDataForTransactionSpy, - broadcastTransactionSpy, - getTransactionStatusSpy, - getBalancesSpy, - getFeeRatesSpy, - }; -} - -/** - * Create a mock for getting balances and rates. - * - * @returns The spy instance for `getRatesAndBalances`. - */ -export function createRatesAndBalancesMock() { - const getRatesAndBalancesSpy = jest.spyOn( - ratesAndBalances, - 'createRatesAndBalances', - ); - - return getRatesAndBalancesSpy; -} - -/** - * Generate UTXOs for a given address. - * - * @param address - The address to generate UTXOs for. - * @param counter - The number of UTXOs to generate. - * @param minVal - The minimum value of the UTXOs. - * @param maxVal - The maximum value of the UTXOs. - * @returns The generated UTXOs. - */ -export function createMockGetDataForTransactionResp( - address: string, - counter: number, - minVal = 10000, - maxVal = 100000, -) { - const utxos = generateFormattedUtxos(address, counter, minVal, maxVal); - - const total = utxos.reduce((acc, utxo) => acc + utxo.value, 0); - - return { - data: utxos, - total, - }; -} - -/** - * Create a mock wallet. - * - * @param caip2ChainId - The Caip2 Chain ID. - * @returns The `BtcWallet` object. - */ -export function createMockWallet(caip2ChainId: string) { - return Factory.createWallet(caip2ChainId); -} - -/** - * Create a mock sender account. - * - * @param wallet - The `BtcWallet` object. - * @param index - The index of the account to be derived. Default is 0. - * @param type - The type of the account. Default is `Config.wallet.defaultAccountType`. - * @returns The `BtcAccount` object. - */ -export async function createMockSender( - wallet: BtcWallet, - index = 0, - type: string = Config.wallet.defaultAccountType, -) { - return wallet.unlock(index, type); -} - -/** - * Create a mock `keyringAccount`. - * - * @param account - The `BtcAccount` object. - * @param caip2ChainId - The Caip2 Chain ID. - * @returns The `keyringAccount` and `getWalletSpy`. - */ -export async function createMockKeyringAccount( - account: BtcAccount, - caip2ChainId: string, -) { - const getWalletSpy = jest.spyOn(KeyringStateManager.prototype, 'getWallet'); - - const keyringAccount = { - type: account.type, - id: uuidV4(), - address: account.address, - options: { - scope: caip2ChainId, - index: account.index, - }, - methods: [`${BtcMethod.SendBitcoin}`], - } as unknown as KeyringAccount; - - getWalletSpy.mockResolvedValue({ - account: keyringAccount, - hdPath: `m/0'/0/${account.index}`, - index: account.index, - scope: caip2ChainId, - }); - - return { - getWalletSpy, - keyringAccount, - }; -} - -/** - * Create a mock `confirmDialog`. - * - * @returns The `confirmDialogSpy`. - */ -export function createMockConfirmDialog() { - const confirmDialogSpy = jest.spyOn(snapUtils, 'confirmDialog'); - return confirmDialogSpy; -} - -/** - * Create a mock `alertDialog`. - * - * @returns The `alertDialogSpy`. - */ -export function createMockAlertDialog() { - const alertDialogSpy = jest.spyOn(snapUtils, 'alertDialog'); - return alertDialogSpy; -} - -/** - * Create request mocks for testing. - * - * @param defaultRequest - The default send flow request. - * @returns An object containing spies for `getRequest` and `upsertRequest`. - */ -export function createRequestMocks(defaultRequest: SendFlowRequest) { - const getRequestSpy = jest.spyOn(KeyringStateManager.prototype, 'getRequest'); - const upsertRequestSpy = jest.spyOn( - KeyringStateManager.prototype, - 'upsertRequest', - ); - - upsertRequestSpy.mockResolvedValue(); - getRequestSpy.mockResolvedValue(defaultRequest); - - return { - getRequestSpy, - upsertRequestSpy, - }; -} - -/** - * Create a mock `sendUIDialog`. - * - * @returns The `sendUIDialogSpy`. - */ -export function createSendUIDialogMock() { - const sendUIDialogSpy = jest.spyOn(snapUtils, 'createSendUIDialog'); - return sendUIDialogSpy; -} - -/** - * Create mock spies for send flow functions. - * - * @returns An object containing spies for `generateSendFlow`, `updateSendFlow`, and `generateConfirmationReviewInterface`. - */ -export function createMockSendFlow() { - const generateSendFlowSpy = jest.spyOn(renderInterfaces, 'generateSendFlow'); - const updateSendFlowSpy = jest.spyOn(renderInterfaces, 'updateSendFlow'); - const generateConfirmationReviewInterfaceSpy = jest.spyOn( - renderInterfaces, - 'generateConfirmationReviewInterface', - ); - - return { - generateSendFlowSpy, - updateSendFlowSpy, - generateConfirmationReviewInterfaceSpy, - }; -} - -export type AccountTestCreateOption = { - caip2ChainId: string; -}; - -export type EstimateFeeTestCreateOption = AccountTestCreateOption & { - feeRate: number; - utxoCount: number; - utxoMinVal: number; - utxoMaxVal: number; -}; - -export type SendBitcoinCreateOption = EstimateFeeTestCreateOption & { - recipientCount: number; -}; - -export class AccountTest { - testCase: AccountTestCreateOption; - - keyringAccount: KeyringAccount; - - sender: BtcAccount; - - wallet: BtcWallet; - - getWalletSpy: jest.SpyInstance; - - constructor(testCase: AccountTestCreateOption) { - this.testCase = testCase; - } - - async setup() { - const { caip2ChainId } = this.testCase; - this.wallet = createMockWallet(caip2ChainId); - this.sender = await createMockSender(this.wallet); - - const { keyringAccount, getWalletSpy } = await createMockKeyringAccount( - this.sender, - caip2ChainId, - ); - - this.keyringAccount = keyringAccount; - this.getWalletSpy = getWalletSpy; - } - - async setupAccountNotFoundTest() { - this.getWalletSpy.mockReset().mockResolvedValue(null); - } - - async setupAccountNotMatchingTest() { - const unmatchAccount = await this.wallet.unlock( - this.sender.index + 1, - Config.wallet.defaultAccountType, - ); - - this.getWalletSpy.mockReset().mockResolvedValue({ - account: { - ...this.keyringAccount, - address: unmatchAccount.address, - }, - hdPath: `m/0'/0/${this.sender.index}`, - index: this.sender.index, - scope: this.testCase.caip2ChainId, - }); - } -} - -export class EstimateFeeTest extends AccountTest { - testCase: EstimateFeeTestCreateOption; - - getFeeRatesSpy: jest.SpyInstance; - - getDataForTransactionSpy: jest.SpyInstance; - - utxos: { - list: Utxo[]; - total: number; - }; - - constructor(testCase: EstimateFeeTestCreateOption) { - super(testCase); - const { getDataForTransactionSpy, getFeeRatesSpy } = - createMockChainApiFactory(); - this.getFeeRatesSpy = getFeeRatesSpy; - this.getDataForTransactionSpy = getDataForTransactionSpy; - this.utxos = { - list: [], - total: 0, - }; - } - - protected createUtxos() { - const { data: utxoDataList, total: utxoTotalValue } = - createMockGetDataForTransactionResp( - this.sender.address, - this.testCase.utxoCount, - this.testCase.utxoMinVal, - this.testCase.utxoMaxVal, - ); - this.utxos.list = utxoDataList; - this.utxos.total = utxoTotalValue; - this.getDataForTransactionSpy.mockResolvedValue({ - data: { - utxos: this.utxos.list, - }, - }); - } - - async setup() { - await super.setup(); - - this.createUtxos(); - - this.getFeeRatesSpy.mockResolvedValue({ - fees: [ - { - type: Config.defaultFeeRate, - rate: BigInt(this.testCase.feeRate), - }, - ], - }); - } - - async setupNoFeeAvailableTest() { - this.getFeeRatesSpy.mockReset().mockResolvedValue({ - fees: [], - }); - } -} - -export class GetMaxSpendableBalanceTest extends EstimateFeeTest {} - -export class SendBitcoinTest extends EstimateFeeTest { - recipients: BtcAccount[]; - - testCase: SendBitcoinCreateOption; - - broadcastTransactionSpy: jest.SpyInstance; - - confirmDialogSpy: jest.SpyInstance; - - alertDialogSpy: jest.SpyInstance; - - #broadCastTxResp: string | null; - - constructor(testCase: SendBitcoinCreateOption) { - super(testCase); - const { broadcastTransactionSpy } = createMockChainApiFactory(); - this.broadcastTransactionSpy = broadcastTransactionSpy; - this.confirmDialogSpy = createMockConfirmDialog(); - this.alertDialogSpy = createMockAlertDialog(); - } - - async setup() { - await super.setup(); - this.recipients = await this.createMockRecipients( - this.testCase.recipientCount, - ); - this.broadcastTransactionSpy.mockResolvedValue({ - transactionId: this.broadCastTxResp, - }); - // expect to be override by the test case - this.confirmDialogSpy.mockResolvedValue(true); - this.alertDialogSpy.mockReturnThis(); - } - - async setupUserDeniedTest() { - this.confirmDialogSpy.mockReset().mockResolvedValue(false); - } - - async setupInsufficientFundsTest() { - this.getDataForTransactionSpy.mockReset().mockResolvedValue({ - data: { - utxos: [], - }, - }); - } - - async createMockRecipients(recipientCount: number) { - const recipients: BtcAccount[] = []; - for (let i = 1; i < recipientCount + 1; i++) { - recipients.push( - await this.wallet.unlock(i, Config.wallet.defaultAccountType), - ); - } - return recipients; - } - - get broadCastTxResp() { - if (!this.#broadCastTxResp) { - this.#broadCastTxResp = generateQuickNodeSendRawTransactionResp().result; - } - return this.#broadCastTxResp; - } -} - -export class StartSendTransactionFlowTest extends SendBitcoinTest { - generateSendFlowSpy: jest.SpyInstance; - - updateSendFlowSpy: jest.SpyInstance; - - generateConfirmationReviewInterfaceSpy: jest.SpyInstance; - - getBalancesSpy: jest.SpyInstance; - - snapRequestSpy: jest.SpyInstance; - - getRequestSpy: jest.SpyInstance; - - upsertRequestSpy: jest.SpyInstance; - - createSendUIDialogMock: jest.SpyInstance; - - getBalanceAndRatesSpy: jest.SpyInstance; - - scope: string; - - requestId = 'mock-requestId'; - - interfaceId = 'mock-interfaceId'; - - constructor(testCase: SendBitcoinCreateOption) { - super(testCase); - this.scope = testCase.caip2ChainId; - const mocks = createMockSendFlow(); - const { getBalancesSpy } = createMockChainApiFactory(); - this.getBalancesSpy = getBalancesSpy; - this.generateSendFlowSpy = mocks.generateSendFlowSpy; - this.updateSendFlowSpy = mocks.updateSendFlowSpy; - this.generateConfirmationReviewInterfaceSpy = - mocks.generateConfirmationReviewInterfaceSpy; - this.createSendUIDialogMock = createSendUIDialogMock(); - const { getRequestSpy, upsertRequestSpy } = createRequestMocks( - generateDefaultSendFlowRequest( - this.keyringAccount, - this.scope, - this.requestId, - this.interfaceId, - ), - ); - this.upsertRequestSpy = upsertRequestSpy; - this.getRequestSpy = getRequestSpy; - this.getBalanceAndRatesSpy = createRatesAndBalancesMock(); - } - - async setup() { - await super.setup(); - this.snapRequestSpy = jest.spyOn(snap, 'request').mockResolvedValue(true); - this.broadcastTransactionSpy.mockResolvedValue({ - transactionId: this.broadCastTxResp, - }); - // expect to be override by the test case - const sendFlowRequest = generateDefaultSendFlowRequest( - this.keyringAccount, - this.scope, - this.requestId, - this.interfaceId, - ); - - this.generateSendFlowSpy.mockResolvedValue(sendFlowRequest); - this.updateSendFlowSpy.mockResolvedValue(true); - this.getBalancesSpy.mockResolvedValue({ - balances: { - [this.keyringAccount.address]: { - [Caip19Asset.TBtc]: { - amount: '100000000', - }, - [Caip19Asset.Btc]: { - amount: '100000000', - }, - }, - }, - }); - this.createSendUIDialogMock.mockResolvedValue(true); - this.getBalanceAndRatesSpy.mockResolvedValue({ - rates: { - value: '62000', - error: '', - }, - balances: { - value: { - [Caip19Asset.TBtc]: { - amount: '1', - }, - [Caip19Asset.Btc]: { - amount: '1', - }, - error: '', - }, - }, - }); - } - - async setupMockRequest(sendFlowRequest: SendFlowRequest): Promise { - this.generateSendFlowSpy.mockReset().mockResolvedValue(sendFlowRequest); - } - - async setupSnapRequestSpy(result: any): Promise { - this.snapRequestSpy.mockReset().mockResolvedValue(result); - } - - async setupGetRequest(sendFlowRequest: SendFlowRequest): Promise { - this.snapRequestSpy.mockReset().mockResolvedValue(sendFlowRequest); - } - - async rejectSnapRequest(): Promise { - this.createSendUIDialogMock.mockResolvedValue({ - status: TransactionStatus.Rejected, - } as SendFlowRequest); - } - - async setupResolvedConfirmationReview(request: SendFlowRequest) { - this.createSendUIDialogMock.mockResolvedValue(request); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.test.ts deleted file mode 100644 index 2371a6ee..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { InvalidParamsError } from '@metamask/snaps-sdk'; -import { v4 as uuidV4 } from 'uuid'; - -import { CoinSelectService, TxValidationError } from '../bitcoin/wallet'; -import { Caip2ChainId } from '../constants'; -import { AccountNotFoundError } from '../exceptions'; -import { logger, satsToBtc } from '../utils'; -import { EstimateFeeTest } from './__tests__/helper'; -import type { EstimateFeeParams } from './estimate-fee'; -import { estimateFee } from './estimate-fee'; - -jest.mock('../utils/logger'); -jest.mock('../utils/snap'); - -describe('EstimateFeeHandler', () => { - describe('estimateFee', () => { - const prepareEstimateFee = async ( - caip2ChainId: string, - feeRate = 1, - utxoCount = 10, - utxoMinVal = 100000, - utxoMaxVal = 100000, - ) => { - const testHelper = new EstimateFeeTest({ - caip2ChainId, - utxoCount, - utxoMinVal, - utxoMaxVal, - feeRate, - }); - await testHelper.setup(); - - return testHelper; - }; - - it('returns fee correctly', async () => { - // Create test with 1 utxos of 100000 sats - const { keyringAccount } = await prepareEstimateFee( - Caip2ChainId.Testnet, - 1, - 1, - 100000, - 100000, - ); - - const result = await estimateFee({ - account: keyringAccount.id, - // spend 10000 sats to make sure we have change - amount: satsToBtc(10000), - }); - - expect(result).toStrictEqual({ - fee: { - // 1 input = 63 bytes - // 1 output = 31 bytes - // 1 change = 34 bytes - // 1 overhead = 10 - // FeeRate * (1 input bytes + 1 output bytes + overhead) = 1 * (63 + 34 + 10) = 138 sats - amount: satsToBtc(138), - unit: 'BTC', - }, - }); - }); - - it('does not throw error if the account has insufficient funds to pay the tx fee', async () => { - // Create test with 1 utxos of 1000 sats, to make sure the account has insufficient funds to pay the tx fee - const { keyringAccount } = await prepareEstimateFee( - Caip2ChainId.Testnet, - 1, - 1, - 1000, - 1000, - ); - - const coinSelectServiceSpy = jest.spyOn( - CoinSelectService.prototype, - 'selectCoins', - ); - - const expectedFee = 2000; - coinSelectServiceSpy.mockReturnValue({ - inputs: [], - outputs: [], - fee: expectedFee, - }); - - const result = await estimateFee({ - account: keyringAccount.id, - amount: '1', - }); - - expect(logger.warn).toHaveBeenCalledWith( - 'No input or output found, fee estimation might be inaccurate', - ); - expect(result).toStrictEqual({ - fee: { - amount: satsToBtc(expectedFee), - unit: 'BTC', - }, - }); - }); - - it('throws `InvalidParamsError` when the request parameter is not correct', async () => { - await expect( - estimateFee({ - amount: '0.0001', - } as unknown as EstimateFeeParams), - ).rejects.toThrow(InvalidParamsError); - }); - - it('throws `AccountNotFoundError` if the account does not exist', async () => { - const helper = await prepareEstimateFee(Caip2ChainId.Testnet); - await helper.setupAccountNotFoundTest(); - - await expect( - estimateFee({ - account: uuidV4(), - amount: '0.0001', - }), - ).rejects.toThrow(AccountNotFoundError); - }); - - it('throws `AccountNotFoundError` if the derived account is not matching with the account from state', async () => { - const helper = await prepareEstimateFee(Caip2ChainId.Testnet); - await helper.setupAccountNotMatchingTest(); - - await expect( - estimateFee({ - account: helper.keyringAccount.id, - amount: '0.0001', - }), - ).rejects.toThrow(AccountNotFoundError); - }); - - it('throws `Failed to estimate fee` error if no fee rate is returned from the chain service', async () => { - const helper = await prepareEstimateFee(Caip2ChainId.Testnet); - await helper.setupNoFeeAvailableTest(); - - await expect( - estimateFee({ - account: helper.keyringAccount.id, - amount: '0.0001', - }), - ).rejects.toThrow('Failed to estimate fee'); - }); - - it('throws `Transaction amount too small` error if amount to estimate for is considered dust', async () => { - const { keyringAccount } = await prepareEstimateFee(Caip2ChainId.Testnet); - - await expect( - estimateFee({ - account: keyringAccount.id, - amount: satsToBtc(1), - }), - ).rejects.toThrow(new TxValidationError('Transaction amount too small')); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts deleted file mode 100644 index 500ddfbe..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/estimate-fee.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { object, string, type Infer, nonempty, enums } from 'superstruct'; - -import { TxValidationError } from '../bitcoin/wallet'; -import { Config } from '../config'; -import { AccountNotFoundError } from '../exceptions'; -import { Factory } from '../factory'; -import { KeyringStateManager } from '../stateManagement'; -import { - isSnapRpcError, - btcToSats, - validateRequest, - validateResponse, - logger, - PositiveNumberStringStruct, - AmountStruct, - satsToBtc, - getFeeRate, - verifyIfAccountValid, -} from '../utils'; - -export const EstimateFeeParamsStruct = object({ - account: nonempty(string()), - amount: AmountStruct, -}); - -export const EstimateFeeResponseStruct = object({ - fee: object({ - amount: nonempty(PositiveNumberStringStruct), - unit: enums([Config.unit]), - }), -}); - -export type EstimateFeeParams = Infer; - -export type EstimateFeeResponse = Infer; - -/** - * Estimate transaction fee. - * - * This function will traverse account's UTXOs to verify the amount and compute the fee estimation. - * - * @param params - The parameters to use when estimating the fee. - * @returns A Promise that resolves to an EstimateFeeResponse object. - */ -export async function estimateFee(params: EstimateFeeParams) { - try { - validateRequest(params, EstimateFeeParamsStruct); - - const { account: accountId, amount } = params; - - const stateManager = new KeyringStateManager(); - const walletData = await stateManager.getWallet(accountId); - - if (!walletData) { - throw new AccountNotFoundError(); - } - - const wallet = Factory.createWallet(walletData.scope); - - const account = await wallet.unlock( - walletData.index, - walletData.account.type, - ); - - verifyIfAccountValid(account, walletData.account); - - const chainApi = Factory.createOnChainServiceProvider(walletData.scope); - - const feesResp = await chainApi.getFeeRates(); - - const fee = getFeeRate(feesResp.fees); - - const { - data: { utxos }, - } = await chainApi.getDataForTransaction([account.address]); - - // TODO: change this to use the first address from account when we support multi-addresses per accounts - // We do not need the real recipient address when estimating the fees, so we just use our account's address here - const recipients = [ - { - address: account.address, - value: btcToSats(amount), - }, - ]; - - const result = await wallet.estimateFee(account, recipients, { - utxos, - fee, - }); - - // The fee estimation will be inaccurate when: - // - The account has no input (no UTXOs) - // - The account does not have enough funds to cover the requested amount - // - There is no output when computing the estimation - // - // NOTE: It is by design that we do not raise any error for now - if (result.inputs.length === 0 || result.outputs.length === 0) { - logger.warn( - 'No input or output found, fee estimation might be inaccurate', - ); - } - - const resp: EstimateFeeResponse = { - fee: { - amount: satsToBtc(result.fee), - unit: Config.unit, - }, - }; - - validateResponse(resp, EstimateFeeResponseStruct); - - return resp; - } catch (error) { - logger.error('Failed to estimate fee', error); - - if (isSnapRpcError(error)) { - throw error as unknown as Error; - } - - if ( - error instanceof TxValidationError || - error instanceof AccountNotFoundError - ) { - throw error; - } - - throw new Error('Failed to estimate fee'); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts deleted file mode 100644 index 1f7445d0..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { InvalidParamsError } from '@metamask/snaps-sdk'; - -import { Config } from '../config'; -import { Caip19Asset, Caip2ChainId } from '../constants'; -import { satsToBtc } from '../utils'; -import { - createMockChainApiFactory, - createMockSender, - createMockWallet, -} from './__tests__/helper'; -import { getBalances } from './get-balances'; - -jest.mock('../utils/logger'); -jest.mock('../utils/snap'); - -describe('getBalances', () => { - const tBtc = Caip19Asset.TBtc; - const btc = Caip19Asset.Btc; - - const createMockAccount = async (caip2ChainId: string) => { - const wallet = createMockWallet(caip2ChainId); - const sender = await createMockSender(wallet); - - return { - sender, - }; - }; - - const prepareGetBalances = async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { getBalancesSpy } = createMockChainApiFactory(); - const { sender } = await createMockAccount(caip2ChainId); - const addresses = [sender.address]; - - const mockGetBalanceResp = { - balances: { - [tBtc]: { - amount: BigInt(100), - }, - }, - }; - - getBalancesSpy.mockResolvedValue(mockGetBalanceResp); - - return { - getBalancesSpy, - caip2ChainId, - sender, - addresses, - mockGetBalanceResp, - }; - }; - - it('gets the balances', async () => { - const { - getBalancesSpy, - caip2ChainId, - addresses, - sender, - mockGetBalanceResp, - } = await prepareGetBalances(); - - const expected = { - [tBtc]: { - amount: satsToBtc(mockGetBalanceResp.balances[tBtc].amount), - unit: Config.unit, - }, - }; - - const result = await getBalances(sender, { - scope: caip2ChainId, - assets: [tBtc], - }); - - expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [tBtc]); - expect(result).toStrictEqual(expected); - }); - - it('assign 0 balance if the given asset can not be found from the account', async () => { - const { - getBalancesSpy, - caip2ChainId, - addresses, - sender, - mockGetBalanceResp, - } = await prepareGetBalances(); - - // Getting BTC and tBTC at the same time should never really happen, but - // we have to simulate this case to test the behavior of the function. - const expected = { - [tBtc]: { - amount: satsToBtc(mockGetBalanceResp.balances[tBtc].amount), - unit: Config.unit, - }, - [btc]: { - amount: satsToBtc(0), - unit: Config.unit, - }, - }; - - const result = await getBalances(sender, { - scope: caip2ChainId, - assets: [tBtc, btc], - }); - - expect(getBalancesSpy).toHaveBeenCalledWith(addresses, [tBtc, btc]); - expect(result).toStrictEqual(expected); - }); - - it('throws `Fail to get the balances` when transaction status fetch failed', async () => { - const { getBalancesSpy, caip2ChainId, sender } = await prepareGetBalances(); - - getBalancesSpy.mockRejectedValue(new Error('error')); - - await expect( - getBalances(sender, { - scope: caip2ChainId, - assets: [tBtc], - }), - ).rejects.toThrow(`Fail to get the balances`); - }); - - it('throws `Request params is invalid` when request parameter is not correct', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { sender } = await createMockAccount(caip2ChainId); - - await expect( - getBalances(sender, { - scope: caip2ChainId, - assets: ['some-asset'], - }), - ).rejects.toThrow(InvalidParamsError); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts deleted file mode 100644 index 1aefd2b7..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-balances.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { Infer } from 'superstruct'; -import { object, array, record, enums } from 'superstruct'; - -import type { BtcAccount } from '../bitcoin/wallet'; -import { Config } from '../config'; -import { Factory } from '../factory'; -import { - isSnapRpcError, - validateRequest, - validateResponse, - logger, - satsToBtc, -} from '../utils'; -import { - AssetsStruct, - PositiveNumberStringStruct, - ScopeStruct, -} from '../utils/superstruct'; - -export const GetBalancesRequestStruct = object({ - assets: array(AssetsStruct), - scope: ScopeStruct, -}); - -export const GetBalancesResponseStruct = record( - AssetsStruct, - object({ - amount: PositiveNumberStringStruct, - unit: enums([Config.unit]), - }), -); - -export type GetBalancesParams = Infer; - -export type GetBalancesResponse = Infer; - -/** - * Get Balances by a given account. - * - * @param account - The account to get the balances. - * @param params - The parameters for get the account. - * @returns A Promise that resolves to an GetBalancesResponse object. - */ -export async function getBalances( - account: BtcAccount, - params: GetBalancesParams, -) { - try { - validateRequest(params, GetBalancesRequestStruct); - - const { assets, scope } = params; - - const chainApi = Factory.createOnChainServiceProvider(scope); - const addresses = [account.address]; - - const balances = await chainApi.getBalances(addresses, assets); - - const resp = {}; - - assets.forEach((asset) => { - // If we cannot find the asset, we fallback to an amount of 0. - const amount = balances.balances[asset]?.amount ?? BigInt(0); - - resp[asset] = { - amount: satsToBtc(amount), - unit: Config.unit, - }; - }); - - validateResponse(resp, GetBalancesResponseStruct); - - return resp; - } catch (error) { - logger.error('Failed to get balances', error); - - if (isSnapRpcError(error)) { - throw error as unknown as Error; - } - - throw new Error('Fail to get the balances'); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.test.ts deleted file mode 100644 index 6d828ed5..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { InvalidParamsError } from '@metamask/snaps-sdk'; -import { v4 as uuidV4 } from 'uuid'; - -import { BtcWallet } from '../bitcoin/wallet'; -import { Caip2ChainId } from '../constants'; -import { AccountNotFoundError } from '../exceptions'; -import { btcToSats, satsToBtc } from '../utils'; -import { GetMaxSpendableBalanceTest } from './__tests__/helper'; -import type { GetMaxSpendableBalanceParams } from './get-max-spendable-balance'; -import { getMaxSpendableBalance } from './get-max-spendable-balance'; - -jest.mock('../utils/logger'); -jest.mock('../utils/snap'); - -describe('GetMaxSpendableBalanceHandler', () => { - describe('getMaxSpendableBalance', () => { - const prepareGetMaxSpendableBalance = async ( - caip2ChainId: string, - feeRate = 1, - utxoCount = 10, - utxoMinVal = 100000, - utxoMaxVal = 100000, - ) => { - const testHelper = new GetMaxSpendableBalanceTest({ - caip2ChainId, - utxoCount, - utxoMinVal, - utxoMaxVal, - feeRate, - }); - await testHelper.setup(); - - return testHelper; - }; - - it('returns the maximum spendable balance correctly', async () => { - const { keyringAccount, utxos } = await prepareGetMaxSpendableBalance( - Caip2ChainId.Testnet, - ); - - const result = await getMaxSpendableBalance({ - account: keyringAccount.id, - }); - - expect(result).toStrictEqual({ - fee: { - amount: result.fee.amount, - unit: 'BTC', - }, - balance: { - amount: result.balance.amount, - unit: 'BTC', - }, - }); - - // If all UTXOs are above the dust threshold, then the total balance should be equal to the sum of the fee and the spendable balance. - expect( - btcToSats(result.fee.amount) + btcToSats(result.balance.amount), - ).toStrictEqual(BigInt(utxos.total)); - }); - - it('estimates the maximum spendable balance by excluding any UTXO whose value is equal to or less than the dust threshold', async () => { - const feeRate = 104; - const { utxos, keyringAccount } = await prepareGetMaxSpendableBalance( - Caip2ChainId.Testnet, - feeRate, - 100, - 10000, - 10000000000, - ); - - // When using 104 satoshis per byte and 1 input contains 63 bytes, the dust threshold (fee for using this UTXO) will be 104 * 63 bytes = 6552 satoshis. Any UTXO less than this amount will be discarded as it would be a waste to use it. - const utxoInputBytesSize = 63; - const dustThreshold = utxoInputBytesSize * feeRate; - // An assertion to make sure the utxos.length is > 0. - expect(utxos.list.length).toBeGreaterThan(0); - // We set the first UTXO to be the dust threshold and re-calculate the total. - utxos.total = utxos.total - utxos.list[0].value + dustThreshold; - utxos.list[0].value = dustThreshold; - - const result = await getMaxSpendableBalance({ - account: keyringAccount.id, - }); - - // One of our UTXO was below the dust threshold, then the total balance will not count this UTXO, thus we need to subtract it from the total UTXO balance. - expect( - btcToSats(result.fee.amount) + btcToSats(result.balance.amount), - ).toStrictEqual(BigInt(utxos.total) - BigInt(dustThreshold)); - }); - - it.each([ - { - utxoCount: 1, - utxoVal: 200, - }, - { - utxoCount: 0, - utxoVal: 1, - }, - ])( - "returns a zero-spendable-balance if the account's balance is too small or the account does not have UTXO", - async ({ - utxoCount, - utxoVal, - }: { - utxoCount: number; - utxoVal: number; - }) => { - const { keyringAccount } = await prepareGetMaxSpendableBalance( - Caip2ChainId.Testnet, - 1, - utxoCount, - utxoVal, - utxoVal, - ); - - const result = await getMaxSpendableBalance({ - account: keyringAccount.id, - }); - - expect(result).toStrictEqual({ - fee: { - amount: satsToBtc(0), - unit: 'BTC', - }, - balance: { - amount: satsToBtc(0), - unit: 'BTC', - }, - }); - }, - ); - - it('throws `InvalidParamsError` when the request parameter is not correct', async () => { - await expect( - getMaxSpendableBalance({} as unknown as GetMaxSpendableBalanceParams), - ).rejects.toThrow(InvalidParamsError); - }); - - it('throws `AccountNotFoundError` if the account does not exist', async () => { - const helper = await prepareGetMaxSpendableBalance(Caip2ChainId.Testnet); - await helper.setupAccountNotFoundTest(); - - await expect( - getMaxSpendableBalance({ - account: uuidV4(), - }), - ).rejects.toThrow(AccountNotFoundError); - }); - - it('throws `AccountNotFoundError` if the derived account if the derived account is not matching with the account from state', async () => { - const helper = await prepareGetMaxSpendableBalance(Caip2ChainId.Testnet); - await helper.setupAccountNotMatchingTest(); - - await expect( - getMaxSpendableBalance({ - account: helper.keyringAccount.id, - }), - ).rejects.toThrow(AccountNotFoundError); - }); - - it('throws `Failed to get max spendable balance` error if no fee rate is returned from the chain service', async () => { - const helper = await prepareGetMaxSpendableBalance(Caip2ChainId.Testnet); - await helper.setupNoFeeAvailableTest(); - - await expect( - getMaxSpendableBalance({ - account: helper.keyringAccount.id, - }), - ).rejects.toThrow('Failed to get max spendable balance'); - }); - - it('throws `Failed to get max spendable balance` error if another error was thrown during the estimation', async () => { - const { keyringAccount } = await prepareGetMaxSpendableBalance( - Caip2ChainId.Testnet, - ); - jest - .spyOn(BtcWallet.prototype, 'estimateFee') - .mockRejectedValue(new Error('error')); - - await expect( - getMaxSpendableBalance({ - account: keyringAccount.id, - }), - ).rejects.toThrow('Failed to get max spendable balance'); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.ts deleted file mode 100644 index 994558c8..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-max-spendable-balance.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { object, string, type Infer, nonempty, enums } from 'superstruct'; - -import { TransactionDustError, TxValidationError } from '../bitcoin/wallet'; -import { Config } from '../config'; -import { AccountNotFoundError } from '../exceptions'; -import { Factory } from '../factory'; -import { KeyringStateManager } from '../stateManagement'; -import { - isSnapRpcError, - validateRequest, - validateResponse, - logger, - PositiveNumberStringStruct, - satsToBtc, - verifyIfAccountValid, - getFeeRate, -} from '../utils'; - -export const GetMaxSpendableBalanceParamsStruct = object({ - account: nonempty(string()), -}); - -export const GetMaxSpendableBalanceResponseStruct = object({ - fee: object({ - amount: nonempty(PositiveNumberStringStruct), - unit: enums([Config.unit]), - }), - balance: object({ - amount: nonempty(PositiveNumberStringStruct), - unit: enums([Config.unit]), - }), -}); - -export type GetMaxSpendableBalanceParams = Infer< - typeof GetMaxSpendableBalanceParamsStruct ->; - -export type GetMaxSpendableBalanceResponse = Infer< - typeof GetMaxSpendableBalanceResponseStruct ->; - -/** - * Get max spendable balance. - * - * This function uses a binary search algorithm to compute the maximum spendable balance of the given account. - * - * @param params - The parameters to use when computing the max spendable balance. - * @returns A Promise that resolves to an GetMaxSpendableBalanceResponse object. - */ -export async function getMaxSpendableBalance( - params: GetMaxSpendableBalanceParams, -) { - try { - validateRequest(params, GetMaxSpendableBalanceParamsStruct); - - const { account: accountId } = params; - - const stateManager = new KeyringStateManager(); - const walletData = await stateManager.getWallet(accountId); - - if (!walletData) { - throw new AccountNotFoundError(); - } - - const wallet = Factory.createWallet(walletData.scope); - - const account = await wallet.unlock( - walletData.index, - walletData.account.type, - ); - - verifyIfAccountValid(account, walletData.account); - - const chainApi = Factory.createOnChainServiceProvider(walletData.scope); - - const feesResp = await chainApi.getFeeRates(); - - const fee = getFeeRate(feesResp.fees); - - const { - data: { utxos }, - } = await chainApi.getDataForTransaction([account.address]); - - let spendable = BigInt(0); - let estimatedFee = BigInt(0); - let low = BigInt(0); - // Using the sum of all UTXO values as the high value, instead of directly using the balance. - // This is more accurate because balance data may be outdated. - let high = utxos.reduce((acc, utxo) => acc + BigInt(utxo.value), BigInt(0)); - - while (low <= high) { - // Compute the middle value. - const mid = (low + high) / BigInt(2); - - // Test the middle value. - try { - const estimateResult = await wallet.estimateFee( - account, - [ - { - address: account.address, - value: mid, - }, - ], - { - utxos, - fee, - }, - ); - - // If the middle value is valid, we can increase the low value to test a higher amount. - if (estimateResult.outputs && estimateResult.outputs.length > 0) { - low = mid + BigInt(1); - - if (mid > spendable) { - // If the updated spendable amount is larger than the previous amount, then update both the spendable amount and the estimated fee. - spendable = mid; - estimatedFee = BigInt(estimateResult.fee); - } - } else { - // If the middle value is out of bounds, we need to decrease the high value to test a lower amount. - high = mid - BigInt(1); - } - } catch (error) { - // In the case where the middle value is too small, we can increase the low value to test a higher amount. This scenario typically occurs when the sum of the account's UTXOs is too small. - if (error instanceof TransactionDustError) { - low = mid + BigInt(1); - } else { - throw error; - } - } - } - - const resp: GetMaxSpendableBalanceResponse = { - fee: { - amount: satsToBtc(estimatedFee), - unit: Config.unit, - }, - balance: { - amount: satsToBtc(spendable), - unit: Config.unit, - }, - }; - - validateResponse(resp, GetMaxSpendableBalanceResponseStruct); - return resp; - } catch (error) { - logger.error('Failed to get max spendable balance', error); - - if (isSnapRpcError(error)) { - throw error as unknown as Error; - } - - if ( - error instanceof TxValidationError || - error instanceof AccountNotFoundError - ) { - throw error; - } - - throw new Error('Failed to get max spendable balance'); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.test.ts deleted file mode 100644 index 9d36ed34..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { getRates } from '../utils/rates'; -import { getBalances } from './get-balances'; -import type { GetRatesAndBalancesParams } from './get-rates-and-balances'; -import { createRatesAndBalances } from './get-rates-and-balances'; - -jest.mock('../utils/rates'); -jest.mock('./get-balances'); - -describe('getRatesAndBalances', () => { - const mockAsset = { - /* mock asset data */ - } as any; - const mockBtcAccount = { - /* mock btc account data */ - } as any; - const mockScope = 'test-scope'; - - const params: GetRatesAndBalancesParams = { - asset: mockAsset, - scope: mockScope, - btcAccount: mockBtcAccount, - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('returns rates and balances when both promises are fulfilled', async () => { - (getRates as jest.Mock).mockResolvedValue('mockRates'); - (getBalances as jest.Mock).mockResolvedValue({ - [mockAsset]: { amount: 'mockBalance' }, - }); - - const result = await createRatesAndBalances(params); - - expect(result).toStrictEqual({ - rates: { - value: 'mockRates', - }, - balances: { - value: 'mockBalance', - error: '', - }, - }); - }); - - it('returns an error for rates when getRates promise is rejected', async () => { - (getRates as jest.Mock).mockRejectedValue(new Error('Rates error')); - (getBalances as jest.Mock).mockResolvedValue({ - [mockAsset]: { amount: 'mockBalance' }, - }); - - const result = await createRatesAndBalances(params); - - expect(result).toStrictEqual({ - rates: { - value: undefined, - }, - balances: { - value: 'mockBalance', - error: '', - }, - }); - }); - - it('returns an error for balances when getBalances promise is rejected', async () => { - (getRates as jest.Mock).mockResolvedValue('mockRates'); - (getBalances as jest.Mock).mockRejectedValue(new Error('Balances error')); - - const result = await createRatesAndBalances(params); - - expect(result).toStrictEqual({ - rates: { - value: 'mockRates', - }, - balances: { - value: undefined, - error: 'Balances error: Balances error', - }, - }); - }); - - it('returns errors for both rates and balances when both promises are rejected', async () => { - (getRates as jest.Mock).mockRejectedValue(new Error('Rates error')); - (getBalances as jest.Mock).mockRejectedValue(new Error('Balances error')); - - const result = await createRatesAndBalances(params); - - expect(result).toStrictEqual({ - rates: { - value: undefined, - }, - balances: { - value: undefined, - error: 'Balances error: Balances error', - }, - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.ts deleted file mode 100644 index 0152f31d..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-rates-and-balances.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { BtcAccount } from '../bitcoin/wallet'; -import type { Caip19Asset } from '../constants'; -import { getRates } from '../utils/rates'; -import { getBalances } from './get-balances'; - -export type GetRatesAndBalancesParams = { - asset: Caip19Asset; - scope: string; - btcAccount: BtcAccount; -}; - -/** - * Fetches rates and balances for a given asset and account. - * - * @param options - The options for fetching rates and balances. - * @param options.asset - The asset to fetch rates and balances for. - * @param options.scope - The scope for fetching rates. - * @param options.btcAccount - The Bitcoin account to fetch balances for. - * @returns An object containing rates and balances. - */ -export async function createRatesAndBalances({ - asset, - scope, - btcAccount, -}: GetRatesAndBalancesParams) { - const errors = { - balances: '', - }; - let rates; - let balances; - - const [ratesResult, balancesResult] = await Promise.allSettled([ - getRates(asset), - getBalances(btcAccount, { scope, assets: [asset] }), - ]); - - if (ratesResult.status === 'fulfilled') { - // Rates are "optional" here (hence the ''). If they are not available, no conversion - // will be done. - rates = ratesResult.value ?? ''; - } - - if (balancesResult.status === 'fulfilled') { - balances = balancesResult.value[asset]?.amount; - // Double-check that `getBalances` returned a valid amount for that asset. - if (balances === undefined) { - errors.balances = `Balances error: no balance found for "${asset}"`; - } - } else { - errors.balances = `Balances error: ${ - balancesResult.reason.message as string - }`; - } - - return { - rates: { - value: rates, - }, - balances: { - value: balances, - error: errors.balances, - }, - }; -} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts deleted file mode 100644 index 094377cf..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { InvalidParamsError } from '@metamask/snaps-sdk'; - -import { TransactionStatus } from '../bitcoin/chain'; -import { Caip2ChainId } from '../constants'; -import { createMockChainApiFactory } from './__tests__/helper'; -import { getTransactionStatus } from './get-transaction-status'; - -jest.mock('../utils/logger'); - -describe('getTransactionStatus', () => { - const txHash = - '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08'; - - it('gets status', async () => { - const { getTransactionStatusSpy } = createMockChainApiFactory(); - - const mockResp = { - status: TransactionStatus.Confirmed, - }; - - getTransactionStatusSpy.mockResolvedValue(mockResp); - - const result = await getTransactionStatus({ - scope: Caip2ChainId.Testnet, - transactionId: txHash, - }); - - expect(getTransactionStatusSpy).toHaveBeenCalledWith(txHash); - expect(result).toStrictEqual({ - status: TransactionStatus.Confirmed, - }); - }); - - it('throws `Fail to get the transaction status` when transaction status fetch failed', async () => { - const { getTransactionStatusSpy } = createMockChainApiFactory(); - - getTransactionStatusSpy.mockRejectedValue(new Error('error')); - - await expect( - getTransactionStatus({ - scope: Caip2ChainId.Testnet, - transactionId: txHash, - }), - ).rejects.toThrow(`Fail to get the transaction status`); - }); - - it('throws `Request params is invalid` when request parameter is not correct', async () => { - await expect( - getTransactionStatus({ - scope: Caip2ChainId.Testnet, - transactionId: '', - }), - ).rejects.toThrow(InvalidParamsError); - - await expect( - getTransactionStatus({ - scope: Caip2ChainId.Testnet, - transactionId: 'x', - }), - ).rejects.toThrow(InvalidParamsError); - - await expect( - getTransactionStatus({ - scope: Caip2ChainId.Testnet, - transactionId: - // 63 characters long while `transactionId` is expected to be 64 long - '2c2a9ef9cecbe08117da640ce5761c8d2209b418cd43cc3af05ffc16a425828', - }), - ).rejects.toThrow(InvalidParamsError); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts deleted file mode 100644 index 4684bc26..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/get-transaction-status.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Infer } from 'superstruct'; -import { object, enums, nonempty } from 'superstruct'; - -import { TransactionStatus } from '../bitcoin/chain'; -import { Factory } from '../factory'; -import { - isSnapRpcError, - ScopeStruct, - validateRequest, - validateResponse, - logger, - TxIdStruct, -} from '../utils'; - -export const GetTransactionStatusParamsRequestStruct = object({ - transactionId: nonempty(TxIdStruct), - scope: ScopeStruct, -}); - -export const GetTransactionStatusParamsResponseStruct = object({ - status: enums(Object.values(TransactionStatus)), -}); - -export type GetTransactionStatusParams = Infer< - typeof GetTransactionStatusParamsRequestStruct ->; - -export type GetTransactionStatusResponse = Infer< - typeof GetTransactionStatusParamsResponseStruct ->; - -/** - * Get Transaction Status by a given transaction id. - * - * @param params - The parameters for get the transaction status. - * @returns A Promise that resolves to an GetTransactionStatusResponse object. - */ -export async function getTransactionStatus( - params: GetTransactionStatusParams, -): Promise { - try { - validateRequest(params, GetTransactionStatusParamsRequestStruct); - - const { scope, transactionId } = params; - - const chainApi = Factory.createOnChainServiceProvider(scope); - - const txStatusResp = await chainApi.getTransactionStatus(transactionId); - - const resp = { - status: txStatusResp.status, - }; - - validateResponse(resp, GetTransactionStatusParamsResponseStruct); - - return resp; - } catch (error) { - logger.error('Failed to get transaction status', error); - - if (isSnapRpcError(error)) { - throw error as unknown as Error; - } - - throw new Error('Fail to get the transaction status'); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts deleted file mode 100644 index e1909bd0..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './get-balances'; -export * from './get-transaction-status'; -export * from './send-bitcoin'; -export * from './estimate-fee'; -export * from './get-max-spendable-balance'; diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.test.ts deleted file mode 100644 index 392ec76a..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { InvalidParamsError } from '@metamask/snaps-sdk'; - -import type { BtcAccount } from '../bitcoin/wallet'; -import { Caip2ChainId } from '../constants'; -import { satsToBtc } from '../utils'; -import { SendBitcoinTest } from './__tests__/helper'; -import { type SendBitcoinParams, sendBitcoin } from './send-bitcoin'; - -jest.mock('../utils/logger'); -jest.mock('../utils/snap'); - -describe('SendBitcoinHandler', () => { - describe('sendBitcoin', () => { - const origin = 'http://localhost:3000'; - - const createSendBitcoinParams = ( - recipients: BtcAccount[], - caip2ChainId: string, - dryrun: boolean, - amount = 500, - ): SendBitcoinParams => { - return { - recipients: recipients.reduce((acc, recipient) => { - acc[recipient.address] = satsToBtc(amount); - return acc; - }, {}), - replaceable: true, - dryrun, - scope: caip2ChainId, - } as unknown as SendBitcoinParams; - }; - - const prepareSendBitcoin = async ( - caip2ChainId: string, - recipientCount = 10, - feeRate = 1, - utxoCount = 10, - utxoMinVal = 100000, - utxoMaxVal = 100000, - ) => { - const testHelper = new SendBitcoinTest({ - caip2ChainId, - utxoCount, - utxoMinVal, - utxoMaxVal, - feeRate, - recipientCount, - }); - await testHelper.setup(); - - return testHelper; - }; - - it('returns correct result', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { - sender, - recipients, - broadCastTxResp, - getDataForTransactionSpy, - getFeeRatesSpy, - broadcastTransactionSpy, - } = await prepareSendBitcoin(caip2ChainId); - - const result = await sendBitcoin( - sender, - origin, - createSendBitcoinParams(recipients, caip2ChainId, false), - ); - - expect(result).toStrictEqual({ txId: broadCastTxResp }); - expect(getFeeRatesSpy).toHaveBeenCalledTimes(1); - expect(getDataForTransactionSpy).toHaveBeenCalledTimes(1); - expect(broadcastTransactionSpy).toHaveBeenCalledTimes(1); - }); - - it('does not broadcast transaction if in dryrun mode', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { recipients, sender, broadcastTransactionSpy } = - await prepareSendBitcoin(caip2ChainId); - - await sendBitcoin( - sender, - origin, - createSendBitcoinParams(recipients, caip2ChainId, true), - ); - - expect(broadcastTransactionSpy).toHaveBeenCalledTimes(0); - }); - - it('throws InvalidParamsError when request parameter is not correct', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { sender } = await prepareSendBitcoin(caip2ChainId); - - await expect( - sendBitcoin(sender, origin, { - recipients: { - 'some-address': '1', - }, - } as unknown as SendBitcoinParams), - ).rejects.toThrow(InvalidParamsError); - }); - - it('throws `Recipients object must have at least one recipient` error if no recipient provided', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients } = await prepareSendBitcoin(caip2ChainId, 0); - - await expect( - sendBitcoin( - sender, - origin, - createSendBitcoinParams(recipients, caip2ChainId, false), - ), - ).rejects.toThrow('Recipients object must have at least one recipient'); - }); - - it('throws `Invalid amount, must be a positive finite number` error if receive amount is not valid', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients } = await prepareSendBitcoin(caip2ChainId, 2); - - await expect( - sendBitcoin(sender, origin, { - ...createSendBitcoinParams(recipients, caip2ChainId, false), - recipients: { - [recipients[0].address]: 'invalid', - [recipients[1].address]: '0.1', - }, - }), - ).rejects.toThrow('Invalid amount, must be a positive finite number'); - - await expect( - sendBitcoin(sender, origin, { - ...createSendBitcoinParams(recipients, caip2ChainId, false), - recipients: { - [recipients[0].address]: '0', - [recipients[1].address]: '0.1', - }, - }), - ).rejects.toThrow('Invalid amount, must be a positive finite number'); - - await expect( - sendBitcoin(sender, origin, { - ...createSendBitcoinParams(recipients, caip2ChainId, false), - recipients: { - [recipients[0].address]: 'invalid', - [recipients[1].address]: '0.000000019', - }, - }), - ).rejects.toThrow('Invalid amount, must be a positive finite number'); - }); - - it('throws `Invalid amount, out of bounds` error if receive amount is out of bounds', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients } = await prepareSendBitcoin(caip2ChainId, 2); - - await expect( - sendBitcoin(sender, origin, { - ...createSendBitcoinParams(recipients, caip2ChainId, false), - recipients: { - [recipients[0].address]: '1', - [recipients[1].address]: '999999999.99999999', - }, - }), - ).rejects.toThrow('Invalid amount, out of bounds'); - }); - - it('throws `Invalid response` error if the response is unexpected', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients, broadcastTransactionSpy } = - await prepareSendBitcoin(caip2ChainId); - - broadcastTransactionSpy.mockResolvedValue({ - transactionId: '', - }); - await expect( - sendBitcoin(sender, origin, { - ...createSendBitcoinParams(recipients, caip2ChainId, false), - }), - ).rejects.toThrow('Invalid Response'); - }); - - it('throws `Failed to send the transaction` error if no fee rate is returned from the chain service', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { sender, recipients, getFeeRatesSpy } = await prepareSendBitcoin( - caip2ChainId, - ); - getFeeRatesSpy.mockResolvedValue({ - fees: [], - }); - - await expect( - sendBitcoin( - sender, - origin, - createSendBitcoinParams(recipients, caip2ChainId, false), - ), - ).rejects.toThrow('Failed to send the transaction'); - }); - - it('throws `Failed to send the transaction` error if the transaction is fail to commit', async () => { - const caip2ChainId = Caip2ChainId.Testnet; - const { broadcastTransactionSpy, sender, recipients } = - await prepareSendBitcoin(caip2ChainId); - broadcastTransactionSpy.mockRejectedValue(new Error('error')); - - await expect( - sendBitcoin(sender, origin, { - ...createSendBitcoinParams(recipients, caip2ChainId, false), - }), - ).rejects.toThrow('Failed to send the transaction'); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.ts deleted file mode 100644 index 2dfafd91..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/send-bitcoin.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { - object, - string, - type Infer, - record, - boolean, - refine, - optional, - nonempty, - assert, -} from 'superstruct'; - -import { type BtcAccount, TxValidationError } from '../bitcoin/wallet'; -import { Factory } from '../factory'; -import { - ScopeStruct, - isSnapRpcError, - btcToSats, - validateRequest, - validateResponse, - logger, - AmountStruct, - getFeeRate, - BitcoinAddressStruct, -} from '../utils'; - -export const RecipientsStruct = refine( - record(BitcoinAddressStruct, string()), - 'RecipientsStruct', - (value: Record) => { - if (Object.entries(value).length === 0) { - return 'Recipients object must have at least one recipient'; - } - - for (const val of Object.values(value)) { - assert(val, AmountStruct); - } - - return true; - }, -); - -export const SendBitcoinStruct = object({ - recipients: RecipientsStruct, - replaceable: boolean(), - dryrun: optional(boolean()), -}); - -export const SendBitcoinParamsStruct = object({ - ...SendBitcoinStruct.schema, - scope: ScopeStruct, -}); - -export const SendBitcoinResponseStruct = object({ - txId: nonempty(string()), - signedTransaction: optional(string()), -}); - -export type SendBitcoinParams = Infer; - -export type SendBitcoinResponse = Infer; - -/** - * Send BTC to multiple account. - * - * @param account - The account to send the transaction. - * @param _origin - The origin of the request. - * @param params - The parameters for send the transaction. - * @returns A Promise that resolves to an SendBitcoinResponse object. - */ -export async function sendBitcoin( - account: BtcAccount, - _origin: string, - params: SendBitcoinParams, -) { - try { - validateRequest(params, SendBitcoinParamsStruct); - - const { dryrun, scope, replaceable } = params; - const chainApi = Factory.createOnChainServiceProvider(scope); - const wallet = Factory.createWallet(scope); - - const feesResp = await chainApi.getFeeRates(); - - const fee = getFeeRate(feesResp.fees); - - const recipients = Object.entries(params.recipients).map( - ([address, value]) => ({ - address, - value: btcToSats(value), - }), - ); - - const { - data: { utxos }, - } = await chainApi.getDataForTransaction([account.address]); - - const txResp = await wallet.createTransaction(account, recipients, { - utxos, - fee, - replaceable, - }); - - const signedTransaction = await wallet.signTransaction( - account.signer, - txResp.tx, - ); - - if (dryrun) { - const result = { - txId: '', - signedTransaction, - }; - return result; - } - - const result = await chainApi.broadcastTransaction(signedTransaction); - - const resp = { - txId: result.transactionId, - }; - - logger.debug(`Submitted transaction ID: ${resp.txId}`); - - validateResponse(resp, SendBitcoinResponseStruct); - - return resp; - } catch (error) { - logger.error('Failed to send the transaction', error); - - if (isSnapRpcError(error)) { - throw error as unknown as Error; - } - - if (error instanceof TxValidationError) { - throw error; - } - - throw new Error('Failed to send the transaction'); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts deleted file mode 100644 index 1b009090..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { Caip19Asset, Caip2ChainId } from '../constants'; -import { AccountNotFoundError } from '../exceptions'; -import { TransactionStatus } from '../stateManagement'; -import { AssetType } from '../ui/types'; -import { generateSendBitcoinParams } from '../ui/utils'; -import { StartSendTransactionFlowTest } from './__tests__/helper'; -import { startSendTransactionFlow } from './start-send-transaction-flow'; - -jest.mock('../utils/logger'); -jest.mock('../utils/snap'); - -const prepareStartSendTransactionFlow = async ( - caip2ChainId, - recipientCount = 1, - feeRate = 1, - utxoCount = 1, - utxoMinVal = 100000000, - utxoMaxVal = 100000000, -) => { - const testHelper = new StartSendTransactionFlowTest({ - caip2ChainId, - feeRate, - utxoCount, - utxoMinVal, - utxoMaxVal, - recipientCount, - }); - - await testHelper.setup(); - - return testHelper; -}; - -describe('startSendTransactionFlow', () => { - const mockScope = Caip2ChainId.Testnet; - - beforeEach(() => { - jest.resetAllMocks(); - }); - - it('creates a new request', async () => { - const helper = await prepareStartSendTransactionFlow(mockScope); - const { keyringAccount, getBalanceAndRatesSpy } = helper; - getBalanceAndRatesSpy.mockResolvedValue({ - balances: { - value: { - [Caip19Asset.Btc]: { amount: '1' }, - [Caip19Asset.TBtc]: { amount: '1' }, - }, - error: '', - }, - rates: { - value: 62000, - error: '', - }, - }); - const mockRequestWithCorrectValues = { - id: 'mock-requestId', - interfaceId: 'mock-interfaceId', - account: keyringAccount, - scope: mockScope, - transaction: generateSendBitcoinParams(mockScope), - status: TransactionStatus.Draft, - selectedCurrency: AssetType.BTC, - recipient: { - address: keyringAccount.address, - error: '', - valid: true, - }, - fees: { - amount: '0.01', - fiat: '620.00', - loading: true, - error: '', - }, - amount: { - amount: '0.5', - fiat: '31000.00', - error: '', - valid: true, - }, - rates: '62000', - balance: { - amount: '1', - fiat: '', - }, - total: { - amount: '0.51', - fiat: '31620.00', - valid: true, - error: '', - }, - }; - await helper.setupResolvedConfirmationReview(mockRequestWithCorrectValues); - - const transactionTx = await startSendTransactionFlow({ - account: keyringAccount.id, - scope: mockScope, - }); - - expect(helper.generateSendFlowSpy).toHaveBeenCalledTimes(1); - expect(helper.upsertRequestSpy).toHaveBeenCalledTimes(1); - expect(transactionTx).toStrictEqual({ - txId: helper.broadCastTxResp, - }); - }); - - it('throws an error when the user rejects the transaction', async () => { - const helper = await prepareStartSendTransactionFlow(mockScope); - const { keyringAccount, getBalanceAndRatesSpy } = helper; - getBalanceAndRatesSpy.mockResolvedValue({ - balances: { - value: { - [Caip19Asset.Btc]: { amount: '1' }, - [Caip19Asset.TBtc]: { amount: '1' }, - }, - error: '', - }, - rates: { - value: 62000, - error: '', - }, - }); - const mockRequestWithCorrectValues = { - id: 'mock-requestId', - interfaceId: 'mock-interfaceId', - account: keyringAccount, - scope: mockScope, - transaction: generateSendBitcoinParams(mockScope), - status: TransactionStatus.Draft, - selectedCurrency: AssetType.BTC, - recipient: { - address: keyringAccount.address, - error: '', - valid: true, - }, - fees: { - amount: '0.01', - fiat: '620.00', - loading: true, - error: '', - }, - amount: { - amount: '0.5', - fiat: '31000.00', - error: '', - valid: true, - }, - rates: '62000', - balance: { - amount: '1', - fiat: '', - }, - total: { - amount: '0.51', - fiat: '31620.00', - valid: true, - error: '', - }, - }; - await helper.setupGetRequest(mockRequestWithCorrectValues); - await helper.rejectSnapRequest(); - - await expect( - startSendTransactionFlow({ - account: keyringAccount.id, - scope: mockScope, - }), - ).rejects.toThrow('User rejected the request'); - }); - - it('throws an error when the account is not found', async () => { - await expect( - startSendTransactionFlow({ - account: 'non-existing-account-id', - scope: mockScope, - }), - ).rejects.toThrow(AccountNotFoundError); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts b/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts deleted file mode 100644 index 1ceb5ef6..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/rpcs/start-send-transaction-flow.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { UserRejectedRequestError } from '@metamask/snaps-sdk'; -import { enums, nonempty, object, string } from 'superstruct'; - -import { TxValidationError } from '../bitcoin/wallet'; -import { Caip2ChainId } from '../constants'; -import { AccountNotFoundError, isSnapException } from '../exceptions'; -import { Factory } from '../factory'; -import type { SendFlowRequest } from '../stateManagement'; -import { KeyringStateManager, TransactionStatus } from '../stateManagement'; -import { generateSendFlow, updateSendFlow } from '../ui/render-interfaces'; -import { - btcToFiat, - getAssetTypeFromScope, - generateSendBitcoinParams, -} from '../ui/utils'; -import { - createSendUIDialog, - isSnapRpcError, - logger, - validateRequest, - verifyIfAccountValid, -} from '../utils'; -import { createRatesAndBalances } from './get-rates-and-balances'; -import { sendBitcoin } from './send-bitcoin'; - -export type StartSendTransactionFlowParams = { - account: string; - scope: Caip2ChainId; -}; - -export const StartSendTransactionFlowParamsStruct = object({ - account: nonempty(string()), - scope: enums([...Object.values(Caip2ChainId)]), -}); - -/** - * Starts the send transaction flow for a given account and scope. - * - * @param params - The parameters for starting the transaction flow. - * @returns The transaction result. - */ -export async function startSendTransactionFlow( - params: StartSendTransactionFlowParams, -) { - validateRequest( - params, - StartSendTransactionFlowParamsStruct, - ); - const { account, scope } = params; - try { - const stateManager = new KeyringStateManager(); - const walletData = await stateManager.getWallet(account); - - if (!walletData) { - throw new AccountNotFoundError(); - } - - const wallet = Factory.createWallet(walletData.scope); - const btcAccount = await wallet.unlock( - walletData.index, - walletData.account.type, - ); - verifyIfAccountValid(btcAccount, walletData.account); - - const asset = getAssetTypeFromScope(scope); - - const sendFlowRequest = await generateSendFlow({ - account: walletData.account, - scope, - }); - - // This will be awaited later on the flow in order to display the UI as soon as possible. - // If we don't, then the UI will be displayed after the balances and rates call are finished. - const sendFlowPromise = createSendUIDialog(sendFlowRequest.interfaceId); - - const { rates, balances } = await createRatesAndBalances({ - asset, - scope, - btcAccount, - }); - - const errors: string[] = []; - - if (balances.error) { - errors.push(balances.error); - } - - if (errors.length > 0) { - throw new Error(`Error fetching rates and balances: ${errors.join(',')}`); - } - - sendFlowRequest.balance.amount = balances.value; - sendFlowRequest.balance.fiat = btcToFiat(balances.value, rates.value); - sendFlowRequest.rates = rates.value; - - await updateSendFlow({ - request: { - ...sendFlowRequest, - }, - }); - - // The dialog resolves into the send flow request that has been confirmed by the user - const updatedSendFlowRequest = (await sendFlowPromise) as SendFlowRequest; - - if (updatedSendFlowRequest.status === TransactionStatus.Rejected) { - throw new UserRejectedRequestError() as unknown as Error; - } - - const sendBitcoinParams = generateSendBitcoinParams( - walletData.scope, - updatedSendFlowRequest, - ); - updatedSendFlowRequest.transaction = sendBitcoinParams; - updatedSendFlowRequest.status = TransactionStatus.Confirmed; - - const tx = await sendBitcoin(btcAccount, scope, { - ...updatedSendFlowRequest.transaction, - scope, - }); - - updatedSendFlowRequest.txId = tx.txId; - - await stateManager.upsertRequest(updatedSendFlowRequest); - return tx; - } catch (error) { - logger.error('Failed to start send transaction flow', error); - - if (isSnapRpcError(error)) { - throw error as unknown as Error; - } - - if (isSnapException(error) || error instanceof TxValidationError) { - throw error; - } - - throw new Error('Failed to send the transaction'); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts b/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts deleted file mode 100644 index 086eb1ed..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/stateManagement.test.ts +++ /dev/null @@ -1,690 +0,0 @@ -import { generateAccounts } from '../test/utils'; -import { Caip2ChainId } from './constants'; -import type { SendFlowRequest } from './stateManagement'; -import { KeyringStateManager, TransactionStatus } from './stateManagement'; -import { AssetType } from './ui/types'; -import { generateSendBitcoinParams } from './ui/utils'; -import * as snapUtil from './utils/snap'; - -describe('KeyringStateManager', () => { - const createMockStateManager = () => { - const getDataSpy = jest.spyOn(snapUtil, 'getStateData'); - const setDataSpy = jest.spyOn(snapUtil, 'setStateData'); - return { - instance: new KeyringStateManager(), - getDataSpy, - setDataSpy, - }; - }; - - const getHdPath = (index: number) => { - return [`m`, `0'`, `0`, `${index}`].join('/'); - }; - - const createInitState = (cnt = 1, scope = Caip2ChainId.Testnet) => { - const generatedAccounts = generateAccounts(cnt); - return { - walletIds: generatedAccounts.map((accounts) => accounts.id), - wallets: generatedAccounts.reduce((acc, account) => { - acc[account.id] = { - account, - hdPath: getHdPath(account.options.index), - index: account.options.index, - scope, - }; - return acc; - }, {}), - requests: {} as Record, - }; - }; - - 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.walletIds.map((id) => state.wallets[id].account), - ); - }); - - 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 `walletIds` if `walletIds` does not exist', async () => { - const { instance, getDataSpy } = createMockStateManager(); - getDataSpy.mockResolvedValue({ - wallets: [], - }); - - const result = await instance.listAccounts(); - - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(result).toStrictEqual([]); - }); - - it('init keyring state `wallets` if `wallets` does not exist', async () => { - const { instance, getDataSpy } = createMockStateManager(); - getDataSpy.mockResolvedValue({ - account: {}, - }); - - const result = await instance.listAccounts(); - - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(result).toStrictEqual([]); - }); - - it('throws an Error if another Error was thrown', async () => { - const { instance, getDataSpy } = createMockStateManager(); - getDataSpy.mockRejectedValue(new Error('error')); - - await expect(instance.listAccounts()).rejects.toThrow(Error); - }); - }); - - describe('addWallet', () => { - it('adds an new wallet when state is empty', async () => { - const { instance, getDataSpy, setDataSpy } = createMockStateManager(); - const accountToSave = generateAccounts(1)[0]; - const state = { - accounts: [], - wallets: {}, - }; - getDataSpy.mockResolvedValue(state); - - await instance.addWallet({ - account: accountToSave, - hdPath: getHdPath(accountToSave.index), - index: accountToSave.index, - scope: accountToSave.scope, - }); - - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(setDataSpy).toHaveBeenCalledTimes(1); - expect(state.wallets[accountToSave.id]).toStrictEqual({ - account: accountToSave, - hdPath: getHdPath(accountToSave.index), - index: accountToSave.index, - scope: accountToSave.scope, - }); - }); - - it('adds an new wallet when state is not empty', async () => { - const { instance, getDataSpy, setDataSpy } = createMockStateManager(); - const state = createInitState(5); - const accountToSave = generateAccounts(1, 'new')[0]; - getDataSpy.mockResolvedValue(state); - - await instance.addWallet({ - account: accountToSave, - hdPath: getHdPath(accountToSave.index), - index: accountToSave.index, - scope: accountToSave.scope, - }); - - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(setDataSpy).toHaveBeenCalledTimes(1); - expect(state.wallets[accountToSave.id]).toStrictEqual({ - account: accountToSave, - hdPath: getHdPath(accountToSave.index), - index: accountToSave.index, - scope: accountToSave.scope, - }); - }); - - it('throw Error if the given account id exist', async () => { - const { instance, getDataSpy } = createMockStateManager(); - const state = createInitState(20); - getDataSpy.mockResolvedValue(state); - const accountToSave = state.wallets[state.walletIds[0]].account; - - await expect( - instance.addWallet({ - account: accountToSave, - hdPath: getHdPath(accountToSave.index), - index: accountToSave.index, - scope: accountToSave.scope, - }), - ).rejects.toThrow(Error); - }); - - it('throw Error if the given account address exist', async () => { - const { instance, getDataSpy } = createMockStateManager(); - const state = createInitState(20); - getDataSpy.mockResolvedValue(state); - const accountToSave = generateAccounts(1, 'new')[0]; - const { address } = state.wallets[state.walletIds[0]].account; - accountToSave.address = address; - - await expect( - instance.addWallet({ - account: accountToSave, - hdPath: getHdPath(accountToSave.index), - index: accountToSave.index, - scope: accountToSave.scope, - }), - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - ).rejects.toThrow(`Account address ${address} already exists`); - }); - }); - - 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.walletIds.length; - const testInput = [state.walletIds[0], state.walletIds[10]]; - - await instance.removeAccounts(testInput); - - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(setDataSpy).toHaveBeenCalledTimes(1); - expect(state.walletIds).toHaveLength(lengthB4Remove - testInput.length); - expect(state.wallets).not.toContain(testInput[0]); - expect(state.wallets).not.toContain(testInput[1]); - }); - - it('throw Error if the account does not exist', async () => { - const { instance, getDataSpy } = createMockStateManager(); - const state = createInitState(20); - const nonExistAcc = generateAccounts(1, 'notexist')[0]; - getDataSpy.mockResolvedValue(state); - - await expect( - instance.removeAccounts([nonExistAcc.id, state.walletIds[0]]), - ).rejects.toThrow( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Account id ${nonExistAcc.id} does not exist`, - ); - }); - - it('throws an Error if another Error was thrown', async () => { - const { instance, getDataSpy } = createMockStateManager(); - getDataSpy.mockRejectedValue(new Error('error')); - const state = createInitState(1); - - await expect(instance.removeAccounts(state.walletIds)).rejects.toThrow( - Error, - ); - }); - }); - - describe('getAccount', () => { - it('returns result', async () => { - const { instance, getDataSpy } = createMockStateManager(); - const state = createInitState(20); - getDataSpy.mockResolvedValue(state); - const id = state.walletIds[0]; - - const result = await instance.getAccount(id); - - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(result).toStrictEqual(state.wallets[id].account); - }); - - 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')[0]; - - const result = await instance.getAccount(id); - - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(result).toBeNull(); - }); - - it('throws an Error if another Error was thrown', async () => { - const { instance, getDataSpy } = createMockStateManager(); - getDataSpy.mockRejectedValue(new Error('error')); - const { id } = generateAccounts(1)[0]; - - await expect(instance.getAccount(id)).rejects.toThrow(Error); - }); - }); - - describe('updateAccount', () => { - it('update account if the account exist', async () => { - const { instance, getDataSpy, setDataSpy } = createMockStateManager(); - const state = createInitState(20); - getDataSpy.mockResolvedValue(state); - - const accToUpdate = { - ...state.wallets[state.walletIds[0]].account, - methods: ['newBtcMethod'], - }; - - await instance.updateAccount(accToUpdate); - - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(setDataSpy).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - wallets: expect.objectContaining({ - [accToUpdate.id]: expect.objectContaining({ - account: accToUpdate, - }), - }), - }), - encrypted: true, - }), - ); - }); - - it('throw Error if the account does not exist', async () => { - const { instance, getDataSpy } = createMockStateManager(); - const state = createInitState(20); - const accToUpdate = generateAccounts(1, 'notexist')[0]; - getDataSpy.mockResolvedValue(state); - - await expect(instance.updateAccount(accToUpdate)).rejects.toThrow( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Account id ${accToUpdate.id} does not exist`, - ); - }); - - it('throw `immutable` error if the update value is address', async () => { - const { instance, getDataSpy } = createMockStateManager(); - const state = createInitState(20); - const accToUpdate = { - ...state.wallets[state.walletIds[0]].account, - address: state.wallets[state.walletIds[1]].account.address, - }; - getDataSpy.mockResolvedValue(state); - - await expect(instance.updateAccount(accToUpdate)).rejects.toThrow( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Account address or type is immutable`, - ); - }); - - it('throw `immutable` error if the update value is type', async () => { - const { instance, getDataSpy } = createMockStateManager(); - const state = createInitState(20); - const accToUpdate = { - ...state.wallets[state.walletIds[0]].account, - type: 'someothertype', - }; - getDataSpy.mockResolvedValue(state); - - await expect(instance.updateAccount(accToUpdate)).rejects.toThrow( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Account address or type is immutable`, - ); - }); - }); - - describe('getWallet', () => { - it('returns result', async () => { - const { instance, getDataSpy } = createMockStateManager(); - const state = createInitState(20); - getDataSpy.mockResolvedValue(state); - const { id } = state.wallets[state.walletIds[0]].account; - - const result = await instance.getWallet(id); - - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(result).toStrictEqual(state.wallets[state.walletIds[0]]); - }); - - it('returns null if the id does not exist', async () => { - const { instance, getDataSpy } = createMockStateManager(); - const state = createInitState(20); - getDataSpy.mockResolvedValue(state); - const { id } = generateAccounts(1, 'notexist')[0]; - - const result = await instance.getWallet(id); - - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(result).toBeNull(); - }); - - it('throws when getData fails', async () => { - const { instance, getDataSpy } = createMockStateManager(); - getDataSpy.mockRejectedValue(new Error('error')); - const state = createInitState(20); - const { id } = state.wallets[state.walletIds[0]].account; - - await expect(instance.getWallet(id)).rejects.toThrow(Error); - }); - }); - - describe('Requests', () => { - describe('getRequest', () => { - it('returns the request if it exists', async () => { - const { instance, getDataSpy } = createMockStateManager(); - const state = createInitState(20); - const requestId = 'request-1'; - const request = { - id: requestId, - interfaceId: 'interface-1', - account: state.wallets[state.walletIds[0]].account, - scope: 'scope-1', - transaction: { - ...generateSendBitcoinParams('scope-1'), - sender: 'sender-1', - recipient: 'recipient-1', - amount: '1', - total: '1', - }, - status: TransactionStatus.Draft, - selectedCurrency: AssetType.BTC, - recipient: { - address: 'recipient-address', - error: '', - valid: true, - }, - fees: { - amount: '0.0001', - fiat: '0.01', - loading: false, - error: '', - }, - amount: { - amount: '1', - fiat: '100', - error: '', - valid: true, - }, - rates: '100', - balance: { - amount: '10', - fiat: '1000', - }, - total: { - amount: '1.0001', - fiat: '100.01', - error: '', - valid: true, - }, - }; - state.requests[requestId] = request; - getDataSpy.mockResolvedValue(state); - - const result = await instance.getRequest(requestId); - - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(result).toStrictEqual(request); - }); - - it('returns null if the request does not exist', async () => { - const { instance, getDataSpy } = createMockStateManager(); - const state = createInitState(20); - getDataSpy.mockResolvedValue(state); - const requestId = 'non-existent-request'; - - const result = await instance.getRequest(requestId); - - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(result).toBeNull(); - }); - - it('returns null if the state is null', async () => { - const { instance, getDataSpy } = createMockStateManager(); - getDataSpy.mockResolvedValue(null); - const requestId = 'request-1'; - - const result = await instance.getRequest(requestId); - - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(result).toBeNull(); - }); - - it('throws an Error if another Error was thrown', async () => { - const { instance, getDataSpy } = createMockStateManager(); - getDataSpy.mockRejectedValue(new Error('error')); - const requestId = 'request-1'; - - await expect(instance.getRequest(requestId)).rejects.toThrow(Error); - }); - }); - - describe('upsertRequest', () => { - it('adds a new request if it does not exist', async () => { - const { instance, getDataSpy, setDataSpy } = createMockStateManager(); - const state = createInitState(20); - getDataSpy.mockResolvedValue(state); - const requestId = 'new-request'; - const newRequest: SendFlowRequest = { - id: requestId, - interfaceId: 'interface-1', - account: state.wallets[state.walletIds[0]].account, - scope: 'scope-1', - transaction: { - ...generateSendBitcoinParams('scope-1'), - }, - status: TransactionStatus.Draft, - selectedCurrency: AssetType.BTC, - recipient: { - address: 'recipient-address', - error: '', - valid: true, - }, - fees: { - amount: '0.0001', - fiat: '0.01', - loading: false, - error: '', - }, - amount: { - amount: '1', - fiat: '100', - error: '', - valid: true, - }, - rates: '100', - balance: { - amount: '10', - fiat: '1000', - }, - total: { - amount: '1.0001', - fiat: '100.01', - error: '', - valid: true, - }, - }; - - await instance.upsertRequest(newRequest); - - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(setDataSpy).toHaveBeenCalledTimes(1); - expect(state.requests[requestId]).toStrictEqual(newRequest); - }); - - it('updates an existing request if it exists', async () => { - const { instance, getDataSpy, setDataSpy } = createMockStateManager(); - const state = createInitState(20); - const requestId = 'existing-request'; - const existingRequest: SendFlowRequest = { - id: requestId, - interfaceId: 'interface-1', - account: state.wallets[state.walletIds[0]].account, - scope: 'scope-1', - transaction: { - ...generateSendBitcoinParams('scope-1'), - }, - status: TransactionStatus.Draft, - selectedCurrency: AssetType.BTC, - recipient: { - address: 'recipient-address', - error: '', - valid: true, - }, - fees: { - amount: '0.0001', - fiat: '0.01', - loading: false, - error: '', - }, - amount: { - amount: '1', - fiat: '100', - error: '', - valid: true, - }, - rates: '100', - balance: { - amount: '10', - fiat: '1000', - }, - total: { - amount: '1.0001', - fiat: '100.01', - error: '', - valid: true, - }, - }; - state.requests[requestId] = existingRequest; - getDataSpy.mockResolvedValue(state); - - const updatedRequest: SendFlowRequest = { - ...existingRequest, - status: TransactionStatus.Review, - }; - - await instance.upsertRequest(updatedRequest); - - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(setDataSpy).toHaveBeenCalledTimes(1); - expect(state.requests[requestId]).toStrictEqual(updatedRequest); - }); - - it('throws an Error if another Error was thrown', async () => { - const { instance, getDataSpy } = createMockStateManager(); - getDataSpy.mockRejectedValue(new Error('error')); - const requestId = 'request-1'; - const request: SendFlowRequest = { - id: requestId, - interfaceId: 'interface-1', - account: generateAccounts(1)[0], - scope: 'scope-1', - transaction: { - ...generateSendBitcoinParams('scope-1'), - }, - status: TransactionStatus.Draft, - selectedCurrency: AssetType.BTC, - recipient: { - address: 'recipient-address', - error: '', - valid: true, - }, - fees: { - amount: '0.0001', - fiat: '0.01', - loading: false, - error: '', - }, - amount: { - amount: '1', - fiat: '100', - error: '', - valid: true, - }, - rates: '100', - balance: { - amount: '10', - fiat: '1000', - }, - total: { - amount: '1.0001', - fiat: '100.01', - error: '', - valid: true, - }, - }; - - await expect(instance.upsertRequest(request)).rejects.toThrow(Error); - }); - }); - describe('removeRequest', () => { - it('removes the request if it exists', async () => { - const { instance, getDataSpy, setDataSpy } = createMockStateManager(); - const state = createInitState(20); - const requestId = 'request-to-remove'; - const request: SendFlowRequest = { - id: requestId, - interfaceId: 'interface-1', - account: state.wallets[state.walletIds[0]].account, - scope: 'scope-1', - transaction: { - ...generateSendBitcoinParams('scope-1'), - }, - status: TransactionStatus.Draft, - selectedCurrency: AssetType.BTC, - recipient: { - address: 'recipient-address', - error: '', - valid: true, - }, - fees: { - amount: '0.0001', - fiat: '0.01', - loading: false, - error: '', - }, - amount: { - amount: '1', - fiat: '100', - error: '', - valid: true, - }, - rates: '100', - balance: { - amount: '10', - fiat: '1000', - }, - total: { - amount: '1.0001', - fiat: '100.01', - error: '', - valid: true, - }, - }; - state.requests[requestId] = request; - getDataSpy.mockResolvedValue(state); - - await instance.removeRequest(requestId); - - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(setDataSpy).toHaveBeenCalledTimes(1); - expect(state.requests[requestId]).toBeUndefined(); - }); - - it('does nothing if the request does not exist', async () => { - const { instance, getDataSpy, setDataSpy } = createMockStateManager(); - const state = createInitState(20); - getDataSpy.mockResolvedValue(state); - const requestId = 'non-existent-request'; - - await instance.removeRequest(requestId); - - expect(getDataSpy).toHaveBeenCalledTimes(1); - expect(setDataSpy).toHaveBeenCalledTimes(1); - expect(state.requests[requestId]).toBeUndefined(); - }); - - it('throws an Error if another Error was thrown', async () => { - const { instance, getDataSpy } = createMockStateManager(); - getDataSpy.mockRejectedValue(new Error('error')); - const requestId = 'request-1'; - - await expect(instance.removeRequest(requestId)).rejects.toThrow(Error); - }); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts b/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts deleted file mode 100644 index 233f9c0f..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/stateManagement.ts +++ /dev/null @@ -1,260 +0,0 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; - -import { type EstimateFeeResponse, type SendBitcoinParams } from './rpcs'; -import type { AssetType, Currency } from './ui/types'; -import { compactError, SnapStateManager } from './utils'; - -export type Wallet = { - account: KeyringAccount; - hdPath: string; - index: number; - scope: string; -}; - -export type Wallets = Record; - -export type SendEstimate = { - // The estimated fee in BTC. - fees: EstimateFeeResponse & { loading: boolean }; - // The estimated time to confirmation time. - confirmationTime: string; -}; - -export type Transaction = SendBitcoinParams & { - sender: string; - recipient: string; - amount: string; - total: string; -}; - -export type BaseRequestState = { - id: string; - interfaceId: string; - account: KeyringAccount; - scope: string; -}; - -export type SendFlowParams = { - scope: string; - selectedCurrency: AssetType; - recipient: { - address: string; - error: string; - valid: boolean; - }; - fees: Currency & { loading: boolean; error: string }; - amount: Currency & { error: string; valid: boolean }; - rates: string; - balance: Currency; // TODO: To be removed once metadata is available - total: Currency & { error: string; valid: boolean }; -}; - -export enum TransactionStatus { - Draft = 'draft', - Review = 'review', - Signed = 'signed', - Rejected = 'rejected', - Confirmed = 'confirmed', - Pending = 'pending', - Failure = 'failure', -} - -export type TransactionState = { - transaction: Omit; - /* The status of the transaction - - draft: The transaction is being created and edited - - review: The transaction is in a review state that is ready to be confirmed by the user to sign - - signed: The transaction is signed and ready to be sent - - rejected: The transaction is rejected by the user - - confirmed: The transaction is confirmed by the network - - pending: The transaction is pending confirmation - - failure: The transaction failed - */ - status: TransactionStatus; - txId?: string; -}; - -export type SendFlowRequest = BaseRequestState & - SendFlowParams & - TransactionState; - -export type SnapState = { - walletIds: string[]; - wallets: Wallets; - requests: { - [id: string]: SendFlowRequest; - }; -}; - -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 = { - walletIds: [], - wallets: {}, - requests: {}, - }; - } - - if (!state.walletIds) { - state.walletIds = []; - } - - if (!state.wallets) { - state.wallets = {}; - } - - if (!state.requests) { - state.requests = {}; - } - - return state; - }); - } - - async listAccounts(): Promise { - try { - const state = await this.get(); - return state.walletIds.map((id) => state.wallets[id].account); - } catch (error) { - throw compactError(error, Error); - } - } - - async addWallet(wallet: Wallet): Promise { - try { - await this.update(async (state: SnapState) => { - const { id, address } = wallet.account; - if ( - this.isAccountExist(state, id) || - this.getAccountByAddress(state, address) - ) { - throw new Error(`Account address ${address} already exists`); - } - - state.wallets[id] = wallet; - state.walletIds.push(id); - }); - } catch (error) { - throw compactError(error, Error); - } - } - - async updateAccount(account: KeyringAccount): Promise { - try { - await this.update(async (state: SnapState) => { - if (!this.isAccountExist(state, account.id)) { - throw new Error(`Account id ${account.id} does not exist`); - } - - const wallet = state.wallets[account.id]; - const accountInState = wallet.account; - - if ( - accountInState.address.toLowerCase() !== - account.address.toLowerCase() || - accountInState.type !== account.type - ) { - throw new Error(`Account address or type is immutable`); - } - - state.wallets[account.id].account = account; - }); - } catch (error) { - throw compactError(error, Error); - } - } - - async removeAccounts(ids: string[]): Promise { - try { - await this.update(async (state: SnapState) => { - const removeIds = new Set(); - - for (const id of ids) { - if (!this.isAccountExist(state, id)) { - throw new Error(`Account id ${id} does not exist`); - } - removeIds.add(id); - } - - removeIds.forEach((id) => delete state.wallets[id]); - state.walletIds = state.walletIds.filter((id) => !removeIds.has(id)); - }); - } catch (error) { - throw compactError(error, Error); - } - } - - async getAccount(id: string): Promise { - try { - const state = await this.get(); - return state.wallets[id]?.account ?? null; - } catch (error) { - throw compactError(error, Error); - } - } - - async getWallet(id: string): Promise { - try { - const state = await this.get(); - return state.wallets[id] ?? null; - } catch (error) { - throw compactError(error, Error); - } - } - - async getRequest(id: string): Promise { - try { - const state = await this.get(); - return state.requests[id] ?? null; - } catch (error) { - throw compactError(error, Error); - } - } - - async upsertRequest(sendFlowRequest: SendFlowRequest): Promise { - try { - await this.update(async (state: SnapState) => { - state.requests[sendFlowRequest.id] = { - ...state.requests[sendFlowRequest.id], - ...sendFlowRequest, - }; - }); - } catch (error) { - throw compactError(error, Error); - } - } - - async removeRequest(id: string): Promise { - try { - await this.update(async (state: SnapState) => { - if (state.requests[id]) { - delete state.requests[id]; - } - }); - } catch (error) { - throw compactError(error, Error); - } - } - - protected getAccountByAddress( - state: SnapState, - address: string, - ): KeyringAccount | null { - return ( - Object.values(state.wallets).find( - (wallet) => wallet.account.address.toString() === address.toLowerCase(), - )?.account ?? null - ); - } - - protected isAccountExist(state: SnapState, id: string): boolean { - return Object.prototype.hasOwnProperty.call(state.wallets, id); - } - - protected isRequestExist(state: SnapState, id: string): boolean { - return Object.prototype.hasOwnProperty.call(state.requests, id); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/AccountSelector.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/AccountSelector.tsx deleted file mode 100644 index 3a55f455..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/AccountSelector.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; -import { - Card, - Field, - Selector, - SelectorOption, - type SnapComponent, -} from '@metamask/snaps-sdk/jsx'; - -import { shortenAddress } from '../../utils'; -import { getTranslator } from '../../utils/locale'; -import jazzicon1 from '../images/jazzicon1.svg'; -import type { Currency } from '../types'; -import { displayEmptyStringIfAmountNotAvailableOrEmptyAmount } from '../utils'; - -/** - * The props for the {@link AccountSelector} component. - * - * @property selectedAccount - The currently selected account. - * @property balance - The balance of the selected account. - * @property accounts - The available accounts. - */ -export type AccountSelectorProps = { - selectedAccount: string; - balance: Currency; - accounts: KeyringAccount[]; -}; - -/** - * A component that shows the account selector. - * - * @param props - The component props. - * @param props.selectedAccount - The currently selected account. - * @param props.accounts - The available accounts. - * @param props.balance - The balance of the selected account. - * @returns The AccountSelector component. - */ -export const AccountSelector: SnapComponent = ({ - selectedAccount, - accounts, - balance, -}) => { - const t = getTranslator(); - return ( - - - {accounts.map(({ address }) => { - return ( - - - - ); - })} - - - ); -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/ReviewTransaction.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/ReviewTransaction.tsx deleted file mode 100644 index 1b0119cb..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/ReviewTransaction.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; -import { - Address, - Box, - Button, - Container, - Footer, - Heading, - Row, - Section, - Text, - Value, - Image, - Link, -} from '@metamask/snaps-sdk/jsx'; -import type { CaipAccountId } from '@metamask/utils'; - -import { BaseExplorerUrl, Caip2ChainId } from '../../constants'; -import type { SendFlowRequest } from '../../stateManagement'; -import { getTranslator } from '../../utils/locale'; -import btcIcon from '../images/btc-halo.svg'; -import { - displayEmptyStringIfAmountNotAvailableOrEmptyAmount, - getNetworkNameFromScope, -} from '../utils'; -import { SendFlowHeader } from './SendFlowHeader'; -import { SendFormNames } from './SendForm'; - -export type ReviewTransactionProps = SendFlowRequest & { - txSpeed: string; -}; - -const getExplorerLink = (scope: string, address: string) => { - const explorerBaseLink = - scope === Caip2ChainId.Mainnet - ? BaseExplorerUrl.Mainnet - : BaseExplorerUrl.Testnet; - - return `${explorerBaseLink}/${address}`; -}; - -export const ReviewTransaction: SnapComponent = ({ - account, - amount, - total, - recipient, - scope, - txSpeed, - fees, -}) => { - const t = getTranslator(); - const network = getNetworkNameFromScope(scope); - const disabledSend = Boolean( - amount.error || recipient.error || total.error || fees.error, - ); - - return ( - - - - - - - - {`${t('sending')} ${total.amount} BTC`} - {t('reviewTransactionWarning')} - -
- - -
-
-
- - - - - -
-
-
-
-
- - {network} - - - {txSpeed} - - - - - - - -
- {Boolean(recipient.error) && ( - {recipient.error} - )} - {Boolean(amount.error) && {amount.error}} - {Boolean(fees.error) && {fees.error}} - {Boolean(total.error) && {total.error}} -
-
- -
-
- ); -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SatsProtectionToolTip.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SatsProtectionToolTip.tsx deleted file mode 100644 index 0636e8bd..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/SatsProtectionToolTip.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { - Icon, - Text, - Tooltip, - type SnapComponent, -} from '@metamask/snaps-sdk/jsx'; - -import { getTranslator } from '../../utils/locale'; - -/** - * A component that shows a tooltip for SATs protection. - * - * @returns The SatsProtectionToolTip component. - */ -export const SatsProtectionToolTip: SnapComponent = () => { - const t = getTranslator(); - - return ( - {t('satProtectionTooltip')}}> - - - ); -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlow.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlow.tsx deleted file mode 100644 index 2a1edbc4..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlow.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; -import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; -import { Box, Container } from '@metamask/snaps-sdk/jsx'; - -import type { SendFlowParams } from '../../stateManagement'; -import { getTranslator } from '../../utils/locale'; -import { SendFlowFooter } from './SendFlowFooter'; -import { SendFlowHeader } from './SendFlowHeader'; -import { SendForm } from './SendForm'; -import { TransactionSummary } from './TransactionSummary'; - -/** - * The props for the {@link SendFlow} component. - * - * @property account - The account information for the transaction. - * @property flushToAddress - Flag to flush to address. - * @property sendFlowParams - Additional parameters for the send flow. - * @property currencySwitched - Flag indicating if the currency was switched. - * @property backEventTriggered - Flag indicating if the back event was triggered. - */ -export type SendFlowProps = { - account: KeyringAccount; - flushToAddress?: boolean; - sendFlowParams: SendFlowParams; - currencySwitched?: boolean; - backEventTriggered?: boolean; -}; - -/** - * A send flow component, which shows the user a form to send funds to another. - * - * @param props - The properties object. - * @param props.account - The account information for the transaction. - * @param props.flushToAddress - Flag to flush to address. - * @param props.sendFlowParams - Additional parameters for the send flow. - * @param props.currencySwitched - Flag indicating if the currency was switched. - * @param props.backEventTriggered - Flag indicating if the back event was triggered. - * @returns The rendered SendFlow component. - */ -export const SendFlow: SnapComponent = ({ - account, - sendFlowParams, - flushToAddress = false, - currencySwitched = false, - backEventTriggered = false, -}) => { - const t = getTranslator(); - const { amount, recipient, fees, total } = sendFlowParams; - - const disabledReview = Boolean( - !amount.valid || - !recipient.valid || - !total.valid || - fees.loading || - fees.error, - ); - - const showTransactionSummary = - Boolean(!amount.error && amount.amount) || fees.loading; - - return ( - - - - - {showTransactionSummary && ( - - )} - - - - ); -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowFooter.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowFooter.tsx deleted file mode 100644 index 4f655019..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowFooter.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Button, Footer, type SnapComponent } from '@metamask/snaps-sdk/jsx'; - -import { getTranslator } from '../../utils/locale'; -import { SendFormNames } from './SendForm'; - -/** - * The props for the {@link SendFlowFooter} component. - * - * @property disabled - Whether the button is disabled or not. - */ -export type SendFlowFooterProps = { - disabled: boolean; -}; - -/** - * A component that shows the send flow footer. - * - * @param props - The options object. - * @param props.disabled - Whether the button is disabled or not. - * @returns The SendFlowFooter component. - */ -export const SendFlowFooter: SnapComponent = ({ - disabled, -}: SendFlowFooterProps) => { - const t = getTranslator(); - - return ( -
- - -
- ); -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx deleted file mode 100644 index d540593d..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendFlowHeader.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; -import { Box, Button, Heading, Icon, Image } from '@metamask/snaps-sdk/jsx'; - -import emptySpace from '../images/empty-space.svg'; -import { SendFormNames } from './SendForm'; - -/** - * The props for the {@link SendFlowHeader} component. - * - * @property heading - The heading to display. - */ -export type SendFlowHeaderProps = { - heading: string; -}; - -/** - * A component that shows the send flow header. - * - * @param props - The component props. - * @param props.heading - The heading to display. - * @returns The SendFlowHeader component. - */ -export const SendFlowHeader: SnapComponent = ({ - heading, -}) => ( - - - {heading} - {/* FIXME: This empty space is needed to center-align the header text. - * The Snap UI centers the text within its container, but the container - * itself is misaligned in the header due to the back arrow. - */} - - -); diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx deleted file mode 100644 index 81d625d2..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/SendForm.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { - Box, - Button, - Field, - Form, - Icon, - Image, - Input, - Text, - type SnapComponent, -} from '@metamask/snaps-sdk/jsx'; - -import { getBtcNetwork } from '../../bitcoin/wallet'; -import type { SendFlowParams } from '../../stateManagement'; -import { isSatsProtectionEnabled } from '../../utils/config'; -import { getTranslator } from '../../utils/locale'; -import btcIcon from '../images/bitcoin.svg'; -import jazzicon3 from '../images/jazzicon3.svg'; -import type { AccountWithBalance } from '../types'; -import { AssetType } from '../types'; -import { amountNotAvailable } from '../utils'; -import { AccountSelector as AccountSelectorComponent } from './AccountSelector'; -import { SatsProtectionToolTip } from './SatsProtectionToolTip'; - -export enum SendFormNames { - Amount = 'amount', - To = 'to', - SwapCurrencyDisplay = 'swap', - AccountSelector = 'accountSelector', - Clear = 'clear', - Close = 'close', - Review = 'review', - Cancel = 'cancel', - Send = 'send', - HeaderBack = 'headerBack', - SetMax = 'max', -} - -/** - * The props for the {@link SendForm} component. - * - * @property selectedAccount - The currently selected account. - * @property accounts - The available accounts. - * @property errors - The form errors. - * @property selectedCurrency - The selected currency to display. - * @property flushToAddress - Whether to flush the address field or not. - */ -export type SendFormProps = { - selectedAccount: string; - accounts: AccountWithBalance[]; - balance: SendFlowParams['balance']; - amount: SendFlowParams['amount']; - selectedCurrency: SendFlowParams['selectedCurrency']; - recipient: SendFlowParams['recipient']; - total: SendFlowParams['total']; - rates: SendFlowParams['rates']; - flushToAddress?: boolean; - currencySwitched: boolean; - backEventTriggered: boolean; - scope: string; -}; - -const getAmountFrom = ( - selectedCurrency: AssetType, - amount: SendFlowParams['amount'], -) => { - return selectedCurrency === AssetType.BTC ? amount.amount : amount.fiat; -}; - -/** - * A component that shows the send form. - * - * @param props - The component props. - * @param props.selectedAccount - The currently selected account. - * @param props.accounts - The available accounts. - * @param props.balance - The balance of the account. - * @param props.amount - The amount of the transaction from the formState. - * @param props.selectedCurrency - The selected currency to display. - * @param props.flushToAddress - Whether to flush the address field or not. - * @param props.recipient - The recipient details including address and validation status. - * @param props.total - The total amount including fees. - * @param props.currencySwitched - Whether the currency display has been switched. - * @param props.rates - The exchange rates for the selected currency. - * @param props.backEventTriggered - Whether the back event has been triggered. - * @param props.scope - The CAIP-2 Chain ID. - * @returns The SendForm component. - */ -export const SendForm: SnapComponent = ({ - selectedAccount, - accounts, - selectedCurrency, - flushToAddress, - balance, - amount, - recipient, - total, - rates, - currencySwitched, - backEventTriggered, - scope, -}) => { - const t = getTranslator(); - const showRecipientError = recipient.address.length > 0 && !recipient.error; - const amountToDisplay = - currencySwitched || backEventTriggered - ? getAmountFrom(selectedCurrency, amount) - : undefined; - - let addressToDisplay: string | undefined; - if (backEventTriggered) { - addressToDisplay = recipient.address; - } else if (flushToAddress) { - addressToDisplay = ''; - } - - // Fiat might not be available if rates are still loading or cannot be fetched. - const fiatNotAvailable = amountNotAvailable(balance.fiat); - - return ( -
- - - - - - - {Boolean(rates) && ( - - - {selectedCurrency === AssetType.FIAT ? 'USD' : selectedCurrency} - - - - )} - - - - - {t('balance')} - {`${ - fiatNotAvailable ? `${balance.amount} BTC` : `$${balance.fiat}` - }`} - - {Boolean(isSatsProtectionEnabled(getBtcNetwork(scope))) && ( - - )} - - - - - - {recipient.valid && ( - - - - )} - - {Boolean(recipient.address) && ( - - - - )} - - {showRecipientError && {t('validAddress')}} -
- ); -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/TransactionSummary.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/components/TransactionSummary.tsx deleted file mode 100644 index 0edb34c9..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/TransactionSummary.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { - Box, - Row, - Section, - Spinner, - Text, - Value, - type SnapComponent, -} from '@metamask/snaps-sdk/jsx'; - -import type { SendFlowRequest } from '../../stateManagement'; -import { getTranslator } from '../../utils/locale'; -import { displayEmptyStringIfAmountNotAvailableOrEmptyAmount } from '../utils'; - -/** - * The props for the {@link TransactionSummary} component. - * - * @property fees - The fees for the transaction. - * @property total - The total cost of the transaction. - */ -export type TransactionSummaryProps = { - fees: SendFlowRequest['fees']; - total: SendFlowRequest['total']; -}; - -/** - * A component that shows the transaction summary. - * - * @param props - The component props. - * @param props.fees - The fees for the transaction. - * @param props.total - The total cost of the transaction. - * @returns The TransactionSummary component. - */ -export const TransactionSummary: SnapComponent = ({ - fees, - total, -}) => { - const t = getTranslator(); - - if (fees.loading) { - return ( -
- - - {t('preparingTransaction')} - -
- ); - } - - if (fees.error) { - return ( -
- - {fees.error} - -
- ); - } - - return ( -
- - - - - {t('estimatedTransactionSpeed')} - - - - -
- ); -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/components/index.ts b/merged-packages/bitcoin-wallet-snap/src/ui/components/index.ts deleted file mode 100644 index 072f0137..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './SendFlow'; -export * from './ReviewTransaction'; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts deleted file mode 100644 index f77c90ea..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.test.ts +++ /dev/null @@ -1,759 +0,0 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; -import { BtcAccountType, BtcMethod, BtcScope } from '@metamask/keyring-api'; -import type { UserInputEvent } from '@metamask/snaps-sdk'; -import { UserInputEventType } from '@metamask/snaps-sdk'; -import BigNumber from 'bignumber.js'; -import { v4 as uuidv4 } from 'uuid'; - -import { Caip2ChainId } from '../../constants'; -import { estimateFee, getMaxSpendableBalance } from '../../rpcs'; -import type { SendFlowRequest } from '../../stateManagement'; -import { TransactionStatus } from '../../stateManagement'; -import { generateDefaultSendFlowRequest } from '../../utils/transaction'; -import { SendFormNames } from '../components/SendForm'; -import { updateSendFlow } from '../render-interfaces'; -import type { SendFormState } from '../types'; -import { AssetType } from '../types'; -import { - SendBitcoinController, - isSendFormEvent, -} from './send-bitcoin-controller'; - -jest.mock('../../rpcs', () => ({ - ...jest.requireActual('../../rpcs'), - estimateFee: jest.fn(), - getMaxSpendableBalance: jest.fn(), -})); - -// @ts-expect-error Mocking Snap global object -// eslint-disable-next-line no-restricted-globals -global.snap = { - request: jest.fn(), -}; - -const mockDisplayConfirmationReview = jest.fn(); -jest.mock('../render-interfaces', () => ({ - updateSendFlow: jest.fn(), - displayConfirmationReview: (args) => mockDisplayConfirmationReview(args), -})); - -const mockInterfaceId = 'interfaceId'; -const mockScope = Caip2ChainId.Mainnet; -const mockRequestId = 'requestId'; -const mockAccount = { - type: BtcAccountType.P2wpkh, - id: uuidv4(), - address: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', - options: { - scope: Caip2ChainId.Mainnet, - index: '1', - }, - methods: [`${BtcMethod.SendBitcoin}`], - scopes: [BtcScope.Mainnet], -}; - -const createMockContext = (request: SendFlowRequest) => { - return { - accounts: [{ id: 'account1' } as KeyringAccount], - scope: mockScope, - requestId: mockRequestId, - request, - }; -}; - -describe('SendBitcoinController', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('isSendFormEvent', () => { - it.each([ - { - formEvent: { - name: SendFormNames.AccountSelector, - type: UserInputEventType.InputChangeEvent, - value: '25671892-75c7-4661-9a01-3dcfd2fedcdf', - }, - result: true, - }, - { - formEvent: { - name: SendFormNames.Amount, - type: UserInputEventType.InputChangeEvent, - value: '123', - }, - result: true, - }, - { - formEvent: { - name: SendFormNames.Amount, - type: UserInputEventType.InputChangeEvent, - value: 'tb1q9lakrt5sw0w0twnc6ww4vxs7hm0q23e03286k8', - }, - result: true, - }, - { - formEvent: { - name: SendFormNames.Cancel, - type: UserInputEventType.ButtonClickEvent, - }, - result: true, - }, - { - formEvent: { - name: SendFormNames.Clear, - type: UserInputEventType.ButtonClickEvent, - }, - result: true, - }, - { - formEvent: { - name: SendFormNames.Close, - type: UserInputEventType.ButtonClickEvent, - }, - result: true, - }, - { - formEvent: { - name: SendFormNames.HeaderBack, - type: UserInputEventType.ButtonClickEvent, - }, - result: true, - }, - { - formEvent: { - name: SendFormNames.Review, - type: UserInputEventType.ButtonClickEvent, - }, - result: true, - }, - { - formEvent: { - name: SendFormNames.Send, - type: UserInputEventType.ButtonClickEvent, - }, - result: true, - }, - { - formEvent: { - name: SendFormNames.SwapCurrencyDisplay, - type: UserInputEventType.ButtonClickEvent, - }, - result: true, - }, - { - formEvent: { - name: 'unknown', - type: UserInputEventType.InputChangeEvent, - }, - result: false, - }, - { - formEvent: { - name: 'unknown', - type: UserInputEventType.ButtonClickEvent, - }, - result: false, - }, - ])( - 'returns $result for event name $formEvent.name and type $formEvent.type', - ({ formEvent, result }) => { - // @ts-expect-error testing error case - expect(isSendFormEvent(formEvent)).toBe(result); - }, - ); - }); - - describe('handleEvent', () => { - it('should handle input change event', async () => { - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - - const mockEvent: UserInputEvent = { - name: SendFormNames.To, - type: UserInputEventType.InputChangeEvent, - value: 'address', - }; - - const mockFormState: SendFormState = { - to: 'address', - amount: '', - accountSelector: '', - }; - - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - await controller.handleEvent(mockEvent, mockContext, mockFormState); - - const expectedRequest = { - ...mockRequest, - recipient: { - ...mockRequest.recipient, - address: 'address', - }, - }; - - expect(updateSendFlow).toHaveBeenCalledWith({ - request: expectedRequest, - }); - }); - - it('should handle button click event', async () => { - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - - const mockEvent: UserInputEvent = { - name: SendFormNames.Cancel, - type: UserInputEventType.ButtonClickEvent, - }; - - const mockFormState: SendFormState = { - to: 'address', - amount: '', - accountSelector: '', - }; - - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - jest.spyOn(controller, 'handleButtonEvent').mockResolvedValue(undefined); - await controller.handleEvent(mockEvent, mockContext, mockFormState); - expect(controller.handleButtonEvent).toHaveBeenCalledWith(mockEvent.name); - }); - - it('should not handle unknown event type', async () => { - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - - const mockEvent: UserInputEvent = { - name: 'unknown', - type: UserInputEventType.ButtonClickEvent, - }; - - const mockFormState: SendFormState = { - to: '', - amount: '', - accountSelector: '', - }; - - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - jest.spyOn(controller, 'handleButtonEvent').mockResolvedValue(undefined); - jest.spyOn(controller, 'handleInputEvent').mockResolvedValue(undefined); - await controller.handleEvent(mockEvent, mockContext, mockFormState); - expect(controller.handleButtonEvent).not.toHaveBeenCalled(); - expect(controller.handleInputEvent).not.toHaveBeenCalled(); - expect(updateSendFlow).not.toHaveBeenCalled(); - }); - }); - - describe('handleInputEvent', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('To input event', () => { - it('handles valid "To" input event', async () => { - const mockAddress = 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a'; - const mockFormState = { - to: mockAddress, - amount: '', - accountSelector: '', - }; - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - mockRequest.status = TransactionStatus.Review; - - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - await controller.handleInputEvent( - SendFormNames.To, - mockContext, - mockFormState, - ); - expect(controller.context.request).toStrictEqual({ - ...mockRequest, - recipient: { - address: mockAddress, - valid: true, - error: '', - }, - }); - expect(updateSendFlow).toHaveBeenCalledWith({ - request: controller.context.request, - }); - }); - - it('handle invalid address "To" input event', async () => { - const mockAddress = 'invalid address'; - const mockFormState = { - to: mockAddress, - amount: '', - accountSelector: '', - }; - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - mockRequest.status = TransactionStatus.Review; - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - await controller.handleInputEvent( - SendFormNames.To, - mockContext, - mockFormState, - ); - expect(controller.context.request).toStrictEqual({ - ...mockRequest, - recipient: { - address: mockAddress, - valid: false, - error: 'Invalid address', - }, - }); - expect(updateSendFlow).toHaveBeenCalledWith({ - request: controller.context.request, - }); - }); - }); - - describe('Amount input event', () => { - it('handles valid "Amount" input event with BTC currency', async () => { - const mockAmount = '0.01'; - const mockFormState = { - to: '', - amount: mockAmount, - accountSelector: '', - }; - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - mockRequest.selectedCurrency = AssetType.BTC; - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - - (estimateFee as jest.Mock).mockResolvedValue({ - fee: { amount: '0.0001' }, - }); - - await controller.handleInputEvent( - SendFormNames.Amount, - mockContext, - mockFormState, - ); - - expect(controller.context.request.amount.amount).toBe(mockAmount); - expect(controller.context.request.amount.fiat).toBeDefined(); - expect(controller.context.request.fees.amount).toBe('0.0001'); - expect(controller.context.request.total.amount).toBeDefined(); - expect(updateSendFlow).toHaveBeenCalledWith({ - request: controller.context.request, - }); - }); - - it('handles valid "Amount" input event with FIAT currency', async () => { - const mockAmount = '100.00'; - const mockFormState = { - to: '', - amount: mockAmount, - accountSelector: '', - }; - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - mockRequest.selectedCurrency = AssetType.FIAT; - mockRequest.rates = '60000'; - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - - (estimateFee as jest.Mock).mockResolvedValue({ - fee: { amount: '0.0001' }, - }); - - await controller.handleInputEvent( - SendFormNames.Amount, - mockContext, - mockFormState, - ); - - expect(controller.context.request.amount.amount).toBe('0.00166667'); - expect(controller.context.request.amount.fiat).toBe(mockAmount); - expect(controller.context.request.fees.amount).toBe('0.0001'); - expect(controller.context.request.total.amount).toBeDefined(); - expect(updateSendFlow).toHaveBeenCalledWith({ - request: controller.context.request, - }); - }); - - it('handles invalid "Amount" input event', async () => { - const mockAmount = 'invalid amount'; - const mockFormState = { - to: '', - amount: mockAmount, - accountSelector: '', - }; - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - - await controller.handleInputEvent( - SendFormNames.Amount, - mockContext, - mockFormState, - ); - - expect(controller.context.request.amount.valid).toBe(false); - expect(updateSendFlow).toHaveBeenCalledWith({ - request: controller.context.request, - }); - }); - - it('handles fee estimation error', async () => { - const mockAmount = '0.01'; - const mockFormState = { - to: '', - amount: mockAmount, - accountSelector: '', - }; - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - mockRequest.selectedCurrency = AssetType.BTC; - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - - (estimateFee as jest.Mock).mockRejectedValue( - new Error('Fee estimation error'), - ); - - await controller.handleInputEvent( - SendFormNames.Amount, - mockContext, - mockFormState, - ); - - expect(controller.context.request.fees.error).toBe( - 'Fee estimation error', - ); - expect(updateSendFlow).toHaveBeenCalledWith({ - request: controller.context.request, - }); - }); - }); - }); - - describe('handleButtonEvent', () => { - afterEach(() => { - jest.resetAllMocks(); - }); - - it('should handle "HeaderBack" button event when the status is in review', async () => { - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - mockRequest.status = TransactionStatus.Review; - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - await controller.handleButtonEvent(SendFormNames.HeaderBack); - expect(controller.context.request.status).toBe(TransactionStatus.Draft); - expect(controller.context.request).toStrictEqual({ - ...mockRequest, - status: TransactionStatus.Draft, - }); - expect(updateSendFlow).toHaveBeenCalled(); - }); - - it('should handle "HeaderBack" button event when the status is in draft', async () => { - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - mockRequest.status = TransactionStatus.Draft; - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - await controller.handleButtonEvent(SendFormNames.HeaderBack); - expect(controller.context.request.status).toBe( - TransactionStatus.Rejected, - ); - expect(controller.context.request).toStrictEqual({ - ...mockRequest, - status: TransactionStatus.Rejected, - }); - expect(snap.request).toHaveBeenCalledWith({ - method: 'snap_resolveInterface', - params: { - id: controller.interfaceId, - value: controller.context.request, - }, - }); - }); - - it('should handle "Clear" button event', async () => { - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - mockRequest.recipient.address = 'address'; - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - await controller.handleButtonEvent(SendFormNames.Clear); - expect(controller.context.request.recipient.address).toBe(''); - }); - - it('should handle "Cancel" button event', async () => { - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - await controller.handleButtonEvent(SendFormNames.Cancel); - expect(controller.context.request.status).toBe( - TransactionStatus.Rejected, - ); - }); - - it('should handle "SwapCurrencyDisplay" button event', async () => { - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - await controller.handleButtonEvent(SendFormNames.SwapCurrencyDisplay); - const expectedResult = { - ...mockRequest, - selectedCurrency: AssetType.FIAT, - }; - expect(controller.context.request.selectedCurrency).toBe(AssetType.FIAT); - expect(updateSendFlow).toHaveBeenCalledWith({ - request: expectedResult, - flushToAddress: false, - currencySwitched: true, - }); - }); - - it('should handle "Review" button event', async () => { - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - await controller.handleButtonEvent(SendFormNames.Review); - const expectedResult = { - ...mockRequest, - status: TransactionStatus.Review, - }; - expect(controller.context.request.status).toBe(TransactionStatus.Review); - expect(mockDisplayConfirmationReview).toHaveBeenCalledWith({ - request: expectedResult, - }); - }); - - it('should handle "Send" button event', async () => { - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - mockRequest.status = TransactionStatus.Review; - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - await controller.handleButtonEvent(SendFormNames.Send); - const expectedResult = { - ...mockRequest, - status: TransactionStatus.Signed, - }; - expect(controller.context.request.status).toBe(TransactionStatus.Signed); - expect(snap.request).toHaveBeenCalledWith({ - method: 'snap_resolveInterface', - params: { - id: expectedResult.interfaceId, - value: controller.context.request, - }, - }); - }); - - it('should handle "SetMax" button event', async () => { - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - - const mockMaxSpendableBalance = { - balance: { amount: '0.05' }, - fee: { amount: '0.0001' }, - }; - - (getMaxSpendableBalance as jest.Mock).mockResolvedValue( - mockMaxSpendableBalance, - ); - - await controller.handleButtonEvent(SendFormNames.SetMax); - - expect(controller.context.request.amount.amount).toBe( - mockMaxSpendableBalance.balance.amount, - ); - expect(controller.context.request.fees.amount).toBe( - mockMaxSpendableBalance.fee.amount, - ); - expect(controller.context.request.total.amount).toBe( - new BigNumber(mockMaxSpendableBalance.balance.amount) - .plus(new BigNumber(mockMaxSpendableBalance.fee.amount)) - .toString(), - ); - expect(updateSendFlow).toHaveBeenCalledWith({ - request: controller.context.request, - currencySwitched: true, - }); - }); - - it('should handle "SetMax" button event with error', async () => { - const mockRequest = generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ); - const mockContext = createMockContext(mockRequest); - - const controller = new SendBitcoinController({ - context: mockContext, - interfaceId: mockInterfaceId, - }); - - (getMaxSpendableBalance as jest.Mock).mockRejectedValue( - new Error('Error fetching max amount'), - ); - - await controller.handleButtonEvent(SendFormNames.SetMax); - - expect(controller.context.request.amount.error).toBe( - 'Error fetching max amount: Error fetching max amount', - ); - expect(controller.context.request.fees.loading).toBe(false); - expect(updateSendFlow).toHaveBeenCalledWith({ - request: controller.context.request, - currencySwitched: true, - }); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.ts b/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.ts deleted file mode 100644 index 575c8698..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/controller/send-bitcoin-controller.ts +++ /dev/null @@ -1,278 +0,0 @@ -import type { UserInputEvent } from '@metamask/snaps-sdk'; -import { UserInputEventType } from '@metamask/snaps-sdk'; -import { BigNumber } from 'bignumber.js'; - -import { TransactionDustError } from '../../bitcoin/wallet'; -import { estimateFee, getMaxSpendableBalance } from '../../rpcs'; -import type { KeyringStateManager } from '../../stateManagement'; -import { TransactionStatus, type SendFlowRequest } from '../../stateManagement'; -import { getDustThreshold } from '../../utils'; -import { SendFormNames } from '../components/SendForm'; -import { - displayConfirmationReview, - updateSendFlow, -} from '../render-interfaces'; -import { AssetType, type SendFlowContext, type SendFormState } from '../types'; -import { btcToFiat, fiatToBtc, formValidation, validateTotal } from '../utils'; - -export const isSendFormEvent = (event: UserInputEvent): boolean => { - return Object.values(SendFormNames).includes(event?.name as SendFormNames); -}; - -export class SendBitcoinController { - protected stateManager: KeyringStateManager; - - context: SendFlowContext; - - interfaceId: string; - - constructor({ - context, - interfaceId, - }: { - context: SendFlowContext; - interfaceId: string; - }) { - this.context = context; - this.interfaceId = interfaceId; - } - - async handleEvent( - event: UserInputEvent, - context: SendFlowContext, - formState: SendFormState, - ) { - if (!isSendFormEvent(event)) { - return; - } - - switch (event.type) { - case UserInputEventType.InputChangeEvent: { - await this.handleInputEvent( - event.name as SendFormNames, - context, - formState, - ); - break; - } - case UserInputEventType.ButtonClickEvent: { - await this.handleButtonEvent(event.name as SendFormNames); - break; - } - default: - break; - } - } - - async handleInputEvent( - eventName: SendFormNames, - context: SendFlowContext, - formState: SendFormState, - ): Promise { - // If there isn't an interfaceId, return early because the interface is not ready. - if (!this.context.request.interfaceId) { - return; - } - - formValidation(formState, context, this.context.request); - - switch (eventName) { - case SendFormNames.To: { - this.context.request.recipient.address = formState.to; - this.context.request.recipient.valid = Boolean( - !this.context.request.recipient.error, - ); - await updateSendFlow({ - request: this.context.request, - }); - break; - } - case SendFormNames.Amount: { - if (this.context.request.amount.error) { - await updateSendFlow({ - request: this.context.request, - }); - return; - } - this.context.request.amount.valid = Boolean( - !this.context.request.amount.error, - ); - this.context.request.fees.loading = true; - - // show loading state for fees - await updateSendFlow({ - request: this.context.request, - }); - - if (this.context.request.selectedCurrency === AssetType.BTC) { - this.context.request.amount.amount = formState.amount; - this.context.request.amount.fiat = btcToFiat( - formState.amount, - this.context.request.rates, - ); - } else { - this.context.request.amount.fiat = formState.amount; - this.context.request.amount.amount = fiatToBtc( - formState.amount, - this.context.request.rates, - ); - } - - try { - const estimates = await estimateFee({ - account: this.context.accounts[0].id, - amount: this.context.request.amount.amount, - }); - this.context.request.fees = { - fiat: btcToFiat(estimates.fee.amount, this.context.request.rates), - amount: estimates.fee.amount, - loading: false, - error: '', - }; - this.context.request.total = validateTotal( - this.context.request.amount.amount, - estimates.fee.amount, - this.context.request.balance.amount, - this.context.request.rates, - ); - } catch (feeError) { - if (feeError instanceof TransactionDustError) { - this.context.request.amount.error = `Transaction amount is too small. Please provide a value of at least ${getDustThreshold( - context.request.account, - )} SATs.`; - this.context.request.fees.loading = false; - } else { - this.context.request.fees = { - fiat: '', - amount: '', - loading: false, - error: feeError.message, - }; - } - } - await updateSendFlow({ - request: this.context.request, - }); - break; - } - default: - break; - } - } - - async handleButtonEvent(eventName: SendFormNames): Promise { - switch (eventName) { - case SendFormNames.HeaderBack: { - if (this.context.request.status === TransactionStatus.Review) { - this.context.request.status = TransactionStatus.Draft; - return await updateSendFlow({ - request: this.context.request, - flushToAddress: false, - backEventTriggered: true, - }); - } else if (this.context.request.status === TransactionStatus.Draft) { - this.context.request.status = TransactionStatus.Rejected; - return await this.resolveInterface(this.context.request); - } - throw new Error('Invalid state'); - } - case SendFormNames.Clear: - this.context.request.recipient = { - address: '', - error: '', - valid: false, - }; - return await updateSendFlow({ - request: this.context.request, - flushToAddress: true, - }); - case SendFormNames.Cancel: - case SendFormNames.Close: { - this.context.request.status = TransactionStatus.Rejected; - await this.resolveInterface(this.context.request); - return null; - } - case SendFormNames.SwapCurrencyDisplay: { - this.context.request.selectedCurrency = - this.context.request.selectedCurrency === AssetType.BTC - ? AssetType.FIAT - : AssetType.BTC; - return await updateSendFlow({ - request: this.context.request, - flushToAddress: false, - currencySwitched: true, - }); - } - case SendFormNames.Review: { - this.context.request.status = TransactionStatus.Review; - await displayConfirmationReview({ request: this.context.request }); - return null; - } - case SendFormNames.Send: { - this.context.request.status = TransactionStatus.Signed; - await this.resolveInterface(this.context.request); - return null; - } - case SendFormNames.SetMax: { - this.context.request.fees.loading = true; - await updateSendFlow({ - request: this.context.request, - }); - return await this.handleSetMax(); - } - default: - return null; - } - } - - async resolveInterface(value: SendFlowRequest): Promise { - await snap.request({ - method: 'snap_resolveInterface', - params: { - id: this.interfaceId, - value, - }, - }); - } - - async handleSetMax() { - try { - const maxAmount = await getMaxSpendableBalance({ - account: this.context.accounts[0].id, - }); - if (new BigNumber(maxAmount.balance.amount).lte(new BigNumber(0))) { - this.context.request.amount.error = 'Fees exceed max sendable amount'; - this.context.request.fees.loading = false; - } else { - this.context.request.amount = { - amount: maxAmount.balance.amount, - fiat: btcToFiat(maxAmount.balance.amount, this.context.request.rates), - error: '', - valid: true, - }; - this.context.request.fees = { - amount: maxAmount.fee.amount, - fiat: btcToFiat(maxAmount.fee.amount, this.context.request.rates), - loading: false, - error: '', - }; - this.context.request.total = validateTotal( - maxAmount.balance.amount, - maxAmount.fee.amount, - this.context.request.balance.amount, - this.context.request.rates, - ); - } - } catch (error) { - this.context.request.amount.error = `Error fetching max amount: ${ - error.message as string - }`; - this.context.request.fees.loading = false; - } - - return await updateSendFlow({ - request: this.context.request, - currencySwitched: true, - }); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/images/bitcoin.svg b/merged-packages/bitcoin-wallet-snap/src/ui/images/bitcoin.svg deleted file mode 100644 index a3a355aa..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/images/bitcoin.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/images/btc-halo.svg b/merged-packages/bitcoin-wallet-snap/src/ui/images/btc-halo.svg deleted file mode 100644 index 69a14a20..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/images/btc-halo.svg +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/images/empty-space.svg b/merged-packages/bitcoin-wallet-snap/src/ui/images/empty-space.svg deleted file mode 100644 index ddd505d0..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/images/empty-space.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon1.svg b/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon1.svg deleted file mode 100644 index c7cc308b..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon1.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon2.svg b/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon2.svg deleted file mode 100644 index b00299ac..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon2.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon3.svg b/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon3.svg deleted file mode 100644 index c43767a6..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/images/jazzicon3.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx b/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx deleted file mode 100644 index 8fcd6845..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/render-interfaces.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { v4 as uuidv4 } from 'uuid'; - -import { TransactionStatus, type SendFlowRequest } from '../stateManagement'; -import { - generateDefaultSendFlowParams, - generateDefaultSendFlowRequest, -} from '../utils/transaction'; -import { SendFlow, ReviewTransaction } from './components'; -import type { GenerateSendFlowParams, UpdateSendFlowParams } from './types'; - -/** - * Generate the send flow. - * - * @param params - The parameters for the send form. - * @param params.account - The selected account. - * @param params.scope - The scope of the send flow. - * @returns The interface ID. - */ -export async function generateSendFlow({ - account, - scope, -}: GenerateSendFlowParams): Promise { - const requestId = uuidv4(); - const sendFlowProps = generateDefaultSendFlowParams(scope); - const interfaceId = await snap.request({ - method: 'snap_createInterface', - params: { - ui: ( - - ), - context: { - requestId, - accounts: [account], - scope, - request: { - id: requestId, - interfaceId: '', // to be set in the next update - account, - transaction: {}, - status: TransactionStatus.Draft, - ...sendFlowProps, - }, - }, - }, - }); - - const sendFlowRequest = generateDefaultSendFlowRequest( - account, - scope, - requestId, - interfaceId, - ); - - return sendFlowRequest; -} - -/** - * Update the send flow interface. - * - * @param options - The options for updating the send flow. - * @param options.request - The send flow request object. - * @param options.flushToAddress - Whether to flush to address. - * @param options.currencySwitched - Whether the currency was switched. - * @param options.backEventTriggered - Whether the back event was triggered. - */ -export async function updateSendFlow({ - request, - flushToAddress = false, - currencySwitched = false, - backEventTriggered = false, -}: UpdateSendFlowParams) { - await snap.request({ - method: 'snap_updateInterface', - params: { - id: request.interfaceId, - ui: ( - - ), - context: { - requestId: request.id, - accounts: [request.account], - scope: request.scope, - request, - }, - }, - }); -} - -/** - * Generate the confirmation review interface. - * - * @param options0 - The options for generating the confirmation review interface. - * @param options0.request - The send flow request object. - * @returns The interface ID as a string. - */ -export async function generateConfirmationReviewInterface({ - request, -}: { - request: SendFlowRequest; -}): Promise { - return await snap.request({ - method: 'snap_createInterface', - params: { - ui: , - }, - }); -} - -/** - * Display the confirmation review interface. - * - * @param options0 - The options for displaying the confirmation review interface. - * @param options0.request - The send flow request object. - * @returns A promise that resolves when the confirmation review interface is displayed. - */ -export async function displayConfirmationReview({ - request, -}: { - request: SendFlowRequest; -}) { - return await snap.request({ - method: 'snap_updateInterface', - params: { - id: request.interfaceId, - ui: , - context: { - requestId: request.id, - accounts: [request.account], - scope: request.scope, - request, - }, - }, - }); -} diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/types.ts b/merged-packages/bitcoin-wallet-snap/src/ui/types.ts deleted file mode 100644 index bd228f06..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/types.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; - -import type { SendFlowRequest } from '../stateManagement'; - -/** - * The state of the send form. - * - * @property to - The receiving address. - * @property amount - The amount to send. - * @property accountSelector - The selected account. - */ -export type SendFormState = { - to: string; - amount: string; - accountSelector: string; -}; - -export enum SendFormError { - InvalidAddress = 'Invalid address', - InvalidAmount = 'Invalid amount', - ZeroAmount = 'Amount must be greater than 0', - InsufficientFunds = 'Insufficient funds', - TotalExceedsBalance = 'Amount and fees exceeds balance', - InvalidTotal = 'Invalid total', - InvalidFees = 'Invalid fees', -} - -/** - * The form errors. - * - * @property to - The error for the receiving address. - * @property amount - The error for the amount. - * @property total - The error for the total amount. - * @property fees - The error for the estimated fees. - */ -export type SendFormErrorsObject = { - to: SendFormError; - amount: SendFormError; - total: SendFormError; - fees: SendFormError; -}; - -/** - * A currency value. - * - * @property amount - The amount in the selected currency. - * @property fiat - The amount in fiat currency. - */ -export type Currency = { - amount: string; - fiat: string; -}; - -/** - * The context of the send flow interface. - * - * @property accounts - The available accounts. - * @property fees - The fees for the transaction. - * @property requestId - The ID of the send flow request. - */ -export type SendFlowContext = { - accounts: AccountWithBalance[]; - scope: string; - requestId: string; - request: SendFlowRequest; -}; - -export type AccountWithBalance = KeyringAccount & { balance?: Currency }; - -export enum AssetType { - BTC = 'BTC', - FIAT = '$', -} - -export type GenerateSendFlowParams = { - account: KeyringAccount; - scope: string; -}; - -export type UpdateSendFlowParams = { - request: SendFlowRequest; - flushToAddress?: boolean; - currencySwitched?: boolean; - backEventTriggered?: boolean; -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts b/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts deleted file mode 100644 index 20eb73ff..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/utils.test.ts +++ /dev/null @@ -1,572 +0,0 @@ -import { expect } from '@jest/globals'; -import { BtcAccountType, BtcMethod, BtcScope } from '@metamask/keyring-api'; -import { BigNumber } from 'bignumber.js'; -import { v4 as uuidv4 } from 'uuid'; - -import { Caip2ChainId, Caip2ChainIdToNetworkName } from '../constants'; -import type { SendBitcoinParams } from '../rpcs'; -import { TransactionStatus } from '../stateManagement'; -import { generateDefaultSendFlowRequest } from '../utils/transaction'; -import { AssetType, SendFormError } from './types'; -import { - validateAmount, - validateRecipient, - btcToFiat, - fiatToBtc, - generateSendBitcoinParams, - sendBitcoinParamsToSendFlowParams, - formValidation, - getNetworkNameFromScope, -} from './utils'; - -const mockEstimateFee = jest.fn(); -jest.mock('../rpcs/estimate-fee', () => ({ - estimateFee: () => mockEstimateFee(), -})); - -const mockInterfaceId = 'interfaceId'; -const mockScope = Caip2ChainId.Mainnet; -const mockRequestId = 'requestId'; -const mockAccount = { - type: BtcAccountType.P2wpkh, - id: uuidv4(), - address: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', - options: { - scope: Caip2ChainId.Mainnet, - index: '1', - }, - methods: [`${BtcMethod.SendBitcoin}`], - scopes: [BtcScope.Mainnet], -}; - -describe('utils', () => { - describe('validateAmount', () => { - it('should return error if amount is not a number', () => { - const result = validateAmount('abc', '100', '62000'); - expect(result).toStrictEqual({ - amount: '', - fiat: '', - error: SendFormError.InvalidAmount, - valid: false, - }); - }); - - it('should return error if amount is less than or equal to 0', () => { - const result = validateAmount('0', '100', '62000'); - expect(result).toStrictEqual({ - amount: '0', - fiat: '0', - error: SendFormError.ZeroAmount, - valid: false, - }); - }); - - it('should return error if amount is greater than balance', () => { - const result = validateAmount('200', '100', '62000'); - expect(result).toStrictEqual({ - amount: '200', - fiat: '12400000.00', - error: SendFormError.InsufficientFunds, - valid: false, - }); - }); - - it('should return valid amount if amount is valid', () => { - const result = validateAmount('50', '100', '62000'); - expect(result).toStrictEqual({ - amount: '50', - fiat: '3100000.00', - error: '', - valid: true, - }); - }); - }); - - describe('validateRecipient', () => { - it('should return valid for a correct mainnet address', () => { - const result = validateRecipient( - 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', - Caip2ChainId.Mainnet, - ); - expect(result).toStrictEqual({ - address: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', - error: '', - valid: true, - }); - }); - - it('should return valid for a correct testnet address', () => { - const result = validateRecipient( - 'mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn', - Caip2ChainId.Testnet, - ); - expect(result).toStrictEqual({ - address: 'mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn', - error: '', - valid: true, - }); - }); - - it('should return error for an invalid mainnet address', () => { - const result = validateRecipient('invalidAddress', Caip2ChainId.Mainnet); - expect(result).toStrictEqual({ - address: 'invalidAddress', - error: SendFormError.InvalidAddress, - valid: false, - }); - }); - - it('should return error for an invalid testnet address', () => { - const result = validateRecipient('invalidAddress', Caip2ChainId.Testnet); - expect(result).toStrictEqual({ - address: 'invalidAddress', - error: SendFormError.InvalidAddress, - valid: false, - }); - }); - - it('should return error for a valid mainnet address in testnet scope', () => { - const result = validateRecipient( - 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', - Caip2ChainId.Testnet, - ); - expect(result).toStrictEqual({ - address: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', - error: SendFormError.InvalidAddress, - valid: false, - }); - }); - - it('should return error for a valid testnet address in mainnet scope', () => { - const result = validateRecipient( - 'mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn', - Caip2ChainId.Mainnet, - ); - expect(result).toStrictEqual({ - address: 'mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn', - error: SendFormError.InvalidAddress, - valid: false, - }); - }); - - it('should handle empty address', () => { - const result = validateRecipient('', Caip2ChainId.Mainnet); - expect(result).toStrictEqual({ - address: '', - error: SendFormError.InvalidAddress, - valid: false, - }); - }); - - it('should handle null address', () => { - const result = validateRecipient( - null as unknown as string, - Caip2ChainId.Mainnet, - ); - expect(result).toStrictEqual({ - address: '', - error: SendFormError.InvalidAddress, - valid: false, - }); - }); - - it('should handle undefined address', () => { - const result = validateRecipient( - undefined as unknown as string, - Caip2ChainId.Mainnet, - ); - expect(result).toStrictEqual({ - address: '', - error: SendFormError.InvalidAddress, - valid: false, - }); - }); - }); - - describe('sendStateToSendBitcoinParams', () => { - it('should convert send state to SendBitcoinParams correctly', async () => { - const request = { - ...generateDefaultSendFlowRequest( - mockAccount, - mockScope, - mockRequestId, - mockInterfaceId, - ), - recipient: { - address: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', - error: '', - valid: true, - }, - amount: { amount: '0.1', fiat: '6200.00', error: '', valid: true }, - fees: { amount: '0.0001', fiat: '6.20', loading: false, error: '' }, - selectedCurrency: AssetType.BTC, - rates: '62000', - balance: { amount: '1', fiat: '62000.00' }, - }; - const scope = Caip2ChainId.Mainnet; - - const result = generateSendBitcoinParams(scope, request); - - expect(result).toStrictEqual({ - recipients: { bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a: '0.1' }, - replaceable: true, - dryrun: false, - scope, - }); - }); - }); - - describe('sendBitcoinParamsToSendFlowParams', () => { - const mockFee = { - fee: { - amount: '0.0001', - unit: 'BTC', - }, - }; - - beforeEach(() => { - mockEstimateFee.mockResolvedValue(mockFee); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('should convert SendBitcoinParams to SendFlowParams correctly', async () => { - const params: Omit = { - recipients: { bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a: '0.1' }, - replaceable: true, - dryrun: false, - }; - const account = 'testAccount'; - const scope = Caip2ChainId.Mainnet; - const rates = '62000'; - const balance = '1'; - - const result = await sendBitcoinParamsToSendFlowParams( - params, - account, - scope, - rates, - balance, - ); - - const expectedAmount = Object.values(params.recipients)[0]; - const expectedTotal = new BigNumber(expectedAmount) - .plus(new BigNumber(mockFee.fee.amount)) - .toString(); - - expect(result).toStrictEqual({ - rates, - recipient: { - address: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', - error: '', - valid: true, - }, - balance: { - amount: balance, - fiat: btcToFiat(balance, rates), - }, - fees: { - amount: mockFee.fee.amount, - fiat: btcToFiat(mockFee.fee.amount, rates), - loading: false, - error: '', - }, - amount: { - amount: expectedAmount, - fiat: btcToFiat(expectedAmount, rates), - error: '', - valid: true, - }, - total: { - amount: expectedTotal, - fiat: btcToFiat(expectedTotal, rates), - valid: true, - error: '', - }, - selectedCurrency: AssetType.BTC, - scope, - }); - }); - - it('should handle invalid recipient address', async () => { - const params = { - recipients: { invalidAddress: '0.1' }, - replaceable: true, - dryrun: false, - }; - const account = 'testAccount'; - const scope = Caip2ChainId.Mainnet; - const rates = '62000'; - const balance = '1'; - - const result = await sendBitcoinParamsToSendFlowParams( - params, - account, - scope, - rates, - balance, - ); - expect(result.recipient.error).toBe('Invalid address'); - }); - - it('should handle invalid amount', async () => { - const params = { - recipients: { bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a: '0' }, - replaceable: true, - dryrun: false, - }; - const account = 'testAccount'; - const scope = Caip2ChainId.Mainnet; - const rates = '62000'; - const balance = '1'; - - const result = await sendBitcoinParamsToSendFlowParams( - params, - account, - scope, - rates, - balance, - ); - - expect(result.amount.error).toBe('Amount must be greater than 0'); - }); - - it('should handle insufficient balance', async () => { - const params = { - recipients: { bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a: '2' }, - replaceable: true, - dryrun: false, - }; - const account = 'testAccount'; - const scope = Caip2ChainId.Mainnet; - const rates = '62000'; - const balance = '1'; - - const result = sendBitcoinParamsToSendFlowParams( - params, - account, - scope, - rates, - balance, - ); - expect((await result).amount.error).toBe(SendFormError.InsufficientFunds); - }); - }); - - describe('btcToFiat', () => { - it.each([ - { amount: '1', rate: '62000', expected: '62000.00' }, - { amount: '0.1', rate: '62000', expected: '6200.00' }, - { amount: '1.1', rate: '62000', expected: '68200.00' }, - { amount: '1', rate: '0', expected: '0.00' }, - { amount: '0', rate: '62000', expected: '0.00' }, - { amount: '12', rate: '62000.888', expected: '744010.66' }, - { amount: '12', rate: '0.888', expected: '10.66' }, - ])( - 'should convert $amount btc to $rate fiat', - ({ amount, rate, expected }) => { - expect(btcToFiat(amount, rate)).toStrictEqual(expected); - }, - ); - }); - - describe('fiatToBtc', () => { - it.each([ - { amount: '1', rate: '62000', expected: '0.00001613' }, - { amount: '0.1', rate: '62000', expected: '0.00000161' }, - { amount: '1.1', rate: '62000', expected: '0.00001774' }, - { amount: '0', rate: '62000', expected: '0.00000000' }, - { amount: '12', rate: '62000.888', expected: '0.00019355' }, - { amount: '12', rate: '0.888', expected: '13.51351351' }, - ])( - 'should convert $amount fiat to $rate btc', - ({ amount, rate, expected }) => { - expect(fiatToBtc(amount, rate)).toStrictEqual(expected); - }, - ); - }); - - describe('formValidation', () => { - const context = { scope: Caip2ChainId.Mainnet }; - const rates = '62000'; - const balance = '1'; - - it('should validate form correctly with valid data', () => { - const formState = { - accountSelector: 'testAccount', - amount: '0.1', - to: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', - }; - const request = { - id: 'test-id', - interfaceId: 'test-interface-id', - account: mockAccount, - scope: Caip2ChainId.Mainnet, - status: TransactionStatus.Draft, - transaction: { - recipients: {}, - replaceable: true, - dryrun: false, - }, - total: { amount: '', fiat: '', error: '', valid: false }, - recipient: { address: '', error: '', valid: false }, - amount: { amount: '', fiat: '', error: '', valid: false }, - fees: { amount: '', fiat: '', loading: false, error: '' }, - selectedCurrency: AssetType.BTC, - rates, - balance: { amount: balance, fiat: '62000.00' }, - }; - const result = formValidation( - formState, - { - ...context, - request, - accounts: [mockAccount], - requestId: 'test-id', - }, - request, - ); - - expect(result).toStrictEqual({ - ...request, - recipient: { - address: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', - error: '', - valid: true, - }, - amount: { - amount: '0.1', - fiat: '6200.00', - error: '', - valid: true, - }, - fees: { amount: '', fiat: '', loading: false, error: '' }, - selectedCurrency: AssetType.BTC, - rates, - balance: { amount: balance, fiat: '62000.00' }, - // We are only validating the inputs here. - total: { - amount: '', - fiat: '', - error: '', - valid: false, - }, - }); - }); - - it.each([ - { - formState: { - amount: 'abc', - to: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', - }, - expectedAmountError: SendFormError.InvalidAmount, - expectedRecipientError: '', - }, - { - formState: { - amount: '0', - to: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', - }, - expectedAmountError: SendFormError.ZeroAmount, - expectedRecipientError: '', - }, - { - formState: { - amount: '2', - to: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', - }, - expectedAmountError: SendFormError.InsufficientFunds, - expectedRecipientError: '', - }, - { - formState: { amount: '0.1', to: 'invalidAddress' }, - expectedAmountError: '', - expectedRecipientError: SendFormError.InvalidAddress, - }, - ])( - 'should handle error cases for form validation', - ({ formState, expectedAmountError, expectedRecipientError }) => { - const request = { - id: 'test-id', - interfaceId: 'test-interface-id', - account: mockAccount, - scope: Caip2ChainId.Mainnet, - recipient: { address: '', error: '', valid: false }, - amount: { amount: '', fiat: '', error: '', valid: false }, - fees: { amount: '', fiat: '', loading: false, error: '' }, - selectedCurrency: AssetType.BTC, - rates, - balance: { amount: balance, fiat: '62000.00' }, - }; - // @ts-expect-error test only request params and not the whole object - const result = formValidation(formState, context, request); - - expect(result.amount.error).toBe(expectedAmountError); - expect(result.recipient.error).toBe(expectedRecipientError); - }, - ); - - it('should reset fees if amount is invalid', () => { - const formState = { - amount: 'abc', - to: 'bc1q26a367uz34eg5mufwhlscwdcplu6frtgf00r7a', - }; - const request = { - recipient: { address: '', error: '', valid: false }, - amount: { amount: '', fiat: '', error: '', valid: false }, - fees: { amount: '0.0001', fiat: '6.20', loading: false, error: '' }, - selectedCurrency: AssetType.BTC, - rates, - balance: { amount: balance, fiat: '62000.00' }, - }; - // @ts-expect-error test only request params and not the whole object - const result = formValidation(formState, context, request); - - expect(result.fees).toStrictEqual({ - amount: '', - fiat: '', - loading: false, - error: '', - }); - }); - }); - - describe('getNetworkNameFromScope', () => { - const expectedError = 'Unknown Network'; - - it('should return "Mainnet" for Caip2ChainId.Mainnet', () => { - const result = getNetworkNameFromScope(Caip2ChainId.Mainnet); - expect(result).toBe(Caip2ChainIdToNetworkName[Caip2ChainId.Mainnet]); - }); - - it('should return "Testnet" for Caip2ChainId.Testnet', () => { - const result = getNetworkNameFromScope(Caip2ChainId.Testnet); - expect(result).toBe(Caip2ChainIdToNetworkName[Caip2ChainId.Testnet]); - }); - - it('should return "Unknown" for an unknown scope', () => { - const result = getNetworkNameFromScope('unknownScope' as Caip2ChainId); - expect(result).toBe(expectedError); - }); - - it('should return "Unknown" for an empty string', () => { - const result = getNetworkNameFromScope('' as Caip2ChainId); - expect(result).toBe(expectedError); - }); - - it('should return "Unknown" for null', () => { - const result = getNetworkNameFromScope(null as unknown as Caip2ChainId); - expect(result).toBe(expectedError); - }); - - it('should return "Unknown" for undefined', () => { - const result = getNetworkNameFromScope( - undefined as unknown as Caip2ChainId, - ); - expect(result).toBe(expectedError); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts b/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts deleted file mode 100644 index 4900b256..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/ui/utils.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { BigNumber } from 'bignumber.js'; -// eslint-disable-next-line import/no-named-as-default -import validate, { Network } from 'bitcoin-address-validation'; -import { v4 as uuidv4 } from 'uuid'; - -import { - Caip19Asset, - Caip2ChainId, - Caip2ChainIdToNetworkName, -} from '../constants'; -import type { SendBitcoinParams } from '../rpcs'; -import { estimateFee } from '../rpcs'; -import type { SendFlowParams, Wallet } from '../stateManagement'; -import { TransactionStatus, type SendFlowRequest } from '../stateManagement'; -import { generateDefaultSendFlowParams } from '../utils/transaction'; -import { generateConfirmationReviewInterface } from './render-interfaces'; -import { AssetType, SendFormError } from './types'; -import type { SendFlowContext, SendFormState } from './types'; - -/** - * Validate the send form. - * - * @param formState - The state of the send form. - * @param context - The context of the interface. - * @param request - The request object containing form data and errors. - * @returns The `SendFlowRequest` object with the assigned errors. - */ -export function formValidation( - formState: SendFormState, - context: SendFlowContext, - request: SendFlowRequest, -): SendFlowRequest { - // We only validate the values that have changed - // If we validate all the values on every change there can be a race condition. - - const { amount, to } = formState; - - // `amount` won't be defined during the first initialization: - if (amount && amount !== request.amount.amount) { - const formAmount = formState.amount ?? '0'; - const cryptoAmount = - request.selectedCurrency === AssetType.BTC - ? formAmount - : fiatToBtc(formAmount, request.rates); - request.amount = validateAmount( - cryptoAmount, - request.balance.amount, - request.rates, - ); - // Reset the fees if the amount is invalid - if (request.amount.error) { - request.fees = { - amount: '', - fiat: '', - loading: false, - error: '', - }; - } - } - - // `to` won't be defined during the first initialization: - if (to && to !== request.recipient.address) { - request.recipient = validateRecipient(to, context.scope); - } - - return request; -} - -/** - * Validates the amount to be sent. - * - * @param amount - The amount to be validated. - * @param balance - The current balance of the account. - * @param rates - The conversion rates from Bitcoin to fiat. - * @returns An object containing the validated amount, fiat equivalent, error message, and validity status. - */ -export function validateAmount( - amount: string, - balance: string, - rates: string, -): SendFlowRequest['amount'] { - if (!amount || isNaN(Number(amount))) { - return { - amount: '', - fiat: '', - error: SendFormError.InvalidAmount, - valid: false, - }; - } - - const fiatAmount = tryFiatConversion(rates, amount); - - if (new BigNumber(amount).lte(new BigNumber(0))) { - return { - amount: '0', - fiat: '0', - error: SendFormError.ZeroAmount, - valid: false, - }; - } - - if (new BigNumber(amount).gt(new BigNumber(balance))) { - return { - amount, - fiat: fiatAmount, - error: SendFormError.InsufficientFunds, - valid: false, - }; - } - - return { - amount, - fiat: fiatAmount, - error: '', - valid: true, - }; -} - -/** - * Validates the amount to be sent. - * - * @param amount - The amount to be validated. - * @param fees - The fees to be validated. - * @param balance - The current balance of the account. - * @param rates - The conversion rates from Bitcoin to fiat. - * @returns An object containing the validated amount, fiat equivalent, error message, and validity status. - */ -export function validateTotal( - amount: string, - fees: string, - balance: string, - rates: string, -): SendFlowRequest['total'] { - if ([amount, fees, balance].some((value) => isNaN(Number(value)))) { - return { - amount: '', - fiat: '', - error: '', - valid: false, - }; - } - - const total = new BigNumber(amount).plus(new BigNumber(fees)); - const fiatTotal = tryFiatConversion(rates, total.toString()); - - if (total.gt(new BigNumber(balance))) { - return { - amount: total.toString(), - fiat: fiatTotal, - error: SendFormError.TotalExceedsBalance, - valid: false, - }; - } - - return { - amount: total.toString(), - fiat: fiatTotal, - error: '', - valid: true, - }; -} - -/** - * Converts the send state to SendBitcoinParams. - * - * @param scope - The scope of the network (mainnet or testnet). - * @param request - The request object containing form data and errors. - * @returns A promise that resolves to the SendBitcoinParams object. - */ -export function generateSendBitcoinParams( - scope: string, - request?: SendFlowRequest, -): SendBitcoinParams { - if (!request) { - return { - recipients: {}, - replaceable: true, - dryrun: false, - scope, - }; - } - - return { - recipients: { - [request.recipient.address]: request.amount.amount, - }, - replaceable: true, - dryrun: false, - scope, - }; -} - -/** - * Converts a Bitcoin amount to its equivalent fiat value. - * - * @param amount - The amount of Bitcoin to convert. - * @param rate - The conversion rate from Bitcoin to fiat. - * @returns The equivalent fiat value as a string. - */ -export function btcToFiat(amount: string, rate: string): string { - const amountBN = new BigNumber(amount); - const rateBN = new BigNumber(rate); - return amountBN.multipliedBy(rateBN).toFixed(2); -} - -/** - * Converts a fiat amount to its equivalent Bitcoin value. - * - * @param amount - The amount of fiat currency to convert. - * @param rate - The conversion rate from fiat to Bitcoin. - * @returns The equivalent Bitcoin value as a string. - */ -export function fiatToBtc(amount: string, rate: string): string { - const amountBN = new BigNumber(amount); - const rateBN = new BigNumber(rate); - return amountBN.dividedBy(rateBN).toFixed(8); // 8 is the number of decimals for btc -} - -/** - * Validates the recipient address based on the given scope. - * - * @param address - The recipient address to validate. - * @param scope - The scope of the network (mainnet or testnet). - * @returns An object containing the address, error message, and validity status. - */ -export function validateRecipient( - address: string, - scope: string, -): SendFlowRequest['recipient'] { - if ( - (scope === Caip2ChainId.Mainnet && !validate(address, Network.mainnet)) || - (scope === Caip2ChainId.Testnet && !validate(address, Network.testnet)) - ) { - return { - address: address ?? '', - error: SendFormError.InvalidAddress, - valid: false, - }; - } - - return { - address, - error: '', - valid: true, - }; -} - -/** - * Generates a send flow request object. - * - * @param wallet - The wallet object containing account and scope information. - * @param status - The current transaction status. - * @param rates - The conversion rates from Bitcoin to fiat. - * @param balance - The current balance of the account. - * @param transaction - Optional transaction details. - * @returns A promise that resolves to the SendFlowRequest object. - */ -export async function generateSendFlowRequest( - wallet: Wallet, - status: TransactionStatus, - rates: string, - balance: string, - transaction?: SendFlowRequest['transaction'], -): Promise { - const sendBitcoinParams = - transaction ?? generateSendBitcoinParams(wallet.scope); - const sendFlowRequest = { - id: uuidv4(), - account: wallet.account, - transaction: sendBitcoinParams, - interfaceId: '', - status: status ?? TransactionStatus.Draft, - ...(await sendBitcoinParamsToSendFlowParams( - sendBitcoinParams, - wallet.account.id, - wallet.scope, - rates, - balance, - )), - }; - - const interfaceId = await generateConfirmationReviewInterface({ - request: sendFlowRequest, - }); - - sendFlowRequest.interfaceId = interfaceId; - - return sendFlowRequest; -} - -/** - * Converts SendBitcoinParams to SendFlowParams. - * - * @param params - The parameters for sending many transactions. - * @param account - The account from which the transactions will be sent. - * @param scope - The scope of the network (mainnet or testnet). - * @param rates - The conversion rates from Bitcoin to fiat. - * @param balance - The balance of the account. - * @returns A promise that resolves to the send flow parameters. - */ -export async function sendBitcoinParamsToSendFlowParams( - params: Omit, - account: string, - scope: string, - rates: string, - balance: string, -): Promise { - const defaultParams = generateDefaultSendFlowParams(scope); - // This is safe because we validate the recipient in `validateRecipient` if it is not defined. - const recipient = Object.keys(params.recipients)[0]; - const amount = params.recipients[recipient]; - - defaultParams.rates = rates; - defaultParams.recipient = validateRecipient(recipient, scope); - defaultParams.balance = { - amount: balance, - fiat: btcToFiat(balance, rates), - }; - - try { - const estimatedFees = await estimateFee({ - account, - amount, - }); - defaultParams.fees.amount = estimatedFees.fee.amount; - defaultParams.fees.fiat = btcToFiat(estimatedFees.fee.amount, rates); - defaultParams.amount = validateAmount(amount, balance, rates); - defaultParams.total = validateTotal( - amount, - estimatedFees.fee.amount, - balance, - rates, - ); - } catch (error) { - defaultParams.fees.error = `Error estimating fees: ${ - error.message as string - }`; - } - - return defaultParams; -} - -/** - * Gets the asset type based on the given scope. - * - * @param scope - The scope of the network (mainnet or testnet). - * @returns The asset type corresponding to the scope. - */ -export function getAssetTypeFromScope(scope: string): Caip19Asset { - return scope === Caip2ChainId.Mainnet ? Caip19Asset.Btc : Caip19Asset.TBtc; -} - -/** - * Gets the network name based on the given scope. - * - * @param scope - The scope of the network (mainnet or testnet). - * @returns The network name corresponding to the scope. - */ -export function getNetworkNameFromScope(scope: string): string { - return Caip2ChainIdToNetworkName[scope] ?? 'Unknown Network'; -} - -/** - * Checks if the given amount is available. - * - * @param amount - The amount to check. - * @returns True if the amount is an empty string or not a number, otherwise false. - */ -export function amountNotAvailable(amount: string): boolean { - return amount === '' || isNaN(Number(amount)); -} - -/** - * Returns an empty string if the provided value is not a number, otherwise returns the value. - * - * @param value - The value to be checked. - * @param prefix - The prefix to be added before the value if it is a number. - * @param suffix - The suffix to be added after the value if it is a number. - * @returns The original value if it is a number, otherwise an empty string. - */ -export function displayEmptyStringIfAmountNotAvailableOrEmptyAmount( - value: string, - prefix = '', - suffix = '', -): string { - return amountNotAvailable(value) ? '' : `${prefix} ${value} ${suffix}`.trim(); -} - -/** - * Tries to convert a Bitcoin amount to its equivalent fiat value. - * - * @param rates - The conversion rate from Bitcoin to fiat. - * @param amount - The amount of Bitcoin to convert. - * @returns The equivalent fiat value as a string, or an empty string if the rates are invalid. - */ -export function tryFiatConversion(rates: string, amount: string): string { - // We do not want to block a user from sending if the rates are not available - const isValidRates = rates && !isNaN(Number(rates)); - const fiatTotal = isValidRates ? btcToFiat(amount, rates) : ''; - return fiatTotal; -} diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index e28deab0..811ce19c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -12,9 +12,12 @@ import type { SnapClient, TransactionRequest, } from '../entities'; +import type { ILogger } from '../infra/logger'; import { AccountUseCases } from './AccountUseCases'; -jest.mock('../utils/logger'); +jest.mock('../infra/logger', () => { + return { logger: mock() }; +}); describe('AccountUseCases', () => { let useCases: AccountUseCases; diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index e8138233..1caacaf7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -9,7 +9,7 @@ import type { SnapClient, MetaProtocolsClient, } from '../entities'; -import { logger } from '../utils'; +import { logger } from '../infra/logger'; const addressTypeToPurpose: Record = { p2pkh: "44'", diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 409e6baf..c48b1e49 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -19,6 +19,7 @@ import { CurrencyUnit, SendFormEvent, } from '../entities'; +import type { ILogger } from '../infra/logger'; import { SendFlowUseCases } from './SendFlowUseCases'; // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 @@ -34,7 +35,9 @@ jest.mock('bitcoindevkit', () => { }; }); -jest.mock('../utils/logger'); +jest.mock('../infra/logger', () => { + return { logger: mock() }; +}); describe('SendFlowUseCases', () => { let useCases: SendFlowUseCases; diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index 93ce13b0..b3bbaa4a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -15,7 +15,7 @@ import { ReviewTransactionEvent, networkToCurrencyUnit, } from '../entities'; -import { logger } from '../utils'; +import { logger } from '../infra/logger'; export class SendFlowUseCases { readonly #snapClient: SnapClient; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/logger.ts b/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/logger.ts deleted file mode 100644 index 12595b73..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/logger.ts +++ /dev/null @@ -1,17 +0,0 @@ -export class Logger { - log = jest.fn(); - - warn = jest.fn(); - - error = jest.fn(); - - debug = jest.fn(); - - info = jest.fn(); - - trace = jest.fn(); - - logLevel = 0; -} - -export const logger = new Logger(); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts deleted file mode 100644 index bab01542..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/__mocks__/snap.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { type SLIP10NodeInterface } from '@metamask/key-tree'; -import { networks } from 'bitcoinjs-lib'; - -import { createRandomBip32Data } from '../../../test/utils'; - -export const getProvider = jest.fn(); - -/** - * Retrieves a `SLIP10NodeInterface` object for the specified path and curve. - * - * @param path - The BIP32 derivation path for which to retrieve a `SLIP10NodeInterface`. - * @param curve - The elliptic curve to use for key derivation. - * @returns A Promise that resolves to a `SLIP10NodeInterface` object. - */ -export async function getBip32Deriver( - path: string[], - curve: 'secp256k1' | 'ed25519', -): Promise { - const { data } = createRandomBip32Data(networks.bitcoin, path, curve); - return { - ...data, - toJSON: jest.fn().mockReturnValue(data), - } as SLIP10NodeInterface; -} - -export const getBip44Deriver = jest.fn(); - -export const confirmDialog = jest.fn(); - -export const alertDialog = jest.fn(); - -export const getStateData = jest.fn(); - -export const setStateData = jest.fn(); - -export const createSendUIDialog = () => jest.fn().mockResolvedValue(true)(); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/account.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/account.test.ts deleted file mode 100644 index 6c9d5fa4..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/account.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { BtcMethod, type KeyringAccount } from '@metamask/keyring-api'; -import type { Network } from 'bitcoinjs-lib'; -import { networks } from 'bitcoinjs-lib'; -import { v4 as uuidV4 } from 'uuid'; - -import type { BtcAccount } from '../bitcoin/wallet'; -import { BtcAccountDeriver, BtcWallet } from '../bitcoin/wallet'; -import { Config } from '../config'; -import { Caip2ChainId } from '../constants'; -import { AccountNotFoundError } from '../exceptions'; -import { verifyIfAccountValid } from './account'; - -jest.mock('./snap'); - -describe('verifyIfAccountValid', function () { - const createMockDeriver = (network) => { - return { - instance: new BtcAccountDeriver(network), - }; - }; - - const createBtcAccount = async (network: Network, index = 0) => { - const { instance } = createMockDeriver(network); - const wallet = new BtcWallet(instance, network); - return await wallet.unlock(index, Config.wallet.defaultAccountType); - }; - - const createKeyringAccount = ( - address: string, - caip2ChainId: string, - index: number, - ) => { - return { - type: Config.wallet.defaultAccountType, - id: uuidV4(), - address, - options: { - scope: caip2ChainId, - index, - }, - methods: [`${BtcMethod.SendBitcoin}`], - } as unknown as KeyringAccount; - }; - - it('does not throw error if `BtcAccount` object and the `KeyringAccount` object are valid and consistent', async function () { - const btcAccount = await createBtcAccount(networks.testnet); - const keyringAccount = createKeyringAccount( - btcAccount.address, - Caip2ChainId.Testnet, - btcAccount.index, - ); - - expect(() => - verifyIfAccountValid(btcAccount, keyringAccount), - ).not.toThrow(); - }); - - it('throws AccountNotFoundError if either the `BtcAccount` object or the `KeyringAccount` object is not provided', async function () { - const btcAccount = await createBtcAccount(networks.testnet); - const keyringAccount = createKeyringAccount( - btcAccount.address, - Caip2ChainId.Testnet, - btcAccount.index, - ); - - expect(() => - verifyIfAccountValid(btcAccount, null as unknown as KeyringAccount), - ).toThrow(AccountNotFoundError); - expect(() => - verifyIfAccountValid(null as unknown as BtcAccount, keyringAccount), - ).toThrow(AccountNotFoundError); - }); - - it("throws `Inconsistent account found` error if the BtcAccount's address is not matching the KeyringAccount's address", async function () { - const btcAccount = await createBtcAccount(networks.testnet, 0); - const inconsistentBtcAccount = await createBtcAccount(networks.testnet, 1); - const keyringAccount = createKeyringAccount( - inconsistentBtcAccount.address, - Caip2ChainId.Testnet, - inconsistentBtcAccount.index, - ); - - expect(() => verifyIfAccountValid(btcAccount, keyringAccount)).toThrow( - new AccountNotFoundError('Inconsistent account found'), - ); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/account.ts b/merged-packages/bitcoin-wallet-snap/src/utils/account.ts deleted file mode 100644 index 9218f9b8..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/account.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; - -import type { BtcAccount } from '../bitcoin/wallet'; -import { AccountNotFoundError } from '../exceptions'; - -/** - * Verifies if the provided `BtcAccount` object and `KeyringAccount` object are valid and that their addresses are consistent. - * - * @param account - The `BtcAccount` object to verify. - * @param keyringAccount - The `KeyringAccount` object to verify. - * @throws {AccountNotFoundError} If either the `BtcAccount` object or the `KeyringAccount` object is not provided. - * @throws {AccountNotFoundError} If the `BtcAccount`'s address and the `KeyringAccount`'s address are not matching. - */ -export function verifyIfAccountValid( - account: BtcAccount, - keyringAccount: KeyringAccount, -): void { - if (!account || !keyringAccount) { - throw new AccountNotFoundError(); - } - // Make sure the BtcAccount's address is consistent with the state data, if not, then either the state has been corrupted or - // the derivation scheme changed (which should not happen without an explicit migration of the state) - if (account.address !== keyringAccount.address) { - throw new AccountNotFoundError('Inconsistent account found'); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/async.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/async.test.ts deleted file mode 100644 index 11a673fb..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/async.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { processBatch } from './async'; - -describe('processBatch', function () { - it('processes the array in batch', async function () { - const fn = jest.fn() as any; - const mockArr = new Array(99).fill(0); - - await processBatch(mockArr, fn); - expect(fn).toHaveBeenCalledTimes(mockArr.length); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/async.ts b/merged-packages/bitcoin-wallet-snap/src/utils/async.ts deleted file mode 100644 index 78817007..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/async.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Executes the given promise callback on the data in batches. - * - * @param dataList - An array of data to be processed in batches. - * @param callback - A promise callback to be executed on each item of the array. - * @param batchSize - The size of the batch operation, default is 50. - * @returns A promise that resolves when all batches have been processed. - */ -export async function processBatch( - dataList: Data[], - callback: (item: Data) => Promise, - batchSize = 50, -): Promise { - let from = 0; - let to = batchSize; - while (from < dataList.length) { - const batch: Promise[] = []; - for (let i = from; i < Math.min(to, dataList.length); i++) { - batch.push(callback(dataList[i])); - } - await Promise.all(batch); - from += batchSize; - to += batchSize; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/config.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/config.test.ts deleted file mode 100644 index e5167c53..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/config.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { networks } from 'bitcoinjs-lib'; - -import { Config } from '../config'; -import { isSatsProtectionEnabled } from './config'; - -describe('isSatsProtectionEnabled', () => { - const { defaultSatsProtectionEnabled } = Config; - - afterEach(() => { - Config.defaultSatsProtectionEnabled = defaultSatsProtectionEnabled; - }); - - it.each([ - { - network: networks.bitcoin, - networkName: 'mainnet', - satsProtection: true, - expected: true, - }, - { - network: networks.bitcoin, - networkName: 'mainnet', - satsProtection: false, - expected: false, - }, - { - network: networks.testnet, - networkName: 'testnet', - satsProtection: true, - expected: false, - }, - { - network: networks.testnet, - networkName: 'testnet', - satsProtection: false, - expected: false, - }, - ])( - 'return $expected when: satsProtection - $satsProtection, network - $networkName', - async ({ network, satsProtection, expected }) => { - Config.defaultSatsProtectionEnabled = satsProtection; - - expect(isSatsProtectionEnabled(network)).toBe(expected); - }, - ); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/config.ts b/merged-packages/bitcoin-wallet-snap/src/utils/config.ts deleted file mode 100644 index ca49e75c..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Network } from 'bitcoinjs-lib'; -import { networks } from 'bitcoinjs-lib'; - -import { Config } from '../config'; - -/** - * Determines if Sats Protection is enabled for the given network. - * - * @param network - The network for which to determine if Sats Protection is enabled. - * @returns `true` if Sats Protection is enabled, `false` otherwise. - */ -export function isSatsProtectionEnabled(network: Network): boolean { - // Safeguard to only allow Sats Protection on mainnet (since SimpleHash - // does not support testnet for this use case). - return Config.defaultSatsProtectionEnabled && network === networks.bitcoin; -} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/explorer.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/explorer.test.ts deleted file mode 100644 index 557545f4..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/explorer.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Caip2ChainId } from '../constants'; -import { getExplorerUrl } from './explorer'; - -describe('getExplorerUrl', () => { - const address = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; - - it('returns a testnet explorer url', () => { - const result = getExplorerUrl(address, Caip2ChainId.Testnet); - expect(result).toBe(`https://blockstream.info/testnet/address/${address}`); - }); - - it('returns a mainnet explorer url', () => { - const result = getExplorerUrl(address, Caip2ChainId.Mainnet); - expect(result).toBe(`https://blockstream.info/address/${address}`); - }); - - it('throws `Invalid Chain ID` error if the given Chain ID is not support', () => { - expect(() => getExplorerUrl(address, 'some Chain ID')).toThrow( - 'Invalid Chain ID', - ); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/explorer.ts b/merged-packages/bitcoin-wallet-snap/src/utils/explorer.ts deleted file mode 100644 index 04f1aecb..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/explorer.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Config } from '../config'; -import { Caip2ChainId } from '../constants'; - -/** - * Gets the explorer URL for a given bitcoin address and CAIP-2 Chain ID. - * - * @param address - The bitcoin address to get the explorer URL for. - * @param caip2ChainId - The CAIP-2 Chain ID. - * @returns The explorer URL as a string. - * @throws An error if an invalid scope is provided. - */ -export function getExplorerUrl(address: string, caip2ChainId: string): string { - switch (caip2ChainId) { - case Caip2ChainId.Mainnet: - return Config.explorer[Caip2ChainId.Mainnet].replace( - // eslint-disable-next-line no-template-curly-in-string - '${address}', - address, - ); - case Caip2ChainId.Testnet: - return Config.explorer[Caip2ChainId.Testnet].replace( - // eslint-disable-next-line no-template-curly-in-string - '${address}', - address, - ); - default: - throw new Error('Invalid Chain ID'); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/fee.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/fee.test.ts deleted file mode 100644 index f512af89..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/fee.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { DefaultTxMinFeeRateInBtcPerKvb } from '../bitcoin/wallet'; -import { getMinimumFeeRateInKvb } from './fee'; - -describe('getMinimumFeeRateInKvb', () => { - it.each( - [ - // when all inputs are same - [1, 1, 1, 1], - // when minrelaytxfee is the highest - [1, 2, 3, 3], - // when mempoolminfee is the highest - [1, 3, 2, 3], - // when smartFee is the highest - [4, 2, 1, 4], - // when all inputs less than DefaultTxMinFeeRateInBtcPerKvb - [ - DefaultTxMinFeeRateInBtcPerKvb / 10, - DefaultTxMinFeeRateInBtcPerKvb / 10, - DefaultTxMinFeeRateInBtcPerKvb / 10, - DefaultTxMinFeeRateInBtcPerKvb, - ], - ].map((data) => ({ - smartFee: data[0], - mempoolminfee: data[1], - minrelaytxfee: data[2], - expected: data[3], - })), - )( - 'returns the minimum required fee for: smartFee=$smartFee, mempoolminfee=$mempoolminfee, minrelaytxfee=$minrelaytxfee', - ({ - smartFee, - mempoolminfee, - minrelaytxfee, - expected, - }: { - smartFee: number; - mempoolminfee: number; - minrelaytxfee: number; - expected: number; - }) => { - const result = getMinimumFeeRateInKvb( - smartFee, - mempoolminfee, - minrelaytxfee, - ); - expect(result).toStrictEqual(expected); - }, - ); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/fee.ts b/merged-packages/bitcoin-wallet-snap/src/utils/fee.ts deleted file mode 100644 index 13957117..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/fee.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; -import { assert, enums } from 'superstruct'; - -import type { Fee } from '../bitcoin/chain'; -import { FeeRate } from '../bitcoin/chain/constants'; -import { - BtcAccount, - BtcAccountTypeToScriptType, - DefaultTxMinFeeRateInBtcPerKvb, - DustLimit, -} from '../bitcoin/wallet'; -import { Config } from '../config'; -import { FeeRateUnavailableError } from '../exceptions'; - -/** - * Retrieves the fee rate from an array of `Fee` objects based on the fee type provided. - * If no fee type is provided, the default fee rate specified in the configuration will be used. - * - * @param fees - The array of `Fee` objects. - * @param feeType - The fee type to retrieve the fee rate for. Default is `Config.defaultFeeRate` - * @returns The fee rate for the given fee type or default fee rate. - * @throws {FeeRateUnavailableError} If no `Fee` object is found for the provided fee type. - */ -export function getFeeRate( - fees: Fee[], - feeType: FeeRate = Config.defaultFeeRate, -) { - assert(feeType, enums(Object.values(FeeRate))); - - const selectedFees = fees.find((fee) => fee.type === feeType); - - if (!selectedFees) { - throw new FeeRateUnavailableError(); - } - // Fee rate cannot be lower than 1 - return Math.max(Number(selectedFees.rate), 1); -} - -/** - * Estimate the minimum fee rate considering required fee. - * Reference: https://github.com/bitcoin/bitcoin/blob/v28.0/src/wallet/fees.cpp#L58-L81. - * - * @param smartFee - The fee rate estimated by the estimatesmartfee rpc in BTC/kvB. - * @param mempoolminfee - Minimum fee rate in BTC/kvB for tx to be accepted. - * @param minrelaytxfee - Current minimum relay fee in BTC/kB for transactions. - * @returns The minimum fee rate in BTC/kvB. - */ -export function getMinimumFeeRateInKvb( - smartFee: number, - mempoolminfee: number, - minrelaytxfee: number, -) { - // Obey mempool min fee when using smart fee estimation - const minFee = Math.max(smartFee, mempoolminfee); - // Prevent user from paying a fee below the required fee rate - `minrelaytxfee` - const minRequiredFee = Math.max( - minFee, - minrelaytxfee, - DefaultTxMinFeeRateInBtcPerKvb, - ); - - return minRequiredFee; -} - -/** - * Retrieves the dust threshold for a given account. - * - * @param account - The account for which to retrieve the dust threshold. - * @returns The dust threshold for the given account. - */ -export function getDustThreshold(account: KeyringAccount | BtcAccount): number { - if (account instanceof BtcAccount) { - return DustLimit[account.scriptType]; - } - const scriptType = BtcAccountTypeToScriptType[account.type]; - return DustLimit[scriptType]; -} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/index.ts b/merged-packages/bitcoin-wallet-snap/src/utils/index.ts deleted file mode 100644 index cbda3030..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export * from './error'; -export * from './string'; -export * from './async'; -export * from './superstruct'; -export * from './lock'; -export * from './snap'; -export * from './snap-state'; -export * from './rpc'; -export * from './explorer'; -export * from './unit'; -export * from './logger'; -export * from './fee'; -export * from './account'; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/lock.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/lock.test.ts deleted file mode 100644 index ca96498e..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/lock.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Mutex } from 'async-mutex'; - -import { acquireLock } from './lock'; - -jest.mock('async-mutex', () => { - return { - Mutex: jest.fn(), - }; -}); - -describe('acquireLock', () => { - it('acquires lock', () => { - acquireLock(); - expect(Mutex).toHaveBeenCalledTimes(0); - }); - - it('acquires new lock if parameter `create` is true', () => { - acquireLock(true); - expect(Mutex).toHaveBeenCalledTimes(1); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/lock.ts b/merged-packages/bitcoin-wallet-snap/src/utils/lock.ts deleted file mode 100644 index f5098f74..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/lock.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Mutex } from 'async-mutex'; - -const saveMutex = new Mutex(); - -/** - * Acquires or retrieves a lock. - * - * @param create - Whether to create a new lock or retrieve an existing one. - * @returns A `Mutex` object representing the lock. - */ -export function acquireLock(create = false) { - if (create) { - return new Mutex(); - } - return saveMutex; -} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/rates.ts b/merged-packages/bitcoin-wallet-snap/src/utils/rates.ts deleted file mode 100644 index 0fdd9016..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/rates.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Caip19Asset } from '../constants'; -import { CurrencyRatesNotAvailableError } from '../exceptions'; -import { getRatesFromMetamask } from './snap'; - -export const getRates = async (_asset: Caip19Asset): Promise => { - // _asset is not used because the only supported asset is 'btc' for now. - const ratesResult = await getRatesFromMetamask('btc'); - - if (!ratesResult) { - throw new CurrencyRatesNotAvailableError(); - } - return ratesResult.conversionRate.toString(); -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/rpc.ts b/merged-packages/bitcoin-wallet-snap/src/utils/rpc.ts deleted file mode 100644 index 4e384a3c..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/rpc.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { InvalidParamsError, SnapError } from '@metamask/snaps-sdk'; -import type { Struct } from 'superstruct'; -import { assert } from 'superstruct'; - -/** - * Validates that the request parameters conform to the expected structure defined by the provided struct. - * - * @template Params - The expected structure of the request parameters. - * @param requestParams - The request parameters to validate. - * @param struct - The expected structure of the request parameters. - * @throws {InvalidParamsError} If the request parameters do not conform to the expected structure. - */ -export function validateRequest(requestParams: Params, struct: Struct) { - try { - assert(requestParams, struct); - } catch (error) { - throw new InvalidParamsError(error.message) as unknown as Error; - } -} - -/** - * Validates that the response conforms to the expected structure defined by the provided struct. - * - * @template Params - The expected structure of the response. - * @param response - The response to validate. - * @param struct - The expected structure of the response. - * @throws {SnapError} If the response does not conform to the expected structure. - */ -export function validateResponse(response: Params, struct: Struct) { - try { - assert(response, struct); - } catch (error) { - throw new SnapError('Invalid Response') as unknown as Error; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts deleted file mode 100644 index 14fa496d..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.test.ts +++ /dev/null @@ -1,610 +0,0 @@ -import { expect } from '@jest/globals'; - -import * as lockUtil from './lock'; -import * as snapUtil from './snap'; -import { SnapStateManager } from './snap-state'; - -jest.mock('../utils/logger'); - -type MockTransactionDetail = { - txHash: string; - cnt: number; -}; - -type MockTransactionDetails = { - [key in string]: MockTransactionDetail; -}; - -type MockTransactions = string[]; - -type MockState = { - transaction: MockTransactions; - transactionDetails: MockTransactionDetails; -}; - -type MockExecuteTransactionInput = { - txHash: string; - id: string; - cnt: number; -}; - -describe('SnapStateManager', () => { - const createMockStateManager = ( - createLock?: boolean, - encrypted?: boolean, - ) => { - const updateDataSpy = jest.fn(); - class MockSnapStateManager extends SnapStateManager { - constructor() { - super({ createLock, encrypted }); - } - - async getData() { - return this.get(); - } - - async updateData(data: StateDataInput) { - await this.update(async (state) => updateDataSpy(state, data)); - } - } - - const instance = new MockSnapStateManager(); - - const executeTransactionFn = async ( - data: StateDataInput, - delay: number, - isThrowError?: boolean, - isCommit?: boolean, - ) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - await instance.withTransaction(async (state) => { - await instance.updateData(data); - if (isCommit === true) { - await instance.commit(); - } - await new Promise((resolve) => setTimeout(resolve, delay)); - if (isThrowError) { - throw new Error('executeTransactionFn error'); - } - }); - }; - - const executeFn = async (data, delay, isThrowError = false) => { - await instance.updateData(data); - await new Promise((resolve) => setTimeout(resolve, delay)); - if (isThrowError) { - throw new Error('executeFn error'); - } - }; - - return { - instance, - updateDataSpy, - executeTransactionFn, - executeFn, - }; - }; - - const createMockState = (initState: MockState) => { - const setStateDataFn = async ({ - data, - }: { - data: MockState; - encrypted: boolean; - }) => { - initState.transaction = [...data.transaction]; - initState.transactionDetails = Object.entries( - data.transactionDetails, - ).reduce( - (acc, [key, value]: [key: string, value: MockTransactionDetail]) => { - acc[key] = { - ...value, - }; - return acc; - }, - {}, - ); - }; - - const updateDataFn = ( - state: MockState, - data: MockExecuteTransactionInput, - ) => { - if ( - Object.prototype.hasOwnProperty.call( - state.transactionDetails, - data.id, - ) === false - ) { - state.transaction.push(data.id); - state.transactionDetails[data.id] = { - txHash: data.txHash, - cnt: data.cnt, - }; - } else { - state.transactionDetails[data.id] = { - txHash: data.txHash, - cnt: state.transactionDetails[data.id].cnt + data.cnt, - }; - } - }; - - const getStateDataSpy = jest - .spyOn(snapUtil, 'getStateData') - .mockImplementation(async () => { - return { - transaction: [...initState.transaction], - transactionDetails: Object.entries( - initState.transactionDetails, - ).reduce( - ( - acc, - [key, value]: [key: string, value: MockTransactionDetail], - ) => { - acc[key] = { - ...value, - }; - return acc; - }, - {}, - ), - }; - }); - - const setStateDataSpy = jest - .spyOn(snapUtil, 'setStateData') - .mockImplementation(setStateDataFn); - - return { - setStateDataFn, - updateDataFn, - getStateDataSpy, - setStateDataSpy, - }; - }; - - describe('constructor', () => { - it('sends `false` to Lock.Acquire if parameter `createLock` is `undefined`', async () => { - const spy = jest.spyOn(lockUtil, 'acquireLock'); - createMockStateManager(); - - expect(spy).toHaveBeenCalledWith(false); - }); - - it('sends `true` to Lock.Acquire if parameter `createLock` is `true`', async () => { - const spy = jest.spyOn(lockUtil, 'acquireLock'); - createMockStateManager(true); - - expect(spy).toHaveBeenCalledWith(true); - }); - }); - - describe('get', () => { - it('returns result', async () => { - const { instance } = createMockStateManager(false); - const state = { - transaction: [ - { - txHash: 'hash', - chainId: 'chainId', - }, - ], - }; - const readSpy = jest - .spyOn(snapUtil, '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: [ - { - txHash: 'hash', - chainId: 'chainId', - }, - ], - }, - data: { - txHash: 'hash2', - chainId: 'chainId2', - }, - }; - const readSpy = jest - .spyOn(snapUtil, 'getStateData') - .mockResolvedValue(testcase.state); - const writeSpy = jest.spyOn(snapUtil, '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({ - data: testcase.state, - encrypted: true, - }); - expect(testcase.state.transaction).toHaveLength(2); - expect(updateDataSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe('withTransaction', () => { - it('executes callback code', async () => { - const initState = { - transaction: ['id'], - transactionDetails: { - id: { - txHash: 'hash', - cnt: 4, - }, - }, - }; - - const { getStateDataSpy, updateDataFn } = createMockState(initState); - - const { updateDataSpy, executeTransactionFn } = createMockStateManager< - MockState, - MockExecuteTransactionInput - >(); - - updateDataSpy.mockImplementation(updateDataFn); - - const promiseArr = [ - executeTransactionFn( - { - txHash: 'hash-final', - id: 'id', - cnt: 2, - }, - 30, - ), - executeTransactionFn( - { - txHash: 'hash2', - id: 'id2', - cnt: 5, - }, - 0, - ), - ]; - - await Promise.all(promiseArr); - expect(initState.transaction).toStrictEqual(['id', 'id2']); - - expect(initState.transactionDetails).toStrictEqual({ - id: { - txHash: 'hash-final', - cnt: 6, - }, - id2: { - txHash: 'hash2', - cnt: 5, - }, - }); - expect(getStateDataSpy).toHaveBeenCalledTimes(promiseArr.length * 2); - // expect(setStateDataSpy).toHaveBeenCalledTimes(promiseArr.length); - expect(updateDataSpy).toHaveBeenCalledTimes(promiseArr.length); - }); - - it('rolls back if an error is caught after a commit', async () => { - const initState = { - transaction: ['id'], - transactionDetails: { - id: { - txHash: 'hash', - cnt: 4, - }, - }, - }; - const { getStateDataSpy, updateDataFn } = createMockState(initState); - const { updateDataSpy, executeTransactionFn } = createMockStateManager< - MockState, - MockExecuteTransactionInput - >(); - updateDataSpy.mockImplementation(updateDataFn); - - const promiseArr = [ - executeTransactionFn( - { - txHash: 'hash4', - id: 'id', - cnt: 1, - }, - 10, - ), - executeTransactionFn( - { - txHash: 'hash-final', - id: 'id', - cnt: 2, - }, - 30, - true, - true, - ), - executeTransactionFn( - { - txHash: 'hash2', - id: 'id2', - cnt: 5, - }, - 0, - ), - ]; - - await Promise.allSettled(promiseArr); - - expect(initState.transaction).toStrictEqual(['id', 'id2']); - expect(initState.transactionDetails).toStrictEqual({ - id: { - txHash: 'hash4', - cnt: 5, - }, - id2: { - txHash: 'hash2', - cnt: 5, - }, - }); - expect(getStateDataSpy).toHaveBeenCalledTimes(promiseArr.length * 2); - expect(updateDataSpy).toHaveBeenCalledTimes(promiseArr.length); - }); - - it('does not trigger rollback if an error is caught and has not been committed', async () => { - const hasCommitted = false; - const initState: MockState = { - transaction: ['id'], - transactionDetails: { - id: { - txHash: 'hash', - cnt: 4, - }, - }, - }; - const { setStateDataSpy, updateDataFn } = createMockState(initState); - const { updateDataSpy, executeTransactionFn } = createMockStateManager< - MockState, - MockExecuteTransactionInput - >(); - updateDataSpy.mockImplementation(updateDataFn); - - let expectedError; - try { - await executeTransactionFn( - { - txHash: 'hash-final', - id: 'id', - cnt: 2, - }, - 30, - true, - hasCommitted, - ); - } catch (error) { - expectedError = error; - } finally { - expect(expectedError).toBeInstanceOf(Error); - expect(initState.transaction).toStrictEqual(['id']); - expect(setStateDataSpy).toHaveBeenCalledTimes(0); - expect(initState.transactionDetails).toStrictEqual({ - id: { - txHash: 'hash', - cnt: 4, - }, - }); - } - }); - - it('does not rollback if rollback failed and has committed', async () => { - const committed = true; - const initState: MockState = { - transaction: ['id'], - transactionDetails: { - id: { - txHash: 'hash', - cnt: 4, - }, - }, - }; - const { setStateDataSpy, updateDataFn, setStateDataFn } = - createMockState(initState); - const { updateDataSpy, executeTransactionFn } = createMockStateManager< - MockState, - MockExecuteTransactionInput - >(); - setStateDataSpy - .mockImplementationOnce(setStateDataFn) - .mockImplementationOnce(() => { - throw new Error('rollback error'); - }); - updateDataSpy.mockImplementation(updateDataFn); - - const promiseArr = [ - executeTransactionFn( - { - txHash: 'hash-final', - id: 'id', - cnt: 2, - }, - 30, - true, - committed, - ), - executeTransactionFn( - { - txHash: 'hash-final', - id: 'id', - cnt: 2, - }, - 30, - true, - false, - ), - ]; - await Promise.allSettled(promiseArr); - - expect(initState.transaction).toStrictEqual(['id']); - expect(initState.transactionDetails).toStrictEqual({ - id: { - txHash: 'hash-final', - cnt: 6, - }, - }); - }); - - it('does not have racing condition', async () => { - const initState = { - transaction: [], - transactionDetails: {}, - }; - const { getStateDataSpy, updateDataFn } = createMockState(initState); - const { updateDataSpy, executeTransactionFn, executeFn } = - createMockStateManager(); - - updateDataSpy.mockImplementation(updateDataFn); - - const promiseArr = [ - executeTransactionFn( - { - txHash: 'hash', - id: 'id', - cnt: 2, - }, - 30, - ), - executeTransactionFn( - { - txHash: 'hash2', - id: 'id2', - cnt: 5, - }, - 20, - ), - executeFn( - { - txHash: 'hash32', - id: 'id3', - cnt: 8, - }, - 0, - ), - executeTransactionFn( - { - txHash: 'hash-updated', - id: 'id', - cnt: 1, - }, - 30, - ), - executeTransactionFn( - { - txHash: 'hash3', - id: 'id3', - cnt: 2, - }, - 10, - ), - executeTransactionFn( - { - txHash: 'hash-updated-final', - id: 'id', - cnt: 3, - }, - 2, - ), - ]; - - await Promise.all(promiseArr); - - expect(initState.transaction).toStrictEqual(['id', 'id2', 'id3']); - expect(initState.transactionDetails).toStrictEqual({ - id: { - txHash: 'hash-updated-final', - cnt: 6, - }, - id2: { - txHash: 'hash2', - cnt: 5, - }, - id3: { - txHash: 'hash3', - cnt: 10, - }, - }); - expect(getStateDataSpy).toHaveBeenCalledTimes(promiseArr.length * 2 - 1); - expect(updateDataSpy).toHaveBeenCalledTimes(promiseArr.length); - }); - - it('throws `Failed to begin transaction` error, if the transaction can not init', async () => { - const initState = { - transaction: [], - transactionDetails: {}, - }; - - const { getStateDataSpy } = createMockState(initState); - - const { executeTransactionFn } = createMockStateManager< - MockState, - MockExecuteTransactionInput - >(); - - getStateDataSpy.mockResolvedValue(undefined); - - await expect( - executeTransactionFn( - { - txHash: 'hash-final', - id: 'id', - cnt: 2, - }, - 30, - ), - ).rejects.toThrow('Failed to begin transaction'); - }); - - it('throws an Error if another Error was thrown', async () => { - const initState = { - transaction: [], - transactionDetails: {}, - }; - - const { setStateDataSpy, setStateDataFn, updateDataFn } = - createMockState(initState); - - const { executeTransactionFn, updateDataSpy } = createMockStateManager< - MockState, - MockExecuteTransactionInput - >(); - - updateDataSpy.mockImplementation(updateDataFn); - - // first mockImplementation mocks the set data actions - setStateDataSpy - .mockImplementationOnce(async () => { - throw new Error('setStateDataSpy'); - // second mockImplementation mocks the rollback actions - }) - .mockImplementationOnce(setStateDataFn); - - await expect( - executeTransactionFn( - { - txHash: 'hash-final', - id: 'id', - cnt: 2, - }, - 30, - ), - ).rejects.toThrow(Error); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts deleted file mode 100644 index 1edffc24..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap-state.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { type MutexInterface } from 'async-mutex'; -import { v4 as uuidv4 } from 'uuid'; - -import { acquireLock } from './lock'; -import { logger } from './logger'; -import { getStateData, setStateData } from './snap'; - -export type Transaction = { - id?: string; - orgState?: State; - current?: State; - isRollingBack: boolean; - hasCommitted: boolean; -}; - -export abstract class SnapStateManager { - protected readonly mtx: MutexInterface; - - #transaction: Transaction; - - #encryptedMode: boolean; - - constructor({ - createLock = false, - encrypted = true, - }: { createLock?: boolean; encrypted?: boolean } = {}) { - this.mtx = acquireLock(createLock); - this.#transaction = { - id: undefined, - orgState: undefined, - current: undefined, - isRollingBack: false, - hasCommitted: false, - }; - this.#encryptedMode = encrypted; - } - - protected async get(): Promise { - return getStateData(this.#encryptedMode); - } - - protected async set(state: State): Promise { - return setStateData({ data: state, encrypted: this.#encryptedMode }); - } - - protected async update( - callback: (state: State) => Promise, - ): Promise { - if (this.mtx.isLocked()) { - if (this.#transaction.current) { - logger.info( - `SnapStateManager.update [${ - this.#transactionId - }]: transaction is processing, use existing state`, - ); - await callback(this.#transaction.current); - return; - } - logger.info( - `SnapStateManager.update: transaction is not exist, create lock after prev lock is released`, - ); - } - await this.mtx.runExclusive(async () => { - const state = await this.get(); - await callback(state); - await this.set(state); - }); - } - - /** - * This method executes the callback code in a transaction-like format. It creates a lock to ensure the state is not intercepted during the transaction and initializes a transaction with the current state, original state, and transaction ID. If there is an error during the transaction, the state is rolled back to the original state. However, if the lock has no time limit, it might cause a deadlock if the transaction is not completed. - * - * @param callback - A Promise function that takes the state as an argument. - */ - public async withTransaction( - callback: (state: State) => Promise, - ): Promise { - await this.mtx.runExclusive(async () => { - await this.#beginTransaction(); - - if ( - !this.#transaction.current || - !this.#transaction.orgState || - !this.#transaction.id - ) { - throw new Error('Failed to begin transaction'); - } - - logger.info( - `SnapStateManager.withTransaction [${ - this.#transactionId - }]: begin transaction`, - ); - - try { - await callback(this.#transaction.current); - await this.set(this.#transaction.current); - } catch (error) { - logger.info( - `SnapStateManager.withTransaction [${ - this.#transactionId - }]: error : ${JSON.stringify(error.message)}`, - ); - - await this.#rollback(); - - throw error; - } finally { - this.#cleanUpTransaction(); - } - }); - } - - async commit() { - if (!this.#transaction.current || !this.#transaction.orgState) { - throw new Error('Failed to commit transaction'); - } - this.#transaction.hasCommitted = true; - await this.set(this.#transaction.current); - } - - async #beginTransaction(): Promise { - this.#transaction = { - id: uuidv4(), - orgState: await this.get(), - current: await this.get(), // make sure the current is not referenced to orgState - isRollingBack: false, - hasCommitted: false, - }; - } - - async #rollback(): Promise { - try { - // we only need to rollback if the transaction is committed - if ( - this.#transaction.hasCommitted && - !this.#transaction.isRollingBack && - this.#transaction.orgState - ) { - logger.info( - `SnapStateManager.rollback [${ - this.#transactionId - }]: attempt to rollback state`, - ); - this.#transaction.isRollingBack = true; - await this.set(this.#transaction.orgState); - } - } catch (error) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - logger.info( - `SnapStateManager.rollback [${ - this.#transactionId - }]: error : ${JSON.stringify(error)}`, - ); - throw new Error('Failed to rollback state'); - } - } - - #cleanUpTransaction(): void { - this.#transaction.orgState = undefined; - this.#transaction.current = undefined; - this.#transaction.id = undefined; - this.#transaction.isRollingBack = false; - this.#transaction.hasCommitted = false; - } - - get #transactionId(): string { - return this.#transaction.id ?? ''; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts deleted file mode 100644 index b84110f0..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { expect } from '@jest/globals'; -import type { Component } from '@metamask/snaps-sdk'; -import { heading, panel, text, divider, row } from '@metamask/snaps-sdk'; - -import * as snapUtil from './snap'; - -jest.mock('@metamask/key-tree', () => ({ - getBIP44AddressKeyDeriver: jest.fn(), -})); - -describe('getBip32Deriver', () => { - it('gets bip32 deriver', async () => { - const spy = jest.spyOn(snapUtil.getProvider(), 'request'); - const path = ['m', "84'", "0'"]; - const curve = 'secp256k1'; - - await snapUtil.getBip32Deriver(path, curve); - - expect(spy).toHaveBeenCalledWith({ - method: 'snap_getBip32Entropy', - params: { - path, - curve, - }, - }); - }); -}); - -describe('confirmDialog', () => { - it('calls snap_dialog', async () => { - const spy = jest.spyOn(snapUtil.getProvider(), 'request'); - const components: Component[] = [ - heading('header'), - text('subHeader'), - divider(), - row('Label1', text('Value1')), - text('**Label2**:'), - row('SubLabel1', text('SubValue1')), - ]; - - await snapUtil.confirmDialog(components); - - expect(spy).toHaveBeenCalledWith({ - method: 'snap_dialog', - params: { - type: 'confirmation', - content: panel(components), - }, - }); - }); -}); - -describe('getStateData', () => { - it('gets state data', async () => { - const spy = jest.spyOn(snapUtil.getProvider(), 'request'); - const testcase = { - state: { - transaction: [ - { - txHash: 'hash', - chainId: 'chainId', - }, - ], - }, - }; - - spy.mockResolvedValue(testcase.state); - const result = await snapUtil.getStateData(true); - - expect(spy).toHaveBeenCalledWith({ - method: 'snap_manageState', - params: { - operation: 'get', - encrypted: true, - }, - }); - - expect(result).toStrictEqual(testcase.state); - }); -}); - -describe('setStateData', () => { - it('sets state data', async () => { - const spy = jest.spyOn(snapUtil.getProvider(), 'request'); - const testcase = { - state: { - transaction: [ - { - txHash: 'hash', - chainId: 'chainId', - }, - ], - }, - }; - - await snapUtil.setStateData({ data: testcase.state, encrypted: true }); - - expect(spy).toHaveBeenCalledWith({ - method: 'snap_manageState', - params: { - operation: 'update', - newState: testcase.state, - encrypted: true, - }, - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts deleted file mode 100644 index 69d882d1..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snap.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { type SLIP10NodeInterface } from '@metamask/key-tree'; -import type { - Component, - DialogResult, - GetCurrencyRateResult, - Json, -} from '@metamask/snaps-sdk'; -import { DialogType, panel, type SnapsProvider } from '@metamask/snaps-sdk'; - -declare const snap: SnapsProvider; - -/** - * Retrieves the current SnapsProvider. - * - * @returns The current SnapsProvider. - */ -export function getProvider(): SnapsProvider { - return snap; -} - -/** - * Retrieves a `SLIP10NodeInterface` object for the specified path and curve. - * - * @param path - The BIP32 derivation path for which to retrieve a `SLIP10NodeInterface`. - * @param curve - The elliptic curve to use for key derivation. - * @returns A Promise that resolves to a `SLIP10NodeInterface` object. - */ -export async function getBip32Deriver( - path: string[], - curve: 'secp256k1' | 'ed25519', -): Promise { - const node = await snap.request({ - method: 'snap_getBip32Entropy', - params: { - path, - curve, - }, - }); - return node as SLIP10NodeInterface; -} - -/** - * Displays a confirmation dialog with the specified components. - * - * @param components - An array of components to display in the dialog. - * @returns A Promise that resolves to the result of the dialog. - */ -export async function confirmDialog( - components: Component[], -): Promise { - return snap.request({ - method: 'snap_dialog', - params: { - type: DialogType.Confirmation, - content: panel(components), - }, - }); -} - -/** - * Displays a alert dialog with the specified components. - * - * @param components - An array of components to display in the dialog. - * @returns A Promise that resolves to the result of the dialog. - */ -export async function alertDialog( - components: Component[], -): Promise { - return snap.request({ - method: 'snap_dialog', - params: { - type: DialogType.Alert, - content: panel(components), - }, - }); -} - -/** - * Retrieves the current state data. - * - * @param encrypted - A boolean indicating whether the state data is encrypted. - * @returns A Promise that resolves to the current state data. - */ -export async function getStateData(encrypted: boolean): Promise { - return (await snap.request({ - method: 'snap_manageState', - params: { - operation: 'get', - encrypted, - }, - })) as unknown as State; -} - -/** - * Sets the current state data to the specified data. - * - * @param data - An object containing the new state data and encryption flag. - * @param data.data - The new state data to set. - * @param data.encrypted - A boolean indicating whether the state data is encrypted. - */ -export async function setStateData({ - data, - encrypted, -}: { - data: State; - encrypted: boolean; -}): Promise { - await snap.request({ - method: 'snap_manageState', - params: { - operation: 'update', - newState: data as unknown as Record, - encrypted, - }, - }); -} - -/** - * Creates and sends a UI dialog with the specified interface ID. - * - * @param interfaceId - The ID of the interface to create the dialog for. - * @returns A Promise that resolves to the result of the dialog request. - */ -export async function createSendUIDialog(interfaceId: string) { - return await snap.request({ - method: 'snap_dialog', - params: { - id: interfaceId, - }, - }); -} - -/** - * Retrieves the currency rates from MetaMask for the specified asset. - * - * @param currency - The currency for which to retrieve the currency rates. - * @returns A Promise that resolves to the currency rates. - */ -export async function getRatesFromMetamask( - currency: string, -): Promise { - return await snap.request({ - method: 'snap_getCurrencyRate', - params: { - // @ts-expect-error TODO: snaps will fix this type error - currency, - }, - }); -} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/static.ts b/merged-packages/bitcoin-wallet-snap/src/utils/static.ts deleted file mode 100644 index 70a6b2d6..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/static.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * A type that enforces static implementation of a class. - * - * @template Inter - An interface that the class must implement. - * @template Cls - The class that implements the interface. - * @returns The instance type of the interface. - */ -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/src/utils/string.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts deleted file mode 100644 index d0c6a275..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Buffer } from 'buffer'; - -import { - hexToBuffer, - bufferToString, - replaceMiddleChar, - shortenAddress, -} from './string'; - -describe('hexToBuffer', () => { - it('converts a hex string to buffer with trimmed prefix', () => { - const key = '0x1234'; - const result = hexToBuffer(key); - - expect(result).toStrictEqual(Buffer.from('1234', 'hex')); - }); - - it('converts a hex string to buffer without trimmed prefix', () => { - const key = '0x1234'; - const result = hexToBuffer(key, false); - - expect(result).toStrictEqual(Buffer.from('0x1234', 'hex')); - }); - - it('throws `Unable to convert hex string to buffer` error if the execution fail', () => { - expect(() => hexToBuffer(null as unknown as string)).toThrow( - 'Unable to convert hex string to buffer', - ); - }); - - it('throws `Unable to convert hex string to buffer` error if the given string is not a hex string', () => { - expect(() => hexToBuffer('hello123')).toThrow( - 'Unable to convert hex string to buffer', - ); - }); -}); - -describe('bufferToString', () => { - it('converts a buffer to string with encoding', () => { - const result = bufferToString(Buffer.from('1234', 'hex'), 'hex'); - expect(result).toBe('1234'); - }); - - it('throws `Unable to convert buffer to string` error if the execution fail', () => { - expect(() => bufferToString(undefined as unknown as Buffer, 'hex')).toThrow( - 'Unable to convert buffer to string', - ); - }); -}); - -describe('replaceMiddleChar', () => { - const str = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; - it('replaces the middle of a string', () => { - expect(replaceMiddleChar(str, 5, 3)).toBe('tb1qt...aeu'); - }); - - it('does not replace if the string is empty', () => { - expect(replaceMiddleChar('', 5, 3)).toBe(''); - }); - - it('throws `Indexes must be positives` error if headLength or tailLength is negative value', () => { - expect(() => replaceMiddleChar(str, -1, 20)).toThrow( - 'Indexes must be positives', - ); - expect(() => replaceMiddleChar(str, 20, -10)).toThrow( - 'Indexes must be positives', - ); - }); - - it('throws `Indexes out of bounds` error if headLength + tailLength is out of bounds', () => { - expect(() => replaceMiddleChar(str, 100, 0)).toThrow( - 'Indexes out of bounds', - ); - expect(() => replaceMiddleChar(str, 0, 100)).toThrow( - 'Indexes out of bounds', - ); - }); -}); - -describe('shortenAddress', () => { - const str = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; - it('shorten an address', () => { - expect(shortenAddress(str)).toBe('tb1qt2m...dxaeu'); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts b/merged-packages/bitcoin-wallet-snap/src/utils/string.ts deleted file mode 100644 index 76dec0fe..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/string.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { remove0x, HexStruct } from '@metamask/utils'; -import { Buffer } from 'buffer'; -import { assert } from 'superstruct'; - -/** - * Converts a hex string to a buffer instance. - * - * @param hexStr - The hex string to convert to a buffer. - * @param trimPrefix - A flag indicating whether to remove the '0x' prefix from the hex string. - * @returns The buffer instance. - * @throws An error if the hex string cannot be converted to a buffer. - */ -export function hexToBuffer(hexStr: string, trimPrefix = true) { - try { - assert(hexStr, HexStruct); - return Buffer.from(trimPrefix ? remove0x(hexStr) : hexStr, 'hex'); - } catch (error) { - throw new Error('Unable to convert hex string to buffer'); - } -} - -/** - * Converts a buffer instance to a string. - * - * @param buffer - The buffer instance to convert to a string. - * @param encoding - The encoding to use when converting the buffer to a string. - * @returns The converted string. - * @throws An error if the buffer cannot be converted to a string. - */ -export function bufferToString(buffer: Buffer, encoding: BufferEncoding) { - try { - return buffer.toString(encoding); - } catch (error) { - throw new Error('Unable to convert buffer to string'); - } -} - -/** - * Replaces the middle characters of a string with a given string. - * - * @param str - The string to replace. - * @param headLength - The length of the head of the string that should not be replaced. - * @param tailLength - The length of the tail of the string that should not be replaced. - * @param replaceStr - The string to replace the middle characters with. Default is '...'. - * @returns The formatted string. - * @throws An error if the given headLength and tailLength cannot be replaced. - */ -export function replaceMiddleChar( - str: string, - headLength: number, - tailLength: number, - replaceStr = '...', -) { - if (!str) { - return str; - } - // Enforces indexes to be positive to avoid parameter swapping in `.substring` - if (headLength < 0 || tailLength < 0) { - throw new Error('Indexes must be positives'); - } - // Check upper bound (using + is safe here, since we know that both lengths are positives) - if (headLength + tailLength > str.length) { - throw new Error('Indexes out of bounds'); - } - return `${str.substring(0, headLength)}${replaceStr}${str.substring( - str.length - tailLength, - )}`; -} - -/** - * Format the address in shorten string. - * - * @param address - The address to format. - * @returns The formatted address. - */ -export function shortenAddress(address: string) { - return replaceMiddleChar(address, 7, 5); -} diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts deleted file mode 100644 index bdf5c6ff..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { assert } from 'superstruct'; - -import { Config } from '../config'; -import * as superstruct from './superstruct'; - -describe('superstruct', () => { - describe('BitcoinAddressStruct', () => { - it.each([ - '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', - '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy', - 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', - 'bc1p5d7rjq7g6rdk2yhzks9smlaqtedr4dekq08ge8ztwac72sfr9rusxg3297', - 'tb1q9lakrt5sw0w0twnc6ww4vxs7hm0q23e03286k8', - ])('returns true for valid address: %s', (val: string) => { - expect(() => assert(val, superstruct.BitcoinAddressStruct)).not.toThrow( - Error, - ); - }); - - it.each(['invalid', '0xA86Df057874BB37C324210a19d3DA51aA31A74C8'])( - 'returns false for invalid address: %s', - (val: string) => { - expect(() => assert(val, superstruct.BitcoinAddressStruct)).toThrow( - Error, - ); - }, - ); - }); - - describe('PositiveNumberStringStruct', () => { - it.each(['1', '1.2', '0.0023'])( - 'does not throw error if the value is valid: %s', - (val: string) => { - expect(() => - assert(val, superstruct.PositiveNumberStringStruct), - ).not.toThrow(Error); - }, - ); - - it.each(['0101', '0101.1', '0101', '-1.3', ' 1.3', '+1-3', 'abc', '1e89'])( - 'throws error if the value is invalid: %s', - (val: string) => { - expect(() => - assert(val, superstruct.PositiveNumberStringStruct), - ).toThrow(Error); - }, - ); - }); - - describe('ScopeStruct', () => { - it.each(Config.availableNetworks)( - 'does not throw error if the value is valid: %s', - (val: string) => { - expect(() => assert(val, superstruct.ScopeStruct)).not.toThrow(Error); - }, - ); - - it('throws error if the value is invalid', () => { - expect(() => assert('custom scope', superstruct.ScopeStruct)).toThrow( - Error, - ); - }); - }); - - describe('AssetsStruct', () => { - it.each(Config.availableAssets)( - 'does not throw error if the value is valid: %s', - (val: string) => { - expect(() => assert(val, superstruct.AssetsStruct)).not.toThrow(Error); - }, - ); - - it('throws error if the value is invalid', () => { - expect(() => assert('custom asset', superstruct.AssetsStruct)).toThrow( - Error, - ); - }); - }); - - describe('TxIdStruct', () => { - it('does not throw error if the value is valid', () => { - expect(() => - assert( - '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c08', - superstruct.TxIdStruct, - ), - ).not.toThrow(); - }); - - it.each([ - // 63 characters long while `transactionId` is expected to be 64 long - '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c0', - // 64 characters long but with invalid characters * / z - '1cd985fc26a9b27d0b574739b908d5fe78e2297b24323a7f8c04526648dc9c*z', - 'z9a', - ])('throws error if the value is valid: %s', (val: string) => { - expect(() => assert(val, superstruct.TxIdStruct)).toThrow(Error); - }); - }); - - describe('AmountStruct', () => { - it('does not throw error if the value is valid', () => { - expect(() => assert('1', superstruct.AmountStruct)).not.toThrow(); - }); - - it.each(['0', '-1', 'abc', '1e-99999999999'])( - 'throws `Invalid amount, must be a positive finite number` error if the value is invalid: %s', - (val: string) => { - expect(() => assert(val, superstruct.AmountStruct)).toThrow( - 'Invalid amount, must be a positive finite number', - ); - }, - ); - - it.each(['9999999999.9999999999', '9999999999.0', '0.9999999999'])( - 'throws `Invalid amount, out of bounds` error if the value is out of bounds: %s', - (val: string) => { - expect(() => assert(val, superstruct.AmountStruct)).toThrow( - 'Invalid amount, out of bounds', - ); - }, - ); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts b/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts deleted file mode 100644 index ee6d29ff..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/superstruct.ts +++ /dev/null @@ -1,49 +0,0 @@ -// eslint-disable-next-line import/no-named-as-default -import validate, { Network } from 'bitcoin-address-validation'; -import { enums, string, pattern, refine } from 'superstruct'; - -import { Config } from '../config'; -import { btcToSats } from './unit'; - -export const BitcoinAddressStruct = refine( - string(), - 'BitcoinAddressStruct', - (address: string) => { - return ( - validate(address, Network.mainnet) || validate(address, Network.testnet) - ); - }, -); - -export const AssetsStruct = enums(Config.availableAssets); - -export const ScopeStruct = enums(Config.availableNetworks); - -export const PositiveNumberStringStruct = pattern( - string(), - /^(?!0\d)(\d+(\.\d+)?)$/u, -); - -export const TxIdStruct = pattern(string(), /^[0-9a-fA-F]{64}$/u); - -export const AmountStruct = refine( - string(), - 'AmountStruct', - (value: string) => { - const parsedVal = parseFloat(value); - if ( - Number.isNaN(parsedVal) || - parsedVal <= 0 || - !Number.isFinite(parsedVal) - ) { - return 'Invalid amount, must be a positive finite number'; - } - - try { - btcToSats(value); - } catch (error) { - return 'Invalid amount, out of bounds'; - } - return true; - }, -); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/transaction.ts b/merged-packages/bitcoin-wallet-snap/src/utils/transaction.ts deleted file mode 100644 index 0b8aa905..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/transaction.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; - -import type { SendFlowRequest } from '../stateManagement'; -import { TransactionStatus, type SendFlowParams } from '../stateManagement'; -import { AssetType } from '../ui/types'; -import { generateSendBitcoinParams } from '../ui/utils'; - -export const generateDefaultSendFlowParams = ( - scope: string, -): SendFlowParams => { - return { - selectedCurrency: AssetType.BTC, - recipient: { - address: '', - error: '', - valid: false, - }, - fees: { - amount: '', - fiat: '', - loading: false, - error: '', - }, - amount: { - amount: '', - fiat: '', - error: '', - valid: false, - }, - rates: '', - balance: { - amount: '', - fiat: '', - }, - total: { - amount: '', - fiat: '', - error: '', - valid: false, - }, - scope, - }; -}; - -export const generateDefaultSendFlowRequest = ( - account: KeyringAccount, - scope: string, - requestId: string, - interfaceId: string, -): SendFlowRequest => { - return { - id: requestId, - interfaceId, - account, - transaction: generateSendBitcoinParams(scope), - status: TransactionStatus.Draft, - // Send flow params - ...generateDefaultSendFlowParams(scope), - }; -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts deleted file mode 100644 index c37176f7..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/unit.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { - satsToBtc, - btcToSats, - maxSatoshi, - minSatoshi, - satsKvbToVb, -} from './unit'; - -describe('satsKvbToVb', () => { - it.each([ - { fromKvb: 1000n, toVb: 1n }, - { fromKvb: 1001n, toVb: 1n }, - { fromKvb: 1499n, toVb: 1n }, - { fromKvb: 1500n, toVb: 2n }, - { fromKvb: 1501n, toVb: 2n }, - { fromKvb: 1999n, toVb: 2n }, - { fromKvb: 123456789123456789n, toVb: 123456789123457n }, - ])( - 'converts from "$fromKvb" (sats/kvB) to "$toVb" (sats/vB)', - ({ fromKvb, toVb }) => { - expect(satsKvbToVb(fromKvb)).toBe(toVb); - }, - ); - - it.each([0, 1, 500, 999])( - 'throws an error if not convertible: %s', - (fromKvb) => { - expect(() => satsKvbToVb(fromKvb)).toThrow(Error); - }, - ); -}); - -describe('satsToBtc', () => { - it('returns Btc unit', () => { - expect(satsToBtc(2099999999999999n)).toBe('20999999.99999999'); - }); - - it('returns Btc unit with max Satoshis', () => { - expect(satsToBtc(maxSatoshi)).toBe('21000000.00000000'); - }); - - it('returns Btc unit with min Satoshis', () => { - expect(satsToBtc(minSatoshi)).toBe('0.00000001'); - }); - - it('returns Btc unit with unit', () => { - expect(satsToBtc(minSatoshi, true)).toBe('0.00000001 BTC'); - }); - - it('throw an error if then given Satoshis in float', () => { - const sats = 1.1; - expect(() => satsToBtc(sats)).toThrow(Error); - }); -}); - -describe('btcToSats', () => { - it('returns Btc unit', () => { - expect(btcToSats('20999999.99999999')).toBe(2099999999999999n); - }); - - it('returns Btc unit with max Satoshis', () => { - expect(btcToSats('21000000')).toBe(2100000000000000n); - }); - - it('returns Btc unit with 0 Satoshis', () => { - expect(btcToSats('0')).toBe(0n); - }); - - it('returns Btc unit with min Satoshis', () => { - expect(btcToSats('0.00000001')).toBe(1n); - }); - - it('throws an error if the given BTC is out of range', () => { - expect(() => btcToSats('0.9999999999999')).toThrow( - 'BTC amount is out of range', - ); - expect(() => btcToSats('21000000.999999999"')).toThrow( - 'BTC amount is out of range', - ); - expect(() => btcToSats('22000000')).toThrow('BTC amount is out of range'); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts b/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts deleted file mode 100644 index da14c0e0..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/unit.ts +++ /dev/null @@ -1,83 +0,0 @@ -import BigNumber from 'bignumber.js'; - -import { Config } from '../config'; - -// Bitcoin Core's RPC 'estimatesmartfee' returns values in BTC/kvB. -// Hence, we will have to convert it back to standard BTC/vB. -// See: https://github.com/bitcoin/bitcoin/blob/v28.0/src/policy/feerate.cpp#L17 -export const bitcoinCoreKb = 1000; - -// Maximum amount of satoshis -export const maxSatoshi = 21 * 1e14; - -// Minimum amount of satoshis -export const minSatoshi = 1; - -// Rate to convert from BTC to sats -export const satsIn1Btc = 100000000; - -/** - * Converts sats/kvB to sats/vB. - * - * @param kvb - The amount in sats/kvB. - * @returns The converted amount to sats/vB. - */ -export function satsKvbToVb(kvb: number | bigint): bigint { - // It cannot be lower than 1 sat per vB. - if (kvb < bitcoinCoreKb) { - throw new Error(`Unable to convert kvB to vB: "${kvb}" is too small`); - } - // NOTE: From here, we now we can safely divides by `bitcoinCoreKb` and we know it's not - // going to give a results of 0. - - // We still use `BigNumber` here and not `bigint` to be able to round up the result - const bigKvb = BigNumber(kvb.toString()); - const bigKb = BigNumber(bitcoinCoreKb); - const bigVb = bigKvb.div(bigKb).toFixed(0, BigNumber.ROUND_HALF_UP); - - return BigInt(bigVb.toString()); -} - -/** - * Converts a satoshis to a string representing the equivalent amount of BTC. - * - * @param sats - The number of satoshis to convert. - * @param withUnit - A boolean indicating whether to include the unit in the string representation. Default is false. - * @returns The equivalent amount of BTC as a string, fixed to 8 decimal places. - * @throws A Error if sats is not an integer. - */ -export function satsToBtc(sats: number | bigint, withUnit = false): string { - if (typeof sats === 'number' && !Number.isInteger(sats)) { - throw new Error('satsToBtc must be called on an integer number'); - } - const bigSat = new BigNumber(sats.toString()); - const bigBtc = bigSat.div(satsIn1Btc).toFixed(8); - - if (withUnit) { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - return `${bigBtc} ${Config.unit}`; - } - return bigBtc; -} - -/** - * Converts a BTC to a bigint representing the equivalent amount of satoshis. - * - * @param btc - The amount of BTC to convert. - * @returns The equivalent amount of satoshis as a string, rounded to the nearest integer. - * @throws A Error if the BTC > max amount of satoshis (21 * 1e14) or the BTC < 0 or the BTC has more than 8 decimals. - */ -export function btcToSats(btc: string): bigint { - const bigBtc = new BigNumber(btc); - const bigSats = bigBtc.times(satsIn1Btc); - - // If it's not a "true" integer, it means we still have some decimals, so the original - // amount had more than 8 digits after the decimal point. - if (!bigSats.isInteger()) { - throw new Error('BTC amount is out of range'); - } - if (bigSats.lt(0) || bigSats.gt(maxSatoshi)) { - throw new Error('BTC amount is out of range'); - } - return BigInt(bigSats.toFixed(0)); -} diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/quicknode.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/quicknode.json deleted file mode 100644 index 62136ed4..00000000 --- a/merged-packages/bitcoin-wallet-snap/test/fixtures/quicknode.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "getrawtransactionResp": { - "result": { - "txid": "xxxxx", - "hash": "xxxxx", - "version": 1, - "size": 246, - "vsize": 165, - "weight": 657, - "locktime": 0, - "vin": [ - { - "txid": "xxxxx", - "vout": 1, - "txinwitness": [ - "304402207b3f3e6d5bf8fe781d226af2043566ba5b5dc375940c1eee56c175621a739f8b02200e6e57f1b4a401f4abea080f07e460ca613c9cc980c81699bbb15c69388dda3001", - "03927dd425ca1e744ac605aa60a62afd22b6e7e3b1c4f962d7acd6cb00e422627d" - ], - "sequence": 4294967295 - } - ], - "vout": [ - { - "value": 0.00105089, - "n": 0 - } - ], - "hex": "xxxxx", - "blockhash": "xxxxx", - "confirmations": 185569, - "time": 1616673563, - "blocktime": 1616673563 - }, - "error": null, - "id": null - }, - "estimatesmartfeeResp": { - "result": { - "feerate": 0.00004213, - "blocks": 2 - }, - "error": null, - "id": null - }, - "bb_getaddressResp": { - "id": null, - "result": { - "address": "xxxxxx", - "balance": "1", - "totalReceived": "100000", - "totalSent": "100000", - "unconfirmedBalance": "0", - "unconfirmedTxs": 0, - "txs": 100 - }, - "jsonrpc": "2.0" - }, - "bb_getutxosResp": { - "id": null, - "result": [ - { - "txid": "xxxxxx", - "vout": 1, - "value": "1", - "height": 100000, - "confirmations": 1000 - } - ], - "jsonrpc": "2.0" - }, - "getmempoolinfo": { - "result": { - "loaded": true, - "size": 124821, - "bytes": 83361781, - "usage": 481941024, - "total_fee": 1.11361224, - "maxmempool": 2048000000, - "mempoolminfee": 0.00001, - "minrelaytxfee": 0.00001, - "incrementalrelayfee": 0.00001, - "unbroadcastcount": 0, - "fullrbf": true - }, - "error": null, - "id": null - } -} diff --git a/merged-packages/bitcoin-wallet-snap/test/fixtures/simplehash.json b/merged-packages/bitcoin-wallet-snap/test/fixtures/simplehash.json deleted file mode 100644 index f7e55ec0..00000000 --- a/merged-packages/bitcoin-wallet-snap/test/fixtures/simplehash.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "walletAssetsByAddress": { - "count": 1, - "utxos": [ - { - "output": "xxxxxxx:1", - "block_number": 999999, - "value": 999999 - } - ] - } -} diff --git a/merged-packages/bitcoin-wallet-snap/test/snap-provider.mock.ts b/merged-packages/bitcoin-wallet-snap/test/snap-provider.mock.ts deleted file mode 100644 index ccaa5eef..00000000 --- a/merged-packages/bitcoin-wallet-snap/test/snap-provider.mock.ts +++ /dev/null @@ -1,42 +0,0 @@ -export type SnapProvider = { - registerRpcMessageHandler: (fn) => unknown; - request(options: { - method: string; - params?: { [key: string]: unknown } | unknown[]; - }): unknown; -}; - -export class MockSnapProvider implements SnapProvider { - public readonly registerRpcMessageHandler = jest.fn(); - - public readonly requestStub = jest.fn(); - /* eslint-disable */ - public readonly rpcStubs = { - snap_getBip32Entropy: jest.fn(), - snap_getBip44Entropy: 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); - } -} diff --git a/merged-packages/bitcoin-wallet-snap/test/utils.ts b/merged-packages/bitcoin-wallet-snap/test/utils.ts deleted file mode 100644 index 31a5d460..00000000 --- a/merged-packages/bitcoin-wallet-snap/test/utils.ts +++ /dev/null @@ -1,437 +0,0 @@ -import ecc from '@bitcoinerlab/secp256k1'; -import { BtcMethod } from '@metamask/keyring-api'; -import { BIP32Factory, type BIP32Interface } from 'bip32'; -import type { Network } from 'bitcoinjs-lib'; -import { Buffer } from 'buffer'; -import ECPairFactory from 'ecpair'; -import { v4 as uuidV4 } from 'uuid'; - -import { Caip2ChainId } from '../src/constants'; -import quickNodeData from './fixtures/quicknode.json'; -import simpleHashData from './fixtures/simplehash.json'; - -/* eslint-disable */ - -/** - * Method to generate testing account. - * - * @param cnt - Number of accounts to generate. - * @param addressPrefix - Prefix for the address. - * @returns Array of generated accounts. - */ -export function generateAccounts(cnt = 1, addressPrefix = '') { - const accounts: any[] = []; - let baseAddress = 'tb1qt2mpt38wmgw3j0hnr9mp5hsa7kxf2a3ktdxaeu'; - - baseAddress = - addressPrefix + baseAddress.slice(addressPrefix.length, baseAddress.length); - - for (let i = 0; i < cnt; i++) { - accounts.push({ - type: 'bip122:p2wpkh', - id: uuidV4(), - address: - baseAddress.slice(0, baseAddress.length - i.toString().length) + - i.toString(), - options: { - scope: Caip2ChainId.Testnet, - index: i, - }, - methods: [`${BtcMethod.SendBitcoin}`], - }); - } - - return accounts; -} - -/** - * Method to generate random bip32 deriver. - * - * @param network - Bitcoin network. - * @param path - Derived path. - * @param curve - Curve. - * @returns An Json data and the bip32 deriver. - */ -export function createRandomBip32Data( - network: Network, - path: string[], - curve: string, -) { - const ECPair = ECPairFactory(ecc); - const bip32 = BIP32Factory(ecc); - - const key = `${path.join('')}${curve}`; - const hexStr = Buffer.from(key, 'utf8').toString('hex'); - const bufferHex = Buffer.from(hexStr, 'hex'); - - const customRandomBufferFunc = (size: number): Buffer => { - const byteBuffer32 = Buffer.alloc(size); - bufferHex.copy(byteBuffer32); - return byteBuffer32; - }; - - const keyPair = ECPair.makeRandom({ - network, - rng: customRandomBufferFunc, - }); - - const deriver = bip32.fromSeed(keyPair.publicKey, network); - - const data = { - privateKey: deriver.privateKey?.toString('hex'), - publicKey: deriver.publicKey.toString('hex'), - chainCode: deriver.chainCode.toString('hex'), - depth: deriver.depth, - index: deriver.index, - curve, - masterFingerprint: undefined, - parentFingerprint: 0, - chainCodeBytes: deriver.chainCode, - privateKeyBytes: deriver.privateKey, - publicKeyBytes: deriver.publicKey, - }; - - return { - deriver, - data, - }; -} - -/** - * 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 and spys. - */ -export function 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, - }; -} - -const randomNum = (max) => Math.floor(Math.random() * max); - -/** - * Generate QuickNode bb_getaddress response by address. - * - * @param address - The account address. - * @returns A QuickNode bb_getaddress response. - */ -export function generateQuickNodeGetBalanceResp(address: string) { - const template = quickNodeData.bb_getaddressResp; - const data: typeof template = { - ...template, result: { - ...template.result, - address: address, - balance: randomNum(1000000).toString(), - } - }; - - return data; -} - -/** - * Generate QuickNode bb_getutxos response. - * - * @param params - The params to generate mock utxos. - * @param params.utxosCount - The utxos count. - * @param params.minAmount - The min amount of each utxo value. - * @param params.maxAmount - The max amount of each utxo value. - * @param params.minConfirmations - The min confirmation of each utxo. - * @param params.maxConfirmations - The max confirmation of each utxo. - * @returns A QuickNode bb_getutxos response. - */ -export function generateQuickNodeGetUtxosResp( - { - utxosCount, - minAmount = 0, - maxAmount = 1000000, - minConfirmations = 1000, - maxConfirmations = 10000, - }: { - utxosCount: number, - minAmount?: number, - maxAmount?: number, - minConfirmations?: number, - maxConfirmations?: number, - } -) { - const template = quickNodeData.bb_getutxosResp; - const data = { ...template }; - data.result = Array.from({ length: utxosCount }, (_, idx) => { - return { - txid: generateRandomTransactionId(), - vout: idx, - value: Math.max(minAmount, randomNum(maxAmount)).toString(), - height: 100000 + idx, - confirmations: Math.max(minConfirmations, randomNum(maxConfirmations)), - }; - }); - return data; -} - -/** - * Generate QuickNode get rawtransaction response. - * - * @param params - The params to generate mock get rawtransaction response. - * @param params.txid - The transaction id of the transaction. - * @param params.confirmations - The number of confirmations of the transaction. - * @returns A QuickNode get rawtransaction response. - */ -export function generateQuickNodeGetRawTransactionResp( - { - txid, - confirmations, - }: { - txid: string, - confirmations: number | undefined, - } -) { - const template = quickNodeData.getrawtransactionResp; - const data = { - ...template, - result: { - ...template.result, - txid, - confirmations: confirmations, - }, - }; - return data; -} - -/** - * Generate QuickNode estimate smartfee response. - * - * @param params - The params to generate mock estimate smartfee response. - * @param params.feerate - The fee rate in btc unit. - * @returns A QuickNode estimate smartfee response. - */ -export function generateQuickNodeEstimatefeeResp( - { - feerate - }: { - feerate: number | undefined; - } -) { - const template = quickNodeData.estimatesmartfeeResp; - const data = { - ...template, - result: { - ...template.result, - feerate, - block: Math.max(1000, randomNum(100000)), - }, - }; - return data; -} - -/** - * Generate QuickNode get mempool info response. - * - * @param params - The params to generate mock get mempool info response. - * @param params.mempoolminfee - Minimum fee rate in BTC/kvB for tx to be accepted. - * @param params.minrelaytxfee - Minimum relay fee in BTC/kB for transactions. - * @returns A QuickNode get mempool info response. - */ -export function generateQuickNodeMempoolResp({ - mempoolminfee = Math.max(1000, randomNum(10000)), - minrelaytxfee, -}: { - mempoolminfee?: number; - minrelaytxfee?: number; -}) { - const template = quickNodeData.getmempoolinfo; - const data = { - ...template, - result: { - ...template.result, - mempoolminfee: mempoolminfee, - minrelaytxfee: minrelaytxfee ?? Math.max(mempoolminfee, randomNum(10000)), - }, - }; - return data; -} - -/** - * Generate QuickNode send rawtransaction response. - * - * @returns A QuickNode send rawtransaction response. - */ -export function generateQuickNodeSendRawTransactionResp() { - const template = quickNodeData.estimatesmartfeeResp; - const data = { - ...template, - result: generateRandomTransactionId(), - }; - return data; -} - -/** - * Generate SimpleHash wallet_assets_by_utxo response. - * - * @param count - The number of utxo to generate. - * @returns A SimpleHash wallet_assets_by_utxo response. - */ -export function generateSimpleHashWalletAssetsByAddressResp(address: string, count: number) { - const template = simpleHashData.walletAssetsByAddress; - const utxos = Array.from({ length: count }, (idx: number) => { - return { - output: `${generateTransactionId(Number(address) + idx)}:${randomNum(100)}`, - value: randomNum(1000000), - block_number: randomNum(1000000), - }; - }); - - return { - ...template, - utxos, - count: utxos.length - }; -} - -/** - * Generate a 64 long hex transaction id. - * - * @returns A 64 long hex transaction id. - */ -export function generateTransactionId(id: number) { - return id - .toString(16) - .padStart( - 64, - '0', - ); -} - -/** - * Generate a random 64 long hex transaction id. - * - * @returns A 64 long hex transaction id. - */ -export function generateRandomTransactionId() { - return generateTransactionId(randomNum(100000000)); -} - -/** - * Method to generate formatted utxos with QuickNode resp. - * - * @param _address - the utxos owner address (deprecated). - * @param utxosCount - count of the utxo to be generated. - * @param minAmount - min amount of the utxo array. - * @param maxAmount - max amount of the utxo array. - * @returns An formatted utxo array. - */ -export function generateFormattedUtxos( - _address: string, - utxosCount: number, - minAmount?: number, - maxAmount?: number, -) { - return generateQuickNodeGetUtxosResp( - { - utxosCount, - minAmount, - maxAmount, - } - ).result.map((utxo) => ({ - block: utxo.height, - txHash: utxo.txid, - index: utxo.vout, - value: parseInt(utxo.value, 10), - })); -} - -/* eslint-disable */ From 1b341e6cafee7faf047260c1b5274ba4e7977acb Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 4 Mar 2025 12:47:16 +0100 Subject: [PATCH 198/362] feat: on asset conversions (#418) * add unspendable to tx builder * fetch assets * running * run only on new outputs * UTXO protection done * tests * done * all done * rename files * done * remove old Snap and release * remove unused dependencies * buils and tests pass * buils and tests pass * use v0.1.4 * from_string * rebase * rebase done * done * lint + build * remove quicknode * Delete packages/snap/src/infra/SimplehashClientAdapter.ts * final alignment * integration test link * integration tests * back to blockstream * typo * do not log in integration tests * use exchange rates * full scan only on demand * tests * all done * integration tests * done * remove onInstall * aligned * before rebase * blockstream * lint * esplora * all comments applied * destructure * done * lint * styling * lint * signet vs regtest --- .../bitcoin-wallet-snap/.env.example | 5 + .../integration-test/snap.test.ts | 149 +++++++----------- .../bitcoin-wallet-snap/package.json | 3 +- .../bitcoin-wallet-snap/snap.config.ts | 1 + .../bitcoin-wallet-snap/snap.manifest.json | 5 +- .../bitcoin-wallet-snap/src/config.ts | 4 + .../src/entities/config.ts | 6 + .../bitcoin-wallet-snap/src/entities/index.ts | 1 + .../bitcoin-wallet-snap/src/entities/rates.ts | 17 ++ .../src/handlers/AssetsHandler.test.ts | 85 +++++++++- .../src/handlers/AssetsHandler.ts | 60 +++++++ .../src/handlers/CronHandler.test.ts | 38 ++++- .../src/handlers/CronHandler.ts | 24 ++- .../src/handlers/KeyringHandler.test.ts | 30 ++++ .../src/handlers/KeyringHandler.ts | 13 +- .../src/handlers/caip19.ts | 8 +- .../src/handlers/mapping.ts | 18 +++ .../bitcoin-wallet-snap/src/index.ts | 51 +++--- .../src/infra/PriceApiClientAdapter.ts | 26 +++ .../src/infra/SimpleHashClientAdapter.ts | 3 +- .../src/infra/SnapClientAdapter.ts | 22 +-- .../bitcoin-wallet-snap/src/infra/index.ts | 1 + .../src/use-cases/AccountUseCases.test.ts | 69 ++------ .../src/use-cases/AccountUseCases.ts | 44 ++---- .../src/use-cases/AssetsUseCases.test.ts | 56 +++++++ .../src/use-cases/AssetsUseCases.ts | 43 +++++ .../src/use-cases/index.ts | 1 + 27 files changed, 535 insertions(+), 248 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/rates.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts diff --git a/merged-packages/bitcoin-wallet-snap/.env.example b/merged-packages/bitcoin-wallet-snap/.env.example index ade8110f..8a374c01 100644 --- a/merged-packages/bitcoin-wallet-snap/.env.example +++ b/merged-packages/bitcoin-wallet-snap/.env.example @@ -44,3 +44,8 @@ SIMPLEHASH_BITCOIN= # Default: No API key # Required: false SIMPLEHASH_API_KEY= + +# Price API endpoint +# Default: https://price.api.cx.metamask.io +# Required: false +PRICE_API_URL= diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/snap.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/snap.test.ts index ff29ab61..bc9ecc0e 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/snap.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/snap.test.ts @@ -15,7 +15,7 @@ describe('Bitcoin Snap', () => { const origin = 'metamask'; let snap: Snap; - it('installs the Snap and creates the default account', async () => { + it('installs the Snap', async () => { snap = await installSnap({ options: { secretRecoveryPhrase: @@ -23,121 +23,84 @@ describe('Bitcoin Snap', () => { }, }); - snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); - await snap.onInstall(); - - const response = await snap.onKeyringRequest({ - origin, - method: 'keyring_listAccounts', - }); - - expect(response).toRespondWith([ - { - type: Caip2AddressType.P2wpkh, - id: expect.anything(), - address: 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t', - options: {}, - scopes: [BtcScope.Regtest], - methods: [BtcMethod.SendBitcoin], - }, - ]); - - if ('result' in response.response) { - const [defaultAccount] = response.response.result as KeyringAccount[]; - accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`] = - defaultAccount; - } - }); - - it('synchronize accounts via cronjob', async () => { - // IMPORTANT: The order of this test is important because the cron job synchronizes all accounts, doing external requests to backends - // for mainnet, testnet and signet. Ideally avoid synchronizing accounts outside of regtest as that is tested in e2e. - - await snap.onCronjob({ method: 'synchronize' }); - - const response = await snap.onKeyringRequest({ - origin, - method: 'keyring_getAccountBalances', - params: { - id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id, - assets: [Caip19Asset.Regtest], - }, - }); - - expect(response).toRespondWith({ - [Caip19Asset.Regtest]: { - amount: expect.stringMatching(/^[1-9]\d*$/u), // non-zero positive number - unit: CurrencyUnit.Regtest, - }, - }); + expect(snap).toBeDefined(); }); it.each([ + { + // Main account used in the tests, only one to synchronize + addressType: Caip2AddressType.P2wpkh, + scope: BtcScope.Regtest, + expectedAddress: 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t', + synchronize: true, + }, { addressType: Caip2AddressType.P2wpkh, scope: BtcScope.Mainnet, expectedAddress: 'bc1q832zlt4tgnqy88vd20mazw77dlt0j0wf2naw8q', + synchronize: false, }, { addressType: Caip2AddressType.P2pkh, scope: BtcScope.Mainnet, expectedAddress: '15feVv7kK3z7jxA4RZZzY7Fwdu3yqFwzcT', + synchronize: false, }, { addressType: Caip2AddressType.P2pkh, scope: BtcScope.Testnet, expectedAddress: 'mjPQaLkhZN3MxsYN8Nebzwevuz8vdTaRCq', + synchronize: false, }, { addressType: Caip2AddressType.P2sh, scope: BtcScope.Mainnet, expectedAddress: '3QVSaDYjxEh4L3K24eorrQjfVxPAKJMys2', + synchronize: false, }, { addressType: Caip2AddressType.P2sh, scope: BtcScope.Testnet, expectedAddress: '2NBG623WvXp1zxKB6gK2mnMe2mSDCur5qRU', + synchronize: false, }, { addressType: Caip2AddressType.P2tr, scope: BtcScope.Mainnet, expectedAddress: 'bc1p4rue37y0v9snd4z3fvw43d29u97qxf9j3fva72xy2t7hekg24dzsaz40mz', + synchronize: false, }, { addressType: Caip2AddressType.P2tr, scope: BtcScope.Testnet, expectedAddress: 'tb1pwwjax3vpq6h69965hcr22vkpm4qdvyu2pz67wyj8eagp9vxkcz0q0ya20h', + synchronize: false, }, - ])( - 'creates an account: %s', - async ({ addressType, scope, expectedAddress }) => { - snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); + ])('creates an account: %s', async ({ expectedAddress, ...requestOpts }) => { + snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); - const response = await snap.onKeyringRequest({ - origin, - method: 'keyring_createAccount', - params: { - options: { scope, addressType }, - }, - }); + const response = await snap.onKeyringRequest({ + origin, + method: 'keyring_createAccount', + params: { options: { ...requestOpts } }, + }); - expect(response).toRespondWith({ - type: addressType, - id: expect.anything(), - address: expectedAddress, - options: {}, - scopes: [scope], - methods: [BtcMethod.SendBitcoin], - }); + expect(response).toRespondWith({ + type: requestOpts.addressType, + id: expect.anything(), + address: expectedAddress, + options: {}, + scopes: [requestOpts.scope], + methods: [BtcMethod.SendBitcoin], + }); - if ('result' in response.response) { - accounts[`${addressType}:${scope}`] = response.response - .result as KeyringAccount; - } - }, - ); + if ('result' in response.response) { + accounts[`${requestOpts.addressType}:${requestOpts.scope}`] = response + .response.result as KeyringAccount; + } + }); it('returns the same account if already exists', async () => { snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); @@ -181,6 +144,24 @@ describe('Bitcoin Snap', () => { expect(response).toRespondWith(Object.values(accounts)); }); + it('gets an account balance', async () => { + const response = await snap.onKeyringRequest({ + origin, + method: 'keyring_getAccountBalances', + params: { + id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id, + assets: [Caip19Asset.Regtest], + }, + }); + + expect(response).toRespondWith({ + [Caip19Asset.Regtest]: { + amount: '500', + unit: CurrencyUnit.Regtest, + }, + }); + }); + it('removes an account', async () => { const { id } = accounts[`${Caip2AddressType.P2pkh}:${BtcScope.Mainnet}`]; @@ -209,22 +190,6 @@ describe('Bitcoin Snap', () => { }); }); - it('fails to remove the default account', async () => { - const response = await snap.onKeyringRequest({ - origin, - method: 'keyring_deleteAccount', - params: { - id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id, - }, - }); - - expect(response).toRespondWithError({ - code: -32603, - message: 'Default Bitcoin account cannot be removed', - stack: expect.anything(), - }); - }); - it('returns empty list for account transactions', async () => { const response = await snap.onKeyringRequest({ origin, @@ -383,4 +348,10 @@ describe('Bitcoin Snap', () => { stack: expect.anything(), }); }); + + // To be improved once listAccountTransactions is implemented to check the tx confirmation status + it('synchronize accounts via cronjob', async () => { + const response = await snap.onCronjob({ method: 'synchronizeAccounts' }); + expect(response).toRespondWith(null); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 7a09b7fa..7eba4e5d 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -43,7 +43,8 @@ "@metamask/eslint-config-typescript": "^12.1.0", "@metamask/key-tree": "^10.0.2", "@metamask/keyring-api": "^17.2.0", - "@metamask/keyring-snap-sdk": "^3.0.1", + "@metamask/keyring-snap-sdk": "^3.1.0", + "@metamask/slip44": "^4.1.0", "@metamask/snaps-cli": "^6.7.0", "@metamask/snaps-jest": "^8.12.0", "@metamask/snaps-sdk": "^6.18.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts index 9143ceb8..e9f2a151 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.config.ts +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -22,6 +22,7 @@ const config: SnapConfig = { ESPLORA_REGTEST: process.env.ESPLORA_REGTEST, SIMPLEHASH_BITCOIN: process.env.SIMPLEHASH_BITCOIN, SIMPLEHASH_API_KEY: process.env.SIMPLEHASH_API_KEY, + PRICE_API_URL: process.env.PRICE_API_URL, /* eslint-disable */ }, polyfills: true, diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 25841460..81ea0ab1 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "UcY/75+Dj5afjkU4g+7CYF/Xd0oXTnrTl7Xp+kJ/YMM=", + "shasum": "keSL7lctZRNpiXhc3kpYTPCgThhJUD5rLDXjW74XA0A=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -72,7 +72,6 @@ "curve": "secp256k1" } ], - "endowment:lifecycle-hooks": {}, "endowment:network-access": {}, "snap_manageAccounts": {}, "snap_manageState": {}, @@ -83,7 +82,7 @@ { "expression": "* * * * *", "request": { - "method": "synchronize" + "method": "synchronizeAccounts" } } ] diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 7a786297..e24bac99 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -36,4 +36,8 @@ export const Config: SnapConfig = { }, targetBlocksConfirmation: 3, fallbackFeeRate: 5.0, + priceApi: { + url: process.env.PRICE_API_URL ?? 'https://price.api.cx.metamask.io', + }, + conversionsExpirationInterval: 60, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts index 51e9d30e..d81c66f5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -9,6 +9,8 @@ export type SnapConfig = { // Temporary config to set the expected confirmation target, should eventually be chosen by the user targetBlocksConfirmation: number; fallbackFeeRate: number; + priceApi: PriceApiConfig; + conversionsExpirationInterval: number; }; export type AccountsConfig = { @@ -31,3 +33,7 @@ export type SimpleHashConfig = { [network in Network]?: string; }; }; + +export type PriceApiConfig = { + url: string; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts index 6826d799..6e7f8d0e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts @@ -8,3 +8,4 @@ export * from './snap'; export * from './meta-protocols'; export * from './error'; export * from './locale'; +export * from './rates'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts b/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts new file mode 100644 index 00000000..51cbb058 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts @@ -0,0 +1,17 @@ +import type { CaipAssetType } from '@metamask/utils'; + +export type AssetRate = [CaipAssetType, number | null]; + +export type ExchangeRates = { + [ticker: string]: { + value: number; + }; +}; + +export type AssetRatesClient = { + /** + * Returns a list of exchange rates for all supported currencies. + * @param baseCurrency - the currency to convert prices to. Defaults to 'btc'. + */ + exchangeRates(baseCurrency?: string): Promise; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts index b5dc334b..7e90e7e5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts @@ -1,11 +1,17 @@ +import { mock } from 'jest-mock-extended'; + +import type { AssetsUseCases } from '../use-cases'; import { AssetsHandler } from './AssetsHandler'; import { Caip19Asset } from './caip19'; describe('AssetsHandler', () => { + const mockAssetsUseCases = mock(); + const expirationInterval = 60; + let handler: AssetsHandler; beforeEach(() => { - handler = new AssetsHandler(); + handler = new AssetsHandler(mockAssetsUseCases, expirationInterval); }); describe('lookup', () => { @@ -21,4 +27,81 @@ describe('AssetsHandler', () => { expect(result.assets[Caip19Asset.Regtest]?.name).toBe('Regtest Bitcoin'); }); }); + + describe('conversion', () => { + it('returns rates for all networks successfully', async () => { + mockAssetsUseCases.getRates.mockResolvedValue([ + [Caip19Asset.Testnet, 0.1], + [Caip19Asset.Regtest, 0.2], + ]); + + const conversions = [ + { from: Caip19Asset.Bitcoin, to: Caip19Asset.Testnet }, + { from: Caip19Asset.Bitcoin, to: Caip19Asset.Regtest }, + { from: Caip19Asset.Testnet, to: Caip19Asset.Bitcoin }, + { from: Caip19Asset.Testnet4, to: Caip19Asset.Bitcoin }, + { from: Caip19Asset.Signet, to: Caip19Asset.Bitcoin }, + { from: Caip19Asset.Regtest, to: Caip19Asset.Bitcoin }, + ]; + const result = await handler.conversion({ conversions }); + + expect(mockAssetsUseCases.getRates).toHaveBeenCalledTimes(1); + expect(mockAssetsUseCases.getRates).toHaveBeenCalledWith([ + Caip19Asset.Testnet, + Caip19Asset.Regtest, + ]); + expect(result.conversionRates).toStrictEqual({ + [Caip19Asset.Bitcoin]: { + [Caip19Asset.Testnet]: { + rate: '0.1', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + [Caip19Asset.Regtest]: { + rate: '0.2', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }, + [Caip19Asset.Testnet]: { + [Caip19Asset.Bitcoin]: { + rate: '0', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }, + [Caip19Asset.Testnet4]: { + [Caip19Asset.Bitcoin]: { + rate: '0', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }, + [Caip19Asset.Signet]: { + [Caip19Asset.Bitcoin]: { + rate: '0', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }, + [Caip19Asset.Regtest]: { + [Caip19Asset.Bitcoin]: { + rate: '0', + conversionTime: expect.any(Number), + expirationTime: expect.any(Number), + }, + }, + }); + }); + + it('propagates errors from getRates', async () => { + const conversions = [ + { from: Caip19Asset.Bitcoin, to: Caip19Asset.Testnet }, + ]; + const error = new Error(); + mockAssetsUseCases.getRates.mockRejectedValue(error); + + await expect(handler.conversion({ conversions })).rejects.toThrow(error); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts index d13d6baf..a0a0d6e2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts @@ -1,11 +1,25 @@ +import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; import type { + CaipAssetType, FungibleAssetMetadata, + OnAssetsConversionArguments, + OnAssetsConversionResponse, OnAssetsLookupResponse, } from '@metamask/snaps-sdk'; +import type { AssetsUseCases } from '../use-cases'; import { Caip19Asset } from './caip19'; export class AssetsHandler { + readonly #assetsUseCases: AssetsUseCases; + + readonly #expirationInterval: number; + + constructor(assets: AssetsUseCases, expirationInterval: number) { + this.#assetsUseCases = assets; + this.#expirationInterval = expirationInterval; + } + lookup(): OnAssetsLookupResponse { const metadata = ( name: string, @@ -58,4 +72,50 @@ export class AssetsHandler { }, }; } + + async conversion({ + conversions, + }: OnAssetsConversionArguments): Promise { + const conversionTime = getCurrentUnixTimestamp(); + + // Group conversions by "from" + const assetMap: Record = {}; + for (const { from, to } of conversions) { + if (!assetMap[from]) { + assetMap[from] = []; + } + assetMap[from].push(to); + } + + const conversionRates: OnAssetsConversionResponse['conversionRates'] = {}; + for (const [fromAsset, toAssets] of Object.entries(assetMap)) { + conversionRates[fromAsset] = {}; + + if (fromAsset === Caip19Asset.Bitcoin) { + // For Bitcoin, fetch rates. + for (const [toAsset, rate] of await this.#assetsUseCases.getRates( + toAssets, + )) { + conversionRates[fromAsset][toAsset] = rate + ? { + rate: rate.toString(), + conversionTime, + expirationTime: conversionTime + this.#expirationInterval, + } + : null; + } + } else { + // For every other conversions, we just use a rate of 0. + for (const toAsset of toAssets) { + conversionRates[fromAsset][toAsset] = { + rate: '0', + conversionTime, + expirationTime: conversionTime + 60 * 60 * 24, // Long expiration time (1 day) to avoid unnecessary requests + }; + } + } + } + + return { conversionRates }; + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts index d7b55a12..431656c7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -1,8 +1,14 @@ import { mock } from 'jest-mock-extended'; +import type { BitcoinAccount } from '../entities'; +import type { ILogger } from '../infra/logger'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; import { CronHandler } from './CronHandler'; +jest.mock('../infra/logger', () => { + return { logger: mock() }; +}); + describe('CronHandler', () => { const mockAccountUseCases = mock(); let handler: CronHandler; @@ -11,19 +17,37 @@ describe('CronHandler', () => { handler = new CronHandler(mockAccountUseCases); }); - describe('route', () => { + describe('synchronizeAccounts', () => { + const mockAccounts = [mock(), mock()]; + it('synchronizes all accounts', async () => { - await handler.route('synchronize'); + mockAccountUseCases.list.mockResolvedValue(mockAccounts); - expect(mockAccountUseCases.synchronizeAll).toHaveBeenCalled(); + await handler.route('synchronizeAccounts'); + + expect(mockAccountUseCases.list).toHaveBeenCalled(); + expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes( + mockAccounts.length, + ); }); - it('propagates errors from synchronizeAll', async () => { + it('propagates errors from list', async () => { const error = new Error(); - mockAccountUseCases.synchronizeAll.mockRejectedValue(error); + mockAccountUseCases.list.mockRejectedValue(error); + + await expect(handler.route('synchronizeAccounts')).rejects.toThrow(error); + }); + + it('does not propagate errors from synchronize', async () => { + mockAccountUseCases.list.mockResolvedValue(mockAccounts); + const error = new Error(); + mockAccountUseCases.synchronize.mockRejectedValue(error); + + await handler.route('synchronizeAccounts'); - await expect(handler.route('synchronize')).rejects.toThrow(error); - expect(mockAccountUseCases.synchronizeAll).toHaveBeenCalled(); + expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes( + mockAccounts.length, + ); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index b6b8a90f..33590291 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -1,3 +1,4 @@ +import { logger } from '../infra/logger'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; export class CronHandler { @@ -9,11 +10,30 @@ export class CronHandler { async route(method: string): Promise { switch (method) { - case 'synchronize': { - return await this.#accountsUseCases.synchronizeAll(); + case 'synchronizeAccounts': { + return this.synchronizeAccounts(); } default: throw new Error('Method not found.'); } } + + async synchronizeAccounts(): Promise { + const accounts = await this.#accountsUseCases.list(); + const results = await Promise.allSettled( + accounts.map(async (account) => { + return this.#accountsUseCases.synchronize(account); + }), + ); + + results.forEach((result, index) => { + if (result.status === 'rejected') { + logger.error( + `Account failed to sync. ID: %s. Error: %o`, + accounts[index].id, + result.reason, + ); + } + }); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 5e903eba..964cc41c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -48,6 +48,21 @@ describe('KeyringHandler', () => { caip2ToNetwork[BtcScope.Signet], caip2ToAddressType[Caip2AddressType.P2pkh], ); + expect(mockAccounts.fullScan).not.toHaveBeenCalled(); + }); + + it('performs a full scan when synchronize option is true', async () => { + mockAccounts.create.mockResolvedValue(mockAccount); + const options = { + scope: BtcScope.Signet, + addressType: Caip2AddressType.P2pkh, + synchronize: true, + }; + await handler.createAccount(options); + + expect(assert).toHaveBeenCalled(); + expect(mockAccounts.create).toHaveBeenCalled(); + expect(mockAccounts.fullScan).toHaveBeenCalledWith(mockAccount); }); it('propagates errors from createAccount', async () => { @@ -59,6 +74,21 @@ describe('KeyringHandler', () => { ).rejects.toThrow(error); expect(mockAccounts.create).toHaveBeenCalled(); }); + + it('propagates errors from full scan', async () => { + const error = new Error(); + mockAccounts.create.mockResolvedValue(mockAccount); + mockAccounts.fullScan.mockRejectedValue(error); + + await expect( + handler.createAccount({ + options: { scopes: [BtcScope.Mainnet] }, + synchronize: true, + }), + ).rejects.toThrow(error); + expect(mockAccounts.create).toHaveBeenCalled(); + expect(mockAccounts.fullScan).toHaveBeenCalled(); + }); }); describe('getAccountBalances', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index cc3248dc..f84782a2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -2,7 +2,6 @@ import { BtcScope } from '@metamask/keyring-api'; import type { Keyring, KeyringAccount, - KeyringRequest, KeyringResponse, Balance, CaipAssetType, @@ -11,7 +10,7 @@ import type { Transaction, } from '@metamask/keyring-api'; import type { Json } from '@metamask/utils'; -import { assert, enums, object, optional } from 'superstruct'; +import { assert, boolean, enums, object, optional } from 'superstruct'; import { networkToCurrencyUnit } from '../entities'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; @@ -27,11 +26,9 @@ import { snapToKeyringAccount } from './keyring-account'; export const CreateAccountRequest = object({ scope: enums(Object.values(BtcScope)), addressType: optional(enums(Object.values(Caip2AddressType))), + synchronize: optional(boolean()), }); -// TODO: enable when all methods are implemented -/* eslint-disable @typescript-eslint/no-unused-vars */ - export class KeyringHandler implements Keyring { readonly #accountsUseCases: AccountUseCases; @@ -57,6 +54,10 @@ export class KeyringHandler implements Keyring { opts.addressType ? caip2ToAddressType[opts.addressType] : undefined, ); + if (opts.synchronize) { + await this.#accountsUseCases.fullScan(account); + } + return snapToKeyringAccount(account); } @@ -100,7 +101,7 @@ export class KeyringHandler implements Keyring { }; } - async submitRequest(request: KeyringRequest): Promise { + async submitRequest(): Promise { throw new Error('Method not implemented.'); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/caip19.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/caip19.ts index 867ec492..4c0f0110 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/caip19.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/caip19.ts @@ -2,10 +2,10 @@ import type { Network } from 'bitcoindevkit'; export enum Caip19Asset { Bitcoin = 'bip122:000000000019d6689c085ae165831e93/slip44:0', - Testnet = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0', - Testnet4 = 'bip122:00000000da84f2bafbbc53dee25a72ae/slip44:0', - Signet = 'bip122:00000008819873e925422c1ff0f99f7c/slip44:0', - Regtest = 'bip122:regtest/slip44:0', + Testnet = 'bip122:000000000933ea01ad0ee984209779ba/slip44:1', + Testnet4 = 'bip122:00000000da84f2bafbbc53dee25a72ae/slip44:1', + Signet = 'bip122:00000008819873e925422c1ff0f99f7c/slip44:1', + Regtest = 'bip122:regtest/slip44:1', } export const networkToCaip19: Record = { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mapping.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mapping.ts index 6dc67491..d4a30471 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mapping.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mapping.ts @@ -1,3 +1,5 @@ +import type { AddressType, Network } from 'bitcoindevkit'; + /** * Reverse a map object. * @@ -13,3 +15,19 @@ export function reverseMapping< Object.entries(map).map(([from, to]) => [to, from]), ) as Record; } + +export const addressTypeToName: Record = { + p2pkh: 'Legacy', + p2sh: 'Nested SegWit', + p2wpkh: 'Native SegWit', + p2tr: 'Taproot', + p2wsh: 'Multisig', +}; + +export const networkToName: Record = { + bitcoin: 'Bitcoin', + testnet: 'Bitcoin Testnet', + testnet4: 'Bitcoin Testnet4', + signet: 'Bitcoin Signet', + regtest: 'Bitcoin Regtest', +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 34f0f92c..940a3d71 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -1,8 +1,8 @@ import { handleKeyringRequest } from '@metamask/keyring-snap-sdk'; import type { + OnAssetsConversionHandler, OnAssetsLookupHandler, OnCronjobHandler, - OnInstallHandler, } from '@metamask/snaps-sdk'; import { type OnRpcRequestHandler, @@ -26,17 +26,19 @@ import { SnapClientAdapter, EsploraClientAdapter, SimpleHashClientAdapter, + PriceApiClientAdapter, } from './infra'; import { logger } from './infra/logger'; import { originPermissions } from './permissions'; import { BdkAccountRepository, JSXSendFlowRepository } from './store'; -import { AccountUseCases, SendFlowUseCases } from './use-cases'; +import { AccountUseCases, AssetsUseCases, SendFlowUseCases } from './use-cases'; // Infra layer logger.logLevel = parseInt(Config.logLevel, 10); const snapClient = new SnapClientAdapter(Config.encrypt); const chainClient = new EsploraClientAdapter(Config.chain); const metaProtocolsClient = new SimpleHashClientAdapter(Config.simpleHash); +const assetRatesClient = new PriceApiClientAdapter(Config.priceApi); // Data layer const accountRepository = new BdkAccountRepository(snapClient); @@ -58,13 +60,17 @@ const sendFlowUseCases = new SendFlowUseCases( Config.targetBlocksConfirmation, Config.fallbackFeeRate, ); +const assetsUseCases = new AssetsUseCases(assetRatesClient); // Application layer const keyringHandler = new KeyringHandler(accountsUseCases); const cronHandler = new CronHandler(accountsUseCases); const rpcHandler = new RpcHandler(sendFlowUseCases, accountsUseCases); const userInputHandler = new UserInputHandler(sendFlowUseCases); -const assetsHandler = new AssetsHandler(); +const assetsHandler = new AssetsHandler( + assetsUseCases, + Config.conversionsExpirationInterval, +); export const validateOrigin = (origin: string, method: string): void => { if (!origin) { @@ -77,25 +83,6 @@ export const validateOrigin = (origin: string, method: string): void => { } }; -export const onInstall: OnInstallHandler = async () => { - try { - await accountsUseCases.create( - Config.accounts.defaultNetwork, - Config.accounts.defaultAddressType, - ); - } catch (error) { - let snapError = error; - - if (!isSnapRpcError(error)) { - snapError = new SnapError(error); - } - logger.error( - `onInstall error: ${JSON.stringify(snapError.toJSON(), null, 2)}`, - ); - throw snapError; - } -}; - export const onCronjob: OnCronjobHandler = async ({ request }) => { try { await cronHandler.route(request.method); @@ -194,3 +181,23 @@ export const onAssetsLookup: OnAssetsLookupHandler = async () => { throw snapError; } }; + +export const onAssetsConversion: OnAssetsConversionHandler = async (args) => { + try { + return assetsHandler.conversion(args); + } catch (error) { + let snapError = error; + + if (!isSnapRpcError(error)) { + snapError = new SnapError(error); + } + logger.error( + `onAssetsConversion error: ${JSON.stringify( + snapError.toJSON(), + null, + 2, + )}`, + ); + throw snapError; + } +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts new file mode 100644 index 00000000..ac7fd934 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts @@ -0,0 +1,26 @@ +import type { + AssetRatesClient, + ExchangeRates, + PriceApiConfig, +} from '../entities'; + +export class PriceApiClientAdapter implements AssetRatesClient { + readonly #endpoint: string; + + constructor(config: PriceApiConfig) { + this.#endpoint = config.url; + } + + async exchangeRates(baseCurrency = 'btc'): Promise { + const url = `${ + this.#endpoint + }/v1/exchange-rates?baseCurrency=${baseCurrency}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch exchange rates: ${response.statusText}`); + } + + return await response.json(); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts index 40acb02d..9468fc34 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts @@ -8,6 +8,7 @@ import type { MetaProtocolsClient, SimpleHashConfig as SimplehHashConfig, } from '../entities'; +import { logger } from './logger'; /* eslint-disable @typescript-eslint/naming-convention */ type NFTResponse = { @@ -73,7 +74,7 @@ export class SimpleHashClientAdapter implements MetaProtocolsClient { do { pages += 1; if (pages > MAX_PAGES) { - console.warn(`Maximum page limit reached (${MAX_PAGES}).`); + logger.warn(`Maximum page limit reached (${MAX_PAGES}).`); break; } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 64823380..3e9c34c6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -14,6 +14,7 @@ import type { BitcoinAccount, SnapClient, SnapState } from '../entities'; import { CurrencyUnit, networkToCurrencyUnit } from '../entities'; import { networkToCaip19 } from '../handlers/caip19'; import { snapToKeyringAccount } from '../handlers/keyring-account'; +import { addressTypeToName, networkToName } from '../handlers/mapping'; export class SnapClientAdapter implements SnapClient { readonly #encrypt: boolean; @@ -69,26 +70,11 @@ export class SnapClientAdapter implements SnapClient { } async emitAccountCreatedEvent(account: BitcoinAccount): Promise { - const suggestedName = () => { - switch (account.network) { - case 'bitcoin': - return 'Bitcoin'; - case 'testnet': - case 'testnet4': - return 'Bitcoin Testnet'; - case 'signet': - return 'Bitcoin Signet'; - case 'regtest': - return 'Bitcoin Regtest'; - default: - // Leave it blank to fallback to auto-suggested name on the extension side - return ''; - } - }; - return emitSnapKeyringEvent(snap, KeyringEvent.AccountCreated, { account: snapToKeyringAccount(account), - accountNameSuggestion: suggestedName(), + accountNameSuggestion: `${networkToName[account.network]} ${ + addressTypeToName[account.addressType] + }`, }); } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts index 43a492c8..60227b3f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts @@ -2,3 +2,4 @@ export * from './BdkAccountAdapter'; export * from './SnapClientAdapter'; export * from './EsploraClientAdapter'; export * from './SimpleHashClientAdapter'; +export * from './PriceApiClientAdapter'; diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 811ce19c..09c3d130 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -240,9 +240,17 @@ describe('AccountUseCases', () => { mockAccount.listOutput.mockReturnValue([]); }); + it('does not sync if account is not scanned', async () => { + mockAccount.listOutput.mockReturnValue([]); + + await useCases.synchronize({ ...mockAccount, isScanned: false }); + + expect(mockChain.sync).not.toHaveBeenCalled(); + expect(mockAccount.listOutput).not.toHaveBeenCalled(); + }); + it('performs a regular sync', async () => { mockAccount.listOutput.mockReturnValue([]); - mockRepository.get.mockResolvedValue(mockAccount); await useCases.synchronize(mockAccount); @@ -354,40 +362,6 @@ describe('AccountUseCases', () => { }); }); - describe('synchronizeAll', () => { - const mockAccounts = [ - { - id: 'id-1', - isScanned: true, - listOutput: jest.fn(), - }, - { - id: 'id-2', - listOutput: jest.fn(), - }, - ] as unknown as BitcoinAccount[]; - - it('synchronizes all accounts', async () => { - mockRepository.getAll.mockResolvedValue(mockAccounts); - (mockAccounts[0].listOutput as jest.Mock).mockReturnValue([]); - - await useCases.synchronizeAll(); - - expect(mockRepository.getAll).toHaveBeenCalled(); - expect(mockAccounts[0].listOutput).toHaveBeenCalledTimes(2); - expect(mockChain.sync).toHaveBeenCalledWith(mockAccounts[0]); - expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccounts[1]); - }); - - it('propagates errors from getAll', async () => { - const error = new Error(); - mockRepository.getAll.mockRejectedValue(error); - - await expect(useCases.synchronizeAll()).rejects.toThrow(error); - expect(mockRepository.getAll).toHaveBeenCalled(); - }); - }); - describe('delete', () => { it('throws error if account is not found', async () => { mockRepository.get.mockResolvedValue(null); @@ -401,28 +375,9 @@ describe('AccountUseCases', () => { expect(mockRepository.delete).not.toHaveBeenCalled(); }); - it('throws error if account is the default account', async () => { - const defaultAccount = mock(); - defaultAccount.id = 'default-id'; - defaultAccount.addressType = accountsConfig.defaultAddressType; - defaultAccount.network = accountsConfig.defaultNetwork; - - mockRepository.get.mockResolvedValue(defaultAccount); - - await expect(useCases.delete('default-id')).rejects.toThrow( - 'Default Bitcoin account cannot be removed', - ); - - expect(mockRepository.get).toHaveBeenCalledWith('default-id'); - expect(mockSnapClient.emitAccountDeletedEvent).not.toHaveBeenCalled(); - expect(mockRepository.delete).not.toHaveBeenCalled(); - }); - - it('removes account if not default', async () => { + it('removes an account', async () => { const mockAccount = mock(); mockAccount.id = 'some-id'; - mockAccount.addressType = 'p2wpkh'; - mockAccount.network = 'testnet'; mockRepository.get.mockResolvedValue(mockAccount); @@ -438,8 +393,6 @@ describe('AccountUseCases', () => { it('propagates an error if the event emitting fails', async () => { const mockAccount = mock(); mockAccount.id = 'some-id'; - mockAccount.addressType = 'p2wpkh'; - mockAccount.network = 'testnet'; const error = new Error('Event emit failed'); mockRepository.get.mockResolvedValue(mockAccount); @@ -455,8 +408,6 @@ describe('AccountUseCases', () => { it('propagates an error if the repository fails', async () => { const mockAccount = mock(); mockAccount.id = 'some-id'; - mockAccount.addressType = 'p2wpkh'; - mockAccount.network = 'testnet'; const error = new Error('Delete failed'); mockRepository.get.mockResolvedValue(mockAccount); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 1caacaf7..c012cfe7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -114,38 +114,19 @@ export class AccountUseCases { return newAccount; } - async synchronizeAll(): Promise { - logger.trace('Synchronizing all accounts'); - - const accounts = await this.#repository.getAll(); - const results = await Promise.allSettled( - accounts.map(async (account) => { - if (account.isScanned) { - return this.synchronize(account); - } - - return this.fullScan(account); - }), - ); - - results.forEach((result, index) => { - if (result.status === 'rejected') { - logger.error( - `Account failed to sync. ID: %s. Error: %o`, - accounts[index].id, - result.reason, - ); - } - }); - - logger.debug('Accounts synchronized successfully'); - } - async synchronize(account: BitcoinAccount): Promise { logger.trace('Synchronizing account. ID: %s', account.id); + if (!account.isScanned) { + logger.warn( + 'Account has not yet performed initial full scan, skipping synchronization. ID: %s', + account.id, + ); + return; + } + // Outputs are monotone, meaning they can only be added, like transactions. So we can be confident - // that a change on the balance cam only happen when new outputs appear. + // that a change on the balance can only happen when new outputs appear. const nOutputsBefore = account.listOutput().length; await this.#chain.sync(account); const nOutputsAfter = account.listOutput().length; @@ -182,13 +163,6 @@ export class AccountUseCases { throw new Error(`Account not found: ${id}`); } - if ( - account.addressType === this.#accountConfig.defaultAddressType && - account.network === this.#accountConfig.defaultNetwork - ) { - throw new Error('Default Bitcoin account cannot be removed'); - } - await this.#snapClient.emitAccountDeletedEvent(id); await this.#repository.delete(id); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts new file mode 100644 index 00000000..67d21c5e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts @@ -0,0 +1,56 @@ +import { mock } from 'jest-mock-extended'; + +import type { AssetRatesClient, ExchangeRates } from '../entities'; +import { Caip19Asset } from '../handlers'; +import type { ILogger } from '../infra/logger'; +import { AssetsUseCases } from './AssetsUseCases'; + +jest.mock('../infra/logger', () => { + return { logger: mock() }; +}); + +describe('AssetsUseCases', () => { + let useCases: AssetsUseCases; + + const mockAssetRates = mock(); + + beforeEach(() => { + useCases = new AssetsUseCases(mockAssetRates); + }); + + describe('getBtcRates', () => { + it('returns rate for the known assets and null for unknown', async () => { + const mockExchangeRates = mock({ + usd: { value: 1 }, + eth: { value: 2 }, + btc: { value: 3 }, + }); + + mockAssetRates.exchangeRates.mockResolvedValue(mockExchangeRates); + + const result = await useCases.getRates([ + 'eip155:1/slip44:60', + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'swift:0/iso4217:USD', + 'swift:0/unknown:unknown', + ]); + + expect(mockAssetRates.exchangeRates).toHaveBeenCalled(); + expect(result).toStrictEqual([ + ['eip155:1/slip44:60', 2], + ['bip122:000000000019d6689c085ae165831e93/slip44:0', 3], + ['swift:0/iso4217:USD', 1], + ['swift:0/unknown:unknown', null], + ]); + }); + + it('propagates an error if exchangeRates fails', async () => { + const error = new Error('Get failed'); + mockAssetRates.exchangeRates.mockRejectedValue(error); + + await expect(useCases.getRates([Caip19Asset.Testnet])).rejects.toBe( + error, + ); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts new file mode 100644 index 00000000..7e235732 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts @@ -0,0 +1,43 @@ +import slip44 from '@metamask/slip44'; +import type { CaipAssetType } from '@metamask/utils'; +import { parseCaipAssetType } from '@metamask/utils'; + +import type { AssetRatesClient, AssetRate } from '../entities'; +import { logger } from '../infra/logger'; + +export class AssetsUseCases { + readonly #assetRates: AssetRatesClient; + + constructor(assetRates: AssetRatesClient) { + this.#assetRates = assetRates; + } + + async getRates(assets: CaipAssetType[]): Promise { + logger.trace('Fetching BTC rates for: %o', assets); + const exchangeRates = await this.#assetRates.exchangeRates(); + logger.debug('BTC rates fetched successfully'); + + return assets.map((asset): AssetRate => { + const ticker = this.#assetToTicker(asset); + if (ticker && exchangeRates[ticker]) { + return [asset, exchangeRates[ticker].value]; + } + + return [asset, null]; + }); + } + + #assetToTicker(asset: CaipAssetType): string | undefined { + const { assetNamespace, assetReference } = parseCaipAssetType(asset); + + if (assetNamespace === 'iso4217') { + return assetReference.toLowerCase(); + } + + if (assetNamespace === 'slip44') { + return slip44[assetReference]?.symbol.toLowerCase(); + } + + return undefined; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts index 65ed43bf..0a765c79 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts @@ -1,2 +1,3 @@ export * from './AccountUseCases'; export * from './SendFlowUseCases'; +export * from './AssetsUseCases'; From adf61107d672df005ff412e45d459b4d3ce3d469 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 11 Mar 2025 18:42:52 +0100 Subject: [PATCH 199/362] feat: background loop (#419) * add unspendable to tx builder * fetch assets * running * run only on new outputs * UTXO protection done * tests * done * all done * rename files * done * remove old Snap and release * remove unused dependencies * buils and tests pass * buils and tests pass * use v0.1.4 * from_string * rebase * rebase done * done * lint + build * remove quicknode * Delete packages/snap/src/infra/SimplehashClientAdapter.ts * final alignment * integration test link * integration tests * back to blockstream * typo * do not log in integration tests * use exchange rates * full scan only on demand * tests * all done * integration tests * done * remove onInstall * aligned * background loop implemented * fiat rate only for Bitcoin * move to repository * remove to repository * logic done * bug on catch * before rebase * blockstream * lint * esplora * only one test failing suite * test failing * use real object * skip tests bug snaps-jest * esplora * skip tests * all comments applied * destructure * test start * done * lint * styling * lint * review PR * rebase * all cosmetic comments * use repository * guard currency * typo * apply cosmetic comments * no-void * add test for header back * tests passing * cancel loop on cancel * refactor tests * done * add blockstream * add blockstream * naming * reduce verbosity * reduce verbosity * tests fixed * spacing * spacing * final --- .../integration-test/constants.ts | 3 + .../{snap.test.ts => keyring.test.ts} | 138 +------- .../integration-test/run-integration.sh | 2 +- .../integration-test/send-flow.test.ts | 114 +++++++ .../bitcoin-wallet-snap/locales/en.json | 8 +- .../bitcoin-wallet-snap/messages.json | 8 +- .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/config.ts | 1 + .../src/entities/config.ts | 1 + .../src/entities/send-flow.ts | 26 +- .../bitcoin-wallet-snap/src/entities/snap.ts | 47 ++- .../src/entities/transaction.ts | 2 +- .../src/handlers/CronHandler.test.ts | 34 +- .../src/handlers/CronHandler.ts | 26 +- .../src/handlers/UserInputHandler.test.ts | 13 +- .../src/handlers/UserInputHandler.ts | 15 +- .../bitcoin-wallet-snap/src/index.ts | 8 +- .../src/infra/SnapClientAdapter.ts | 90 ++--- .../src/infra/jsx/ReviewTransactionView.tsx | 26 +- .../jsx/components/TransactionSummary.tsx | 20 +- .../src/infra/jsx/format.ts | 10 +- .../src/store/JSXSendFlowRepository.test.tsx | 100 +++--- .../src/store/JSXSendFlowRepository.tsx | 37 +- .../src/use-cases/AccountUseCases.ts | 12 +- .../src/use-cases/AssetsUseCases.ts | 2 +- .../src/use-cases/SendFlowUseCases.test.ts | 316 ++++++++++++------ .../src/use-cases/SendFlowUseCases.ts | 146 ++++++-- 28 files changed, 732 insertions(+), 477 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/integration-test/constants.ts rename merged-packages/bitcoin-wallet-snap/integration-test/{snap.test.ts => keyring.test.ts} (59%) create mode 100644 merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts b/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts new file mode 100644 index 00000000..53ff72c2 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts @@ -0,0 +1,3 @@ +export const MNEMONIC = + 'journey embrace permit coil indoor stereo welcome maid movie easy clock spider tent slush bright luxury awake waste legal modify awkward answer acid goose'; +export const TEST_ADDRESS = 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t'; diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/snap.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts similarity index 59% rename from merged-packages/bitcoin-wallet-snap/integration-test/snap.test.ts rename to merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index bc9ecc0e..3bd36f5e 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/snap.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -1,29 +1,23 @@ import type { KeyringAccount } from '@metamask/keyring-api'; import { BtcMethod, BtcScope } from '@metamask/keyring-api'; import type { Snap } from '@metamask/snaps-jest'; -import { assertIsCustomDialog, installSnap } from '@metamask/snaps-jest'; +import { installSnap } from '@metamask/snaps-jest'; -import { - CurrencyUnit, - ReviewTransactionEvent, - SendFormEvent, -} from '../src/entities'; +import { CurrencyUnit } from '../src/entities'; import { Caip2AddressType, Caip19Asset } from '../src/handlers'; +import { MNEMONIC, TEST_ADDRESS } from './constants'; -describe('Bitcoin Snap', () => { +describe('Keyring', () => { const accounts: Record = {}; const origin = 'metamask'; let snap: Snap; - it('installs the Snap', async () => { + beforeAll(async () => { snap = await installSnap({ options: { - secretRecoveryPhrase: - 'journey embrace permit coil indoor stereo welcome maid movie easy clock spider tent slush bright luxury awake waste legal modify awkward answer acid goose', + secretRecoveryPhrase: MNEMONIC, }, }); - - expect(snap).toBeDefined(); }); it.each([ @@ -31,7 +25,7 @@ describe('Bitcoin Snap', () => { // Main account used in the tests, only one to synchronize addressType: Caip2AddressType.P2wpkh, scope: BtcScope.Regtest, - expectedAddress: 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t', + expectedAddress: TEST_ADDRESS, synchronize: true, }, { @@ -236,122 +230,4 @@ describe('Bitcoin Snap', () => { expect(response).toRespondWith(expectedAssets); }, ); - - it('executes Send flow: happy path', async () => { - const response = snap.request({ - origin, - method: 'startSendTransactionFlow', - params: { - account: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id, - }, - }); - - let ui = await response.getInterface(); - assertIsCustomDialog(ui); - - await ui.typeInField(SendFormEvent.Amount, '0.1'); - await ui.typeInField( - SendFormEvent.Recipient, - 'bcrt1qyvhf2epk9s659206lq3rdvtf07uq3t9e7xtjje', - ); - await ui.clickElement(SendFormEvent.Confirm); - - ui = await response.getInterface(); - await ui.clickElement(ReviewTransactionEvent.Send); - - const result = await response; - expect(result).toRespondWith({ txId: expect.any(String) }); - }); - - it('executes Send flow: happy path drain account', async () => { - const response = snap.request({ - origin, - method: 'startSendTransactionFlow', - params: { - account: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id, - }, - }); - - let ui = await response.getInterface(); - assertIsCustomDialog(ui); - - await ui.clickElement(SendFormEvent.SetMax); - await ui.typeInField( - SendFormEvent.Recipient, - 'bcrt1qyvhf2epk9s659206lq3rdvtf07uq3t9e7xtjje', - ); - await ui.clickElement(SendFormEvent.Confirm); - - ui = await response.getInterface(); - await ui.clickElement(ReviewTransactionEvent.HeaderBack); - - ui = await response.getInterface(); - await ui.clickElement(SendFormEvent.Cancel); - - const result = await response; - expect(result).toRespondWithError({ - code: 4001, - message: 'User rejected the request.', - stack: expect.anything(), - }); - }); - - it('executes Send flow: cancel', async () => { - const response = snap.request({ - origin, - method: 'startSendTransactionFlow', - params: { - account: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id, - }, - }); - - const ui = await response.getInterface(); - await ui.clickElement(SendFormEvent.Cancel); - - const result = await response; - expect(result).toRespondWithError({ - code: 4001, - message: 'User rejected the request.', - stack: expect.anything(), - }); - }); - - it('executes Send flow: revert back to send form', async () => { - const response = snap.request({ - origin, - method: 'startSendTransactionFlow', - params: { - account: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id, - }, - }); - - let ui = await response.getInterface(); - assertIsCustomDialog(ui); - - await ui.typeInField(SendFormEvent.Amount, '0.1'); - await ui.typeInField( - SendFormEvent.Recipient, - 'bcrt1qyvhf2epk9s659206lq3rdvtf07uq3t9e7xtjje', - ); - await ui.clickElement(SendFormEvent.Confirm); - - ui = await response.getInterface(); - await ui.clickElement(ReviewTransactionEvent.HeaderBack); - - ui = await response.getInterface(); - await ui.clickElement(SendFormEvent.Cancel); - - const result = await response; - expect(result).toRespondWithError({ - code: 4001, - message: 'User rejected the request.', - stack: expect.anything(), - }); - }); - - // To be improved once listAccountTransactions is implemented to check the tx confirmation status - it('synchronize accounts via cronjob', async () => { - const response = await snap.onCronjob({ method: 'synchronizeAccounts' }); - expect(response).toRespondWith(null); - }); }); diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh b/merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh index 40de2097..e66d3f27 100755 --- a/merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh +++ b/merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh @@ -17,7 +17,7 @@ echo "Docker services started successfully." docker-compose -f integration-test/docker-compose.yml ps echo "Waiting for Esplora to be ready..." -sleep 10 +sleep 5 # Transfer funds to test address docker exec esplora bash /init-esplora.sh diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts new file mode 100644 index 00000000..dc447e6c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts @@ -0,0 +1,114 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import { BtcScope } from '@metamask/keyring-api'; +import type { Snap } from '@metamask/snaps-jest'; +import { assertIsCustomDialog, installSnap } from '@metamask/snaps-jest'; + +import { ReviewTransactionEvent, SendFormEvent } from '../src/entities'; +import { Caip2AddressType } from '../src/handlers'; +import { MNEMONIC } from './constants'; + +describe('Send flow', () => { + const origin = 'metamask'; + const recipient = 'bcrt1qyvhf2epk9s659206lq3rdvtf07uq3t9e7xtjje'; + let account: KeyringAccount; + let snap: Snap; + + beforeAll(async () => { + snap = await installSnap({ + options: { + secretRecoveryPhrase: MNEMONIC, + }, + }); + + snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); + snap.mockJsonRpc({ + method: 'snap_scheduleBackgroundEvent', + result: 'background-event-id', + }); + snap.mockJsonRpc({ + method: 'snap_cancelBackgroundEvent', + result: {}, + }); + + const response = await snap.onKeyringRequest({ + origin, + method: 'keyring_createAccount', + params: { + options: { + addressType: Caip2AddressType.P2wpkh, + scope: BtcScope.Regtest, + synchronize: true, + }, + }, + }); + + if ('result' in response.response) { + account = response.response.result as KeyringAccount; + } + }); + + it('sends a transaction', async () => { + const response = snap.request({ + origin, + method: 'startSendTransactionFlow', + params: { + account: account.id, + }, + }); + let ui = await response.getInterface(); + assertIsCustomDialog(ui); + + // Perform user interactions. + await ui.clickElement(SendFormEvent.SetMax); + await ui.typeInField(SendFormEvent.Recipient, recipient); + await ui.typeInField(SendFormEvent.Amount, '0.1'); + + const backgroundEventResponse = await snap.onBackgroundEvent({ + method: SendFormEvent.RefreshRates, + params: { interfaceId: ui.id }, + }); + expect(backgroundEventResponse).toRespondWith(null); + + ui = await response.getInterface(); + await ui.clickElement(SendFormEvent.Confirm); + + // Test that we can successfully revert to send form. + ui = await response.getInterface(); + await ui.clickElement(ReviewTransactionEvent.HeaderBack); + + ui = await response.getInterface(); + await ui.clickElement(SendFormEvent.Confirm); + + ui = await response.getInterface(); + await ui.clickElement(ReviewTransactionEvent.Send); + + const result = await response; + expect(result).toRespondWith({ txId: expect.any(String) }); + + // TODO: To be improved once listAccountTransactions is implemented to check the tx confirmation status. + const cronJobResponse = await snap.onCronjob({ + method: 'synchronizeAccounts', + }); + expect(cronJobResponse).toRespondWith(null); + }); + + it('cancels by return button', async () => { + const response = snap.request({ + origin, + method: 'startSendTransactionFlow', + params: { + account: account.id, + }, + }); + + const ui = await response.getInterface(); + await ui.clickElement(SendFormEvent.Cancel); + + const result = await response; + expect(result).toRespondWithError({ + code: 4001, + message: 'User rejected the request.', + stack: expect.anything(), + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/locales/en.json b/merged-packages/bitcoin-wallet-snap/locales/en.json index 72c23ddb..7b93a3c9 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/en.json +++ b/merged-packages/bitcoin-wallet-snap/locales/en.json @@ -49,14 +49,14 @@ "transactionSpeedTooltip": { "message": "The estimated time of the transaction" }, - "transactionFee": { - "message": "Transaction Fee" + "networkFee": { + "message": "Network Fee" }, "feeRate": { "message": "Fee rate" }, - "transactionFeeTooltip": { - "message": "The total transaction fee" + "networkFeeTooltip": { + "message": "The total network fee" }, "total": { "message": "Total" diff --git a/merged-packages/bitcoin-wallet-snap/messages.json b/merged-packages/bitcoin-wallet-snap/messages.json index 1c0435cc..b3b9b2b1 100644 --- a/merged-packages/bitcoin-wallet-snap/messages.json +++ b/merged-packages/bitcoin-wallet-snap/messages.json @@ -49,14 +49,14 @@ "transactionSpeedTooltip": { "message": "The estimated time of the transaction" }, - "transactionFee": { - "message": "Transaction Fee" + "networkFee": { + "message": "Network Fee" }, "feeRate": { "message": "Fee rate" }, - "transactionFeeTooltip": { - "message": "The total transaction fee" + "networkFeeTooltip": { + "message": "The total network fee" }, "total": { "message": "Total" diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 7eba4e5d..110132c8 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -46,7 +46,7 @@ "@metamask/keyring-snap-sdk": "^3.1.0", "@metamask/slip44": "^4.1.0", "@metamask/snaps-cli": "^6.7.0", - "@metamask/snaps-jest": "^8.12.0", + "@metamask/snaps-jest": "^8.13.0", "@metamask/snaps-sdk": "^6.18.0", "@metamask/utils": "^11.2.0", "@types/qs": "^6.9.18", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 81ea0ab1..f0d46781 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "keSL7lctZRNpiXhc3kpYTPCgThhJUD5rLDXjW74XA0A=", + "shasum": "VBvSPvWJBcdfHVScmd/iYSF1En1aEH043JaJ/AugMPg=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index e24bac99..cc174810 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -36,6 +36,7 @@ export const Config: SnapConfig = { }, targetBlocksConfirmation: 3, fallbackFeeRate: 5.0, + ratesRefreshInterval: 'PT30S', priceApi: { url: process.env.PRICE_API_URL ?? 'https://price.api.cx.metamask.io', }, diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts index d81c66f5..51fd78eb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -9,6 +9,7 @@ export type SnapConfig = { // Temporary config to set the expected confirmation target, should eventually be chosen by the user targetBlocksConfirmation: number; fallbackFeeRate: number; + ratesRefreshInterval: string; priceApi: PriceApiConfig; conversionsExpirationInterval: number; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts index 67f0b4f8..b40aceb2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts @@ -1,7 +1,6 @@ import type { CurrencyRate } from '@metamask/snaps-sdk'; import type { Network } from 'bitcoindevkit'; -import type { BitcoinAccount } from './account'; import type { CurrencyUnit } from './currency'; export const SENDFORM_NAME = 'sendForm'; @@ -15,7 +14,7 @@ export type SendFormContext = { balance: string; feeRate: number; currency: CurrencyUnit; - fiatRate?: CurrencyRate; + exchangeRate?: CurrencyRate; recipient?: string; amount?: string; fee?: string; @@ -25,6 +24,7 @@ export type SendFormContext = { recipient?: string; amount?: string; }; + backgroundEventId?: string; }; export enum SendFormEvent { @@ -34,6 +34,7 @@ export enum SendFormEvent { Confirm = 'confirm', Cancel = 'cancel', SetMax = 'max', + RefreshRates = 'refreshRates', } export type SendFormState = { @@ -46,10 +47,12 @@ export type ReviewTransactionContext = { network: Network; feeRate: number; currency: CurrencyUnit; - fiatRate?: CurrencyRate; + exchangeRate?: CurrencyRate; recipient: string; amount: string; fee: string; + backgroundEventId?: string; + drain?: boolean; /** * Used to repopulate the send form if the user decides to go back in the flow @@ -70,20 +73,23 @@ export type SendFlowRepository = { /** * Get the form state. * @param id - the interface ID - * @returns the form state + * @returns the form state or null + */ + getState(id: string): Promise; + + /** + * Get the form context. + * @param id - the interface ID + * @returns the form context or null */ - getState(id: string): Promise; + getContext(id: string): Promise; /** * Insert a new send form interface. * @param context - the form context * @returns the interface ID */ - insertForm( - account: BitcoinAccount, - feeRate: number, - fiatRate?: CurrencyRate, - ): Promise; + insertForm(context: SendFormContext): Promise; /** * Update an interface to the send form view. diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index d8485e43..b82705ae 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -1,9 +1,11 @@ import type { JsonSLIP10Node, SLIP10Node } from '@metamask/key-tree'; -import type { ComponentOrElement, CurrencyRate } from '@metamask/snaps-sdk'; +import type { + ComponentOrElement, + GetPreferencesResult, +} from '@metamask/snaps-sdk'; import type { Json } from '@metamask/utils'; import type { BitcoinAccount } from './account'; -import type { CurrencyUnit } from './currency'; import type { Inscription } from './meta-protocols'; export type SnapState = { @@ -102,18 +104,39 @@ export type SnapClient = { /** * Get the state of an interface. * @param id - The interface id. - * @param field - The field to return from the state. - * @returns the interface state value or undefined. + * @returns the interface state. */ - getInterfaceState( - id: string, - field: string, - ): Promise; + getInterfaceState(id: string): Promise | null>; + + /** + * Get the context of an interface. + * @param id - The interface id. + * @returns the interface context. + */ + getInterfaceContext(id: string): Promise | null>; + + /** + * Schedule a one-off callback. + * @param interval - The interval in seconds before the event is executed. + * @param method - The method to call on reception of the event being triggered. + * @param interfaceId - The interface id. + * @returns the background event id. + */ + scheduleBackgroundEvent( + interval: string, + method: string, + interfaceId: string, + ): Promise; + + /** + * Cancel an already scheduled background event. + * @param id - The background event id. + */ + cancelBackgroundEvent(id: string): Promise; /** - * Retrieve the currency rate. - * @param currency - The currency unit. - * @returns A Promise that resolves to the currency rate. + * Get user preferences. + * @returns the user's preferences. */ - getCurrencyRate(currency: CurrencyUnit): Promise; + getPreferences(): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts b/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts index 94ae16f8..0220eae4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts @@ -20,7 +20,7 @@ export type TransactionBuilder = { /** * Set the PSBT fee rate. - * @param feeRate - The fee rate in sat/vb + * @param feeRate - The fee rate in sat/vB */ feeRate(feeRate: number): TransactionBuilder; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts index 431656c7..a63f0a35 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -1,8 +1,8 @@ import { mock } from 'jest-mock-extended'; -import type { BitcoinAccount } from '../entities'; +import { SendFormEvent, type BitcoinAccount } from '../entities'; import type { ILogger } from '../infra/logger'; -import type { AccountUseCases } from '../use-cases/AccountUseCases'; +import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; import { CronHandler } from './CronHandler'; jest.mock('../infra/logger', () => { @@ -10,11 +10,12 @@ jest.mock('../infra/logger', () => { }); describe('CronHandler', () => { + const mockSendFlowUseCases = mock(); const mockAccountUseCases = mock(); let handler: CronHandler; beforeEach(() => { - handler = new CronHandler(mockAccountUseCases); + handler = new CronHandler(mockAccountUseCases, mockSendFlowUseCases); }); describe('synchronizeAccounts', () => { @@ -50,4 +51,31 @@ describe('CronHandler', () => { ); }); }); + + describe('refreshRates', () => { + it('throws if invalid params', async () => { + await expect( + handler.route(SendFormEvent.RefreshRates, { invalid: 'id' }), + ).rejects.toThrow(''); + }); + + it('refreshes the send form rates', async () => { + const interfaceId = 'id'; + await handler.route(SendFormEvent.RefreshRates, { interfaceId }); + + expect(mockSendFlowUseCases.onChangeForm).toHaveBeenCalledWith( + interfaceId, + SendFormEvent.RefreshRates, + ); + }); + + it('propagates errors from onChangeForm', async () => { + const error = new Error(); + mockSendFlowUseCases.onChangeForm.mockRejectedValue(error); + + await expect( + handler.route(SendFormEvent.RefreshRates, { interfaceId: 'id' }), + ).rejects.toThrow(error); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index 33590291..b7323a41 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -1,18 +1,36 @@ +import type { JsonRpcParams } from '@metamask/utils'; +import { assert, object, string } from 'superstruct'; + +import { SendFormEvent } from '../entities'; import { logger } from '../infra/logger'; -import type { AccountUseCases } from '../use-cases/AccountUseCases'; +import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; + +export const SendFormRefreshRatesRequest = object({ + interfaceId: string(), +}); export class CronHandler { readonly #accountsUseCases: AccountUseCases; - constructor(accounts: AccountUseCases) { + readonly #sendFlowUseCases: SendFlowUseCases; + + constructor(accounts: AccountUseCases, sendFlow: SendFlowUseCases) { this.#accountsUseCases = accounts; + this.#sendFlowUseCases = sendFlow; } - async route(method: string): Promise { + async route(method: string, params?: JsonRpcParams): Promise { switch (method) { case 'synchronizeAccounts': { return this.synchronizeAccounts(); } + case SendFormEvent.RefreshRates: { + assert(params, SendFormRefreshRatesRequest); + return this.#sendFlowUseCases.onChangeForm( + params.interfaceId, + SendFormEvent.RefreshRates, + ); + } default: throw new Error('Method not found.'); } @@ -29,7 +47,7 @@ export class CronHandler { results.forEach((result, index) => { if (result.status === 'rejected') { logger.error( - `Account failed to sync. ID: %s. Error: %o`, + `Account failed to sync. ID: %s. Error: %s`, accounts[index].id, result.reason, ); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts index e83e0be7..6175a7cb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts @@ -63,16 +63,15 @@ describe('UserInputHandler', () => { mockContext, ); - expect(mockSendFlowUseCases.updateForm).toHaveBeenCalledWith( + expect(mockSendFlowUseCases.onChangeForm).toHaveBeenCalledWith( 'interface-id', SendFormEvent.ClearRecipient, - mockContext, ); }); - it('propagates errors from updateForm', async () => { + it('propagates errors from onChangeForm', async () => { const error = new Error(); - mockSendFlowUseCases.updateForm.mockRejectedValue(error); + mockSendFlowUseCases.onChangeForm.mockRejectedValue(error); await expect( handler.route( @@ -98,16 +97,16 @@ describe('UserInputHandler', () => { mockContext, ); - expect(mockSendFlowUseCases.updateReview).toHaveBeenCalledWith( + expect(mockSendFlowUseCases.onChangeReview).toHaveBeenCalledWith( 'interface-id', ReviewTransactionEvent.Send, mockContext, ); }); - it('propagates errors from updateReview', async () => { + it('propagates errors from onChangeReview', async () => { const error = new Error(); - mockSendFlowUseCases.updateReview.mockRejectedValue(error); + mockSendFlowUseCases.onChangeReview.mockRejectedValue(error); await expect( handler.route( diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts index 808bc8c1..266d7ad8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts @@ -1,11 +1,7 @@ import type { Json, UserInputEvent } from '@metamask/snaps-sdk'; import type { ReviewTransactionContext } from '../entities'; -import { - ReviewTransactionEvent, - type SendFormContext, - SendFormEvent, -} from '../entities'; +import { ReviewTransactionEvent, SendFormEvent } from '../entities'; import type { SendFlowUseCases } from '../use-cases'; export class UserInputHandler { @@ -29,15 +25,14 @@ export class UserInputHandler { } if (this.#isSendFormEvent(event.name)) { - // Cast context to SendFormContext - return this.#sendFlowUseCases.updateForm( + return this.#sendFlowUseCases.onChangeForm( interfaceId, event.name, - context as SendFormContext, + // TODO: Reuse when fixed: https://github.com/MetaMask/snaps/issues/3069 + // context as SendFormContext, ); } else if (this.#isReviewTransactionEvent(event.name)) { - // Cast context to the appropriate type for review - return this.#sendFlowUseCases.updateReview( + return this.#sendFlowUseCases.onChangeReview( interfaceId, event.name, context as ReviewTransactionContext, diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 940a3d71..e3b96d03 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -57,14 +57,16 @@ const sendFlowUseCases = new SendFlowUseCases( accountRepository, sendFlowRepository, chainClient, + assetRatesClient, Config.targetBlocksConfirmation, Config.fallbackFeeRate, + Config.ratesRefreshInterval, ); const assetsUseCases = new AssetsUseCases(assetRatesClient); // Application layer const keyringHandler = new KeyringHandler(accountsUseCases); -const cronHandler = new CronHandler(accountsUseCases); +const cronHandler = new CronHandler(accountsUseCases, sendFlowUseCases); const rpcHandler = new RpcHandler(sendFlowUseCases, accountsUseCases); const userInputHandler = new UserInputHandler(sendFlowUseCases); const assetsHandler = new AssetsHandler( @@ -84,8 +86,10 @@ export const validateOrigin = (origin: string, method: string): void => { }; export const onCronjob: OnCronjobHandler = async ({ request }) => { + await loadLocale(); + try { - await cronHandler.route(request.method); + await cronHandler.route(request.method, request.params); } catch (error) { let snapError = error; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 3e9c34c6..e6ffff3d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -3,15 +3,17 @@ import { SLIP10Node } from '@metamask/key-tree'; import { KeyringEvent } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import type { - AvailableCurrency, - ComponentOrElement, - CurrencyRate, - Json, - SnapsProvider, + GetInterfaceContextResult, + GetInterfaceStateResult, +} from '@metamask/snaps-sdk'; +import { + type ComponentOrElement, + type GetPreferencesResult, + type Json, } from '@metamask/snaps-sdk'; import type { BitcoinAccount, SnapClient, SnapState } from '../entities'; -import { CurrencyUnit, networkToCurrencyUnit } from '../entities'; +import { networkToCurrencyUnit } from '../entities'; import { networkToCaip19 } from '../handlers/caip19'; import { snapToKeyringAccount } from '../handlers/keyring-account'; import { addressTypeToName, networkToName } from '../handlers/mapping'; @@ -23,10 +25,6 @@ export class SnapClientAdapter implements SnapClient { this.#encrypt = encrypt; } - get provider(): SnapsProvider { - return snap; - } - async get(): Promise { const state = await snap.request({ method: 'snap_manageState', @@ -55,7 +53,7 @@ export class SnapClientAdapter implements SnapClient { } async getPrivateEntropy(derivationPath: string[]): Promise { - return await snap.request({ + return snap.request({ method: 'snap_getBip32Entropy', params: { path: derivationPath, @@ -104,12 +102,9 @@ export class SnapClientAdapter implements SnapClient { ui: ComponentOrElement, context: Record, ): Promise { - return await snap.request({ + return snap.request({ method: 'snap_createInterface', - params: { - ui, - context, - }, + params: { ui, context }, }); } @@ -120,60 +115,67 @@ export class SnapClientAdapter implements SnapClient { ): Promise { await snap.request({ method: 'snap_updateInterface', - params: { - id, - ui, - context, - }, + params: { id, ui, context }, }); } async displayInterface(id: string): Promise { return (await snap.request({ method: 'snap_dialog', - params: { - id, - }, + params: { id }, })) as unknown as ResolveType; } - async getInterfaceState( - id: string, - field: string, - ): Promise { - const result = await snap.request({ + async getInterfaceState(id: string): Promise { + return snap.request({ method: 'snap_getInterfaceState', params: { id }, }); + } - return result[field] as unknown as InterfaceStateType; + async getInterfaceContext(id: string): Promise { + return snap.request({ + method: 'snap_getInterfaceContext', + params: { id }, + }); } async resolveInterface(id: string, value: Json): Promise { await snap.request({ method: 'snap_resolveInterface', + params: { id, value }, + }); + } + + async scheduleBackgroundEvent( + interval: string, + method: string, + interfaceId: string, + ): Promise { + return snap.request({ + method: 'snap_scheduleBackgroundEvent', params: { - id, - value, + duration: interval, + request: { + method, + params: { interfaceId }, + }, }, }); } - async getCurrencyRate( - currency: CurrencyUnit, - ): Promise { - // TODO: Remove when fix implemented: https://github.com/MetaMask/accounts-planning/issues/832 - if (currency !== CurrencyUnit.Bitcoin) { - return undefined; - } - - const rate = await snap.request({ - method: 'snap_getCurrencyRate', + async cancelBackgroundEvent(id: string): Promise { + await snap.request({ + method: 'snap_cancelBackgroundEvent', params: { - currency: currency as unknown as AvailableCurrency, + id, }, }); + } - return rate ?? undefined; + async getPreferences(): Promise { + return snap.request({ + method: 'snap_getPreferences', + }); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx index 2f1d45a7..ef1939db 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx @@ -20,15 +20,23 @@ import { BlockTime, ReviewTransactionEvent } from '../../entities'; import { getTranslator } from '../../entities/locale'; import { networkToCaip2 } from '../../handlers/caip2'; import { HeadingWithReturn } from './components'; -import { displayAmount, displayFiatAmount } from './format'; +import { displayAmount, displayExchangeAmount } from './format'; import btcIcon from './images/btc-halo.svg'; export const ReviewTransactionView: SnapComponent = ( props, ) => { const t = getTranslator(); - const { amount, fee, currency, fiatRate, feeRate, recipient, network, from } = - props; + const { + amount, + fee, + currency, + exchangeRate, + feeRate, + recipient, + network, + from, + } = props; const total = BigInt(amount) + BigInt(fee); @@ -61,7 +69,7 @@ export const ReviewTransactionView: SnapComponent = ( @@ -85,19 +93,19 @@ export const ReviewTransactionView: SnapComponent = ( )}`} - + - {`${feeRate} sat/vb`} + {`${Math.floor(feeRate)} sat/vB`}
diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx index bfabe350..7a0085dc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx @@ -11,11 +11,11 @@ import type { Network } from 'bitcoindevkit'; import { Config } from '../../../config'; import { BlockTime, type CurrencyUnit } from '../../../entities'; import { getTranslator } from '../../../entities/locale'; -import { displayAmount, displayFiatAmount } from '../format'; +import { displayAmount, displayExchangeAmount } from '../format'; type TransactionSummaryProps = { currency: CurrencyUnit; - fiatRate?: CurrencyRate; + exchangeRate?: CurrencyRate; amount: string; fee: string; network: Network; @@ -25,7 +25,7 @@ export const TransactionSummary: SnapComponent = ({ fee, amount, currency, - fiatRate, + exchangeRate, network, }) => { const t = getTranslator(); @@ -34,21 +34,21 @@ export const TransactionSummary: SnapComponent = ({ return (
- - - {`${Config.targetBlocksConfirmation * BlockTime[network]} ${t( 'minutes', )}`} + + +
diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts index 26533a8e..a03fb027 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts @@ -15,13 +15,13 @@ export const displayAmount = ( return amount.toString(); }; -export const displayFiatAmount = ( +export const displayExchangeAmount = ( amount: bigint, - fiatRate?: CurrencyRate, + exchangeRate?: CurrencyRate, ): string => { - return fiatRate - ? `${((Number(amount) * fiatRate.conversionRate) / 1e8).toFixed(2)} ${ - fiatRate.currency + return exchangeRate + ? `${((Number(amount) * exchangeRate.conversionRate) / 1e8).toFixed(2)} ${ + exchangeRate.currency }` : ''; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx index ded4463d..e1bd8491 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx @@ -1,13 +1,11 @@ -import type { AddressInfo } from 'bitcoindevkit'; import { mock } from 'jest-mock-extended'; import type { SnapClient, SendFormContext, ReviewTransactionContext, - BitcoinAccount, } from '../entities'; -import { CurrencyUnit, SENDFORM_NAME } from '../entities'; +import { SENDFORM_NAME } from '../entities'; import { ReviewTransactionView, SendFormView } from '../infra/jsx'; import { JSXSendFlowRepository } from './JSXSendFlowRepository'; @@ -25,65 +23,73 @@ describe('JSXSendFlowRepository', () => { }); describe('getState', () => { - it('returns state when found', async () => { - const state = { foo: 'bar' }; + it('returns send form state if found', async () => { + const id = 'test-id'; + const state = { [SENDFORM_NAME]: 'bar' }; mockSnapClient.getInterfaceState.mockResolvedValue(state); - const result = await repo.getState('test-id'); + const result = await repo.getState(id); - expect(mockSnapClient.getInterfaceState).toHaveBeenCalledWith( - 'test-id', - SENDFORM_NAME, - ); - expect(result).toEqual(state); + expect(mockSnapClient.getInterfaceState).toHaveBeenCalledWith(id); + expect(result).toEqual(state[SENDFORM_NAME]); }); - it('throws error if state is missing', async () => { + it('returns null if state is null', async () => { mockSnapClient.getInterfaceState.mockResolvedValue(null); - await expect(repo.getState('test-id')).rejects.toThrow( - 'Missing state from Send Form', - ); + const result = await repo.getState('test-id'); + + expect(result).toBeNull(); + }); + + it('returns null if send form state is not present in interface state', async () => { + const state = { unknownField: 'bar' }; + mockSnapClient.getInterfaceState.mockResolvedValue(state); + + const result = await repo.getState('test-id'); + + expect(result).toBeNull(); }); }); - describe('insertForm', () => { - const feeRate = 10; - const mockAccount = mock({ - id: 'acc-id', - network: 'bitcoin', - // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 - /* eslint-disable @typescript-eslint/naming-convention */ - balance: { trusted_spendable: { to_sat: () => BigInt(1234) } }, - peekAddress: jest.fn(), + describe('getContext', () => { + it('returns context if found', async () => { + const context = { foo: 'bar' }; + const id = 'test-id'; + mockSnapClient.getInterfaceContext.mockResolvedValue(context); + + const result = await repo.getContext(id); + + expect(mockSnapClient.getInterfaceContext).toHaveBeenCalledWith(id); + expect(result).toEqual(context); }); - const fiatRate = { - currency: 'USD', - conversionRate: 100000, - conversionDate: 2025, - }; + it('returns null if context is null', async () => { + mockSnapClient.getInterfaceContext.mockResolvedValue(null); + + const result = await repo.getContext('test-id'); + + expect(result).toBeNull(); + }); + + it('propagates error from getInterfaceContext', async () => { + const error = new Error('getInterfaceContext failed'); + mockSnapClient.getInterfaceContext.mockRejectedValue(error); + + await expect(repo.getContext('test-id')).rejects.toBe(error); + }); + }); + + describe('insertForm', () => { it('creates interface with correct context', async () => { mockSnapClient.createInterface.mockResolvedValue('interface-id'); - mockAccount.peekAddress.mockReturnValue({ - address: 'myAddress', - } as AddressInfo); - const expectedContext: SendFormContext = { - balance: '1234', - currency: CurrencyUnit.Bitcoin, - account: { id: 'acc-id', address: 'myAddress' }, - network: 'bitcoin', - feeRate, - errors: {}, - fiatRate, - }; - - const result = await repo.insertForm(mockAccount, feeRate, fiatRate); - - expect(mockAccount.peekAddress).toHaveBeenCalledWith(0); + const mockContext = mock({}); + + const result = await repo.insertForm(mockContext); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( - , - expectedContext, + , + mockContext, ); expect(result).toBe('interface-id'); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx index 1569dc91..261fa8b0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx @@ -1,14 +1,11 @@ -import type { CurrencyRate } from '@metamask/snaps-sdk'; - import type { SendFormContext, SendFlowRepository, SendFormState, SnapClient, ReviewTransactionContext, - BitcoinAccount, } from '../entities'; -import { networkToCurrencyUnit, SENDFORM_NAME } from '../entities'; +import { SENDFORM_NAME } from '../entities'; import { ReviewTransactionView, SendFormView } from '../infra/jsx'; export class JSXSendFlowRepository implements SendFlowRepository { @@ -18,34 +15,22 @@ export class JSXSendFlowRepository implements SendFlowRepository { this.#snapClient = snapClient; } - async getState(id: string): Promise { - const state = await this.#snapClient.getInterfaceState( - id, - SENDFORM_NAME, - ); - // Should never occur by assertion. It is a critical inconsistent state error that should be caught in integration tests + async getState(id: string): Promise { + const state = await this.#snapClient.getInterfaceState(id); if (!state) { - throw new Error('Missing state from Send Form'); + return null; } - return state; + return (state[SENDFORM_NAME] as SendFormState) ?? null; } - async insertForm( - account: BitcoinAccount, - feeRate: number, - fiatRate?: CurrencyRate, - ): Promise { - const context: SendFormContext = { - balance: account.balance.trusted_spendable.to_sat().toString(), - currency: networkToCurrencyUnit[account.network], - account: { id: account.id, address: account.peekAddress(0).address }, // FIXME: Address should not be needed here - network: account.network, - feeRate, - fiatRate, - errors: {}, - }; + async getContext(id: string): Promise { + return (await this.#snapClient.getInterfaceContext( + id, + )) as SendFormContext | null; + } + async insertForm(context: SendFormContext): Promise { return this.#snapClient.createInterface( , context, diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index c012cfe7..0f650d85 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -53,7 +53,7 @@ export class AccountUseCases { } async list(): Promise { - logger.trace('Listing accounts'); + logger.debug('Listing accounts'); const accounts = await this.#repository.getAll(); @@ -62,7 +62,7 @@ export class AccountUseCases { } async get(id: string): Promise { - logger.trace('Fetching account. ID: %s', id); + logger.debug('Fetching account: %s', id); const account = await this.#repository.get(id); if (!account) { @@ -93,7 +93,7 @@ export class AccountUseCases { // Idempotent account creation + ensures only one account per derivation path const account = await this.#repository.getByDerivationPath(derivationPath); if (account) { - logger.debug('Account already exists. ID: %s,', account.id); + logger.debug('Account already exists: %s,', account.id); await this.#snapClient.emitAccountCreatedEvent(account); return account; } @@ -115,7 +115,7 @@ export class AccountUseCases { } async synchronize(account: BitcoinAccount): Promise { - logger.trace('Synchronizing account. ID: %s', account.id); + logger.debug('Synchronizing account: %s', account.id); if (!account.isScanned) { logger.warn( @@ -156,7 +156,7 @@ export class AccountUseCases { } async delete(id: string): Promise { - logger.debug('Deleting account. ID: %s', id); + logger.debug('Deleting account: %s', id); const account = await this.#repository.get(id); if (!account) { @@ -170,7 +170,7 @@ export class AccountUseCases { } async send(id: string, request: TransactionRequest): Promise { - logger.debug('Sending transaction. ID: %s. Request: %o', id, request); + logger.debug('Sending transaction: %s. Request: %o', id, request); if (request.drain && request.amount) { throw new Error("Cannot specify both 'amount' and 'drain' options"); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts index 7e235732..ef91417b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts @@ -13,7 +13,7 @@ export class AssetsUseCases { } async getRates(assets: CaipAssetType[]): Promise { - logger.trace('Fetching BTC rates for: %o', assets); + logger.debug('Fetching BTC rates for: %o', assets); const exchangeRates = await this.#assetRates.exchangeRates(); logger.debug('BTC rates fetched successfully'); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index c48b1e49..a4224840 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -1,6 +1,6 @@ -import type { CurrencyRate } from '@metamask/snaps-sdk'; +import type { GetPreferencesResult } from '@metamask/snaps-sdk'; import { UserRejectedRequestError } from '@metamask/snaps-sdk'; -import type { Psbt, FeeEstimates } from 'bitcoindevkit'; +import type { Psbt, FeeEstimates, Network, AddressInfo } from 'bitcoindevkit'; import { Address, Amount } from 'bitcoindevkit'; import { mock } from 'jest-mock-extended'; @@ -13,6 +13,7 @@ import type { SnapClient, TransactionRequest, ReviewTransactionContext, + AssetRatesClient, } from '../entities'; import { ReviewTransactionEvent, @@ -46,16 +47,21 @@ describe('SendFlowUseCases', () => { const mockAccountRepository = mock(); const mockSendFlowRepository = mock(); const mockChain = mock(); + const mockRatesClient = mock(); const targetBlocksConfirmation = 3; const fallbackFeeRate = 5.0; + const ratesRefreshInterval = 'PT30S'; const mockAccount = mock({ network: 'bitcoin', buildTx: jest.fn(), sign: jest.fn(), + id: 'acc-id', + // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 + /* eslint-disable @typescript-eslint/naming-convention */ + balance: { trusted_spendable: { to_sat: () => BigInt(1234) } }, + peekAddress: jest.fn(), }); - const mockFeeEstimates = mock({ get: jest.fn() }); const mockTxRequest = mock(); - const mockCurrencyRate = mock(); beforeEach(() => { useCases = new SendFlowUseCases( @@ -63,12 +69,22 @@ describe('SendFlowUseCases', () => { mockAccountRepository, mockSendFlowRepository, mockChain, + mockRatesClient, targetBlocksConfirmation, fallbackFeeRate, + ratesRefreshInterval, ); }); describe('displayForm', () => { + beforeEach(() => { + mockAccountRepository.get.mockResolvedValue(mockAccount); + mockAccount.peekAddress.mockReturnValue({ + address: 'myAddress', + } as AddressInfo); + mockSendFlowRepository.insertForm.mockResolvedValue('interface-id'); + }); + it('throws error if account not found', async () => { mockAccountRepository.get.mockResolvedValue(null); await expect(useCases.display('non-existent-account')).rejects.toThrow( @@ -77,10 +93,6 @@ describe('SendFlowUseCases', () => { }); it('throws UserRejectedRequestError if displayInterface returns null', async () => { - mockAccountRepository.get.mockResolvedValue(mockAccount); - mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); - mockFeeEstimates.get.mockReturnValue(5); - mockSendFlowRepository.insertForm.mockResolvedValue('interface-id'); mockSnapClient.displayInterface.mockResolvedValue(null); await expect(useCases.display('account-id')).rejects.toThrow( @@ -89,38 +101,28 @@ describe('SendFlowUseCases', () => { }); it('displays Send form and returns transaction request when resolved', async () => { - mockAccountRepository.get.mockResolvedValue(mockAccount); - mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); - mockFeeEstimates.get.mockReturnValue(5); - mockSendFlowRepository.insertForm.mockResolvedValue('interface-id'); mockSnapClient.displayInterface.mockResolvedValue(mockTxRequest); - mockSnapClient.getCurrencyRate.mockResolvedValue(mockCurrencyRate); const result = await useCases.display('account-id'); expect(mockAccountRepository.get).toHaveBeenCalledWith('account-id'); - expect(mockChain.getFeeEstimates).toHaveBeenCalledWith( - mockAccount.network, - ); - expect(mockSnapClient.getCurrencyRate).toHaveBeenCalledWith( - CurrencyUnit.Bitcoin, - ); - expect(mockFeeEstimates.get).toHaveBeenCalledWith( - targetBlocksConfirmation, - ); - expect(mockSendFlowRepository.insertForm).toHaveBeenCalledWith( - mockAccount, - 5, - mockCurrencyRate, - ); + expect(mockSendFlowRepository.insertForm).toHaveBeenCalledWith({ + balance: '1234', + currency: CurrencyUnit.Bitcoin, + account: { id: 'acc-id', address: 'myAddress' }, + network: 'bitcoin', + feeRate: fallbackFeeRate, + errors: {}, + }); expect(mockSnapClient.displayInterface).toHaveBeenCalledWith( 'interface-id', ); + expect(mockChain.getFeeEstimates).toHaveBeenCalled(); expect(result).toStrictEqual(mockTxRequest); }); }); - describe('updateForm', () => { + describe('onChangeForm', () => { const mockTxBuilder = { addRecipient: jest.fn(), feeRate: jest.fn(), @@ -130,17 +132,6 @@ describe('SendFlowUseCases', () => { unspendable: jest.fn(), }; const mockPsbt = mock(); - - beforeEach(() => { - mockAccount.buildTx.mockReturnValue(mockTxBuilder); - mockTxBuilder.addRecipient.mockReturnThis(); - mockTxBuilder.feeRate.mockReturnThis(); - mockTxBuilder.drainTo.mockReturnThis(); - mockTxBuilder.drainWallet.mockReturnThis(); - mockTxBuilder.finish.mockReturnValue(mockPsbt); - mockTxBuilder.unspendable.mockReturnThis(); - }); - const mockContext: SendFormContext = { account: { id: 'account-id', address: 'myAddress' }, amount: '1000', @@ -156,33 +147,41 @@ describe('SendFlowUseCases', () => { feeRate: 2.4, network: 'bitcoin', fee: '10', - fiatRate: { + exchangeRate: { currency: 'USD', conversionRate: 100000, conversionDate: 2025, }, + backgroundEventId: 'backgroundEventId', }; + beforeEach(() => { + mockAccount.buildTx.mockReturnValue(mockTxBuilder); + mockTxBuilder.addRecipient.mockReturnThis(); + mockTxBuilder.feeRate.mockReturnThis(); + mockTxBuilder.drainTo.mockReturnThis(); + mockTxBuilder.drainWallet.mockReturnThis(); + mockTxBuilder.finish.mockReturnValue(mockPsbt); + mockTxBuilder.unspendable.mockReturnThis(); + + mockSendFlowRepository.getContext.mockResolvedValue(mockContext); + }); + it('throws error unrecognized event', async () => { await expect( - useCases.updateForm( - 'interface-id', - 'randomEvent' as SendFormEvent, - mockContext, - ), + useCases.onChangeForm('interface-id', 'randomEvent' as SendFormEvent), ).rejects.toThrow('Unrecognized event'); }); it('resolves to null on Cancel', async () => { - await useCases.updateForm( - 'interface-id', - SendFormEvent.Cancel, - mockContext, - ); + await useCases.onChangeForm('interface-id', SendFormEvent.Cancel); expect(mockSnapClient.resolveInterface).toHaveBeenCalledWith( 'interface-id', null, ); + expect(mockSnapClient.cancelBackgroundEvent).toHaveBeenCalledWith( + mockContext.backgroundEventId, + ); }); it('clears state on ClearRecipient', async () => { @@ -197,11 +196,7 @@ describe('SendFlowUseCases', () => { }, }; - await useCases.updateForm( - 'interface-id', - SendFormEvent.ClearRecipient, - mockContext, - ); + await useCases.onChangeForm('interface-id', SendFormEvent.ClearRecipient); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( 'interface-id', expectedContext, @@ -209,25 +204,28 @@ describe('SendFlowUseCases', () => { }); it('throws error on Review if amount, recipient or fee are not defined', async () => { + mockSendFlowRepository.getContext.mockResolvedValueOnce({ + ...mockContext, + recipient: undefined, + }); await expect( - useCases.updateForm('interface-id', SendFormEvent.Confirm, { - ...mockContext, - recipient: undefined, - }), + useCases.onChangeForm('interface-id', SendFormEvent.Confirm), ).rejects.toThrow('Inconsistent Send form context'); + mockSendFlowRepository.getContext.mockResolvedValueOnce({ + ...mockContext, + amount: undefined, + }); await expect( - useCases.updateForm('interface-id', SendFormEvent.Confirm, { - ...mockContext, - amount: undefined, - }), + useCases.onChangeForm('interface-id', SendFormEvent.Confirm), ).rejects.toThrow('Inconsistent Send form context'); + mockSendFlowRepository.getContext.mockResolvedValueOnce({ + ...mockContext, + fee: undefined, + }); await expect( - useCases.updateForm('interface-id', SendFormEvent.Confirm, { - ...mockContext, - fee: undefined, - }), + useCases.onChangeForm('interface-id', SendFormEvent.Confirm), ).rejects.toThrow('Inconsistent Send form context'); }); @@ -238,17 +236,14 @@ describe('SendFlowUseCases', () => { amount: '1000', recipient: 'recipientAddress', feeRate: mockContext.feeRate, - fiatRate: mockContext.fiatRate, + exchangeRate: mockContext.exchangeRate, currency: mockContext.currency, fee: '10', sendForm: mockContext, + drain: mockContext.drain, }; - await useCases.updateForm( - 'interface-id', - SendFormEvent.Confirm, - mockContext, - ); + await useCases.onChangeForm('interface-id', SendFormEvent.Confirm); expect(mockSendFlowRepository.updateReview).toHaveBeenCalledWith( 'interface-id', expectedReviewContext, @@ -261,6 +256,8 @@ describe('SendFlowUseCases', () => { ...mockContext, recipient: undefined, }; + mockSendFlowRepository.getContext.mockResolvedValueOnce(testContext); + const expectedContext = { ...testContext, drain: true, @@ -273,11 +270,7 @@ describe('SendFlowUseCases', () => { }, }; - await useCases.updateForm( - 'interface-id', - SendFormEvent.SetMax, - testContext, - ); + await useCases.onChangeForm('interface-id', SendFormEvent.SetMax); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( 'interface-id', expectedContext, @@ -294,6 +287,7 @@ describe('SendFlowUseCases', () => { ...mockContext, amount: undefined, }; + mockSendFlowRepository.getContext.mockResolvedValueOnce(testContext); mockSendFlowRepository.getState.mockResolvedValue({ recipient: 'newAddress', @@ -309,11 +303,7 @@ describe('SendFlowUseCases', () => { }, }; - await useCases.updateForm( - 'interface-id', - SendFormEvent.Recipient, - testContext, - ); + await useCases.onChangeForm('interface-id', SendFormEvent.Recipient); expect(mockSendFlowRepository.getState).toHaveBeenCalledWith( 'interface-id', @@ -333,6 +323,7 @@ describe('SendFlowUseCases', () => { ...mockContext, recipient: undefined, // avoid computing the fee in this test }; + mockSendFlowRepository.getContext.mockResolvedValueOnce(testContext); mockSendFlowRepository.getState.mockResolvedValue({ recipient: '', @@ -350,11 +341,7 @@ describe('SendFlowUseCases', () => { }, }; - await useCases.updateForm( - 'interface-id', - SendFormEvent.Amount, - testContext, - ); + await useCases.onChangeForm('interface-id', SendFormEvent.Amount); expect(mockSendFlowRepository.getState).toHaveBeenCalledWith( 'interface-id', @@ -380,11 +367,7 @@ describe('SendFlowUseCases', () => { errors: expect.anything(), }; - await useCases.updateForm( - 'interface-id', - SendFormEvent.SetMax, - mockContext, - ); + await useCases.onChangeForm('interface-id', SendFormEvent.SetMax); expect(mockAccountRepository.get).toHaveBeenCalledWith('account-id'); expect(mockAccountRepository.getFrozenUTXOs).toHaveBeenCalledWith( @@ -422,11 +405,7 @@ describe('SendFlowUseCases', () => { recipient: 'newAddressValidated', }; - await useCases.updateForm( - 'interface-id', - SendFormEvent.Recipient, - mockContext, - ); + await useCases.onChangeForm('interface-id', SendFormEvent.Recipient); expect(mockAccountRepository.get).toHaveBeenCalled(); expect(mockAccountRepository.getFrozenUTXOs).toHaveBeenCalled(); @@ -443,7 +422,7 @@ describe('SendFlowUseCases', () => { }); }); - describe('updateReview', () => { + describe('onChangeReview', () => { const mockContext: ReviewTransactionContext = { from: 'myAddress', network: 'bitcoin', @@ -452,12 +431,14 @@ describe('SendFlowUseCases', () => { recipient: 'recipientAddress', feeRate: 2.4, fee: '10', - sendForm: {} as SendFormContext, + sendForm: { + network: 'bitcoin', + } as SendFormContext, }; it('throws error unrecognized event', async () => { await expect( - useCases.updateReview( + useCases.onChangeReview( 'interface-id', 'randomEvent' as ReviewTransactionEvent, mockContext, @@ -466,7 +447,7 @@ describe('SendFlowUseCases', () => { }); it('resolves to null on HeaderBack if missing send form in context', async () => { - await useCases.updateReview( + await useCases.onChangeReview( 'interface-id', ReviewTransactionEvent.HeaderBack, { ...mockContext, sendForm: undefined }, @@ -478,19 +459,23 @@ describe('SendFlowUseCases', () => { }); it('reverts interface back to send form if present in context', async () => { - await useCases.updateReview( - 'interface-id', + const id = 'interface-id'; + await useCases.onChangeReview( + id, ReviewTransactionEvent.HeaderBack, mockContext, ); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( - 'interface-id', + id, mockContext.sendForm, ); + expect(mockChain.getFeeEstimates).toHaveBeenCalledWith( + mockContext.network, + ); }); it('resolves to the transaction request on Send', async () => { - await useCases.updateReview( + await useCases.onChangeReview( 'interface-id', ReviewTransactionEvent.Send, mockContext, @@ -506,4 +491,123 @@ describe('SendFlowUseCases', () => { ); }); }); + + describe('refreshRates', () => { + const mockFeeEstimates = mock({ get: jest.fn() }); + const mockContext: SendFormContext = { + account: { id: 'account-id', address: 'myAddress' }, + balance: '20000', + currency: CurrencyUnit.Bitcoin, + recipient: 'recipientAddress', + errors: {}, + network: 'bitcoin', + feeRate: fallbackFeeRate, + }; + const mockExchangeRates = { + usd: { value: 200000 }, + }; + const mockPreferences = mock({ currency: 'usd' }); + const mockFeeRate = 4.4; + + beforeEach(() => { + mockSendFlowRepository.getContext.mockResolvedValue(mockContext); + mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); + mockRatesClient.exchangeRates.mockResolvedValue(mockExchangeRates); + mockSnapClient.scheduleBackgroundEvent.mockResolvedValue('event-id'); + mockSnapClient.getPreferences.mockResolvedValue(mockPreferences); + }); + + it('schedules next event if fetching rates fail', async () => { + mockChain.getFeeEstimates.mockRejectedValueOnce( + new Error('getFeeEstimates'), + ); + + await useCases.onChangeForm('interface-id', SendFormEvent.RefreshRates); + + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalled(); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + { + ...mockContext, + network: 'bitcoin', + backgroundEventId: 'event-id', + }, + ); + }); + + it('sets fee and exchange rates successfully', async () => { + (mockFeeEstimates.get as jest.Mock).mockReturnValue(mockFeeRate); + + await useCases.onChangeForm('interface-id', SendFormEvent.RefreshRates); + + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith( + ratesRefreshInterval, + SendFormEvent.RefreshRates, + 'interface-id', + ); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + { + ...mockContext, + backgroundEventId: 'event-id', + exchangeRate: { + conversionRate: mockExchangeRates.usd.value, + conversionDate: expect.any(Number), + currency: 'USD', + }, + feeRate: mockFeeRate, + }, + ); + }); + + it('does not set exchange rate if network is not bitcoin', async () => { + (mockFeeEstimates.get as jest.Mock).mockReturnValue(mockFeeRate); + mockSendFlowRepository.getContext.mockResolvedValueOnce({ + ...mockContext, + network: 'notBitcoin' as Network, + }); + + await useCases.onChangeForm('interface-id', SendFormEvent.RefreshRates); + + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalled(); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + { + ...mockContext, + backgroundEventId: 'event-id', + network: 'notBitcoin', + feeRate: mockFeeRate, + }, + ); + }); + + it('does not set exchange rate if currency is not supported', async () => { + (mockFeeEstimates.get as jest.Mock).mockReturnValue(mockFeeRate); + mockSnapClient.getPreferences.mockResolvedValue({ + ...mockPreferences, + currency: 'unknown', + }); + + await useCases.onChangeForm('interface-id', SendFormEvent.RefreshRates); + + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalled(); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + { + ...mockContext, + backgroundEventId: 'event-id', + feeRate: mockFeeRate, + }, + ); + }); + + it('propagates error if scheduleBackgroundEvent fails', async () => { + const error = new Error('scheduleBackgroundEvent failed'); + mockSnapClient.scheduleBackgroundEvent.mockRejectedValue(error); + + await expect( + useCases.onChangeForm('interface-id', SendFormEvent.RefreshRates), + ).rejects.toThrow(error); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index b3bbaa4a..d1c597d4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -1,3 +1,4 @@ +import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; import { UserRejectedRequestError } from '@metamask/snaps-sdk'; import { Address, Amount } from 'bitcoindevkit'; @@ -9,6 +10,7 @@ import type { TransactionRequest, SendFormContext, ReviewTransactionContext, + AssetRatesClient, } from '../entities'; import { SendFormEvent, @@ -26,51 +28,56 @@ export class SendFlowUseCases { readonly #chainClient: BlockchainClient; + readonly #ratesClient: AssetRatesClient; + readonly #targetBlocksConfirmation: number; readonly #fallbackFeeRate: number; + readonly #ratesRefreshInterval: string; + constructor( snapClient: SnapClient, accountRepository: BitcoinAccountRepository, sendFlowRepository: SendFlowRepository, chainClient: BlockchainClient, + ratesClient: AssetRatesClient, targetBlocksConfirmation: number, fallbackFeeRate: number, + ratesRefreshInterval: string, ) { this.#snapClient = snapClient; this.#accountRepository = accountRepository; this.#sendFlowRepository = sendFlowRepository; this.#chainClient = chainClient; + this.#ratesClient = ratesClient; this.#targetBlocksConfirmation = targetBlocksConfirmation; this.#fallbackFeeRate = fallbackFeeRate; + this.#ratesRefreshInterval = ratesRefreshInterval; } async display(accountId: string): Promise { - logger.trace('Displaying Send form view. Account: %s', accountId); + logger.debug('Displaying Send form. Account: %s', accountId); const account = await this.#accountRepository.get(accountId); if (!account) { throw new Error('Account not found'); } - // TODO: Fetch fee rates from state and refresh on updates - const feeEstimates = await this.#chainClient.getFeeEstimates( - account.network, - ); - const feeRate = - feeEstimates.get(this.#targetBlocksConfirmation) ?? this.#fallbackFeeRate; + const context: SendFormContext = { + balance: account.balance.trusted_spendable.to_sat().toString(), + currency: networkToCurrencyUnit[account.network], + account: { id: account.id, address: account.peekAddress(0).address }, // FIXME: Address should not be needed here + network: account.network, + feeRate: this.#fallbackFeeRate, + errors: {}, + }; - // TODO: Fetch fiat/fee rates from state and refresh on updates - const fiatRate = await this.#snapClient.getCurrencyRate( - networkToCurrencyUnit[account.network], - ); + const interfaceId = await this.#sendFlowRepository.insertForm(context); - const interfaceId = await this.#sendFlowRepository.insertForm( - account, - feeRate, - fiatRate, - ); + // Asynchronously start the fetching of rates background loop. + /* eslint-disable no-void */ + void this.#refreshRates(interfaceId, context); // Blocks and waits for user actions const request = await this.#snapClient.displayInterface( @@ -84,16 +91,23 @@ export class SendFlowUseCases { return request; } - async updateForm( - id: string, - event: SendFormEvent, - context: SendFormContext, - ): Promise { - logger.trace('Updating Send form. ID: %s. Event: %s', id, event); + async onChangeForm(id: string, event: SendFormEvent): Promise { + logger.debug('Event triggered on send form: %s. Event: %s', id, event); + + // TODO: Temporary fetch the context while this is fixed: https://github.com/MetaMask/snaps/issues/3069 + const context = await this.#sendFlowRepository.getContext(id); + if (!context) { + throw new Error(`Context not found in send form: ${id}`); + } switch (event) { case SendFormEvent.Cancel: { - return await this.#snapClient.resolveInterface(id, null); + if (context.backgroundEventId) { + await this.#snapClient.cancelBackgroundEvent( + context.backgroundEventId, + ); + } + return this.#snapClient.resolveInterface(id, null); } case SendFormEvent.ClearRecipient: { const updatedContext = { ...context }; @@ -102,9 +116,15 @@ export class SendFlowUseCases { delete updatedContext.errors.tx; delete updatedContext.fee; - return await this.#sendFlowRepository.updateForm(id, updatedContext); + return this.#sendFlowRepository.updateForm(id, updatedContext); } case SendFormEvent.Confirm: { + if (context.backgroundEventId) { + await this.#snapClient.cancelBackgroundEvent( + context.backgroundEventId, + ); + } + if (context.amount && context.recipient && context.fee) { const reviewContext: ReviewTransactionContext = { from: context.account.address, @@ -112,13 +132,15 @@ export class SendFlowUseCases { amount: context.amount, recipient: context.recipient, feeRate: context.feeRate, - fiatRate: context.fiatRate, + exchangeRate: context.exchangeRate, currency: context.currency, fee: context.fee, + drain: context.drain, sendForm: context, }; - return await this.#sendFlowRepository.updateReview(id, reviewContext); + return this.#sendFlowRepository.updateReview(id, reviewContext); } + throw new Error('Inconsistent Send form context'); } case SendFormEvent.SetMax: { @@ -130,34 +152,43 @@ export class SendFlowUseCases { case SendFormEvent.Amount: { return this.#handleSetAmount(id, context); } + case SendFormEvent.RefreshRates: { + return this.#refreshRates(id, context); + } default: throw new Error('Unrecognized event'); } } - async updateReview( + async onChangeReview( id: string, event: ReviewTransactionEvent, context: ReviewTransactionContext, ): Promise { - logger.trace('Updating transaction review. ID: %s. Event: %s', id, event); + logger.debug( + 'Event triggered on transaction review: %s. Event: %s', + id, + event, + ); switch (event) { case ReviewTransactionEvent.HeaderBack: { // If we come from a send form, we display it again, otherwise we resolve the interface (reject) if (context.sendForm) { - return this.#sendFlowRepository.updateForm(id, context.sendForm); + await this.#sendFlowRepository.updateForm(id, context.sendForm); + return this.#refreshRates(id, context.sendForm); } - return this.#snapClient.resolveInterface(id, null); } case ReviewTransactionEvent.Send: { - const { amount, feeRate, recipient } = context; + const { amount, feeRate, recipient, drain } = context; const txRequest: TransactionRequest = { feeRate, - amount, + amount: drain ? undefined : amount, recipient, + drain, }; + return this.#snapClient.resolveInterface(id, txRequest); } default: @@ -184,6 +215,9 @@ export class SendFlowUseCases { context: SendFormContext, ): Promise { const formState = await this.#sendFlowRepository.getState(id); + if (!formState) { + throw new Error(`Form state not found when setting recipient: ${id}`); + } let updatedContext = { ...context }; delete updatedContext.errors.recipient; @@ -207,6 +241,9 @@ export class SendFlowUseCases { async #handleSetAmount(id: string, context: SendFormContext): Promise { const formState = await this.#sendFlowRepository.getState(id); + if (!formState) { + throw new Error(`Form state not found when setting amount: ${id}`); + } let updatedContext = { ...context }; delete updatedContext.errors.amount; @@ -229,6 +266,51 @@ export class SendFlowUseCases { return await this.#sendFlowRepository.updateForm(id, updatedContext); } + async #refreshRates(id: string, context: SendFormContext): Promise { + const { network } = context; + let updatedContext = { ...context }; + + try { + const feeEstimates = await this.#chainClient.getFeeEstimates(network); + const feeRate = + feeEstimates.get(this.#targetBlocksConfirmation) ?? + this.#fallbackFeeRate; + updatedContext.feeRate = feeRate; + + // Exchange rate is only relevant for Bitcoin + if (network === 'bitcoin') { + const exchangeRates = await this.#ratesClient.exchangeRates(); + const { currency } = await this.#snapClient.getPreferences(); + const conversionRate = exchangeRates[currency]; + if (conversionRate) { + updatedContext.exchangeRate = { + conversionRate: conversionRate.value, + conversionDate: getCurrentUnixTimestamp(), + currency: currency.toUpperCase(), + }; + } + } + + updatedContext = await this.#computeFee(updatedContext); + } catch (error) { + // We do not throw so we can reschedule. Previous fetched values or fallbacks will be used. + logger.error( + `Failed to fetch rates in send form: %s. Error: %s`, + id, + error, + ); + } + + updatedContext.backgroundEventId = + await this.#snapClient.scheduleBackgroundEvent( + this.#ratesRefreshInterval, + SendFormEvent.RefreshRates, + id, + ); + + await this.#sendFlowRepository.updateForm(id, updatedContext); + } + async #computeFee(context: SendFormContext): Promise { const { amount, recipient, drain } = context; if (amount && recipient) { From fb7b6f92caf8c531edb7347cb6eebaf1dc4eb8f3 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Fri, 14 Mar 2025 14:19:30 +0100 Subject: [PATCH 200/362] feat: list transactions (#420) * add unspendable to tx builder * fetch assets * running * run only on new outputs * UTXO protection done * tests * done * all done * rename files * done * remove old Snap and release * remove unused dependencies * buils and tests pass * buils and tests pass * use v0.1.4 * from_string * rebase * rebase done * done * lint + build * remove quicknode * Delete packages/snap/src/infra/SimplehashClientAdapter.ts * final alignment * integration test link * integration tests * back to blockstream * typo * do not log in integration tests * use exchange rates * full scan only on demand * tests * all done * integration tests * done * remove onInstall * aligned * background loop implemented * fiat rate only for Bitcoin * move to repository * remove to repository * logic done * bug on catch * before rebase * blockstream * lint * esplora * only one test failing suite * test failing * use real object * skip tests bug snaps-jest * esplora * skip tests * listing transactions work * returns correct list with too much info * all comments applied * destructure * test start * done * lint * styling * lint * review PR * rebase * all cosmetic comments * use repository * guard currency * too much info * successful mapping of txs * synchronize only unconfirmed or new txs * typo * use walletTx * apply cosmetic comments * no-void * add test for header back * tests passing * cancel loop on cancel * refactor tests * done * add blockstream * add blockstream * naming * reduce verbosity * reduce verbosity * tests fixed * spacing * spacing * final * working listing * unit tests done * done * revert blockstream * bdk v0.1.5 * dedupe * do not export mappings * lint * lint --- .../bitcoin-wallet-snap/.env.example | 5 - .../bitcoin-wallet-snap/.eslintrc.js | 2 +- .../images/icon-signet.svg | 1 + .../images/icon-testnet.svg | 1 + .../integration-test/constants.ts | 41 ++++ .../integration-test/keyring.test.ts | 55 ++--- .../integration-test/run-integration.sh | 14 +- .../integration-test/send-flow.test.ts | 69 ++++++- .../bitcoin-wallet-snap/package.json | 8 +- .../bitcoin-wallet-snap/snap.config.ts | 1 - .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../bitcoin-wallet-snap/src/config.ts | 3 +- .../src/entities/account.ts | 49 ++++- .../src/entities/config.ts | 1 - .../bitcoin-wallet-snap/src/entities/snap.ts | 11 + .../src/handlers/AssetsHandler.test.ts | 2 +- .../src/handlers/AssetsHandler.ts | 28 ++- .../src/handlers/KeyringHandler.test.ts | 188 ++++++++++++++++-- .../src/handlers/KeyringHandler.ts | 40 +++- .../src/handlers/{caip2.ts => caip.ts} | 28 ++- .../src/handlers/caip19.ts | 17 -- .../bitcoin-wallet-snap/src/handlers/index.ts | 8 +- .../src/handlers/keyring-account.ts | 21 -- .../src/handlers/mapping.ts | 33 --- .../src/handlers/mappings.ts | 170 ++++++++++++++++ .../src/infra/BdkAccountAdapter.ts | 41 +++- .../src/infra/BdkTxBuilderAdapter.ts | 4 +- .../src/infra/SnapClientAdapter.ts | 24 ++- .../src/infra/jsx/ReviewTransactionView.tsx | 2 +- .../src/infra/jsx/components/SendForm.tsx | 14 +- .../src/infra/jsx/images/signet.svg | 1 + .../src/infra/jsx/images/testnet.svg | 1 + .../src/use-cases/AccountUseCases.test.ts | 82 ++++++-- .../src/use-cases/AccountUseCases.ts | 46 ++++- .../src/use-cases/AssetsUseCases.test.ts | 2 +- .../src/use-cases/SendFlowUseCases.test.ts | 8 +- .../src/use-cases/SendFlowUseCases.ts | 5 +- 37 files changed, 810 insertions(+), 220 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/images/icon-signet.svg create mode 100644 merged-packages/bitcoin-wallet-snap/images/icon-testnet.svg rename merged-packages/bitcoin-wallet-snap/src/handlers/{caip2.ts => caip.ts} (50%) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/caip19.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/keyring-account.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/mapping.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/signet.svg create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/testnet.svg diff --git a/merged-packages/bitcoin-wallet-snap/.env.example b/merged-packages/bitcoin-wallet-snap/.env.example index 8a374c01..ab551db9 100644 --- a/merged-packages/bitcoin-wallet-snap/.env.example +++ b/merged-packages/bitcoin-wallet-snap/.env.example @@ -4,11 +4,6 @@ # Required: false LOG_LEVEL=6 -# Default Bitcoin network -# Possible options: bitcoin, testnet, testnet4, signet, regtest -# Default: bitcoin -# Required: false -DEFAULT_NETWORK= # Default address type # Possible options: p2pkh, p2sh, p2wpkh, p2wsh, p2tr # Default: p2wpkh diff --git a/merged-packages/bitcoin-wallet-snap/.eslintrc.js b/merged-packages/bitcoin-wallet-snap/.eslintrc.js index 0a4b6985..8aa2a012 100644 --- a/merged-packages/bitcoin-wallet-snap/.eslintrc.js +++ b/merged-packages/bitcoin-wallet-snap/.eslintrc.js @@ -11,7 +11,7 @@ module.exports = { 'id-length': [ 'warn', // Used for the localized translator helper. - { exceptions: ['t'] }, + { exceptions: ['t', '_'] }, ], }, diff --git a/merged-packages/bitcoin-wallet-snap/images/icon-signet.svg b/merged-packages/bitcoin-wallet-snap/images/icon-signet.svg new file mode 100644 index 00000000..cb0c6cb2 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/images/icon-signet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/images/icon-testnet.svg b/merged-packages/bitcoin-wallet-snap/images/icon-testnet.svg new file mode 100644 index 00000000..336e8600 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/images/icon-testnet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts b/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts index 53ff72c2..4b5b234a 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts @@ -1,3 +1,44 @@ +import { BtcScope } from '@metamask/keyring-api'; + +import { CurrencyUnit } from '../src/entities'; +import { Caip19Asset } from '../src/handlers/caip'; + export const MNEMONIC = 'journey embrace permit coil indoor stereo welcome maid movie easy clock spider tent slush bright luxury awake waste legal modify awkward answer acid goose'; export const TEST_ADDRESS = 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t'; +export const ORIGIN = 'metamask'; +export const FUNDING_TX = { + account: expect.any(Number), + chain: BtcScope.Regtest, + events: [ + { status: 'unconfirmed', timestamp: null }, + { status: 'confirmed', timestamp: expect.any(Number) }, + ], + fees: [ + { + asset: { + amount: expect.any(String), + fungible: true, + type: Caip19Asset.Regtest, + unit: CurrencyUnit.Regtest, + }, + type: 'priority', + }, + ], + from: [], + id: expect.any(String), + status: 'confirmed', + timestamp: expect.any(Number), + to: [ + { + address: TEST_ADDRESS, + asset: { + amount: '500', + fungible: true, + type: Caip19Asset.Regtest, + unit: CurrencyUnit.Regtest, + }, + }, + ], + type: 'receive', +}; diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index 3bd36f5e..315d1c4a 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -4,12 +4,11 @@ import type { Snap } from '@metamask/snaps-jest'; import { installSnap } from '@metamask/snaps-jest'; import { CurrencyUnit } from '../src/entities'; -import { Caip2AddressType, Caip19Asset } from '../src/handlers'; -import { MNEMONIC, TEST_ADDRESS } from './constants'; +import { Caip2AddressType, Caip19Asset } from '../src/handlers/caip'; +import { FUNDING_TX, MNEMONIC, ORIGIN, TEST_ADDRESS } from './constants'; describe('Keyring', () => { const accounts: Record = {}; - const origin = 'metamask'; let snap: Snap; beforeAll(async () => { @@ -76,7 +75,7 @@ describe('Keyring', () => { snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); const response = await snap.onKeyringRequest({ - origin, + origin: ORIGIN, method: 'keyring_createAccount', params: { options: { ...requestOpts } }, }); @@ -100,7 +99,7 @@ describe('Keyring', () => { snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); const response = await snap.onKeyringRequest({ - origin, + origin: ORIGIN, method: 'keyring_createAccount', params: { options: { @@ -117,7 +116,7 @@ describe('Keyring', () => { it('gets an account', async () => { const response = await snap.onKeyringRequest({ - origin, + origin: ORIGIN, method: 'keyring_getAccount', params: { id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Mainnet}`].id, @@ -131,16 +130,34 @@ describe('Keyring', () => { it('lists all accounts', async () => { const response = await snap.onKeyringRequest({ - origin, + origin: ORIGIN, method: 'keyring_listAccounts', }); expect(response).toRespondWith(Object.values(accounts)); }); + it('lists account transactions', async () => { + const accoundId = + accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id; + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_listAccountTransactions', + params: { + id: accoundId, + pagination: { limit: 10, next: null }, + }, + }); + + expect(response).toRespondWith({ + data: [{ ...FUNDING_TX, account: accoundId }], + next: null, + }); + }); + it('gets an account balance', async () => { const response = await snap.onKeyringRequest({ - origin, + origin: ORIGIN, method: 'keyring_getAccountBalances', params: { id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id, @@ -160,7 +177,7 @@ describe('Keyring', () => { const { id } = accounts[`${Caip2AddressType.P2pkh}:${BtcScope.Mainnet}`]; let response = await snap.onKeyringRequest({ - origin, + origin: ORIGIN, method: 'keyring_deleteAccount', params: { id, @@ -170,7 +187,7 @@ describe('Keyring', () => { expect(response).toRespondWith(null); response = await snap.onKeyringRequest({ - origin, + origin: ORIGIN, method: 'keyring_getAccount', params: { id, @@ -184,22 +201,6 @@ describe('Keyring', () => { }); }); - it('returns empty list for account transactions', async () => { - const response = await snap.onKeyringRequest({ - origin, - method: 'keyring_listAccountTransactions', - params: { - id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id, - pagination: { limit: 10, next: null }, - }, - }); - - expect(response).toRespondWith({ - data: [], - next: null, - }); - }); - it.each([ { addressType: Caip2AddressType.P2wpkh, @@ -220,7 +221,7 @@ describe('Keyring', () => { 'lists account assets: %s', async ({ addressType, scope, expectedAssets }) => { const response = await snap.onKeyringRequest({ - origin, + origin: ORIGIN, method: 'keyring_listAccountAssets', params: { id: accounts[`${addressType}:${scope}`].id, diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh b/merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh index e66d3f27..46e018c6 100755 --- a/merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh +++ b/merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh @@ -23,20 +23,8 @@ sleep 5 docker exec esplora bash /init-esplora.sh echo "Running integration tests..." +set +e jest --config jest.integration.config.js -if [ $? -eq 0 ]; then - echo "Tests completed successfully." -else - echo "Tests failed." -fi - echo "Stopping Docker services..." docker-compose -f integration-test/docker-compose.yml down - -if [ $? -ne 0 ]; then - echo "Error: Failed to stop Docker services." - exit 1 -fi - -echo "Docker services stopped successfully." \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts index dc447e6c..1d16a04f 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts @@ -3,13 +3,18 @@ import { BtcScope } from '@metamask/keyring-api'; import type { Snap } from '@metamask/snaps-jest'; import { assertIsCustomDialog, installSnap } from '@metamask/snaps-jest'; -import { ReviewTransactionEvent, SendFormEvent } from '../src/entities'; -import { Caip2AddressType } from '../src/handlers'; -import { MNEMONIC } from './constants'; +import { + CurrencyUnit, + ReviewTransactionEvent, + SendFormEvent, +} from '../src/entities'; +import { Caip19Asset, Caip2AddressType } from '../src/handlers/caip'; +import { FUNDING_TX, MNEMONIC, ORIGIN } from './constants'; describe('Send flow', () => { - const origin = 'metamask'; const recipient = 'bcrt1qyvhf2epk9s659206lq3rdvtf07uq3t9e7xtjje'; + const sendAmount = '0.1'; + let account: KeyringAccount; let snap: Snap; @@ -31,7 +36,7 @@ describe('Send flow', () => { }); const response = await snap.onKeyringRequest({ - origin, + origin: ORIGIN, method: 'keyring_createAccount', params: { options: { @@ -49,7 +54,7 @@ describe('Send flow', () => { it('sends a transaction', async () => { const response = snap.request({ - origin, + origin: ORIGIN, method: 'startSendTransactionFlow', params: { account: account.id, @@ -61,7 +66,7 @@ describe('Send flow', () => { // Perform user interactions. await ui.clickElement(SendFormEvent.SetMax); await ui.typeInField(SendFormEvent.Recipient, recipient); - await ui.typeInField(SendFormEvent.Amount, '0.1'); + await ui.typeInField(SendFormEvent.Amount, sendAmount); const backgroundEventResponse = await snap.onBackgroundEvent({ method: SendFormEvent.RefreshRates, @@ -90,11 +95,59 @@ describe('Send flow', () => { method: 'synchronizeAccounts', }); expect(cronJobResponse).toRespondWith(null); + + const listTxsResponse = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_listAccountTransactions', + params: { + id: account.id, + pagination: { limit: 10, next: null }, + }, + }); + + expect(listTxsResponse).toRespondWith({ + data: [ + { ...FUNDING_TX, account: account.id }, + { + account: account.id, + chain: BtcScope.Regtest, + events: [{ status: 'unconfirmed', timestamp: 1741784431 }], + fees: [ + { + asset: { + amount: expect.any(String), + fungible: true, + type: Caip19Asset.Regtest, + unit: CurrencyUnit.Regtest, + }, + type: 'priority', + }, + ], + from: [], + id: expect.any(String), + status: 'unconfirmed', + timestamp: expect.any(Number), + to: [ + { + address: recipient, + asset: { + amount: sendAmount, + fungible: true, + type: Caip19Asset.Regtest, + unit: CurrencyUnit.Regtest, + }, + }, + ], + type: 'send', + }, + ], + next: null, + }); }); it('cancels by return button', async () => { const response = snap.request({ - origin, + origin: ORIGIN, method: 'startSendTransactionFlow', params: { account: account.id, diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 110132c8..fae1e8ce 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -41,19 +41,19 @@ "@metamask/eslint-config-jest": "^12.1.0", "@metamask/eslint-config-nodejs": "^12.1.0", "@metamask/eslint-config-typescript": "^12.1.0", - "@metamask/key-tree": "^10.0.2", - "@metamask/keyring-api": "^17.2.0", + "@metamask/key-tree": "^10.1.0", + "@metamask/keyring-api": "^17.2.1", "@metamask/keyring-snap-sdk": "^3.1.0", "@metamask/slip44": "^4.1.0", "@metamask/snaps-cli": "^6.7.0", "@metamask/snaps-jest": "^8.13.0", - "@metamask/snaps-sdk": "^6.18.0", + "@metamask/snaps-sdk": "^6.19.0", "@metamask/utils": "^11.2.0", "@types/qs": "^6.9.18", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", "async-mutex": "^0.3.2", - "bitcoindevkit": "^0.1.4", + "bitcoindevkit": "^0.1.5", "concurrently": "^9.1.0", "dotenv": "^16.4.5", "eslint": "^8.45.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts index e9f2a151..ea2fc9b6 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.config.ts +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -13,7 +13,6 @@ const config: SnapConfig = { environment: { /* eslint-disable */ LOG_LEVEL: process.env.LOG_LEVEL, - DEFAULT_NETWORK: process.env.DEFAULT_NETWORK, DEFAULT_ADDRESS_TYPE: process.env.DEFAULT_ADDRESS_TYPE, ESPLORA_BITCOIN: process.env.ESPLORA_BITCOIN, ESPLORA_TESTNET: process.env.ESPLORA_TESTNET, diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index f0d46781..f1d9798e 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "VBvSPvWJBcdfHVScmd/iYSF1En1aEH043JaJ/AugMPg=", + "shasum": "LBPhtxfMaueoIIKI21LD4eKryhMFqhzJqqLAzdEkrEI=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -97,6 +97,6 @@ ] } }, - "platformVersion": "6.18.0", + "platformVersion": "6.19.0", "manifestVersion": "0.1" } diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index cc174810..84284497 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -1,6 +1,6 @@ /* eslint-disable no-restricted-globals */ -import type { AddressType, Network } from 'bitcoindevkit'; +import type { AddressType } from 'bitcoindevkit'; import type { SnapConfig } from './entities'; @@ -9,7 +9,6 @@ export const Config: SnapConfig = { encrypt: false, accounts: { index: 0, - defaultNetwork: (process.env.DEFAULT_NETWORK ?? 'bitcoin') as Network, defaultAddressType: (process.env.DEFAULT_ADDRESS_TYPE ?? 'p2wpkh') as AddressType, }, diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index a91f58e7..4dc47eb3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -10,6 +10,10 @@ import type { Psbt, Transaction, LocalOutput, + WalletTx, + Amount, + ScriptBuf, + KeychainKind, } from 'bitcoindevkit'; import type { Inscription } from './meta-protocols'; @@ -108,10 +112,49 @@ export type BitcoinAccount = { listUnspent(): LocalOutput[]; /** - * List all relevant outputs (includes both spent and unspent, confirmed and unconfirmed). - * @returns the list of outputs + * List relevant and canonical transactions in the wallet. + * A transaction is relevant when it spends from or spends to at least one tracked output. + * A transaction is canonical when it is confirmed in the best chain, or does not conflict with any transaction confirmed in the best chain. + * @returns the list of wallet transactions */ - listOutput(): LocalOutput[]; + listTransactions(): WalletTx[]; + + /** + * Get a single transaction from the wallet as a [`WalletTx`] (if the transaction exists). + * @returns the wallet transaction + */ + getTransaction(txid: string): WalletTx | undefined; + + /** + * Calculate the fee of a given transaction. Returns [`Amount::ZERO`] if `tx` is a coinbase transaction. + * @param tx - The transaction. + * @returns the fee amount. + */ + calculateFee(tx: Transaction): Amount; + + /** + * Return whether or not a `script` is part of this wallet (either internal or external). + * @param script - The Bitcoin script. + * @returns the ownership state. + */ + isMine(script: ScriptBuf): boolean; + + /** + * Compute the `tx`'s sent and received [`Amount`]s. + * This method returns a tuple `(sent, received)`. Sent is the sum of the txin amounts + * that spend from previous txouts tracked by this wallet. Received is the summation + * of this tx's outputs that send to script pubkeys tracked by this wallet. + * @param tx - The Bitcoin transaction. + * @returns the sent and received amounts. + */ + sentAndReceived(tx: Transaction): [Amount, Amount]; + + /** + * Finds how the wallet derived the script pubkey `spk`. + * @param spk - The Bitcoin script. + * @returns the keychain used and derivation index, if the script is found. + */ + derivationOfSpk(spk: ScriptBuf): [KeychainKind, number] | undefined; }; /** diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts index 51fd78eb..d9eac323 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -16,7 +16,6 @@ export type SnapConfig = { export type AccountsConfig = { index: number; - defaultNetwork: Network; defaultAddressType: AddressType; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index b82705ae..eb43b600 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -4,6 +4,7 @@ import type { GetPreferencesResult, } from '@metamask/snaps-sdk'; import type { Json } from '@metamask/utils'; +import type { WalletTx } from 'bitcoindevkit'; import type { BitcoinAccount } from './account'; import type { Inscription } from './meta-protocols'; @@ -64,6 +65,16 @@ export type SnapClient = { */ emitAccountBalancesUpdatedEvent(account: BitcoinAccount): Promise; + /** + * Emit an event notifying the extension of updated transactions + * @param account - The Bitcoin account. + * @param txs - The transactions included in the event. + */ + emitAccountTransactionsUpdatedEvent( + account: BitcoinAccount, + txs: WalletTx[], + ): Promise; + /** * Create a User Interface. * @param ui - The UI Component. diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts index 7e90e7e5..439c940e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts @@ -2,7 +2,7 @@ import { mock } from 'jest-mock-extended'; import type { AssetsUseCases } from '../use-cases'; import { AssetsHandler } from './AssetsHandler'; -import { Caip19Asset } from './caip19'; +import { Caip19Asset } from './caip'; describe('AssetsHandler', () => { const mockAssetsUseCases = mock(); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts index a0a0d6e2..c592d2d1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts @@ -8,7 +8,7 @@ import type { } from '@metamask/snaps-sdk'; import type { AssetsUseCases } from '../use-cases'; -import { Caip19Asset } from './caip19'; +import { Caip19Asset } from './caip'; export class AssetsHandler { readonly #assetsUseCases: AssetsUseCases; @@ -24,6 +24,7 @@ export class AssetsHandler { const metadata = ( name: string, mainSymbol: string, + icon: string, ): FungibleAssetMetadata => { return { fungible: true, @@ -55,8 +56,7 @@ export class AssetsHandler { symbol: 'satoshi', }, ], - iconUrl: - 'https://upload.wikimedia.org/wikipedia/commons/4/46/Bitcoin.svg', + iconUrl: `./images/${icon}.svg`, symbol: '₿', }; }; @@ -64,11 +64,23 @@ export class AssetsHandler { // Use the same denominations as Bitcoin for testnets but change the name and main unit symbol return { assets: { - [Caip19Asset.Bitcoin]: metadata('Bitcoin', 'BTC'), - [Caip19Asset.Testnet]: metadata('Testnet Bitcoin', 'tBTC'), - [Caip19Asset.Testnet4]: metadata('Testnet4 Bitcoin', 'tBTC'), - [Caip19Asset.Signet]: metadata('Signet Bitcoin', 'sBTC'), - [Caip19Asset.Regtest]: metadata('Regtest Bitcoin', 'rBTC'), + [Caip19Asset.Bitcoin]: metadata('Bitcoin', 'BTC', 'icon'), + [Caip19Asset.Testnet]: metadata( + 'Testnet Bitcoin', + 'tBTC', + 'icon-testnet', + ), + [Caip19Asset.Testnet4]: metadata( + 'Testnet4 Bitcoin', + 'tBTC', + 'icon-testnet', + ), + [Caip19Asset.Signet]: metadata('Signet Bitcoin', 'sBTC', 'icon-signet'), + [Caip19Asset.Regtest]: metadata( + 'Regtest Bitcoin', + 'rBTC', + 'icon-signet', + ), }, }; } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 964cc41c..7dd9b116 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -1,12 +1,24 @@ +import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; import { BtcMethod, BtcScope } from '@metamask/keyring-api'; -import type { Network } from 'bitcoindevkit'; +import type { + AddressInfo, + Amount, + Transaction, + Txid, + TxOut, +} from 'bitcoindevkit'; +import { Address, type Network, type WalletTx } from 'bitcoindevkit'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; -import type { BitcoinAccount } from '../entities'; +import { CurrencyUnit, type BitcoinAccount } from '../entities'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; -import { Caip19Asset } from './caip19'; -import { caip2ToNetwork, caip2ToAddressType, Caip2AddressType } from './caip2'; +import { + caip2ToNetwork, + caip2ToAddressType, + Caip2AddressType, + Caip19Asset, +} from './caip'; import { KeyringHandler, CreateAccountRequest } from './KeyringHandler'; jest.mock('superstruct', () => ({ @@ -14,24 +26,39 @@ jest.mock('superstruct', () => ({ assert: jest.fn(), })); +// TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('bitcoindevkit', () => { + return { + Address: { + from_script: jest.fn(), + }, + }; +}); + describe('KeyringHandler', () => { const mockAccounts = mock(); + const mockAddress = mock
({ + toString: () => 'bc1qaddress...', + }); // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 /* eslint-disable @typescript-eslint/naming-convention */ - const mockAccount = { + const mockAccount = mock({ id: 'some-id', addressType: 'p2wpkh', - suggestedName: 'My Bitcoin Account', balance: { trusted_spendable: { to_btc: () => 1 } }, network: 'bitcoin', - peekAddress: () => ({ address: 'bc1qaddress...' }), - } as unknown as BitcoinAccount; + }); - let handler: KeyringHandler; + const handler = new KeyringHandler(mockAccounts); beforeEach(() => { - handler = new KeyringHandler(mockAccounts); + mockAccount.peekAddress.mockReturnValue( + mock({ + address: mockAddress, + }), + ); }); describe('createAccount', () => { @@ -249,9 +276,144 @@ describe('KeyringHandler', () => { }); describe('listAccountTransactions', () => { - it('returns empty list', async () => { - const result = await handler.listAccountTransactions(); - expect(result).toStrictEqual({ data: [], next: null }); + const pagination = { limit: 10, next: null }; + + const mockAmount = mock({ + to_btc: () => 21, + }); + const mockOutput = mock({ + value: mockAmount, + }); + const mockTxid = mock({ + toString: () => 'txid', + }); + const mockTx = mock({ + output: [mockOutput], + }); + const mockWalletTx = mock({ + tx: mockTx, + txid: mockTxid, + chain_position: { + last_seen: BigInt(12345), + anchor: { + confirmation_time: BigInt(4567), + }, + }, + }); + + const expectedResult: KeyringTransaction = { + account: mockAccount.id, + chain: BtcScope.Mainnet, + id: 'txid', + type: 'send', + status: 'confirmed', + timestamp: 4567, + events: [ + { + status: 'unconfirmed', + timestamp: 12345, + }, + { + status: 'confirmed', + timestamp: 4567, + }, + ], + fees: [ + { + type: 'priority', + asset: { + amount: '21', + fungible: true, + type: Caip19Asset.Bitcoin, + unit: CurrencyUnit.Bitcoin, + }, + }, + ], + from: [], + to: [ + { + address: 'bc1qaddress...', + asset: { + amount: '21', + fungible: true, + type: Caip19Asset.Bitcoin, + unit: CurrencyUnit.Bitcoin, + }, + }, + ], + }; + + beforeEach(() => { + mockAccount.calculateFee.mockReturnValue(mockAmount); + mockAccounts.get.mockResolvedValue(mockAccount); + mockAccount.sentAndReceived.mockReturnValue([mockAmount, mockAmount]); + mockAccount.listTransactions.mockReturnValue([mockWalletTx]); + (Address.from_script as jest.Mock).mockReturnValue(mockAddress); + }); + + it('lists transactions successfully: send', async () => { + const id = 'some-id'; + + const result = await handler.listAccountTransactions(id, pagination); + + expect(mockAccounts.get).toHaveBeenCalledWith(id); + expect(result.data).toStrictEqual([expectedResult]); + }); + + it('lists transactions successfully: receive', async () => { + const id = 'some-id'; + + mockAccount.sentAndReceived.mockReturnValueOnce([ + { ...mockAmount, to_btc: () => 0 }, + mockAmount, + ]); + mockAccount.isMine.mockReturnValueOnce(true); + + const result = await handler.listAccountTransactions(id, pagination); + + expect(mockAccounts.get).toHaveBeenCalledWith(id); + expect(result.data).toStrictEqual([ + { ...expectedResult, type: 'receive' }, + ]); + }); + + it('respects limit and sets next to last txid', async () => { + const id = 'some-id'; + const mockTransactions = Array.from({ length: 12 }, (_, index) => ({ + ...mockWalletTx, + txid: mock({ + toString: () => `txid-${index}`, + }), + })); + + mockAccount.listTransactions.mockReturnValue(mockTransactions); + + const result = await handler.listAccountTransactions(id, pagination); + + expect(result.data).toHaveLength(pagination.limit); + expect(result.next).toBe('txid-9'); + }); + + it('applies next parameter until last element', async () => { + const id = 'some-id'; + const mockTransactions = Array.from({ length: 12 }, (_, index) => ({ + ...mockWalletTx, + txid: mock({ + toString: () => `txid-${index}`, + }), + })); + + mockAccount.listTransactions.mockReturnValue(mockTransactions); + + const result = await handler.listAccountTransactions(id, { + ...pagination, + next: 'txid-9', + }); + + expect(result.data).toHaveLength( + mockTransactions.length - pagination.limit, + ); + expect(result.next).toBeNull(); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index f84782a2..284c61bb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -8,20 +8,21 @@ import type { CaipAssetTypeOrId, Paginated, Transaction, + Pagination, } from '@metamask/keyring-api'; import type { Json } from '@metamask/utils'; import { assert, boolean, enums, object, optional } from 'superstruct'; import { networkToCurrencyUnit } from '../entities'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; -import { networkToCaip19 } from './caip19'; import { + networkToCaip19, Caip2AddressType, caip2ToAddressType, caip2ToNetwork, networkToCaip2, -} from './caip2'; -import { snapToKeyringAccount } from './keyring-account'; +} from './caip'; +import { mapToKeyringAccount, mapToTransaction } from './mappings'; export const CreateAccountRequest = object({ scope: enums(Object.values(BtcScope)), @@ -38,12 +39,12 @@ export class KeyringHandler implements Keyring { async listAccounts(): Promise { const accounts = await this.#accountsUseCases.list(); - return accounts.map(snapToKeyringAccount); + return accounts.map(mapToKeyringAccount); } async getAccount(id: string): Promise { const account = await this.#accountsUseCases.get(id); - return snapToKeyringAccount(account); + return mapToKeyringAccount(account); } async createAccount(opts: Record): Promise { @@ -58,7 +59,7 @@ export class KeyringHandler implements Keyring { await this.#accountsUseCases.fullScan(account); } - return snapToKeyringAccount(account); + return mapToKeyringAccount(account); } async getAccountBalances( @@ -94,10 +95,31 @@ export class KeyringHandler implements Keyring { return [networkToCaip19[account.network]]; } - async listAccountTransactions(): Promise> { + async listAccountTransactions( + id: string, + { limit, next }: Pagination, + ): Promise> { + const account = await this.#accountsUseCases.get(id); + const transactions = account.listTransactions(); + + // Find starting index based on provided cursor + let startIndex = 0; + if (next) { + const cursorIndex = transactions.findIndex( + (tx) => tx.txid.toString() === next, + ); + startIndex = cursorIndex >= 0 ? cursorIndex + 1 : 0; + } + + const paginatedTxs = transactions.slice(startIndex, startIndex + limit); + const nextCursor = + startIndex + limit < transactions.length + ? paginatedTxs[paginatedTxs.length - 1].txid.toString() + : null; + return { - data: [], - next: null, + data: paginatedTxs.map((tx) => mapToTransaction(account, tx)), + next: nextCursor, }; } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/caip2.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts similarity index 50% rename from merged-packages/bitcoin-wallet-snap/src/handlers/caip2.ts rename to merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts index db5f10bc..0323d7ed 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/caip2.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts @@ -1,7 +1,17 @@ import { BtcScope } from '@metamask/keyring-api'; import type { AddressType, Network } from 'bitcoindevkit'; -import { reverseMapping } from './mapping'; +const reverseMapping = < + From extends string | number | symbol, + // eslint-disable-next-line @typescript-eslint/naming-convention + To extends string | number | symbol, +>( + map: Record, +) => { + return Object.fromEntries( + Object.entries(map).map(([from, to]) => [to, from]), + ) as Record; +}; export enum Caip2AddressType { P2pkh = 'bip122:p2pkh', @@ -29,3 +39,19 @@ export const caip2ToAddressType: Record = { export const networkToCaip2 = reverseMapping(caip2ToNetwork); export const addressTypeToCaip2 = reverseMapping(caip2ToAddressType); + +export enum Caip19Asset { + Bitcoin = 'bip122:000000000019d6689c085ae165831e93/slip44:0', + Testnet = 'bip122:000000000933ea01ad0ee984209779ba/slip44:0', + Testnet4 = 'bip122:00000000da84f2bafbbc53dee25a72ae/slip44:0', + Signet = 'bip122:00000008819873e925422c1ff0f99f7c/slip44:0', + Regtest = 'bip122:regtest/slip44:0', +} + +export const networkToCaip19: Record = { + bitcoin: Caip19Asset.Bitcoin, + testnet: Caip19Asset.Testnet, + testnet4: Caip19Asset.Testnet4, + signet: Caip19Asset.Signet, + regtest: Caip19Asset.Regtest, +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/caip19.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/caip19.ts deleted file mode 100644 index 4c0f0110..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/caip19.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Network } from 'bitcoindevkit'; - -export enum Caip19Asset { - Bitcoin = 'bip122:000000000019d6689c085ae165831e93/slip44:0', - Testnet = 'bip122:000000000933ea01ad0ee984209779ba/slip44:1', - Testnet4 = 'bip122:00000000da84f2bafbbc53dee25a72ae/slip44:1', - Signet = 'bip122:00000008819873e925422c1ff0f99f7c/slip44:1', - Regtest = 'bip122:regtest/slip44:1', -} - -export const networkToCaip19: Record = { - bitcoin: Caip19Asset.Bitcoin, - testnet: Caip19Asset.Testnet, - testnet4: Caip19Asset.Testnet4, - signet: Caip19Asset.Signet, - regtest: Caip19Asset.Regtest, -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts index c8172709..0a6cba51 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts @@ -4,5 +4,9 @@ export * from './RpcHandler'; export * from './UserInputHandler'; export * from './AssetsHandler'; -export { Caip2AddressType } from './caip2'; -export { Caip19Asset } from './caip19'; +export { + Caip2AddressType, + Caip19Asset, + networkToCaip19, + networkToCaip2, +} from './caip'; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/keyring-account.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/keyring-account.ts deleted file mode 100644 index 1ad91455..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/keyring-account.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; -import { BtcMethod } from '@metamask/keyring-api'; - -import type { BitcoinAccount } from '../entities'; -import { addressTypeToCaip2, networkToCaip2 } from './caip2'; - -/** - * Maps a Bitcoin Account to a Keyring Account. - * @param account - The Bitcoin account. - * @returns The Keyring account. - */ -export function snapToKeyringAccount(account: BitcoinAccount): KeyringAccount { - return { - type: addressTypeToCaip2[account.addressType] as KeyringAccount['type'], - scopes: [networkToCaip2[account.network]], - id: account.id, - address: account.peekAddress(0).address, - options: {}, - methods: [BtcMethod.SendBitcoin], - }; -} diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mapping.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mapping.ts deleted file mode 100644 index d4a30471..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mapping.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { AddressType, Network } from 'bitcoindevkit'; - -/** - * Reverse a map object. - * - * @param map - The map to reverse. - * @returns New reversed map. - */ -export function reverseMapping< - From extends string | number | symbol, - // eslint-disable-next-line @typescript-eslint/naming-convention - To extends string | number | symbol, ->(map: Record) { - return Object.fromEntries( - Object.entries(map).map(([from, to]) => [to, from]), - ) as Record; -} - -export const addressTypeToName: Record = { - p2pkh: 'Legacy', - p2sh: 'Nested SegWit', - p2wpkh: 'Native SegWit', - p2tr: 'Taproot', - p2wsh: 'Multisig', -}; - -export const networkToName: Record = { - bitcoin: 'Bitcoin', - testnet: 'Bitcoin Testnet', - testnet4: 'Bitcoin Testnet4', - signet: 'Bitcoin Signet', - regtest: 'Bitcoin Regtest', -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts new file mode 100644 index 00000000..219503a5 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -0,0 +1,170 @@ +import type { + KeyringAccount, + Transaction as KeyringTransaction, +} from '@metamask/keyring-api'; +import { TransactionStatus, BtcMethod } from '@metamask/keyring-api'; +import { Address } from 'bitcoindevkit'; +import type { + AddressType, + Amount, + Network, + TxOut, + ChainPosition, + WalletTx, +} from 'bitcoindevkit'; + +import { networkToCurrencyUnit, type BitcoinAccount } from '../entities'; +import type { Caip19Asset } from './caip'; +import { addressTypeToCaip2, networkToCaip19, networkToCaip2 } from './caip'; + +type TransactionAmount = { + amount: string; + fungible: true; + unit: string; + type: Caip19Asset; +}; + +type TransactionRecipient = { + address: string; + asset: TransactionAmount; +}; + +type TransactionEvent = { + status: TransactionStatus; + timestamp: number | null; +}; + +export const addressTypeToName: Record = { + p2pkh: 'Legacy', + p2sh: 'Nested SegWit', + p2wpkh: 'Native SegWit', + p2tr: 'Taproot', + p2wsh: 'Multisig', +}; + +export const networkToName: Record = { + bitcoin: 'Bitcoin', + testnet: 'Bitcoin Testnet', + testnet4: 'Bitcoin Testnet4', + signet: 'Bitcoin Signet', + regtest: 'Bitcoin Regtest', +}; + +/** + * Maps a Bitcoin Account to a Keyring Account. + * @param account - The Bitcoin account. + * @returns The Keyring account. + */ +export function mapToKeyringAccount(account: BitcoinAccount): KeyringAccount { + return { + type: addressTypeToCaip2[account.addressType] as KeyringAccount['type'], + scopes: [networkToCaip2[account.network]], + id: account.id, + address: account.peekAddress(0).address.toString(), + options: {}, + methods: [BtcMethod.SendBitcoin], + }; +} + +const mapToAmount = (amount: Amount, network: Network): TransactionAmount => { + return { + amount: amount.to_btc().toString(), + fungible: true, + unit: networkToCurrencyUnit[network], + type: networkToCaip19[network], + }; +}; + +const mapToAssetMovement = ( + output: TxOut, + network: Network, +): TransactionRecipient => { + return { + address: Address.from_script(output.script_pubkey, network).toString(), + asset: mapToAmount(output.value, network), + }; +}; + +const mapToEvents = ( + chainPosition: ChainPosition, +): [TransactionEvent[], number | null, TransactionStatus] => { + let timestamp = chainPosition.last_seen + ? Number(chainPosition.last_seen) + : null; + let status = TransactionStatus.Unconfirmed; + const events: TransactionEvent[] = [ + { + status, + timestamp, + }, + ]; + if (chainPosition.anchor) { + timestamp = Number(chainPosition.anchor.confirmation_time); + status = TransactionStatus.Confirmed; + events.push({ + status, + timestamp, + }); + } + return [events, timestamp, status]; +}; + +/** + * Maps a Bitcoin Transaction to a Keyring Transaction. + * @param account - The account account. + * @param walletTx - The Bitcoin transaction managed by this account. + * @returns The Keyring transaction. + */ +export function mapToTransaction( + account: BitcoinAccount, + walletTx: WalletTx, +): KeyringTransaction { + const { tx, chain_position: chainPosition, txid } = walletTx; + const { network } = account; + + const [events, timestamp, status] = mapToEvents(chainPosition); + const [sent] = account.sentAndReceived(tx); + const isSend = sent.to_btc() > 0; + + const transaction: KeyringTransaction = { + type: isSend ? 'send' : 'receive', + id: txid.toString(), + account: account.id, + chain: networkToCaip2[network], + status, + timestamp, + events, + to: [], + from: [], + fees: [ + { + type: 'priority', + asset: mapToAmount(account.calculateFee(tx), network), + }, + ], + }; + + // If it's a Send transaction: + // - to: all the outputs discarding the change (so it also works for consolidations). + // - from: empty as irrelevant because we might be sending from multiple addresses. Sufficient to say "Sent from Bitcoin Account". + // If it's a Receive transaction: + // - to: all the outputs spending to addresses we own. + // - from: empty as irrevelant because we might have hundreds of inputs in a tx. Point to explorer for details. + if (isSend) { + for (const txout of tx.output) { + const spkIndex = account.derivationOfSpk(txout.script_pubkey); + const isConsolidation = spkIndex && spkIndex[0] === 'external'; + if (!spkIndex || isConsolidation) { + transaction.to.push(mapToAssetMovement(txout, network)); + } + } + } else { + for (const txout of tx.output) { + if (account.isMine(txout.script_pubkey)) { + transaction.to.push(mapToAssetMovement(txout, network)); + } + } + } + + return transaction; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index 9bd7bd2c..93da857f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -11,8 +11,12 @@ import type { Psbt, Transaction, LocalOutput, + WalletTx, + Amount, + ScriptBuf, + KeychainKind, } from 'bitcoindevkit'; -import { Wallet } from 'bitcoindevkit'; +import { Txid, Wallet } from 'bitcoindevkit'; import type { BitcoinAccount, TransactionBuilder } from '../entities'; import { BdkTxBuilderAdapter } from './BdkTxBuilderAdapter'; @@ -59,7 +63,7 @@ export class BdkAccountAdapter implements BitcoinAccount { } get balance(): Balance { - return this.#wallet.balance(); + return this.#wallet.balance; } get addressType(): AddressType { @@ -74,11 +78,11 @@ export class BdkAccountAdapter implements BitcoinAccount { } get network(): Network { - return this.#wallet.network(); + return this.#wallet.network; } get isScanned(): boolean { - return this.#wallet.latest_checkpoint().height > 0; + return this.#wallet.latest_checkpoint.height > 0; } peekAddress(index: number): AddressInfo { @@ -126,7 +130,32 @@ export class BdkAccountAdapter implements BitcoinAccount { return this.#wallet.list_unspent(); } - listOutput(): LocalOutput[] { - return this.#wallet.list_output(); + listTransactions(): WalletTx[] { + return this.#wallet.transactions(); + } + + getTransaction(txid: string): WalletTx | undefined { + return this.#wallet.get_tx(Txid.from_string(txid)); + } + + calculateFee(tx: Transaction): Amount { + return this.#wallet.calculate_fee(tx.clone()); + } + + isMine(script: ScriptBuf): boolean { + return this.#wallet.is_mine(script); + } + + sentAndReceived(tx: Transaction): [Amount, Amount] { + const sentAndReceived = this.#wallet.sent_and_received(tx.clone()); + return [sentAndReceived[0], sentAndReceived[1]]; + } + + derivationOfSpk(spk: ScriptBuf): [KeychainKind, number] | undefined { + const spkIndex = this.#wallet.derivation_of_spk(spk); + if (!spkIndex) { + return undefined; + } + return [spkIndex[0], spkIndex[1]]; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts index 051f5d0a..16f0b807 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts @@ -1,5 +1,5 @@ import type { Network, Psbt, TxBuilder } from 'bitcoindevkit'; -import { Outpoint, Address, Amount, FeeRate, Recipient } from 'bitcoindevkit'; +import { OutPoint, Address, Amount, FeeRate, Recipient } from 'bitcoindevkit'; import type { TransactionBuilder } from '../entities'; @@ -42,7 +42,7 @@ export class BdkTxBuilderAdapter implements TransactionBuilder { unspendable(unspendable: string[]): BdkTxBuilderAdapter { const outpoints = unspendable.map((outpoint) => - Outpoint.from_string(outpoint), + OutPoint.from_string(outpoint), ); this.#builder = this.#builder.unspendable(outpoints); return this; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index e6ffff3d..fde6d1c5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -11,12 +11,17 @@ import { type GetPreferencesResult, type Json, } from '@metamask/snaps-sdk'; +import type { WalletTx } from 'bitcoindevkit'; import type { BitcoinAccount, SnapClient, SnapState } from '../entities'; import { networkToCurrencyUnit } from '../entities'; -import { networkToCaip19 } from '../handlers/caip19'; -import { snapToKeyringAccount } from '../handlers/keyring-account'; -import { addressTypeToName, networkToName } from '../handlers/mapping'; +import { networkToCaip19 } from '../handlers'; +import { + mapToKeyringAccount, + mapToTransaction, + addressTypeToName, + networkToName, +} from '../handlers/mappings'; export class SnapClientAdapter implements SnapClient { readonly #encrypt: boolean; @@ -69,7 +74,7 @@ export class SnapClientAdapter implements SnapClient { async emitAccountCreatedEvent(account: BitcoinAccount): Promise { return emitSnapKeyringEvent(snap, KeyringEvent.AccountCreated, { - account: snapToKeyringAccount(account), + account: mapToKeyringAccount(account), accountNameSuggestion: `${networkToName[account.network]} ${ addressTypeToName[account.addressType] }`, @@ -98,6 +103,17 @@ export class SnapClientAdapter implements SnapClient { }); } + async emitAccountTransactionsUpdatedEvent( + account: BitcoinAccount, + txs: WalletTx[], + ): Promise { + return emitSnapKeyringEvent(snap, KeyringEvent.AccountTransactionsUpdated, { + transactions: { + [account.id]: txs.map((tx) => mapToTransaction(account, tx)), + }, + }); + } + async createInterface( ui: ComponentOrElement, context: Record, diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx index ef1939db..7af6235b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx @@ -18,7 +18,7 @@ import { Config } from '../../config'; import type { ReviewTransactionContext } from '../../entities'; import { BlockTime, ReviewTransactionEvent } from '../../entities'; import { getTranslator } from '../../entities/locale'; -import { networkToCaip2 } from '../../handlers/caip2'; +import { networkToCaip2 } from '../../handlers'; import { HeadingWithReturn } from './components'; import { displayAmount, displayExchangeAmount } from './format'; import btcIcon from './images/btc-halo.svg'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx index 56955675..237b2ea7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx @@ -9,12 +9,23 @@ import { Text, type SnapComponent, } from '@metamask/snaps-sdk/jsx'; +import type { Network } from 'bitcoindevkit'; import type { SendFormContext } from '../../../entities'; import { SENDFORM_NAME, SendFormEvent } from '../../../entities'; import { getTranslator } from '../../../entities/locale'; import { displayAmount } from '../format'; import btcIcon from '../images/bitcoin.svg'; +import signetIcon from '../images/signet.svg'; +import testnetIcon from '../images/testnet.svg'; + +const networkToIcon: Record = { + bitcoin: btcIcon, + testnet: testnetIcon, + testnet4: testnetIcon, + signet: signetIcon, + regtest: signetIcon, +}; export const SendForm: SnapComponent = ({ currency, @@ -22,6 +33,7 @@ export const SendForm: SnapComponent = ({ amount, recipient, errors, + network, }) => { const t = getTranslator(); @@ -31,7 +43,7 @@ export const SendForm: SnapComponent = ({
- +
\ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/testnet.svg b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/testnet.svg new file mode 100644 index 00000000..336e8600 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/testnet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 09c3d130..5dd8d235 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -1,5 +1,11 @@ -import type { LocalOutput, Transaction, Txid } from 'bitcoindevkit'; -import { type AddressType, type Network, type Psbt } from 'bitcoindevkit'; +import type { + Transaction, + Txid, + WalletTx, + AddressType, + Network, + Psbt, +} from 'bitcoindevkit'; import { mock } from 'jest-mock-extended'; import type { @@ -29,7 +35,6 @@ describe('AccountUseCases', () => { const accountsConfig: AccountsConfig = { index: 0, defaultAddressType: 'p2wpkh', - defaultNetwork: 'bitcoin', }; beforeEach(() => { @@ -233,43 +238,44 @@ describe('AccountUseCases', () => { const mockAccount = mock({ id: 'some-id', isScanned: true, - listOutput: jest.fn(), + listTransactions: jest.fn(), }); beforeEach(() => { - mockAccount.listOutput.mockReturnValue([]); + mockAccount.listTransactions.mockReturnValue([]); }); it('does not sync if account is not scanned', async () => { - mockAccount.listOutput.mockReturnValue([]); + mockAccount.listTransactions.mockReturnValue([]); await useCases.synchronize({ ...mockAccount, isScanned: false }); expect(mockChain.sync).not.toHaveBeenCalled(); - expect(mockAccount.listOutput).not.toHaveBeenCalled(); + expect(mockAccount.listTransactions).not.toHaveBeenCalled(); }); - it('performs a regular sync', async () => { - mockAccount.listOutput.mockReturnValue([]); + it('synchronizes', async () => { + mockAccount.listTransactions.mockReturnValue([]); await useCases.synchronize(mockAccount); expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); - expect(mockAccount.listOutput).toHaveBeenCalledTimes(2); + expect(mockAccount.listTransactions).toHaveBeenCalledTimes(2); expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); }); - it('performs a regular sync with assets and balance update event', async () => { + it('synchronizes with new transactions', async () => { const mockInscriptions = mock(); - mockAccount.listOutput + const mockTransaction = mock(); + mockAccount.listTransactions .mockReturnValueOnce([]) - .mockReturnValueOnce([mock()]); + .mockReturnValueOnce([mockTransaction]); mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); await useCases.synchronize(mockAccount); expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); - expect(mockAccount.listOutput).toHaveBeenCalledTimes(2); + expect(mockAccount.listTransactions).toHaveBeenCalledTimes(2); expect(mockMetaProtocols.fetchInscriptions).toHaveBeenCalledWith( mockAccount, ); @@ -280,6 +286,39 @@ describe('AccountUseCases', () => { expect( mockSnapClient.emitAccountBalancesUpdatedEvent, ).toHaveBeenCalledWith(mockAccount); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount, [mockTransaction]); + }); + + it('synchronizes with confirmed transactions', async () => { + const mockTxPending = mock({ + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_position: { is_confirmed: false }, + txid: { + toString: () => 'txid', + }, + }); + const mockTxConfirmed = mock({ + ...mockTxPending, + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_position: { is_confirmed: true }, + }); + mockAccount.listTransactions + .mockReturnValueOnce([mockTxPending]) + .mockReturnValueOnce([mockTxConfirmed]); + + await useCases.synchronize(mockAccount); + + expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); + expect(mockAccount.listTransactions).toHaveBeenCalledTimes(2); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount, [mockTxConfirmed]); }); it('propagates an error if the chain sync fails', async () => { @@ -309,8 +348,10 @@ describe('AccountUseCases', () => { isScanned: false, }); const mockInscriptions = mock(); + const mockTransactions = mock(); it('performs a full scan', async () => { + mockAccount.listTransactions.mockReturnValue(mockTransactions); mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); await useCases.fullScan(mockAccount); @@ -326,6 +367,9 @@ describe('AccountUseCases', () => { expect( mockSnapClient.emitAccountBalancesUpdatedEvent, ).toHaveBeenCalledWith(mockAccount); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount, mockTransactions); }); it('propagates an error if the chain full scan fails', async () => { @@ -434,7 +478,7 @@ describe('AccountUseCases', () => { }; const mockTxid = mock(); - const mockOutpoint = 'txid:vout'; + const mockOutPoint = 'txid:vout'; const mockPsbt = mock(); const mockTransaction = mock({ // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 @@ -481,7 +525,7 @@ describe('AccountUseCases', () => { it('sends transaction', async () => { mockRepository.getWithSigner.mockResolvedValue(mockAccount); mockAccount.buildTx.mockReturnValue(mockTxBuilder); - mockRepository.getFrozenUTXOs.mockResolvedValue([mockOutpoint]); + mockRepository.getFrozenUTXOs.mockResolvedValue([mockOutPoint]); mockAccount.sign.mockReturnValue(mockTransaction); mockTransaction.compute_txid.mockReturnValue(mockTxid); @@ -497,7 +541,7 @@ describe('AccountUseCases', () => { requestWithAmount.recipient, ); expect(mockRepository.getFrozenUTXOs).toHaveBeenCalledWith('account-id'); - expect(mockTxBuilder.unspendable).toHaveBeenCalledWith([mockOutpoint]); + expect(mockTxBuilder.unspendable).toHaveBeenCalledWith([mockOutPoint]); expect(mockAccount.sign).toHaveBeenCalledWith(mockPsbt); expect(mockChain.broadcast).toHaveBeenCalledWith( mockAccount.network, @@ -514,7 +558,7 @@ describe('AccountUseCases', () => { it('sends a drain transaction when amount is not provided', async () => { mockRepository.getWithSigner.mockResolvedValue(mockAccount); mockAccount.buildTx.mockReturnValue(mockTxBuilder); - mockRepository.getFrozenUTXOs.mockResolvedValue([mockOutpoint]); + mockRepository.getFrozenUTXOs.mockResolvedValue([mockOutPoint]); mockAccount.sign.mockReturnValue(mockTransaction); mockTransaction.compute_txid.mockReturnValue(mockTxid); @@ -528,7 +572,7 @@ describe('AccountUseCases', () => { requestDrain.recipient, ); expect(mockRepository.getFrozenUTXOs).toHaveBeenCalledWith('account-id'); - expect(mockTxBuilder.unspendable).toHaveBeenCalledWith([mockOutpoint]); + expect(mockTxBuilder.unspendable).toHaveBeenCalledWith([mockOutPoint]); expect(mockAccount.sign).toHaveBeenCalledWith(mockPsbt); expect(mockChain.broadcast).toHaveBeenCalledWith( mockAccount.network, diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 0f650d85..55d2df76 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -1,4 +1,4 @@ -import type { AddressType, Network, Txid } from 'bitcoindevkit'; +import type { AddressType, Network, Txid, WalletTx } from 'bitcoindevkit'; import type { AccountsConfig, @@ -118,28 +118,48 @@ export class AccountUseCases { logger.debug('Synchronizing account: %s', account.id); if (!account.isScanned) { - logger.warn( - 'Account has not yet performed initial full scan, skipping synchronization. ID: %s', + logger.debug( + 'Account has not yet performed initial full scan, skipping synchronization: %s', account.id, ); return; } - // Outputs are monotone, meaning they can only be added, like transactions. So we can be confident - // that a change on the balance can only happen when new outputs appear. - const nOutputsBefore = account.listOutput().length; + const txsBeforeSync = account.listTransactions(); await this.#chain.sync(account); - const nOutputsAfter = account.listOutput().length; + const txsAfterSync = account.listTransactions(); - // Sync assets only if new outputs exist. - if (nOutputsAfter > nOutputsBefore) { + // If new transactions appeared, fetch inscriptions; otherwise, just update. + if (txsAfterSync.length > txsBeforeSync.length) { const inscriptions = await this.#metaProtocols.fetchInscriptions(account); await this.#repository.update(account, inscriptions); - await this.#snapClient.emitAccountBalancesUpdatedEvent(account); } else { await this.#repository.update(account); } + // Create a map for quick lookup of transactions before sync + const txMapBefore = new Map(); + for (const tx of txsBeforeSync) { + txMapBefore.set(tx.txid.toString(), tx); + } + + // Identify transactions that are either new or whose confirmation status changed + const txsToNotify = txsAfterSync.filter((tx) => { + const prevTx = txMapBefore.get(tx.txid.toString()); + return ( + !prevTx || + prevTx.chain_position.is_confirmed !== tx.chain_position.is_confirmed + ); + }); + + if (txsToNotify.length > 0) { + await this.#snapClient.emitAccountBalancesUpdatedEvent(account); + await this.#snapClient.emitAccountTransactionsUpdatedEvent( + account, + txsToNotify, + ); + } + logger.debug('Account synchronized successfully: %s', account.id); } @@ -150,7 +170,12 @@ export class AccountUseCases { const inscriptions = await this.#metaProtocols.fetchInscriptions(account); await this.#repository.update(account, inscriptions); + await this.#snapClient.emitAccountBalancesUpdatedEvent(account); + await this.#snapClient.emitAccountTransactionsUpdatedEvent( + account, + account.listTransactions(), + ); logger.debug('initial full scan performed successfully: %s', account.id); } @@ -199,6 +224,7 @@ export class AccountUseCases { const tx = account.sign(psbt); await this.#chain.broadcast(account.network, tx); await this.#repository.update(account); + await this.#snapClient.emitAccountBalancesUpdatedEvent(account); const txId = tx.compute_txid(); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts index 67d21c5e..11746fc6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts @@ -1,7 +1,7 @@ import { mock } from 'jest-mock-extended'; import type { AssetRatesClient, ExchangeRates } from '../entities'; -import { Caip19Asset } from '../handlers'; +import { Caip19Asset } from '../handlers/caip'; import type { ILogger } from '../infra/logger'; import { AssetsUseCases } from './AssetsUseCases'; diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index a4224840..a5d107ae 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -79,9 +79,11 @@ describe('SendFlowUseCases', () => { describe('displayForm', () => { beforeEach(() => { mockAccountRepository.get.mockResolvedValue(mockAccount); - mockAccount.peekAddress.mockReturnValue({ - address: 'myAddress', - } as AddressInfo); + mockAccount.peekAddress.mockReturnValue( + mock({ + address: mock
({ toString: () => 'myAddress' }), + }), + ); mockSendFlowRepository.insertForm.mockResolvedValue('interface-id'); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index d1c597d4..1f669aec 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -67,7 +67,10 @@ export class SendFlowUseCases { const context: SendFormContext = { balance: account.balance.trusted_spendable.to_sat().toString(), currency: networkToCurrencyUnit[account.network], - account: { id: account.id, address: account.peekAddress(0).address }, // FIXME: Address should not be needed here + account: { + id: account.id, + address: account.peekAddress(0).address.toString(), // FIXME: Address should not be needed in the send flow + }, network: account.network, feeRate: this.#fallbackFeeRate, errors: {}, From a2249679d5950c4ef97a92635c8b34c8903e5eeb Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 17 Mar 2025 14:30:29 +0100 Subject: [PATCH 201/362] fix: icons as base64 (#422) * add unspendable to tx builder * fetch assets * running * run only on new outputs * UTXO protection done * tests * done * all done * rename files * done * remove old Snap and release * remove unused dependencies * buils and tests pass * buils and tests pass * use v0.1.4 * from_string * rebase * rebase done * done * lint + build * remove quicknode * Delete packages/snap/src/infra/SimplehashClientAdapter.ts * final alignment * integration test link * integration tests * back to blockstream * typo * do not log in integration tests * use exchange rates * full scan only on demand * tests * all done * integration tests * done * remove onInstall * aligned * background loop implemented * fiat rate only for Bitcoin * move to repository * remove to repository * logic done * bug on catch * before rebase * blockstream * lint * esplora * only one test failing suite * test failing * use real object * skip tests bug snaps-jest * esplora * skip tests * listing transactions work * returns correct list with too much info * all comments applied * destructure * test start * done * lint * styling * lint * review PR * rebase * all cosmetic comments * use repository * guard currency * too much info * successful mapping of txs * synchronize only unconfirmed or new txs * typo * use walletTx * apply cosmetic comments * no-void * add test for header back * tests passing * cancel loop on cancel * refactor tests * done * add blockstream * add blockstream * naming * reduce verbosity * reduce verbosity * tests fixed * spacing * spacing * final * working listing * unit tests done * done * revert blockstream * bdk v0.1.5 * dedupe * do not export mappings * lint * lint * embed icons * icons * move to file --- .../images/icon-signet.svg | 1 - .../images/icon-testnet.svg | 1 - .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/config.ts | 2 +- .../src/handlers/AssetsHandler.ts | 24 +++++++------------ .../bitcoin-wallet-snap/src/handlers/icons.ts | 14 +++++++++++ .../src/infra/jsx/images/bitcoin.svg | 15 +----------- 7 files changed, 26 insertions(+), 33 deletions(-) delete mode 100644 merged-packages/bitcoin-wallet-snap/images/icon-signet.svg delete mode 100644 merged-packages/bitcoin-wallet-snap/images/icon-testnet.svg create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/icons.ts diff --git a/merged-packages/bitcoin-wallet-snap/images/icon-signet.svg b/merged-packages/bitcoin-wallet-snap/images/icon-signet.svg deleted file mode 100644 index cb0c6cb2..00000000 --- a/merged-packages/bitcoin-wallet-snap/images/icon-signet.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/images/icon-testnet.svg b/merged-packages/bitcoin-wallet-snap/images/icon-testnet.svg deleted file mode 100644 index 336e8600..00000000 --- a/merged-packages/bitcoin-wallet-snap/images/icon-testnet.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index f1d9798e..05383844 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "LBPhtxfMaueoIIKI21LD4eKryhMFqhzJqqLAzdEkrEI=", + "shasum": "KelONDTkYHwFjGg2PmD+7Wz2gxRal2plwlU4JqowHg0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 84284497..abc3cfed 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -14,7 +14,7 @@ export const Config: SnapConfig = { }, chain: { parallelRequests: 1, - stopGap: 10, + stopGap: 3, url: { bitcoin: process.env.ESPLORA_BITCOIN ?? 'https://blockstream.info/api', testnet: diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts index c592d2d1..11aefb68 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts @@ -6,9 +6,11 @@ import type { OnAssetsConversionResponse, OnAssetsLookupResponse, } from '@metamask/snaps-sdk'; +import type { Network } from 'bitcoindevkit'; import type { AssetsUseCases } from '../use-cases'; import { Caip19Asset } from './caip'; +import { networkToIcon } from './icons'; export class AssetsHandler { readonly #assetsUseCases: AssetsUseCases; @@ -22,9 +24,9 @@ export class AssetsHandler { lookup(): OnAssetsLookupResponse { const metadata = ( + network: Network, name: string, mainSymbol: string, - icon: string, ): FungibleAssetMetadata => { return { fungible: true, @@ -56,7 +58,7 @@ export class AssetsHandler { symbol: 'satoshi', }, ], - iconUrl: `./images/${icon}.svg`, + iconUrl: networkToIcon[network], symbol: '₿', }; }; @@ -64,23 +66,15 @@ export class AssetsHandler { // Use the same denominations as Bitcoin for testnets but change the name and main unit symbol return { assets: { - [Caip19Asset.Bitcoin]: metadata('Bitcoin', 'BTC', 'icon'), - [Caip19Asset.Testnet]: metadata( - 'Testnet Bitcoin', - 'tBTC', - 'icon-testnet', - ), + [Caip19Asset.Bitcoin]: metadata('bitcoin', 'Bitcoin', 'BTC'), + [Caip19Asset.Testnet]: metadata('testnet', 'Testnet Bitcoin', 'tBTC'), [Caip19Asset.Testnet4]: metadata( + 'testnet4', 'Testnet4 Bitcoin', 'tBTC', - 'icon-testnet', - ), - [Caip19Asset.Signet]: metadata('Signet Bitcoin', 'sBTC', 'icon-signet'), - [Caip19Asset.Regtest]: metadata( - 'Regtest Bitcoin', - 'rBTC', - 'icon-signet', ), + [Caip19Asset.Signet]: metadata('signet', 'Signet Bitcoin', 'sBTC'), + [Caip19Asset.Regtest]: metadata('regtest', 'Regtest Bitcoin', 'rBTC'), }, }; } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/icons.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/icons.ts new file mode 100644 index 00000000..ae9dc90e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/icons.ts @@ -0,0 +1,14 @@ +import type { Network } from 'bitcoindevkit'; + +export const networkToIcon: Record = { + bitcoin: + 'data:image/svg+xml;base64,PHN2ZyB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHZpZXdCb3g9IjAgMCA2NSA2NSIgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIiBjbGFzcz0ibmctc3Rhci1pbnNlcnRlZCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMC4wMDYzMDg3NiwtMC4wMDMwMTk4NCkiPjxwYXRoIGQ9Im02My4wMzMsMzkuNzQ0Yy00LjI3NCwxNy4xNDMtMjEuNjM3LDI3LjU3Ni0zOC43ODIsMjMuMzAxLTE3LjEzOC00LjI3NC0yNy41NzEtMjEuNjM4LTIzLjI5NS0zOC43OCw0LjI3Mi0xNy4xNDUsMjEuNjM1LTI3LjU3OSwzOC43NzUtMjMuMzA1LDE3LjE0NCw0LjI3NCwyNy41NzYsMjEuNjQsMjMuMzAyLDM4Ljc4NHoiIGZpbGw9IiNmNzkzMWEiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJtNDYuMTAzLDI3LjQ0NGMwLjYzNy00LjI1OC0yLjYwNS02LjU0Ny03LjAzOC04LjA3NGwxLjQzOC01Ljc2OC0zLjUxMS0wLjg3NS0xLjQsNS42MTZjLTAuOTIzLTAuMjMtMS44NzEtMC40NDctMi44MTMtMC42NjJsMS40MS01LjY1My0zLjUwOS0wLjg3NS0xLjQzOSw1Ljc2NmMtMC43NjQtMC4xNzQtMS41MTQtMC4zNDYtMi4yNDItMC41MjdsMC4wMDQtMC4wMTgtNC44NDItMS4yMDktMC45MzQsMy43NXMyLjYwNSwwLjU5NywyLjU1LDAuNjM0YzEuNDIyLDAuMzU1LDEuNjc5LDEuMjk2LDEuNjM2LDIuMDQybC0xLjYzOCw2LjU3MWMwLjA5OCwwLjAyNSwwLjIyNSwwLjA2MSwwLjM2NSwwLjExNy0wLjExNy0wLjAyOS0wLjI0Mi0wLjA2MS0wLjM3MS0wLjA5MmwtMi4yOTYsOS4yMDVjLTAuMTc0LDAuNDMyLTAuNjE1LDEuMDgtMS42MDksMC44MzQsMC4wMzUsMC4wNTEtMi41NTItMC42MzctMi41NTItMC42MzdsLTEuNzQzLDQuMDE5LDQuNTY5LDEuMTM5YzAuODUsMC4yMTMsMS42ODMsMC40MzYsMi41MDMsMC42NDZsLTEuNDUzLDUuODM0LDMuNTA3LDAuODc1LDEuNDM5LTUuNzcyYzAuOTU4LDAuMjYsMS44ODgsMC41LDIuNzk4LDAuNzI2bC0xLjQzNCw1Ljc0NSwzLjUxMSwwLjg3NSwxLjQ1My01LjgyM2M1Ljk4NywxLjEzMywxMC40ODksMC42NzYsMTIuMzg0LTQuNzM5LDEuNTI3LTQuMzYtMC4wNzYtNi44NzUtMy4yMjYtOC41MTUsMi4yOTQtMC41MjksNC4wMjItMi4wMzgsNC40ODMtNS4xNTV6bS04LjAyMiwxMS4yNDljLTEuMDg1LDQuMzYtOC40MjYsMi4wMDMtMTAuODA2LDEuNDEybDEuOTI4LTcuNzI5YzIuMzgsMC41OTQsMTAuMDEyLDEuNzcsOC44NzgsNi4zMTd6bTEuMDg2LTExLjMxMmMtMC45OSwzLjk2Ni03LjEsMS45NTEtOS4wODIsMS40NTdsMS43NDgtNy4wMWMxLjk4MiwwLjQ5NCw4LjM2NSwxLjQxNiw3LjMzNCw1LjU1M3oiPjwvcGF0aD48L2c+PC9zdmc+', + testnet: + 'data:image/svg+xml;base64,PHN2ZyB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHZpZXdCb3g9IjAgMCA2NSA2NSIgd2lkdGg9IjIyIiBoZWlnaHQ9IjIyIiBjbGFzcz0ibmctc3Rhci1pbnNlcnRlZCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMC4wMDYzMDg3NiwtMC4wMDMwMTk4NCkiPjxwYXRoIGQ9Im02My4wMzMsMzkuNzQ0Yy00LjI3NCwxNy4xNDMtMjEuNjM3LDI3LjU3Ni0zOC43ODIsMjMuMzAxLTE3LjEzOC00LjI3NC0yNy41NzEtMjEuNjM4LTIzLjI5NS0zOC43OCw0LjI3Mi0xNy4xNDUsMjEuNjM1LTI3LjU3OSwzOC43NzUtMjMuMzA1LDE3LjE0NCw0LjI3NCwyNy41NzYsMjEuNjQsMjMuMzAyLDM4Ljc4NHoiIGZpbGw9IiM1ZmQxNWMiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJtNDYuMTAzLDI3LjQ0NGMwLjYzNy00LjI1OC0yLjYwNS02LjU0Ny03LjAzOC04LjA3NGwxLjQzOC01Ljc2OC0zLjUxMS0wLjg3NS0xLjQsNS42MTZjLTAuOTIzLTAuMjMtMS44NzEtMC40NDctMi44MTMtMC42NjJsMS40MS01LjY1My0zLjUwOS0wLjg3NS0xLjQzOSw1Ljc2NmMtMC43NjQtMC4xNzQtMS41MTQtMC4zNDYtMi4yNDItMC41MjdsMC4wMDQtMC4wMTgtNC44NDItMS4yMDktMC45MzQsMy43NXMyLjYwNSwwLjU5NywyLjU1LDAuNjM0YzEuNDIyLDAuMzU1LDEuNjc5LDEuMjk2LDEuNjM2LDIuMDQybC0xLjYzOCw2LjU3MWMwLjA5OCwwLjAyNSwwLjIyNSwwLjA2MSwwLjM2NSwwLjExNy0wLjExNy0wLjAyOS0wLjI0Mi0wLjA2MS0wLjM3MS0wLjA5MmwtMi4yOTYsOS4yMDVjLTAuMTc0LDAuNDMyLTAuNjE1LDEuMDgtMS42MDksMC44MzQsMC4wMzUsMC4wNTEtMi41NTItMC42MzctMi41NTItMC42MzdsLTEuNzQzLDQuMDE5LDQuNTY5LDEuMTM5YzAuODUsMC4yMTMsMS42ODMsMC40MzYsMi41MDMsMC42NDZsLTEuNDUzLDUuODM0LDMuNTA3LDAuODc1LDEuNDM5LTUuNzcyYzAuOTU4LDAuMjYsMS44ODgsMC41LDIuNzk4LDAuNzI2bC0xLjQzNCw1Ljc0NSwzLjUxMSwwLjg3NSwxLjQ1My01LjgyM2M1Ljk4NywxLjEzMywxMC40ODksMC42NzYsMTIuMzg0LTQuNzM5LDEuNTI3LTQuMzYtMC4wNzYtNi44NzUtMy4yMjYtOC41MTUsMi4yOTQtMC41MjksNC4wMjItMi4wMzgsNC40ODMtNS4xNTV6bS04LjAyMiwxMS4yNDljLTEuMDg1LDQuMzYtOC40MjYsMi4wMDMtMTAuODA2LDEuNDEybDEuOTI4LTcuNzI5YzIuMzgsMC41OTQsMTAuMDEyLDEuNzcsOC44NzgsNi4zMTd6bTEuMDg2LTExLjMxMmMtMC45OSwzLjk2Ni03LjEsMS45NTEtOS4wODIsMS40NTdsMS43NDgtNy4wMWMxLjk4MiwwLjQ5NCw4LjM2NSwxLjQxNiw3LjMzNCw1LjU1M3oiPjwvcGF0aD48L2c+PC9zdmc+', + testnet4: + 'data:image/svg+xml;base64,PHN2ZyB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHZpZXdCb3g9IjAgMCA2NSA2NSIgd2lkdGg9IjIyIiBoZWlnaHQ9IjIyIiBjbGFzcz0ibmctc3Rhci1pbnNlcnRlZCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMC4wMDYzMDg3NiwtMC4wMDMwMTk4NCkiPjxwYXRoIGQ9Im02My4wMzMsMzkuNzQ0Yy00LjI3NCwxNy4xNDMtMjEuNjM3LDI3LjU3Ni0zOC43ODIsMjMuMzAxLTE3LjEzOC00LjI3NC0yNy41NzEtMjEuNjM4LTIzLjI5NS0zOC43OCw0LjI3Mi0xNy4xNDUsMjEuNjM1LTI3LjU3OSwzOC43NzUtMjMuMzA1LDE3LjE0NCw0LjI3NCwyNy41NzYsMjEuNjQsMjMuMzAyLDM4Ljc4NHoiIGZpbGw9IiM1ZmQxNWMiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJtNDYuMTAzLDI3LjQ0NGMwLjYzNy00LjI1OC0yLjYwNS02LjU0Ny03LjAzOC04LjA3NGwxLjQzOC01Ljc2OC0zLjUxMS0wLjg3NS0xLjQsNS42MTZjLTAuOTIzLTAuMjMtMS44NzEtMC40NDctMi44MTMtMC42NjJsMS40MS01LjY1My0zLjUwOS0wLjg3NS0xLjQzOSw1Ljc2NmMtMC43NjQtMC4xNzQtMS41MTQtMC4zNDYtMi4yNDItMC41MjdsMC4wMDQtMC4wMTgtNC44NDItMS4yMDktMC45MzQsMy43NXMyLjYwNSwwLjU5NywyLjU1LDAuNjM0YzEuNDIyLDAuMzU1LDEuNjc5LDEuMjk2LDEuNjM2LDIuMDQybC0xLjYzOCw2LjU3MWMwLjA5OCwwLjAyNSwwLjIyNSwwLjA2MSwwLjM2NSwwLjExNy0wLjExNy0wLjAyOS0wLjI0Mi0wLjA2MS0wLjM3MS0wLjA5MmwtMi4yOTYsOS4yMDVjLTAuMTc0LDAuNDMyLTAuNjE1LDEuMDgtMS42MDksMC44MzQsMC4wMzUsMC4wNTEtMi41NTItMC42MzctMi41NTItMC42MzdsLTEuNzQzLDQuMDE5LDQuNTY5LDEuMTM5YzAuODUsMC4yMTMsMS42ODMsMC40MzYsMi41MDMsMC42NDZsLTEuNDUzLDUuODM0LDMuNTA3LDAuODc1LDEuNDM5LTUuNzcyYzAuOTU4LDAuMjYsMS44ODgsMC41LDIuNzk4LDAuNzI2bC0xLjQzNCw1Ljc0NSwzLjUxMSwwLjg3NSwxLjQ1My01LjgyM2M1Ljk4NywxLjEzMywxMC40ODksMC42NzYsMTIuMzg0LTQuNzM5LDEuNTI3LTQuMzYtMC4wNzYtNi44NzUtMy4yMjYtOC41MTUsMi4yOTQtMC41MjksNC4wMjItMi4wMzgsNC40ODMtNS4xNTV6bS04LjAyMiwxMS4yNDljLTEuMDg1LDQuMzYtOC40MjYsMi4wMDMtMTAuODA2LDEuNDEybDEuOTI4LTcuNzI5YzIuMzgsMC41OTQsMTAuMDEyLDEuNzcsOC44NzgsNi4zMTd6bTEuMDg2LTExLjMxMmMtMC45OSwzLjk2Ni03LjEsMS45NTEtOS4wODIsMS40NTdsMS43NDgtNy4wMWMxLjk4MiwwLjQ5NCw4LjM2NSwxLjQxNiw3LjMzNCw1LjU1M3oiPjwvcGF0aD48L2c+PC9zdmc+', + signet: + 'data:image/svg+xml;base64,PHN2ZyB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHZpZXdCb3g9IjAgMCA2NSA2NSIgd2lkdGg9IjIyIiBoZWlnaHQ9IjIyIiBjbGFzcz0ibmctc3Rhci1pbnNlcnRlZCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMC4wMDYzMDg3NiwtMC4wMDMwMTk4NCkiPjxwYXRoIGQ9Im02My4wMzMsMzkuNzQ0Yy00LjI3NCwxNy4xNDMtMjEuNjM3LDI3LjU3Ni0zOC43ODIsMjMuMzAxLTE3LjEzOC00LjI3NC0yNy41NzEtMjEuNjM4LTIzLjI5NS0zOC43OCw0LjI3Mi0xNy4xNDUsMjEuNjM1LTI3LjU3OSwzOC43NzUtMjMuMzA1LDE3LjE0NCw0LjI3NCwyNy41NzYsMjEuNjQsMjMuMzAyLDM4Ljc4NHoiIGZpbGw9IiNiMDI4YWEiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJtNDYuMTAzLDI3LjQ0NGMwLjYzNy00LjI1OC0yLjYwNS02LjU0Ny03LjAzOC04LjA3NGwxLjQzOC01Ljc2OC0zLjUxMS0wLjg3NS0xLjQsNS42MTZjLTAuOTIzLTAuMjMtMS44NzEtMC40NDctMi44MTMtMC42NjJsMS40MS01LjY1My0zLjUwOS0wLjg3NS0xLjQzOSw1Ljc2NmMtMC43NjQtMC4xNzQtMS41MTQtMC4zNDYtMi4yNDItMC41MjdsMC4wMDQtMC4wMTgtNC44NDItMS4yMDktMC45MzQsMy43NXMyLjYwNSwwLjU5NywyLjU1LDAuNjM0YzEuNDIyLDAuMzU1LDEuNjc5LDEuMjk2LDEuNjM2LDIuMDQybC0xLjYzOCw2LjU3MWMwLjA5OCwwLjAyNSwwLjIyNSwwLjA2MSwwLjM2NSwwLjExNy0wLjExNy0wLjAyOS0wLjI0Mi0wLjA2MS0wLjM3MS0wLjA5MmwtMi4yOTYsOS4yMDVjLTAuMTc0LDAuNDMyLTAuNjE1LDEuMDgtMS42MDksMC44MzQsMC4wMzUsMC4wNTEtMi41NTItMC42MzctMi41NTItMC42MzdsLTEuNzQzLDQuMDE5LDQuNTY5LDEuMTM5YzAuODUsMC4yMTMsMS42ODMsMC40MzYsMi41MDMsMC42NDZsLTEuNDUzLDUuODM0LDMuNTA3LDAuODc1LDEuNDM5LTUuNzcyYzAuOTU4LDAuMjYsMS44ODgsMC41LDIuNzk4LDAuNzI2bC0xLjQzNCw1Ljc0NSwzLjUxMSwwLjg3NSwxLjQ1My01LjgyM2M1Ljk4NywxLjEzMywxMC40ODksMC42NzYsMTIuMzg0LTQuNzM5LDEuNTI3LTQuMzYtMC4wNzYtNi44NzUtMy4yMjYtOC41MTUsMi4yOTQtMC41MjksNC4wMjItMi4wMzgsNC40ODMtNS4xNTV6bS04LjAyMiwxMS4yNDljLTEuMDg1LDQuMzYtOC40MjYsMi4wMDMtMTAuODA2LDEuNDEybDEuOTI4LTcuNzI5YzIuMzgsMC41OTQsMTAuMDEyLDEuNzcsOC44NzgsNi4zMTd6bTEuMDg2LTExLjMxMmMtMC45OSwzLjk2Ni03LjEsMS45NTEtOS4wODIsMS40NTdsMS43NDgtNy4wMWMxLjk4MiwwLjQ5NCw4LjM2NSwxLjQxNiw3LjMzNCw1LjU1M3oiPjwvcGF0aD48L2c+PC9zdmc+', + regtest: + 'data:image/svg+xml;base64,PHN2ZyB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHZpZXdCb3g9IjAgMCA2NSA2NSIgd2lkdGg9IjIyIiBoZWlnaHQ9IjIyIiBjbGFzcz0ibmctc3Rhci1pbnNlcnRlZCI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMC4wMDYzMDg3NiwtMC4wMDMwMTk4NCkiPjxwYXRoIGQ9Im02My4wMzMsMzkuNzQ0Yy00LjI3NCwxNy4xNDMtMjEuNjM3LDI3LjU3Ni0zOC43ODIsMjMuMzAxLTE3LjEzOC00LjI3NC0yNy41NzEtMjEuNjM4LTIzLjI5NS0zOC43OCw0LjI3Mi0xNy4xNDUsMjEuNjM1LTI3LjU3OSwzOC43NzUtMjMuMzA1LDE3LjE0NCw0LjI3NCwyNy41NzYsMjEuNjQsMjMuMzAyLDM4Ljc4NHoiIGZpbGw9IiNiMDI4YWEiPjwvcGF0aD48cGF0aCBmaWxsPSIjRkZGIiBkPSJtNDYuMTAzLDI3LjQ0NGMwLjYzNy00LjI1OC0yLjYwNS02LjU0Ny03LjAzOC04LjA3NGwxLjQzOC01Ljc2OC0zLjUxMS0wLjg3NS0xLjQsNS42MTZjLTAuOTIzLTAuMjMtMS44NzEtMC40NDctMi44MTMtMC42NjJsMS40MS01LjY1My0zLjUwOS0wLjg3NS0xLjQzOSw1Ljc2NmMtMC43NjQtMC4xNzQtMS41MTQtMC4zNDYtMi4yNDItMC41MjdsMC4wMDQtMC4wMTgtNC44NDItMS4yMDktMC45MzQsMy43NXMyLjYwNSwwLjU5NywyLjU1LDAuNjM0YzEuNDIyLDAuMzU1LDEuNjc5LDEuMjk2LDEuNjM2LDIuMDQybC0xLjYzOCw2LjU3MWMwLjA5OCwwLjAyNSwwLjIyNSwwLjA2MSwwLjM2NSwwLjExNy0wLjExNy0wLjAyOS0wLjI0Mi0wLjA2MS0wLjM3MS0wLjA5MmwtMi4yOTYsOS4yMDVjLTAuMTc0LDAuNDMyLTAuNjE1LDEuMDgtMS42MDksMC44MzQsMC4wMzUsMC4wNTEtMi41NTItMC42MzctMi41NTItMC42MzdsLTEuNzQzLDQuMDE5LDQuNTY5LDEuMTM5YzAuODUsMC4yMTMsMS42ODMsMC40MzYsMi41MDMsMC42NDZsLTEuNDUzLDUuODM0LDMuNTA3LDAuODc1LDEuNDM5LTUuNzcyYzAuOTU4LDAuMjYsMS44ODgsMC41LDIuNzk4LDAuNzI2bC0xLjQzNCw1Ljc0NSwzLjUxMSwwLjg3NSwxLjQ1My01LjgyM2M1Ljk4NywxLjEzMywxMC40ODksMC42NzYsMTIuMzg0LTQuNzM5LDEuNTI3LTQuMzYtMC4wNzYtNi44NzUtMy4yMjYtOC41MTUsMi4yOTQtMC41MjksNC4wMjItMi4wMzgsNC40ODMtNS4xNTV6bS04LjAyMiwxMS4yNDljLTEuMDg1LDQuMzYtOC40MjYsMi4wMDMtMTAuODA2LDEuNDEybDEuOTI4LTcuNzI5YzIuMzgsMC41OTQsMTAuMDEyLDEuNzcsOC44NzgsNi4zMTd6bTEuMDg2LTExLjMxMmMtMC45OSwzLjk2Ni03LjEsMS45NTEtOS4wODIsMS40NTdsMS43NDgtNy4wMWMxLjk4MiwwLjQ5NCw4LjM2NSwxLjQxNiw3LjMzNCw1LjU1M3oiPjwvcGF0aD48L2c+PC9zdmc+', +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin.svg b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin.svg index a3a355aa..925525ec 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin.svg +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin.svg @@ -1,14 +1 @@ - - - - - - - - - - - - - - + \ No newline at end of file From 86e22c8f6658735607ded90ca06732712a0171ce Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 19 Mar 2025 10:44:54 +0100 Subject: [PATCH 202/362] chore: increase parallel requests and reduce stop gap (#424) * add parallelRequests and reduce stop gap * manifest --- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- merged-packages/bitcoin-wallet-snap/src/config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 05383844..63fb4490 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "KelONDTkYHwFjGg2PmD+7Wz2gxRal2plwlU4JqowHg0=", + "shasum": "rf9a/FADzsc4M1RddDp61HCbgRmZJ0Bynip4OuzykJg=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index abc3cfed..04fcba25 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -13,7 +13,7 @@ export const Config: SnapConfig = { 'p2wpkh') as AddressType, }, chain: { - parallelRequests: 1, + parallelRequests: 3, stopGap: 3, url: { bitcoin: process.env.ESPLORA_BITCOIN ?? 'https://blockstream.info/api', From afcc7893a84b0f84490c08124e7b7944dc81b063 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Mar 2025 01:13:35 -0900 Subject: [PATCH 203/362] New Crowdin translations by Github Action (#403) * New Crowdin translations by Github Action * Fix JSON formatting in es-419 locale file * Fix JSON formatting in es-419 locale file --------- Co-authored-by: metamaskbot Co-authored-by: Dario Anongba Varela --- .../bitcoin-wallet-snap/locales/de-DE.json | 95 +++++++++++++++++++ .../bitcoin-wallet-snap/locales/el-GR.json | 95 +++++++++++++++++++ .../bitcoin-wallet-snap/locales/en-GB.json | 95 +++++++++++++++++++ .../bitcoin-wallet-snap/locales/es-419.json | 95 +++++++++++++++++++ .../bitcoin-wallet-snap/locales/fr-FR.json | 95 +++++++++++++++++++ .../bitcoin-wallet-snap/locales/hi-IN.json | 95 +++++++++++++++++++ .../bitcoin-wallet-snap/locales/id-ID.json | 95 +++++++++++++++++++ .../bitcoin-wallet-snap/locales/ja-JP.json | 95 +++++++++++++++++++ .../bitcoin-wallet-snap/locales/ko-KR.json | 95 +++++++++++++++++++ .../bitcoin-wallet-snap/locales/pt-BR.json | 95 +++++++++++++++++++ .../bitcoin-wallet-snap/locales/ru-RU.json | 95 +++++++++++++++++++ .../bitcoin-wallet-snap/locales/tl-PH.json | 95 +++++++++++++++++++ .../bitcoin-wallet-snap/locales/tr-TR.json | 95 +++++++++++++++++++ .../bitcoin-wallet-snap/locales/vi-VN.json | 95 +++++++++++++++++++ .../bitcoin-wallet-snap/locales/zh-CN.json | 95 +++++++++++++++++++ 15 files changed, 1425 insertions(+) create mode 100644 merged-packages/bitcoin-wallet-snap/locales/de-DE.json create mode 100644 merged-packages/bitcoin-wallet-snap/locales/el-GR.json create mode 100644 merged-packages/bitcoin-wallet-snap/locales/en-GB.json create mode 100644 merged-packages/bitcoin-wallet-snap/locales/es-419.json create mode 100644 merged-packages/bitcoin-wallet-snap/locales/fr-FR.json create mode 100644 merged-packages/bitcoin-wallet-snap/locales/hi-IN.json create mode 100644 merged-packages/bitcoin-wallet-snap/locales/id-ID.json create mode 100644 merged-packages/bitcoin-wallet-snap/locales/ja-JP.json create mode 100644 merged-packages/bitcoin-wallet-snap/locales/ko-KR.json create mode 100644 merged-packages/bitcoin-wallet-snap/locales/pt-BR.json create mode 100644 merged-packages/bitcoin-wallet-snap/locales/ru-RU.json create mode 100644 merged-packages/bitcoin-wallet-snap/locales/tl-PH.json create mode 100644 merged-packages/bitcoin-wallet-snap/locales/tr-TR.json create mode 100644 merged-packages/bitcoin-wallet-snap/locales/vi-VN.json create mode 100644 merged-packages/bitcoin-wallet-snap/locales/zh-CN.json diff --git a/merged-packages/bitcoin-wallet-snap/locales/de-DE.json b/merged-packages/bitcoin-wallet-snap/locales/de-DE.json new file mode 100644 index 00000000..e3a4090d --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/de-DE.json @@ -0,0 +1,95 @@ +{ + "locale": "de", + "messages": { + "snapDescription": { + "message": "Verwaltung von Bitcoin mit MetaMask" + }, + "snapProposedName": { + "message": "Bitcoin" + }, + "reviewTransactionWarning": { + "message": "Überprüfen Sie die Transaktion, bevor Sie fortfahren" + }, + "loading": { + "message": "Wird geladen" + }, + "from": { + "message": "Von" + }, + "toAddress": { + "message": "To Address" + }, + "fromAccount": { + "message": "Von Konto" + }, + "review": { + "message": "Überprüfung" + }, + "cancel": { + "message": "Abbrechen" + }, + "amount": { + "message": "Betrag" + }, + "balance": { + "message": "Guthaben" + }, + "recipient": { + "message": "Empfänger" + }, + "network": { + "message": "Netzwerk" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Transaktionsgeschwindigkeit" + }, + "transactionSpeedTooltip": { + "message": "Die geschätzte Transaktionsdauer" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Insgesamt" + }, + "send": { + "message": "Senden" + }, + "sending": { + "message": "Wird gesendet" + }, + "sendAmount": { + "message": "Betrag senden" + }, + "amountPlaceholder": { + "message": "Enter amount to send" + }, + "max": { + "message": "Max." + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "validAddress": { + "message": "Gültige Bitcoin-Adresse" + }, + "preparingTransaction": { + "message": "Transaktion wird vorbereitet" + }, + "error": { + "message": "Fehler" + }, + "satProtectionTooltip": { + "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/el-GR.json b/merged-packages/bitcoin-wallet-snap/locales/el-GR.json new file mode 100644 index 00000000..1e6f8d72 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/el-GR.json @@ -0,0 +1,95 @@ +{ + "locale": "el", + "messages": { + "snapDescription": { + "message": "Διαχειριστείτε τα Bitcoins χρησιμοποιώντας το MetaMask" + }, + "snapProposedName": { + "message": "Bitcoin" + }, + "reviewTransactionWarning": { + "message": "Ελέγξτε τη συναλλαγή πριν προχωρήσετε" + }, + "loading": { + "message": "Φόρτωση" + }, + "from": { + "message": "Από" + }, + "toAddress": { + "message": "To Address" + }, + "fromAccount": { + "message": "Από λογαριασμό" + }, + "review": { + "message": "Έλεγχος" + }, + "cancel": { + "message": "Άκυρο" + }, + "amount": { + "message": "Ποσό" + }, + "balance": { + "message": "Υπόλοιπο" + }, + "recipient": { + "message": "Παραλήπτης" + }, + "network": { + "message": "Δίκτυο" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Ταχύτητα συναλλαγής" + }, + "transactionSpeedTooltip": { + "message": "Ο εκτιμώμενος χρόνος της συναλλαγής" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Σύνολο" + }, + "send": { + "message": "Εστάλη" + }, + "sending": { + "message": "Αποστολή" + }, + "sendAmount": { + "message": "Ποσό προς αποστολή" + }, + "amountPlaceholder": { + "message": "Enter amount to send" + }, + "max": { + "message": "Μεγ" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "validAddress": { + "message": "Έγκυρη διεύθυνση των bitcoins" + }, + "preparingTransaction": { + "message": "Προετοιμασία συναλλαγής" + }, + "error": { + "message": "Σφάλμα" + }, + "satProtectionTooltip": { + "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/en-GB.json b/merged-packages/bitcoin-wallet-snap/locales/en-GB.json new file mode 100644 index 00000000..7b93a3c9 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/en-GB.json @@ -0,0 +1,95 @@ +{ + "locale": "en", + "messages": { + "snapDescription": { + "message": "Manage Bitcoin using MetaMask" + }, + "snapProposedName": { + "message": "Bitcoin" + }, + "reviewTransactionWarning": { + "message": "Review the transaction before proceeding" + }, + "loading": { + "message": "Loading" + }, + "from": { + "message": "From" + }, + "toAddress": { + "message": "To Address" + }, + "fromAccount": { + "message": "From Account" + }, + "review": { + "message": "Review" + }, + "cancel": { + "message": "Cancel" + }, + "amount": { + "message": "Amount" + }, + "balance": { + "message": "Balance" + }, + "recipient": { + "message": "Recipient" + }, + "network": { + "message": "Network" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Transaction Speed" + }, + "transactionSpeedTooltip": { + "message": "The estimated time of the transaction" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Total" + }, + "send": { + "message": "Send" + }, + "sending": { + "message": "Sending" + }, + "sendAmount": { + "message": "Send Amount" + }, + "amountPlaceholder": { + "message": "Enter amount to send" + }, + "max": { + "message": "Max" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "validAddress": { + "message": "Valid bitcoin address" + }, + "preparingTransaction": { + "message": "Preparing transaction" + }, + "error": { + "message": "Error" + }, + "satProtectionTooltip": { + "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/es-419.json b/merged-packages/bitcoin-wallet-snap/locales/es-419.json new file mode 100644 index 00000000..f7e1cb21 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/es-419.json @@ -0,0 +1,95 @@ +{ + "locale": "es", + "messages": { + "snapDescription": { + "message": "Gestione Bitcoin con MetaMask" + }, + "snapProposedName": { + "message": "Bitcoin" + }, + "reviewTransactionWarning": { + "message": "Revise la transacción antes de continuar" + }, + "loading": { + "message": "Cargando" + }, + "from": { + "message": "De" + }, + "toAddress": { + "message": "To Address" + }, + "fromAccount": { + "message": "Desde la cuenta" + }, + "review": { + "message": "Revisar" + }, + "cancel": { + "message": "Cancelar" + }, + "amount": { + "message": "Monto" + }, + "balance": { + "message": "Saldo" + }, + "recipient": { + "message": "Destinatario" + }, + "network": { + "message": "Red" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Velocidad de la transacción" + }, + "transactionSpeedTooltip": { + "message": "El tiempo estimado de la transacción" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Total" + }, + "send": { + "message": "Enviar" + }, + "sending": { + "message": "Enviando" + }, + "sendAmount": { + "message": "Enviar monto" + }, + "amountPlaceholder": { + "message": "Enter amount to send" + }, + "max": { + "message": "Máx." + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "validAddress": { + "message": "Dirección de bitcoin válida" + }, + "preparingTransaction": { + "message": "Preparando la transacción" + }, + "error": { + "message": "Error" + }, + "satProtectionTooltip": { + "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/fr-FR.json b/merged-packages/bitcoin-wallet-snap/locales/fr-FR.json new file mode 100644 index 00000000..0ce8348e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/fr-FR.json @@ -0,0 +1,95 @@ +{ + "locale": "fr", + "messages": { + "snapDescription": { + "message": "Gérer les bitcoins avec MetaMask" + }, + "snapProposedName": { + "message": "Bitcoin" + }, + "reviewTransactionWarning": { + "message": "Vérifiez la transaction avant de continuer" + }, + "loading": { + "message": "Chargement" + }, + "from": { + "message": "De" + }, + "toAddress": { + "message": "To Address" + }, + "fromAccount": { + "message": "Depuis le compte" + }, + "review": { + "message": "Vérifier" + }, + "cancel": { + "message": "Annuler" + }, + "amount": { + "message": "Montant" + }, + "balance": { + "message": "Solde" + }, + "recipient": { + "message": "Destinataire" + }, + "network": { + "message": "Réseau" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Vitesse de transaction" + }, + "transactionSpeedTooltip": { + "message": "Heure estimée de la transaction" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Total" + }, + "send": { + "message": "Envoyer" + }, + "sending": { + "message": "Envoi" + }, + "sendAmount": { + "message": "Montant à envoyer" + }, + "amountPlaceholder": { + "message": "Enter amount to send" + }, + "max": { + "message": "Max." + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "validAddress": { + "message": "Adresse bitcoin valide" + }, + "preparingTransaction": { + "message": "Préparation de la transaction" + }, + "error": { + "message": "Erreur" + }, + "satProtectionTooltip": { + "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/hi-IN.json b/merged-packages/bitcoin-wallet-snap/locales/hi-IN.json new file mode 100644 index 00000000..0baf0117 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/hi-IN.json @@ -0,0 +1,95 @@ +{ + "locale": "hi", + "messages": { + "snapDescription": { + "message": "MetaMask का उपयोग करके बिटकॉइन को मैनेज करें" + }, + "snapProposedName": { + "message": "बिटकॉइन" + }, + "reviewTransactionWarning": { + "message": "आगे बढ़ने से पहले ट्रांसेक्शन की समीक्षा करें" + }, + "loading": { + "message": "लोड हो रहा है" + }, + "from": { + "message": "इनके द्वारा" + }, + "toAddress": { + "message": "To Address" + }, + "fromAccount": { + "message": "अकाउंट से" + }, + "review": { + "message": "समीक्षा करें" + }, + "cancel": { + "message": "कैंसिल करें" + }, + "amount": { + "message": "अमाउंट" + }, + "balance": { + "message": "बैलेंस" + }, + "recipient": { + "message": "प्राप्तकर्ता" + }, + "network": { + "message": "नेटवर्क" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "ट्रांसेक्शन की गति" + }, + "transactionSpeedTooltip": { + "message": "ट्रांसेक्शन का अनुमानित समय" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "कुल" + }, + "send": { + "message": "भेजें" + }, + "sending": { + "message": "भेजा जा रहा है" + }, + "sendAmount": { + "message": "अमाउंट भेजें" + }, + "amountPlaceholder": { + "message": "Enter amount to send" + }, + "max": { + "message": "अधिकतम" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "validAddress": { + "message": "वैध बिटकॉइन एड्रेस" + }, + "preparingTransaction": { + "message": "ट्रांसेक्शन तैयार किया जा रहा है" + }, + "error": { + "message": "एरर" + }, + "satProtectionTooltip": { + "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/id-ID.json b/merged-packages/bitcoin-wallet-snap/locales/id-ID.json new file mode 100644 index 00000000..9c0e3c67 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/id-ID.json @@ -0,0 +1,95 @@ +{ + "locale": "id", + "messages": { + "snapDescription": { + "message": "Kelola Bitcoin menggunakan MetaMask" + }, + "snapProposedName": { + "message": "Bitcoin" + }, + "reviewTransactionWarning": { + "message": "Tinjau transaksi sebelum melanjutkan" + }, + "loading": { + "message": "Memuat" + }, + "from": { + "message": "Dari" + }, + "toAddress": { + "message": "To Address" + }, + "fromAccount": { + "message": "Dari Akun" + }, + "review": { + "message": "Tinjau" + }, + "cancel": { + "message": "Batal" + }, + "amount": { + "message": "Jumlah" + }, + "balance": { + "message": "Saldo" + }, + "recipient": { + "message": "Penerima" + }, + "network": { + "message": "Jaringan" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Kecepatan Transaksi" + }, + "transactionSpeedTooltip": { + "message": "Estimasi waktu transaksi" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Total" + }, + "send": { + "message": "Kirim" + }, + "sending": { + "message": "Mengirim" + }, + "sendAmount": { + "message": "Kirim Jumlah" + }, + "amountPlaceholder": { + "message": "Enter amount to send" + }, + "max": { + "message": "Maks" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "validAddress": { + "message": "Alamat bitcoin yang valid" + }, + "preparingTransaction": { + "message": "Mempersiapkan transaksi" + }, + "error": { + "message": "Kesalahan" + }, + "satProtectionTooltip": { + "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/ja-JP.json b/merged-packages/bitcoin-wallet-snap/locales/ja-JP.json new file mode 100644 index 00000000..9a21891d --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/ja-JP.json @@ -0,0 +1,95 @@ +{ + "locale": "en", + "messages": { + "snapDescription": { + "message": "MetaMaskでビットコインを管理" + }, + "snapProposedName": { + "message": "ビットコイン" + }, + "reviewTransactionWarning": { + "message": "進める前にトランザクションを確認してください" + }, + "loading": { + "message": "読み込み中" + }, + "from": { + "message": "送金元" + }, + "toAddress": { + "message": "To Address" + }, + "fromAccount": { + "message": "送金元アカウント" + }, + "review": { + "message": "確認" + }, + "cancel": { + "message": "キャンセル" + }, + "amount": { + "message": "金額" + }, + "balance": { + "message": "残額" + }, + "recipient": { + "message": "受取人" + }, + "network": { + "message": "ネットワーク" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "トランザクションの速度" + }, + "transactionSpeedTooltip": { + "message": "トランザクションの予想時間" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "合計" + }, + "send": { + "message": "送金" + }, + "sending": { + "message": "送金中" + }, + "sendAmount": { + "message": "送金額" + }, + "amountPlaceholder": { + "message": "Enter amount to send" + }, + "max": { + "message": "最大" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "validAddress": { + "message": "有効なビットコインアドレス" + }, + "preparingTransaction": { + "message": "トランザクションを準備中" + }, + "error": { + "message": "エラー" + }, + "satProtectionTooltip": { + "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/ko-KR.json b/merged-packages/bitcoin-wallet-snap/locales/ko-KR.json new file mode 100644 index 00000000..23bcec62 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/ko-KR.json @@ -0,0 +1,95 @@ +{ + "locale": "ko", + "messages": { + "snapDescription": { + "message": "MetaMask로 비트코인 관리" + }, + "snapProposedName": { + "message": "비트코인" + }, + "reviewTransactionWarning": { + "message": "계속하기 전에 트랜잭션을 검토하세요" + }, + "loading": { + "message": "불러오는 중" + }, + "from": { + "message": "보내는 사람" + }, + "toAddress": { + "message": "To Address" + }, + "fromAccount": { + "message": "보내는 계정" + }, + "review": { + "message": "검토" + }, + "cancel": { + "message": "취소" + }, + "amount": { + "message": "금액" + }, + "balance": { + "message": "잔액" + }, + "recipient": { + "message": "받는 사람" + }, + "network": { + "message": "네트워크" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "트랜잭션 속도" + }, + "transactionSpeedTooltip": { + "message": "예상 트랜잭션 시간" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "총액" + }, + "send": { + "message": "보내기" + }, + "sending": { + "message": "보내는 중" + }, + "sendAmount": { + "message": "보낼 금액" + }, + "amountPlaceholder": { + "message": "Enter amount to send" + }, + "max": { + "message": "최대" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "validAddress": { + "message": "유효한 비트코인 주소" + }, + "preparingTransaction": { + "message": "트랜잭션 준비 중" + }, + "error": { + "message": "오류" + }, + "satProtectionTooltip": { + "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/pt-BR.json b/merged-packages/bitcoin-wallet-snap/locales/pt-BR.json new file mode 100644 index 00000000..e947b92e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/pt-BR.json @@ -0,0 +1,95 @@ +{ + "locale": "pt-BR", + "messages": { + "snapDescription": { + "message": "Gerencie Bitcoins usando MetaMask" + }, + "snapProposedName": { + "message": "Bitcoin" + }, + "reviewTransactionWarning": { + "message": "Revise a transação antes de prosseguir" + }, + "loading": { + "message": "Carregando" + }, + "from": { + "message": "De" + }, + "toAddress": { + "message": "To Address" + }, + "fromAccount": { + "message": "Da conta" + }, + "review": { + "message": "Revisar" + }, + "cancel": { + "message": "Cancelar" + }, + "amount": { + "message": "Valor" + }, + "balance": { + "message": "Saldo" + }, + "recipient": { + "message": "Destinatário" + }, + "network": { + "message": "Rede" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Velocidade da transação" + }, + "transactionSpeedTooltip": { + "message": "O tempo estimado da transação" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Total" + }, + "send": { + "message": "Enviar" + }, + "sending": { + "message": "Enviando" + }, + "sendAmount": { + "message": "Enviar valor" + }, + "amountPlaceholder": { + "message": "Enter amount to send" + }, + "max": { + "message": "Máx." + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "validAddress": { + "message": "Endereço de bitcoin válido" + }, + "preparingTransaction": { + "message": "Preparando transação" + }, + "error": { + "message": "Erro" + }, + "satProtectionTooltip": { + "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/ru-RU.json b/merged-packages/bitcoin-wallet-snap/locales/ru-RU.json new file mode 100644 index 00000000..1b43b68c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/ru-RU.json @@ -0,0 +1,95 @@ +{ + "locale": "ru", + "messages": { + "snapDescription": { + "message": "Управляйте биткойнами с помощью MetaMask" + }, + "snapProposedName": { + "message": "Биткойн" + }, + "reviewTransactionWarning": { + "message": "Проверьте транзакцию, прежде чем продолжить" + }, + "loading": { + "message": "Загрузка..." + }, + "from": { + "message": "От" + }, + "toAddress": { + "message": "To Address" + }, + "fromAccount": { + "message": "Со счета" + }, + "review": { + "message": "Просмотр" + }, + "cancel": { + "message": "Отмена" + }, + "amount": { + "message": "Сумма" + }, + "balance": { + "message": "Баланс" + }, + "recipient": { + "message": "Получатель" + }, + "network": { + "message": "Сеть" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Скорость транзакции" + }, + "transactionSpeedTooltip": { + "message": "Примерное время транзакции" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Итого" + }, + "send": { + "message": "Отправить" + }, + "sending": { + "message": "Отправка..." + }, + "sendAmount": { + "message": "Отправляемая сумма" + }, + "amountPlaceholder": { + "message": "Enter amount to send" + }, + "max": { + "message": "Макс." + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "validAddress": { + "message": "Действительный адрес биткойна" + }, + "preparingTransaction": { + "message": "Подготовка транзакции..." + }, + "error": { + "message": "Ошибка" + }, + "satProtectionTooltip": { + "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/tl-PH.json b/merged-packages/bitcoin-wallet-snap/locales/tl-PH.json new file mode 100644 index 00000000..726da283 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/tl-PH.json @@ -0,0 +1,95 @@ +{ + "locale": "tl", + "messages": { + "snapDescription": { + "message": "Pamahalaan ang Bitcoin gamit ang MetaMask" + }, + "snapProposedName": { + "message": "Bitcoin" + }, + "reviewTransactionWarning": { + "message": "Suriin ang transaksyon bago magpatuloy" + }, + "loading": { + "message": "Naglo-load" + }, + "from": { + "message": "Mula sa" + }, + "toAddress": { + "message": "To Address" + }, + "fromAccount": { + "message": "Mula sa Account" + }, + "review": { + "message": "Suriin" + }, + "cancel": { + "message": "Kanselahin" + }, + "amount": { + "message": "Halaga" + }, + "balance": { + "message": "Balanse" + }, + "recipient": { + "message": "Tatanggap" + }, + "network": { + "message": "Network" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Bilis ng Transaksyon" + }, + "transactionSpeedTooltip": { + "message": "Ang tinatayang tagal ng transaksyon" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Kabuuan" + }, + "send": { + "message": "Ipadala" + }, + "sending": { + "message": "Ipinapadala" + }, + "sendAmount": { + "message": "Ipadala ang Halaga" + }, + "amountPlaceholder": { + "message": "Enter amount to send" + }, + "max": { + "message": "Max" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "validAddress": { + "message": "Valid na address ng bitcoin" + }, + "preparingTransaction": { + "message": "Inihahanda ang transaksyon" + }, + "error": { + "message": "Error" + }, + "satProtectionTooltip": { + "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/tr-TR.json b/merged-packages/bitcoin-wallet-snap/locales/tr-TR.json new file mode 100644 index 00000000..a08757dd --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/tr-TR.json @@ -0,0 +1,95 @@ +{ + "locale": "tr", + "messages": { + "snapDescription": { + "message": "MetaMask'i kullanarak Bitcoin'i yönetin" + }, + "snapProposedName": { + "message": "Bitcoin" + }, + "reviewTransactionWarning": { + "message": "Devam etmeden önce işlemi inceleyin" + }, + "loading": { + "message": "Yükleniyor" + }, + "from": { + "message": "Gönderen" + }, + "toAddress": { + "message": "To Address" + }, + "fromAccount": { + "message": "Gönderen Hesap" + }, + "review": { + "message": "İncele" + }, + "cancel": { + "message": "İptal" + }, + "amount": { + "message": "Miktar" + }, + "balance": { + "message": "Bakiye" + }, + "recipient": { + "message": "Alıcı" + }, + "network": { + "message": "Ağ" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "İşlem Hızı" + }, + "transactionSpeedTooltip": { + "message": "Tahmini işlem süresi" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Toplam" + }, + "send": { + "message": "Gönder" + }, + "sending": { + "message": "Gönderiliyor" + }, + "sendAmount": { + "message": "Gönderilecek Miktar" + }, + "amountPlaceholder": { + "message": "Enter amount to send" + }, + "max": { + "message": "Maksimum" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "validAddress": { + "message": "Geçerli bitcoin adresi" + }, + "preparingTransaction": { + "message": "İşlem hazırlanıyor" + }, + "error": { + "message": "Hata" + }, + "satProtectionTooltip": { + "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/vi-VN.json b/merged-packages/bitcoin-wallet-snap/locales/vi-VN.json new file mode 100644 index 00000000..937507c9 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/vi-VN.json @@ -0,0 +1,95 @@ +{ + "locale": "vi", + "messages": { + "snapDescription": { + "message": "Quản lý Bitcoin bằng MetaMask" + }, + "snapProposedName": { + "message": "Bitcoin" + }, + "reviewTransactionWarning": { + "message": "Xem lại giao dịch trước khi tiếp tục" + }, + "loading": { + "message": "Đang tải" + }, + "from": { + "message": "Từ" + }, + "toAddress": { + "message": "To Address" + }, + "fromAccount": { + "message": "Từ tài khoản" + }, + "review": { + "message": "Xem lại" + }, + "cancel": { + "message": "Hủy" + }, + "amount": { + "message": "Số tiền" + }, + "balance": { + "message": "Số dư" + }, + "recipient": { + "message": "Người nhận" + }, + "network": { + "message": "Mạng" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Tốc độ giao dịch" + }, + "transactionSpeedTooltip": { + "message": "Thời gian giao dịch ước tính" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "Tổng" + }, + "send": { + "message": "Gửi" + }, + "sending": { + "message": "Đang gửi" + }, + "sendAmount": { + "message": "Số tiền gửi" + }, + "amountPlaceholder": { + "message": "Enter amount to send" + }, + "max": { + "message": "Tối đa" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "validAddress": { + "message": "Địa chỉ bitcoin hợp lệ" + }, + "preparingTransaction": { + "message": "Đang chuẩn bị giao dịch" + }, + "error": { + "message": "Lỗi" + }, + "satProtectionTooltip": { + "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/zh-CN.json b/merged-packages/bitcoin-wallet-snap/locales/zh-CN.json new file mode 100644 index 00000000..e9be25c9 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/zh-CN.json @@ -0,0 +1,95 @@ +{ + "locale": "中文", + "messages": { + "snapDescription": { + "message": "使用 MetaMask 管理比特币" + }, + "snapProposedName": { + "message": "比特币" + }, + "reviewTransactionWarning": { + "message": "继续之前,请先审查交易" + }, + "loading": { + "message": "加载中" + }, + "from": { + "message": "从" + }, + "toAddress": { + "message": "To Address" + }, + "fromAccount": { + "message": "从账户" + }, + "review": { + "message": "审查" + }, + "cancel": { + "message": "取消" + }, + "amount": { + "message": "金额" + }, + "balance": { + "message": "余额" + }, + "recipient": { + "message": "收款人" + }, + "network": { + "message": "网络" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "交易速度" + }, + "transactionSpeedTooltip": { + "message": "交易预计时间" + }, + "networkFee": { + "message": "Network Fee" + }, + "feeRate": { + "message": "Fee rate" + }, + "networkFeeTooltip": { + "message": "The total network fee" + }, + "total": { + "message": "总额" + }, + "send": { + "message": "发送" + }, + "sending": { + "message": "发送中" + }, + "sendAmount": { + "message": "发送金额" + }, + "amountPlaceholder": { + "message": "Enter amount to send" + }, + "max": { + "message": "最多" + }, + "recipientPlaceholder": { + "message": "Enter receiving address" + }, + "validAddress": { + "message": "有效的比特币地址" + }, + "preparingTransaction": { + "message": "准备交易" + }, + "error": { + "message": "错误" + }, + "satProtectionTooltip": { + "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + } + } +} From b411d4137c8bb26b239a5f3ab0e96952827acfdb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Mar 2025 12:01:25 +0100 Subject: [PATCH 204/362] 0.10.0 (#423) * 0.10.0 * Update CHANGELOG for version 0.10.0 * Update CHANGELOG for version 0.10.0 * ordering * add translations --------- Co-authored-by: github-actions Co-authored-by: Dario Anongba Varela --- .../bitcoin-wallet-snap/CHANGELOG.md | 29 ++++++++++++++++++- .../bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index c4e63dbf..10d38b54 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.0] + +### Added + +- List account transactions and assets ([#405](https://github.com/MetaMask/snap-bitcoin-wallet/pull/405), [#420](https://github.com/MetaMask/snap-bitcoin-wallet/pull/420), [#408](https://github.com/MetaMask/snap-bitcoin-wallet/pull/408)) +- Refresh rates and fees in a background loop inside the Send Flow ([#419](https://github.com/MetaMask/snap-bitcoin-wallet/pull/419)) +- On asset conversion handler ([#418](https://github.com/MetaMask/snap-bitcoin-wallet/pull/418)) +- On asset lookup handler + emission of events on balance updates ([#416](https://github.com/MetaMask/snap-bitcoin-wallet/pull/416)) +- Icons as base64 ([#422](https://github.com/MetaMask/snap-bitcoin-wallet/pull/422)) +- Synchronize Bitcoin accounts in a cron job ([#407](https://github.com/MetaMask/snap-bitcoin-wallet/pull/407)) +- Integration tests ([#382](https://github.com/MetaMask/snap-bitcoin-wallet/pull/382)) +- Translations ([#382]https://github.com/MetaMask/snap-bitcoin-wallet/pull/403) + +### Changed + +- Refactor core Bitcoin library to use the [`bitcoindevkit`](https://www.npmjs.com/package/bitcoindevkit), allowing synchronization of multiple addresses, support of multiple networks (`bitcoin`, `testnet`, `signet`, `regtest`) and address types (`p2pkh`, `p2sh`, `p2wsh`, `p2wpkh`, `p2tr`) ([#361](https://github.com/MetaMask/snap-bitcoin-wallet/pull/361), [#393](https://github.com/MetaMask/snap-bitcoin-wallet/pull/393), [#394](https://github.com/MetaMask/snap-bitcoin-wallet/pull/394), [#378](https://github.com/MetaMask/snap-bitcoin-wallet/pull/378), [#411](https://github.com/MetaMask/snap-bitcoin-wallet/pull/411), [#413](https://github.com/MetaMask/snap-bitcoin-wallet/pull/413), [#414](https://github.com/MetaMask/snap-bitcoin-wallet/pull/414)) +- Upgrade yarn to v4 ([#389](https://github.com/MetaMask/snap-bitcoin-wallet/pull/389)) + +### Removed + +- Remove unused dependencies and codebase ([#417](https://github.com/MetaMask/snap-bitcoin-wallet/pull/417)) + +### Fixed + +- Typo in word "Ordinals" ([#402](https://github.com/MetaMask/snap-bitcoin-wallet/pull/402)) + ## [0.9.0] ### Added @@ -255,7 +281,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.10.0...HEAD +[0.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.2...v0.9.0 [0.8.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.1...v0.8.2 [0.8.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.0...v0.8.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index fae1e8ce..a02ff06b 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.9.0", + "version": "0.10.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 9f6420cf7e232274d41501fddc805029defa10c4 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 19 Mar 2025 15:15:37 +0100 Subject: [PATCH 205/362] Revert "0.10.0 (#423)" (#428) This reverts commit b411d4137c8bb26b239a5f3ab0e96952827acfdb. --- .../bitcoin-wallet-snap/CHANGELOG.md | 29 +------------------ .../bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 10d38b54..c4e63dbf 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,32 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.10.0] - -### Added - -- List account transactions and assets ([#405](https://github.com/MetaMask/snap-bitcoin-wallet/pull/405), [#420](https://github.com/MetaMask/snap-bitcoin-wallet/pull/420), [#408](https://github.com/MetaMask/snap-bitcoin-wallet/pull/408)) -- Refresh rates and fees in a background loop inside the Send Flow ([#419](https://github.com/MetaMask/snap-bitcoin-wallet/pull/419)) -- On asset conversion handler ([#418](https://github.com/MetaMask/snap-bitcoin-wallet/pull/418)) -- On asset lookup handler + emission of events on balance updates ([#416](https://github.com/MetaMask/snap-bitcoin-wallet/pull/416)) -- Icons as base64 ([#422](https://github.com/MetaMask/snap-bitcoin-wallet/pull/422)) -- Synchronize Bitcoin accounts in a cron job ([#407](https://github.com/MetaMask/snap-bitcoin-wallet/pull/407)) -- Integration tests ([#382](https://github.com/MetaMask/snap-bitcoin-wallet/pull/382)) -- Translations ([#382]https://github.com/MetaMask/snap-bitcoin-wallet/pull/403) - -### Changed - -- Refactor core Bitcoin library to use the [`bitcoindevkit`](https://www.npmjs.com/package/bitcoindevkit), allowing synchronization of multiple addresses, support of multiple networks (`bitcoin`, `testnet`, `signet`, `regtest`) and address types (`p2pkh`, `p2sh`, `p2wsh`, `p2wpkh`, `p2tr`) ([#361](https://github.com/MetaMask/snap-bitcoin-wallet/pull/361), [#393](https://github.com/MetaMask/snap-bitcoin-wallet/pull/393), [#394](https://github.com/MetaMask/snap-bitcoin-wallet/pull/394), [#378](https://github.com/MetaMask/snap-bitcoin-wallet/pull/378), [#411](https://github.com/MetaMask/snap-bitcoin-wallet/pull/411), [#413](https://github.com/MetaMask/snap-bitcoin-wallet/pull/413), [#414](https://github.com/MetaMask/snap-bitcoin-wallet/pull/414)) -- Upgrade yarn to v4 ([#389](https://github.com/MetaMask/snap-bitcoin-wallet/pull/389)) - -### Removed - -- Remove unused dependencies and codebase ([#417](https://github.com/MetaMask/snap-bitcoin-wallet/pull/417)) - -### Fixed - -- Typo in word "Ordinals" ([#402](https://github.com/MetaMask/snap-bitcoin-wallet/pull/402)) - ## [0.9.0] ### Added @@ -281,8 +255,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.10.0...HEAD -[0.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...v0.10.0 +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...HEAD [0.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.2...v0.9.0 [0.8.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.1...v0.8.2 [0.8.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.0...v0.8.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index a02ff06b..fae1e8ce 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.10.0", + "version": "0.9.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 55276fca3faf00859832a38ad111efc95d2d9485 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 19 Mar 2025 16:07:12 +0100 Subject: [PATCH 206/362] fix: remove fees on receive (#427) --- .../integration-test/constants.ts | 12 +----------- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/KeyringHandler.test.ts | 2 +- .../bitcoin-wallet-snap/src/handlers/mappings.ts | 14 ++++++++------ 4 files changed, 11 insertions(+), 19 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts b/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts index 4b5b234a..6f7935b4 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts @@ -14,17 +14,7 @@ export const FUNDING_TX = { { status: 'unconfirmed', timestamp: null }, { status: 'confirmed', timestamp: expect.any(Number) }, ], - fees: [ - { - asset: { - amount: expect.any(String), - fungible: true, - type: Caip19Asset.Regtest, - unit: CurrencyUnit.Regtest, - }, - type: 'priority', - }, - ], + fees: [], from: [], id: expect.any(String), status: 'confirmed', diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 63fb4490..60bfdec3 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "rf9a/FADzsc4M1RddDp61HCbgRmZJ0Bynip4OuzykJg=", + "shasum": "hjJKrHCdEYJPHXts1YoqyT1hpYecQTolbGsBymybYHM=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 7dd9b116..a8cb8881 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -373,7 +373,7 @@ describe('KeyringHandler', () => { expect(mockAccounts.get).toHaveBeenCalledWith(id); expect(result.data).toStrictEqual([ - { ...expectedResult, type: 'receive' }, + { ...expectedResult, type: 'receive', fees: [] }, ]); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index 219503a5..d759d528 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -136,12 +136,14 @@ export function mapToTransaction( events, to: [], from: [], - fees: [ - { - type: 'priority', - asset: mapToAmount(account.calculateFee(tx), network), - }, - ], + fees: isSend + ? [ + { + type: 'priority', + asset: mapToAmount(account.calculateFee(tx), network), + }, + ] + : [], }; // If it's a Send transaction: From 3a7909c0dec2667300234b0d9067b79c61e7d3f7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Mar 2025 18:22:12 +0100 Subject: [PATCH 207/362] 0.10.0 (#429) --- .../bitcoin-wallet-snap/CHANGELOG.md | 29 ++++++++++++++++++- .../bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index c4e63dbf..f6a7b35b 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.0] + +### Added + +- List account transactions and assets ([#405](https://github.com/MetaMask/snap-bitcoin-wallet/pull/405), [#420](https://github.com/MetaMask/snap-bitcoin-wallet/pull/420), [#408](https://github.com/MetaMask/snap-bitcoin-wallet/pull/408), [#427](https://github.com/MetaMask/snap-bitcoin-wallet/pull/427)) +- Refresh rates and fees in a background loop inside the Send Flow ([#419](https://github.com/MetaMask/snap-bitcoin-wallet/pull/419)) +- On asset conversion handler ([#418](https://github.com/MetaMask/snap-bitcoin-wallet/pull/418)) +- On asset lookup handler + emission of events on balance updates ([#416](https://github.com/MetaMask/snap-bitcoin-wallet/pull/416)) +- Icons as base64 ([#422](https://github.com/MetaMask/snap-bitcoin-wallet/pull/422)) +- Synchronize Bitcoin accounts in a cron job ([#407](https://github.com/MetaMask/snap-bitcoin-wallet/pull/407)) +- Integration tests ([#382](https://github.com/MetaMask/snap-bitcoin-wallet/pull/382)) +- Translations ([#403]https://github.com/MetaMask/snap-bitcoin-wallet/pull/403) + +### Changed + +- Refactor core Bitcoin library to use the [`bitcoindevkit`](https://www.npmjs.com/package/bitcoindevkit), allowing synchronization of multiple addresses, support of multiple networks (`bitcoin`, `testnet`, `signet`, `regtest`) and address types (`p2pkh`, `p2sh`, `p2wsh`, `p2wpkh`, `p2tr`) ([#361](https://github.com/MetaMask/snap-bitcoin-wallet/pull/361), [#393](https://github.com/MetaMask/snap-bitcoin-wallet/pull/393), [#394](https://github.com/MetaMask/snap-bitcoin-wallet/pull/394), [#378](https://github.com/MetaMask/snap-bitcoin-wallet/pull/378), [#411](https://github.com/MetaMask/snap-bitcoin-wallet/pull/411), [#413](https://github.com/MetaMask/snap-bitcoin-wallet/pull/413), [#414](https://github.com/MetaMask/snap-bitcoin-wallet/pull/414)) +- Upgrade yarn to v4 ([#389](https://github.com/MetaMask/snap-bitcoin-wallet/pull/389)) + +### Removed + +- Remove unused dependencies and codebase ([#417](https://github.com/MetaMask/snap-bitcoin-wallet/pull/417)) + +### Fixed + +- Typo in word "Ordinals" ([#402](https://github.com/MetaMask/snap-bitcoin-wallet/pull/402)) + ## [0.9.0] ### Added @@ -255,7 +281,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.10.0...HEAD +[0.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.2...v0.9.0 [0.8.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.1...v0.8.2 [0.8.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.0...v0.8.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index fae1e8ce..a02ff06b 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.9.0", + "version": "0.10.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From aecf2d8e4df53d54d15fc937cdd03b7c5c8938e7 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 19 Mar 2025 22:28:06 +0100 Subject: [PATCH 208/362] Revert "0.10.0 (#429)" (#431) This reverts commit 3a7909c0dec2667300234b0d9067b79c61e7d3f7. --- .../bitcoin-wallet-snap/CHANGELOG.md | 29 +------------------ .../bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index f6a7b35b..c4e63dbf 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,32 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.10.0] - -### Added - -- List account transactions and assets ([#405](https://github.com/MetaMask/snap-bitcoin-wallet/pull/405), [#420](https://github.com/MetaMask/snap-bitcoin-wallet/pull/420), [#408](https://github.com/MetaMask/snap-bitcoin-wallet/pull/408), [#427](https://github.com/MetaMask/snap-bitcoin-wallet/pull/427)) -- Refresh rates and fees in a background loop inside the Send Flow ([#419](https://github.com/MetaMask/snap-bitcoin-wallet/pull/419)) -- On asset conversion handler ([#418](https://github.com/MetaMask/snap-bitcoin-wallet/pull/418)) -- On asset lookup handler + emission of events on balance updates ([#416](https://github.com/MetaMask/snap-bitcoin-wallet/pull/416)) -- Icons as base64 ([#422](https://github.com/MetaMask/snap-bitcoin-wallet/pull/422)) -- Synchronize Bitcoin accounts in a cron job ([#407](https://github.com/MetaMask/snap-bitcoin-wallet/pull/407)) -- Integration tests ([#382](https://github.com/MetaMask/snap-bitcoin-wallet/pull/382)) -- Translations ([#403]https://github.com/MetaMask/snap-bitcoin-wallet/pull/403) - -### Changed - -- Refactor core Bitcoin library to use the [`bitcoindevkit`](https://www.npmjs.com/package/bitcoindevkit), allowing synchronization of multiple addresses, support of multiple networks (`bitcoin`, `testnet`, `signet`, `regtest`) and address types (`p2pkh`, `p2sh`, `p2wsh`, `p2wpkh`, `p2tr`) ([#361](https://github.com/MetaMask/snap-bitcoin-wallet/pull/361), [#393](https://github.com/MetaMask/snap-bitcoin-wallet/pull/393), [#394](https://github.com/MetaMask/snap-bitcoin-wallet/pull/394), [#378](https://github.com/MetaMask/snap-bitcoin-wallet/pull/378), [#411](https://github.com/MetaMask/snap-bitcoin-wallet/pull/411), [#413](https://github.com/MetaMask/snap-bitcoin-wallet/pull/413), [#414](https://github.com/MetaMask/snap-bitcoin-wallet/pull/414)) -- Upgrade yarn to v4 ([#389](https://github.com/MetaMask/snap-bitcoin-wallet/pull/389)) - -### Removed - -- Remove unused dependencies and codebase ([#417](https://github.com/MetaMask/snap-bitcoin-wallet/pull/417)) - -### Fixed - -- Typo in word "Ordinals" ([#402](https://github.com/MetaMask/snap-bitcoin-wallet/pull/402)) - ## [0.9.0] ### Added @@ -281,8 +255,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.10.0...HEAD -[0.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...v0.10.0 +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...HEAD [0.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.2...v0.9.0 [0.8.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.1...v0.8.2 [0.8.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.0...v0.8.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index a02ff06b..fae1e8ce 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.10.0", + "version": "0.9.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 65833b390e122c94ec7cbcb3647dbaf135d08d88 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Mar 2025 12:04:02 +0100 Subject: [PATCH 209/362] 0.10.0 (#432) --- .../bitcoin-wallet-snap/CHANGELOG.md | 29 ++++++++++++++++++- .../bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index c4e63dbf..f6a7b35b 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.0] + +### Added + +- List account transactions and assets ([#405](https://github.com/MetaMask/snap-bitcoin-wallet/pull/405), [#420](https://github.com/MetaMask/snap-bitcoin-wallet/pull/420), [#408](https://github.com/MetaMask/snap-bitcoin-wallet/pull/408), [#427](https://github.com/MetaMask/snap-bitcoin-wallet/pull/427)) +- Refresh rates and fees in a background loop inside the Send Flow ([#419](https://github.com/MetaMask/snap-bitcoin-wallet/pull/419)) +- On asset conversion handler ([#418](https://github.com/MetaMask/snap-bitcoin-wallet/pull/418)) +- On asset lookup handler + emission of events on balance updates ([#416](https://github.com/MetaMask/snap-bitcoin-wallet/pull/416)) +- Icons as base64 ([#422](https://github.com/MetaMask/snap-bitcoin-wallet/pull/422)) +- Synchronize Bitcoin accounts in a cron job ([#407](https://github.com/MetaMask/snap-bitcoin-wallet/pull/407)) +- Integration tests ([#382](https://github.com/MetaMask/snap-bitcoin-wallet/pull/382)) +- Translations ([#403]https://github.com/MetaMask/snap-bitcoin-wallet/pull/403) + +### Changed + +- Refactor core Bitcoin library to use the [`bitcoindevkit`](https://www.npmjs.com/package/bitcoindevkit), allowing synchronization of multiple addresses, support of multiple networks (`bitcoin`, `testnet`, `signet`, `regtest`) and address types (`p2pkh`, `p2sh`, `p2wsh`, `p2wpkh`, `p2tr`) ([#361](https://github.com/MetaMask/snap-bitcoin-wallet/pull/361), [#393](https://github.com/MetaMask/snap-bitcoin-wallet/pull/393), [#394](https://github.com/MetaMask/snap-bitcoin-wallet/pull/394), [#378](https://github.com/MetaMask/snap-bitcoin-wallet/pull/378), [#411](https://github.com/MetaMask/snap-bitcoin-wallet/pull/411), [#413](https://github.com/MetaMask/snap-bitcoin-wallet/pull/413), [#414](https://github.com/MetaMask/snap-bitcoin-wallet/pull/414)) +- Upgrade yarn to v4 ([#389](https://github.com/MetaMask/snap-bitcoin-wallet/pull/389)) + +### Removed + +- Remove unused dependencies and codebase ([#417](https://github.com/MetaMask/snap-bitcoin-wallet/pull/417)) + +### Fixed + +- Typo in word "Ordinals" ([#402](https://github.com/MetaMask/snap-bitcoin-wallet/pull/402)) + ## [0.9.0] ### Added @@ -255,7 +281,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.10.0...HEAD +[0.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.2...v0.9.0 [0.8.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.1...v0.8.2 [0.8.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.0...v0.8.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index fae1e8ce..a02ff06b 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.9.0", + "version": "0.10.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From e485c6f072f07b51aa3ebba876a69a95eb91f824 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 20 Mar 2025 14:21:36 +0100 Subject: [PATCH 210/362] Revert "0.10.0 (#432)" (#434) This reverts commit 65833b390e122c94ec7cbcb3647dbaf135d08d88. --- .../bitcoin-wallet-snap/CHANGELOG.md | 29 +------------------ .../bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index f6a7b35b..c4e63dbf 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,32 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.10.0] - -### Added - -- List account transactions and assets ([#405](https://github.com/MetaMask/snap-bitcoin-wallet/pull/405), [#420](https://github.com/MetaMask/snap-bitcoin-wallet/pull/420), [#408](https://github.com/MetaMask/snap-bitcoin-wallet/pull/408), [#427](https://github.com/MetaMask/snap-bitcoin-wallet/pull/427)) -- Refresh rates and fees in a background loop inside the Send Flow ([#419](https://github.com/MetaMask/snap-bitcoin-wallet/pull/419)) -- On asset conversion handler ([#418](https://github.com/MetaMask/snap-bitcoin-wallet/pull/418)) -- On asset lookup handler + emission of events on balance updates ([#416](https://github.com/MetaMask/snap-bitcoin-wallet/pull/416)) -- Icons as base64 ([#422](https://github.com/MetaMask/snap-bitcoin-wallet/pull/422)) -- Synchronize Bitcoin accounts in a cron job ([#407](https://github.com/MetaMask/snap-bitcoin-wallet/pull/407)) -- Integration tests ([#382](https://github.com/MetaMask/snap-bitcoin-wallet/pull/382)) -- Translations ([#403]https://github.com/MetaMask/snap-bitcoin-wallet/pull/403) - -### Changed - -- Refactor core Bitcoin library to use the [`bitcoindevkit`](https://www.npmjs.com/package/bitcoindevkit), allowing synchronization of multiple addresses, support of multiple networks (`bitcoin`, `testnet`, `signet`, `regtest`) and address types (`p2pkh`, `p2sh`, `p2wsh`, `p2wpkh`, `p2tr`) ([#361](https://github.com/MetaMask/snap-bitcoin-wallet/pull/361), [#393](https://github.com/MetaMask/snap-bitcoin-wallet/pull/393), [#394](https://github.com/MetaMask/snap-bitcoin-wallet/pull/394), [#378](https://github.com/MetaMask/snap-bitcoin-wallet/pull/378), [#411](https://github.com/MetaMask/snap-bitcoin-wallet/pull/411), [#413](https://github.com/MetaMask/snap-bitcoin-wallet/pull/413), [#414](https://github.com/MetaMask/snap-bitcoin-wallet/pull/414)) -- Upgrade yarn to v4 ([#389](https://github.com/MetaMask/snap-bitcoin-wallet/pull/389)) - -### Removed - -- Remove unused dependencies and codebase ([#417](https://github.com/MetaMask/snap-bitcoin-wallet/pull/417)) - -### Fixed - -- Typo in word "Ordinals" ([#402](https://github.com/MetaMask/snap-bitcoin-wallet/pull/402)) - ## [0.9.0] ### Added @@ -281,8 +255,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.10.0...HEAD -[0.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...v0.10.0 +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...HEAD [0.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.2...v0.9.0 [0.8.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.1...v0.8.2 [0.8.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.0...v0.8.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index a02ff06b..fae1e8ce 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.10.0", + "version": "0.9.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 1e87e3d0c8b67b7f13d3a89719d413b30cf6eab1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Mar 2025 15:58:03 +0100 Subject: [PATCH 211/362] 0.10.0 (#436) --- .../bitcoin-wallet-snap/CHANGELOG.md | 29 ++++++++++++++++++- .../bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index c4e63dbf..f6a7b35b 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.0] + +### Added + +- List account transactions and assets ([#405](https://github.com/MetaMask/snap-bitcoin-wallet/pull/405), [#420](https://github.com/MetaMask/snap-bitcoin-wallet/pull/420), [#408](https://github.com/MetaMask/snap-bitcoin-wallet/pull/408), [#427](https://github.com/MetaMask/snap-bitcoin-wallet/pull/427)) +- Refresh rates and fees in a background loop inside the Send Flow ([#419](https://github.com/MetaMask/snap-bitcoin-wallet/pull/419)) +- On asset conversion handler ([#418](https://github.com/MetaMask/snap-bitcoin-wallet/pull/418)) +- On asset lookup handler + emission of events on balance updates ([#416](https://github.com/MetaMask/snap-bitcoin-wallet/pull/416)) +- Icons as base64 ([#422](https://github.com/MetaMask/snap-bitcoin-wallet/pull/422)) +- Synchronize Bitcoin accounts in a cron job ([#407](https://github.com/MetaMask/snap-bitcoin-wallet/pull/407)) +- Integration tests ([#382](https://github.com/MetaMask/snap-bitcoin-wallet/pull/382)) +- Translations ([#403]https://github.com/MetaMask/snap-bitcoin-wallet/pull/403) + +### Changed + +- Refactor core Bitcoin library to use the [`bitcoindevkit`](https://www.npmjs.com/package/bitcoindevkit), allowing synchronization of multiple addresses, support of multiple networks (`bitcoin`, `testnet`, `signet`, `regtest`) and address types (`p2pkh`, `p2sh`, `p2wsh`, `p2wpkh`, `p2tr`) ([#361](https://github.com/MetaMask/snap-bitcoin-wallet/pull/361), [#393](https://github.com/MetaMask/snap-bitcoin-wallet/pull/393), [#394](https://github.com/MetaMask/snap-bitcoin-wallet/pull/394), [#378](https://github.com/MetaMask/snap-bitcoin-wallet/pull/378), [#411](https://github.com/MetaMask/snap-bitcoin-wallet/pull/411), [#413](https://github.com/MetaMask/snap-bitcoin-wallet/pull/413), [#414](https://github.com/MetaMask/snap-bitcoin-wallet/pull/414)) +- Upgrade yarn to v4 ([#389](https://github.com/MetaMask/snap-bitcoin-wallet/pull/389)) + +### Removed + +- Remove unused dependencies and codebase ([#417](https://github.com/MetaMask/snap-bitcoin-wallet/pull/417)) + +### Fixed + +- Typo in word "Ordinals" ([#402](https://github.com/MetaMask/snap-bitcoin-wallet/pull/402)) + ## [0.9.0] ### Added @@ -255,7 +281,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.10.0...HEAD +[0.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.2...v0.9.0 [0.8.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.1...v0.8.2 [0.8.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.0...v0.8.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index fae1e8ce..a02ff06b 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.9.0", + "version": "0.10.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From d5c1860f6c491c55e1c2883986b90b9dfcc918f5 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 26 Mar 2025 14:28:46 +0100 Subject: [PATCH 212/362] chore: logger refactor (#437) --- .../bitcoin-wallet-snap/.env.example | 8 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../bitcoin-wallet-snap/src/config.ts | 4 +- .../src/entities/config.ts | 4 +- .../bitcoin-wallet-snap/src/entities/index.ts | 1 + .../src/entities/logger.ts | 43 +++++++++ .../src/handlers/CronHandler.test.ts | 16 ++-- .../src/handlers/CronHandler.ts | 13 ++- .../bitcoin-wallet-snap/src/index.ts | 10 ++- .../src/infra/ConsoleLoggerAdapter.ts | 53 +++++++++++ .../src/infra/EsploraClientAdapter.ts | 7 +- .../src/infra/SimpleHashClientAdapter.ts | 2 - .../bitcoin-wallet-snap/src/infra/index.ts | 1 + .../src/infra/logger.test.ts | 78 ---------------- .../bitcoin-wallet-snap/src/infra/logger.ts | 88 ------------------- .../src/use-cases/AccountUseCases.test.ts | 26 +++--- .../src/use-cases/AccountUseCases.ts | 41 +++++---- .../src/use-cases/AssetsUseCases.test.ts | 14 +-- .../src/use-cases/AssetsUseCases.ts | 12 +-- .../src/use-cases/SendFlowUseCases.test.ts | 32 +++---- .../src/use-cases/SendFlowUseCases.ts | 23 +++-- 21 files changed, 211 insertions(+), 269 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/logger.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/ConsoleLoggerAdapter.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/logger.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/logger.ts diff --git a/merged-packages/bitcoin-wallet-snap/.env.example b/merged-packages/bitcoin-wallet-snap/.env.example index ab551db9..fec72210 100644 --- a/merged-packages/bitcoin-wallet-snap/.env.example +++ b/merged-packages/bitcoin-wallet-snap/.env.example @@ -1,8 +1,8 @@ -# Log Level, 0 does not log anything, 6 logs everything -# Possible Options: 0-6 -# Default: 0 +# Log Level +# Possible Options: error, warn, info, debug, trace, silent +# Default: info # Required: false -LOG_LEVEL=6 +LOG_LEVEL= # Default address type # Possible options: p2pkh, p2sh, p2wpkh, p2wsh, p2tr diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 60bfdec3..00404527 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.9.0", + "version": "0.10.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "hjJKrHCdEYJPHXts1YoqyT1hpYecQTolbGsBymybYHM=", + "shasum": "wXSCbl042XO40+TMmomKd2i+B8mIuOjN/qncbQ+3XE0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 04fcba25..286444d8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -2,10 +2,10 @@ import type { AddressType } from 'bitcoindevkit'; -import type { SnapConfig } from './entities'; +import { LogLevel, type SnapConfig } from './entities'; export const Config: SnapConfig = { - logLevel: process.env.LOG_LEVEL ?? '3', + logLevel: (process.env.LOG_LEVEL ?? LogLevel.INFO) as LogLevel, encrypt: false, accounts: { index: 0, diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts index d9eac323..b1f449b5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -1,7 +1,9 @@ import type { AddressType, Network } from 'bitcoindevkit'; +import type { LogLevel } from './logger'; + export type SnapConfig = { - logLevel: string; + logLevel: LogLevel; encrypt: boolean; accounts: AccountsConfig; chain: ChainConfig; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts index 6e7f8d0e..f71016e2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts @@ -9,3 +9,4 @@ export * from './meta-protocols'; export * from './error'; export * from './locale'; export * from './rates'; +export * from './logger'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/logger.ts b/merged-packages/bitcoin-wallet-snap/src/entities/logger.ts new file mode 100644 index 00000000..de13d92d --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/logger.ts @@ -0,0 +1,43 @@ +export enum LogLevel { + ERROR = 'error', + WARN = 'warn', + INFO = 'info', + DEBUG = 'debug', + TRACE = 'trace', + SILENT = 'silent', +} + +/** + * A Logger. + */ +export type Logger = { + /** + * Logs at the `ERROR` level. + * @param data - The data to log. + */ + error(...data: any[]): void; + + /** + * Logs at the `WARN` level. + * @param data - The data to log. + */ + warn(...data: any[]): void; + + /** + * Logs at the `INFO` level. + * @param data - The data to log. + */ + info(...data: any[]): void; + + /** + * Logs at the `DEBUG` level. + * @param data - The data to log. + */ + debug(...data: any[]): void; + + /** + * Logs at the `TRACE` level. + * @param data - The data to log. + */ + trace(...data: any[]): void; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts index a63f0a35..a9d256c7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -1,22 +1,20 @@ import { mock } from 'jest-mock-extended'; +import type { Logger } from '../entities'; import { SendFormEvent, type BitcoinAccount } from '../entities'; -import type { ILogger } from '../infra/logger'; import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; import { CronHandler } from './CronHandler'; -jest.mock('../infra/logger', () => { - return { logger: mock() }; -}); - describe('CronHandler', () => { + const mockLogger = mock(); const mockSendFlowUseCases = mock(); const mockAccountUseCases = mock(); - let handler: CronHandler; - beforeEach(() => { - handler = new CronHandler(mockAccountUseCases, mockSendFlowUseCases); - }); + const handler = new CronHandler( + mockLogger, + mockAccountUseCases, + mockSendFlowUseCases, + ); describe('synchronizeAccounts', () => { const mockAccounts = [mock(), mock()]; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index b7323a41..226b6376 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -1,8 +1,8 @@ import type { JsonRpcParams } from '@metamask/utils'; import { assert, object, string } from 'superstruct'; +import type { Logger } from '../entities'; import { SendFormEvent } from '../entities'; -import { logger } from '../infra/logger'; import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; export const SendFormRefreshRatesRequest = object({ @@ -10,11 +10,18 @@ export const SendFormRefreshRatesRequest = object({ }); export class CronHandler { + readonly #logger: Logger; + readonly #accountsUseCases: AccountUseCases; readonly #sendFlowUseCases: SendFlowUseCases; - constructor(accounts: AccountUseCases, sendFlow: SendFlowUseCases) { + constructor( + logger: Logger, + accounts: AccountUseCases, + sendFlow: SendFlowUseCases, + ) { + this.#logger = logger; this.#accountsUseCases = accounts; this.#sendFlowUseCases = sendFlow; } @@ -46,7 +53,7 @@ export class CronHandler { results.forEach((result, index) => { if (result.status === 'rejected') { - logger.error( + this.#logger.error( `Account failed to sync. ID: %s. Error: %s`, accounts[index].id, result.reason, diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index e3b96d03..59d131b4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -27,14 +27,14 @@ import { EsploraClientAdapter, SimpleHashClientAdapter, PriceApiClientAdapter, + ConsoleLoggerAdapter, } from './infra'; -import { logger } from './infra/logger'; import { originPermissions } from './permissions'; import { BdkAccountRepository, JSXSendFlowRepository } from './store'; import { AccountUseCases, AssetsUseCases, SendFlowUseCases } from './use-cases'; // Infra layer -logger.logLevel = parseInt(Config.logLevel, 10); +const logger = new ConsoleLoggerAdapter(Config.logLevel); const snapClient = new SnapClientAdapter(Config.encrypt); const chainClient = new EsploraClientAdapter(Config.chain); const metaProtocolsClient = new SimpleHashClientAdapter(Config.simpleHash); @@ -46,6 +46,7 @@ const sendFlowRepository = new JSXSendFlowRepository(snapClient); // Business layer const accountsUseCases = new AccountUseCases( + logger, snapClient, accountRepository, chainClient, @@ -53,6 +54,7 @@ const accountsUseCases = new AccountUseCases( Config.accounts, ); const sendFlowUseCases = new SendFlowUseCases( + logger, snapClient, accountRepository, sendFlowRepository, @@ -62,11 +64,11 @@ const sendFlowUseCases = new SendFlowUseCases( Config.fallbackFeeRate, Config.ratesRefreshInterval, ); -const assetsUseCases = new AssetsUseCases(assetRatesClient); +const assetsUseCases = new AssetsUseCases(logger, assetRatesClient); // Application layer const keyringHandler = new KeyringHandler(accountsUseCases); -const cronHandler = new CronHandler(accountsUseCases, sendFlowUseCases); +const cronHandler = new CronHandler(logger, accountsUseCases, sendFlowUseCases); const rpcHandler = new RpcHandler(sendFlowUseCases, accountsUseCases); const userInputHandler = new UserInputHandler(sendFlowUseCases); const assetsHandler = new AssetsHandler( diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/ConsoleLoggerAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/ConsoleLoggerAdapter.ts new file mode 100644 index 00000000..1b5fc336 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/ConsoleLoggerAdapter.ts @@ -0,0 +1,53 @@ +import type { Logger } from '../entities'; +import { LogLevel } from '../entities'; + +const logLevelPriority = { + [LogLevel.ERROR]: 0, + [LogLevel.WARN]: 1, + [LogLevel.INFO]: 2, + [LogLevel.DEBUG]: 3, + [LogLevel.TRACE]: 4, + [LogLevel.SILENT]: 5, +}; + +export class ConsoleLoggerAdapter implements Logger { + readonly #logLevel: LogLevel; + + constructor(logLevel: LogLevel) { + this.#logLevel = logLevel; + } + + #shouldLog(level: LogLevel): boolean { + return logLevelPriority[level] <= logLevelPriority[this.#logLevel]; + } + + error(...data: any[]): void { + if (this.#shouldLog(LogLevel.ERROR)) { + console.error(...data); + } + } + + warn(...data: any[]): void { + if (this.#shouldLog(LogLevel.WARN)) { + console.warn(...data); + } + } + + info(...data: any[]): void { + if (this.#shouldLog(LogLevel.INFO)) { + console.info(...data); + } + } + + debug(...data: any[]): void { + if (this.#shouldLog(LogLevel.DEBUG)) { + console.debug(...data); + } + } + + trace(...data: any[]): void { + if (this.#shouldLog(LogLevel.TRACE)) { + console.trace(...data); + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts index 097577c7..18da39fa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts @@ -1,8 +1,11 @@ import type { FeeEstimates, Network, Transaction } from 'bitcoindevkit'; import { EsploraClient } from 'bitcoindevkit'; -import type { BitcoinAccount, ChainConfig } from '../entities'; -import type { BlockchainClient } from '../entities/chain'; +import type { + BitcoinAccount, + ChainConfig, + BlockchainClient, +} from '../entities'; export class EsploraClientAdapter implements BlockchainClient { // Should be a Repository but we don't support custom networks so we can save in memory from config values diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts index 9468fc34..51d48836 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts @@ -8,7 +8,6 @@ import type { MetaProtocolsClient, SimpleHashConfig as SimplehHashConfig, } from '../entities'; -import { logger } from './logger'; /* eslint-disable @typescript-eslint/naming-convention */ type NFTResponse = { @@ -74,7 +73,6 @@ export class SimpleHashClientAdapter implements MetaProtocolsClient { do { pages += 1; if (pages > MAX_PAGES) { - logger.warn(`Maximum page limit reached (${MAX_PAGES}).`); break; } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts index 60227b3f..5c2ad7d7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts @@ -3,3 +3,4 @@ export * from './SnapClientAdapter'; export * from './EsploraClientAdapter'; export * from './SimpleHashClientAdapter'; export * from './PriceApiClientAdapter'; +export * from './ConsoleLoggerAdapter'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/logger.test.ts b/merged-packages/bitcoin-wallet-snap/src/infra/logger.test.ts deleted file mode 100644 index 8d50c85e..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/infra/logger.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -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/infra/logger.ts b/merged-packages/bitcoin-wallet-snap/src/infra/logger.ts deleted file mode 100644 index 538ba281..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/infra/logger.ts +++ /dev/null @@ -1,88 +0,0 @@ -// 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 - {}; - -export 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/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 5dd8d235..02e59952 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -14,20 +14,15 @@ import type { BitcoinAccountRepository, BlockchainClient, Inscription, + Logger, MetaProtocolsClient, SnapClient, TransactionRequest, } from '../entities'; -import type { ILogger } from '../infra/logger'; import { AccountUseCases } from './AccountUseCases'; -jest.mock('../infra/logger', () => { - return { logger: mock() }; -}); - describe('AccountUseCases', () => { - let useCases: AccountUseCases; - + const mockLogger = mock(); const mockSnapClient = mock(); const mockRepository = mock(); const mockChain = mock(); @@ -37,15 +32,14 @@ describe('AccountUseCases', () => { defaultAddressType: 'p2wpkh', }; - beforeEach(() => { - useCases = new AccountUseCases( - mockSnapClient, - mockRepository, - mockChain, - mockMetaProtocols, - accountsConfig, - ); - }); + const useCases = new AccountUseCases( + mockLogger, + mockSnapClient, + mockRepository, + mockChain, + mockMetaProtocols, + accountsConfig, + ); describe('get', () => { it('returns account', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 55d2df76..6ae636c1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -8,8 +8,8 @@ import type { TransactionRequest, SnapClient, MetaProtocolsClient, + Logger, } from '../entities'; -import { logger } from '../infra/logger'; const addressTypeToPurpose: Record = { p2pkh: "44'", @@ -28,6 +28,8 @@ const networkToCoinType: Record = { }; export class AccountUseCases { + readonly #logger: Logger; + readonly #snapClient: SnapClient; readonly #repository: BitcoinAccountRepository; @@ -39,12 +41,14 @@ export class AccountUseCases { readonly #accountConfig: AccountsConfig; constructor( + logger: Logger, snapClient: SnapClient, repository: BitcoinAccountRepository, chain: BlockchainClient, metaProtocols: MetaProtocolsClient, accountConfig: AccountsConfig, ) { + this.#logger = logger; this.#snapClient = snapClient; this.#repository = repository; this.#chain = chain; @@ -53,23 +57,23 @@ export class AccountUseCases { } async list(): Promise { - logger.debug('Listing accounts'); + this.#logger.debug('Listing accounts'); const accounts = await this.#repository.getAll(); - logger.debug('Accounts listed successfully'); + this.#logger.debug('Accounts listed successfully'); return accounts; } async get(id: string): Promise { - logger.debug('Fetching account: %s', id); + this.#logger.debug('Fetching account: %s', id); const account = await this.#repository.get(id); if (!account) { throw new Error(`Account not found: ${id}`); } - logger.debug('Account found: %s', account.id); + this.#logger.debug('Account found: %s', account.id); return account; } @@ -77,7 +81,7 @@ export class AccountUseCases { network: Network, addressType: AddressType = this.#accountConfig.defaultAddressType, ): Promise { - logger.debug( + this.#logger.debug( 'Creating new Bitcoin account. Network: %o. addressType: %o,', network, addressType, @@ -93,7 +97,7 @@ export class AccountUseCases { // Idempotent account creation + ensures only one account per derivation path const account = await this.#repository.getByDerivationPath(derivationPath); if (account) { - logger.debug('Account already exists: %s,', account.id); + this.#logger.debug('Account already exists: %s,', account.id); await this.#snapClient.emitAccountCreatedEvent(account); return account; } @@ -106,7 +110,7 @@ export class AccountUseCases { await this.#snapClient.emitAccountCreatedEvent(newAccount); - logger.info( + this.#logger.info( 'Bitcoin account created successfully: %s. derivationPath: %s', newAccount.id, derivationPath.join('/'), @@ -115,10 +119,10 @@ export class AccountUseCases { } async synchronize(account: BitcoinAccount): Promise { - logger.debug('Synchronizing account: %s', account.id); + this.#logger.debug('Synchronizing account: %s', account.id); if (!account.isScanned) { - logger.debug( + this.#logger.debug( 'Account has not yet performed initial full scan, skipping synchronization: %s', account.id, ); @@ -160,11 +164,11 @@ export class AccountUseCases { ); } - logger.debug('Account synchronized successfully: %s', account.id); + this.#logger.debug('Account synchronized successfully: %s', account.id); } async fullScan(account: BitcoinAccount): Promise { - logger.debug('Performing initial full scan: %s', account.id); + this.#logger.debug('Performing initial full scan: %s', account.id); await this.#chain.fullScan(account); @@ -177,11 +181,14 @@ export class AccountUseCases { account.listTransactions(), ); - logger.debug('initial full scan performed successfully: %s', account.id); + this.#logger.debug( + 'initial full scan performed successfully: %s', + account.id, + ); } async delete(id: string): Promise { - logger.debug('Deleting account: %s', id); + this.#logger.debug('Deleting account: %s', id); const account = await this.#repository.get(id); if (!account) { @@ -191,11 +198,11 @@ export class AccountUseCases { await this.#snapClient.emitAccountDeletedEvent(id); await this.#repository.delete(id); - logger.info('Account deleted successfully: %s', account.id); + this.#logger.info('Account deleted successfully: %s', account.id); } async send(id: string, request: TransactionRequest): Promise { - logger.debug('Sending transaction: %s. Request: %o', id, request); + this.#logger.debug('Sending transaction: %s. Request: %o', id, request); if (request.drain && request.amount) { throw new Error("Cannot specify both 'amount' and 'drain' options"); @@ -228,7 +235,7 @@ export class AccountUseCases { await this.#snapClient.emitAccountBalancesUpdatedEvent(account); const txId = tx.compute_txid(); - logger.info( + this.#logger.info( 'Transaction sent successfully: %s. Account: %s, Network: %s', txId, id, diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts index 11746fc6..f8b474ec 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts @@ -1,22 +1,14 @@ import { mock } from 'jest-mock-extended'; -import type { AssetRatesClient, ExchangeRates } from '../entities'; +import type { AssetRatesClient, ExchangeRates, Logger } from '../entities'; import { Caip19Asset } from '../handlers/caip'; -import type { ILogger } from '../infra/logger'; import { AssetsUseCases } from './AssetsUseCases'; -jest.mock('../infra/logger', () => { - return { logger: mock() }; -}); - describe('AssetsUseCases', () => { - let useCases: AssetsUseCases; - + const mockLogger = mock(); const mockAssetRates = mock(); - beforeEach(() => { - useCases = new AssetsUseCases(mockAssetRates); - }); + const useCases = new AssetsUseCases(mockLogger, mockAssetRates); describe('getBtcRates', () => { it('returns rate for the known assets and null for unknown', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts index ef91417b..5052d593 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts @@ -2,20 +2,22 @@ import slip44 from '@metamask/slip44'; import type { CaipAssetType } from '@metamask/utils'; import { parseCaipAssetType } from '@metamask/utils'; -import type { AssetRatesClient, AssetRate } from '../entities'; -import { logger } from '../infra/logger'; +import type { AssetRatesClient, AssetRate, Logger } from '../entities'; export class AssetsUseCases { + readonly #logger: Logger; + readonly #assetRates: AssetRatesClient; - constructor(assetRates: AssetRatesClient) { + constructor(logger: Logger, assetRates: AssetRatesClient) { + this.#logger = logger; this.#assetRates = assetRates; } async getRates(assets: CaipAssetType[]): Promise { - logger.debug('Fetching BTC rates for: %o', assets); + this.#logger.debug('Fetching BTC rates for: %o', assets); const exchangeRates = await this.#assetRates.exchangeRates(); - logger.debug('BTC rates fetched successfully'); + this.#logger.debug('BTC rates fetched successfully'); return assets.map((asset): AssetRate => { const ticker = this.#assetToTicker(asset); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index a5d107ae..e55986dc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -14,13 +14,13 @@ import type { TransactionRequest, ReviewTransactionContext, AssetRatesClient, + Logger, } from '../entities'; import { ReviewTransactionEvent, CurrencyUnit, SendFormEvent, } from '../entities'; -import type { ILogger } from '../infra/logger'; import { SendFlowUseCases } from './SendFlowUseCases'; // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 @@ -36,13 +36,8 @@ jest.mock('bitcoindevkit', () => { }; }); -jest.mock('../infra/logger', () => { - return { logger: mock() }; -}); - describe('SendFlowUseCases', () => { - let useCases: SendFlowUseCases; - + const mockLogger = mock(); const mockSnapClient = mock(); const mockAccountRepository = mock(); const mockSendFlowRepository = mock(); @@ -63,18 +58,17 @@ describe('SendFlowUseCases', () => { }); const mockTxRequest = mock(); - beforeEach(() => { - useCases = new SendFlowUseCases( - mockSnapClient, - mockAccountRepository, - mockSendFlowRepository, - mockChain, - mockRatesClient, - targetBlocksConfirmation, - fallbackFeeRate, - ratesRefreshInterval, - ); - }); + const useCases = new SendFlowUseCases( + mockLogger, + mockSnapClient, + mockAccountRepository, + mockSendFlowRepository, + mockChain, + mockRatesClient, + targetBlocksConfirmation, + fallbackFeeRate, + ratesRefreshInterval, + ); describe('displayForm', () => { beforeEach(() => { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index 1f669aec..8911a964 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -11,15 +11,17 @@ import type { SendFormContext, ReviewTransactionContext, AssetRatesClient, + Logger, } from '../entities'; import { SendFormEvent, ReviewTransactionEvent, networkToCurrencyUnit, } from '../entities'; -import { logger } from '../infra/logger'; export class SendFlowUseCases { + readonly #logger: Logger; + readonly #snapClient: SnapClient; readonly #accountRepository: BitcoinAccountRepository; @@ -37,6 +39,7 @@ export class SendFlowUseCases { readonly #ratesRefreshInterval: string; constructor( + logger: Logger, snapClient: SnapClient, accountRepository: BitcoinAccountRepository, sendFlowRepository: SendFlowRepository, @@ -46,6 +49,7 @@ export class SendFlowUseCases { fallbackFeeRate: number, ratesRefreshInterval: string, ) { + this.#logger = logger; this.#snapClient = snapClient; this.#accountRepository = accountRepository; this.#sendFlowRepository = sendFlowRepository; @@ -57,7 +61,7 @@ export class SendFlowUseCases { } async display(accountId: string): Promise { - logger.debug('Displaying Send form. Account: %s', accountId); + this.#logger.debug('Displaying Send form. Account: %s', accountId); const account = await this.#accountRepository.get(accountId); if (!account) { @@ -90,12 +94,19 @@ export class SendFlowUseCases { throw new UserRejectedRequestError() as unknown as Error; } - logger.debug('Transaction request generated successfully: %o', request); + this.#logger.debug( + 'Transaction request generated successfully: %o', + request, + ); return request; } async onChangeForm(id: string, event: SendFormEvent): Promise { - logger.debug('Event triggered on send form: %s. Event: %s', id, event); + this.#logger.debug( + 'Event triggered on send form: %s. Event: %s', + id, + event, + ); // TODO: Temporary fetch the context while this is fixed: https://github.com/MetaMask/snaps/issues/3069 const context = await this.#sendFlowRepository.getContext(id); @@ -168,7 +179,7 @@ export class SendFlowUseCases { event: ReviewTransactionEvent, context: ReviewTransactionContext, ): Promise { - logger.debug( + this.#logger.debug( 'Event triggered on transaction review: %s. Event: %s', id, event, @@ -297,7 +308,7 @@ export class SendFlowUseCases { updatedContext = await this.#computeFee(updatedContext); } catch (error) { // We do not throw so we can reschedule. Previous fetched values or fallbacks will be used. - logger.error( + this.#logger.error( `Failed to fetch rates in send form: %s. Error: %s`, id, error, From cc9ce2645558dce7f161e19f871c22aca4413d6f Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 27 Mar 2025 17:49:29 +0100 Subject: [PATCH 213/362] chore: reduce bundle size (#439) --- .../integration-test/send-flow.test.ts | 2 +- .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/config.ts | 2 +- .../src/entities/account.ts | 2 +- .../bitcoin-wallet-snap/src/entities/chain.ts | 6 ++- .../src/entities/config.ts | 2 +- .../src/entities/currency.ts | 2 +- .../src/entities/send-flow.ts | 2 +- .../bitcoin-wallet-snap/src/entities/snap.ts | 2 +- .../src/entities/transaction.ts | 2 +- .../src/handlers/AssetsHandler.ts | 2 +- .../src/handlers/KeyringHandler.test.ts | 10 ++--- .../src/handlers/RpcHandler.test.ts | 2 +- .../bitcoin-wallet-snap/src/handlers/caip.ts | 2 +- .../bitcoin-wallet-snap/src/handlers/icons.ts | 2 +- .../src/handlers/mappings.ts | 14 +++---- .../src/infra/BdkAccountAdapter.ts | 4 +- .../src/infra/BdkTxBuilderAdapter.ts | 10 ++++- .../src/infra/EsploraClientAdapter.ts | 8 +++- .../src/infra/SimpleHashClientAdapter.ts | 2 +- .../src/infra/SnapClientAdapter.ts | 2 +- .../src/infra/jsx/ReviewTransactionView.tsx | 8 +--- .../src/infra/jsx/components/AssetIcon.tsx | 25 ++++++++++++ .../src/infra/jsx/components/SendForm.tsx | 18 +-------- .../jsx/components/TransactionSummary.tsx | 2 +- .../src/infra/jsx/components/index.ts | 1 + .../src/infra/jsx/format.ts | 2 +- .../src/infra/jsx/images/btc-halo.svg | 38 ------------------- .../src/store/BdkAccountRepository.test.ts | 4 +- .../src/store/BdkAccountRepository.ts | 4 +- .../src/use-cases/AccountUseCases.test.ts | 2 +- .../src/use-cases/AccountUseCases.ts | 7 +++- .../src/use-cases/SendFlowUseCases.test.ts | 11 ++++-- .../src/use-cases/SendFlowUseCases.ts | 2 +- 35 files changed, 101 insertions(+), 107 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/AssetIcon.tsx delete mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/btc-halo.svg diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts index 1d16a04f..946f6ccb 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts @@ -111,7 +111,7 @@ describe('Send flow', () => { { account: account.id, chain: BtcScope.Regtest, - events: [{ status: 'unconfirmed', timestamp: 1741784431 }], + events: [{ status: 'unconfirmed', timestamp: expect.any(Number) }], fees: [ { asset: { diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index a02ff06b..423c1a66 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -37,6 +37,7 @@ "devDependencies": { "@babel/preset-typescript": "^7.23.3", "@metamask/auto-changelog": "^3.4.4", + "@metamask/bitcoindevkit": "^0.1.6", "@metamask/eslint-config": "^12.2.0", "@metamask/eslint-config-jest": "^12.1.0", "@metamask/eslint-config-nodejs": "^12.1.0", @@ -53,7 +54,6 @@ "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", "async-mutex": "^0.3.2", - "bitcoindevkit": "^0.1.5", "concurrently": "^9.1.0", "dotenv": "^16.4.5", "eslint": "^8.45.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 00404527..82585434 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "wXSCbl042XO40+TMmomKd2i+B8mIuOjN/qncbQ+3XE0=", + "shasum": "1I0R3uQ7zqRY746bh2PgF5tHKR3v44TdFf/dZdFdlJY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 286444d8..9340ae20 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -1,6 +1,6 @@ /* eslint-disable no-restricted-globals */ -import type { AddressType } from 'bitcoindevkit'; +import type { AddressType } from '@metamask/bitcoindevkit'; import { LogLevel, type SnapConfig } from './entities'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index 4dc47eb3..cdbf4ca4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -14,7 +14,7 @@ import type { Amount, ScriptBuf, KeychainKind, -} from 'bitcoindevkit'; +} from '@metamask/bitcoindevkit'; import type { Inscription } from './meta-protocols'; import type { TransactionBuilder } from './transaction'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts b/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts index 6aa3637c..538027fe 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts @@ -1,4 +1,8 @@ -import type { FeeEstimates, Network, Transaction } from 'bitcoindevkit'; +import type { + FeeEstimates, + Network, + Transaction, +} from '@metamask/bitcoindevkit'; import type { BitcoinAccount } from './account'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts index b1f449b5..1fd274cb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -1,4 +1,4 @@ -import type { AddressType, Network } from 'bitcoindevkit'; +import type { AddressType, Network } from '@metamask/bitcoindevkit'; import type { LogLevel } from './logger'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts b/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts index 935f0f0b..70c03dd4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts @@ -1,4 +1,4 @@ -import type { Network } from 'bitcoindevkit'; +import type { Network } from '@metamask/bitcoindevkit'; export enum CurrencyUnit { Bitcoin = 'BTC', diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts index b40aceb2..dba0aec2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts @@ -1,5 +1,5 @@ +import type { Network } from '@metamask/bitcoindevkit'; import type { CurrencyRate } from '@metamask/snaps-sdk'; -import type { Network } from 'bitcoindevkit'; import type { CurrencyUnit } from './currency'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index eb43b600..c4a98c17 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -1,10 +1,10 @@ +import type { WalletTx } from '@metamask/bitcoindevkit'; import type { JsonSLIP10Node, SLIP10Node } from '@metamask/key-tree'; import type { ComponentOrElement, GetPreferencesResult, } from '@metamask/snaps-sdk'; import type { Json } from '@metamask/utils'; -import type { WalletTx } from 'bitcoindevkit'; import type { BitcoinAccount } from './account'; import type { Inscription } from './meta-protocols'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts b/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts index 0220eae4..1e86bbdc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts @@ -1,4 +1,4 @@ -import type { Psbt } from 'bitcoindevkit'; +import type { Psbt } from '@metamask/bitcoindevkit'; export type TransactionRequest = { recipient: string; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts index 11aefb68..fadbf40e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts @@ -1,3 +1,4 @@ +import type { Network } from '@metamask/bitcoindevkit'; import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; import type { CaipAssetType, @@ -6,7 +7,6 @@ import type { OnAssetsConversionResponse, OnAssetsLookupResponse, } from '@metamask/snaps-sdk'; -import type { Network } from 'bitcoindevkit'; import type { AssetsUseCases } from '../use-cases'; import { Caip19Asset } from './caip'; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index a8cb8881..5b625b52 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -1,13 +1,13 @@ -import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; -import { BtcMethod, BtcScope } from '@metamask/keyring-api'; import type { AddressInfo, Amount, Transaction, Txid, TxOut, -} from 'bitcoindevkit'; -import { Address, type Network, type WalletTx } from 'bitcoindevkit'; +} from '@metamask/bitcoindevkit'; +import { Address, type Network, type WalletTx } from '@metamask/bitcoindevkit'; +import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; +import { BtcMethod, BtcScope } from '@metamask/keyring-api'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; @@ -28,7 +28,7 @@ jest.mock('superstruct', () => ({ // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 /* eslint-disable @typescript-eslint/naming-convention */ -jest.mock('bitcoindevkit', () => { +jest.mock('@metamask/bitcoindevkit', () => { return { Address: { from_script: jest.fn(), diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index 1371b990..aef90917 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -1,4 +1,4 @@ -import type { Txid } from 'bitcoindevkit'; +import type { Txid } from '@metamask/bitcoindevkit'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts index 0323d7ed..61d84715 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts @@ -1,5 +1,5 @@ +import type { AddressType, Network } from '@metamask/bitcoindevkit'; import { BtcScope } from '@metamask/keyring-api'; -import type { AddressType, Network } from 'bitcoindevkit'; const reverseMapping = < From extends string | number | symbol, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/icons.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/icons.ts index ae9dc90e..636c5714 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/icons.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/icons.ts @@ -1,4 +1,4 @@ -import type { Network } from 'bitcoindevkit'; +import type { Network } from '@metamask/bitcoindevkit'; export const networkToIcon: Record = { bitcoin: diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index d759d528..1d78c473 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -1,9 +1,4 @@ -import type { - KeyringAccount, - Transaction as KeyringTransaction, -} from '@metamask/keyring-api'; -import { TransactionStatus, BtcMethod } from '@metamask/keyring-api'; -import { Address } from 'bitcoindevkit'; +import { Address } from '@metamask/bitcoindevkit'; import type { AddressType, Amount, @@ -11,7 +6,12 @@ import type { TxOut, ChainPosition, WalletTx, -} from 'bitcoindevkit'; +} from '@metamask/bitcoindevkit'; +import type { + KeyringAccount, + Transaction as KeyringTransaction, +} from '@metamask/keyring-api'; +import { TransactionStatus, BtcMethod } from '@metamask/keyring-api'; import { networkToCurrencyUnit, type BitcoinAccount } from '../entities'; import type { Caip19Asset } from './caip'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index 93da857f..5319b673 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -15,8 +15,8 @@ import type { Amount, ScriptBuf, KeychainKind, -} from 'bitcoindevkit'; -import { Txid, Wallet } from 'bitcoindevkit'; +} from '@metamask/bitcoindevkit'; +import { Txid, Wallet } from '@metamask/bitcoindevkit'; import type { BitcoinAccount, TransactionBuilder } from '../entities'; import { BdkTxBuilderAdapter } from './BdkTxBuilderAdapter'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts index 16f0b807..5c9f3433 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts @@ -1,5 +1,11 @@ -import type { Network, Psbt, TxBuilder } from 'bitcoindevkit'; -import { OutPoint, Address, Amount, FeeRate, Recipient } from 'bitcoindevkit'; +import type { Network, Psbt, TxBuilder } from '@metamask/bitcoindevkit'; +import { + OutPoint, + Address, + Amount, + FeeRate, + Recipient, +} from '@metamask/bitcoindevkit'; import type { TransactionBuilder } from '../entities'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts index 18da39fa..4219fe93 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts @@ -1,5 +1,9 @@ -import type { FeeEstimates, Network, Transaction } from 'bitcoindevkit'; -import { EsploraClient } from 'bitcoindevkit'; +import type { + FeeEstimates, + Network, + Transaction, +} from '@metamask/bitcoindevkit'; +import { EsploraClient } from '@metamask/bitcoindevkit'; import type { BitcoinAccount, diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts index 51d48836..728b6fe3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts @@ -1,5 +1,5 @@ +import type { Network } from '@metamask/bitcoindevkit'; import type { Json } from '@metamask/utils'; -import type { Network } from 'bitcoindevkit'; import qs from 'qs'; import type { diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index fde6d1c5..1769ceba 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -1,3 +1,4 @@ +import type { WalletTx } from '@metamask/bitcoindevkit'; import type { JsonSLIP10Node } from '@metamask/key-tree'; import { SLIP10Node } from '@metamask/key-tree'; import { KeyringEvent } from '@metamask/keyring-api'; @@ -11,7 +12,6 @@ import { type GetPreferencesResult, type Json, } from '@metamask/snaps-sdk'; -import type { WalletTx } from 'bitcoindevkit'; import type { BitcoinAccount, SnapClient, SnapState } from '../entities'; import { networkToCurrencyUnit } from '../entities'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx index 7af6235b..cd763a10 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx @@ -9,7 +9,6 @@ import { Section, Text, Value, - Image, Address, } from '@metamask/snaps-sdk/jsx'; import type { CaipAccountId } from '@metamask/utils'; @@ -19,9 +18,8 @@ import type { ReviewTransactionContext } from '../../entities'; import { BlockTime, ReviewTransactionEvent } from '../../entities'; import { getTranslator } from '../../entities/locale'; import { networkToCaip2 } from '../../handlers'; -import { HeadingWithReturn } from './components'; +import { AssetIcon, HeadingWithReturn } from './components'; import { displayAmount, displayExchangeAmount } from './format'; -import btcIcon from './images/btc-halo.svg'; export const ReviewTransactionView: SnapComponent = ( props, @@ -49,9 +47,7 @@ export const ReviewTransactionView: SnapComponent = ( /> - - - + {`${t('sending')} ${displayAmount( BigInt(amount), currency, diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/AssetIcon.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/AssetIcon.tsx new file mode 100644 index 00000000..d5ef52ab --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/AssetIcon.tsx @@ -0,0 +1,25 @@ +import type { Network } from '@metamask/bitcoindevkit'; +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Box, Image } from '@metamask/snaps-sdk/jsx'; + +import btcIcon from '../images/bitcoin.svg'; +import signetIcon from '../images/signet.svg'; +import testnetIcon from '../images/testnet.svg'; + +const networkToIcon: Record = { + bitcoin: btcIcon, + testnet: testnetIcon, + testnet4: testnetIcon, + signet: signetIcon, + regtest: signetIcon, +}; + +export type AssetIconProps = { + network: Network; +}; + +export const AssetIcon: SnapComponent = ({ network }) => ( + + + +); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx index 237b2ea7..9445c2b4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx @@ -4,28 +4,16 @@ import { Field, Form, Icon, - Image, Input, Text, type SnapComponent, } from '@metamask/snaps-sdk/jsx'; -import type { Network } from 'bitcoindevkit'; import type { SendFormContext } from '../../../entities'; import { SENDFORM_NAME, SendFormEvent } from '../../../entities'; import { getTranslator } from '../../../entities/locale'; import { displayAmount } from '../format'; -import btcIcon from '../images/bitcoin.svg'; -import signetIcon from '../images/signet.svg'; -import testnetIcon from '../images/testnet.svg'; - -const networkToIcon: Record = { - bitcoin: btcIcon, - testnet: testnetIcon, - testnet4: testnetIcon, - signet: signetIcon, - regtest: signetIcon, -}; +import { AssetIcon } from './AssetIcon'; export const SendForm: SnapComponent = ({ currency, @@ -42,9 +30,7 @@ export const SendForm: SnapComponent = ({ return (
- - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts index 83f6bdd5..466a7b5b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts @@ -1,5 +1,5 @@ +import { ChangeSet } from '@metamask/bitcoindevkit'; import type { SLIP10Node } from '@metamask/key-tree'; -import { ChangeSet } from 'bitcoindevkit'; import { mock } from 'jest-mock-extended'; import type { @@ -13,7 +13,7 @@ import { BdkAccountRepository } from './BdkAccountRepository'; // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 /* eslint-disable @typescript-eslint/naming-convention */ -jest.mock('bitcoindevkit', () => { +jest.mock('@metamask/bitcoindevkit', () => { return { ChangeSet: { from_json: jest.fn(), diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts index 56d8ea35..e81367a4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts @@ -1,13 +1,13 @@ // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 /* eslint-disable camelcase */ -import type { AddressType, Network } from 'bitcoindevkit'; +import type { AddressType, Network } from '@metamask/bitcoindevkit'; import { ChangeSet, slip10_to_extended, xpriv_to_descriptor, xpub_to_descriptor, -} from 'bitcoindevkit'; +} from '@metamask/bitcoindevkit'; import { v4 } from 'uuid'; import type { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 02e59952..1213b760 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -5,7 +5,7 @@ import type { AddressType, Network, Psbt, -} from 'bitcoindevkit'; +} from '@metamask/bitcoindevkit'; import { mock } from 'jest-mock-extended'; import type { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 6ae636c1..ab0c8971 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -1,4 +1,9 @@ -import type { AddressType, Network, Txid, WalletTx } from 'bitcoindevkit'; +import type { + AddressType, + Network, + Txid, + WalletTx, +} from '@metamask/bitcoindevkit'; import type { AccountsConfig, diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index e55986dc..38a463a0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -1,7 +1,12 @@ +import type { + Psbt, + FeeEstimates, + Network, + AddressInfo, +} from '@metamask/bitcoindevkit'; +import { Address, Amount } from '@metamask/bitcoindevkit'; import type { GetPreferencesResult } from '@metamask/snaps-sdk'; import { UserRejectedRequestError } from '@metamask/snaps-sdk'; -import type { Psbt, FeeEstimates, Network, AddressInfo } from 'bitcoindevkit'; -import { Address, Amount } from 'bitcoindevkit'; import { mock } from 'jest-mock-extended'; import type { @@ -25,7 +30,7 @@ import { SendFlowUseCases } from './SendFlowUseCases'; // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 /* eslint-disable @typescript-eslint/naming-convention */ -jest.mock('bitcoindevkit', () => { +jest.mock('@metamask/bitcoindevkit', () => { return { Address: { from_string: jest.fn(), diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index 8911a964..4f81d75b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -1,6 +1,6 @@ +import { Address, Amount } from '@metamask/bitcoindevkit'; import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; import { UserRejectedRequestError } from '@metamask/snaps-sdk'; -import { Address, Amount } from 'bitcoindevkit'; import type { BitcoinAccountRepository, From dc4a00355049197971cb0b092c49e170520511df Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 1 Apr 2025 17:39:16 +0200 Subject: [PATCH 214/362] fix: discard own outputs from send transactions (#441) --- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/entities/account.ts | 8 -------- .../src/handlers/KeyringHandler.test.ts | 10 ++++++++++ .../bitcoin-wallet-snap/src/handlers/mappings.ts | 4 +--- .../bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts | 9 --------- 5 files changed, 12 insertions(+), 21 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 82585434..5a80f3b3 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "1I0R3uQ7zqRY746bh2PgF5tHKR3v44TdFf/dZdFdlJY=", + "shasum": "/S/AFs9ZMkitYDd7pCB/YeGziaOJfQPnhJh25G2v1PA=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index cdbf4ca4..5bf5fba0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -13,7 +13,6 @@ import type { WalletTx, Amount, ScriptBuf, - KeychainKind, } from '@metamask/bitcoindevkit'; import type { Inscription } from './meta-protocols'; @@ -148,13 +147,6 @@ export type BitcoinAccount = { * @returns the sent and received amounts. */ sentAndReceived(tx: Transaction): [Amount, Amount]; - - /** - * Finds how the wallet derived the script pubkey `spk`. - * @param spk - The Bitcoin script. - * @returns the keychain used and derivation index, if the script is found. - */ - derivationOfSpk(spk: ScriptBuf): [KeychainKind, number] | undefined; }; /** diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 5b625b52..22514fe0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -360,6 +360,16 @@ describe('KeyringHandler', () => { expect(result.data).toStrictEqual([expectedResult]); }); + it('discards own outputs from send transactions', async () => { + const id = 'some-id'; + mockAccount.isMine.mockReturnValueOnce(true); + + const result = await handler.listAccountTransactions(id, pagination); + + expect(mockAccounts.get).toHaveBeenCalledWith(id); + expect(result.data).toStrictEqual([{ ...expectedResult, to: [] }]); + }); + it('lists transactions successfully: receive', async () => { const id = 'some-id'; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index 1d78c473..54159505 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -154,9 +154,7 @@ export function mapToTransaction( // - from: empty as irrevelant because we might have hundreds of inputs in a tx. Point to explorer for details. if (isSend) { for (const txout of tx.output) { - const spkIndex = account.derivationOfSpk(txout.script_pubkey); - const isConsolidation = spkIndex && spkIndex[0] === 'external'; - if (!spkIndex || isConsolidation) { + if (!account.isMine(txout.script_pubkey)) { transaction.to.push(mapToAssetMovement(txout, network)); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index 5319b673..3e338b2b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -14,7 +14,6 @@ import type { WalletTx, Amount, ScriptBuf, - KeychainKind, } from '@metamask/bitcoindevkit'; import { Txid, Wallet } from '@metamask/bitcoindevkit'; @@ -150,12 +149,4 @@ export class BdkAccountAdapter implements BitcoinAccount { const sentAndReceived = this.#wallet.sent_and_received(tx.clone()); return [sentAndReceived[0], sentAndReceived[1]]; } - - derivationOfSpk(spk: ScriptBuf): [KeychainKind, number] | undefined { - const spkIndex = this.#wallet.derivation_of_spk(spk); - if (!spkIndex) { - return undefined; - } - return [spkIndex[0], spkIndex[1]]; - } } From fd93730709fe1ea39f14085cd14d3152af3c5587 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 3 Apr 2025 12:32:51 +0200 Subject: [PATCH 215/362] chore: refactor locale (#433) --- .../bitcoin-wallet-snap/package.json | 1 - .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/entities/index.ts | 2 +- .../src/entities/locale.test.ts | 63 ------------------- .../src/entities/locale.ts | 59 ----------------- .../src/entities/send-flow.ts | 2 + .../src/entities/translator.ts | 12 ++++ .../bitcoin-wallet-snap/src/index.ts | 27 +++----- .../src/infra/LocalTranslatorAdapter.ts | 27 ++++++++ .../bitcoin-wallet-snap/src/infra/index.ts | 1 + .../src/infra/jsx/ReviewTransactionView.tsx | 20 +++--- .../src/infra/jsx/SendFormView.tsx | 36 +++++++---- .../src/infra/jsx/components/SendForm.tsx | 23 +++---- .../jsx/components/TransactionSummary.tsx | 8 ++- .../src/infra/jsx/format.ts | 7 ++- .../src/store/JSXSendFlowRepository.test.tsx | 27 +++++--- .../src/store/JSXSendFlowRepository.tsx | 15 +++-- .../src/use-cases/SendFlowUseCases.test.ts | 14 ++++- .../src/use-cases/SendFlowUseCases.ts | 8 ++- 19 files changed, 158 insertions(+), 196 deletions(-) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/locale.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/locale.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/translator.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/LocalTranslatorAdapter.ts diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 423c1a66..bd8c89c2 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -53,7 +53,6 @@ "@types/qs": "^6.9.18", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", - "async-mutex": "^0.3.2", "concurrently": "^9.1.0", "dotenv": "^16.4.5", "eslint": "^8.45.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 5a80f3b3..6d239edb 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "/S/AFs9ZMkitYDd7pCB/YeGziaOJfQPnhJh25G2v1PA=", + "shasum": "OeXZenJhsJPV87o2BGQwws/YbHQvJP6StxxY3nd8njg=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts index f71016e2..0c5f72a9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts @@ -7,6 +7,6 @@ export * from './transaction'; export * from './snap'; export * from './meta-protocols'; export * from './error'; -export * from './locale'; +export * from './translator'; export * from './rates'; export * from './logger'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/locale.test.ts b/merged-packages/bitcoin-wallet-snap/src/entities/locale.test.ts deleted file mode 100644 index 78d39ab6..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/entities/locale.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* eslint-disable no-restricted-globals */ -import { - loadLocale, - getUserLocale, - getTranslator, - getUserLocalePreference, -} from './locale'; - -const mockSnapRequest = jest.fn(); - -jest.mock('../../locales/en.json', () => ({ - messages: { greeting: { message: 'Hello' } }, -})); - -(global as any).snap = { - request: mockSnapRequest.mockResolvedValue({ locale: 'en' }), -}; - -describe('locale utils', () => { - describe('getUserLocale', () => { - it("returns the locale messages for the user's preferred locale", async () => { - const locale = await getUserLocalePreference(); - expect(locale).toStrictEqual({ greeting: { message: 'Hello' } }); - }); - - it("returns the default locale messages if the user's preferred locale is not available", async () => { - mockSnapRequest.mockRejectedValue(new Error('Locale not found')); - - const locale = await getUserLocalePreference(); - expect(locale).toStrictEqual({ greeting: { message: 'Hello' } }); - }); - }); - - describe('loadLocale', () => { - it('loads and set the user locale', async () => { - await loadLocale(); - const locale = getUserLocale(); - expect(locale).toStrictEqual({ greeting: { message: 'Hello' } }); - }); - - it('loads and set the default locale if user locale is not available', async () => { - mockSnapRequest.mockRejectedValue(new Error('Locale not found')); - - await loadLocale(); - const locale = getUserLocale(); - expect(locale).toStrictEqual({ greeting: { message: 'Hello' } }); - }); - }); - - describe('getTranslator', () => { - it('translates keys to user locale messages', async () => { - await loadLocale(); - const translate = getTranslator(); - expect(translate('greeting')).toBe('Hello'); - }); - - it('returns the key wrapped in curly braces if translation is not found', async () => { - await loadLocale(); - const translate = getTranslator(); - expect(translate('nonexistent')).toBe('{nonexistent}'); - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/locale.ts b/merged-packages/bitcoin-wallet-snap/src/entities/locale.ts deleted file mode 100644 index 3d721e0e..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/entities/locale.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Mutex } from 'async-mutex'; - -const localeSetterLock = new Mutex(); -let locale: Locale; - -/** - * Loads the user's locale preferences and sets the locale variable. - */ -export async function loadLocale() { - await localeSetterLock.runExclusive(async () => { - locale = await getUserLocalePreference(); - }); -} - -/** - * Retrieves the current user locale. - * - * @returns The current user locale. - */ -export function getUserLocale() { - return locale; -} - -export type Locale = Record< - string, - { - message: string; - } ->; - -/** - * Retrieves the user's locale preferences. - * - * @returns A promise that resolves to the user's locale messages. - */ -export async function getUserLocalePreference(): Promise { - try { - const { locale: userLocale } = await snap.request({ - method: 'snap_getPreferences', - }); - return (await import(`../../locales/${userLocale}.json`)).messages; - } catch { - return (await import(`../../locales/en.json`)).messages; - } -} - -export type Translator = (string) => string; - -/** - * Returns a translator function that translates keys to user locale messages. - * - * @returns A function that translates keys to user locale messages. - */ -export function getTranslator(): Translator { - const userLocale = getUserLocale(); - return (key: string) => { - return userLocale[key]?.message ?? `{${key}}`; - }; -} diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts index dba0aec2..c0d88b78 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts @@ -25,6 +25,7 @@ export type SendFormContext = { amount?: string; }; backgroundEventId?: string; + locale: string; }; export enum SendFormEvent { @@ -53,6 +54,7 @@ export type ReviewTransactionContext = { fee: string; backgroundEventId?: string; drain?: boolean; + locale: string; /** * Used to repopulate the send form if the user decides to go back in the flow diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/translator.ts b/merged-packages/bitcoin-wallet-snap/src/entities/translator.ts new file mode 100644 index 00000000..ad3a1e62 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/translator.ts @@ -0,0 +1,12 @@ +export type Messages = Record; + +/** + * A Translator. + */ +export type Translator = { + /** + * Load messages given a locale. Defaults to english if the locale is not found. + * @param locale - the locale. + */ + load(locale: string): Promise; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 59d131b4..a9dfaad5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -3,18 +3,15 @@ import type { OnAssetsConversionHandler, OnAssetsLookupHandler, OnCronjobHandler, + OnRpcRequestHandler, + OnKeyringRequestHandler, + OnUserInputHandler, + Json, } from '@metamask/snaps-sdk'; -import { - type OnRpcRequestHandler, - type OnKeyringRequestHandler, - type OnUserInputHandler, - type Json, - UnauthorizedError, - SnapError, -} from '@metamask/snaps-sdk'; +import { UnauthorizedError, SnapError } from '@metamask/snaps-sdk'; import { Config } from './config'; -import { isSnapRpcError, loadLocale } from './entities'; +import { isSnapRpcError } from './entities'; import { KeyringHandler, CronHandler, @@ -28,6 +25,7 @@ import { SimpleHashClientAdapter, PriceApiClientAdapter, ConsoleLoggerAdapter, + LocalTranslatorAdapter, } from './infra'; import { originPermissions } from './permissions'; import { BdkAccountRepository, JSXSendFlowRepository } from './store'; @@ -39,10 +37,11 @@ const snapClient = new SnapClientAdapter(Config.encrypt); const chainClient = new EsploraClientAdapter(Config.chain); const metaProtocolsClient = new SimpleHashClientAdapter(Config.simpleHash); const assetRatesClient = new PriceApiClientAdapter(Config.priceApi); +const translator = new LocalTranslatorAdapter(); // Data layer const accountRepository = new BdkAccountRepository(snapClient); -const sendFlowRepository = new JSXSendFlowRepository(snapClient); +const sendFlowRepository = new JSXSendFlowRepository(snapClient, translator); // Business layer const accountsUseCases = new AccountUseCases( @@ -88,8 +87,6 @@ export const validateOrigin = (origin: string, method: string): void => { }; export const onCronjob: OnCronjobHandler = async ({ request }) => { - await loadLocale(); - try { await cronHandler.route(request.method, request.params); } catch (error) { @@ -109,8 +106,6 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request, }): Promise => { - await loadLocale(); - try { const { method } = request; validateOrigin(origin, method); @@ -132,8 +127,6 @@ export const onKeyringRequest: OnKeyringRequestHandler = async ({ origin, request, }): Promise => { - await loadLocale(); - try { validateOrigin(origin, request.method); return (await handleKeyringRequest(keyringHandler, request)) ?? null; @@ -155,8 +148,6 @@ export const onUserInput: OnUserInputHandler = async ({ event, context, }) => { - await loadLocale(); - try { return userInputHandler.route(id, event, context); } catch (error) { diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/LocalTranslatorAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/LocalTranslatorAdapter.ts new file mode 100644 index 00000000..c8284d22 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/LocalTranslatorAdapter.ts @@ -0,0 +1,27 @@ +import type { Messages, Translator } from '../entities'; + +export class LocalTranslatorAdapter implements Translator { + #messages: Messages; + + #locale: string; + + constructor() { + this.#locale = ''; + this.#messages = {}; + } + + async load(locale: string): Promise { + if (this.#locale === locale) { + return this.#messages; + } + + try { + this.#messages = (await import(`../../locales/${locale}.json`)).messages; + } catch { + this.#messages = (await import(`../../locales/en.json`)).messages; + } + + this.#locale = locale; + return this.#messages; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts index 5c2ad7d7..d0a92667 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts @@ -4,3 +4,4 @@ export * from './EsploraClientAdapter'; export * from './SimpleHashClientAdapter'; export * from './PriceApiClientAdapter'; export * from './ConsoleLoggerAdapter'; +export * from './LocalTranslatorAdapter'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx index cd763a10..06393e8b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx @@ -14,17 +14,21 @@ import { import type { CaipAccountId } from '@metamask/utils'; import { Config } from '../../config'; -import type { ReviewTransactionContext } from '../../entities'; +import type { Messages, ReviewTransactionContext } from '../../entities'; import { BlockTime, ReviewTransactionEvent } from '../../entities'; -import { getTranslator } from '../../entities/locale'; import { networkToCaip2 } from '../../handlers'; import { AssetIcon, HeadingWithReturn } from './components'; -import { displayAmount, displayExchangeAmount } from './format'; +import { displayAmount, displayExchangeAmount, translate } from './format'; -export const ReviewTransactionView: SnapComponent = ( - props, -) => { - const t = getTranslator(); +type ReviewTransactionViewProps = { + context: ReviewTransactionContext; + messages: Messages; +}; + +export const ReviewTransactionView: SnapComponent< + ReviewTransactionViewProps +> = ({ context, messages }) => { + const t = translate(messages); const { amount, fee, @@ -34,7 +38,7 @@ export const ReviewTransactionView: SnapComponent = ( recipient, network, from, - } = props; + } = context; const total = BigInt(amount) + BigInt(fee); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx index 8bfcfed0..7eee4a70 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx @@ -8,13 +8,21 @@ import { Text, } from '@metamask/snaps-sdk/jsx'; -import type { SendFormContext } from '../../entities'; +import type { Messages, SendFormContext } from '../../entities'; import { SendFormEvent } from '../../entities'; -import { getTranslator } from '../../entities/locale'; import { HeadingWithReturn, SendForm, TransactionSummary } from './components'; +import { translate } from './format'; -export const SendFormView: SnapComponent = (props) => { - const t = getTranslator(); +export type SendFormViewProps = { + context: SendFormContext; + messages: Messages; +}; + +export const SendFormView: SnapComponent = ({ + context, + messages, +}) => { + const t = translate(messages); return ( @@ -24,25 +32,29 @@ export const SendFormView: SnapComponent = (props) => { returnButtonName={SendFormEvent.Cancel} /> - + - {props.errors.tx !== undefined && ( + {context.errors.tx !== undefined && ( - {props.errors.tx} + {context.errors.tx} )} - {props.fee !== undefined && props.amount !== undefined && ( + {context.fee !== undefined && context.amount !== undefined && ( )}
-
diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx index 9445c2b4..76d90b69 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx @@ -6,24 +6,21 @@ import { Icon, Input, Text, - type SnapComponent, } from '@metamask/snaps-sdk/jsx'; -import type { SendFormContext } from '../../../entities'; +import type { Messages, SendFormContext } from '../../../entities'; import { SENDFORM_NAME, SendFormEvent } from '../../../entities'; -import { getTranslator } from '../../../entities/locale'; -import { displayAmount } from '../format'; +import { displayAmount, translate } from '../format'; import { AssetIcon } from './AssetIcon'; -export const SendForm: SnapComponent = ({ - currency, - balance, - amount, - recipient, - errors, - network, -}) => { - const t = getTranslator(); +type SendFormProps = SendFormContext & { + messages: Messages; +}; + +export const SendForm = (props: SendFormProps) => { + const { currency, balance, amount, recipient, errors, network, messages } = + props; + const t = translate(messages); const validAddress = Boolean(recipient && !errors.recipient); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx index 8082c1ca..d90e136f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx @@ -9,9 +9,9 @@ import { } from '@metamask/snaps-sdk/jsx'; import { Config } from '../../../config'; +import type { Messages } from '../../../entities'; import { BlockTime, type CurrencyUnit } from '../../../entities'; -import { getTranslator } from '../../../entities/locale'; -import { displayAmount, displayExchangeAmount } from '../format'; +import { displayAmount, displayExchangeAmount, translate } from '../format'; type TransactionSummaryProps = { currency: CurrencyUnit; @@ -19,6 +19,7 @@ type TransactionSummaryProps = { amount: string; fee: string; network: Network; + messages: Messages; }; export const TransactionSummary: SnapComponent = ({ @@ -27,8 +28,9 @@ export const TransactionSummary: SnapComponent = ({ currency, exchangeRate, network, + messages, }) => { - const t = getTranslator(); + const t = translate(messages); const total = BigInt(amount) + BigInt(fee); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts index 57e8c098..af7dad9e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts @@ -1,7 +1,7 @@ import { Amount } from '@metamask/bitcoindevkit'; import type { CurrencyRate } from '@metamask/snaps-sdk'; -import type { CurrencyUnit } from '../../entities'; +import type { CurrencyUnit, Messages } from '../../entities'; export const displayAmount = ( amountSats: bigint, @@ -25,3 +25,8 @@ export const displayExchangeAmount = ( }` : ''; }; + +export const translate = + (messages: Messages) => + (key: string): string => + messages[key]?.message ?? `{${key}}`; diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx index e1bd8491..fb180f6a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx @@ -4,6 +4,7 @@ import type { SnapClient, SendFormContext, ReviewTransactionContext, + Translator, } from '../entities'; import { SENDFORM_NAME } from '../entities'; import { ReviewTransactionView, SendFormView } from '../infra/jsx'; @@ -15,12 +16,12 @@ jest.mock('../infra/jsx', () => ({ })); describe('JSXSendFlowRepository', () => { + const mockMessages = { foo: { message: 'bar' } }; + const mockSnapClient = mock(); - let repo: JSXSendFlowRepository; + const mockTranslator = mock(); - beforeEach(() => { - repo = new JSXSendFlowRepository(mockSnapClient); - }); + const repo = new JSXSendFlowRepository(mockSnapClient, mockTranslator); describe('getState', () => { it('returns send form state if found', async () => { @@ -82,15 +83,17 @@ describe('JSXSendFlowRepository', () => { describe('insertForm', () => { it('creates interface with correct context', async () => { + const mockContext = mock({ locale: 'en' }); mockSnapClient.createInterface.mockResolvedValue('interface-id'); - const mockContext = mock({}); + mockTranslator.load.mockResolvedValue(mockMessages); const result = await repo.insertForm(mockContext); expect(mockSnapClient.createInterface).toHaveBeenCalledWith( - , + , mockContext, ); + expect(mockTranslator.load).toHaveBeenCalledWith('en'); expect(result).toBe('interface-id'); }); }); @@ -98,13 +101,15 @@ describe('JSXSendFlowRepository', () => { describe('updateForm', () => { it('updates interface with context', async () => { const id = 'interface-id'; - const mockContext = mock({}); + const mockContext = mock({ locale: 'de' }); + mockTranslator.load.mockResolvedValue(mockMessages); await repo.updateForm(id, mockContext); + expect(mockTranslator.load).toHaveBeenCalledWith('de'); expect(mockSnapClient.updateInterface).toHaveBeenCalledWith( id, - , + , mockContext, ); }); @@ -113,13 +118,15 @@ describe('JSXSendFlowRepository', () => { describe('updateReview', () => { it('updates interface with context', async () => { const id = 'interface-id'; - const mockContext = mock({}); + const mockContext = mock({ locale: 'fr' }); + mockTranslator.load.mockResolvedValue(mockMessages); await repo.updateReview(id, mockContext); + expect(mockTranslator.load).toHaveBeenCalledWith('fr'); expect(mockSnapClient.updateInterface).toHaveBeenCalledWith( id, - , + , mockContext, ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx index 261fa8b0..5deb62e4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx @@ -4,6 +4,7 @@ import type { SendFormState, SnapClient, ReviewTransactionContext, + Translator, } from '../entities'; import { SENDFORM_NAME } from '../entities'; import { ReviewTransactionView, SendFormView } from '../infra/jsx'; @@ -11,8 +12,11 @@ import { ReviewTransactionView, SendFormView } from '../infra/jsx'; export class JSXSendFlowRepository implements SendFlowRepository { readonly #snapClient: SnapClient; - constructor(snapClient: SnapClient) { + readonly #translator: Translator; + + constructor(snapClient: SnapClient, translator: Translator) { this.#snapClient = snapClient; + this.#translator = translator; } async getState(id: string): Promise { @@ -31,16 +35,18 @@ export class JSXSendFlowRepository implements SendFlowRepository { } async insertForm(context: SendFormContext): Promise { + const messages = await this.#translator.load(context.locale); return this.#snapClient.createInterface( - , + , context, ); } async updateForm(id: string, context: SendFormContext): Promise { + const messages = await this.#translator.load(context.locale); return this.#snapClient.updateInterface( id, - , + , context, ); } @@ -49,9 +55,10 @@ export class JSXSendFlowRepository implements SendFlowRepository { id: string, context: ReviewTransactionContext, ): Promise { + const messages = await this.#translator.load(context.locale); return this.#snapClient.updateInterface( id, - , + , context, ); } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 38a463a0..293aef78 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -62,6 +62,10 @@ describe('SendFlowUseCases', () => { peekAddress: jest.fn(), }); const mockTxRequest = mock(); + const mockPreferences = mock({ + currency: 'usd', + locale: 'en', + }); const useCases = new SendFlowUseCases( mockLogger, @@ -84,6 +88,7 @@ describe('SendFlowUseCases', () => { }), ); mockSendFlowRepository.insertForm.mockResolvedValue('interface-id'); + mockSnapClient.getPreferences.mockResolvedValue(mockPreferences); }); it('throws error if account not found', async () => { @@ -107,6 +112,7 @@ describe('SendFlowUseCases', () => { const result = await useCases.display('account-id'); expect(mockAccountRepository.get).toHaveBeenCalledWith('account-id'); + expect(mockSnapClient.getPreferences).toHaveBeenCalled(); expect(mockSendFlowRepository.insertForm).toHaveBeenCalledWith({ balance: '1234', currency: CurrencyUnit.Bitcoin, @@ -114,6 +120,7 @@ describe('SendFlowUseCases', () => { network: 'bitcoin', feeRate: fallbackFeeRate, errors: {}, + locale: 'en', }); expect(mockSnapClient.displayInterface).toHaveBeenCalledWith( 'interface-id', @@ -154,6 +161,7 @@ describe('SendFlowUseCases', () => { conversionDate: 2025, }, backgroundEventId: 'backgroundEventId', + locale: 'en', }; beforeEach(() => { @@ -242,6 +250,7 @@ describe('SendFlowUseCases', () => { fee: '10', sendForm: mockContext, drain: mockContext.drain, + locale: 'en', }; await useCases.onChangeForm('interface-id', SendFormEvent.Confirm); @@ -435,6 +444,7 @@ describe('SendFlowUseCases', () => { sendForm: { network: 'bitcoin', } as SendFormContext, + locale: 'en', }; it('throws error unrecognized event', async () => { @@ -461,6 +471,8 @@ describe('SendFlowUseCases', () => { it('reverts interface back to send form if present in context', async () => { const id = 'interface-id'; + mockSnapClient.getPreferences.mockResolvedValue(mockPreferences); + await useCases.onChangeReview( id, ReviewTransactionEvent.HeaderBack, @@ -503,11 +515,11 @@ describe('SendFlowUseCases', () => { errors: {}, network: 'bitcoin', feeRate: fallbackFeeRate, + locale: 'en', }; const mockExchangeRates = { usd: { value: 200000 }, }; - const mockPreferences = mock({ currency: 'usd' }); const mockFeeRate = 4.4; beforeEach(() => { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index 4f81d75b..a33a4ef4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -68,6 +68,8 @@ export class SendFlowUseCases { throw new Error('Account not found'); } + const { locale } = await this.#snapClient.getPreferences(); + const context: SendFormContext = { balance: account.balance.trusted_spendable.to_sat().toString(), currency: networkToCurrencyUnit[account.network], @@ -78,6 +80,7 @@ export class SendFlowUseCases { network: account.network, feeRate: this.#fallbackFeeRate, errors: {}, + locale, }; const interfaceId = await this.#sendFlowRepository.insertForm(context); @@ -151,6 +154,7 @@ export class SendFlowUseCases { fee: context.fee, drain: context.drain, sendForm: context, + locale: context.locale, }; return this.#sendFlowRepository.updateReview(id, reviewContext); } @@ -284,6 +288,8 @@ export class SendFlowUseCases { const { network } = context; let updatedContext = { ...context }; + const { locale, currency } = await this.#snapClient.getPreferences(); + try { const feeEstimates = await this.#chainClient.getFeeEstimates(network); const feeRate = @@ -294,7 +300,6 @@ export class SendFlowUseCases { // Exchange rate is only relevant for Bitcoin if (network === 'bitcoin') { const exchangeRates = await this.#ratesClient.exchangeRates(); - const { currency } = await this.#snapClient.getPreferences(); const conversionRate = exchangeRates[currency]; if (conversionRate) { updatedContext.exchangeRate = { @@ -321,6 +326,7 @@ export class SendFlowUseCases { SendFormEvent.RefreshRates, id, ); + updatedContext.locale = locale; // Take advantage of the loop to update the locale as well await this.#sendFlowRepository.updateForm(id, updatedContext); } From cd02d7bf2c032bb4574e73b8b60172506ddd4715 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 7 Apr 2025 18:08:43 +0200 Subject: [PATCH 216/362] feat: align latest dependencies (#443) --- .../bitcoin-wallet-snap/package.json | 12 ++--- .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../bitcoin-wallet-snap/src/entities/snap.ts | 6 ++- .../src/handlers/KeyringHandler.test.ts | 13 +++++- .../src/handlers/KeyringHandler.ts | 14 ++++-- .../src/infra/SnapClientAdapter.ts | 13 +++++- .../src/use-cases/AccountUseCases.test.ts | 44 +++++++++++++++---- .../src/use-cases/AccountUseCases.ts | 15 ++++--- 8 files changed, 90 insertions(+), 31 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index bd8c89c2..18e148cb 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -42,14 +42,14 @@ "@metamask/eslint-config-jest": "^12.1.0", "@metamask/eslint-config-nodejs": "^12.1.0", "@metamask/eslint-config-typescript": "^12.1.0", - "@metamask/key-tree": "^10.1.0", - "@metamask/keyring-api": "^17.2.1", - "@metamask/keyring-snap-sdk": "^3.1.0", + "@metamask/key-tree": "^10.1.1", + "@metamask/keyring-api": "^17.4.0", + "@metamask/keyring-snap-sdk": "^3.2.0", "@metamask/slip44": "^4.1.0", "@metamask/snaps-cli": "^6.7.0", - "@metamask/snaps-jest": "^8.13.0", - "@metamask/snaps-sdk": "^6.19.0", - "@metamask/utils": "^11.2.0", + "@metamask/snaps-jest": "^8.14.0", + "@metamask/snaps-sdk": "^6.21.0", + "@metamask/utils": "^11.4.0", "@types/qs": "^6.9.18", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 6d239edb..54f2c799 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "OeXZenJhsJPV87o2BGQwws/YbHQvJP6StxxY3nd8njg=", + "shasum": "XBPaTd2zn8y4X/Dn5/3glFdC1x1MdGL/XBrfEI6KK2k=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -97,6 +97,6 @@ ] } }, - "platformVersion": "6.19.0", + "platformVersion": "6.21.0", "manifestVersion": "0.1" } diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index c4a98c17..98a58cb8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -50,8 +50,12 @@ export type SnapClient = { /** * Emit an event notifying the extension of a newly created Bitcoin account * @param account - The Bitcoin account. + * @param correlationId - The correlation ID to be used for the event. */ - emitAccountCreatedEvent(account: BitcoinAccount): Promise; + emitAccountCreatedEvent( + account: BitcoinAccount, + correlationId?: string, + ): Promise; /** * Emit an event notifying the extension of a deleted Bitcoin account diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 22514fe0..d85ab613 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -38,7 +38,6 @@ jest.mock('@metamask/bitcoindevkit', () => { describe('KeyringHandler', () => { const mockAccounts = mock(); - const mockAddress = mock
({ toString: () => 'bc1qaddress...', }); @@ -62,18 +61,27 @@ describe('KeyringHandler', () => { }); describe('createAccount', () => { - it('respects provided provided scope and addressType', async () => { + const entropySource = 'some-source'; + const correlationId = 'correlation-id'; + + it('respects provided params', async () => { mockAccounts.create.mockResolvedValue(mockAccount); const options = { scope: BtcScope.Signet, + entropySource, addressType: Caip2AddressType.P2pkh, + metamask: { + correlationId, + }, }; await handler.createAccount(options); expect(assert).toHaveBeenCalledWith(options, CreateAccountRequest); expect(mockAccounts.create).toHaveBeenCalledWith( caip2ToNetwork[BtcScope.Signet], + entropySource, caip2ToAddressType[Caip2AddressType.P2pkh], + correlationId, ); expect(mockAccounts.fullScan).not.toHaveBeenCalled(); }); @@ -82,6 +90,7 @@ describe('KeyringHandler', () => { mockAccounts.create.mockResolvedValue(mockAccount); const options = { scope: BtcScope.Signet, + entropySourceId: entropySource, addressType: Caip2AddressType.P2pkh, synchronize: true, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 284c61bb..f09c797f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -1,4 +1,4 @@ -import { BtcScope } from '@metamask/keyring-api'; +import { BtcScope, MetaMaskOptionsStruct } from '@metamask/keyring-api'; import type { Keyring, KeyringAccount, @@ -9,9 +9,10 @@ import type { Paginated, Transaction, Pagination, + MetaMaskOptions, } from '@metamask/keyring-api'; import type { Json } from '@metamask/utils'; -import { assert, boolean, enums, object, optional } from 'superstruct'; +import { assert, boolean, enums, object, optional, string } from 'superstruct'; import { networkToCurrencyUnit } from '../entities'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; @@ -27,7 +28,10 @@ import { mapToKeyringAccount, mapToTransaction } from './mappings'; export const CreateAccountRequest = object({ scope: enums(Object.values(BtcScope)), addressType: optional(enums(Object.values(Caip2AddressType))), + entropySource: optional(string()), + accountNameSuggestion: optional(string()), synchronize: optional(boolean()), + ...MetaMaskOptionsStruct.schema, }); export class KeyringHandler implements Keyring { @@ -47,12 +51,16 @@ export class KeyringHandler implements Keyring { return mapToKeyringAccount(account); } - async createAccount(opts: Record): Promise { + async createAccount( + opts: Record & MetaMaskOptions, + ): Promise { assert(opts, CreateAccountRequest); const account = await this.#accountsUseCases.create( caip2ToNetwork[opts.scope], + opts.entropySource, opts.addressType ? caip2ToAddressType[opts.addressType] : undefined, + opts.metamask?.correlationId, ); if (opts.synchronize) { diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 1769ceba..a955dacd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -58,11 +58,15 @@ export class SnapClientAdapter implements SnapClient { } async getPrivateEntropy(derivationPath: string[]): Promise { + const source = derivationPath[0] === 'm' ? undefined : derivationPath[0]; + const path = ['m', ...derivationPath.slice(1)]; + return snap.request({ method: 'snap_getBip32Entropy', params: { - path: derivationPath, + path, curve: 'secp256k1', + source, }, }); } @@ -72,12 +76,17 @@ export class SnapClientAdapter implements SnapClient { return (await SLIP10Node.fromJSON(slip10)).neuter(); } - async emitAccountCreatedEvent(account: BitcoinAccount): Promise { + async emitAccountCreatedEvent( + account: BitcoinAccount, + correlationId?: string, + ): Promise { return emitSnapKeyringEvent(snap, KeyringEvent.AccountCreated, { account: mapToKeyringAccount(account), accountNameSuggestion: `${networkToName[account.network]} ${ addressTypeToName[account.addressType] }`, + displayConfirmation: false, + ...(correlationId ? { metamask: { correlationId } } : {}), }); } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 1213b760..abf85a56 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -102,6 +102,9 @@ describe('AccountUseCases', () => { describe('create', () => { const network: Network = 'bitcoin'; const addressType: AddressType = 'p2wpkh'; + const entropySource = 'some-source'; + const correlationId = 'some-correlation-id'; + const mockAccount = mock(); beforeEach(() => { @@ -117,9 +120,19 @@ describe('AccountUseCases', () => { ] as { tAddressType: AddressType; purpose: string }[])( 'creates an account of type: %s', async ({ tAddressType, purpose }) => { - const derivationPath = ['m', purpose, "0'", `${accountsConfig.index}'`]; + const derivationPath = [ + entropySource, + purpose, + "0'", + `${accountsConfig.index}'`, + ]; - await useCases.create(network, tAddressType); + await useCases.create( + network, + entropySource, + tAddressType, + correlationId, + ); expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( derivationPath, @@ -131,6 +144,7 @@ describe('AccountUseCases', () => { ); expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalledWith( mockAccount, + correlationId, ); }, ); @@ -145,13 +159,18 @@ describe('AccountUseCases', () => { 'should create an account on network: %s', async ({ tNetwork, coinType }) => { const expectedDerivationPath = [ - 'm', + entropySource, "84'", coinType, `${accountsConfig.index}'`, ]; - await useCases.create(tNetwork, addressType); + await useCases.create( + tNetwork, + entropySource, + addressType, + correlationId, + ); expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( expectedDerivationPath, @@ -163,6 +182,7 @@ describe('AccountUseCases', () => { ); expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalledWith( mockAccount, + correlationId, ); }, ); @@ -171,7 +191,7 @@ describe('AccountUseCases', () => { const mockExistingAccount = mock(); mockRepository.getByDerivationPath.mockResolvedValue(mockExistingAccount); - const result = await useCases.create(network, addressType); + const result = await useCases.create(network, entropySource, addressType); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.insert).not.toHaveBeenCalled(); @@ -183,7 +203,7 @@ describe('AccountUseCases', () => { it('creates a new account if one does not exist', async () => { mockRepository.getByDerivationPath.mockResolvedValue(null); - const result = await useCases.create(network, addressType); + const result = await useCases.create(network, entropySource, addressType); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.insert).toHaveBeenCalled(); @@ -196,7 +216,9 @@ describe('AccountUseCases', () => { const error = new Error(); mockRepository.getByDerivationPath.mockRejectedValue(error); - await expect(useCases.create(network, addressType)).rejects.toBe(error); + await expect( + useCases.create(network, entropySource, addressType), + ).rejects.toBe(error); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.insert).not.toHaveBeenCalled(); @@ -208,7 +230,9 @@ describe('AccountUseCases', () => { mockRepository.getByDerivationPath.mockResolvedValue(null); mockRepository.insert.mockRejectedValue(error); - await expect(useCases.create(network, addressType)).rejects.toBe(error); + await expect( + useCases.create(network, entropySource, addressType), + ).rejects.toBe(error); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.insert).toHaveBeenCalled(); @@ -220,7 +244,9 @@ describe('AccountUseCases', () => { mockRepository.getByDerivationPath.mockResolvedValue(null); mockSnapClient.emitAccountCreatedEvent.mockRejectedValue(error); - await expect(useCases.create(network, addressType)).rejects.toBe(error); + await expect( + useCases.create(network, entropySource, addressType), + ).rejects.toBe(error); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.insert).toHaveBeenCalled(); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index ab0c8971..a513ffb0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -84,16 +84,19 @@ export class AccountUseCases { async create( network: Network, + entropySource?: string, addressType: AddressType = this.#accountConfig.defaultAddressType, + correlationId?: string, ): Promise { - this.#logger.debug( - 'Creating new Bitcoin account. Network: %o. addressType: %o,', + this.#logger.debug('Creating new Bitcoin account. Params: %o', { network, addressType, - ); + entropySource, + correlationId, + }); const derivationPath = [ - 'm', + entropySource ?? 'm', addressTypeToPurpose[addressType], networkToCoinType[network], `${this.#accountConfig.index}'`, @@ -103,7 +106,7 @@ export class AccountUseCases { const account = await this.#repository.getByDerivationPath(derivationPath); if (account) { this.#logger.debug('Account already exists: %s,', account.id); - await this.#snapClient.emitAccountCreatedEvent(account); + await this.#snapClient.emitAccountCreatedEvent(account, correlationId); return account; } @@ -113,7 +116,7 @@ export class AccountUseCases { addressType, ); - await this.#snapClient.emitAccountCreatedEvent(newAccount); + await this.#snapClient.emitAccountCreatedEvent(newAccount, correlationId); this.#logger.info( 'Bitcoin account created successfully: %s. derivationPath: %s', From dc53e854daa65fd10b8780955f24e3a5acff7db2 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 8 Apr 2025 12:38:53 +0200 Subject: [PATCH 217/362] refactor: error handling (#442) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/entities/error.ts | 90 ------------ .../bitcoin-wallet-snap/src/entities/index.ts | 1 - .../src/handlers/AssetsHandler.test.ts | 11 +- .../src/handlers/AssetsHandler.ts | 64 +++++---- .../src/handlers/CronHandler.test.ts | 36 +++-- .../src/handlers/CronHandler.ts | 36 +++-- .../src/handlers/KeyringHandler.ts | 15 +- .../src/handlers/RpcHandler.test.ts | 60 ++++---- .../src/handlers/RpcHandler.ts | 46 +++--- .../src/handlers/UserInputHandler.test.ts | 7 +- .../src/handlers/UserInputHandler.ts | 52 +++---- .../src/handlers/errors.ts | 19 +++ .../src/handlers/permissions.ts | 8 ++ .../bitcoin-wallet-snap/src/index.ts | 132 ++---------------- .../bitcoin-wallet-snap/src/permissions.ts | 44 ------ .../src/use-cases/SendFlowUseCases.test.ts | 95 ++++++++----- .../src/use-cases/SendFlowUseCases.ts | 24 ++-- 18 files changed, 306 insertions(+), 436 deletions(-) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/error.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/permissions.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 54f2c799..564dda3e 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "XBPaTd2zn8y4X/Dn5/3glFdC1x1MdGL/XBrfEI6KK2k=", + "shasum": "zR/hZlrR9dLqzw0OvZ5BTcizTaXkClj553bxUszauq8=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/error.ts b/merged-packages/bitcoin-wallet-snap/src/entities/error.ts deleted file mode 100644 index 4ad5d501..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/entities/error.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { - MethodNotFoundError, - UserRejectedRequestError, - MethodNotSupportedError, - ParseError, - ResourceNotFoundError, - ResourceUnavailableError, - TransactionRejected, - ChainDisconnectedError, - DisconnectedError, - UnauthorizedError, - UnsupportedMethodError, - InternalError, - InvalidInputError, - InvalidParamsError, - InvalidRequestError, - LimitExceededError, - SnapError, -} from '@metamask/snaps-sdk'; - -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); - } -} - -/** - * Compacts an error to a specific error instance. - * - * @param error - The error instance to be compacted. - * @param ErrCtor - The error constructor for the desired error instance. - * @returns The compacted error instance. - */ -export function compactError( - error: ErrorInstance, - ErrCtor: new (message?: string) => ErrorInstance, -): ErrorInstance { - if (error instanceof ErrCtor) { - return error; - } - return new ErrCtor(error.message); -} - -/** - * Determines if the given error is a Snap RPC error. - * - * @param error - The error instance to be checked. - * @returns A boolean indicating whether the error is a Snap RPC error. - */ -export function isSnapRpcError(error: Error): boolean { - const errors = [ - SnapError, - MethodNotFoundError, - UserRejectedRequestError, - MethodNotSupportedError, - MethodNotFoundError, - ParseError, - ResourceNotFoundError, - ResourceUnavailableError, - TransactionRejected, - ChainDisconnectedError, - DisconnectedError, - UnauthorizedError, - UnsupportedMethodError, - InternalError, - InvalidInputError, - InvalidParamsError, - InvalidRequestError, - LimitExceededError, - ]; - return errors.some((errType) => error instanceof errType); -} diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts index 0c5f72a9..e386c6a2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts @@ -6,7 +6,6 @@ export * from './send-flow'; export * from './transaction'; export * from './snap'; export * from './meta-protocols'; -export * from './error'; export * from './translator'; export * from './rates'; export * from './logger'; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts index 439c940e..3f83cbd3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts @@ -1,3 +1,4 @@ +import { SnapError } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; import type { AssetsUseCases } from '../use-cases'; @@ -15,8 +16,8 @@ describe('AssetsHandler', () => { }); describe('lookup', () => { - it('returns data for all networks', () => { - const result = handler.lookup(); + it('returns data for all networks', async () => { + const result = await handler.lookup(); expect(result.assets[Caip19Asset.Bitcoin]?.name).toBe('Bitcoin'); expect(result.assets[Caip19Asset.Testnet]?.name).toBe('Testnet Bitcoin'); @@ -43,7 +44,7 @@ describe('AssetsHandler', () => { { from: Caip19Asset.Signet, to: Caip19Asset.Bitcoin }, { from: Caip19Asset.Regtest, to: Caip19Asset.Bitcoin }, ]; - const result = await handler.conversion({ conversions }); + const result = await handler.conversion(conversions); expect(mockAssetsUseCases.getRates).toHaveBeenCalledTimes(1); expect(mockAssetsUseCases.getRates).toHaveBeenCalledWith([ @@ -101,7 +102,9 @@ describe('AssetsHandler', () => { const error = new Error(); mockAssetsUseCases.getRates.mockRejectedValue(error); - await expect(handler.conversion({ conversions })).rejects.toThrow(error); + await expect(handler.conversion(conversions)).rejects.toThrow( + new SnapError(error), + ); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts index fadbf40e..fa75c73b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts @@ -10,6 +10,7 @@ import type { import type { AssetsUseCases } from '../use-cases'; import { Caip19Asset } from './caip'; +import { handle } from './errors'; import { networkToIcon } from './icons'; export class AssetsHandler { @@ -22,7 +23,9 @@ export class AssetsHandler { this.#expirationInterval = expirationInterval; } - lookup(): OnAssetsLookupResponse { + async lookup(): Promise { + // Static function that cannot fail so no need to use handle() + const metadata = ( network: Network, name: string, @@ -79,9 +82,9 @@ export class AssetsHandler { }; } - async conversion({ - conversions, - }: OnAssetsConversionArguments): Promise { + async conversion( + conversions: OnAssetsConversionArguments['conversions'], + ): Promise { const conversionTime = getCurrentUnixTimestamp(); // Group conversions by "from" @@ -94,34 +97,37 @@ export class AssetsHandler { } const conversionRates: OnAssetsConversionResponse['conversionRates'] = {}; - for (const [fromAsset, toAssets] of Object.entries(assetMap)) { - conversionRates[fromAsset] = {}; - if (fromAsset === Caip19Asset.Bitcoin) { - // For Bitcoin, fetch rates. - for (const [toAsset, rate] of await this.#assetsUseCases.getRates( - toAssets, - )) { - conversionRates[fromAsset][toAsset] = rate - ? { - rate: rate.toString(), - conversionTime, - expirationTime: conversionTime + this.#expirationInterval, - } - : null; - } - } else { - // For every other conversions, we just use a rate of 0. - for (const toAsset of toAssets) { - conversionRates[fromAsset][toAsset] = { - rate: '0', - conversionTime, - expirationTime: conversionTime + 60 * 60 * 24, // Long expiration time (1 day) to avoid unnecessary requests - }; + return handle(async () => { + for (const [fromAsset, toAssets] of Object.entries(assetMap)) { + conversionRates[fromAsset] = {}; + + if (fromAsset === Caip19Asset.Bitcoin) { + // For Bitcoin, fetch rates. + for (const [toAsset, rate] of await this.#assetsUseCases.getRates( + toAssets, + )) { + conversionRates[fromAsset][toAsset] = rate + ? { + rate: rate.toString(), + conversionTime, + expirationTime: conversionTime + this.#expirationInterval, + } + : null; + } + } else { + // For every other conversions, we just use a rate of 0. + for (const toAsset of toAssets) { + conversionRates[fromAsset][toAsset] = { + rate: '0', + conversionTime, + expirationTime: conversionTime + 60 * 60 * 24, // Long expiration time (1 day) to avoid unnecessary requests + }; + } } } - } - return { conversionRates }; + return { conversionRates }; + }); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts index a9d256c7..63f73c41 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -1,3 +1,5 @@ +import { SnapError } from '@metamask/snaps-sdk'; +import type { JsonRpcRequest } from '@metamask/utils'; import { mock } from 'jest-mock-extended'; import type { Logger } from '../entities'; @@ -18,11 +20,12 @@ describe('CronHandler', () => { describe('synchronizeAccounts', () => { const mockAccounts = [mock(), mock()]; + const request = { method: 'synchronizeAccounts' } as JsonRpcRequest; it('synchronizes all accounts', async () => { mockAccountUseCases.list.mockResolvedValue(mockAccounts); - await handler.route('synchronizeAccounts'); + await handler.route(request); expect(mockAccountUseCases.list).toHaveBeenCalled(); expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes( @@ -34,7 +37,9 @@ describe('CronHandler', () => { const error = new Error(); mockAccountUseCases.list.mockRejectedValue(error); - await expect(handler.route('synchronizeAccounts')).rejects.toThrow(error); + await expect(handler.route(request)).rejects.toThrow( + new SnapError(error), + ); }); it('does not propagate errors from synchronize', async () => { @@ -42,7 +47,7 @@ describe('CronHandler', () => { const error = new Error(); mockAccountUseCases.synchronize.mockRejectedValue(error); - await handler.route('synchronizeAccounts'); + await handler.route(request); expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes( mockAccounts.length, @@ -51,29 +56,30 @@ describe('CronHandler', () => { }); describe('refreshRates', () => { + const request = { + method: SendFormEvent.RefreshRates, + params: { interfaceId: 'id' }, + } as unknown as JsonRpcRequest; + it('throws if invalid params', async () => { await expect( - handler.route(SendFormEvent.RefreshRates, { invalid: 'id' }), + handler.route({ ...request, params: { invalid: true } }), ).rejects.toThrow(''); }); it('refreshes the send form rates', async () => { - const interfaceId = 'id'; - await handler.route(SendFormEvent.RefreshRates, { interfaceId }); + await handler.route(request); - expect(mockSendFlowUseCases.onChangeForm).toHaveBeenCalledWith( - interfaceId, - SendFormEvent.RefreshRates, - ); + expect(mockSendFlowUseCases.refresh).toHaveBeenCalledWith('id'); }); - it('propagates errors from onChangeForm', async () => { + it('propagates errors from refresh', async () => { const error = new Error(); - mockSendFlowUseCases.onChangeForm.mockRejectedValue(error); + mockSendFlowUseCases.refresh.mockRejectedValue(error); - await expect( - handler.route(SendFormEvent.RefreshRates, { interfaceId: 'id' }), - ).rejects.toThrow(error); + await expect(handler.route(request)).rejects.toThrow( + new SnapError(error), + ); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index 226b6376..c553795f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -1,9 +1,14 @@ -import type { JsonRpcParams } from '@metamask/utils'; +import type { JsonRpcRequest } from '@metamask/utils'; import { assert, object, string } from 'superstruct'; import type { Logger } from '../entities'; import { SendFormEvent } from '../entities'; import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; +import { handle } from './errors'; + +export enum CronMethod { + SynchronizeAccounts = 'synchronizeAccounts', +} export const SendFormRefreshRatesRequest = object({ interfaceId: string(), @@ -26,21 +31,22 @@ export class CronHandler { this.#sendFlowUseCases = sendFlow; } - async route(method: string, params?: JsonRpcParams): Promise { - switch (method) { - case 'synchronizeAccounts': { - return this.synchronizeAccounts(); - } - case SendFormEvent.RefreshRates: { - assert(params, SendFormRefreshRatesRequest); - return this.#sendFlowUseCases.onChangeForm( - params.interfaceId, - SendFormEvent.RefreshRates, - ); + async route(request: JsonRpcRequest) { + const { method, params } = request; + + return handle(async () => { + switch (method) { + case CronMethod.SynchronizeAccounts: { + return this.synchronizeAccounts(); + } + case SendFormEvent.RefreshRates: { + assert(params, SendFormRefreshRatesRequest); + return this.#sendFlowUseCases.refresh(params.interfaceId); + } + default: + throw new Error(`Method not found: ${method}`); } - default: - throw new Error('Method not found.'); - } + }); } async synchronizeAccounts(): Promise { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index f09c797f..dace0694 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -11,7 +11,8 @@ import type { Pagination, MetaMaskOptions, } from '@metamask/keyring-api'; -import type { Json } from '@metamask/utils'; +import { handleKeyringRequest } from '@metamask/keyring-snap-sdk'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; import { assert, boolean, enums, object, optional, string } from 'superstruct'; import { networkToCurrencyUnit } from '../entities'; @@ -23,7 +24,9 @@ import { caip2ToNetwork, networkToCaip2, } from './caip'; +import { handle } from './errors'; import { mapToKeyringAccount, mapToTransaction } from './mappings'; +import { validateOrigin } from './permissions'; export const CreateAccountRequest = object({ scope: enums(Object.values(BtcScope)), @@ -41,6 +44,16 @@ export class KeyringHandler implements Keyring { this.#accountsUseCases = accounts; } + async route(origin: string, request: JsonRpcRequest): Promise { + validateOrigin(origin); + + const result = await handle(async () => + handleKeyringRequest(this, request), + ); + + return result ?? null; // Use `null` since `undefined` is not valid in JSON. + } + async listAccounts(): Promise { const accounts = await this.#accountsUseCases.list(); return accounts.map(mapToKeyringAccount); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index aef90917..42dfe3b2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -1,12 +1,13 @@ import type { Txid } from '@metamask/bitcoindevkit'; +import { SnapError } from '@metamask/snaps-sdk'; +import type { JsonRpcRequest } from '@metamask/utils'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; import type { TransactionRequest } from '../entities'; -import { InternalRpcMethod } from '../permissions'; import type { SendFlowUseCases } from '../use-cases'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; -import { CreateSendFormRequest, RpcHandler } from './RpcHandler'; +import { CreateSendFormRequest, RpcHandler, RpcMethod } from './RpcHandler'; jest.mock('superstruct', () => ({ ...jest.requireActual('superstruct'), @@ -17,30 +18,37 @@ describe('RpcHandler', () => { const mockSendFlowUseCases = mock(); const mockAccountsUseCases = mock(); const mockTxRequest = mock(); - - let handler: RpcHandler; - - beforeEach(() => { - handler = new RpcHandler(mockSendFlowUseCases, mockAccountsUseCases); + const origin = 'metamask'; + const mockRequest = mock({ + method: RpcMethod.StartSendTransactionFlow, + params: { + account: 'account-id', + }, }); + const handler = new RpcHandler(mockSendFlowUseCases, mockAccountsUseCases); + describe('route', () => { + it('throws error if invalid origin', async () => { + await expect(handler.route('invalidOrigin', mockRequest)).rejects.toThrow( + 'Permission denied', + ); + }); + it('throws error if missing params', async () => { - await expect(handler.route('method')).rejects.toThrow('Missing params'); + await expect( + handler.route(origin, { ...mockRequest, params: undefined }), + ).rejects.toThrow('Missing params'); }); it('throws error if unrecognized method', async () => { - await expect(handler.route('randomMethod', {})).rejects.toThrow( - 'Method not found: randomMethod', - ); + await expect( + handler.route(origin, { ...mockRequest, method: 'randomMethod' }), + ).rejects.toThrow('Method not found: randomMethod'); }); }); describe('executeSendFlow', () => { - const params = { - account: 'account-id', - }; - it('executes startSendTransactionFlow', async () => { mockSendFlowUseCases.display.mockResolvedValue(mockTxRequest); mockAccountsUseCases.send.mockResolvedValue( @@ -49,12 +57,12 @@ describe('RpcHandler', () => { }), ); - const result = await handler.route( - InternalRpcMethod.StartSendTransactionFlow, - params, - ); + const result = await handler.route(origin, mockRequest); - expect(assert).toHaveBeenCalledWith(params, CreateSendFormRequest); + expect(assert).toHaveBeenCalledWith( + mockRequest.params, + CreateSendFormRequest, + ); expect(mockSendFlowUseCases.display).toHaveBeenCalledWith('account-id'); expect(mockAccountsUseCases.send).toHaveBeenCalledWith( 'account-id', @@ -67,9 +75,9 @@ describe('RpcHandler', () => { const error = new Error(); mockSendFlowUseCases.display.mockRejectedValue(error); - await expect( - handler.route(InternalRpcMethod.StartSendTransactionFlow, params), - ).rejects.toThrow(error); + await expect(handler.route(origin, mockRequest)).rejects.toThrow( + new SnapError(error), + ); expect(mockSendFlowUseCases.display).toHaveBeenCalled(); expect(mockAccountsUseCases.send).not.toHaveBeenCalled(); @@ -80,9 +88,9 @@ describe('RpcHandler', () => { mockSendFlowUseCases.display.mockResolvedValue(mockTxRequest); mockAccountsUseCases.send.mockRejectedValue(error); - await expect( - handler.route(InternalRpcMethod.StartSendTransactionFlow, params), - ).rejects.toThrow(error); + await expect(handler.route(origin, mockRequest)).rejects.toThrow( + new SnapError(error), + ); expect(mockSendFlowUseCases.display).toHaveBeenCalled(); expect(mockAccountsUseCases.send).toHaveBeenCalled(); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index 87ad29ee..68663e46 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -1,9 +1,14 @@ import { BtcScope } from '@metamask/keyring-api'; -import type { Json, JsonRpcParams } from '@metamask/utils'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; import { assert, enums, object, optional, string } from 'superstruct'; -import { InternalRpcMethod } from '../permissions'; import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; +import { handle } from './errors'; +import { validateOrigin } from './permissions'; + +export enum RpcMethod { + StartSendTransactionFlow = 'startSendTransactionFlow', +} export const CreateSendFormRequest = object({ account: string(), @@ -24,28 +29,31 @@ export class RpcHandler { this.#accountUseCases = accounts; } - async route(method: string, params?: JsonRpcParams): Promise { - if (!params) { - throw new Error('Missing params'); - } + async route(origin: string, request: JsonRpcRequest): Promise { + validateOrigin(origin); + + const { method, params } = request; - switch (method) { - case InternalRpcMethod.StartSendTransactionFlow: { - return this.#executeSendFlow(params); + return handle(async () => { + if (!params) { + throw new Error('Missing params'); } - default: - throw new Error(`Method not found: ${method}`); - } - } + switch (method) { + case RpcMethod.StartSendTransactionFlow: { + assert(params, CreateSendFormRequest); + return this.#executeSendFlow(params.account); + } - async #executeSendFlow( - params: JsonRpcParams, - ): Promise { - assert(params, CreateSendFormRequest); + default: + throw new Error(`Method not found: ${method}`); + } + }); + } - const txRequest = await this.#sendFlowUseCases.display(params.account); - const txId = await this.#accountUseCases.send(params.account, txRequest); + async #executeSendFlow(account: string): Promise { + const txRequest = await this.#sendFlowUseCases.display(account); + const txId = await this.#accountUseCases.send(account, txRequest); return { txId: txId.toString() }; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts index 6175a7cb..ecd11cea 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts @@ -1,5 +1,5 @@ import type { UserInputEvent } from '@metamask/snaps-sdk'; -import { UserInputEventType } from '@metamask/snaps-sdk'; +import { SnapError, UserInputEventType } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; import type { SendFormContext } from '../entities'; @@ -66,6 +66,7 @@ describe('UserInputHandler', () => { expect(mockSendFlowUseCases.onChangeForm).toHaveBeenCalledWith( 'interface-id', SendFormEvent.ClearRecipient, + mockContext, ); }); @@ -82,7 +83,7 @@ describe('UserInputHandler', () => { }, mockContext, ), - ).rejects.toThrow(error); + ).rejects.toThrow(new SnapError(error)); }); }); @@ -117,7 +118,7 @@ describe('UserInputHandler', () => { }, mockContext, ), - ).rejects.toThrow(error); + ).rejects.toThrow(new SnapError(error)); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts index 266d7ad8..a2d723c0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts @@ -1,8 +1,9 @@ import type { Json, UserInputEvent } from '@metamask/snaps-sdk'; -import type { ReviewTransactionContext } from '../entities'; +import type { ReviewTransactionContext, SendFormContext } from '../entities'; import { ReviewTransactionEvent, SendFormEvent } from '../entities'; import type { SendFlowUseCases } from '../use-cases'; +import { handle } from './errors'; export class UserInputHandler { readonly #sendFlowUseCases: SendFlowUseCases; @@ -16,30 +17,31 @@ export class UserInputHandler { event: UserInputEvent, context: Record | null, ): Promise { - if (!context) { - throw new Error('Missing context'); - } - - if (!event.name) { - throw new Error('Missing event name'); - } - - if (this.#isSendFormEvent(event.name)) { - return this.#sendFlowUseCases.onChangeForm( - interfaceId, - event.name, - // TODO: Reuse when fixed: https://github.com/MetaMask/snaps/issues/3069 - // context as SendFormContext, - ); - } else if (this.#isReviewTransactionEvent(event.name)) { - return this.#sendFlowUseCases.onChangeReview( - interfaceId, - event.name, - context as ReviewTransactionContext, - ); - } - - throw new Error(`Unsupported event: ${event.name}`); + return handle(async () => { + if (!context) { + throw new Error('Missing context'); + } + + if (!event.name) { + throw new Error('Missing event name'); + } + + if (this.#isSendFormEvent(event.name)) { + return this.#sendFlowUseCases.onChangeForm( + interfaceId, + event.name, + context as SendFormContext, + ); + } else if (this.#isReviewTransactionEvent(event.name)) { + return this.#sendFlowUseCases.onChangeReview( + interfaceId, + event.name, + context as ReviewTransactionContext, + ); + } + + throw new Error(`Unsupported event: ${event.name}`); + }); } #isSendFormEvent(name: string): name is SendFormEvent { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts new file mode 100644 index 00000000..7234e30a --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts @@ -0,0 +1,19 @@ +import { SnapError } from '@metamask/snaps-sdk'; + +export const handle = async ( + fn: () => Promise, +): Promise => { + try { + return await fn(); + } catch (error) { + // TODO: Improve error handling in the following way: + // 1. Use custom error types in the use cases with the initial error message (+context if necessary). + // 2. Log the error using the context from the custom error. + // 3. Map the error to a user-friendly message. + // 4. Throw the more aligned error type from the Snaps SDK. + // 5. Default to InternalError('an internal error occurred') if no custom error is thrown. + + console.error(error); + throw new SnapError(error); + } +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts new file mode 100644 index 00000000..1b50e129 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts @@ -0,0 +1,8 @@ +import { UnauthorizedError } from '@metamask/snaps-sdk'; + +export const validateOrigin = (origin: string) => { + if (origin !== 'metamask') { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw new UnauthorizedError('Permission denied'); + } +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index a9dfaad5..55807bca 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -1,4 +1,3 @@ -import { handleKeyringRequest } from '@metamask/keyring-snap-sdk'; import type { OnAssetsConversionHandler, OnAssetsLookupHandler, @@ -6,12 +5,9 @@ import type { OnRpcRequestHandler, OnKeyringRequestHandler, OnUserInputHandler, - Json, } from '@metamask/snaps-sdk'; -import { UnauthorizedError, SnapError } from '@metamask/snaps-sdk'; import { Config } from './config'; -import { isSnapRpcError } from './entities'; import { KeyringHandler, CronHandler, @@ -27,7 +23,6 @@ import { ConsoleLoggerAdapter, LocalTranslatorAdapter, } from './infra'; -import { originPermissions } from './permissions'; import { BdkAccountRepository, JSXSendFlowRepository } from './store'; import { AccountUseCases, AssetsUseCases, SendFlowUseCases } from './use-cases'; @@ -75,126 +70,23 @@ const assetsHandler = new AssetsHandler( Config.conversionsExpirationInterval, ); -export const validateOrigin = (origin: string, method: string): void => { - if (!origin) { - // eslint-disable-next-line @typescript-eslint/no-throw-literal - throw new UnauthorizedError('Origin not found'); - } - if (!originPermissions.get(origin)?.has(method)) { - // eslint-disable-next-line @typescript-eslint/no-throw-literal - throw new UnauthorizedError(`Permission denied`); - } -}; +export const onCronjob: OnCronjobHandler = async ({ request }) => + cronHandler.route(request); -export const onCronjob: OnCronjobHandler = async ({ request }) => { - try { - await cronHandler.route(request.method, request.params); - } catch (error) { - let snapError = error; - - if (!isSnapRpcError(error)) { - snapError = new SnapError(error); - } - logger.error( - `onCronjob error: ${JSON.stringify(snapError.toJSON(), null, 2)}`, - ); - throw snapError; - } -}; - -export const onRpcRequest: OnRpcRequestHandler = async ({ - origin, - request, -}): Promise => { - try { - const { method } = request; - validateOrigin(origin, method); - return await rpcHandler.route(method, request.params); - } catch (error) { - let snapError = error; - - if (!isSnapRpcError(error)) { - snapError = new SnapError(error); - } - logger.error( - `onRpcRequest error: ${JSON.stringify(snapError.toJSON(), null, 2)}`, - ); - throw snapError; - } -}; +export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request }) => + rpcHandler.route(origin, request); export const onKeyringRequest: OnKeyringRequestHandler = async ({ origin, request, -}): Promise => { - try { - validateOrigin(origin, request.method); - return (await handleKeyringRequest(keyringHandler, request)) ?? null; - } catch (error) { - let snapError = error; - - if (!isSnapRpcError(error)) { - snapError = new SnapError(error); - } - logger.error( - `onKeyringRequest error: ${JSON.stringify(snapError.toJSON(), null, 2)}`, - ); - throw snapError; - } -}; - -export const onUserInput: OnUserInputHandler = async ({ - id, - event, - context, -}) => { - try { - return userInputHandler.route(id, event, context); - } catch (error) { - let snapError = error; - - if (!isSnapRpcError(error)) { - snapError = new SnapError(error); - } - logger.error( - `onUserInput error: ${JSON.stringify(snapError.toJSON(), null, 2)}`, - ); - throw snapError; - } -}; - -export const onAssetsLookup: OnAssetsLookupHandler = async () => { - try { - return assetsHandler.lookup(); - } catch (error) { - let snapError = error; +}) => keyringHandler.route(origin, request); - if (!isSnapRpcError(error)) { - snapError = new SnapError(error); - } - logger.error( - `onAssetsLookup error: ${JSON.stringify(snapError.toJSON(), null, 2)}`, - ); - throw snapError; - } -}; +export const onUserInput: OnUserInputHandler = async ({ id, event, context }) => + userInputHandler.route(id, event, context); -export const onAssetsConversion: OnAssetsConversionHandler = async (args) => { - try { - return assetsHandler.conversion(args); - } catch (error) { - let snapError = error; +export const onAssetsLookup: OnAssetsLookupHandler = async () => + assetsHandler.lookup(); - if (!isSnapRpcError(error)) { - snapError = new SnapError(error); - } - logger.error( - `onAssetsConversion error: ${JSON.stringify( - snapError.toJSON(), - null, - 2, - )}`, - ); - throw snapError; - } -}; +export const onAssetsConversion: OnAssetsConversionHandler = async ({ + conversions, +}) => assetsHandler.conversion(conversions); diff --git a/merged-packages/bitcoin-wallet-snap/src/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/permissions.ts deleted file mode 100644 index c67cf52b..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/permissions.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { KeyringRpcMethod } from '@metamask/keyring-api'; - -export enum InternalRpcMethod { - StartSendTransactionFlow = 'startSendTransactionFlow', -} - -const dappPermissions = new Set([ - // Keyring methods - KeyringRpcMethod.ListAccounts, - KeyringRpcMethod.GetAccount, - KeyringRpcMethod.GetAccountBalances, - KeyringRpcMethod.SubmitRequest, -]); - -const metamaskPermissions = new Set([ - // Keyring methods - KeyringRpcMethod.ListAccounts, - KeyringRpcMethod.ListAccountTransactions, - KeyringRpcMethod.ListAccountAssets, - KeyringRpcMethod.GetAccount, - KeyringRpcMethod.CreateAccount, - KeyringRpcMethod.FilterAccountChains, - KeyringRpcMethod.DeleteAccount, - KeyringRpcMethod.GetAccountBalances, - KeyringRpcMethod.SubmitRequest, - // Internal methods with a UI - InternalRpcMethod.StartSendTransactionFlow, -]); - -const allowedOrigins = [ - 'https://portfolio.metamask.io', - 'https://portfolio-builds.metafi-dev.codefi.network', - 'https://dev.portfolio.metamask.io', - 'https://ramps-dev.portfolio.metamask.io', -]; - -const metamask = 'metamask'; - -export const originPermissions = new Map>([]); - -for (const origin of allowedOrigins) { - originPermissions.set(origin, dappPermissions); -} -originPermissions.set(metamask, metamaskPermissions); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 293aef78..24502c2e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -178,12 +178,20 @@ describe('SendFlowUseCases', () => { it('throws error unrecognized event', async () => { await expect( - useCases.onChangeForm('interface-id', 'randomEvent' as SendFormEvent), + useCases.onChangeForm( + 'interface-id', + 'randomEvent' as SendFormEvent, + mockContext, + ), ).rejects.toThrow('Unrecognized event'); }); it('resolves to null on Cancel', async () => { - await useCases.onChangeForm('interface-id', SendFormEvent.Cancel); + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Cancel, + mockContext, + ); expect(mockSnapClient.resolveInterface).toHaveBeenCalledWith( 'interface-id', null, @@ -205,7 +213,11 @@ describe('SendFlowUseCases', () => { }, }; - await useCases.onChangeForm('interface-id', SendFormEvent.ClearRecipient); + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.ClearRecipient, + mockContext, + ); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( 'interface-id', expectedContext, @@ -213,28 +225,25 @@ describe('SendFlowUseCases', () => { }); it('throws error on Review if amount, recipient or fee are not defined', async () => { - mockSendFlowRepository.getContext.mockResolvedValueOnce({ - ...mockContext, - recipient: undefined, - }); await expect( - useCases.onChangeForm('interface-id', SendFormEvent.Confirm), + useCases.onChangeForm('interface-id', SendFormEvent.Confirm, { + ...mockContext, + recipient: undefined, + }), ).rejects.toThrow('Inconsistent Send form context'); - mockSendFlowRepository.getContext.mockResolvedValueOnce({ - ...mockContext, - amount: undefined, - }); await expect( - useCases.onChangeForm('interface-id', SendFormEvent.Confirm), + useCases.onChangeForm('interface-id', SendFormEvent.Confirm, { + ...mockContext, + amount: undefined, + }), ).rejects.toThrow('Inconsistent Send form context'); - mockSendFlowRepository.getContext.mockResolvedValueOnce({ - ...mockContext, - fee: undefined, - }); await expect( - useCases.onChangeForm('interface-id', SendFormEvent.Confirm), + useCases.onChangeForm('interface-id', SendFormEvent.Confirm, { + ...mockContext, + fee: undefined, + }), ).rejects.toThrow('Inconsistent Send form context'); }); @@ -253,7 +262,11 @@ describe('SendFlowUseCases', () => { locale: 'en', }; - await useCases.onChangeForm('interface-id', SendFormEvent.Confirm); + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Confirm, + mockContext, + ); expect(mockSendFlowRepository.updateReview).toHaveBeenCalledWith( 'interface-id', expectedReviewContext, @@ -280,7 +293,11 @@ describe('SendFlowUseCases', () => { }, }; - await useCases.onChangeForm('interface-id', SendFormEvent.SetMax); + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.SetMax, + testContext, + ); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( 'interface-id', expectedContext, @@ -297,7 +314,6 @@ describe('SendFlowUseCases', () => { ...mockContext, amount: undefined, }; - mockSendFlowRepository.getContext.mockResolvedValueOnce(testContext); mockSendFlowRepository.getState.mockResolvedValue({ recipient: 'newAddress', @@ -313,7 +329,11 @@ describe('SendFlowUseCases', () => { }, }; - await useCases.onChangeForm('interface-id', SendFormEvent.Recipient); + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Recipient, + testContext, + ); expect(mockSendFlowRepository.getState).toHaveBeenCalledWith( 'interface-id', @@ -333,7 +353,6 @@ describe('SendFlowUseCases', () => { ...mockContext, recipient: undefined, // avoid computing the fee in this test }; - mockSendFlowRepository.getContext.mockResolvedValueOnce(testContext); mockSendFlowRepository.getState.mockResolvedValue({ recipient: '', @@ -351,7 +370,11 @@ describe('SendFlowUseCases', () => { }, }; - await useCases.onChangeForm('interface-id', SendFormEvent.Amount); + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Amount, + testContext, + ); expect(mockSendFlowRepository.getState).toHaveBeenCalledWith( 'interface-id', @@ -377,7 +400,11 @@ describe('SendFlowUseCases', () => { errors: expect.anything(), }; - await useCases.onChangeForm('interface-id', SendFormEvent.SetMax); + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.SetMax, + mockContext, + ); expect(mockAccountRepository.get).toHaveBeenCalledWith('account-id'); expect(mockAccountRepository.getFrozenUTXOs).toHaveBeenCalledWith( @@ -415,7 +442,11 @@ describe('SendFlowUseCases', () => { recipient: 'newAddressValidated', }; - await useCases.onChangeForm('interface-id', SendFormEvent.Recipient); + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Recipient, + mockContext, + ); expect(mockAccountRepository.get).toHaveBeenCalled(); expect(mockAccountRepository.getFrozenUTXOs).toHaveBeenCalled(); @@ -535,7 +566,7 @@ describe('SendFlowUseCases', () => { new Error('getFeeEstimates'), ); - await useCases.onChangeForm('interface-id', SendFormEvent.RefreshRates); + await useCases.refresh('interface-id'); expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalled(); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( @@ -551,7 +582,7 @@ describe('SendFlowUseCases', () => { it('sets fee and exchange rates successfully', async () => { (mockFeeEstimates.get as jest.Mock).mockReturnValue(mockFeeRate); - await useCases.onChangeForm('interface-id', SendFormEvent.RefreshRates); + await useCases.refresh('interface-id'); expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith( ratesRefreshInterval, @@ -580,7 +611,7 @@ describe('SendFlowUseCases', () => { network: 'notBitcoin' as Network, }); - await useCases.onChangeForm('interface-id', SendFormEvent.RefreshRates); + await useCases.refresh('interface-id'); expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalled(); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( @@ -601,7 +632,7 @@ describe('SendFlowUseCases', () => { currency: 'unknown', }); - await useCases.onChangeForm('interface-id', SendFormEvent.RefreshRates); + await useCases.refresh('interface-id'); expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalled(); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( @@ -618,9 +649,7 @@ describe('SendFlowUseCases', () => { const error = new Error('scheduleBackgroundEvent failed'); mockSnapClient.scheduleBackgroundEvent.mockRejectedValue(error); - await expect( - useCases.onChangeForm('interface-id', SendFormEvent.RefreshRates), - ).rejects.toThrow(error); + await expect(useCases.refresh('interface-id')).rejects.toThrow(error); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index a33a4ef4..754ce3fe 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -104,19 +104,17 @@ export class SendFlowUseCases { return request; } - async onChangeForm(id: string, event: SendFormEvent): Promise { + async onChangeForm( + id: string, + event: SendFormEvent, + context: SendFormContext, + ): Promise { this.#logger.debug( 'Event triggered on send form: %s. Event: %s', id, event, ); - // TODO: Temporary fetch the context while this is fixed: https://github.com/MetaMask/snaps/issues/3069 - const context = await this.#sendFlowRepository.getContext(id); - if (!context) { - throw new Error(`Context not found in send form: ${id}`); - } - switch (event) { case SendFormEvent.Cancel: { if (context.backgroundEventId) { @@ -170,9 +168,6 @@ export class SendFlowUseCases { case SendFormEvent.Amount: { return this.#handleSetAmount(id, context); } - case SendFormEvent.RefreshRates: { - return this.#refreshRates(id, context); - } default: throw new Error('Unrecognized event'); } @@ -284,6 +279,15 @@ export class SendFlowUseCases { return await this.#sendFlowRepository.updateForm(id, updatedContext); } + async refresh(id: string): Promise { + const context = await this.#sendFlowRepository.getContext(id); + if (!context) { + throw new Error(`Context not found in send form: ${id}`); + } + + return this.#refreshRates(id, context); + } + async #refreshRates(id: string, context: SendFormContext): Promise { const { network } = context; let updatedContext = { ...context }; From 6c591ee91a42107b9f0dd2435f42aa589d737a4b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 12:58:33 +0200 Subject: [PATCH 218/362] 0.11.0 (#444) --- .../bitcoin-wallet-snap/CHANGELOG.md | 20 ++++++++++++++++++- .../bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index f6a7b35b..b7fd925b 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.0] + +### Added + +- Support for `correlationId` and `entropySource` ([#443](https://github.com/MetaMask/snap-bitcoin-wallet/pull/443)) + +### Changed + +- Refactor error handling ([#442](https://github.com/MetaMask/snap-bitcoin-wallet/pull/442)) +- Refactor locale ([#433](https://github.com/MetaMask/snap-bitcoin-wallet/pull/433)) +- Refactor logger ([#437](https://github.com/MetaMask/snap-bitcoin-wallet/pull/437)) +- Reduce bundle size ([#439](https://github.com/MetaMask/snap-bitcoin-wallet/pull/439)) + +### Fixed + +- Discard own outputs from send transactions ([#441](https://github.com/MetaMask/snap-bitcoin-wallet/pull/441)) + ## [0.10.0] ### Added @@ -281,7 +298,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.10.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.11.0...HEAD +[0.11.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.2...v0.9.0 [0.8.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.1...v0.8.2 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 18e148cb..53dd0a01 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.10.0", + "version": "0.11.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From f2d3fa147e3c6a80f7f0561319d50b431f9b4f89 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Fri, 11 Apr 2025 16:03:21 +0200 Subject: [PATCH 219/362] chore: disable utxo protection (#445) --- .../bitcoin-wallet-snap/snap.manifest.json | 4 +-- .../bitcoin-wallet-snap/src/config.ts | 1 + .../src/entities/config.ts | 4 +-- .../src/handlers/errors.ts | 1 - .../src/infra/BdkTxBuilderAdapter.ts | 12 ++++--- .../src/infra/SnapClientAdapter.ts | 1 + .../src/use-cases/AccountUseCases.test.ts | 36 +++++++++++++++++++ .../src/use-cases/AccountUseCases.ts | 8 +++-- 8 files changed, 56 insertions(+), 11 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 564dda3e..eabfa343 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.10.0", + "version": "0.11.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "zR/hZlrR9dLqzw0OvZ5BTcizTaXkClj553bxUszauq8=", + "shasum": "+o80ydRtD4pKZeTakH+Trk4NXaiDkXj5fImXQ3ViT4Q=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 9340ae20..03458df1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -11,6 +11,7 @@ export const Config: SnapConfig = { index: 0, defaultAddressType: (process.env.DEFAULT_ADDRESS_TYPE ?? 'p2wpkh') as AddressType, + utxoProtectionEnabled: false, }, chain: { parallelRequests: 3, diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts index 1fd274cb..45318c96 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -8,8 +8,7 @@ export type SnapConfig = { accounts: AccountsConfig; chain: ChainConfig; simpleHash: SimpleHashConfig; - // Temporary config to set the expected confirmation target, should eventually be chosen by the user - targetBlocksConfirmation: number; + targetBlocksConfirmation: number; // Temporary global config to set the expected confirmation target, should eventually be a user setting fallbackFeeRate: number; ratesRefreshInterval: string; priceApi: PriceApiConfig; @@ -19,6 +18,7 @@ export type SnapConfig = { export type AccountsConfig = { index: number; defaultAddressType: AddressType; + utxoProtectionEnabled: boolean; // Temporary global config, should eventually be a user setting }; export type ChainConfig = { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts index 7234e30a..672f02e1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts @@ -13,7 +13,6 @@ export const handle = async ( // 4. Throw the more aligned error type from the Snaps SDK. // 5. Default to InternalError('an internal error occurred') if no custom error is thrown. - console.error(error); throw new SnapError(error); } }; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts index 5c9f3433..6cde90be 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts @@ -47,10 +47,14 @@ export class BdkTxBuilderAdapter implements TransactionBuilder { } unspendable(unspendable: string[]): BdkTxBuilderAdapter { - const outpoints = unspendable.map((outpoint) => - OutPoint.from_string(outpoint), - ); - this.#builder = this.#builder.unspendable(outpoints); + // Avoid calling WASM if there are no unspendable UTXOs + if (unspendable.length) { + const outpoints = unspendable.map((outpoint) => + OutPoint.from_string(outpoint), + ); + this.#builder = this.#builder.unspendable(outpoints); + } + return this; } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index a955dacd..5a1f4338 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -86,6 +86,7 @@ export class SnapClientAdapter implements SnapClient { addressTypeToName[account.addressType] }`, displayConfirmation: false, + displayAccountNameSuggestion: false, ...(correlationId ? { metamask: { correlationId } } : {}), }); } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index abf85a56..57c3c73b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -30,6 +30,7 @@ describe('AccountUseCases', () => { const accountsConfig: AccountsConfig = { index: 0, defaultAddressType: 'p2wpkh', + utxoProtectionEnabled: true, }; const useCases = new AccountUseCases( @@ -360,6 +361,25 @@ describe('AccountUseCases', () => { expect(mockChain.sync).toHaveBeenCalled(); expect(mockRepository.update).toHaveBeenCalled(); }); + + it('does not synchronize assets if utxo protection is disabled', async () => { + const testUseCases = new AccountUseCases( + mockLogger, + mockSnapClient, + mockRepository, + mockChain, + mockMetaProtocols, + { ...accountsConfig, utxoProtectionEnabled: false }, + ); + const mockTransaction = mock(); + mockAccount.listTransactions + .mockReturnValueOnce([]) + .mockReturnValueOnce([mockTransaction]); + + await testUseCases.synchronize(mockAccount); + + expect(mockMetaProtocols.fetchInscriptions).not.toHaveBeenCalled(); + }); }); describe('fullScan', () => { @@ -424,6 +444,22 @@ describe('AccountUseCases', () => { expect(mockMetaProtocols.fetchInscriptions).toHaveBeenCalled(); expect(mockRepository.update).toHaveBeenCalled(); }); + + it('does not scans for assets if utxo protection is disabled', async () => { + const testUseCases = new AccountUseCases( + mockLogger, + mockSnapClient, + mockRepository, + mockChain, + mockMetaProtocols, + { ...accountsConfig, utxoProtectionEnabled: false }, + ); + mockAccount.listTransactions.mockReturnValue(mockTransactions); + + await testUseCases.synchronize(mockAccount); + + expect(mockMetaProtocols.fetchInscriptions).not.toHaveBeenCalled(); + }); }); describe('delete', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index a513ffb0..feb0b7fb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -143,7 +143,9 @@ export class AccountUseCases { // If new transactions appeared, fetch inscriptions; otherwise, just update. if (txsAfterSync.length > txsBeforeSync.length) { - const inscriptions = await this.#metaProtocols.fetchInscriptions(account); + const inscriptions = this.#accountConfig.utxoProtectionEnabled + ? await this.#metaProtocols.fetchInscriptions(account) + : []; await this.#repository.update(account, inscriptions); } else { await this.#repository.update(account); @@ -180,7 +182,9 @@ export class AccountUseCases { await this.#chain.fullScan(account); - const inscriptions = await this.#metaProtocols.fetchInscriptions(account); + const inscriptions = this.#accountConfig.utxoProtectionEnabled + ? await this.#metaProtocols.fetchInscriptions(account) + : []; await this.#repository.update(account, inscriptions); await this.#snapClient.emitAccountBalancesUpdatedEvent(account); From 3d879637e57e195fd33cb80a59652aebda9cb9f0 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 30 Apr 2025 14:37:01 +0200 Subject: [PATCH 220/362] chore: remove simplehash residue (#447) --- .../bitcoin-wallet-snap/.env.example | 9 -- .../bitcoin-wallet-snap/package.json | 12 +- .../bitcoin-wallet-snap/snap.config.ts | 3 - .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../bitcoin-wallet-snap/src/config.ts | 8 -- .../src/entities/config.ts | 9 -- .../src/handlers/RpcHandler.ts | 1 + .../src/handlers/errors.ts | 1 + .../bitcoin-wallet-snap/src/index.ts | 3 - .../src/infra/SimpleHashClientAdapter.ts | 123 ------------------ .../bitcoin-wallet-snap/src/infra/index.ts | 1 - .../src/use-cases/AccountUseCases.test.ts | 11 +- .../src/use-cases/AccountUseCases.ts | 8 +- 13 files changed, 18 insertions(+), 175 deletions(-) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts diff --git a/merged-packages/bitcoin-wallet-snap/.env.example b/merged-packages/bitcoin-wallet-snap/.env.example index fec72210..34dba6a5 100644 --- a/merged-packages/bitcoin-wallet-snap/.env.example +++ b/merged-packages/bitcoin-wallet-snap/.env.example @@ -31,15 +31,6 @@ ESPLORA_SIGNET= # Required: false ESPLORA_REGTEST= -# SimpleHash endpoint for Bitcoin network -# Default: https://api.simplehash.com/api/v0 -# Required: false -SIMPLEHASH_BITCOIN= -# SimpleHash API key -# Default: No API key -# Required: false -SIMPLEHASH_API_KEY= - # Price API endpoint # Default: https://price.api.cx.metamask.io # Required: false diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 53dd0a01..01ded844 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -43,14 +43,13 @@ "@metamask/eslint-config-nodejs": "^12.1.0", "@metamask/eslint-config-typescript": "^12.1.0", "@metamask/key-tree": "^10.1.1", - "@metamask/keyring-api": "^17.4.0", + "@metamask/keyring-api": "^17.5.0", "@metamask/keyring-snap-sdk": "^3.2.0", "@metamask/slip44": "^4.1.0", - "@metamask/snaps-cli": "^6.7.0", - "@metamask/snaps-jest": "^8.14.0", - "@metamask/snaps-sdk": "^6.21.0", + "@metamask/snaps-cli": "^7.1.0", + "@metamask/snaps-jest": "^8.14.1", + "@metamask/snaps-sdk": "^6.22.1", "@metamask/utils": "^11.4.0", - "@types/qs": "^6.9.18", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", "concurrently": "^9.1.0", @@ -67,12 +66,11 @@ "jest-mock-extended": "^3.0.7", "jest-transform-stub": "2.0.0", "prettier": "^2.7.1", - "qs": "^6.14.0", "rimraf": "^3.0.2", "superstruct": "^1.0.3", "ts-jest": "^29.1.0", "typescript": "^4.7.4", - "uuid": "^11.0.3" + "uuid": "^11.1.0" }, "packageManager": "yarn@4.5.1", "engines": { diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts index ea2fc9b6..1f7f0750 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.config.ts +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -5,7 +5,6 @@ import { resolve } from 'path'; require('dotenv').config(); const config: SnapConfig = { - bundler: 'webpack', input: resolve(__dirname, 'src/index.ts'), server: { port: 8080, @@ -19,8 +18,6 @@ const config: SnapConfig = { ESPLORA_TESTNET4: process.env.ESPLORA_TESTNET4, ESPLORA_SIGNET: process.env.ESPLORA_SIGNET, ESPLORA_REGTEST: process.env.ESPLORA_REGTEST, - SIMPLEHASH_BITCOIN: process.env.SIMPLEHASH_BITCOIN, - SIMPLEHASH_API_KEY: process.env.SIMPLEHASH_API_KEY, PRICE_API_URL: process.env.PRICE_API_URL, /* eslint-disable */ }, diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index eabfa343..a1063856 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "+o80ydRtD4pKZeTakH+Trk4NXaiDkXj5fImXQ3ViT4Q=", + "shasum": "+EbAkWOQm7h94dea6FxlfIZKuswopRfBYi1qy3c2+Kc=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -97,6 +97,6 @@ ] } }, - "platformVersion": "6.21.0", + "platformVersion": "6.22.1", "manifestVersion": "0.1" } diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 03458df1..1e8fd4df 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -11,7 +11,6 @@ export const Config: SnapConfig = { index: 0, defaultAddressType: (process.env.DEFAULT_ADDRESS_TYPE ?? 'p2wpkh') as AddressType, - utxoProtectionEnabled: false, }, chain: { parallelRequests: 3, @@ -27,13 +26,6 @@ export const Config: SnapConfig = { process.env.ESPLORA_REGTEST ?? 'http://localhost:8094/regtest/api', }, }, - simpleHash: { - apiKey: process.env.SIMPLEHASH_API_KEY, - url: { - bitcoin: - process.env.SIMPLEHASH_BITCOIN ?? `https://api.simplehash.com/api/v0`, - }, - }, targetBlocksConfirmation: 3, fallbackFeeRate: 5.0, ratesRefreshInterval: 'PT30S', diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts index 45318c96..f57439a0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -7,7 +7,6 @@ export type SnapConfig = { encrypt: boolean; accounts: AccountsConfig; chain: ChainConfig; - simpleHash: SimpleHashConfig; targetBlocksConfirmation: number; // Temporary global config to set the expected confirmation target, should eventually be a user setting fallbackFeeRate: number; ratesRefreshInterval: string; @@ -18,7 +17,6 @@ export type SnapConfig = { export type AccountsConfig = { index: number; defaultAddressType: AddressType; - utxoProtectionEnabled: boolean; // Temporary global config, should eventually be a user setting }; export type ChainConfig = { @@ -29,13 +27,6 @@ export type ChainConfig = { }; }; -export type SimpleHashConfig = { - apiKey?: string; - url: { - [network in Network]?: string; - }; -}; - export type PriceApiConfig = { url: string; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index 68663e46..a6fba8f6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -13,6 +13,7 @@ export enum RpcMethod { export const CreateSendFormRequest = object({ account: string(), scope: optional(enums(Object.values(BtcScope))), // We don't use the scope but need to define it for validation + assetId: optional(string()), // We don't use the Caip19 but need to define it for validation }); type SendTransactionResponse = { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts index 672f02e1..245a1d0c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts @@ -13,6 +13,7 @@ export const handle = async ( // 4. Throw the more aligned error type from the Snaps SDK. // 5. Default to InternalError('an internal error occurred') if no custom error is thrown. + console.error('Error occurred:', error); throw new SnapError(error); } }; diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 55807bca..2b545184 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -18,7 +18,6 @@ import { import { SnapClientAdapter, EsploraClientAdapter, - SimpleHashClientAdapter, PriceApiClientAdapter, ConsoleLoggerAdapter, LocalTranslatorAdapter, @@ -30,7 +29,6 @@ import { AccountUseCases, AssetsUseCases, SendFlowUseCases } from './use-cases'; const logger = new ConsoleLoggerAdapter(Config.logLevel); const snapClient = new SnapClientAdapter(Config.encrypt); const chainClient = new EsploraClientAdapter(Config.chain); -const metaProtocolsClient = new SimpleHashClientAdapter(Config.simpleHash); const assetRatesClient = new PriceApiClientAdapter(Config.priceApi); const translator = new LocalTranslatorAdapter(); @@ -44,7 +42,6 @@ const accountsUseCases = new AccountUseCases( snapClient, accountRepository, chainClient, - metaProtocolsClient, Config.accounts, ); const sendFlowUseCases = new SendFlowUseCases( diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts deleted file mode 100644 index 728b6fe3..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SimpleHashClientAdapter.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { Network } from '@metamask/bitcoindevkit'; -import type { Json } from '@metamask/utils'; -import qs from 'qs'; - -import type { - BitcoinAccount, - Inscription, - MetaProtocolsClient, - SimpleHashConfig as SimplehHashConfig, -} from '../entities'; - -/* eslint-disable @typescript-eslint/naming-convention */ -type NFTResponse = { - next_cursor: string; - nfts: { - extra_metadata: { - ordinal_details: { - inscription_id: string; - inscription_number: number; - content_length: number; - content_type: string; - sat_number: number; - sat_name: string; - sat_rarity: string; - protocol_name: string | null; - protocol_content: Json[] | null; - location: string; - charms: string[] | null; - }; - image_original_url: string | null; - }; - }[]; -}; - -export class SimpleHashClientAdapter implements MetaProtocolsClient { - readonly #endpoints: Record; - - readonly #apiKey: string | undefined; - - constructor(config: SimplehHashConfig) { - this.#endpoints = { - bitcoin: config.url.bitcoin, - testnet: config.url.testnet, - testnet4: config.url.testnet4, - signet: config.url.signet, - regtest: config.url.regtest, - }; - this.#apiKey = config.apiKey; - } - - async fetchInscriptions(account: BitcoinAccount): Promise { - const endpoint = this.#endpoints[account.network]; - if (!endpoint) { - return []; - } - - const usedAddresses = new Set( - account - .listUnspent() - .map((utxo) => account.peekAddress(utxo.derivation_index)) - .toString(), - ); - - if (usedAddresses.size === 0) { - return []; - } - - let cursor: string | undefined; - const inscriptions: Inscription[] = []; - const MAX_PAGES = 5; - let pages = 0; - - do { - pages += 1; - if (pages > MAX_PAGES) { - break; - } - - const params = { - chains: 'bitcoin', - wallet_addresses: Array.from(usedAddresses), - limit: 50, - cursor, - }; - - const url = `${endpoint}/nfts/owners_v2?${qs.stringify(params, { - arrayFormat: 'comma', - })}`; - - const headers = this.#apiKey ? { 'X-API-KEY': this.#apiKey } : undefined; - const response = await fetch(url, { - method: 'GET', - headers, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch inscriptions: ${response.statusText}`); - } - - const data: NFTResponse = await response.json(); - - inscriptions.push( - ...data.nfts.map((nft) => { - const details = nft.extra_metadata.ordinal_details; - return { - id: details.inscription_id, - number: details.inscription_number, - contentLength: details.content_length, - contentType: details.content_type, - satNumber: details.sat_number, - satRarity: details.sat_rarity, - location: details.location, - imageUrl: nft.extra_metadata.image_original_url ?? undefined, - }; - }), - ); - - cursor = data.next_cursor; - } while (cursor); - - return inscriptions; - } -} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts index d0a92667..dbc4a2f3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts @@ -1,7 +1,6 @@ export * from './BdkAccountAdapter'; export * from './SnapClientAdapter'; export * from './EsploraClientAdapter'; -export * from './SimpleHashClientAdapter'; export * from './PriceApiClientAdapter'; export * from './ConsoleLoggerAdapter'; export * from './LocalTranslatorAdapter'; diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 57c3c73b..c60aef7c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -30,7 +30,6 @@ describe('AccountUseCases', () => { const accountsConfig: AccountsConfig = { index: 0, defaultAddressType: 'p2wpkh', - utxoProtectionEnabled: true, }; const useCases = new AccountUseCases( @@ -38,8 +37,8 @@ describe('AccountUseCases', () => { mockSnapClient, mockRepository, mockChain, - mockMetaProtocols, accountsConfig, + mockMetaProtocols, ); describe('get', () => { @@ -368,8 +367,8 @@ describe('AccountUseCases', () => { mockSnapClient, mockRepository, mockChain, - mockMetaProtocols, - { ...accountsConfig, utxoProtectionEnabled: false }, + accountsConfig, + undefined, ); const mockTransaction = mock(); mockAccount.listTransactions @@ -451,8 +450,8 @@ describe('AccountUseCases', () => { mockSnapClient, mockRepository, mockChain, - mockMetaProtocols, - { ...accountsConfig, utxoProtectionEnabled: false }, + accountsConfig, + undefined, ); mockAccount.listTransactions.mockReturnValue(mockTransactions); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index feb0b7fb..01ece3ca 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -41,7 +41,7 @@ export class AccountUseCases { readonly #chain: BlockchainClient; - readonly #metaProtocols: MetaProtocolsClient; + readonly #metaProtocols: MetaProtocolsClient | undefined; readonly #accountConfig: AccountsConfig; @@ -50,8 +50,8 @@ export class AccountUseCases { snapClient: SnapClient, repository: BitcoinAccountRepository, chain: BlockchainClient, - metaProtocols: MetaProtocolsClient, accountConfig: AccountsConfig, + metaProtocols?: MetaProtocolsClient, ) { this.#logger = logger; this.#snapClient = snapClient; @@ -143,7 +143,7 @@ export class AccountUseCases { // If new transactions appeared, fetch inscriptions; otherwise, just update. if (txsAfterSync.length > txsBeforeSync.length) { - const inscriptions = this.#accountConfig.utxoProtectionEnabled + const inscriptions = this.#metaProtocols ? await this.#metaProtocols.fetchInscriptions(account) : []; await this.#repository.update(account, inscriptions); @@ -182,7 +182,7 @@ export class AccountUseCases { await this.#chain.fullScan(account); - const inscriptions = this.#accountConfig.utxoProtectionEnabled + const inscriptions = this.#metaProtocols ? await this.#metaProtocols.fetchInscriptions(account) : []; await this.#repository.update(account, inscriptions); From f2a81660e806417db8feff3c6bbb41e2670d6521 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Fri, 2 May 2025 10:53:44 +0200 Subject: [PATCH 221/362] build: compress snap (#451) --- merged-packages/bitcoin-wallet-snap/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 01ded844..0cc3558a 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -16,12 +16,13 @@ ], "scripts": { "allow-scripts": "yarn workspace root allow-scripts", - "build": "mm-snap build && yarn build:locale && yarn build-preinstalled-snap", + "build": "mm-snap build && yarn build:locale && yarn build-preinstalled-snap && yarn compress-preinstalled-snap", "build-preinstalled-snap": "node scripts/build-preinstalled-snap.js", "build:clean": "yarn clean && yarn build:locale && yarn build", "build:locale": "node ./scripts/populate-en-locale.js && prettier 'locales/**/*.json' -w", "build:locale:watch": "npx nodemon --watch packages/snap/messages.json --exec \"node ./scripts/populate-en-locale.js && prettier 'locales/**/*.json' -w\"", "clean": "rimraf dist", + "compress-preinstalled-snap": "gzip -9 -c dist/preinstalled-snap.json > dist/preinstalled-snap.json.gz", "lint": "yarn lint:eslint && yarn lint:misc && yarn lint:deps && yarn lint:types", "lint:deps": "depcheck", "lint:eslint": "eslint . --cache --ext js,jsx,ts,tsx", From e00406d5faa09c9164a6752660d6148490bcc2d6 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Fri, 2 May 2025 11:43:53 +0200 Subject: [PATCH 222/362] refactor: generate and send psbt (#448) --- .../integration-test/send-flow.test.ts | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/entities/send-flow.ts | 4 +- .../src/entities/transaction.ts | 7 -- .../src/handlers/RpcHandler.test.ts | 21 ++-- .../src/handlers/RpcHandler.ts | 4 +- .../src/handlers/errors.ts | 2 +- .../src/infra/jsx/ReviewTransactionView.tsx | 19 ++- .../src/use-cases/AccountUseCases.test.ts | 106 +---------------- .../src/use-cases/AccountUseCases.ts | 25 +--- .../src/use-cases/SendFlowUseCases.test.ts | 108 ++++++++++++------ .../src/use-cases/SendFlowUseCases.ts | 54 +++++---- 12 files changed, 134 insertions(+), 220 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts index 946f6ccb..ec86e498 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts @@ -159,7 +159,7 @@ describe('Send flow', () => { const result = await response; expect(result).toRespondWithError({ - code: 4001, + code: -32603, message: 'User rejected the request.', stack: expect.anything(), }); diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index a1063856..a380254f 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "+EbAkWOQm7h94dea6FxlfIZKuswopRfBYi1qy3c2+Kc=", + "shasum": "NkTLFbcjHf5Ly03PLjuwObJ/9Op8ePa7LTk4KFx0oTE=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts index c0d88b78..a0e9d45b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts @@ -46,15 +46,13 @@ export type SendFormState = { export type ReviewTransactionContext = { from: string; network: Network; - feeRate: number; currency: CurrencyUnit; exchangeRate?: CurrencyRate; recipient: string; amount: string; - fee: string; backgroundEventId?: string; - drain?: boolean; locale: string; + psbt: string; /** * Used to repopulate the send form if the user decides to go back in the flow diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts b/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts index 1e86bbdc..4564373f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts @@ -1,12 +1,5 @@ import type { Psbt } from '@metamask/bitcoindevkit'; -export type TransactionRequest = { - recipient: string; - amount?: string; - feeRate: number; - drain?: boolean; -}; - /** * A Bitcoin transaction builder. */ diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index 42dfe3b2..c79ecfbe 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -1,10 +1,9 @@ -import type { Txid } from '@metamask/bitcoindevkit'; +import type { Psbt, Txid } from '@metamask/bitcoindevkit'; import { SnapError } from '@metamask/snaps-sdk'; import type { JsonRpcRequest } from '@metamask/utils'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; -import type { TransactionRequest } from '../entities'; import type { SendFlowUseCases } from '../use-cases'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; import { CreateSendFormRequest, RpcHandler, RpcMethod } from './RpcHandler'; @@ -17,7 +16,7 @@ jest.mock('superstruct', () => ({ describe('RpcHandler', () => { const mockSendFlowUseCases = mock(); const mockAccountsUseCases = mock(); - const mockTxRequest = mock(); + const mockPsbt = mock(); const origin = 'metamask'; const mockRequest = mock({ method: RpcMethod.StartSendTransactionFlow, @@ -50,8 +49,8 @@ describe('RpcHandler', () => { describe('executeSendFlow', () => { it('executes startSendTransactionFlow', async () => { - mockSendFlowUseCases.display.mockResolvedValue(mockTxRequest); - mockAccountsUseCases.send.mockResolvedValue( + mockSendFlowUseCases.display.mockResolvedValue(mockPsbt); + mockAccountsUseCases.sendPsbt.mockResolvedValue( mock({ toString: jest.fn().mockReturnValue('txId'), }), @@ -64,9 +63,9 @@ describe('RpcHandler', () => { CreateSendFormRequest, ); expect(mockSendFlowUseCases.display).toHaveBeenCalledWith('account-id'); - expect(mockAccountsUseCases.send).toHaveBeenCalledWith( + expect(mockAccountsUseCases.sendPsbt).toHaveBeenCalledWith( 'account-id', - mockTxRequest, + mockPsbt, ); expect(result).toStrictEqual({ txId: 'txId' }); }); @@ -80,20 +79,20 @@ describe('RpcHandler', () => { ); expect(mockSendFlowUseCases.display).toHaveBeenCalled(); - expect(mockAccountsUseCases.send).not.toHaveBeenCalled(); + expect(mockAccountsUseCases.sendPsbt).not.toHaveBeenCalled(); }); it('propagates errors from send', async () => { const error = new Error(); - mockSendFlowUseCases.display.mockResolvedValue(mockTxRequest); - mockAccountsUseCases.send.mockRejectedValue(error); + mockSendFlowUseCases.display.mockResolvedValue(mockPsbt); + mockAccountsUseCases.sendPsbt.mockRejectedValue(error); await expect(handler.route(origin, mockRequest)).rejects.toThrow( new SnapError(error), ); expect(mockSendFlowUseCases.display).toHaveBeenCalled(); - expect(mockAccountsUseCases.send).toHaveBeenCalled(); + expect(mockAccountsUseCases.sendPsbt).toHaveBeenCalled(); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index a6fba8f6..f1f83ce2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -53,8 +53,8 @@ export class RpcHandler { } async #executeSendFlow(account: string): Promise { - const txRequest = await this.#sendFlowUseCases.display(account); - const txId = await this.#accountUseCases.send(account, txRequest); + const psbt = await this.#sendFlowUseCases.display(account); + const txId = await this.#accountUseCases.sendPsbt(account, psbt); return { txId: txId.toString() }; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts index 245a1d0c..0bb0a6b3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts @@ -13,7 +13,7 @@ export const handle = async ( // 4. Throw the more aligned error type from the Snaps SDK. // 5. Default to InternalError('an internal error occurred') if no custom error is thrown. - console.error('Error occurred:', error); + // console.error('Error occurred:', error); throw new SnapError(error); } }; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx index 06393e8b..7b9187b5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx @@ -1,3 +1,4 @@ +import { Psbt } from '@metamask/bitcoindevkit'; import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; import { Box, @@ -29,18 +30,12 @@ export const ReviewTransactionView: SnapComponent< ReviewTransactionViewProps > = ({ context, messages }) => { const t = translate(messages); - const { - amount, - fee, - currency, - exchangeRate, - feeRate, - recipient, - network, - from, - } = context; + const { amount, currency, exchangeRate, recipient, network, from } = context; - const total = BigInt(amount) + BigInt(fee); + const psbt = Psbt.from_string(context.psbt); + const fee = psbt.fee().to_sat(); + const feeRate = psbt.fee_rate()?.to_sat_per_vb_floor; + const total = BigInt(amount) + fee; return ( @@ -100,7 +95,7 @@ export const ReviewTransactionView: SnapComponent< /> - {`${Math.floor(feeRate)} sat/vB`} + {`${feeRate ?? 'unknown'} sat/vB`} { }); }); - describe('send', () => { - const requestWithAmount: TransactionRequest = { - amount: '1000', - feeRate: 10, - recipient: 'recipient-address', - }; - const requestDrain: TransactionRequest = { - feeRate: 10, - recipient: 'recipient-address', - drain: true, - }; - + describe('sendPsbt', () => { const mockTxid = mock(); - const mockOutPoint = 'txid:vout'; const mockPsbt = mock(); const mockTransaction = mock({ // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 /* eslint-disable @typescript-eslint/naming-convention */ compute_txid: jest.fn(), }); - const mockTxBuilder = { - addRecipient: jest.fn(), - feeRate: jest.fn(), - drainTo: jest.fn(), - drainWallet: jest.fn(), - finish: jest.fn(), - unspendable: jest.fn(), - }; const mockAccount = mock({ network: 'bitcoin', - buildTx: jest.fn(), sign: jest.fn(), }); - beforeEach(() => { - mockTxBuilder.addRecipient.mockReturnThis(); - mockTxBuilder.feeRate.mockReturnThis(); - mockTxBuilder.drainTo.mockReturnThis(); - mockTxBuilder.drainWallet.mockReturnThis(); - mockTxBuilder.finish.mockReturnValue(mockPsbt); - mockTxBuilder.unspendable.mockReturnThis(); - }); - - it('throws error if both drain and amount are specified', async () => { - await expect( - useCases.send('non-existent-id', { ...requestWithAmount, drain: true }), - ).rejects.toThrow("Cannot specify both 'amount' and 'drain' options"); - }); - it('throws error if account is not found', async () => { mockRepository.getWithSigner.mockResolvedValue(null); await expect( - useCases.send('non-existent-id', requestWithAmount), + useCases.sendPsbt('non-existent-id', mockPsbt), ).rejects.toThrow('Account not found: non-existent-id'); }); it('sends transaction', async () => { mockRepository.getWithSigner.mockResolvedValue(mockAccount); - mockAccount.buildTx.mockReturnValue(mockTxBuilder); - mockRepository.getFrozenUTXOs.mockResolvedValue([mockOutPoint]); - mockAccount.sign.mockReturnValue(mockTransaction); - mockTransaction.compute_txid.mockReturnValue(mockTxid); - - const txId = await useCases.send('account-id', requestWithAmount); - - expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); - expect(mockAccount.buildTx).toHaveBeenCalled(); - expect(mockTxBuilder.feeRate).toHaveBeenCalledWith( - requestWithAmount.feeRate, - ); - expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith( - requestWithAmount.amount, - requestWithAmount.recipient, - ); - expect(mockRepository.getFrozenUTXOs).toHaveBeenCalledWith('account-id'); - expect(mockTxBuilder.unspendable).toHaveBeenCalledWith([mockOutPoint]); - expect(mockAccount.sign).toHaveBeenCalledWith(mockPsbt); - expect(mockChain.broadcast).toHaveBeenCalledWith( - mockAccount.network, - mockTransaction, - ); - expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); - expect( - mockSnapClient.emitAccountBalancesUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount); - expect(mockTransaction.compute_txid).toHaveBeenCalled(); - expect(txId).toBe(mockTxid); - }); - - it('sends a drain transaction when amount is not provided', async () => { - mockRepository.getWithSigner.mockResolvedValue(mockAccount); - mockAccount.buildTx.mockReturnValue(mockTxBuilder); - mockRepository.getFrozenUTXOs.mockResolvedValue([mockOutPoint]); mockAccount.sign.mockReturnValue(mockTransaction); mockTransaction.compute_txid.mockReturnValue(mockTxid); - const txId = await useCases.send('account-id', requestDrain); + const txId = await useCases.sendPsbt('account-id', mockPsbt); expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); - expect(mockAccount.buildTx).toHaveBeenCalled(); - expect(mockTxBuilder.feeRate).toHaveBeenCalledWith(requestDrain.feeRate); - expect(mockTxBuilder.drainWallet).toHaveBeenCalled(); - expect(mockTxBuilder.drainTo).toHaveBeenCalledWith( - requestDrain.recipient, - ); - expect(mockRepository.getFrozenUTXOs).toHaveBeenCalledWith('account-id'); - expect(mockTxBuilder.unspendable).toHaveBeenCalledWith([mockOutPoint]); expect(mockAccount.sign).toHaveBeenCalledWith(mockPsbt); expect(mockChain.broadcast).toHaveBeenCalledWith( mockAccount.network, @@ -645,43 +565,29 @@ describe('AccountUseCases', () => { const error = new Error('getWithSigner failed'); mockRepository.getWithSigner.mockRejectedValueOnce(error); - await expect(useCases.send('account-id', requestWithAmount)).rejects.toBe( - error, - ); - }); - - it('propagates an error if buildTx fails', async () => { - const error = new Error('buildTx failed'); - mockRepository.getWithSigner.mockResolvedValue(mockAccount); - mockAccount.buildTx.mockImplementation(() => { - throw error; - }); - - await expect(useCases.send('account-id', requestWithAmount)).rejects.toBe( + await expect(useCases.sendPsbt('account-id', mockPsbt)).rejects.toBe( error, ); }); it('propagates an error if broadcast fails', async () => { mockRepository.getWithSigner.mockResolvedValue(mockAccount); - mockAccount.buildTx.mockReturnValue(mockTxBuilder); const error = new Error('broadcast failed'); mockChain.broadcast.mockRejectedValueOnce(error); - await expect(useCases.send('account-id', requestWithAmount)).rejects.toBe( + await expect(useCases.sendPsbt('account-id', mockPsbt)).rejects.toBe( error, ); }); it('propagates an error if update fails', async () => { mockRepository.getWithSigner.mockResolvedValue(mockAccount); - mockAccount.buildTx.mockReturnValue(mockTxBuilder); const error = new Error('update failed'); mockRepository.update.mockRejectedValue(error); - await expect(useCases.send('account-id', requestWithAmount)).rejects.toBe( + await expect(useCases.sendPsbt('account-id', mockPsbt)).rejects.toBe( error, ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 01ece3ca..390ac952 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -1,6 +1,7 @@ import type { AddressType, Network, + Psbt, Txid, WalletTx, } from '@metamask/bitcoindevkit'; @@ -10,7 +11,6 @@ import type { BitcoinAccount, BitcoinAccountRepository, BlockchainClient, - TransactionRequest, SnapClient, MetaProtocolsClient, Logger, @@ -213,33 +213,14 @@ export class AccountUseCases { this.#logger.info('Account deleted successfully: %s', account.id); } - async send(id: string, request: TransactionRequest): Promise { - this.#logger.debug('Sending transaction: %s. Request: %o', id, request); - - if (request.drain && request.amount) { - throw new Error("Cannot specify both 'amount' and 'drain' options"); - } + async sendPsbt(id: string, psbt: Psbt): Promise { + this.#logger.debug('Sending transaction: %s', id); const account = await this.#repository.getWithSigner(id); if (!account) { throw new Error(`Account not found: ${id}`); } - const builder = account.buildTx().feeRate(request.feeRate); - - if (request.amount) { - builder.addRecipient(request.amount, request.recipient); - } else if (request.drain) { - builder.drainWallet().drainTo(request.recipient); - } else { - throw new Error("Either 'amount' or 'drain' must be specified"); - } - - // Make sure frozen UTXOs are not spent - const frozenUTXOs = await this.#repository.getFrozenUTXOs(id); - builder.unspendable(frozenUTXOs); - - const psbt = builder.finish(); const tx = account.sign(psbt); await this.#chain.broadcast(account.network, tx); await this.#repository.update(account); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 24502c2e..6373233c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -1,10 +1,9 @@ import type { - Psbt, FeeEstimates, Network, AddressInfo, } from '@metamask/bitcoindevkit'; -import { Address, Amount } from '@metamask/bitcoindevkit'; +import { Psbt, Address, Amount } from '@metamask/bitcoindevkit'; import type { GetPreferencesResult } from '@metamask/snaps-sdk'; import { UserRejectedRequestError } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; @@ -16,7 +15,6 @@ import type { BlockchainClient, SendFlowRepository, SnapClient, - TransactionRequest, ReviewTransactionContext, AssetRatesClient, Logger, @@ -38,6 +36,7 @@ jest.mock('@metamask/bitcoindevkit', () => { Amount: { from_btc: jest.fn(), }, + Psbt: { from_string: jest.fn() }, }; }); @@ -61,11 +60,13 @@ describe('SendFlowUseCases', () => { balance: { trusted_spendable: { to_sat: () => BigInt(1234) } }, peekAddress: jest.fn(), }); - const mockTxRequest = mock(); const mockPreferences = mock({ currency: 'usd', locale: 'en', }); + const mockPsbt = mock({ + toString: jest.fn(), + }); const useCases = new SendFlowUseCases( mockLogger, @@ -106,8 +107,9 @@ describe('SendFlowUseCases', () => { ); }); - it('displays Send form and returns transaction request when resolved', async () => { - mockSnapClient.displayInterface.mockResolvedValue(mockTxRequest); + it('displays Send form and returns PSBT when resolved', async () => { + (Psbt.from_string as jest.Mock).mockReturnValue(mockPsbt); + mockSnapClient.displayInterface.mockResolvedValue('psbtBase64'); const result = await useCases.display('account-id'); @@ -126,20 +128,13 @@ describe('SendFlowUseCases', () => { 'interface-id', ); expect(mockChain.getFeeEstimates).toHaveBeenCalled(); - expect(result).toStrictEqual(mockTxRequest); + expect(Psbt.from_string).toHaveBeenCalledWith('psbtBase64'); + expect(result).toStrictEqual(mockPsbt); }); }); describe('onChangeForm', () => { - const mockTxBuilder = { - addRecipient: jest.fn(), - feeRate: jest.fn(), - drainTo: jest.fn(), - drainWallet: jest.fn(), - finish: jest.fn(), - unspendable: jest.fn(), - }; - const mockPsbt = mock(); + const mockOutPoint = 'txid:vout'; const mockContext: SendFormContext = { account: { id: 'account-id', address: 'myAddress' }, amount: '1000', @@ -163,6 +158,14 @@ describe('SendFlowUseCases', () => { backgroundEventId: 'backgroundEventId', locale: 'en', }; + const mockTxBuilder = { + addRecipient: jest.fn(), + feeRate: jest.fn(), + drainTo: jest.fn(), + drainWallet: jest.fn(), + finish: jest.fn(), + unspendable: jest.fn(), + }; beforeEach(() => { mockAccount.buildTx.mockReturnValue(mockTxBuilder); @@ -172,8 +175,6 @@ describe('SendFlowUseCases', () => { mockTxBuilder.drainWallet.mockReturnThis(); mockTxBuilder.finish.mockReturnValue(mockPsbt); mockTxBuilder.unspendable.mockReturnThis(); - - mockSendFlowRepository.getContext.mockResolvedValue(mockContext); }); it('throws error unrecognized event', async () => { @@ -224,7 +225,7 @@ describe('SendFlowUseCases', () => { ); }); - it('throws error on Review if amount, recipient or fee are not defined', async () => { + it('throws error on Review if amount or recipient are not defined', async () => { await expect( useCases.onChangeForm('interface-id', SendFormEvent.Confirm, { ...mockContext, @@ -238,28 +239,58 @@ describe('SendFlowUseCases', () => { amount: undefined, }), ).rejects.toThrow('Inconsistent Send form context'); + }); - await expect( - useCases.onChangeForm('interface-id', SendFormEvent.Confirm, { - ...mockContext, - fee: undefined, - }), - ).rejects.toThrow('Inconsistent Send form context'); + it('updates interface to the drain transaction review on Confirm', async () => { + mockAccountRepository.get.mockResolvedValue(mockAccount); + mockAccountRepository.getFrozenUTXOs.mockResolvedValue([mockOutPoint]); + mockPsbt.toString.mockReturnValue('psbtBase64'); + const expectedReviewContext: ReviewTransactionContext = { + from: mockContext.account.address, + network: mockContext.network, + amount: '1000', + recipient: 'recipientAddress', + exchangeRate: mockContext.exchangeRate, + currency: mockContext.currency, + sendForm: { ...mockContext, drain: true }, + locale: 'en', + psbt: 'psbtBase64', + }; + + await useCases.onChangeForm('interface-id', SendFormEvent.Confirm, { + ...mockContext, + drain: true, + }); + + expect(mockAccountRepository.get).toHaveBeenCalledWith('account-id'); + expect(mockAccountRepository.getFrozenUTXOs).toHaveBeenCalledWith( + 'account-id', + ); + expect(mockAccount.buildTx).toHaveBeenCalled(); + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith(mockContext.feeRate); + expect(mockTxBuilder.drainWallet).toHaveBeenCalled(); + expect(mockTxBuilder.drainTo).toHaveBeenCalledWith(mockContext.recipient); + expect(mockTxBuilder.unspendable).toHaveBeenCalledWith([mockOutPoint]); + expect(mockSendFlowRepository.updateReview).toHaveBeenCalledWith( + 'interface-id', + expectedReviewContext, + ); }); it('updates interface to the transaction review on Confirm', async () => { + mockAccountRepository.get.mockResolvedValue(mockAccount); + mockAccountRepository.getFrozenUTXOs.mockResolvedValue([mockOutPoint]); + mockPsbt.toString.mockReturnValue('psbtBase64'); const expectedReviewContext: ReviewTransactionContext = { from: mockContext.account.address, network: mockContext.network, amount: '1000', recipient: 'recipientAddress', - feeRate: mockContext.feeRate, exchangeRate: mockContext.exchangeRate, currency: mockContext.currency, - fee: '10', sendForm: mockContext, - drain: mockContext.drain, locale: 'en', + psbt: 'psbtBase64', }; await useCases.onChangeForm( @@ -267,6 +298,18 @@ describe('SendFlowUseCases', () => { SendFormEvent.Confirm, mockContext, ); + + expect(mockAccountRepository.get).toHaveBeenCalledWith('account-id'); + expect(mockAccountRepository.getFrozenUTXOs).toHaveBeenCalledWith( + 'account-id', + ); + expect(mockAccount.buildTx).toHaveBeenCalled(); + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith(mockContext.feeRate); + expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith( + mockContext.amount, + mockContext.recipient, + ); + expect(mockTxBuilder.unspendable).toHaveBeenCalledWith([mockOutPoint]); expect(mockSendFlowRepository.updateReview).toHaveBeenCalledWith( 'interface-id', expectedReviewContext, @@ -470,12 +513,11 @@ describe('SendFlowUseCases', () => { amount: '10000', currency: CurrencyUnit.Bitcoin, recipient: 'recipientAddress', - feeRate: 2.4, - fee: '10', sendForm: { network: 'bitcoin', } as SendFormContext, locale: 'en', + psbt: 'psbt', }; it('throws error unrecognized event', async () => { @@ -527,11 +569,7 @@ describe('SendFlowUseCases', () => { expect(mockSnapClient.resolveInterface).toHaveBeenCalledWith( 'interface-id', - { - amount: mockContext.amount, - recipient: mockContext.recipient, - feeRate: mockContext.feeRate, - }, + mockContext.psbt, ); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index 754ce3fe..3ed5368c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -1,4 +1,4 @@ -import { Address, Amount } from '@metamask/bitcoindevkit'; +import { Psbt, Address, Amount } from '@metamask/bitcoindevkit'; import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; import { UserRejectedRequestError } from '@metamask/snaps-sdk'; @@ -7,7 +7,6 @@ import type { SendFlowRepository, SnapClient, BlockchainClient, - TransactionRequest, SendFormContext, ReviewTransactionContext, AssetRatesClient, @@ -60,7 +59,7 @@ export class SendFlowUseCases { this.#ratesRefreshInterval = ratesRefreshInterval; } - async display(accountId: string): Promise { + async display(accountId: string): Promise { this.#logger.debug('Displaying Send form. Account: %s', accountId); const account = await this.#accountRepository.get(accountId); @@ -90,18 +89,13 @@ export class SendFlowUseCases { void this.#refreshRates(interfaceId, context); // Blocks and waits for user actions - const request = await this.#snapClient.displayInterface( - interfaceId, - ); - if (!request) { + const psbt = await this.#snapClient.displayInterface(interfaceId); + if (!psbt) { throw new UserRejectedRequestError() as unknown as Error; } - this.#logger.debug( - 'Transaction request generated successfully: %o', - request, - ); - return request; + this.#logger.debug('PSBT generated successfully'); + return Psbt.from_string(psbt); } async onChangeForm( @@ -140,17 +134,35 @@ export class SendFlowUseCases { ); } - if (context.amount && context.recipient && context.fee) { + if (context.amount && context.recipient) { + const account = await this.#accountRepository.get(context.account.id); + if (!account) { + throw new Error('Account removed while confirming send flow'); + } + const frozenUTXOs = await this.#accountRepository.getFrozenUTXOs( + context.account.id, + ); + + const builder = account + .buildTx() + .feeRate(context.feeRate) + .unspendable(frozenUTXOs); + + if (context.drain) { + builder.drainWallet().drainTo(context.recipient); + } else { + builder.addRecipient(context.amount, context.recipient); + } + const psbt = builder.finish(); + const reviewContext: ReviewTransactionContext = { from: context.account.address, network: context.network, amount: context.amount, recipient: context.recipient, - feeRate: context.feeRate, exchangeRate: context.exchangeRate, currency: context.currency, - fee: context.fee, - drain: context.drain, + psbt: psbt.toString(), sendForm: context, locale: context.locale, }; @@ -194,15 +206,7 @@ export class SendFlowUseCases { return this.#snapClient.resolveInterface(id, null); } case ReviewTransactionEvent.Send: { - const { amount, feeRate, recipient, drain } = context; - const txRequest: TransactionRequest = { - feeRate, - amount: drain ? undefined : amount, - recipient, - drain, - }; - - return this.#snapClient.resolveInterface(id, txRequest); + return this.#snapClient.resolveInterface(id, context.psbt); } default: throw new Error('Unrecognized event'); From bd6521b3517242e0fe4bf051145449edf934036c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 2 May 2025 13:16:31 +0200 Subject: [PATCH 223/362] 0.12.0 (#452) --- .../bitcoin-wallet-snap/CHANGELOG.md | 18 +++++++++++++++++- .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index b7fd925b..50b32245 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.0] + +### Added + +- Compress preinstalled Snap during build ([#451](https://github.com/MetaMask/snap-bitcoin-wallet/pull/451)) + +### Changed + +- Disable UTXO protection ([#445](https://github.com/MetaMask/snap-bitcoin-wallet/pull/445)) +- Generate PSBT in send flow ([#448](https://github.com/MetaMask/snap-bitcoin-wallet/pull/448)) + +### Removed + +- Remove SimpleHash ([#447](https://github.com/MetaMask/snap-bitcoin-wallet/pull/447)) + ## [0.11.0] ### Added @@ -298,7 +313,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.11.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.0...HEAD +[0.12.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.11.0...v0.12.0 [0.11.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.8.2...v0.9.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 0cc3558a..5add726e 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.11.0", + "version": "0.12.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index a380254f..76b56517 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.11.0", + "version": "0.12.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "NkTLFbcjHf5Ly03PLjuwObJ/9Op8ePa7LTk4KFx0oTE=", + "shasum": "noYPLK36B0Zcy+n2P28DYQUDgoojeZrxjCZc66sl9UM=", "location": { "npm": { "filePath": "dist/bundle.js", From 27005da4af93e67edac75eca7303570a50cf22e8 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 6 May 2025 16:38:45 +0200 Subject: [PATCH 224/362] feat: add unconfirmed tx to history (#453) --- .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/entities/account.ts | 8 +++++++ .../src/infra/BdkAccountAdapter.ts | 15 +++++++++++-- .../src/infra/jsx/ReviewTransactionView.tsx | 2 +- .../src/use-cases/AccountUseCases.test.ts | 22 +++++++++++++------ .../src/use-cases/AccountUseCases.ts | 14 ++++++++++-- .../src/use-cases/SendFlowUseCases.ts | 2 +- 8 files changed, 52 insertions(+), 15 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 5add726e..e68590ab 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -38,7 +38,7 @@ "devDependencies": { "@babel/preset-typescript": "^7.23.3", "@metamask/auto-changelog": "^3.4.4", - "@metamask/bitcoindevkit": "^0.1.6", + "@metamask/bitcoindevkit": "^0.1.7", "@metamask/eslint-config": "^12.2.0", "@metamask/eslint-config-jest": "^12.1.0", "@metamask/eslint-config-nodejs": "^12.1.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 76b56517..80b39220 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "noYPLK36B0Zcy+n2P28DYQUDgoojeZrxjCZc66sl9UM=", + "shasum": "+/R8S+EltOo4oeG2NB2RMA/tveGercEzIktYIrw37JU=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index 5bf5fba0..b80b54fd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -147,6 +147,14 @@ export type BitcoinAccount = { * @returns the sent and received amounts. */ sentAndReceived(tx: Transaction): [Amount, Amount]; + + /** + * Apply relevant unconfirmed transactions to the wallet. + * Transactions that are not relevant are filtered out. + * @param tx - The Bitcoin transaction. + * @param lastSeen - Timestamp of when the transaction was last seen in the mempool. + */ + applyUnconfirmedTx(tx: Transaction, lastSeen: number): void; }; /** diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index 3e338b2b..f7b4a680 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -15,7 +15,12 @@ import type { Amount, ScriptBuf, } from '@metamask/bitcoindevkit'; -import { Txid, Wallet } from '@metamask/bitcoindevkit'; +import { + UnconfirmedTx, + SignOptions, + Txid, + Wallet, +} from '@metamask/bitcoindevkit'; import type { BitcoinAccount, TransactionBuilder } from '../entities'; import { BdkTxBuilderAdapter } from './BdkTxBuilderAdapter'; @@ -117,7 +122,7 @@ export class BdkAccountAdapter implements BitcoinAccount { } sign(psbt: Psbt): Transaction { - const success = this.#wallet.sign(psbt); + const success = this.#wallet.sign(psbt, new SignOptions()); if (!success) { throw new Error('failed to sign PSBT'); } @@ -149,4 +154,10 @@ export class BdkAccountAdapter implements BitcoinAccount { const sentAndReceived = this.#wallet.sent_and_received(tx.clone()); return [sentAndReceived[0], sentAndReceived[1]]; } + + applyUnconfirmedTx(tx: Transaction, lastSeen: number): void { + this.#wallet.apply_unconfirmed_txs([ + new UnconfirmedTx(tx, BigInt(lastSeen)), + ]); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx index 7b9187b5..5c7ae1eb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx @@ -34,7 +34,7 @@ export const ReviewTransactionView: SnapComponent< const psbt = Psbt.from_string(context.psbt); const fee = psbt.fee().to_sat(); - const feeRate = psbt.fee_rate()?.to_sat_per_vb_floor; + const feeRate = psbt.fee_rate()?.to_sat_per_vb_floor(); const total = BigInt(amount) + fee; return ( diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 995495fa..e799ae02 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -526,11 +526,19 @@ describe('AccountUseCases', () => { // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 /* eslint-disable @typescript-eslint/naming-convention */ compute_txid: jest.fn(), + clone: jest.fn(), }); const mockAccount = mock({ network: 'bitcoin', sign: jest.fn(), }); + const mockWalletTx = mock(); + + beforeEach(() => { + mockRepository.getWithSigner.mockResolvedValue(mockAccount); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + mockTransaction.clone.mockReturnThis(); + }); it('throws error if account is not found', async () => { mockRepository.getWithSigner.mockResolvedValue(null); @@ -541,9 +549,8 @@ describe('AccountUseCases', () => { }); it('sends transaction', async () => { - mockRepository.getWithSigner.mockResolvedValue(mockAccount); mockAccount.sign.mockReturnValue(mockTransaction); - mockTransaction.compute_txid.mockReturnValue(mockTxid); + mockAccount.getTransaction.mockReturnValue(mockWalletTx); const txId = await useCases.sendPsbt('account-id', mockPsbt); @@ -554,10 +561,13 @@ describe('AccountUseCases', () => { mockTransaction, ); expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(mockTransaction.compute_txid).toHaveBeenCalled(); expect( mockSnapClient.emitAccountBalancesUpdatedEvent, ).toHaveBeenCalledWith(mockAccount); - expect(mockTransaction.compute_txid).toHaveBeenCalled(); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount, [mockWalletTx]); expect(txId).toBe(mockTxid); }); @@ -571,9 +581,8 @@ describe('AccountUseCases', () => { }); it('propagates an error if broadcast fails', async () => { - mockRepository.getWithSigner.mockResolvedValue(mockAccount); - const error = new Error('broadcast failed'); + mockAccount.sign.mockReturnValue(mockTransaction); mockChain.broadcast.mockRejectedValueOnce(error); await expect(useCases.sendPsbt('account-id', mockPsbt)).rejects.toBe( @@ -582,9 +591,8 @@ describe('AccountUseCases', () => { }); it('propagates an error if update fails', async () => { - mockRepository.getWithSigner.mockResolvedValue(mockAccount); - const error = new Error('update failed'); + mockAccount.sign.mockReturnValue(mockTransaction); mockRepository.update.mockRejectedValue(error); await expect(useCases.sendPsbt('account-id', mockPsbt)).rejects.toBe( diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 390ac952..079e1904 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -5,6 +5,7 @@ import type { Txid, WalletTx, } from '@metamask/bitcoindevkit'; +import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; import type { AccountsConfig, @@ -222,12 +223,21 @@ export class AccountUseCases { } const tx = account.sign(psbt); - await this.#chain.broadcast(account.network, tx); + const txId = tx.compute_txid(); + await this.#chain.broadcast(account.network, tx.clone()); + account.applyUnconfirmedTx(tx, getCurrentUnixTimestamp()); await this.#repository.update(account); await this.#snapClient.emitAccountBalancesUpdatedEvent(account); - const txId = tx.compute_txid(); + const walletTx = account.getTransaction(txId.toString()); + if (walletTx) { + // should always be true by assertion but needed for type checking + await this.#snapClient.emitAccountTransactionsUpdatedEvent(account, [ + walletTx, + ]); + } + this.#logger.info( 'Transaction sent successfully: %s. Account: %s, Network: %s', txId, diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index 3ed5368c..ad4621cd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -94,7 +94,7 @@ export class SendFlowUseCases { throw new UserRejectedRequestError() as unknown as Error; } - this.#logger.debug('PSBT generated successfully'); + this.#logger.info('PSBT generated successfully'); return Psbt.from_string(psbt); } From cdf7d6d36dd668e03901f363f6011e59dfee5100 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 8 May 2025 12:10:32 +0200 Subject: [PATCH 225/362] build: bump bitcoindevkit version (#454) --- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index e68590ab..26c3abd1 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -38,7 +38,7 @@ "devDependencies": { "@babel/preset-typescript": "^7.23.3", "@metamask/auto-changelog": "^3.4.4", - "@metamask/bitcoindevkit": "^0.1.7", + "@metamask/bitcoindevkit": "^0.1.8", "@metamask/eslint-config": "^12.2.0", "@metamask/eslint-config-jest": "^12.1.0", "@metamask/eslint-config-nodejs": "^12.1.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 80b39220..c416346e 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "+/R8S+EltOo4oeG2NB2RMA/tveGercEzIktYIrw37JU=", + "shasum": "t1sbC/O8EYfTMCBvUbwiS/81Y3APi2Crf0e4TdtKhWU=", "location": { "npm": { "filePath": "dist/bundle.js", From 36ef22dd7ccbc46830afdcc0da96dc8b7fa53843 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 8 May 2025 12:44:17 +0200 Subject: [PATCH 226/362] 0.12.1 (#455) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 13 ++++++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 50b32245..35351a16 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.1] + +### Added + +- Add unconfirmed transactions to wallet history ([#453](https://github.com/MetaMask/snap-bitcoin-wallet/pull/453)) + +### Changed + +- Reduce bundle size ([#454](https://github.com/MetaMask/snap-bitcoin-wallet/pull/454)) + ## [0.12.0] ### Added @@ -313,7 +323,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.1...HEAD +[0.12.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.0...v0.12.1 [0.12.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.11.0...v0.12.0 [0.11.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.9.0...v0.10.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 26c3abd1..95e8b512 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.12.0", + "version": "0.12.1", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index c416346e..58e90228 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.12.0", + "version": "0.12.1", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "t1sbC/O8EYfTMCBvUbwiS/81Y3APi2Crf0e4TdtKhWU=", + "shasum": "FXNmQdhqPaBpAu+xShi1Z288zKOLXyFpMREo5JuuclE=", "location": { "npm": { "filePath": "dist/bundle.js", From 0ba5ade879ad0c9a11d33df6968b097820b84441 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 14 May 2025 10:01:14 +0200 Subject: [PATCH 227/362] feat: historical prices (#456) --- .../bitcoin-wallet-snap/package.json | 4 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../bitcoin-wallet-snap/src/entities/rates.ts | 14 ++++++ .../src/handlers/AssetsHandler.test.ts | 35 +++++++++++++++ .../src/handlers/AssetsHandler.ts | 28 ++++++++++++ .../bitcoin-wallet-snap/src/index.ts | 6 +++ .../src/infra/PriceApiClientAdapter.ts | 32 +++++++++++++ .../src/use-cases/AssetsUseCases.test.ts | 30 ++++++++++++- .../src/use-cases/AssetsUseCases.ts | 45 ++++++++++++++++++- 9 files changed, 192 insertions(+), 6 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 95e8b512..21b72953 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -49,7 +49,7 @@ "@metamask/slip44": "^4.1.0", "@metamask/snaps-cli": "^7.1.0", "@metamask/snaps-jest": "^8.14.1", - "@metamask/snaps-sdk": "^6.22.1", + "@metamask/snaps-sdk": "^6.23.0", "@metamask/utils": "^11.4.0", "@typescript-eslint/eslint-plugin": "^5.42.1", "@typescript-eslint/parser": "^5.42.1", @@ -68,7 +68,7 @@ "jest-transform-stub": "2.0.0", "prettier": "^2.7.1", "rimraf": "^3.0.2", - "superstruct": "^1.0.3", + "superstruct": "^2.0.2", "ts-jest": "^29.1.0", "typescript": "^4.7.4", "uuid": "^11.1.0" diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 58e90228..4b515aa7 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "FXNmQdhqPaBpAu+xShi1Z288zKOLXyFpMREo5JuuclE=", + "shasum": "KCAk/gika7mx88LOjO4qhMMHH/SK+5wai2EMWMM031E=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -97,6 +97,6 @@ ] } }, - "platformVersion": "6.22.1", + "platformVersion": "6.23.0", "manifestVersion": "0.1" } diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts b/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts index 51cbb058..99175cdc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts @@ -1,5 +1,7 @@ +import type { HistoricalPriceValue } from '@metamask/snaps-sdk'; import type { CaipAssetType } from '@metamask/utils'; +export type TimePeriod = 'P1D' | 'P7D' | 'P1M' | 'P3M' | 'P1Y' | 'P1000Y'; export type AssetRate = [CaipAssetType, number | null]; export type ExchangeRates = { @@ -14,4 +16,16 @@ export type AssetRatesClient = { * @param baseCurrency - the currency to convert prices to. Defaults to 'btc'. */ exchangeRates(baseCurrency?: string): Promise; + + /** + * Returns a list of historical prices for a token against another. + * @param timePeriod - the time period to fetch prices for. + * @param vsCurrency - the currency to convert prices to. Defaults to 'usd'. + * @param baseCurrency - the currency to get prices for. Defaults to 'bitcoin'. + */ + historicalPrices( + timePeriod: TimePeriod, + vsCurrency?: string, + baseCurrency?: string, + ): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts index 3f83cbd3..bd679d13 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts @@ -1,3 +1,4 @@ +import type { HistoricalPriceIntervals } from '@metamask/snaps-sdk'; import { SnapError } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; @@ -107,4 +108,38 @@ describe('AssetsHandler', () => { ); }); }); + + describe('historicalPrice', () => { + it('returns null if from is not Bitcoin', async () => { + const result = await handler.historicalPrice( + Caip19Asset.Testnet, + Caip19Asset.Bitcoin, + ); + expect(result).toBeNull(); + }); + + it('returns prices for Bitcoin successfully', async () => { + const mockIntervals = mock(); + mockAssetsUseCases.getPriceIntervals.mockResolvedValue(mockIntervals); + + const result = await handler.historicalPrice( + Caip19Asset.Bitcoin, + Caip19Asset.Testnet, + ); + + expect(mockAssetsUseCases.getPriceIntervals).toHaveBeenCalledWith( + Caip19Asset.Testnet, + ); + expect(result?.historicalPrice.intervals).toStrictEqual(mockIntervals); + }); + + it('propagates errors from getPriceIntervals', async () => { + const error = new Error(); + mockAssetsUseCases.getPriceIntervals.mockRejectedValue(error); + + await expect( + handler.historicalPrice(Caip19Asset.Bitcoin, Caip19Asset.Testnet), + ).rejects.toThrow(new SnapError(error)); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts index fa75c73b..28686ec3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts @@ -3,10 +3,13 @@ import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; import type { CaipAssetType, FungibleAssetMetadata, + OnAssetHistoricalPriceResponse, OnAssetsConversionArguments, OnAssetsConversionResponse, OnAssetsLookupResponse, } from '@metamask/snaps-sdk'; +import { CaipAssetTypeStruct } from '@metamask/utils'; +import { assert } from 'superstruct'; import type { AssetsUseCases } from '../use-cases'; import { Caip19Asset } from './caip'; @@ -130,4 +133,29 @@ export class AssetsHandler { return { conversionRates }; }); } + + async historicalPrice( + from: CaipAssetType, + to: CaipAssetType, + ): Promise { + assert(from, CaipAssetTypeStruct); + assert(to, CaipAssetTypeStruct); + + if (from !== Caip19Asset.Bitcoin) { + return null; + } + + const updateTime = getCurrentUnixTimestamp(); + return handle(async () => { + const intervals = await this.#assetsUseCases.getPriceIntervals(to); + + return { + historicalPrice: { + intervals, + updateTime, + expirationTime: updateTime + this.#expirationInterval, + }, + }; + }); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 2b545184..9dffc99b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -5,6 +5,7 @@ import type { OnRpcRequestHandler, OnKeyringRequestHandler, OnUserInputHandler, + OnAssetHistoricalPriceHandler, } from '@metamask/snaps-sdk'; import { Config } from './config'; @@ -87,3 +88,8 @@ export const onAssetsLookup: OnAssetsLookupHandler = async () => export const onAssetsConversion: OnAssetsConversionHandler = async ({ conversions, }) => assetsHandler.conversion(conversions); + +export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ({ + from, + to, +}) => assetsHandler.historicalPrice(from, to); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts index ac7fd934..f840b7c9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts @@ -1,9 +1,16 @@ +import type { HistoricalPriceValue } from '@metamask/snaps-sdk'; + import type { AssetRatesClient, ExchangeRates, PriceApiConfig, + TimePeriod, } from '../entities'; +export type HistoricalPricesResponse = { + prices: [number, number][]; +}; + export class PriceApiClientAdapter implements AssetRatesClient { readonly #endpoint: string; @@ -23,4 +30,29 @@ export class PriceApiClientAdapter implements AssetRatesClient { return await response.json(); } + + async historicalPrices( + timePeriod: TimePeriod, + vsCurrency = 'usd', + baseCurrency = 'bitcoin', + ): Promise { + const url = `${ + this.#endpoint + }/v1/historical-prices/${baseCurrency}?timePeriod=${timePeriod.slice( + 1, + )}&vsCurrency=${vsCurrency}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error( + `Failed to fetch historical rates: ${response.statusText}`, + ); + } + + const prices: HistoricalPricesResponse = await response.json(); + return prices.prices.map(([timestamp, price]) => [ + timestamp, + price.toString(), + ]); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts index f8b474ec..b1371483 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts @@ -1,3 +1,4 @@ +import type { HistoricalPriceValue } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; import type { AssetRatesClient, ExchangeRates, Logger } from '../entities'; @@ -37,7 +38,7 @@ describe('AssetsUseCases', () => { }); it('propagates an error if exchangeRates fails', async () => { - const error = new Error('Get failed'); + const error = new Error('getRates failed'); mockAssetRates.exchangeRates.mockRejectedValue(error); await expect(useCases.getRates([Caip19Asset.Testnet])).rejects.toBe( @@ -45,4 +46,31 @@ describe('AssetsUseCases', () => { ); }); }); + + describe('getPriceIntervals', () => { + it('returns prices against the specified token', async () => { + const mockHistoricalPrices = mock(); + mockAssetRates.historicalPrices.mockResolvedValue(mockHistoricalPrices); + + const result = await useCases.getPriceIntervals('swift:0/iso4217:USD'); + + expect(mockAssetRates.historicalPrices).toHaveBeenCalledTimes(6); + expect(result).toStrictEqual({ + P1D: mockHistoricalPrices, + P7D: mockHistoricalPrices, + P1M: mockHistoricalPrices, + P3M: mockHistoricalPrices, + P1Y: mockHistoricalPrices, + P1000Y: mockHistoricalPrices, + }); + }); + + it('does not fail if historicalPrices returns an error', async () => { + const error = new Error('historicalPrices failed'); + mockAssetRates.historicalPrices.mockRejectedValue(error); + + const result = await useCases.getPriceIntervals('swift:0/iso4217:USD'); + expect(result).toStrictEqual({}); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts index 5052d593..bc275350 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts @@ -1,8 +1,14 @@ import slip44 from '@metamask/slip44'; +import type { HistoricalPriceIntervals } from '@metamask/snaps-sdk'; import type { CaipAssetType } from '@metamask/utils'; import { parseCaipAssetType } from '@metamask/utils'; -import type { AssetRatesClient, AssetRate, Logger } from '../entities'; +import type { + AssetRatesClient, + AssetRate, + Logger, + TimePeriod, +} from '../entities'; export class AssetsUseCases { readonly #logger: Logger; @@ -29,6 +35,43 @@ export class AssetsUseCases { }); } + async getPriceIntervals( + to: CaipAssetType, + ): Promise { + this.#logger.debug('Fetching BTC historical prices. To %s', to); + + const timePeriods: TimePeriod[] = [ + 'P1D', + 'P7D', + 'P1M', + 'P3M', + 'P1Y', + 'P1000Y', + ]; + const vsCurrency = this.#assetToTicker(to); + const historicalPrices: HistoricalPriceIntervals = {}; + await Promise.all( + timePeriods.map(async (timePeriod) => { + try { + const prices = await this.#assetRates.historicalPrices( + timePeriod, + vsCurrency, + ); + historicalPrices[timePeriod] = prices; + } catch (error) { + this.#logger.error( + `Failed to fetch historical prices. Time period: %s. Error: %s`, + timePeriod, + error, + ); + } + }), + ); + + this.#logger.debug('BTC historical prices fetched successfully'); + return historicalPrices; + } + #assetToTicker(asset: CaipAssetType): string | undefined { const { assetNamespace, assetReference } = parseCaipAssetType(asset); From 1b41c5c3e47f22989fd3b5345211646657b32eb1 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 15 May 2025 14:50:43 +0200 Subject: [PATCH 228/362] feat: multi account support (#457) --- .../integration-test/keyring.test.ts | 55 ++++++++++++--- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/config.ts | 1 - .../src/entities/account.ts | 28 ++++++++ .../src/entities/config.ts | 1 - .../src/handlers/KeyringHandler.test.ts | 68 +++++++++++++++---- .../src/handlers/KeyringHandler.ts | 51 +++++++++++--- .../src/use-cases/AccountUseCases.test.ts | 45 ++++++------ .../src/use-cases/AccountUseCases.ts | 63 ++++++++--------- 9 files changed, 226 insertions(+), 88 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index 315d1c4a..828fc8a4 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -24,42 +24,57 @@ describe('Keyring', () => { // Main account used in the tests, only one to synchronize addressType: Caip2AddressType.P2wpkh, scope: BtcScope.Regtest, + index: 0, expectedAddress: TEST_ADDRESS, synchronize: true, }, { addressType: Caip2AddressType.P2wpkh, scope: BtcScope.Mainnet, + index: 0, expectedAddress: 'bc1q832zlt4tgnqy88vd20mazw77dlt0j0wf2naw8q', synchronize: false, }, + { + // Tests multiple accounts of same address type + addressType: Caip2AddressType.P2wpkh, + scope: BtcScope.Mainnet, + index: 1, + expectedAddress: 'bc1qe2e3tdkqwytw7furyl2nlfy3sqs23acynn50d9', + synchronize: false, + }, { addressType: Caip2AddressType.P2pkh, scope: BtcScope.Mainnet, + index: 0, expectedAddress: '15feVv7kK3z7jxA4RZZzY7Fwdu3yqFwzcT', synchronize: false, }, { addressType: Caip2AddressType.P2pkh, scope: BtcScope.Testnet, + index: 0, expectedAddress: 'mjPQaLkhZN3MxsYN8Nebzwevuz8vdTaRCq', synchronize: false, }, { addressType: Caip2AddressType.P2sh, scope: BtcScope.Mainnet, + index: 0, expectedAddress: '3QVSaDYjxEh4L3K24eorrQjfVxPAKJMys2', synchronize: false, }, { addressType: Caip2AddressType.P2sh, scope: BtcScope.Testnet, + index: 0, expectedAddress: '2NBG623WvXp1zxKB6gK2mnMe2mSDCur5qRU', synchronize: false, }, { addressType: Caip2AddressType.P2tr, scope: BtcScope.Mainnet, + index: 0, expectedAddress: 'bc1p4rue37y0v9snd4z3fvw43d29u97qxf9j3fva72xy2t7hekg24dzsaz40mz', synchronize: false, @@ -67,6 +82,7 @@ describe('Keyring', () => { { addressType: Caip2AddressType.P2tr, scope: BtcScope.Testnet, + index: 0, expectedAddress: 'tb1pwwjax3vpq6h69965hcr22vkpm4qdvyu2pz67wyj8eagp9vxkcz0q0ya20h', synchronize: false, @@ -90,11 +106,32 @@ describe('Keyring', () => { }); if ('result' in response.response) { - accounts[`${requestOpts.addressType}:${requestOpts.scope}`] = response - .response.result as KeyringAccount; + accounts[ + `${requestOpts.addressType}:${requestOpts.scope}:${requestOpts.index}` + ] = response.response.result as KeyringAccount; } }); + it('creates account by derivationPath idempotently', async () => { + snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); + + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope: BtcScope.Mainnet, + addressType: Caip2AddressType.P2wpkh, + derivationPath: "m/84'/0'/1'", + }, + }, + }); + + expect(response).toRespondWith( + accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Mainnet}:1`], + ); + }); + it('returns the same account if already exists', async () => { snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); @@ -110,7 +147,7 @@ describe('Keyring', () => { }); expect(response).toRespondWith( - accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Mainnet}`], + accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Mainnet}:0`], ); }); @@ -119,12 +156,12 @@ describe('Keyring', () => { origin: ORIGIN, method: 'keyring_getAccount', params: { - id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Mainnet}`].id, + id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Mainnet}:0`].id, }, }); expect(response).toRespondWith( - accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Mainnet}`], + accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Mainnet}:0`], ); }); @@ -139,7 +176,7 @@ describe('Keyring', () => { it('lists account transactions', async () => { const accoundId = - accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id; + accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}:0`].id; const response = await snap.onKeyringRequest({ origin: ORIGIN, method: 'keyring_listAccountTransactions', @@ -160,7 +197,7 @@ describe('Keyring', () => { origin: ORIGIN, method: 'keyring_getAccountBalances', params: { - id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}`].id, + id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}:0`].id, assets: [Caip19Asset.Regtest], }, }); @@ -174,7 +211,7 @@ describe('Keyring', () => { }); it('removes an account', async () => { - const { id } = accounts[`${Caip2AddressType.P2pkh}:${BtcScope.Mainnet}`]; + const { id } = accounts[`${Caip2AddressType.P2pkh}:${BtcScope.Mainnet}:0`]; let response = await snap.onKeyringRequest({ origin: ORIGIN, @@ -224,7 +261,7 @@ describe('Keyring', () => { origin: ORIGIN, method: 'keyring_listAccountAssets', params: { - id: accounts[`${addressType}:${scope}`].id, + id: accounts[`${addressType}:${scope}:0`].id, }, }); diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 4b515aa7..82fd057d 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "KCAk/gika7mx88LOjO4qhMMHH/SK+5wai2EMWMM031E=", + "shasum": "UJXkasCHOMkLs1Y58OMY7bZzANomv7gxoYnP9kxX6mw=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 1e8fd4df..21c2c52b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -8,7 +8,6 @@ export const Config: SnapConfig = { logLevel: (process.env.LOG_LEVEL ?? LogLevel.INFO) as LogLevel, encrypt: false, accounts: { - index: 0, defaultAddressType: (process.env.DEFAULT_ADDRESS_TYPE ?? 'p2wpkh') as AddressType, }, diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index b80b54fd..7157a1fd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -222,3 +222,31 @@ export type BitcoinAccountRepository = { */ getFrozenUTXOs(id: string): Promise; }; + +export enum Purpose { + Legacy = 44, + Segwit = 49, + NativeSegwit = 84, + Taproot = 86, + Multisig = 45, +} +export enum Slip44 { + Bitcoin = 0, + Testnet = 1, +} + +export const addressTypeToPurpose: Record = { + p2pkh: Purpose.Legacy, + p2sh: Purpose.Segwit, + p2wsh: Purpose.Multisig, + p2wpkh: Purpose.NativeSegwit, + p2tr: Purpose.Taproot, +}; + +export const networkToCoinType: Record = { + bitcoin: Slip44.Bitcoin, + testnet: Slip44.Testnet, + testnet4: Slip44.Testnet, + signet: Slip44.Testnet, + regtest: Slip44.Testnet, +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts index f57439a0..86eac109 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -15,7 +15,6 @@ export type SnapConfig = { }; export type AccountsConfig = { - index: number; defaultAddressType: AddressType; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index d85ab613..d515fea7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -12,7 +12,10 @@ import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; import { CurrencyUnit, type BitcoinAccount } from '../entities'; -import type { AccountUseCases } from '../use-cases/AccountUseCases'; +import type { + AccountUseCases, + CreateAccountParams, +} from '../use-cases/AccountUseCases'; import { caip2ToNetwork, caip2ToAddressType, @@ -53,6 +56,7 @@ describe('KeyringHandler', () => { const handler = new KeyringHandler(mockAccounts); beforeEach(() => { + mockAccounts.create.mockResolvedValue(mockAccount); mockAccount.peekAddress.mockReturnValue( mock({ address: mockAddress, @@ -62,41 +66,80 @@ describe('KeyringHandler', () => { describe('createAccount', () => { const entropySource = 'some-source'; + const index = 1; const correlationId = 'correlation-id'; it('respects provided params', async () => { - mockAccounts.create.mockResolvedValue(mockAccount); const options = { scope: BtcScope.Signet, entropySource, + index, addressType: Caip2AddressType.P2pkh, metamask: { correlationId, }, }; + const expectedCreateParams: CreateAccountParams = { + network: caip2ToNetwork[BtcScope.Signet], + entropySource, + index, + addressType: caip2ToAddressType[Caip2AddressType.P2pkh], + correlationId, + }; + await handler.createAccount(options); expect(assert).toHaveBeenCalledWith(options, CreateAccountRequest); - expect(mockAccounts.create).toHaveBeenCalledWith( - caip2ToNetwork[BtcScope.Signet], - entropySource, - caip2ToAddressType[Caip2AddressType.P2pkh], - correlationId, - ); + expect(mockAccounts.create).toHaveBeenCalledWith(expectedCreateParams); expect(mockAccounts.fullScan).not.toHaveBeenCalled(); }); + it('extracts index from derivationPath', async () => { + const options = { + scope: BtcScope.Signet, + derivationPath: "m/44'/0'/5'/*/*", // change and address indexes can be anything + }; + const expectedCreateParams: CreateAccountParams = { + network: 'signet', + index: 5, + }; + + await handler.createAccount(options); + expect(mockAccounts.create).toHaveBeenCalledWith(expectedCreateParams); + + // Test with a valid derivationPath without change and address index + await handler.createAccount({ + ...options, + derivationPath: "m/44'/0'/3'", + }); + expect(mockAccounts.create).toHaveBeenCalledWith({ + ...expectedCreateParams, + index: 3, + }); + }); + + it('fails if derivationPath is invalid', async () => { + const options = { + scope: BtcScope.Signet, + derivationPath: "m/44'/0'/NaN'", + }; + + await expect(handler.createAccount(options)).rejects.toThrow( + 'Invalid account index: NaN', + ); + + await expect( + handler.createAccount({ ...options, derivationPath: "m/44'" }), // missing segments + ).rejects.toThrow("Invalid derivation path: m/44'"); + }); + it('performs a full scan when synchronize option is true', async () => { - mockAccounts.create.mockResolvedValue(mockAccount); const options = { scope: BtcScope.Signet, - entropySourceId: entropySource, - addressType: Caip2AddressType.P2pkh, synchronize: true, }; await handler.createAccount(options); - expect(assert).toHaveBeenCalled(); expect(mockAccounts.create).toHaveBeenCalled(); expect(mockAccounts.fullScan).toHaveBeenCalledWith(mockAccount); }); @@ -113,7 +156,6 @@ describe('KeyringHandler', () => { it('propagates errors from full scan', async () => { const error = new Error(); - mockAccounts.create.mockResolvedValue(mockAccount); mockAccounts.fullScan.mockRejectedValue(error); await expect( diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index dace0694..ca563d5d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -13,7 +13,15 @@ import type { } from '@metamask/keyring-api'; import { handleKeyringRequest } from '@metamask/keyring-snap-sdk'; import type { Json, JsonRpcRequest } from '@metamask/utils'; -import { assert, boolean, enums, object, optional, string } from 'superstruct'; +import { + assert, + boolean, + enums, + number, + object, + optional, + string, +} from 'superstruct'; import { networkToCurrencyUnit } from '../entities'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; @@ -34,6 +42,8 @@ export const CreateAccountRequest = object({ entropySource: optional(string()), accountNameSuggestion: optional(string()), synchronize: optional(boolean()), + index: optional(number()), + derivationPath: optional(string()), ...MetaMaskOptionsStruct.schema, }); @@ -68,13 +78,23 @@ export class KeyringHandler implements Keyring { opts: Record & MetaMaskOptions, ): Promise { assert(opts, CreateAccountRequest); - - const account = await this.#accountsUseCases.create( - caip2ToNetwork[opts.scope], - opts.entropySource, - opts.addressType ? caip2ToAddressType[opts.addressType] : undefined, - opts.metamask?.correlationId, - ); + const { + metamask, + scope, + entropySource, + index, + derivationPath, + addressType, + } = opts; + + const createParams = { + network: caip2ToNetwork[scope], + entropySource, + index: derivationPath ? this.#extractAccountIndex(derivationPath) : index, + addressType: addressType ? caip2ToAddressType[addressType] : undefined, + correlationId: metamask?.correlationId, + }; + const account = await this.#accountsUseCases.create(createParams); if (opts.synchronize) { await this.#accountsUseCases.fullScan(account); @@ -147,4 +167,19 @@ export class KeyringHandler implements Keyring { async submitRequest(): Promise { throw new Error('Method not implemented.'); } + + #extractAccountIndex(path: string): number { + const segments = path.split('/'); + if (segments.length < 4) { + throw new Error(`Invalid derivation path: ${path}`); + } + + const accountPart = segments[3]; + const match = accountPart.match(/^(\d+)/u); + if (!match) { + throw new Error(`Invalid account index: ${accountPart}`); + } + + return parseInt(match[1], 10); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index e799ae02..3589a916 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -27,7 +27,6 @@ describe('AccountUseCases', () => { const mockChain = mock(); const mockMetaProtocols = mock(); const accountsConfig: AccountsConfig = { - index: 0, defaultAddressType: 'p2wpkh', }; @@ -102,6 +101,7 @@ describe('AccountUseCases', () => { const network: Network = 'bitcoin'; const addressType: AddressType = 'p2wpkh'; const entropySource = 'some-source'; + const index = 1; const correlationId = 'some-correlation-id'; const mockAccount = mock(); @@ -119,19 +119,15 @@ describe('AccountUseCases', () => { ] as { tAddressType: AddressType; purpose: string }[])( 'creates an account of type: %s', async ({ tAddressType, purpose }) => { - const derivationPath = [ - entropySource, - purpose, - "0'", - `${accountsConfig.index}'`, - ]; + const derivationPath = [entropySource, purpose, "0'", `${index}'`]; - await useCases.create( + await useCases.create({ network, entropySource, - tAddressType, + index, + addressType: tAddressType, correlationId, - ); + }); expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( derivationPath, @@ -161,15 +157,16 @@ describe('AccountUseCases', () => { entropySource, "84'", coinType, - `${accountsConfig.index}'`, + `${index}'`, ]; - await useCases.create( - tNetwork, + await useCases.create({ + network: tNetwork, entropySource, + index, addressType, correlationId, - ); + }); expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( expectedDerivationPath, @@ -190,7 +187,12 @@ describe('AccountUseCases', () => { const mockExistingAccount = mock(); mockRepository.getByDerivationPath.mockResolvedValue(mockExistingAccount); - const result = await useCases.create(network, entropySource, addressType); + const result = await useCases.create({ + network, + entropySource, + index, + addressType, + }); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.insert).not.toHaveBeenCalled(); @@ -202,7 +204,12 @@ describe('AccountUseCases', () => { it('creates a new account if one does not exist', async () => { mockRepository.getByDerivationPath.mockResolvedValue(null); - const result = await useCases.create(network, entropySource, addressType); + const result = await useCases.create({ + network, + entropySource, + index, + addressType, + }); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.insert).toHaveBeenCalled(); @@ -216,7 +223,7 @@ describe('AccountUseCases', () => { mockRepository.getByDerivationPath.mockRejectedValue(error); await expect( - useCases.create(network, entropySource, addressType), + useCases.create({ network, entropySource, index, addressType }), ).rejects.toBe(error); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); @@ -230,7 +237,7 @@ describe('AccountUseCases', () => { mockRepository.insert.mockRejectedValue(error); await expect( - useCases.create(network, entropySource, addressType), + useCases.create({ network, entropySource, index, addressType }), ).rejects.toBe(error); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); @@ -244,7 +251,7 @@ describe('AccountUseCases', () => { mockSnapClient.emitAccountCreatedEvent.mockRejectedValue(error); await expect( - useCases.create(network, entropySource, addressType), + useCases.create({ network, entropySource, index, addressType }), ).rejects.toBe(error); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 079e1904..446fc2b8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -7,30 +7,24 @@ import type { } from '@metamask/bitcoindevkit'; import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; -import type { - AccountsConfig, - BitcoinAccount, - BitcoinAccountRepository, - BlockchainClient, - SnapClient, - MetaProtocolsClient, - Logger, +import { + type AccountsConfig, + type BitcoinAccount, + type BitcoinAccountRepository, + type BlockchainClient, + type SnapClient, + type MetaProtocolsClient, + type Logger, + addressTypeToPurpose, + networkToCoinType, } from '../entities'; -const addressTypeToPurpose: Record = { - p2pkh: "44'", - p2sh: "49'", - p2wsh: "45'", - p2wpkh: "84'", - p2tr: "86'", -}; - -const networkToCoinType: Record = { - bitcoin: "0'", - testnet: "1'", - testnet4: "1'", - signet: "1'", - regtest: "1'", +export type CreateAccountParams = { + network: Network; + index?: number; + entropySource?: string; + addressType?: AddressType; + correlationId?: string; }; export class AccountUseCases { @@ -83,24 +77,21 @@ export class AccountUseCases { return account; } - async create( - network: Network, - entropySource?: string, - addressType: AddressType = this.#accountConfig.defaultAddressType, - correlationId?: string, - ): Promise { - this.#logger.debug('Creating new Bitcoin account. Params: %o', { + async create(req: CreateAccountParams): Promise { + this.#logger.debug('Creating new Bitcoin account. Params: %o', req); + const { + addressType = this.#accountConfig.defaultAddressType, + index = 0, network, - addressType, - entropySource, correlationId, - }); + entropySource = 'm', + } = req; const derivationPath = [ - entropySource ?? 'm', - addressTypeToPurpose[addressType], - networkToCoinType[network], - `${this.#accountConfig.index}'`, + entropySource, + `${addressTypeToPurpose[addressType]}'`, + `${networkToCoinType[network]}'`, + `${index}'`, ]; // Idempotent account creation + ensures only one account per derivation path From d24017236cd323687d34822decf45d38d4e3afe2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 16 May 2025 11:55:44 +0200 Subject: [PATCH 229/362] 0.13.0 (#458) * 0.13.0 * changelog --------- Co-authored-by: github-actions Co-authored-by: Dario Anongba Varela --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 10 +++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 35351a16..2c534e50 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.13.0] + +### Added + +- Multi account ([#457](https://github.com/MetaMask/snap-bitcoin-wallet/pull/457)) +- Historical prices ([#456](https://github.com/MetaMask/snap-bitcoin-wallet/pull/456)) + ## [0.12.1] ### Added @@ -323,7 +330,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.1...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.13.0...HEAD +[0.13.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.1...v0.13.0 [0.12.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.0...v0.12.1 [0.12.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.11.0...v0.12.0 [0.11.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.10.0...v0.11.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 21b72953..15fa7b86 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.12.1", + "version": "0.13.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 82fd057d..b2a93ad8 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.12.1", + "version": "0.13.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "UJXkasCHOMkLs1Y58OMY7bZzANomv7gxoYnP9kxX6mw=", + "shasum": "jHCm1ZAHhjWCL2dQTMlAKff6YliM7wrdonDtPJLraKg=", "location": { "npm": { "filePath": "dist/bundle.js", From a3d2aaa5a81f606add24b473565397f817599ed3 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 20 May 2025 12:06:54 +0200 Subject: [PATCH 230/362] chore: monorepo cleanup (#459) --- .../bitcoin-wallet-snap/.depcheckrc.json | 2 +- .../bitcoin-wallet-snap/.eslintrc.js | 44 ------------------- .../integration-test/keyring.test.ts | 15 ++++--- .../integration-test/send-flow.test.ts | 5 ++- .../bitcoin-wallet-snap/jest.config.js | 10 ++++- .../jest.integration.config.js | 9 +++- .../bitcoin-wallet-snap/package.json | 39 ++++------------ .../scripts/build-preinstalled-snap.js | 2 +- .../bitcoin-wallet-snap/snap.config.ts | 6 +-- .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../src/entities/account.ts | 23 ++++++++++ .../bitcoin-wallet-snap/src/entities/chain.ts | 4 ++ .../bitcoin-wallet-snap/src/entities/index.ts | 12 ++--- .../src/entities/logger.ts | 5 +++ .../src/entities/meta-protocols.ts | 1 + .../bitcoin-wallet-snap/src/entities/rates.ts | 2 + .../src/entities/send-flow.ts | 6 ++- .../bitcoin-wallet-snap/src/entities/snap.ts | 17 +++++++ .../src/entities/transaction.ts | 5 +++ .../src/entities/translator.ts | 1 + .../src/handlers/AssetsHandler.ts | 15 +++---- .../src/handlers/CronHandler.test.ts | 7 ++- .../src/handlers/CronHandler.ts | 10 ++--- .../src/handlers/KeyringHandler.test.ts | 12 ++--- .../src/handlers/KeyringHandler.ts | 14 +++--- .../src/handlers/RpcHandler.test.ts | 2 +- .../src/handlers/RpcHandler.ts | 2 +- .../bitcoin-wallet-snap/src/handlers/caip.ts | 7 +-- .../src/handlers/errors.ts | 2 +- .../src/handlers/mappings.ts | 2 + .../src/handlers/permissions.ts | 7 +-- .../src/infra/BdkAccountAdapter.ts | 2 +- .../src/infra/EsploraClientAdapter.ts | 4 +- .../src/infra/SnapClientAdapter.ts | 8 ++-- .../src/infra/jsx/ReviewTransactionView.tsx | 14 +++--- .../src/infra/jsx/SendFormView.tsx | 8 ++-- .../src/infra/jsx/components/SendForm.tsx | 9 ++-- .../jsx/components/TransactionSummary.tsx | 10 ++--- .../src/store/JSXSendFlowRepository.test.tsx | 6 +-- .../src/use-cases/AssetsUseCases.test.ts | 2 +- .../src/use-cases/AssetsUseCases.ts | 4 +- .../src/use-cases/SendFlowUseCases.test.ts | 3 +- .../src/use-cases/SendFlowUseCases.ts | 3 +- .../bitcoin-wallet-snap/tsconfig.json | 19 +++----- 44 files changed, 194 insertions(+), 190 deletions(-) delete mode 100644 merged-packages/bitcoin-wallet-snap/.eslintrc.js diff --git a/merged-packages/bitcoin-wallet-snap/.depcheckrc.json b/merged-packages/bitcoin-wallet-snap/.depcheckrc.json index 98a3b27a..836108a8 100644 --- a/merged-packages/bitcoin-wallet-snap/.depcheckrc.json +++ b/merged-packages/bitcoin-wallet-snap/.depcheckrc.json @@ -1,3 +1,3 @@ { - "ignores": ["@metamask/auto-changelog", "jest-transform-stub"] + "ignores": ["jest-transform-stub", "ts-jest"] } diff --git a/merged-packages/bitcoin-wallet-snap/.eslintrc.js b/merged-packages/bitcoin-wallet-snap/.eslintrc.js deleted file mode 100644 index 8aa2a012..00000000 --- a/merged-packages/bitcoin-wallet-snap/.eslintrc.js +++ /dev/null @@ -1,44 +0,0 @@ -module.exports = { - extends: ['../../.eslintrc.js'], - rules: { - // This allows importing the `Text` JSX component. - '@typescript-eslint/no-shadow': [ - 'error', - { - allow: ['Text'], - }, - ], - 'id-length': [ - 'warn', - // Used for the localized translator helper. - { exceptions: ['t', '_'] }, - ], - }, - - parserOptions: { - tsconfigRootDir: __dirname, - }, - - overrides: [ - { - files: ['snap.config.ts'], - extends: ['@metamask/eslint-config-nodejs'], - }, - - { - files: ['*.test.ts', '*.test.tsx'], - 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/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index 828fc8a4..f915053d 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -3,9 +3,11 @@ import { BtcMethod, BtcScope } from '@metamask/keyring-api'; import type { Snap } from '@metamask/snaps-jest'; import { installSnap } from '@metamask/snaps-jest'; +import { FUNDING_TX, MNEMONIC, ORIGIN, TEST_ADDRESS } from './constants'; import { CurrencyUnit } from '../src/entities'; import { Caip2AddressType, Caip19Asset } from '../src/handlers/caip'; -import { FUNDING_TX, MNEMONIC, ORIGIN, TEST_ADDRESS } from './constants'; + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ describe('Keyring', () => { const accounts: Record = {}; @@ -105,6 +107,7 @@ describe('Keyring', () => { methods: [BtcMethod.SendBitcoin], }); + // eslint-disable-next-line jest/no-conditional-in-test if ('result' in response.response) { accounts[ `${requestOpts.addressType}:${requestOpts.scope}:${requestOpts.index}` @@ -156,7 +159,7 @@ describe('Keyring', () => { origin: ORIGIN, method: 'keyring_getAccount', params: { - id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Mainnet}:0`].id, + id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Mainnet}:0`]!.id, }, }); @@ -176,7 +179,7 @@ describe('Keyring', () => { it('lists account transactions', async () => { const accoundId = - accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}:0`].id; + accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}:0`]!.id; const response = await snap.onKeyringRequest({ origin: ORIGIN, method: 'keyring_listAccountTransactions', @@ -197,7 +200,7 @@ describe('Keyring', () => { origin: ORIGIN, method: 'keyring_getAccountBalances', params: { - id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}:0`].id, + id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}:0`]!.id, assets: [Caip19Asset.Regtest], }, }); @@ -211,7 +214,7 @@ describe('Keyring', () => { }); it('removes an account', async () => { - const { id } = accounts[`${Caip2AddressType.P2pkh}:${BtcScope.Mainnet}:0`]; + const { id } = accounts[`${Caip2AddressType.P2pkh}:${BtcScope.Mainnet}:0`]!; let response = await snap.onKeyringRequest({ origin: ORIGIN, @@ -261,7 +264,7 @@ describe('Keyring', () => { origin: ORIGIN, method: 'keyring_listAccountAssets', params: { - id: accounts[`${addressType}:${scope}:0`].id, + id: accounts[`${addressType}:${scope}:0`]!.id, }, }); diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts index ec86e498..fa239fc6 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts @@ -3,13 +3,14 @@ import { BtcScope } from '@metamask/keyring-api'; import type { Snap } from '@metamask/snaps-jest'; import { assertIsCustomDialog, installSnap } from '@metamask/snaps-jest'; +import { FUNDING_TX, MNEMONIC, ORIGIN } from './constants'; import { CurrencyUnit, ReviewTransactionEvent, SendFormEvent, } from '../src/entities'; import { Caip19Asset, Caip2AddressType } from '../src/handlers/caip'; -import { FUNDING_TX, MNEMONIC, ORIGIN } from './constants'; +import { CronMethod } from '../src/handlers/CronHandler'; describe('Send flow', () => { const recipient = 'bcrt1qyvhf2epk9s659206lq3rdvtf07uq3t9e7xtjje'; @@ -69,7 +70,7 @@ describe('Send flow', () => { await ui.typeInField(SendFormEvent.Amount, sendAmount); const backgroundEventResponse = await snap.onBackgroundEvent({ - method: SendFormEvent.RefreshRates, + method: CronMethod.RefreshRates, params: { interfaceId: ui.id }, }); expect(backgroundEventResponse).toRespondWith(null); diff --git a/merged-packages/bitcoin-wallet-snap/jest.config.js b/merged-packages/bitcoin-wallet-snap/jest.config.js index 05fed64e..e0442ef8 100644 --- a/merged-packages/bitcoin-wallet-snap/jest.config.js +++ b/merged-packages/bitcoin-wallet-snap/jest.config.js @@ -1,8 +1,14 @@ -module.exports = { +// @ts-check +/** + * @type {import('ts-jest').JestConfigWithTsJest} + */ +const config = { preset: '@metamask/snaps-jest', transform: { '^.+\\.(t|j)sx?$': 'ts-jest', }, - restoreMocks: true, + resetMocks: true, testMatch: ['**/src/**/?(*.)+(spec|test).[tj]s?(x)'], }; + +export default config; diff --git a/merged-packages/bitcoin-wallet-snap/jest.integration.config.js b/merged-packages/bitcoin-wallet-snap/jest.integration.config.js index 297549b2..4c82e226 100644 --- a/merged-packages/bitcoin-wallet-snap/jest.integration.config.js +++ b/merged-packages/bitcoin-wallet-snap/jest.integration.config.js @@ -1,4 +1,11 @@ -module.exports = { +// @ts-check +/** + * @type {import('ts-jest').JestConfigWithTsJest} + */ +const config = { preset: '@metamask/snaps-jest', testMatch: ['**/integration-test/**/*.test.ts'], }; + +export default config; + diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 15fa7b86..03e8829d 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -16,13 +16,12 @@ ], "scripts": { "allow-scripts": "yarn workspace root allow-scripts", - "build": "mm-snap build && yarn build:locale && yarn build-preinstalled-snap && yarn compress-preinstalled-snap", + "build": "mm-snap build && yarn build:locale && yarn build-preinstalled-snap", "build-preinstalled-snap": "node scripts/build-preinstalled-snap.js", "build:clean": "yarn clean && yarn build:locale && yarn build", "build:locale": "node ./scripts/populate-en-locale.js && prettier 'locales/**/*.json' -w", "build:locale:watch": "npx nodemon --watch packages/snap/messages.json --exec \"node ./scripts/populate-en-locale.js && prettier 'locales/**/*.json' -w\"", "clean": "rimraf dist", - "compress-preinstalled-snap": "gzip -9 -c dist/preinstalled-snap.json > dist/preinstalled-snap.json.gz", "lint": "yarn lint:eslint && yarn lint:misc && yarn lint:deps && yarn lint:types", "lint:deps": "depcheck", "lint:eslint": "eslint . --cache --ext js,jsx,ts,tsx", @@ -36,47 +35,25 @@ "test:integration": "./integration-test/run-integration.sh" }, "devDependencies": { - "@babel/preset-typescript": "^7.23.3", - "@metamask/auto-changelog": "^3.4.4", + "@jest/globals": "^29.7.0", "@metamask/bitcoindevkit": "^0.1.8", - "@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/key-tree": "^10.1.1", "@metamask/keyring-api": "^17.5.0", "@metamask/keyring-snap-sdk": "^3.2.0", "@metamask/slip44": "^4.1.0", "@metamask/snaps-cli": "^7.1.0", - "@metamask/snaps-jest": "^8.14.1", + "@metamask/snaps-jest": "^8.15.0", "@metamask/snaps-sdk": "^6.23.0", "@metamask/utils": "^11.4.0", - "@typescript-eslint/eslint-plugin": "^5.42.1", - "@typescript-eslint/parser": "^5.42.1", - "concurrently": "^9.1.0", - "dotenv": "^16.4.5", - "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", - "jest-mock-extended": "^3.0.7", + "concurrently": "^9.1.2", + "dotenv": "^16.5.0", + "jest": "^29.7.0", + "jest-mock-extended": "^4.0.0-beta1", "jest-transform-stub": "2.0.0", - "prettier": "^2.7.1", - "rimraf": "^3.0.2", "superstruct": "^2.0.2", - "ts-jest": "^29.1.0", - "typescript": "^4.7.4", + "ts-jest": "^29.3.3", "uuid": "^11.1.0" }, - "packageManager": "yarn@4.5.1", - "engines": { - "node": ">= 20" - }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" diff --git a/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js b/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js index fe3f216a..41f461a9 100644 --- a/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js +++ b/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js @@ -1,5 +1,5 @@ // @ts-check -/* eslint-disable */ + const { readFileSync, writeFileSync } = require('node:fs'); const { join } = require('node:path'); diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts index 1f7f0750..8cfca7e8 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.config.ts +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -1,8 +1,8 @@ import type { SnapConfig } from '@metamask/snaps-cli'; +import { config as dotenv } from 'dotenv'; import { resolve } from 'path'; -// eslint-disable-next-line @typescript-eslint/no-require-imports,@typescript-eslint/no-var-requires -require('dotenv').config(); +dotenv(); const config: SnapConfig = { input: resolve(__dirname, 'src/index.ts'), @@ -10,7 +10,6 @@ const config: SnapConfig = { port: 8080, }, environment: { - /* eslint-disable */ LOG_LEVEL: process.env.LOG_LEVEL, DEFAULT_ADDRESS_TYPE: process.env.DEFAULT_ADDRESS_TYPE, ESPLORA_BITCOIN: process.env.ESPLORA_BITCOIN, @@ -19,7 +18,6 @@ const config: SnapConfig = { ESPLORA_SIGNET: process.env.ESPLORA_SIGNET, ESPLORA_REGTEST: process.env.ESPLORA_REGTEST, PRICE_API_URL: process.env.PRICE_API_URL, - /* eslint-disable */ }, polyfills: true, experimental: { wasm: true }, diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index b2a93ad8..9eef8d36 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "jHCm1ZAHhjWCL2dQTMlAKff6YliM7wrdonDtPJLraKg=", + "shasum": "qyd/KD3jfAg7N4Z9V1Gw1KX6416MW6/S+pEaLgqNsKQ=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -97,6 +97,6 @@ ] } }, - "platformVersion": "6.23.0", + "platformVersion": "6.24.0", "manifestVersion": "0.1" } diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index 7157a1fd..d70863a9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -49,6 +49,7 @@ export type BitcoinAccount = { /** * Get an address at a given index. + * * @param index - derivation index. * @returns the address */ @@ -57,6 +58,7 @@ export type BitcoinAccount = { /** * Get the next unused address. This will reveal a new address if there is no unused address. * Note that the account needs to be persisted for this operation to be idempotent. + * * @returns the address */ nextUnusedAddress(): AddressInfo; @@ -64,18 +66,21 @@ export type BitcoinAccount = { /** * Reveal the next address. * Note that the account needs to be persisted for this operation to be idempotent. + * * @returns the address */ revealNextAddress(): AddressInfo; /** * Start a full scan. + * * @returns the full scan request */ startFullScan(): FullScanRequest; /** * Start a sync with revealed scripts. + * * @returns the sync request */ startSync(): SyncRequest; @@ -87,18 +92,21 @@ export type BitcoinAccount = { /** * Extract the change set if it exists. + * * @returns the change set */ takeStaged(): ChangeSet | undefined; /** * Returns a Transaction Builder. + * * @returns the TxBuilder */ buildTx(): TransactionBuilder; /** * Sign a PSBT with all the registered signers + * * @param psbt - The PSBT to be signed. * @returns the signed transaction */ @@ -106,6 +114,7 @@ export type BitcoinAccount = { /** * Get the list of UTXOs + * * @returns the list of UTXOs */ listUnspent(): LocalOutput[]; @@ -114,18 +123,21 @@ export type BitcoinAccount = { * List relevant and canonical transactions in the wallet. * A transaction is relevant when it spends from or spends to at least one tracked output. * A transaction is canonical when it is confirmed in the best chain, or does not conflict with any transaction confirmed in the best chain. + * * @returns the list of wallet transactions */ listTransactions(): WalletTx[]; /** * Get a single transaction from the wallet as a [`WalletTx`] (if the transaction exists). + * * @returns the wallet transaction */ getTransaction(txid: string): WalletTx | undefined; /** * Calculate the fee of a given transaction. Returns [`Amount::ZERO`] if `tx` is a coinbase transaction. + * * @param tx - The transaction. * @returns the fee amount. */ @@ -133,6 +145,7 @@ export type BitcoinAccount = { /** * Return whether or not a `script` is part of this wallet (either internal or external). + * * @param script - The Bitcoin script. * @returns the ownership state. */ @@ -143,6 +156,7 @@ export type BitcoinAccount = { * This method returns a tuple `(sent, received)`. Sent is the sum of the txin amounts * that spend from previous txouts tracked by this wallet. Received is the summation * of this tx's outputs that send to script pubkeys tracked by this wallet. + * * @param tx - The Bitcoin transaction. * @returns the sent and received amounts. */ @@ -151,6 +165,7 @@ export type BitcoinAccount = { /** * Apply relevant unconfirmed transactions to the wallet. * Transactions that are not relevant are filtered out. + * * @param tx - The Bitcoin transaction. * @param lastSeen - Timestamp of when the transaction was last seen in the mempool. */ @@ -163,6 +178,7 @@ export type BitcoinAccount = { export type BitcoinAccountRepository = { /** * Get an account by its id. + * * @param id - Account ID. * @returns the account or null if it does not exist */ @@ -170,6 +186,7 @@ export type BitcoinAccountRepository = { /** * Get an account by its id with signing capabilities + * * @param id - Account ID. * @returns the account or null if it does not exist */ @@ -177,12 +194,14 @@ export type BitcoinAccountRepository = { /** * Get all accounts. + * * @returns the list of accounts */ getAll(): Promise; /** * Get an account by its derivation path. + * * @param derivationPath - derivation path. * @returns the account or null if it does not exist */ @@ -190,6 +209,7 @@ export type BitcoinAccountRepository = { /** * Insert a new account. + * * @param derivationPath - derivation index. * @param network - network. * @param addressType - address type. @@ -203,6 +223,7 @@ export type BitcoinAccountRepository = { /** * Update an account. + * * @param account - Bitcoin account. * @param inscriptions - List of inscriptions. */ @@ -210,6 +231,7 @@ export type BitcoinAccountRepository = { /** * Delete an account. + * * @param id - Account ID. * @returns true if the account has been deleted. */ @@ -217,6 +239,7 @@ export type BitcoinAccountRepository = { /** * Get the list of frozen UTXO outpoints of an account. + * * @param id - Account ID. * @returns the frozen UTXO outpoints. */ diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts b/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts index 538027fe..0eed0515 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts @@ -18,6 +18,7 @@ export type BlockchainClient = { /** * Perform a full scan operation on the account. * Note that this operation modifies the account in place. + * * @param account - the account to full scan. */ fullScan(account: BitcoinAccount): Promise; @@ -25,12 +26,14 @@ export type BlockchainClient = { /** * Perform a sync operation on the account. * Note that this operation modifies the account in place. + * * @param account - the account to sync. */ sync(account: BitcoinAccount): Promise; /** * Broadcast the signed transaction to the network. + * * @param network - Network where the signed transaction will be broadcasted. * @param transaction - Transaction to broadcast. */ @@ -38,6 +41,7 @@ export type BlockchainClient = { /** * Fetch fee estimates from the chain indexer. + * * @param network - Network to fetch the fees from. * @returns the map of fee estimates */ diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts index e386c6a2..565551bc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts @@ -1,11 +1,11 @@ export * from './account'; -export * from './config'; +export type * from './config'; export * from './chain'; export * from './currency'; export * from './send-flow'; -export * from './transaction'; -export * from './snap'; -export * from './meta-protocols'; -export * from './translator'; -export * from './rates'; +export type * from './transaction'; +export type * from './snap'; +export type * from './meta-protocols'; +export type * from './translator'; +export type * from './rates'; export * from './logger'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/logger.ts b/merged-packages/bitcoin-wallet-snap/src/entities/logger.ts index de13d92d..ae68dad7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/logger.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/logger.ts @@ -13,30 +13,35 @@ export enum LogLevel { export type Logger = { /** * Logs at the `ERROR` level. + * * @param data - The data to log. */ error(...data: any[]): void; /** * Logs at the `WARN` level. + * * @param data - The data to log. */ warn(...data: any[]): void; /** * Logs at the `INFO` level. + * * @param data - The data to log. */ info(...data: any[]): void; /** * Logs at the `DEBUG` level. + * * @param data - The data to log. */ debug(...data: any[]): void; /** * Logs at the `TRACE` level. + * * @param data - The data to log. */ trace(...data: any[]): void; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/meta-protocols.ts b/merged-packages/bitcoin-wallet-snap/src/entities/meta-protocols.ts index d42d2000..bfdfad7b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/meta-protocols.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/meta-protocols.ts @@ -14,6 +14,7 @@ export type Inscription = { export type MetaProtocolsClient = { /** * Fetch the inscriptions of an account. + * * @param account - the account to fetch assets from. * @returns the list of inscriptions */ diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts b/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts index 99175cdc..c2c34f59 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts @@ -13,12 +13,14 @@ export type ExchangeRates = { export type AssetRatesClient = { /** * Returns a list of exchange rates for all supported currencies. + * * @param baseCurrency - the currency to convert prices to. Defaults to 'btc'. */ exchangeRates(baseCurrency?: string): Promise; /** * Returns a list of historical prices for a token against another. + * * @param timePeriod - the time period to fetch prices for. * @param vsCurrency - the currency to convert prices to. Defaults to 'usd'. * @param baseCurrency - the currency to get prices for. Defaults to 'bitcoin'. diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts index a0e9d45b..5e837b16 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts @@ -35,7 +35,6 @@ export enum SendFormEvent { Confirm = 'confirm', Cancel = 'cancel', SetMax = 'max', - RefreshRates = 'refreshRates', } export type SendFormState = { @@ -72,6 +71,7 @@ export enum ReviewTransactionEvent { export type SendFlowRepository = { /** * Get the form state. + * * @param id - the interface ID * @returns the form state or null */ @@ -79,6 +79,7 @@ export type SendFlowRepository = { /** * Get the form context. + * * @param id - the interface ID * @returns the form context or null */ @@ -86,6 +87,7 @@ export type SendFlowRepository = { /** * Insert a new send form interface. + * * @param context - the form context * @returns the interface ID */ @@ -93,6 +95,7 @@ export type SendFlowRepository = { /** * Update an interface to the send form view. + * * @param id - the interface ID * @param context - the form context */ @@ -100,6 +103,7 @@ export type SendFlowRepository = { /** * Update an interface to the review transaction view. + * * @param id - the interface ID * @param context - the review transaction context */ diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index 98a58cb8..8396c2b4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -23,18 +23,21 @@ export type SnapState = { export type SnapClient = { /** * Get the Snap state. + * * @returns The Snap state. */ get(): Promise; /** * Set the Snap state. + * * @param newState - The new state. */ set(newState: SnapState): Promise; /** * Get the private SLIP10 for a given derivation path from the Snap SRP. + * * @param derivationPath - The derivation path. * @returns The private SLIP10 node. */ @@ -42,6 +45,7 @@ export type SnapClient = { /** * Get the public SLIP10 for a given derivation path from the Snap SRP. + * * @param derivationPath - The derivation path. * @returns The public SLIP10 node. */ @@ -49,6 +53,7 @@ export type SnapClient = { /** * Emit an event notifying the extension of a newly created Bitcoin account + * * @param account - The Bitcoin account. * @param correlationId - The correlation ID to be used for the event. */ @@ -59,18 +64,21 @@ export type SnapClient = { /** * Emit an event notifying the extension of a deleted Bitcoin account + * * @param id - The Bitcoin account id. */ emitAccountDeletedEvent(id: string): Promise; /** * Emit an event notifying the extension of updated balances + * * @param account - The Bitcoin account. */ emitAccountBalancesUpdatedEvent(account: BitcoinAccount): Promise; /** * Emit an event notifying the extension of updated transactions + * * @param account - The Bitcoin account. * @param txs - The transactions included in the event. */ @@ -81,6 +89,7 @@ export type SnapClient = { /** * Create a User Interface. + * * @param ui - The UI Component. * @param context - The Interface context. * @returns the interface ID @@ -92,6 +101,7 @@ export type SnapClient = { /** * Update a User Interface. + * * @param id - The interface id. * @param ui - The user interface. * @param context - The Interface context. @@ -104,6 +114,7 @@ export type SnapClient = { /** * Display a User Interface. + * * @param id - The interface id. * @returns the resolved value or null. */ @@ -111,6 +122,7 @@ export type SnapClient = { /** * Resolve a User Interface. + * * @param id - The interface id. * @param value - The resolved value. */ @@ -118,6 +130,7 @@ export type SnapClient = { /** * Get the state of an interface. + * * @param id - The interface id. * @returns the interface state. */ @@ -125,6 +138,7 @@ export type SnapClient = { /** * Get the context of an interface. + * * @param id - The interface id. * @returns the interface context. */ @@ -132,6 +146,7 @@ export type SnapClient = { /** * Schedule a one-off callback. + * * @param interval - The interval in seconds before the event is executed. * @param method - The method to call on reception of the event being triggered. * @param interfaceId - The interface id. @@ -145,12 +160,14 @@ export type SnapClient = { /** * Cancel an already scheduled background event. + * * @param id - The background event id. */ cancelBackgroundEvent(id: string): Promise; /** * Get user preferences. + * * @returns the user's preferences. */ getPreferences(): Promise; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts b/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts index 4564373f..49104ff3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts @@ -6,6 +6,7 @@ import type { Psbt } from '@metamask/bitcoindevkit'; export type TransactionBuilder = { /** * Add a new recipient the PSBT. + * * @param amount - The amount in satoshis * @param recipientAddress - The recipient address */ @@ -13,6 +14,7 @@ export type TransactionBuilder = { /** * Set the PSBT fee rate. + * * @param feeRate - The fee rate in sat/vB */ feeRate(feeRate: number): TransactionBuilder; @@ -28,18 +30,21 @@ export type TransactionBuilder = { * Usually, when there are excess coins they are sent to a change address generated by the * wallet. This option replaces the usual change address with an arbitrary `script_pubkey` of * your choosing. + * * @param address - The recipient address */ drainTo(address: string): TransactionBuilder; /** * Set the list of unspendable UTXOs. These outpoints won't be selected by the coin selection algorithm. + * * @param unspendable - The list of unspendable UTXO outpoints. */ unspendable(unspendable: string[]): TransactionBuilder; /** * Creates the PSBT. + * * @returns the PSBT */ finish(): Psbt; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/translator.ts b/merged-packages/bitcoin-wallet-snap/src/entities/translator.ts index ad3a1e62..ee1010e9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/translator.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/translator.ts @@ -6,6 +6,7 @@ export type Messages = Record; export type Translator = { /** * Load messages given a locale. Defaults to english if the locale is not found. + * * @param locale - the locale. */ load(locale: string): Promise; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts index 28686ec3..3fbf6530 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts @@ -93,9 +93,7 @@ export class AssetsHandler { // Group conversions by "from" const assetMap: Record = {}; for (const { from, to } of conversions) { - if (!assetMap[from]) { - assetMap[from] = []; - } + assetMap[from] ??= []; assetMap[from].push(to); } @@ -103,14 +101,15 @@ export class AssetsHandler { return handle(async () => { for (const [fromAsset, toAssets] of Object.entries(assetMap)) { - conversionRates[fromAsset] = {}; + const fromKey = fromAsset as keyof typeof conversionRates; + conversionRates[fromKey] = {}; - if (fromAsset === Caip19Asset.Bitcoin) { + if (fromKey === (Caip19Asset.Bitcoin as CaipAssetType)) { // For Bitcoin, fetch rates. for (const [toAsset, rate] of await this.#assetsUseCases.getRates( toAssets, )) { - conversionRates[fromAsset][toAsset] = rate + conversionRates[fromKey][toAsset] = rate ? { rate: rate.toString(), conversionTime, @@ -121,7 +120,7 @@ export class AssetsHandler { } else { // For every other conversions, we just use a rate of 0. for (const toAsset of toAssets) { - conversionRates[fromAsset][toAsset] = { + conversionRates[fromKey][toAsset] = { rate: '0', conversionTime, expirationTime: conversionTime + 60 * 60 * 24, // Long expiration time (1 day) to avoid unnecessary requests @@ -141,7 +140,7 @@ export class AssetsHandler { assert(from, CaipAssetTypeStruct); assert(to, CaipAssetTypeStruct); - if (from !== Caip19Asset.Bitcoin) { + if (from !== (Caip19Asset.Bitcoin as CaipAssetType)) { return null; } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts index 63f73c41..64d08c80 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -2,10 +2,9 @@ import { SnapError } from '@metamask/snaps-sdk'; import type { JsonRpcRequest } from '@metamask/utils'; import { mock } from 'jest-mock-extended'; -import type { Logger } from '../entities'; -import { SendFormEvent, type BitcoinAccount } from '../entities'; +import type { Logger, BitcoinAccount } from '../entities'; import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; -import { CronHandler } from './CronHandler'; +import { CronHandler, CronMethod } from './CronHandler'; describe('CronHandler', () => { const mockLogger = mock(); @@ -57,7 +56,7 @@ describe('CronHandler', () => { describe('refreshRates', () => { const request = { - method: SendFormEvent.RefreshRates, + method: CronMethod.RefreshRates, params: { interfaceId: 'id' }, } as unknown as JsonRpcRequest; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index c553795f..a82a1c38 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -2,12 +2,12 @@ import type { JsonRpcRequest } from '@metamask/utils'; import { assert, object, string } from 'superstruct'; import type { Logger } from '../entities'; -import { SendFormEvent } from '../entities'; import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; import { handle } from './errors'; export enum CronMethod { SynchronizeAccounts = 'synchronizeAccounts', + RefreshRates = 'refreshRates', } export const SendFormRefreshRatesRequest = object({ @@ -31,15 +31,15 @@ export class CronHandler { this.#sendFlowUseCases = sendFlow; } - async route(request: JsonRpcRequest) { + async route(request: JsonRpcRequest): Promise { const { method, params } = request; return handle(async () => { - switch (method) { + switch (method as CronMethod) { case CronMethod.SynchronizeAccounts: { return this.synchronizeAccounts(); } - case SendFormEvent.RefreshRates: { + case CronMethod.RefreshRates: { assert(params, SendFormRefreshRatesRequest); return this.#sendFlowUseCases.refresh(params.interfaceId); } @@ -61,7 +61,7 @@ export class CronHandler { if (result.status === 'rejected') { this.#logger.error( `Account failed to sync. ID: %s. Error: %s`, - accounts[index].id, + accounts[index]?.id, result.reason, ); } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index d515fea7..582c4582 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -4,18 +4,16 @@ import type { Transaction, Txid, TxOut, + Network, + WalletTx, } from '@metamask/bitcoindevkit'; -import { Address, type Network, type WalletTx } from '@metamask/bitcoindevkit'; +import { Address } from '@metamask/bitcoindevkit'; import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; import { BtcMethod, BtcScope } from '@metamask/keyring-api'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; import { CurrencyUnit, type BitcoinAccount } from '../entities'; -import type { - AccountUseCases, - CreateAccountParams, -} from '../use-cases/AccountUseCases'; import { caip2ToNetwork, caip2ToAddressType, @@ -23,6 +21,10 @@ import { Caip19Asset, } from './caip'; import { KeyringHandler, CreateAccountRequest } from './KeyringHandler'; +import type { + AccountUseCases, + CreateAccountParams, +} from '../use-cases/AccountUseCases'; jest.mock('superstruct', () => ({ ...jest.requireActual('superstruct'), diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index ca563d5d..5c5a06fe 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -24,7 +24,6 @@ import { } from 'superstruct'; import { networkToCurrencyUnit } from '../entities'; -import type { AccountUseCases } from '../use-cases/AccountUseCases'; import { networkToCaip19, Caip2AddressType, @@ -35,6 +34,7 @@ import { import { handle } from './errors'; import { mapToKeyringAccount, mapToTransaction } from './mappings'; import { validateOrigin } from './permissions'; +import type { AccountUseCases } from '../use-cases/AccountUseCases'; export const CreateAccountRequest = object({ scope: enums(Object.values(BtcScope)), @@ -153,9 +153,11 @@ export class KeyringHandler implements Keyring { } const paginatedTxs = transactions.slice(startIndex, startIndex + limit); + const hasMore = startIndex + limit < transactions.length; const nextCursor = - startIndex + limit < transactions.length - ? paginatedTxs[paginatedTxs.length - 1].txid.toString() + hasMore && paginatedTxs.length > 0 + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + paginatedTxs[paginatedTxs.length - 1]!.txid.toString() : null; return { @@ -174,12 +176,14 @@ export class KeyringHandler implements Keyring { throw new Error(`Invalid derivation path: ${path}`); } - const accountPart = segments[3]; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const accountPart = segments[3]!; const match = accountPart.match(/^(\d+)/u); if (!match) { throw new Error(`Invalid account index: ${accountPart}`); } - return parseInt(match[1], 10); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return parseInt(match[1]!, 10); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index c79ecfbe..b0fff1f7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -5,8 +5,8 @@ import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; import type { SendFlowUseCases } from '../use-cases'; -import type { AccountUseCases } from '../use-cases/AccountUseCases'; import { CreateSendFormRequest, RpcHandler, RpcMethod } from './RpcHandler'; +import type { AccountUseCases } from '../use-cases/AccountUseCases'; jest.mock('superstruct', () => ({ ...jest.requireActual('superstruct'), diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index f1f83ce2..8d5fa4b2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -40,7 +40,7 @@ export class RpcHandler { throw new Error('Missing params'); } - switch (method) { + switch (method as RpcMethod) { case RpcMethod.StartSendTransactionFlow: { assert(params, CreateSendFormRequest); return this.#executeSendFlow(params.account); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts index 61d84715..225ee4ab 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts @@ -7,11 +7,8 @@ const reverseMapping = < To extends string | number | symbol, >( map: Record, -) => { - return Object.fromEntries( - Object.entries(map).map(([from, to]) => [to, from]), - ) as Record; -}; +): Record => + Object.fromEntries(Object.entries(map).map(([from, to]) => [to, from])); export enum Caip2AddressType { P2pkh = 'bip122:p2pkh', diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts index 0bb0a6b3..24630377 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts @@ -14,6 +14,6 @@ export const handle = async ( // 5. Default to InternalError('an internal error occurred') if no custom error is thrown. // console.error('Error occurred:', error); - throw new SnapError(error); + throw new SnapError(error as Error); } }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index 54159505..306f51c5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -52,6 +52,7 @@ export const networkToName: Record = { /** * Maps a Bitcoin Account to a Keyring Account. + * * @param account - The Bitcoin account. * @returns The Keyring account. */ @@ -111,6 +112,7 @@ const mapToEvents = ( /** * Maps a Bitcoin Transaction to a Keyring Transaction. + * * @param account - The account account. * @param walletTx - The Bitcoin transaction managed by this account. * @returns The Keyring transaction. diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts index 1b50e129..ee2314f5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts @@ -1,8 +1,5 @@ -import { UnauthorizedError } from '@metamask/snaps-sdk'; - -export const validateOrigin = (origin: string) => { +export const validateOrigin = (origin: string): void => { if (origin !== 'metamask') { - // eslint-disable-next-line @typescript-eslint/no-throw-literal - throw new UnauthorizedError('Permission denied'); + throw new Error('Permission denied'); } }; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index f7b4a680..e22cedda 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -109,7 +109,7 @@ export class BdkAccountAdapter implements BitcoinAccount { return this.#wallet.start_sync_with_revealed_spks(); } - applyUpdate(update: Update) { + applyUpdate(update: Update): void { return this.#wallet.apply_update(update); } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts index 4219fe93..fe2522a0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts @@ -29,7 +29,7 @@ export class EsploraClientAdapter implements BlockchainClient { this.#config = config; } - async fullScan(account: BitcoinAccount) { + async fullScan(account: BitcoinAccount): Promise { const request = account.startFullScan(); const update = await this.#clients[account.network].full_scan( request, @@ -39,7 +39,7 @@ export class EsploraClientAdapter implements BlockchainClient { account.applyUpdate(update); } - async sync(account: BitcoinAccount) { + async sync(account: BitcoinAccount): Promise { const request = account.startSync(); const update = await this.#clients[account.network].sync( request, diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 5a1f4338..9b21e6d8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -6,11 +6,9 @@ import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import type { GetInterfaceContextResult, GetInterfaceStateResult, -} from '@metamask/snaps-sdk'; -import { - type ComponentOrElement, - type GetPreferencesResult, - type Json, + Json, + ComponentOrElement, + GetPreferencesResult, } from '@metamask/snaps-sdk'; import type { BitcoinAccount, SnapClient, SnapState } from '../entities'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx index 5c7ae1eb..7fdb3993 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx @@ -8,18 +8,18 @@ import { Heading, Row, Section, - Text, + Text as SnapText, Value, Address, } from '@metamask/snaps-sdk/jsx'; import type { CaipAccountId } from '@metamask/utils'; +import { AssetIcon, HeadingWithReturn } from './components'; +import { displayAmount, displayExchangeAmount, translate } from './format'; import { Config } from '../../config'; import type { Messages, ReviewTransactionContext } from '../../entities'; import { BlockTime, ReviewTransactionEvent } from '../../entities'; import { networkToCaip2 } from '../../handlers'; -import { AssetIcon, HeadingWithReturn } from './components'; -import { displayAmount, displayExchangeAmount, translate } from './format'; type ReviewTransactionViewProps = { context: ReviewTransactionContext; @@ -51,7 +51,7 @@ export const ReviewTransactionView: SnapComponent< BigInt(amount), currency, )}`} - {t('reviewTransactionWarning')} + {t('reviewTransactionWarning')}
@@ -82,11 +82,11 @@ export const ReviewTransactionView: SnapComponent< label={t('transactionSpeed')} tooltip={t('transactionSpeedTooltip')} > - + {`${Config.targetBlocksConfirmation * BlockTime[network]} ${t( 'minutes', )}`} - + - {`${feeRate ?? 'unknown'} sat/vB`} + {`${feeRate ?? 'unknown'} sat/vB`} = ({ {context.errors.tx !== undefined && ( - {context.errors.tx} + {context.errors.tx} )} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx index 76d90b69..c275a389 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx @@ -1,3 +1,4 @@ +import type { JSXElement } from '@metamask/snaps-sdk/jsx'; import { Box, Button, @@ -5,7 +6,7 @@ import { Form, Icon, Input, - Text, + Text as SnapText, } from '@metamask/snaps-sdk/jsx'; import type { Messages, SendFormContext } from '../../../entities'; @@ -17,7 +18,7 @@ type SendFormProps = SendFormContext & { messages: Messages; }; -export const SendForm = (props: SendFormProps) => { +export const SendForm = (props: SendFormProps): JSXElement => { const { currency, balance, amount, recipient, errors, network, messages } = props; const t = translate(messages); @@ -39,9 +40,9 @@ export const SendForm = (props: SendFormProps) => { - + {`${t('balance')}: ${displayAmount(BigInt(balance), currency)}`} - + diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx index d90e136f..68884e4f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx @@ -3,14 +3,14 @@ import type { CurrencyRate } from '@metamask/snaps-sdk'; import { Row, Section, - Text, + Text as SnapText, Value, type SnapComponent, } from '@metamask/snaps-sdk/jsx'; import { Config } from '../../../config'; -import type { Messages } from '../../../entities'; -import { BlockTime, type CurrencyUnit } from '../../../entities'; +import type { Messages, CurrencyUnit } from '../../../entities'; +import { BlockTime } from '../../../entities'; import { displayAmount, displayExchangeAmount, translate } from '../format'; type TransactionSummaryProps = { @@ -37,9 +37,9 @@ export const TransactionSummary: SnapComponent = ({ return (
- {`${Config.targetBlocksConfirmation * BlockTime[network]} ${t( + {`${Config.targetBlocksConfirmation * BlockTime[network]} ${t( 'minutes', - )}`} + )}`} ({ SendFormView: jest.fn(), @@ -32,7 +32,7 @@ describe('JSXSendFlowRepository', () => { const result = await repo.getState(id); expect(mockSnapClient.getInterfaceState).toHaveBeenCalledWith(id); - expect(result).toEqual(state[SENDFORM_NAME]); + expect(result).toStrictEqual(state[SENDFORM_NAME]); }); it('returns null if state is null', async () => { @@ -62,7 +62,7 @@ describe('JSXSendFlowRepository', () => { const result = await repo.getContext(id); expect(mockSnapClient.getInterfaceContext).toHaveBeenCalledWith(id); - expect(result).toEqual(context); + expect(result).toStrictEqual(context); }); it('returns null if context is null', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts index b1371483..ae58fecb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts @@ -2,8 +2,8 @@ import type { HistoricalPriceValue } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; import type { AssetRatesClient, ExchangeRates, Logger } from '../entities'; -import { Caip19Asset } from '../handlers/caip'; import { AssetsUseCases } from './AssetsUseCases'; +import { Caip19Asset } from '../handlers/caip'; describe('AssetsUseCases', () => { const mockLogger = mock(); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts index bc275350..d6276ba4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts @@ -80,7 +80,9 @@ export class AssetsUseCases { } if (assetNamespace === 'slip44') { - return slip44[assetReference]?.symbol.toLowerCase(); + return slip44[ + assetReference as keyof typeof slip44 + ]?.symbol.toLowerCase(); } return undefined; diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 6373233c..657e6d4c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -25,6 +25,7 @@ import { SendFormEvent, } from '../entities'; import { SendFlowUseCases } from './SendFlowUseCases'; +import { CronMethod } from '../handlers'; // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 /* eslint-disable @typescript-eslint/naming-convention */ @@ -624,7 +625,7 @@ describe('SendFlowUseCases', () => { expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith( ratesRefreshInterval, - SendFormEvent.RefreshRates, + CronMethod.RefreshRates, 'interface-id', ); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index ad4621cd..b481d1ba 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -17,6 +17,7 @@ import { ReviewTransactionEvent, networkToCurrencyUnit, } from '../entities'; +import { CronMethod } from '../handlers'; export class SendFlowUseCases { readonly #logger: Logger; @@ -331,7 +332,7 @@ export class SendFlowUseCases { updatedContext.backgroundEventId = await this.#snapClient.scheduleBackgroundEvent( this.#ratesRefreshInterval, - SendFormEvent.RefreshRates, + CronMethod.RefreshRates, id, ); updatedContext.locale = locale; // Take advantage of the loop to update the locale as well diff --git a/merged-packages/bitcoin-wallet-snap/tsconfig.json b/merged-packages/bitcoin-wallet-snap/tsconfig.json index 0f1ac346..99b45e34 100644 --- a/merged-packages/bitcoin-wallet-snap/tsconfig.json +++ b/merged-packages/bitcoin-wallet-snap/tsconfig.json @@ -1,20 +1,11 @@ { + "extends": "../../tsconfig.packages.json", "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. */, "jsx": "react-jsx", - "jsxImportSource": "@metamask/snaps-sdk" + "jsxImportSource": "@metamask/snaps-sdk", + "exactOptionalPropertyTypes": false, + "types": ["jest"] }, - "include": [ - "**/*.ts", - "**/*.tsx", - "src/**/*.tsx", - "src/index.ts", - "locales/*.json" - ] + "include": ["**/*.ts", "**/*.tsx", "locales/*.json"] } From aca728da60d4cbdc050b1ec098af3b6075480d6d Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 22 May 2025 14:00:25 +0200 Subject: [PATCH 231/362] feat: discover accounts (#460) --- .../integration-test/keyring.test.ts | 21 +++ .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/entities/account.ts | 9 + .../src/handlers/KeyringHandler.test.ts | 164 +++++++++++++++++- .../src/handlers/KeyringHandler.ts | 119 +++++++++++-- .../bitcoin-wallet-snap/src/handlers/caip.ts | 2 - .../src/handlers/mappings.ts | 32 +++- .../bitcoin-wallet-snap/src/index.ts | 2 +- .../src/infra/ConsoleLoggerAdapter.ts | 12 +- .../src/infra/EsploraClientAdapter.ts | 1 + .../src/use-cases/AccountUseCases.test.ts | 27 --- .../src/use-cases/AccountUseCases.ts | 5 - 13 files changed, 332 insertions(+), 66 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index f915053d..0dd9cc46 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -21,6 +21,27 @@ describe('Keyring', () => { }); }); + it('discover accounts successfully', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_discoverAccounts', + params: { + scopes: [BtcScope.Regtest], // avoid using other networks than Regtest as real external calls will be performed + entropySource: 'm', // we don't know the real entropy source so "m" acts as the default + groupIndex: 0, + }, + }); + + // We should get 1 account, the p2wpkh one of Regtest + expect(response).toRespondWith([ + { + type: 'bip44', + scopes: [BtcScope.Regtest], + derivationPath: "m/84'/0'/0'", + }, + ]); + }); + it.each([ { // Main account used in the tests, only one to synchronize diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 03e8829d..50270cf1 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -36,7 +36,7 @@ }, "devDependencies": { "@jest/globals": "^29.7.0", - "@metamask/bitcoindevkit": "^0.1.8", + "@metamask/bitcoindevkit": "^0.1.9", "@metamask/key-tree": "^10.1.1", "@metamask/keyring-api": "^17.5.0", "@metamask/keyring-snap-sdk": "^3.2.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 9eef8d36..fd5a22fb 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "qyd/KD3jfAg7N4Z9V1Gw1KX6416MW6/S+pEaLgqNsKQ=", + "shasum": "vTG5BqOvxrlBoYADKe6Zoo4aKIPbJDZW9mw/XpQePMQ=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index d70863a9..13837b4f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -253,6 +253,7 @@ export enum Purpose { Taproot = 86, Multisig = 45, } + export enum Slip44 { Bitcoin = 0, Testnet = 1, @@ -266,6 +267,14 @@ export const addressTypeToPurpose: Record = { p2tr: Purpose.Taproot, }; +export const purposeToAddressType: Record = { + [Purpose.Legacy]: 'p2pkh', + [Purpose.Segwit]: 'p2sh', + [Purpose.Multisig]: 'p2wsh', + [Purpose.NativeSegwit]: 'p2wpkh', + [Purpose.Taproot]: 'p2tr', +}; + export const networkToCoinType: Record = { bitcoin: Slip44.Bitcoin, testnet: Slip44.Testnet, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 582c4582..4e50a190 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -6,14 +6,19 @@ import type { TxOut, Network, WalletTx, + AddressType, } from '@metamask/bitcoindevkit'; import { Address } from '@metamask/bitcoindevkit'; -import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; +import type { + DiscoveredAccount, + Transaction as KeyringTransaction, +} from '@metamask/keyring-api'; import { BtcMethod, BtcScope } from '@metamask/keyring-api'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; -import { CurrencyUnit, type BitcoinAccount } from '../entities'; +import type { SnapClient, BitcoinAccount } from '../entities'; +import { CurrencyUnit, Purpose } from '../entities'; import { caip2ToNetwork, caip2ToAddressType, @@ -21,6 +26,7 @@ import { Caip19Asset, } from './caip'; import { KeyringHandler, CreateAccountRequest } from './KeyringHandler'; +import { mapToDiscoveredAccount } from './mappings'; import type { AccountUseCases, CreateAccountParams, @@ -43,6 +49,7 @@ jest.mock('@metamask/bitcoindevkit', () => { describe('KeyringHandler', () => { const mockAccounts = mock(); + const mockSnapClient = mock(); const mockAddress = mock
({ toString: () => 'bc1qaddress...', }); @@ -55,7 +62,7 @@ describe('KeyringHandler', () => { network: 'bitcoin', }); - const handler = new KeyringHandler(mockAccounts); + const handler = new KeyringHandler(mockAccounts, mockSnapClient); beforeEach(() => { mockAccounts.create.mockResolvedValue(mockAccount); @@ -86,13 +93,16 @@ describe('KeyringHandler', () => { entropySource, index, addressType: caip2ToAddressType[Caip2AddressType.P2pkh], - correlationId, }; await handler.createAccount(options); expect(assert).toHaveBeenCalledWith(options, CreateAccountRequest); expect(mockAccounts.create).toHaveBeenCalledWith(expectedCreateParams); + expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalledWith( + mockAccount, + correlationId, + ); expect(mockAccounts.fullScan).not.toHaveBeenCalled(); }); @@ -104,6 +114,7 @@ describe('KeyringHandler', () => { const expectedCreateParams: CreateAccountParams = { network: 'signet', index: 5, + addressType: 'p2pkh', }; await handler.createAccount(options); @@ -120,6 +131,30 @@ describe('KeyringHandler', () => { }); }); + it.each([ + { purpose: Purpose.Legacy, addressType: 'p2pkh' }, + { purpose: Purpose.Segwit, addressType: 'p2sh' }, + { purpose: Purpose.NativeSegwit, addressType: 'p2wpkh' }, + { purpose: Purpose.Taproot, addressType: 'p2tr' }, + { purpose: Purpose.Multisig, addressType: 'p2wsh' }, + ] as { purpose: Purpose; addressType: AddressType }[])( + 'extracts address type from derivationPath: %s', + async ({ purpose, addressType }) => { + const options = { + scope: BtcScope.Signet, + derivationPath: `m/${purpose}'/0'/0'`, + }; + const expectedCreateParams: CreateAccountParams = { + network: 'signet', + index: 0, + addressType, + }; + + await handler.createAccount(options); + expect(mockAccounts.create).toHaveBeenCalledWith(expectedCreateParams); + }, + ); + it('fails if derivationPath is invalid', async () => { const options = { scope: BtcScope.Signet, @@ -130,6 +165,14 @@ describe('KeyringHandler', () => { 'Invalid account index: NaN', ); + await expect( + handler.createAccount({ ...options, derivationPath: "m/60'/0'/0'" }), // unknown purpose + ).rejects.toThrow('Invalid BIP-purpose: 60'); + + await expect( + handler.createAccount({ ...options, derivationPath: "m/44'/0'/-1'" }), // negative index + ).rejects.toThrow("Invalid account index: -1'"); + await expect( handler.createAccount({ ...options, derivationPath: "m/44'" }), // missing segments ).rejects.toThrow("Invalid derivation path: m/44'"); @@ -147,26 +190,129 @@ describe('KeyringHandler', () => { }); it('propagates errors from createAccount', async () => { - const error = new Error(); + const error = new Error('createAccount error'); mockAccounts.create.mockRejectedValue(error); await expect( - handler.createAccount({ options: { scopes: [BtcScope.Mainnet] } }), + handler.createAccount({ scopes: [BtcScope.Mainnet] }), ).rejects.toThrow(error); expect(mockAccounts.create).toHaveBeenCalled(); }); - it('propagates errors from full scan', async () => { - const error = new Error(); + it('propagates errors from fullScan', async () => { + const error = new Error('fullScan error'); mockAccounts.fullScan.mockRejectedValue(error); await expect( handler.createAccount({ - options: { scopes: [BtcScope.Mainnet] }, + scopes: [BtcScope.Mainnet], synchronize: true, }), ).rejects.toThrow(error); + expect(mockAccounts.fullScan).toHaveBeenCalled(); + }); + + it('propagates errors from emitAccountCreatedEvent', async () => { + const error = new Error('emitAccountCreatedEvent error'); + mockSnapClient.emitAccountCreatedEvent.mockRejectedValue(error); + + await expect( + handler.createAccount({ scopes: [BtcScope.Mainnet] }), + ).rejects.toThrow(error); + expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalled(); + }); + }); + + describe('discoverAccounts', () => { + const entropySource = 'some-source'; + const groupIndex = 0; + const scopes = Object.values(BtcScope); + + it('creates, scans and returns accounts for every scope/addressType combination', async () => { + const addressTypes = Object.values(Caip2AddressType); + const totalCombinations = scopes.length * addressTypes.length; + + const expected: DiscoveredAccount[] = []; + scopes.forEach((scope) => { + addressTypes.forEach((addrType) => { + const acc = mock({ + addressType: caip2ToAddressType[addrType], + network: caip2ToNetwork[scope], + listTransactions: jest.fn().mockReturnValue([{}]), // has history + }); + + expected.push(mapToDiscoveredAccount(acc, groupIndex)); + mockAccounts.create.mockResolvedValueOnce(acc); + }); + }); + + const discovered = await handler.discoverAccounts( + scopes, + entropySource, + groupIndex, + ); + + expect(mockAccounts.create).toHaveBeenCalledTimes(totalCombinations); + expect(mockAccounts.fullScan).toHaveBeenCalledTimes(totalCombinations); + + // validate each individual create() call arguments + scopes.forEach((scope, sIdx) => { + addressTypes.forEach((addrType, aIdx) => { + const callIdx = sIdx * addressTypes.length + aIdx; + expect(mockAccounts.create).toHaveBeenNthCalledWith(callIdx + 1, { + network: caip2ToNetwork[scope], + entropySource, + index: groupIndex, + addressType: caip2ToAddressType[addrType], + synchronize: true, + }); + }); + }); + + // Order is not guaranteed, so compare as sets + expect(discovered).toHaveLength(expected.length); + expect(discovered).toStrictEqual(expect.arrayContaining(expected)); + }); + + it('filters out accounts that have no transaction history', async () => { + const addressTypes = Object.values(Caip2AddressType); + const totalCombinations = scopes.length * addressTypes.length; + + for (let i = 0; i < totalCombinations; i += 1) { + const acc = mock({ + listTransactions: jest.fn().mockReturnValue([]), // no history + }); + + mockAccounts.create.mockResolvedValueOnce(acc); + } + + const discovered = await handler.discoverAccounts( + scopes, + entropySource, + groupIndex, + ); + + expect(discovered).toHaveLength(0); + }); + + it('propagates errors from create', async () => { + const error = new Error('create error'); + mockAccounts.create.mockRejectedValue(error); + + await expect( + handler.discoverAccounts(scopes, entropySource, groupIndex), + ).rejects.toThrow(error); expect(mockAccounts.create).toHaveBeenCalled(); + }); + + it('propagates errors from fullScan()', async () => { + const error = new Error('fullScan error'); + mockAccounts.create.mockResolvedValue(mock()); + mockAccounts.fullScan.mockRejectedValue(error); + + await expect( + handler.discoverAccounts(scopes, entropySource, groupIndex), + ).rejects.toThrow(error); expect(mockAccounts.fullScan).toHaveBeenCalled(); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 5c5a06fe..2dbf9612 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -1,3 +1,4 @@ +import type { AddressType } from '@metamask/bitcoindevkit'; import { BtcScope, MetaMaskOptionsStruct } from '@metamask/keyring-api'; import type { Keyring, @@ -10,6 +11,7 @@ import type { Transaction, Pagination, MetaMaskOptions, + DiscoveredAccount, } from '@metamask/keyring-api'; import { handleKeyringRequest } from '@metamask/keyring-snap-sdk'; import type { Json, JsonRpcRequest } from '@metamask/utils'; @@ -23,7 +25,12 @@ import { string, } from 'superstruct'; -import { networkToCurrencyUnit } from '../entities'; +import type { SnapClient } from '../entities'; +import { + networkToCurrencyUnit, + Purpose, + purposeToAddressType, +} from '../entities'; import { networkToCaip19, Caip2AddressType, @@ -32,7 +39,11 @@ import { networkToCaip2, } from './caip'; import { handle } from './errors'; -import { mapToKeyringAccount, mapToTransaction } from './mappings'; +import { + mapToDiscoveredAccount, + mapToKeyringAccount, + mapToTransaction, +} from './mappings'; import { validateOrigin } from './permissions'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; @@ -50,8 +61,11 @@ export const CreateAccountRequest = object({ export class KeyringHandler implements Keyring { readonly #accountsUseCases: AccountUseCases; - constructor(accounts: AccountUseCases) { + readonly #snapClient: SnapClient; + + constructor(accounts: AccountUseCases, snapClient: SnapClient) { this.#accountsUseCases = accounts; + this.#snapClient = snapClient; } async route(origin: string, request: JsonRpcRequest): Promise { @@ -75,9 +89,9 @@ export class KeyringHandler implements Keyring { } async createAccount( - opts: Record & MetaMaskOptions, + options: Record & MetaMaskOptions, ): Promise { - assert(opts, CreateAccountRequest); + assert(options, CreateAccountRequest); const { metamask, scope, @@ -85,24 +99,73 @@ export class KeyringHandler implements Keyring { index, derivationPath, addressType, - } = opts; + synchronize, + } = options; + + const resolvedIndex = derivationPath + ? this.#extractAccountIndex(derivationPath) + : index; + + let resolvedAddressType: AddressType | undefined; + if (addressType) { + resolvedAddressType = caip2ToAddressType[addressType]; + } else if (derivationPath) { + resolvedAddressType = this.#extractAddressType(derivationPath); + } const createParams = { network: caip2ToNetwork[scope], entropySource, - index: derivationPath ? this.#extractAccountIndex(derivationPath) : index, - addressType: addressType ? caip2ToAddressType[addressType] : undefined, - correlationId: metamask?.correlationId, + index: resolvedIndex, + addressType: resolvedAddressType, + synchronize, }; const account = await this.#accountsUseCases.create(createParams); - if (opts.synchronize) { + await this.#snapClient.emitAccountCreatedEvent( + account, + metamask?.correlationId, + ); + + if (synchronize) { await this.#accountsUseCases.fullScan(account); } return mapToKeyringAccount(account); } + async discoverAccounts( + scopes: BtcScope[], + entropySource: string, + groupIndex: number, + ): Promise { + // Discovery is essentially the same as batch creation with synchronization, but without emitting events. + // Accounts are unique per network and address type. + + const accounts = await Promise.all( + scopes.flatMap((scope) => + Object.values(Caip2AddressType).map(async (addressType) => { + const createParams = { + network: caip2ToNetwork[scope], + entropySource, + index: groupIndex, + addressType: caip2ToAddressType[addressType], + synchronize: true, + }; + + const account = await this.#accountsUseCases.create(createParams); + await this.#accountsUseCases.fullScan(account); + return account; + }), + ), + ); + + // Return only accounts with history (even if balance is 0). + return accounts + .filter((account) => account.listTransactions().length > 0) + .map((account) => mapToDiscoveredAccount(account, groupIndex)); + } + async getAccountBalances( id: string, ): Promise> { @@ -170,6 +233,32 @@ export class KeyringHandler implements Keyring { throw new Error('Method not implemented.'); } + #extractAddressType(path: string): AddressType { + const segments = path.split('/'); + if (segments.length < 4) { + throw new Error(`Invalid derivation path: ${path}`); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const purposePart = segments[1]!; + const match = purposePart.match(/^(\d+)/u); + if (!match) { + throw new Error(`Invalid purpose segment: ${purposePart}`); + } + + const purpose = Number(match[1]); + if (!Object.values(Purpose).includes(purpose)) { + throw new Error(`Invalid BIP-purpose: ${purpose}`); + } + + const addressType = purposeToAddressType[purpose as Purpose]; + if (!addressType) { + throw new Error(`No address-type mapping for purpose: ${purpose}`); + } + + return addressType; + } + #extractAccountIndex(path: string): number { const segments = path.split('/'); if (segments.length < 4) { @@ -183,7 +272,13 @@ export class KeyringHandler implements Keyring { throw new Error(`Invalid account index: ${accountPart}`); } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return parseInt(match[1]!, 10); + const index = Number(match[1]); + if (!Number.isInteger(index) || index < 0) { + throw new Error( + `Account index must be a non-negative integer, got: ${index}`, + ); + } + + return index; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts index 225ee4ab..a97d2c8d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts @@ -13,7 +13,6 @@ const reverseMapping = < export enum Caip2AddressType { P2pkh = 'bip122:p2pkh', P2sh = 'bip122:p2sh', - P2wsh = 'bip122:p2wsh', P2wpkh = 'bip122:p2wpkh', P2tr = 'bip122:p2tr', } @@ -29,7 +28,6 @@ export const caip2ToNetwork: Record = { export const caip2ToAddressType: Record = { [Caip2AddressType.P2pkh]: 'p2pkh', [Caip2AddressType.P2sh]: 'p2sh', - [Caip2AddressType.P2wsh]: 'p2wsh', [Caip2AddressType.P2wpkh]: 'p2wpkh', [Caip2AddressType.P2tr]: 'p2tr', }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index 306f51c5..eba1fee0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -8,12 +8,22 @@ import type { WalletTx, } from '@metamask/bitcoindevkit'; import type { + DiscoveredAccount, KeyringAccount, Transaction as KeyringTransaction, } from '@metamask/keyring-api'; -import { TransactionStatus, BtcMethod } from '@metamask/keyring-api'; +import { + TransactionStatus, + BtcMethod, + DiscoveredAccountType, +} from '@metamask/keyring-api'; -import { networkToCurrencyUnit, type BitcoinAccount } from '../entities'; +import { + addressTypeToPurpose, + networkToCoinType, + networkToCurrencyUnit, + type BitcoinAccount, +} from '../entities'; import type { Caip19Asset } from './caip'; import { addressTypeToCaip2, networkToCaip19, networkToCaip2 } from './caip'; @@ -170,3 +180,21 @@ export function mapToTransaction( return transaction; } + +/** + * Maps a Bitcoin Account to a Discovered Account. + * + * @param account - The Bitcoin account. + * @param groupIndex - The group index. + * @returns The Discovered account. + */ +export function mapToDiscoveredAccount( + account: BitcoinAccount, + groupIndex: number, +): DiscoveredAccount { + return { + type: DiscoveredAccountType.Bip44, + scopes: [networkToCaip2[account.network]], + derivationPath: `m/${addressTypeToPurpose[account.addressType]}'/${networkToCoinType[account.network]}'/${groupIndex}'`, + }; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 9dffc99b..5771b8cb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -59,7 +59,7 @@ const sendFlowUseCases = new SendFlowUseCases( const assetsUseCases = new AssetsUseCases(logger, assetRatesClient); // Application layer -const keyringHandler = new KeyringHandler(accountsUseCases); +const keyringHandler = new KeyringHandler(accountsUseCases, snapClient); const cronHandler = new CronHandler(logger, accountsUseCases, sendFlowUseCases); const rpcHandler = new RpcHandler(sendFlowUseCases, accountsUseCases); const userInputHandler = new UserInputHandler(sendFlowUseCases); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/ConsoleLoggerAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/ConsoleLoggerAdapter.ts index 1b5fc336..e73e46bf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/ConsoleLoggerAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/ConsoleLoggerAdapter.ts @@ -2,12 +2,12 @@ import type { Logger } from '../entities'; import { LogLevel } from '../entities'; const logLevelPriority = { - [LogLevel.ERROR]: 0, - [LogLevel.WARN]: 1, - [LogLevel.INFO]: 2, - [LogLevel.DEBUG]: 3, - [LogLevel.TRACE]: 4, - [LogLevel.SILENT]: 5, + [LogLevel.SILENT]: 0, + [LogLevel.ERROR]: 1, + [LogLevel.WARN]: 2, + [LogLevel.INFO]: 3, + [LogLevel.DEBUG]: 4, + [LogLevel.TRACE]: 5, }; export class ConsoleLoggerAdapter implements Logger { diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts index fe2522a0..b5598f64 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts @@ -36,6 +36,7 @@ export class EsploraClientAdapter implements BlockchainClient { this.#config.stopGap, this.#config.parallelRequests, ); + account.applyUpdate(update); } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 3589a916..9c523b2b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -102,7 +102,6 @@ describe('AccountUseCases', () => { const addressType: AddressType = 'p2wpkh'; const entropySource = 'some-source'; const index = 1; - const correlationId = 'some-correlation-id'; const mockAccount = mock(); @@ -126,7 +125,6 @@ describe('AccountUseCases', () => { entropySource, index, addressType: tAddressType, - correlationId, }); expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( @@ -137,10 +135,6 @@ describe('AccountUseCases', () => { network, tAddressType, ); - expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalledWith( - mockAccount, - correlationId, - ); }, ); @@ -165,7 +159,6 @@ describe('AccountUseCases', () => { entropySource, index, addressType, - correlationId, }); expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( @@ -176,10 +169,6 @@ describe('AccountUseCases', () => { tNetwork, addressType, ); - expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalledWith( - mockAccount, - correlationId, - ); }, ); @@ -196,7 +185,6 @@ describe('AccountUseCases', () => { expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.insert).not.toHaveBeenCalled(); - expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalled(); expect(result).toBe(mockExistingAccount); }); @@ -213,7 +201,6 @@ describe('AccountUseCases', () => { expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.insert).toHaveBeenCalled(); - expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalled(); expect(result).toBe(mockAccount); }); @@ -244,20 +231,6 @@ describe('AccountUseCases', () => { expect(mockRepository.insert).toHaveBeenCalled(); expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); }); - - it('propagates an error if emitAccountCreatedEvent throws', async () => { - const error = new Error(); - mockRepository.getByDerivationPath.mockResolvedValue(null); - mockSnapClient.emitAccountCreatedEvent.mockRejectedValue(error); - - await expect( - useCases.create({ network, entropySource, index, addressType }), - ).rejects.toBe(error); - - expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); - expect(mockRepository.insert).toHaveBeenCalled(); - expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalled(); - }); }); describe('synchronize', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 446fc2b8..4b65d77f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -24,7 +24,6 @@ export type CreateAccountParams = { index?: number; entropySource?: string; addressType?: AddressType; - correlationId?: string; }; export class AccountUseCases { @@ -83,7 +82,6 @@ export class AccountUseCases { addressType = this.#accountConfig.defaultAddressType, index = 0, network, - correlationId, entropySource = 'm', } = req; @@ -98,7 +96,6 @@ export class AccountUseCases { const account = await this.#repository.getByDerivationPath(derivationPath); if (account) { this.#logger.debug('Account already exists: %s,', account.id); - await this.#snapClient.emitAccountCreatedEvent(account, correlationId); return account; } @@ -108,8 +105,6 @@ export class AccountUseCases { addressType, ); - await this.#snapClient.emitAccountCreatedEvent(newAccount, correlationId); - this.#logger.info( 'Bitcoin account created successfully: %s. derivationPath: %s', newAccount.id, From b78806af4527b61f98501fa9f97a4caa9309c389 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 22 May 2025 15:39:19 +0200 Subject: [PATCH 232/362] feat: use new account types (#461) --- .../integration-test/keyring.test.ts | 46 +++++++++---------- .../integration-test/send-flow.test.ts | 6 +-- .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/KeyringHandler.test.ts | 31 ++++++------- .../src/handlers/KeyringHandler.ts | 27 ++++++----- .../bitcoin-wallet-snap/src/handlers/caip.ts | 25 ++++------ .../bitcoin-wallet-snap/src/handlers/index.ts | 8 +--- .../src/handlers/mappings.ts | 10 ++-- .../src/infra/jsx/ReviewTransactionView.tsx | 6 +-- 10 files changed, 74 insertions(+), 89 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index 0dd9cc46..5f0daf24 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -1,11 +1,11 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import { BtcMethod, BtcScope } from '@metamask/keyring-api'; +import { BtcAccountType, BtcMethod, BtcScope } from '@metamask/keyring-api'; import type { Snap } from '@metamask/snaps-jest'; import { installSnap } from '@metamask/snaps-jest'; import { FUNDING_TX, MNEMONIC, ORIGIN, TEST_ADDRESS } from './constants'; import { CurrencyUnit } from '../src/entities'; -import { Caip2AddressType, Caip19Asset } from '../src/handlers/caip'; +import { Caip19Asset } from '../src/handlers/caip'; /* eslint-disable @typescript-eslint/no-non-null-assertion */ @@ -45,14 +45,14 @@ describe('Keyring', () => { it.each([ { // Main account used in the tests, only one to synchronize - addressType: Caip2AddressType.P2wpkh, + addressType: BtcAccountType.P2wpkh, scope: BtcScope.Regtest, index: 0, expectedAddress: TEST_ADDRESS, synchronize: true, }, { - addressType: Caip2AddressType.P2wpkh, + addressType: BtcAccountType.P2wpkh, scope: BtcScope.Mainnet, index: 0, expectedAddress: 'bc1q832zlt4tgnqy88vd20mazw77dlt0j0wf2naw8q', @@ -60,42 +60,42 @@ describe('Keyring', () => { }, { // Tests multiple accounts of same address type - addressType: Caip2AddressType.P2wpkh, + addressType: BtcAccountType.P2wpkh, scope: BtcScope.Mainnet, index: 1, expectedAddress: 'bc1qe2e3tdkqwytw7furyl2nlfy3sqs23acynn50d9', synchronize: false, }, { - addressType: Caip2AddressType.P2pkh, + addressType: BtcAccountType.P2pkh, scope: BtcScope.Mainnet, index: 0, expectedAddress: '15feVv7kK3z7jxA4RZZzY7Fwdu3yqFwzcT', synchronize: false, }, { - addressType: Caip2AddressType.P2pkh, + addressType: BtcAccountType.P2pkh, scope: BtcScope.Testnet, index: 0, expectedAddress: 'mjPQaLkhZN3MxsYN8Nebzwevuz8vdTaRCq', synchronize: false, }, { - addressType: Caip2AddressType.P2sh, + addressType: BtcAccountType.P2sh, scope: BtcScope.Mainnet, index: 0, expectedAddress: '3QVSaDYjxEh4L3K24eorrQjfVxPAKJMys2', synchronize: false, }, { - addressType: Caip2AddressType.P2sh, + addressType: BtcAccountType.P2sh, scope: BtcScope.Testnet, index: 0, expectedAddress: '2NBG623WvXp1zxKB6gK2mnMe2mSDCur5qRU', synchronize: false, }, { - addressType: Caip2AddressType.P2tr, + addressType: BtcAccountType.P2tr, scope: BtcScope.Mainnet, index: 0, expectedAddress: @@ -103,7 +103,7 @@ describe('Keyring', () => { synchronize: false, }, { - addressType: Caip2AddressType.P2tr, + addressType: BtcAccountType.P2tr, scope: BtcScope.Testnet, index: 0, expectedAddress: @@ -145,14 +145,14 @@ describe('Keyring', () => { params: { options: { scope: BtcScope.Mainnet, - addressType: Caip2AddressType.P2wpkh, + addressType: BtcAccountType.P2wpkh, derivationPath: "m/84'/0'/1'", }, }, }); expect(response).toRespondWith( - accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Mainnet}:1`], + accounts[`${BtcAccountType.P2wpkh}:${BtcScope.Mainnet}:1`], ); }); @@ -165,13 +165,13 @@ describe('Keyring', () => { params: { options: { scope: BtcScope.Mainnet, - addressType: Caip2AddressType.P2wpkh, + addressType: BtcAccountType.P2wpkh, }, }, }); expect(response).toRespondWith( - accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Mainnet}:0`], + accounts[`${BtcAccountType.P2wpkh}:${BtcScope.Mainnet}:0`], ); }); @@ -180,12 +180,12 @@ describe('Keyring', () => { origin: ORIGIN, method: 'keyring_getAccount', params: { - id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Mainnet}:0`]!.id, + id: accounts[`${BtcAccountType.P2wpkh}:${BtcScope.Mainnet}:0`]!.id, }, }); expect(response).toRespondWith( - accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Mainnet}:0`], + accounts[`${BtcAccountType.P2wpkh}:${BtcScope.Mainnet}:0`], ); }); @@ -200,7 +200,7 @@ describe('Keyring', () => { it('lists account transactions', async () => { const accoundId = - accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}:0`]!.id; + accounts[`${BtcAccountType.P2wpkh}:${BtcScope.Regtest}:0`]!.id; const response = await snap.onKeyringRequest({ origin: ORIGIN, method: 'keyring_listAccountTransactions', @@ -221,7 +221,7 @@ describe('Keyring', () => { origin: ORIGIN, method: 'keyring_getAccountBalances', params: { - id: accounts[`${Caip2AddressType.P2wpkh}:${BtcScope.Regtest}:0`]!.id, + id: accounts[`${BtcAccountType.P2wpkh}:${BtcScope.Regtest}:0`]!.id, assets: [Caip19Asset.Regtest], }, }); @@ -235,7 +235,7 @@ describe('Keyring', () => { }); it('removes an account', async () => { - const { id } = accounts[`${Caip2AddressType.P2pkh}:${BtcScope.Mainnet}:0`]!; + const { id } = accounts[`${BtcAccountType.P2pkh}:${BtcScope.Mainnet}:0`]!; let response = await snap.onKeyringRequest({ origin: ORIGIN, @@ -264,17 +264,17 @@ describe('Keyring', () => { it.each([ { - addressType: Caip2AddressType.P2wpkh, + addressType: BtcAccountType.P2wpkh, scope: BtcScope.Mainnet, expectedAssets: [Caip19Asset.Bitcoin], }, { - addressType: Caip2AddressType.P2wpkh, + addressType: BtcAccountType.P2wpkh, scope: BtcScope.Regtest, expectedAssets: [Caip19Asset.Regtest], }, { - addressType: Caip2AddressType.P2tr, + addressType: BtcAccountType.P2tr, scope: BtcScope.Testnet, expectedAssets: [Caip19Asset.Testnet], }, diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts index fa239fc6..e0d0245f 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts @@ -1,5 +1,5 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import { BtcScope } from '@metamask/keyring-api'; +import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; import type { Snap } from '@metamask/snaps-jest'; import { assertIsCustomDialog, installSnap } from '@metamask/snaps-jest'; @@ -9,7 +9,7 @@ import { ReviewTransactionEvent, SendFormEvent, } from '../src/entities'; -import { Caip19Asset, Caip2AddressType } from '../src/handlers/caip'; +import { Caip19Asset } from '../src/handlers/caip'; import { CronMethod } from '../src/handlers/CronHandler'; describe('Send flow', () => { @@ -41,7 +41,7 @@ describe('Send flow', () => { method: 'keyring_createAccount', params: { options: { - addressType: Caip2AddressType.P2wpkh, + addressType: BtcAccountType.P2wpkh, scope: BtcScope.Regtest, synchronize: true, }, diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 50270cf1..4fbc5087 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -38,7 +38,7 @@ "@jest/globals": "^29.7.0", "@metamask/bitcoindevkit": "^0.1.9", "@metamask/key-tree": "^10.1.1", - "@metamask/keyring-api": "^17.5.0", + "@metamask/keyring-api": "^17.6.0", "@metamask/keyring-snap-sdk": "^3.2.0", "@metamask/slip44": "^4.1.0", "@metamask/snaps-cli": "^7.1.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index fd5a22fb..dd338d21 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "vTG5BqOvxrlBoYADKe6Zoo4aKIPbJDZW9mw/XpQePMQ=", + "shasum": "7M6/ITiRthERWKuEyrpQDPfN3uwa85vya6Kezz5nzkc=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 4e50a190..a9c4b2be 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -13,18 +13,13 @@ import type { DiscoveredAccount, Transaction as KeyringTransaction, } from '@metamask/keyring-api'; -import { BtcMethod, BtcScope } from '@metamask/keyring-api'; +import { BtcAccountType, BtcMethod, BtcScope } from '@metamask/keyring-api'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; import type { SnapClient, BitcoinAccount } from '../entities'; import { CurrencyUnit, Purpose } from '../entities'; -import { - caip2ToNetwork, - caip2ToAddressType, - Caip2AddressType, - Caip19Asset, -} from './caip'; +import { scopeToNetwork, caipToAddressType, Caip19Asset } from './caip'; import { KeyringHandler, CreateAccountRequest } from './KeyringHandler'; import { mapToDiscoveredAccount } from './mappings'; import type { @@ -83,16 +78,16 @@ describe('KeyringHandler', () => { scope: BtcScope.Signet, entropySource, index, - addressType: Caip2AddressType.P2pkh, + addressType: BtcAccountType.P2pkh, metamask: { correlationId, }, }; const expectedCreateParams: CreateAccountParams = { - network: caip2ToNetwork[BtcScope.Signet], + network: scopeToNetwork[BtcScope.Signet], entropySource, index, - addressType: caip2ToAddressType[Caip2AddressType.P2pkh], + addressType: caipToAddressType[BtcAccountType.P2pkh], }; await handler.createAccount(options); @@ -229,15 +224,15 @@ describe('KeyringHandler', () => { const scopes = Object.values(BtcScope); it('creates, scans and returns accounts for every scope/addressType combination', async () => { - const addressTypes = Object.values(Caip2AddressType); + const addressTypes = Object.values(BtcAccountType); const totalCombinations = scopes.length * addressTypes.length; const expected: DiscoveredAccount[] = []; scopes.forEach((scope) => { addressTypes.forEach((addrType) => { const acc = mock({ - addressType: caip2ToAddressType[addrType], - network: caip2ToNetwork[scope], + addressType: caipToAddressType[addrType], + network: scopeToNetwork[scope], listTransactions: jest.fn().mockReturnValue([{}]), // has history }); @@ -260,10 +255,10 @@ describe('KeyringHandler', () => { addressTypes.forEach((addrType, aIdx) => { const callIdx = sIdx * addressTypes.length + aIdx; expect(mockAccounts.create).toHaveBeenNthCalledWith(callIdx + 1, { - network: caip2ToNetwork[scope], + network: scopeToNetwork[scope], entropySource, index: groupIndex, - addressType: caip2ToAddressType[addrType], + addressType: caipToAddressType[addrType], synchronize: true, }); }); @@ -275,7 +270,7 @@ describe('KeyringHandler', () => { }); it('filters out accounts that have no transaction history', async () => { - const addressTypes = Object.values(Caip2AddressType); + const addressTypes = Object.values(BtcAccountType); const totalCombinations = scopes.length * addressTypes.length; for (let i = 0; i < totalCombinations; i += 1) { @@ -348,7 +343,7 @@ describe('KeyringHandler', () => { mockAccounts.get.mockResolvedValue(mockAccount); const expectedKeyringAccount = { id: 'some-id', - type: Caip2AddressType.P2wpkh, + type: BtcAccountType.P2wpkh, scopes: [BtcScope.Mainnet], address: 'bc1qaddress...', options: {}, @@ -375,7 +370,7 @@ describe('KeyringHandler', () => { const expectedKeyringAccounts = [ { id: 'some-id', - type: Caip2AddressType.P2wpkh, + type: BtcAccountType.P2wpkh, scopes: [BtcScope.Mainnet], address: 'bc1qaddress...', options: {}, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 2dbf9612..a427b15c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -1,5 +1,9 @@ import type { AddressType } from '@metamask/bitcoindevkit'; -import { BtcScope, MetaMaskOptionsStruct } from '@metamask/keyring-api'; +import { + BtcAccountType, + BtcScope, + MetaMaskOptionsStruct, +} from '@metamask/keyring-api'; import type { Keyring, KeyringAccount, @@ -33,10 +37,9 @@ import { } from '../entities'; import { networkToCaip19, - Caip2AddressType, - caip2ToAddressType, - caip2ToNetwork, - networkToCaip2, + caipToAddressType, + scopeToNetwork, + networkToScope, } from './caip'; import { handle } from './errors'; import { @@ -49,7 +52,7 @@ import type { AccountUseCases } from '../use-cases/AccountUseCases'; export const CreateAccountRequest = object({ scope: enums(Object.values(BtcScope)), - addressType: optional(enums(Object.values(Caip2AddressType))), + addressType: optional(enums(Object.values(BtcAccountType))), entropySource: optional(string()), accountNameSuggestion: optional(string()), synchronize: optional(boolean()), @@ -108,13 +111,13 @@ export class KeyringHandler implements Keyring { let resolvedAddressType: AddressType | undefined; if (addressType) { - resolvedAddressType = caip2ToAddressType[addressType]; + resolvedAddressType = caipToAddressType[addressType]; } else if (derivationPath) { resolvedAddressType = this.#extractAddressType(derivationPath); } const createParams = { - network: caip2ToNetwork[scope], + network: scopeToNetwork[scope], entropySource, index: resolvedIndex, addressType: resolvedAddressType, @@ -144,12 +147,12 @@ export class KeyringHandler implements Keyring { const accounts = await Promise.all( scopes.flatMap((scope) => - Object.values(Caip2AddressType).map(async (addressType) => { + Object.values(BtcAccountType).map(async (addressType) => { const createParams = { - network: caip2ToNetwork[scope], + network: scopeToNetwork[scope], entropySource, index: groupIndex, - addressType: caip2ToAddressType[addressType], + addressType: caipToAddressType[addressType], synchronize: true, }; @@ -182,7 +185,7 @@ export class KeyringHandler implements Keyring { async filterAccountChains(id: string, chains: string[]): Promise { const account = await this.#accountsUseCases.get(id); - const accountChain = networkToCaip2[account.network]; + const accountChain = networkToScope[account.network]; return chains.includes(accountChain) ? [accountChain] : []; } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts index a97d2c8d..837af60e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts @@ -1,5 +1,5 @@ import type { AddressType, Network } from '@metamask/bitcoindevkit'; -import { BtcScope } from '@metamask/keyring-api'; +import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; const reverseMapping = < From extends string | number | symbol, @@ -10,14 +10,7 @@ const reverseMapping = < ): Record => Object.fromEntries(Object.entries(map).map(([from, to]) => [to, from])); -export enum Caip2AddressType { - P2pkh = 'bip122:p2pkh', - P2sh = 'bip122:p2sh', - P2wpkh = 'bip122:p2wpkh', - P2tr = 'bip122:p2tr', -} - -export const caip2ToNetwork: Record = { +export const scopeToNetwork: Record = { [BtcScope.Mainnet]: 'bitcoin', [BtcScope.Testnet]: 'testnet', [BtcScope.Testnet4]: 'testnet4', @@ -25,15 +18,15 @@ export const caip2ToNetwork: Record = { [BtcScope.Regtest]: 'regtest', }; -export const caip2ToAddressType: Record = { - [Caip2AddressType.P2pkh]: 'p2pkh', - [Caip2AddressType.P2sh]: 'p2sh', - [Caip2AddressType.P2wpkh]: 'p2wpkh', - [Caip2AddressType.P2tr]: 'p2tr', +export const caipToAddressType: Record = { + [BtcAccountType.P2pkh]: 'p2pkh', + [BtcAccountType.P2sh]: 'p2sh', + [BtcAccountType.P2wpkh]: 'p2wpkh', + [BtcAccountType.P2tr]: 'p2tr', }; -export const networkToCaip2 = reverseMapping(caip2ToNetwork); -export const addressTypeToCaip2 = reverseMapping(caip2ToAddressType); +export const networkToScope = reverseMapping(scopeToNetwork); +export const addressTypeToCaip = reverseMapping(caipToAddressType); export enum Caip19Asset { Bitcoin = 'bip122:000000000019d6689c085ae165831e93/slip44:0', diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts index 0a6cba51..ccde4f44 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/index.ts @@ -3,10 +3,4 @@ export * from './CronHandler'; export * from './RpcHandler'; export * from './UserInputHandler'; export * from './AssetsHandler'; - -export { - Caip2AddressType, - Caip19Asset, - networkToCaip19, - networkToCaip2, -} from './caip'; +export * from './caip'; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index eba1fee0..c0cc4685 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -25,7 +25,7 @@ import { type BitcoinAccount, } from '../entities'; import type { Caip19Asset } from './caip'; -import { addressTypeToCaip2, networkToCaip19, networkToCaip2 } from './caip'; +import { addressTypeToCaip, networkToCaip19, networkToScope } from './caip'; type TransactionAmount = { amount: string; @@ -68,8 +68,8 @@ export const networkToName: Record = { */ export function mapToKeyringAccount(account: BitcoinAccount): KeyringAccount { return { - type: addressTypeToCaip2[account.addressType] as KeyringAccount['type'], - scopes: [networkToCaip2[account.network]], + type: addressTypeToCaip[account.addressType] as KeyringAccount['type'], + scopes: [networkToScope[account.network]], id: account.id, address: account.peekAddress(0).address.toString(), options: {}, @@ -142,7 +142,7 @@ export function mapToTransaction( type: isSend ? 'send' : 'receive', id: txid.toString(), account: account.id, - chain: networkToCaip2[network], + chain: networkToScope[network], status, timestamp, events, @@ -194,7 +194,7 @@ export function mapToDiscoveredAccount( ): DiscoveredAccount { return { type: DiscoveredAccountType.Bip44, - scopes: [networkToCaip2[account.network]], + scopes: [networkToScope[account.network]], derivationPath: `m/${addressTypeToPurpose[account.addressType]}'/${networkToCoinType[account.network]}'/${groupIndex}'`, }; } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx index 7fdb3993..73f85e80 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx @@ -19,7 +19,7 @@ import { displayAmount, displayExchangeAmount, translate } from './format'; import { Config } from '../../config'; import type { Messages, ReviewTransactionContext } from '../../entities'; import { BlockTime, ReviewTransactionEvent } from '../../entities'; -import { networkToCaip2 } from '../../handlers'; +import { networkToScope } from '../../handlers'; type ReviewTransactionViewProps = { context: ReviewTransactionContext; @@ -57,7 +57,7 @@ export const ReviewTransactionView: SnapComponent<
@@ -70,7 +70,7 @@ export const ReviewTransactionView: SnapComponent<
From 854716fa0cb74c4ff0e291082a782488fd1a2300 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 27 May 2025 11:18:43 +0200 Subject: [PATCH 233/362] feat: use setState and getState (#463) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/entities/account.ts | 18 +- .../bitcoin-wallet-snap/src/entities/snap.ts | 35 +- .../bitcoin-wallet-snap/src/handlers/caip.ts | 1 + .../src/handlers/errors.ts | 2 +- .../src/infra/BdkAccountAdapter.ts | 15 +- .../src/infra/SnapClientAdapter.ts | 28 +- .../src/store/BdkAccountRepository.test.ts | 417 +++++++++--------- .../src/store/BdkAccountRepository.ts | 209 +++++---- .../src/use-cases/AccountUseCases.test.ts | 16 +- .../src/use-cases/AccountUseCases.ts | 3 +- 11 files changed, 399 insertions(+), 347 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index dd338d21..68f131ab 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "7M6/ITiRthERWKuEyrpQDPfN3uwa85vya6Kezz5nzkc=", + "shasum": "kNYjV3RNAFLK6diSj2lX+ZkfV58q0SiG7eawAIGQBAk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index 13837b4f..863206f8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -27,6 +27,11 @@ export type BitcoinAccount = { */ id: string; + /** + * Derivation path. + */ + derivationPath: string[]; + /** * The balance of the account. */ @@ -208,19 +213,26 @@ export type BitcoinAccountRepository = { getByDerivationPath(derivationPath: string[]): Promise; /** - * Insert a new account. + * Create a new account, without persisting it. * - * @param derivationPath - derivation index. + * @param derivationPath - derivation path. * @param network - network. * @param addressType - address type. * @returns the new account */ - insert( + create( derivationPath: string[], network: Network, addressType: AddressType, ): Promise; + /** + * Insert an account. + * + * @param account - Bitcoin account. + */ + insert(account: BitcoinAccount): Promise; + /** * Update an account. * diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index 8396c2b4..a06e2adf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -10,11 +10,19 @@ import type { BitcoinAccount } from './account'; import type { Inscription } from './meta-protocols'; export type SnapState = { - accounts: { - derivationPaths: Record; - wallets: Record; - inscriptions: Record; - }; + // accountId -> account state. This is the main state of the snap. + accounts: Record; + // derivationPath -> accountId. Only needed for fast lookup. + derivationPaths: Record; +}; + +export type AccountState = { + // Split derivation path. + derivationPath: string[]; + // Wallet data. + wallet: string; + // Wallet inscriptions for meta protocols (ordinals, etc.) + inscriptions: Inscription[]; }; /** @@ -22,18 +30,27 @@ export type SnapState = { */ export type SnapClient = { /** - * Get the Snap state. + * Get the Snap state for a given key. * + * @param key - The key to get the state for. * @returns The Snap state. */ - get(): Promise; + getState(key: string): Promise; /** - * Set the Snap state. + * Set the Snap state for a given key. * + * @param key - The key to set the state for. * @param newState - The new state. */ - set(newState: SnapState): Promise; + setState(key: string, newState: Json | null): Promise; + + /** + * Set the Snap state for a given key to null, effectively removing it. + * + * @param key - The key to set the state for. + */ + removeState(key: string): Promise; /** * Get the private SLIP10 for a given derivation path from the Snap SRP. diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts index 837af60e..182f0654 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts @@ -26,6 +26,7 @@ export const caipToAddressType: Record = { }; export const networkToScope = reverseMapping(scopeToNetwork); + export const addressTypeToCaip = reverseMapping(caipToAddressType); export enum Caip19Asset { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts index 24630377..fd6a58a2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts @@ -13,7 +13,7 @@ export const handle = async ( // 4. Throw the more aligned error type from the Snaps SDK. // 5. Default to InternalError('an internal error occurred') if no custom error is thrown. - // console.error('Error occurred:', error); + // console.error('Error occurred:', (error as Error).message); throw new SnapError(error as Error); } }; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index e22cedda..1f084c9b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -28,26 +28,32 @@ import { BdkTxBuilderAdapter } from './BdkTxBuilderAdapter'; export class BdkAccountAdapter implements BitcoinAccount { readonly #id: string; + readonly #derivationPath: string[]; + readonly #wallet: Wallet; - constructor(id: string, wallet: Wallet) { + constructor(id: string, derivationPath: string[], wallet: Wallet) { this.#id = id; + this.#derivationPath = derivationPath; this.#wallet = wallet; } static create( id: string, + derivationPath: string[], descriptors: DescriptorPair, network: Network, ): BdkAccountAdapter { return new BdkAccountAdapter( id, + derivationPath, Wallet.create(network, descriptors.external, descriptors.internal), ); } static load( id: string, + derivationPath: string[], walletData: ChangeSet, descriptors?: DescriptorPair, ): BdkAccountAdapter { @@ -55,17 +61,22 @@ export class BdkAccountAdapter implements BitcoinAccount { if (descriptors) { return new BdkAccountAdapter( id, + derivationPath, Wallet.load(walletData, descriptors.external, descriptors.internal), ); } - return new BdkAccountAdapter(id, Wallet.load(walletData)); + return new BdkAccountAdapter(id, derivationPath, Wallet.load(walletData)); } get id(): string { return this.#id; } + get derivationPath(): string[] { + return this.#derivationPath; + } + get balance(): Balance { return this.#wallet.balance; } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 9b21e6d8..9aae50cb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -11,7 +11,7 @@ import type { GetPreferencesResult, } from '@metamask/snaps-sdk'; -import type { BitcoinAccount, SnapClient, SnapState } from '../entities'; +import type { BitcoinAccount, SnapClient } from '../entities'; import { networkToCurrencyUnit } from '../entities'; import { networkToCaip19 } from '../handlers'; import { @@ -28,33 +28,31 @@ export class SnapClientAdapter implements SnapClient { this.#encrypt = encrypt; } - async get(): Promise { - const state = await snap.request({ - method: 'snap_manageState', + async getState(key: string): Promise { + return snap.request({ + method: 'snap_getState', params: { - operation: 'get', + key, encrypted: this.#encrypt, }, }); - - return ( - (state as SnapState) ?? { - accounts: { derivationPaths: {}, wallets: {}, inscriptions: {} }, - } - ); } - async set(newState: SnapState): Promise { + async setState(key: string, newState: Json | null): Promise { await snap.request({ - method: 'snap_manageState', + method: 'snap_setState', params: { - operation: 'update', - newState, + key, + value: newState, encrypted: this.#encrypt, }, }); } + async removeState(key: string): Promise { + await this.setState(key, null); + } + async getPrivateEntropy(derivationPath: string[]): Promise { const source = derivationPath[0] === 'm' ? undefined : derivationPath[0]; const path = ['m', ...derivationPath.slice(1)]; diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts index 466a7b5b..5350810b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts @@ -1,12 +1,20 @@ -import { ChangeSet } from '@metamask/bitcoindevkit'; +// TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 +/* eslint-disable camelcase */ + +import type { DescriptorPair } from '@metamask/bitcoindevkit'; +import { + ChangeSet, + xpriv_to_descriptor, + xpub_to_descriptor, +} from '@metamask/bitcoindevkit'; import type { SLIP10Node } from '@metamask/key-tree'; import { mock } from 'jest-mock-extended'; import type { + AccountState, BitcoinAccount, Inscription, SnapClient, - SnapState, } from '../entities'; import { BdkAccountAdapter } from '../infra'; import { BdkAccountRepository } from './BdkAccountRepository'; @@ -19,329 +27,306 @@ jest.mock('@metamask/bitcoindevkit', () => { from_json: jest.fn(), }, slip10_to_extended: jest.fn().mockReturnValue('mock-extended'), - xpub_to_descriptor: jest - .fn() - .mockReturnValue({ external: 'ext-desc', internal: 'int-desc' }), - xpriv_to_descriptor: jest - .fn() - .mockReturnValue({ external: 'ext-desc', internal: 'int-desc' }), + xpub_to_descriptor: jest.fn(), + xpriv_to_descriptor: jest.fn(), }; }); jest.mock('../infra/BdkAccountAdapter', () => ({ BdkAccountAdapter: { load: jest.fn(), - create: jest.fn().mockReturnValue({ - takeStaged: () => ({ to_json: () => '{"mywallet": "mywalletdata"}' }), - }), + create: jest.fn(), }, })); jest.mock('uuid', () => ({ v4: () => 'mock-uuid' })); describe('BdkAccountRepository', () => { - let repo: BdkAccountRepository; - const mockSnapClient = mock(); - const mockState = mock(); + const mockWalletData = '{"mywallet":"data"}'; + const mockDerivationPath = ['m', "84'", "0'", "0'"]; + const mockSlip10Node = { + masterFingerprint: 0xdeadbeef, + } as unknown as SLIP10Node; + const mockDescriptors = mock({ + external: 'ext-desc', + internal: 'int-desc', + }); + const mockAccountState = mock({ + wallet: mockWalletData, + derivationPath: mockDerivationPath, + }); + const mockChangeSet = mock(); + const mockAccount = mock({ + id: 'some-id', + derivationPath: mockDerivationPath, + network: 'bitcoin', + addressType: 'p2wpkh', + }); + + const repo = new BdkAccountRepository(mockSnapClient); beforeEach(() => { - repo = new BdkAccountRepository(mockSnapClient); + (BdkAccountAdapter.load as jest.Mock).mockReturnValue(mockAccount); + (BdkAccountAdapter.create as jest.Mock).mockReturnValue(mockAccount); + (ChangeSet.from_json as jest.Mock).mockReturnValue(mockChangeSet); + mockSnapClient.getPrivateEntropy.mockResolvedValue(mockSlip10Node); + mockSnapClient.getPublicEntropy.mockResolvedValue(mockSlip10Node); + (xpriv_to_descriptor as jest.Mock).mockReturnValue(mockDescriptors); + (xpub_to_descriptor as jest.Mock).mockReturnValue(mockDescriptors); + (mockAccount.takeStaged as jest.Mock) = jest + .fn() + .mockReturnValue(mockChangeSet); + (mockChangeSet.to_json as jest.Mock) = jest + .fn() + .mockReturnValue(mockWalletData); }); describe('get', () => { it('returns null if account not found', async () => { - mockSnapClient.get.mockResolvedValue({ - accounts: { ...mockState.accounts, wallets: {} }, - }); + mockSnapClient.getState.mockResolvedValue(null); const result = await repo.get('non-existent-id'); + expect(result).toBeNull(); }); it('returns loaded account if found', async () => { - const state = mock({ - accounts: { - wallets: { 'some-id': '{"mywallet": "data"}' }, - }, - }); - mockSnapClient.get.mockResolvedValue(state); - - const mockAccount = {} as BitcoinAccount; - (BdkAccountAdapter.load as jest.Mock).mockReturnValue(mockAccount); - (ChangeSet.from_json as jest.Mock).mockReturnValue({ mywallet: 'data' }); + mockSnapClient.getState.mockResolvedValue(mockAccountState); const result = await repo.get('some-id'); + + expect(mockSnapClient.getState).toHaveBeenCalledWith('accounts.some-id'); + expect(ChangeSet.from_json).toHaveBeenCalledWith(mockWalletData); + expect(BdkAccountAdapter.load).toHaveBeenCalledWith( + mockAccount.id, + mockDerivationPath, + mockChangeSet, + ); expect(result).toBe(mockAccount); - expect(ChangeSet.from_json).toHaveBeenCalledWith('{"mywallet": "data"}'); - expect(BdkAccountAdapter.load).toHaveBeenCalledWith('some-id', { - mywallet: 'data', - }); }); }); describe('getAll', () => { - it('returns all accounts', async () => { - const state = mock({ - accounts: { - wallets: { - 'some-id': '{"foo":"bar"}', - 'another-id': '{"hello":"world"}', - }, - }, - }); - mockSnapClient.get.mockResolvedValue(state); + it('returns empty array if no accounts found', async () => { + mockSnapClient.getState.mockResolvedValue(null); + + const result = await repo.getAll(); + + expect(result).toStrictEqual([]); + }); - const mockAccount1 = {} as BitcoinAccount; - const mockAccount2 = {} as BitcoinAccount; + it('returns all accounts', async () => { + const id1 = 'some-id-1'; + const id2 = 'some-id-2'; + const state = { + id1: { ...mockAccountState, id: id1 }, + id2: { ...mockAccountState, id: id2 }, + }; + const mockAccount1 = { ...mockAccount, id: id1 }; + const mockAccount2 = { ...mockAccount, id: id2 }; - (ChangeSet.from_json as jest.Mock).mockImplementation((json) => json); + mockSnapClient.getState.mockResolvedValue(state); (BdkAccountAdapter.load as jest.Mock) .mockReturnValueOnce(mockAccount1) .mockReturnValueOnce(mockAccount2); const result = await repo.getAll(); - expect(result).toStrictEqual([mockAccount1, mockAccount2]); + + expect(mockSnapClient.getState).toHaveBeenCalledWith('accounts'); expect(BdkAccountAdapter.load).toHaveBeenCalledTimes(2); + expect(result).toStrictEqual([mockAccount1, mockAccount2]); }); }); describe('getByDerivationPath', () => { - it('returns null if derivation path not mapped', async () => { - mockSnapClient.get.mockResolvedValue({ - accounts: { ...mockState.accounts, derivationPaths: {} }, - }); + it('returns null if account not found', async () => { + mockSnapClient.getState.mockResolvedValue(null); + + const result = await repo.getByDerivationPath(mockDerivationPath); - const result = await repo.getByDerivationPath(['m', "84'", "0'", "0'"]); expect(result).toBeNull(); }); it('returns account if derivation path exists', async () => { - const derivationPath = ['m', "84'", "0'", "0'"]; - const state = mock({ - accounts: { - derivationPaths: { [derivationPath.join('/')]: 'some-id' }, - wallets: { 'some-id': '{}' }, - }, - }); - mockSnapClient.get.mockResolvedValue(state); + mockSnapClient.getState.mockResolvedValue('some-id'); - const mockAccount = {} as BitcoinAccount; - (BdkAccountAdapter.load as jest.Mock).mockReturnValue(mockAccount); + const result = await repo.getByDerivationPath(mockDerivationPath); - const result = await repo.getByDerivationPath(derivationPath); + expect(mockSnapClient.getState).toHaveBeenCalledWith( + "derivationPaths.m/84'/0'/0'", + ); + expect(mockSnapClient.getState).toHaveBeenCalledWith('accounts.some-id'); expect(result).toBe(mockAccount); }); }); describe('getWithSigner', () => { it('returns null if account not found', async () => { - mockSnapClient.get.mockResolvedValue({ - accounts: { ...mockState.accounts, wallets: {} }, - }); - const result = await repo.getWithSigner('non-existent-id'); - expect(result).toBeNull(); - }); + mockSnapClient.getState.mockResolvedValue(null); - it('throws error if derivation path not found', async () => { - const walletData = '{"mywallet":"data"}'; - const state = mock({ - accounts: { - wallets: { 'some-id': walletData }, - }, - }); - mockSnapClient.get.mockResolvedValue(state); + const result = await repo.getWithSigner('some-id'); - await expect(repo.getWithSigner('some-id')).rejects.toThrow( - 'Inconsistent state. No derivation path found for account some-id', - ); + expect(result).toBeNull(); }); it('returns account with signer if account exists', async () => { - const derivationPath = "m/84'/0'/0'"; - const walletData = '{"mywallet":"data"}'; - const state = mock({ - accounts: { - derivationPaths: { [derivationPath]: 'some-id' }, - wallets: { 'some-id': walletData }, - }, - }); - mockSnapClient.get.mockResolvedValue(state); - const slip10Node = { - masterFingerprint: 0xdeadbeef, - } as unknown as SLIP10Node; - mockSnapClient.getPrivateEntropy.mockResolvedValue(slip10Node); - const mockAccount = mock({ - network: 'bitcoin', - addressType: 'p2wpkh', - }); - const mockAccountWithSigner = mock(); - - (BdkAccountAdapter.load as jest.Mock) - .mockReturnValueOnce(mockAccount) - .mockReturnValueOnce(mockAccountWithSigner); + mockSnapClient.getState.mockResolvedValue(mockAccountState); const result = await repo.getWithSigner('some-id'); - expect(mockSnapClient.getPrivateEntropy).toHaveBeenCalledWith([ - 'm', - "84'", - "0'", - "0'", - ]); - expect(result).toBe(mockAccountWithSigner); + expect(mockSnapClient.getPrivateEntropy).toHaveBeenCalledWith( + mockDerivationPath, + ); + expect(BdkAccountAdapter.load).toHaveBeenCalledTimes(2); + expect(BdkAccountAdapter.load).toHaveBeenLastCalledWith( + 'some-id', + mockDerivationPath, + mockChangeSet, + mockDescriptors, + ); + expect(result).toBe(mockAccount); + }); + }); + + describe('create', () => { + it('creates a new account with xpub', async () => { + const result = await repo.create(mockDerivationPath, 'bitcoin', 'p2wpkh'); + + expect(BdkAccountAdapter.create).toHaveBeenCalledWith( + 'mock-uuid', + mockDerivationPath, + mockDescriptors, + 'bitcoin', + ); + expect(result).toBe(mockAccount); }); }); describe('insert', () => { - it('inserts a new account with xpub', async () => { - const derivationPath = ['m', "84'", "0'", "0'"]; - const state = { - accounts: { - derivationPaths: {}, - wallets: {}, - inscriptions: {}, - }, - }; - mockSnapClient.get.mockResolvedValue(state); - mockSnapClient.getPublicEntropy.mockResolvedValue({ - masterFingerprint: 0xdeadbeef, - } as unknown as SLIP10Node); - - const mockAccount = { - takeStaged: () => ({ to_json: () => '{}' }), - } as unknown as BitcoinAccount; - (BdkAccountAdapter.create as jest.Mock).mockReturnValue(mockAccount); - - await repo.insert(derivationPath, 'bitcoin', 'p2wpkh'); - - expect(mockSnapClient.set).toHaveBeenCalledWith({ - accounts: { - derivationPaths: { [derivationPath.join('/')]: 'mock-uuid' }, - wallets: { 'mock-uuid': '{}' }, - inscriptions: { 'mock-uuid': [] }, + it('throws an error if no wallet data', async () => { + await expect( + repo.insert({ + ...mockAccount, + takeStaged: jest.fn().mockReturnValue(undefined), + }), + ).rejects.toThrow( + 'Missing changeset data for account "some-id" for insertion.', + ); + }); + + it('inserts an account', async () => { + await repo.insert(mockAccount); + + expect(mockSnapClient.setState).toHaveBeenNthCalledWith( + 1, + "derivationPaths.m/84'/0'/0'", + mockAccount.id, + ); + expect(mockSnapClient.setState).toHaveBeenLastCalledWith( + 'accounts.some-id', + { + wallet: mockWalletData, + inscriptions: [], + derivationPath: mockDerivationPath, }, - }); + ); }); }); describe('update', () => { - it('updates the account and inscriptions when staged changes exist', async () => { - const state = { - accounts: { - derivationPaths: { "m/84'/0'/0'": 'some-id' }, - wallets: { 'some-id': '{"original":"data"}' }, - inscriptions: {}, - }, - }; - mockSnapClient.get.mockResolvedValue(state); - const mockAccount = mock(); - mockAccount.id = 'some-id'; - const staged = { - merge: jest.fn(), - to_json: jest.fn().mockReturnValue('{"merged":"data"}'), - } as unknown as ChangeSet; - mockAccount.takeStaged.mockReturnValue(staged); - - await repo.update(mockAccount, [{ id: 'myInscription' } as Inscription]); - - expect(staged.merge).toHaveBeenCalled(); - expect(mockSnapClient.set).toHaveBeenCalledWith({ - accounts: { - derivationPaths: { "m/84'/0'/0'": 'some-id' }, - inscriptions: { 'some-id': [{ id: 'myInscription' }] }, - wallets: { 'some-id': '{"merged":"data"}' }, - }, + it('does nothing if no wallet data', async () => { + await repo.update({ + ...mockAccount, + takeStaged: jest.fn().mockReturnValue(undefined), }); - }); - it('does nothing if account has no staged changes', async () => { - const state = mock({ - accounts: { - derivationPaths: { "m/84'/0'/0'": 'some-id' }, - wallets: { 'some-id': '{"original":"data"}' }, - }, - }); - mockSnapClient.get.mockResolvedValue(state); - const mockAccount = mock(); - mockAccount.id = 'some-id'; - mockAccount.takeStaged.mockReturnValue(undefined); + expect(mockSnapClient.setState).not.toHaveBeenCalled(); + }); - await repo.update(mockAccount); + it('throws an error if account not found', async () => { + mockSnapClient.getState.mockResolvedValue(null); - expect(mockSnapClient.set).not.toHaveBeenCalled(); + await expect(repo.update(mockAccount)).rejects.toThrow( + 'Inconsistent state: account "some-id" not found for update', + ); }); - it('throws an error if account does not exist in store', async () => { - const mockAccount = mock(); - mockAccount.id = 'some-id'; - mockAccount.takeStaged.mockReturnValue(mock()); - mockSnapClient.get.mockResolvedValue({ - accounts: { ...mockState.accounts, wallets: {} }, - }); - mockAccount.id = 'non-existent-id'; + it('updates the account and inscriptions', async () => { + const mockInscription = mock(); - await expect(repo.update(mockAccount)).rejects.toThrow( - 'Inconsistent state: account not found for update', + mockSnapClient.getState.mockResolvedValue(mockWalletData); + + await repo.update(mockAccount, [mockInscription]); + + expect(mockChangeSet.merge).toHaveBeenCalled(); + expect(mockSnapClient.getState).toHaveBeenCalledWith( + 'accounts.some-id.wallet', + ); + expect(mockSnapClient.setState).toHaveBeenNthCalledWith( + 1, + 'accounts.some-id.wallet', + mockWalletData, + ); + expect(mockSnapClient.setState).toHaveBeenLastCalledWith( + 'accounts.some-id.inscriptions', + [mockInscription], ); }); }); describe('delete', () => { it('does nothing if account not found', async () => { - mockSnapClient.get.mockResolvedValue({ - accounts: { ...mockState.accounts, wallets: {} }, - }); + mockSnapClient.getState.mockResolvedValue(null); await repo.delete('non-existent-id'); - expect(mockSnapClient.set).not.toHaveBeenCalled(); + expect(mockSnapClient.removeState).not.toHaveBeenCalled(); }); it('removes wallet data from store', async () => { - const state = { - accounts: { - derivationPaths: { "m/84'/0'/0'": 'some-id' }, - wallets: { 'some-id': '{"wallet":"data"}' }, - inscriptions: { 'some-id': [] }, - }, - }; - mockSnapClient.get.mockResolvedValue(state); + mockSnapClient.getState.mockResolvedValue(mockAccountState); await repo.delete('some-id'); - expect(mockSnapClient.set).toHaveBeenCalledWith({ - accounts: { derivationPaths: {}, wallets: {}, inscriptions: {} }, - }); + expect(mockSnapClient.removeState).toHaveBeenNthCalledWith( + 1, + 'accounts.some-id', + ); + expect(mockSnapClient.removeState).toHaveBeenLastCalledWith( + "derivationPaths.m/84'/0'/0'", + ); }); }); describe('getFrozenUTXOs', () => { - it('returns empty array if account is not found', async () => { - mockSnapClient.get.mockResolvedValue({ - accounts: { ...mockState.accounts, inscriptions: {} }, - }); + it('returns empty array if inscriptions is not found', async () => { + mockSnapClient.getState.mockResolvedValue(null); const result = await repo.getFrozenUTXOs('non-existent-id'); expect(result).toStrictEqual([]); }); + it('returns empty array if inscriptions is empty', async () => { + mockSnapClient.getState.mockResolvedValue([]); + + const result = await repo.getFrozenUTXOs('some-id'); + expect(result).toStrictEqual([]); + }); + it('returns the list of frozen UTXO outpoints', async () => { - const state = mock({ - accounts: { - inscriptions: { - 'some-id': [ - { location: 'txid1:vout:offset' }, - { location: 'txid2:vout:offset' }, - ], - }, - }, - }); - mockSnapClient.get.mockResolvedValue(state); + const mockInscriptions = [ + { location: 'txid1:vout:offset' }, + { location: 'txid2:vout:offset' }, + ] as Inscription[]; + + mockSnapClient.getState.mockResolvedValue(mockInscriptions); const result = await repo.getFrozenUTXOs('some-id'); - expect(mockSnapClient.get).toHaveBeenCalled(); + expect(mockSnapClient.getState).toHaveBeenCalledWith( + 'accounts.some-id.inscriptions', + ); expect(result).toStrictEqual(['txid1:vout', 'txid2:vout']); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts index e81367a4..61eced3a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts @@ -15,6 +15,8 @@ import type { BitcoinAccount, SnapClient, Inscription, + AccountState, + SnapState, } from '../entities'; import { BdkAccountAdapter } from '../infra'; @@ -26,53 +28,73 @@ export class BdkAccountRepository implements BitcoinAccountRepository { } async get(id: string): Promise { - const state = await this.#snapClient.get(); - const walletData = state.accounts.wallets[id]; - if (!walletData) { + const account = (await this.#snapClient.getState( + `accounts.${id}`, + )) as AccountState | null; + if (!account) { return null; } - return BdkAccountAdapter.load(id, ChangeSet.from_json(walletData)); + return BdkAccountAdapter.load( + id, + account.derivationPath, + ChangeSet.from_json(account.wallet), + ); } - async fetchInscriptions(id: string): Promise { - const state = await this.#snapClient.get(); - const inscriptions = state.accounts.inscriptions[id]; - if (!inscriptions) { - return null; + async getAll(): Promise { + const accounts = (await this.#snapClient.getState('accounts')) as + | SnapState['accounts'] + | null; + if (!accounts) { + return []; } - return inscriptions; + return Object.entries(accounts).map(([id, account]) => + BdkAccountAdapter.load( + id, + account.derivationPath, + ChangeSet.from_json(account.wallet), + ), + ); } - async getWithSigner(id: string): Promise { - const state = await this.#snapClient.get(); - const walletData = state.accounts.wallets[id]; - if (!walletData) { + async getByDerivationPath( + derivationPath: string[], + ): Promise { + const id = await this.#snapClient.getState( + `derivationPaths.${derivationPath.join('/')}`, + ); + if (!id) { return null; } - const account = BdkAccountAdapter.load(id, ChangeSet.from_json(walletData)); - const derivationPath = Object.entries(state.accounts.derivationPaths).find( - ([, walletId]) => walletId === id, - ); + return this.get(id as string); + } - // Should never occur by assertion. It is a critical inconsistent state error that should be caught in integration tests - if (!derivationPath) { - throw new Error( - `Inconsistent state. No derivation path found for account ${id}`, - ); + async getWithSigner(id: string): Promise { + const accountState = (await this.#snapClient.getState( + `accounts.${id}`, + )) as AccountState | null; + if (!accountState) { + return null; } - const slip10 = await this.#snapClient.getPrivateEntropy( - derivationPath[0].split('/'), - ); + const { derivationPath, wallet } = accountState; + + const slip10 = await this.#snapClient.getPrivateEntropy(derivationPath); const fingerprint = ( slip10.masterFingerprint ?? slip10.parentFingerprint ).toString(16); - const xpriv = slip10_to_extended(slip10, account.network); - const descriptors = xpriv_to_descriptor( - xpriv, + + const account = BdkAccountAdapter.load( + id, + derivationPath, + ChangeSet.from_json(wallet), + ); + + const privDescriptors = xpriv_to_descriptor( + slip10_to_extended(slip10, account.network), fingerprint, account.network, account.addressType, @@ -80,40 +102,13 @@ export class BdkAccountRepository implements BitcoinAccountRepository { return BdkAccountAdapter.load( id, - ChangeSet.from_json(walletData), - descriptors, - ); - } - - async getAll(): Promise { - const state = await this.#snapClient.get(); - const walletsData = state.accounts.wallets; - - return Object.entries(walletsData).map(([id, walletData]) => - BdkAccountAdapter.load(id, ChangeSet.from_json(walletData)), + derivationPath, + ChangeSet.from_json(wallet), + privDescriptors, ); } - async getByDerivationPath( - derivationPath: string[], - ): Promise { - const derivationPathId = derivationPath.join('/'); - const state = await this.#snapClient.get(); - - const id = state.accounts.derivationPaths[derivationPathId]; - if (!id) { - return null; - } - - const walletData = state.accounts.wallets[id]; - if (!walletData) { - return null; - } - - return BdkAccountAdapter.load(id, ChangeSet.from_json(walletData)); - } - - async insert( + async create( derivationPath: string[], network: Network, addressType: AddressType, @@ -132,13 +127,30 @@ export class BdkAccountRepository implements BitcoinAccountRepository { addressType, ); - const account = BdkAccountAdapter.create(id, descriptors, network); + return BdkAccountAdapter.create(id, derivationPath, descriptors, network); + } + + async insert(account: BitcoinAccount): Promise { + const { id, derivationPath } = account; + + const walletData = account.takeStaged(); + if (!walletData) { + throw new Error( + `Missing changeset data for account "${id}" for insertion.`, + ); + } - const state = await this.#snapClient.get(); - state.accounts.derivationPaths[derivationPath.join('/')] = id; - state.accounts.wallets[id] = account.takeStaged()?.to_json() ?? ''; - state.accounts.inscriptions[id] = []; - await this.#snapClient.set(state); + await Promise.all([ + this.#snapClient.setState( + `derivationPaths.${derivationPath.join('/')}`, + id, + ), + this.#snapClient.setState(`accounts.${id}`, { + wallet: walletData.to_json(), + inscriptions: [], + derivationPath, + }), + ]); return account; } @@ -147,57 +159,72 @@ export class BdkAccountRepository implements BitcoinAccountRepository { account: BitcoinAccount, inscriptions?: Inscription[], ): Promise { + const { id } = account; + const newWalletData = account.takeStaged(); if (!newWalletData) { // Nothing to update return; } - const state = await this.#snapClient.get(); - const walletData = state.accounts.wallets[account.id]; + const walletData = await this.#snapClient.getState(`accounts.${id}.wallet`); if (!walletData) { - throw new Error('Inconsistent state: account not found for update'); + throw new Error( + `Inconsistent state: account "${id}" not found for update`, + ); } - newWalletData.merge(ChangeSet.from_json(walletData)); - state.accounts.wallets[account.id] = newWalletData.to_json(); + newWalletData.merge(ChangeSet.from_json(walletData as string)); + await this.#snapClient.setState( + `accounts.${id}.wallet`, + newWalletData.to_json(), + ); + + // Inscriptions are overwritten and not merged if (inscriptions) { - state.accounts.inscriptions[account.id] = inscriptions; + await this.#snapClient.setState( + `accounts.${id}.inscriptions`, + inscriptions, + ); } - await this.#snapClient.set(state); } async delete(id: string): Promise { - const state = await this.#snapClient.get(); - const walletData = state.accounts.wallets[id]; - if (!walletData) { + const accountState = (await this.#snapClient.getState( + `accounts.${id}`, + )) as AccountState | null; + if (!accountState) { return; } - delete state.accounts.wallets[id]; - delete state.accounts.inscriptions[id]; - - // Find the path in derivationPaths that points to this id and remove it - for (const [path, existingId] of Object.entries( - state.accounts.derivationPaths, - )) { - if (existingId === id) { - delete state.accounts.derivationPaths[path]; - break; - } + await Promise.all([ + this.#snapClient.removeState(`accounts.${id}`), + this.#snapClient.removeState( + `derivationPaths.${accountState.derivationPath.join('/')}`, + ), + ]); + } + + async fetchInscriptions(id: string): Promise { + const inscriptions = await this.#snapClient.getState( + `accounts.${id}.inscriptions`, + ); + if (!inscriptions) { + return null; } - await this.#snapClient.set(state); + return inscriptions as Inscription[]; } async getFrozenUTXOs(id: string): Promise { - const state = await this.#snapClient.get(); - const inscriptions = state.accounts.inscriptions[id]; + const inscriptions = await this.#snapClient.getState( + `accounts.${id}.inscriptions`, + ); if (!inscriptions) { return []; } - return inscriptions.map((inscription) => { + return (inscriptions as AccountState['inscriptions']).map((inscription) => { // format: :: const [txid, vout] = inscription.location.split(':'); return `${txid}:${vout}`; diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 9c523b2b..dee01958 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -106,7 +106,7 @@ describe('AccountUseCases', () => { const mockAccount = mock(); beforeEach(() => { - mockRepository.insert.mockResolvedValue(mockAccount); + mockRepository.create.mockResolvedValue(mockAccount); }); it.each([ @@ -130,7 +130,7 @@ describe('AccountUseCases', () => { expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( derivationPath, ); - expect(mockRepository.insert).toHaveBeenCalledWith( + expect(mockRepository.create).toHaveBeenCalledWith( derivationPath, network, tAddressType, @@ -164,7 +164,7 @@ describe('AccountUseCases', () => { expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( expectedDerivationPath, ); - expect(mockRepository.insert).toHaveBeenCalledWith( + expect(mockRepository.create).toHaveBeenCalledWith( expectedDerivationPath, tNetwork, addressType, @@ -184,7 +184,7 @@ describe('AccountUseCases', () => { }); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); - expect(mockRepository.insert).not.toHaveBeenCalled(); + expect(mockRepository.create).not.toHaveBeenCalled(); expect(result).toBe(mockExistingAccount); }); @@ -200,7 +200,7 @@ describe('AccountUseCases', () => { }); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); - expect(mockRepository.insert).toHaveBeenCalled(); + expect(mockRepository.create).toHaveBeenCalled(); expect(result).toBe(mockAccount); }); @@ -214,21 +214,21 @@ describe('AccountUseCases', () => { ).rejects.toBe(error); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); - expect(mockRepository.insert).not.toHaveBeenCalled(); + expect(mockRepository.create).not.toHaveBeenCalled(); expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); }); it('propagates an error if insert throws', async () => { const error = new Error(); mockRepository.getByDerivationPath.mockResolvedValue(null); - mockRepository.insert.mockRejectedValue(error); + mockRepository.create.mockRejectedValue(error); await expect( useCases.create({ network, entropySource, index, addressType }), ).rejects.toBe(error); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); - expect(mockRepository.insert).toHaveBeenCalled(); + expect(mockRepository.create).toHaveBeenCalled(); expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 4b65d77f..6f6234ea 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -99,11 +99,12 @@ export class AccountUseCases { return account; } - const newAccount = await this.#repository.insert( + const newAccount = await this.#repository.create( derivationPath, network, addressType, ); + await this.#repository.insert(newAccount); this.#logger.info( 'Bitcoin account created successfully: %s. derivationPath: %s', From 732d3c32bbd51c5ee4a0a34e9bdca9f67d5f90c9 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 27 May 2025 13:51:15 +0200 Subject: [PATCH 234/362] fix: discover accounts (#464) --- .../integration-test/constants.ts | 7 +- .../integration-test/keyring.test.ts | 162 +++++------ .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/config.ts | 10 +- .../src/entities/account.ts | 5 - .../src/entities/config.ts | 4 - .../src/handlers/AssetsHandler.ts | 2 +- .../src/handlers/KeyringHandler.test.ts | 81 ++---- .../src/handlers/KeyringHandler.ts | 48 +--- .../bitcoin-wallet-snap/src/index.ts | 6 +- .../src/infra/BdkAccountAdapter.ts | 4 - .../src/use-cases/AccountUseCases.test.ts | 272 +++++++++++++----- .../src/use-cases/AccountUseCases.ts | 89 ++++-- 13 files changed, 394 insertions(+), 298 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts b/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts index 6f7935b4..61746bdb 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts @@ -5,7 +5,10 @@ import { Caip19Asset } from '../src/handlers/caip'; export const MNEMONIC = 'journey embrace permit coil indoor stereo welcome maid movie easy clock spider tent slush bright luxury awake waste legal modify awkward answer acid goose'; -export const TEST_ADDRESS = 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t'; +export const TEST_ADDRESS_REGTEST = + 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t'; +export const TEST_ADDRESS_MAINNET = + 'bc1q832zlt4tgnqy88vd20mazw77dlt0j0wf2naw8q'; export const ORIGIN = 'metamask'; export const FUNDING_TX = { account: expect.any(Number), @@ -21,7 +24,7 @@ export const FUNDING_TX = { timestamp: expect.any(Number), to: [ { - address: TEST_ADDRESS, + address: TEST_ADDRESS_REGTEST, asset: { amount: '500', fungible: true, diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index 5f0daf24..e149fa93 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -3,14 +3,20 @@ import { BtcAccountType, BtcMethod, BtcScope } from '@metamask/keyring-api'; import type { Snap } from '@metamask/snaps-jest'; import { installSnap } from '@metamask/snaps-jest'; -import { FUNDING_TX, MNEMONIC, ORIGIN, TEST_ADDRESS } from './constants'; +import { + FUNDING_TX, + MNEMONIC, + ORIGIN, + TEST_ADDRESS_REGTEST, + TEST_ADDRESS_MAINNET, +} from './constants'; import { CurrencyUnit } from '../src/entities'; import { Caip19Asset } from '../src/handlers/caip'; /* eslint-disable @typescript-eslint/no-non-null-assertion */ describe('Keyring', () => { - const accounts: Record = {}; + const accounts: Record = {}; // accounts stored by address let snap: Snap; beforeAll(async () => { @@ -21,6 +27,10 @@ describe('Keyring', () => { }); }); + beforeEach(() => { + snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); + }); + it('discover accounts successfully', async () => { const response = await snap.onKeyringRequest({ origin: ORIGIN, @@ -37,62 +47,78 @@ describe('Keyring', () => { { type: 'bip44', scopes: [BtcScope.Regtest], - derivationPath: "m/84'/0'/0'", + derivationPath: "m/84'/1'/0'", }, ]); }); + it('creates discovered account', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + addressType: BtcAccountType.P2wpkh, + scope: BtcScope.Regtest, + index: 0, + synchronize: true, + }, + }, + }); + + expect(response).toRespondWith({ + type: BtcAccountType.P2wpkh, + id: expect.anything(), + address: TEST_ADDRESS_REGTEST, + options: {}, + scopes: [BtcScope.Regtest], + methods: [BtcMethod.SendBitcoin], + }); + + // eslint-disable-next-line jest/no-conditional-in-test + if ('result' in response.response) { + accounts[TEST_ADDRESS_REGTEST] = response.response + .result as KeyringAccount; + } + }); + it.each([ { - // Main account used in the tests, only one to synchronize + // tests creation of multiple accounts of same address type and network addressType: BtcAccountType.P2wpkh, scope: BtcScope.Regtest, - index: 0, - expectedAddress: TEST_ADDRESS, - synchronize: true, + index: 1, // index incremented by 1 + expectedAddress: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', }, { addressType: BtcAccountType.P2wpkh, scope: BtcScope.Mainnet, index: 0, - expectedAddress: 'bc1q832zlt4tgnqy88vd20mazw77dlt0j0wf2naw8q', - synchronize: false, - }, - { - // Tests multiple accounts of same address type - addressType: BtcAccountType.P2wpkh, - scope: BtcScope.Mainnet, - index: 1, - expectedAddress: 'bc1qe2e3tdkqwytw7furyl2nlfy3sqs23acynn50d9', - synchronize: false, + expectedAddress: TEST_ADDRESS_MAINNET, }, { addressType: BtcAccountType.P2pkh, scope: BtcScope.Mainnet, index: 0, expectedAddress: '15feVv7kK3z7jxA4RZZzY7Fwdu3yqFwzcT', - synchronize: false, }, { addressType: BtcAccountType.P2pkh, scope: BtcScope.Testnet, index: 0, expectedAddress: 'mjPQaLkhZN3MxsYN8Nebzwevuz8vdTaRCq', - synchronize: false, }, { addressType: BtcAccountType.P2sh, scope: BtcScope.Mainnet, index: 0, expectedAddress: '3QVSaDYjxEh4L3K24eorrQjfVxPAKJMys2', - synchronize: false, }, { addressType: BtcAccountType.P2sh, scope: BtcScope.Testnet, index: 0, expectedAddress: '2NBG623WvXp1zxKB6gK2mnMe2mSDCur5qRU', - synchronize: false, }, { addressType: BtcAccountType.P2tr, @@ -100,7 +126,6 @@ describe('Keyring', () => { index: 0, expectedAddress: 'bc1p4rue37y0v9snd4z3fvw43d29u97qxf9j3fva72xy2t7hekg24dzsaz40mz', - synchronize: false, }, { addressType: BtcAccountType.P2tr, @@ -108,11 +133,8 @@ describe('Keyring', () => { index: 0, expectedAddress: 'tb1pwwjax3vpq6h69965hcr22vkpm4qdvyu2pz67wyj8eagp9vxkcz0q0ya20h', - synchronize: false, }, ])('creates an account: %s', async ({ expectedAddress, ...requestOpts }) => { - snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); - const response = await snap.onKeyringRequest({ origin: ORIGIN, method: 'keyring_createAccount', @@ -130,49 +152,40 @@ describe('Keyring', () => { // eslint-disable-next-line jest/no-conditional-in-test if ('result' in response.response) { - accounts[ - `${requestOpts.addressType}:${requestOpts.scope}:${requestOpts.index}` - ] = response.response.result as KeyringAccount; + accounts[expectedAddress] = response.response.result as KeyringAccount; } }); - it('creates account by derivationPath idempotently', async () => { - snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); - + it('returns the same account if already exists by derivationPath', async () => { + // Account already exists so we should get the same account const response = await snap.onKeyringRequest({ origin: ORIGIN, method: 'keyring_createAccount', params: { options: { - scope: BtcScope.Mainnet, + scope: BtcScope.Regtest, addressType: BtcAccountType.P2wpkh, - derivationPath: "m/84'/0'/1'", + derivationPath: "m/84'/1'/0'", }, }, }); - expect(response).toRespondWith( - accounts[`${BtcAccountType.P2wpkh}:${BtcScope.Mainnet}:1`], - ); + expect(response).toRespondWith(accounts[TEST_ADDRESS_REGTEST]); }); it('returns the same account if already exists', async () => { - snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); - const response = await snap.onKeyringRequest({ origin: ORIGIN, method: 'keyring_createAccount', params: { options: { - scope: BtcScope.Mainnet, + scope: BtcScope.Regtest, addressType: BtcAccountType.P2wpkh, }, }, }); - expect(response).toRespondWith( - accounts[`${BtcAccountType.P2wpkh}:${BtcScope.Mainnet}:0`], - ); + expect(response).toRespondWith(accounts[TEST_ADDRESS_REGTEST]); }); it('gets an account', async () => { @@ -180,13 +193,11 @@ describe('Keyring', () => { origin: ORIGIN, method: 'keyring_getAccount', params: { - id: accounts[`${BtcAccountType.P2wpkh}:${BtcScope.Mainnet}:0`]!.id, + id: accounts[TEST_ADDRESS_REGTEST]!.id, }, }); - expect(response).toRespondWith( - accounts[`${BtcAccountType.P2wpkh}:${BtcScope.Mainnet}:0`], - ); + expect(response).toRespondWith(accounts[TEST_ADDRESS_REGTEST]); }); it('lists all accounts', async () => { @@ -199,8 +210,7 @@ describe('Keyring', () => { }); it('lists account transactions', async () => { - const accoundId = - accounts[`${BtcAccountType.P2wpkh}:${BtcScope.Regtest}:0`]!.id; + const accoundId = accounts[TEST_ADDRESS_REGTEST]!.id; const response = await snap.onKeyringRequest({ origin: ORIGIN, method: 'keyring_listAccountTransactions', @@ -221,7 +231,7 @@ describe('Keyring', () => { origin: ORIGIN, method: 'keyring_getAccountBalances', params: { - id: accounts[`${BtcAccountType.P2wpkh}:${BtcScope.Regtest}:0`]!.id, + id: accounts[TEST_ADDRESS_REGTEST]!.id, assets: [Caip19Asset.Regtest], }, }); @@ -234,8 +244,29 @@ describe('Keyring', () => { }); }); + it.each([ + { + address: TEST_ADDRESS_REGTEST, + expectedAssets: [Caip19Asset.Regtest], + }, + { + address: TEST_ADDRESS_MAINNET, + expectedAssets: [Caip19Asset.Bitcoin], + }, + ])('lists account assets: %s', async ({ address, expectedAssets }) => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_listAccountAssets', + params: { + id: accounts[address]!.id, + }, + }); + + expect(response).toRespondWith(expectedAssets); + }); + it('removes an account', async () => { - const { id } = accounts[`${BtcAccountType.P2pkh}:${BtcScope.Mainnet}:0`]!; + const { id } = accounts[TEST_ADDRESS_REGTEST]!; let response = await snap.onKeyringRequest({ origin: ORIGIN, @@ -261,35 +292,4 @@ describe('Keyring', () => { stack: expect.anything(), }); }); - - it.each([ - { - addressType: BtcAccountType.P2wpkh, - scope: BtcScope.Mainnet, - expectedAssets: [Caip19Asset.Bitcoin], - }, - { - addressType: BtcAccountType.P2wpkh, - scope: BtcScope.Regtest, - expectedAssets: [Caip19Asset.Regtest], - }, - { - addressType: BtcAccountType.P2tr, - scope: BtcScope.Testnet, - expectedAssets: [Caip19Asset.Testnet], - }, - ])( - 'lists account assets: %s', - async ({ addressType, scope, expectedAssets }) => { - const response = await snap.onKeyringRequest({ - origin: ORIGIN, - method: 'keyring_listAccountAssets', - params: { - id: accounts[`${addressType}:${scope}:0`]!.id, - }, - }); - - expect(response).toRespondWith(expectedAssets); - }, - ); }); diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 68f131ab..1942a272 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "kNYjV3RNAFLK6diSj2lX+ZkfV58q0SiG7eawAIGQBAk=", + "shasum": "GuDKvdGqDLqnllsXbghkxnU3h4+Q3hZ/rCYbH5EJQNA=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 21c2c52b..31d3e69e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -7,13 +7,9 @@ import { LogLevel, type SnapConfig } from './entities'; export const Config: SnapConfig = { logLevel: (process.env.LOG_LEVEL ?? LogLevel.INFO) as LogLevel, encrypt: false, - accounts: { - defaultAddressType: (process.env.DEFAULT_ADDRESS_TYPE ?? - 'p2wpkh') as AddressType, - }, chain: { - parallelRequests: 3, - stopGap: 3, + parallelRequests: 1, + stopGap: 2, url: { bitcoin: process.env.ESPLORA_BITCOIN ?? 'https://blockstream.info/api', testnet: @@ -32,4 +28,6 @@ export const Config: SnapConfig = { url: process.env.PRICE_API_URL ?? 'https://price.api.cx.metamask.io', }, conversionsExpirationInterval: 60, + defaultAddressType: (process.env.DEFAULT_ADDRESS_TYPE ?? + 'p2wpkh') as AddressType, }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index 863206f8..770071db 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -47,11 +47,6 @@ export type BitcoinAccount = { */ network: Network; - /** - * Whether the account has already performed a full scan. - */ - isScanned: boolean; - /** * Get an address at a given index. * diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts index 86eac109..68a66c9e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -5,16 +5,12 @@ import type { LogLevel } from './logger'; export type SnapConfig = { logLevel: LogLevel; encrypt: boolean; - accounts: AccountsConfig; chain: ChainConfig; targetBlocksConfirmation: number; // Temporary global config to set the expected confirmation target, should eventually be a user setting fallbackFeeRate: number; ratesRefreshInterval: string; priceApi: PriceApiConfig; conversionsExpirationInterval: number; -}; - -export type AccountsConfig = { defaultAddressType: AddressType; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts index 3fbf6530..a8be47ea 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts @@ -65,7 +65,7 @@ export class AssetsHandler { }, ], iconUrl: networkToIcon[network], - symbol: '₿', + symbol: mainSymbol, }; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index a9c4b2be..be10d719 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -17,7 +17,7 @@ import { BtcAccountType, BtcMethod, BtcScope } from '@metamask/keyring-api'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; -import type { SnapClient, BitcoinAccount } from '../entities'; +import type { BitcoinAccount } from '../entities'; import { CurrencyUnit, Purpose } from '../entities'; import { scopeToNetwork, caipToAddressType, Caip19Asset } from './caip'; import { KeyringHandler, CreateAccountRequest } from './KeyringHandler'; @@ -44,7 +44,6 @@ jest.mock('@metamask/bitcoindevkit', () => { describe('KeyringHandler', () => { const mockAccounts = mock(); - const mockSnapClient = mock(); const mockAddress = mock
({ toString: () => 'bc1qaddress...', }); @@ -56,8 +55,9 @@ describe('KeyringHandler', () => { balance: { trusted_spendable: { to_btc: () => 1 } }, network: 'bitcoin', }); + const defaultAddressType: AddressType = 'p2wpkh'; - const handler = new KeyringHandler(mockAccounts, mockSnapClient); + const handler = new KeyringHandler(mockAccounts, defaultAddressType); beforeEach(() => { mockAccounts.create.mockResolvedValue(mockAccount); @@ -87,17 +87,15 @@ describe('KeyringHandler', () => { network: scopeToNetwork[BtcScope.Signet], entropySource, index, - addressType: caipToAddressType[BtcAccountType.P2pkh], + addressType: 'p2pkh', + synchronize: false, + correlationId, }; await handler.createAccount(options); expect(assert).toHaveBeenCalledWith(options, CreateAccountRequest); expect(mockAccounts.create).toHaveBeenCalledWith(expectedCreateParams); - expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalledWith( - mockAccount, - correlationId, - ); expect(mockAccounts.fullScan).not.toHaveBeenCalled(); }); @@ -110,6 +108,8 @@ describe('KeyringHandler', () => { network: 'signet', index: 5, addressType: 'p2pkh', + entropySource: 'm', + synchronize: false, }; await handler.createAccount(options); @@ -143,6 +143,8 @@ describe('KeyringHandler', () => { network: 'signet', index: 0, addressType, + entropySource: 'm', + synchronize: false, }; await handler.createAccount(options); @@ -173,17 +175,6 @@ describe('KeyringHandler', () => { ).rejects.toThrow("Invalid derivation path: m/44'"); }); - it('performs a full scan when synchronize option is true', async () => { - const options = { - scope: BtcScope.Signet, - synchronize: true, - }; - await handler.createAccount(options); - - expect(mockAccounts.create).toHaveBeenCalled(); - expect(mockAccounts.fullScan).toHaveBeenCalledWith(mockAccount); - }); - it('propagates errors from createAccount', async () => { const error = new Error('createAccount error'); mockAccounts.create.mockRejectedValue(error); @@ -193,29 +184,6 @@ describe('KeyringHandler', () => { ).rejects.toThrow(error); expect(mockAccounts.create).toHaveBeenCalled(); }); - - it('propagates errors from fullScan', async () => { - const error = new Error('fullScan error'); - mockAccounts.fullScan.mockRejectedValue(error); - - await expect( - handler.createAccount({ - scopes: [BtcScope.Mainnet], - synchronize: true, - }), - ).rejects.toThrow(error); - expect(mockAccounts.fullScan).toHaveBeenCalled(); - }); - - it('propagates errors from emitAccountCreatedEvent', async () => { - const error = new Error('emitAccountCreatedEvent error'); - mockSnapClient.emitAccountCreatedEvent.mockRejectedValue(error); - - await expect( - handler.createAccount({ scopes: [BtcScope.Mainnet] }), - ).rejects.toThrow(error); - expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalled(); - }); }); describe('discoverAccounts', () => { @@ -237,7 +205,7 @@ describe('KeyringHandler', () => { }); expected.push(mapToDiscoveredAccount(acc, groupIndex)); - mockAccounts.create.mockResolvedValueOnce(acc); + mockAccounts.discover.mockResolvedValueOnce(acc); }); }); @@ -247,19 +215,17 @@ describe('KeyringHandler', () => { groupIndex, ); - expect(mockAccounts.create).toHaveBeenCalledTimes(totalCombinations); - expect(mockAccounts.fullScan).toHaveBeenCalledTimes(totalCombinations); + expect(mockAccounts.discover).toHaveBeenCalledTimes(totalCombinations); // validate each individual create() call arguments scopes.forEach((scope, sIdx) => { addressTypes.forEach((addrType, aIdx) => { const callIdx = sIdx * addressTypes.length + aIdx; - expect(mockAccounts.create).toHaveBeenNthCalledWith(callIdx + 1, { + expect(mockAccounts.discover).toHaveBeenNthCalledWith(callIdx + 1, { network: scopeToNetwork[scope], entropySource, index: groupIndex, addressType: caipToAddressType[addrType], - synchronize: true, }); }); }); @@ -278,7 +244,7 @@ describe('KeyringHandler', () => { listTransactions: jest.fn().mockReturnValue([]), // no history }); - mockAccounts.create.mockResolvedValueOnce(acc); + mockAccounts.discover.mockResolvedValueOnce(acc); } const discovered = await handler.discoverAccounts( @@ -290,25 +256,14 @@ describe('KeyringHandler', () => { expect(discovered).toHaveLength(0); }); - it('propagates errors from create', async () => { - const error = new Error('create error'); - mockAccounts.create.mockRejectedValue(error); - - await expect( - handler.discoverAccounts(scopes, entropySource, groupIndex), - ).rejects.toThrow(error); - expect(mockAccounts.create).toHaveBeenCalled(); - }); - - it('propagates errors from fullScan()', async () => { - const error = new Error('fullScan error'); - mockAccounts.create.mockResolvedValue(mock()); - mockAccounts.fullScan.mockRejectedValue(error); + it('propagates errors from discover', async () => { + const error = new Error('discover error'); + mockAccounts.discover.mockRejectedValue(error); await expect( handler.discoverAccounts(scopes, entropySource, groupIndex), ).rejects.toThrow(error); - expect(mockAccounts.fullScan).toHaveBeenCalled(); + expect(mockAccounts.discover).toHaveBeenCalled(); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index a427b15c..248d2475 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -29,7 +29,6 @@ import { string, } from 'superstruct'; -import type { SnapClient } from '../entities'; import { networkToCurrencyUnit, Purpose, @@ -64,11 +63,11 @@ export const CreateAccountRequest = object({ export class KeyringHandler implements Keyring { readonly #accountsUseCases: AccountUseCases; - readonly #snapClient: SnapClient; + readonly #defaultAddressType: AddressType; - constructor(accounts: AccountUseCases, snapClient: SnapClient) { + constructor(accounts: AccountUseCases, defaultAddressType: AddressType) { this.#accountsUseCases = accounts; - this.#snapClient = snapClient; + this.#defaultAddressType = defaultAddressType; } async route(origin: string, request: JsonRpcRequest): Promise { @@ -98,11 +97,11 @@ export class KeyringHandler implements Keyring { const { metamask, scope, - entropySource, - index, + entropySource = 'm', + index = 0, derivationPath, addressType, - synchronize, + synchronize = false, } = options; const resolvedIndex = derivationPath @@ -116,23 +115,14 @@ export class KeyringHandler implements Keyring { resolvedAddressType = this.#extractAddressType(derivationPath); } - const createParams = { + const account = await this.#accountsUseCases.create({ network: scopeToNetwork[scope], entropySource, index: resolvedIndex, - addressType: resolvedAddressType, + addressType: resolvedAddressType ?? this.#defaultAddressType, + correlationId: metamask?.correlationId, synchronize, - }; - const account = await this.#accountsUseCases.create(createParams); - - await this.#snapClient.emitAccountCreatedEvent( - account, - metamask?.correlationId, - ); - - if (synchronize) { - await this.#accountsUseCases.fullScan(account); - } + }); return mapToKeyringAccount(account); } @@ -142,28 +132,20 @@ export class KeyringHandler implements Keyring { entropySource: string, groupIndex: number, ): Promise { - // Discovery is essentially the same as batch creation with synchronization, but without emitting events. - // Accounts are unique per network and address type. - const accounts = await Promise.all( scopes.flatMap((scope) => - Object.values(BtcAccountType).map(async (addressType) => { - const createParams = { + Object.values(BtcAccountType).map(async (addressType) => + this.#accountsUseCases.discover({ network: scopeToNetwork[scope], entropySource, index: groupIndex, addressType: caipToAddressType[addressType], - synchronize: true, - }; - - const account = await this.#accountsUseCases.create(createParams); - await this.#accountsUseCases.fullScan(account); - return account; - }), + }), + ), ), ); - // Return only accounts with history (even if balance is 0). + // Return only accounts with history. return accounts .filter((account) => account.listTransactions().length > 0) .map((account) => mapToDiscoveredAccount(account, groupIndex)); diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 5771b8cb..2de49d9b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -43,7 +43,6 @@ const accountsUseCases = new AccountUseCases( snapClient, accountRepository, chainClient, - Config.accounts, ); const sendFlowUseCases = new SendFlowUseCases( logger, @@ -59,7 +58,10 @@ const sendFlowUseCases = new SendFlowUseCases( const assetsUseCases = new AssetsUseCases(logger, assetRatesClient); // Application layer -const keyringHandler = new KeyringHandler(accountsUseCases, snapClient); +const keyringHandler = new KeyringHandler( + accountsUseCases, + Config.defaultAddressType, +); const cronHandler = new CronHandler(logger, accountsUseCases, sendFlowUseCases); const rpcHandler = new RpcHandler(sendFlowUseCases, accountsUseCases); const userInputHandler = new UserInputHandler(sendFlowUseCases); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index 1f084c9b..b44ca935 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -96,10 +96,6 @@ export class BdkAccountAdapter implements BitcoinAccount { return this.#wallet.network; } - get isScanned(): boolean { - return this.#wallet.latest_checkpoint.height > 0; - } - peekAddress(index: number): AddressInfo { return this.#wallet.peek_address('external', index); } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index dee01958..5cac921f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -9,7 +9,6 @@ import type { import { mock } from 'jest-mock-extended'; import type { - AccountsConfig, BitcoinAccount, BitcoinAccountRepository, BlockchainClient, @@ -18,6 +17,10 @@ import type { MetaProtocolsClient, SnapClient, } from '../entities'; +import type { + CreateAccountParams, + DiscoverAccountParams, +} from './AccountUseCases'; import { AccountUseCases } from './AccountUseCases'; describe('AccountUseCases', () => { @@ -26,16 +29,12 @@ describe('AccountUseCases', () => { const mockRepository = mock(); const mockChain = mock(); const mockMetaProtocols = mock(); - const accountsConfig: AccountsConfig = { - defaultAddressType: 'p2wpkh', - }; const useCases = new AccountUseCases( mockLogger, mockSnapClient, mockRepository, mockChain, - accountsConfig, mockMetaProtocols, ); @@ -98,12 +97,15 @@ describe('AccountUseCases', () => { }); describe('create', () => { - const network: Network = 'bitcoin'; - const addressType: AddressType = 'p2wpkh'; - const entropySource = 'some-source'; - const index = 1; - - const mockAccount = mock(); + const createParams: CreateAccountParams = { + network: 'bitcoin', + entropySource: 'some-source', + index: 1, + addressType: 'p2wpkh', + synchronize: false, + correlationId: 'correlation-id', + }; + const mockAccount = mock({ network: createParams.network }); beforeEach(() => { mockRepository.create.mockResolvedValue(mockAccount); @@ -118,13 +120,17 @@ describe('AccountUseCases', () => { ] as { tAddressType: AddressType; purpose: string }[])( 'creates an account of type: %s', async ({ tAddressType, purpose }) => { - const derivationPath = [entropySource, purpose, "0'", `${index}'`]; + const derivationPath = [ + createParams.entropySource, + purpose, + "0'", + `${createParams.index}'`, + ]; await useCases.create({ - network, - entropySource, - index, + ...createParams, addressType: tAddressType, + synchronize: true, }); expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( @@ -132,9 +138,15 @@ describe('AccountUseCases', () => { ); expect(mockRepository.create).toHaveBeenCalledWith( derivationPath, - network, + createParams.network, tAddressType, ); + expect(mockRepository.insert).toHaveBeenCalledWith(mockAccount); + expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalledWith( + mockAccount, + createParams.correlationId, + ); + expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); }, ); @@ -148,17 +160,16 @@ describe('AccountUseCases', () => { 'should create an account on network: %s', async ({ tNetwork, coinType }) => { const expectedDerivationPath = [ - entropySource, + createParams.entropySource, "84'", coinType, - `${index}'`, + `${createParams.index}'`, ]; await useCases.create({ + ...createParams, network: tNetwork, - entropySource, - index, - addressType, + synchronize: true, }); expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( @@ -167,76 +178,214 @@ describe('AccountUseCases', () => { expect(mockRepository.create).toHaveBeenCalledWith( expectedDerivationPath, tNetwork, - addressType, + createParams.addressType, + ); + expect(mockRepository.insert).toHaveBeenCalledWith(mockAccount); + expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalledWith( + mockAccount, + createParams.correlationId, ); + expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); }, ); - it('returns an existing account if one already exists', async () => { - const mockExistingAccount = mock(); - mockRepository.getByDerivationPath.mockResolvedValue(mockExistingAccount); + it('returns an existing account if one already exists on same network', async () => { + mockRepository.getByDerivationPath.mockResolvedValue(mockAccount); - const result = await useCases.create({ - network, - entropySource, - index, - addressType, - }); + const result = await useCases.create(createParams); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.create).not.toHaveBeenCalled(); - expect(result).toBe(mockExistingAccount); + expect(result).toBe(mockAccount); }); - it('creates a new account if one does not exist', async () => { - mockRepository.getByDerivationPath.mockResolvedValue(null); + it('propagates an error if getByDerivationPath throws', async () => { + const error = new Error('getByDerivationPath failed'); + mockRepository.getByDerivationPath.mockRejectedValue(error); - const result = await useCases.create({ - network, - entropySource, - index, - addressType, - }); + await expect(useCases.create(createParams)).rejects.toBe(error); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.create).not.toHaveBeenCalled(); + }); + + it('propagates an error if create throws', async () => { + const error = new Error('create failed'); + mockRepository.create.mockRejectedValue(error); + + await expect(useCases.create(createParams)).rejects.toBe(error); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.create).toHaveBeenCalled(); + }); + + it('propagates an error if insert throws', async () => { + const error = new Error('insert failed'); + mockRepository.insert.mockRejectedValue(error); + + await expect(useCases.create(createParams)).rejects.toBe(error); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.create).toHaveBeenCalled(); + expect(mockRepository.insert).toHaveBeenCalled(); + }); + + it('propagates an error if emitAccountCreatedEvent throws', async () => { + const error = new Error('emitAccountCreatedEvent failed'); + mockSnapClient.emitAccountCreatedEvent.mockRejectedValue(error); + + await expect(useCases.create(createParams)).rejects.toBe(error); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.create).toHaveBeenCalled(); + expect(mockRepository.insert).toHaveBeenCalled(); + expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalled(); + }); + + it('propagates an error if fullScan throws', async () => { + const error = new Error('fullScan failed'); + mockChain.fullScan.mockRejectedValue(error); + + await expect( + useCases.create({ ...createParams, synchronize: true }), + ).rejects.toBe(error); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.create).toHaveBeenCalled(); + expect(mockRepository.insert).toHaveBeenCalled(); + expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalled(); + expect(mockChain.fullScan).toHaveBeenCalled(); + }); + }); + + describe('discover', () => { + const discoverParams: DiscoverAccountParams = { + network: 'bitcoin', + entropySource: 'some-source', + index: 1, + addressType: 'p2wpkh', + }; + const mockAccount = mock({ + network: discoverParams.network, + }); + + beforeEach(() => { + mockRepository.create.mockResolvedValue(mockAccount); + }); + + it.each([ + { tAddressType: 'p2pkh', purpose: "44'" }, + { tAddressType: 'p2sh', purpose: "49'" }, + { tAddressType: 'p2wsh', purpose: "45'" }, + { tAddressType: 'p2wpkh', purpose: "84'" }, + { tAddressType: 'p2tr', purpose: "86'" }, + ] as { tAddressType: AddressType; purpose: string }[])( + 'discovers an account of type: %s', + async ({ tAddressType, purpose }) => { + const derivationPath = [ + discoverParams.entropySource, + purpose, + "0'", + `${discoverParams.index}'`, + ]; + + await useCases.discover({ + ...discoverParams, + addressType: tAddressType, + }); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( + derivationPath, + ); + expect(mockRepository.create).toHaveBeenCalledWith( + derivationPath, + discoverParams.network, + tAddressType, + ); + expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); + }, + ); + + it.each([ + { tNetwork: 'bitcoin', coinType: "0'" }, + { tNetwork: 'testnet', coinType: "1'" }, + { tNetwork: 'testnet4', coinType: "1'" }, + { tNetwork: 'signet', coinType: "1'" }, + { tNetwork: 'regtest', coinType: "1'" }, + ] as { tNetwork: Network; coinType: string }[])( + 'should discover an account on network: %s', + async ({ tNetwork, coinType }) => { + const expectedDerivationPath = [ + discoverParams.entropySource, + "84'", + coinType, + `${discoverParams.index}'`, + ]; + + await useCases.discover({ + ...discoverParams, + network: tNetwork, + }); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalledWith( + expectedDerivationPath, + ); + expect(mockRepository.create).toHaveBeenCalledWith( + expectedDerivationPath, + tNetwork, + discoverParams.addressType, + ); + expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); + }, + ); + + it('returns an existing account if one already exists on same network', async () => { + mockRepository.getByDerivationPath.mockResolvedValue(mockAccount); + + const result = await useCases.discover(discoverParams); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.create).not.toHaveBeenCalled(); expect(result).toBe(mockAccount); }); it('propagates an error if getByDerivationPath throws', async () => { - const error = new Error(); + const error = new Error('getByDerivationPath failed'); mockRepository.getByDerivationPath.mockRejectedValue(error); - await expect( - useCases.create({ network, entropySource, index, addressType }), - ).rejects.toBe(error); + await expect(useCases.discover(discoverParams)).rejects.toBe(error); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.create).not.toHaveBeenCalled(); - expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); }); - it('propagates an error if insert throws', async () => { - const error = new Error(); - mockRepository.getByDerivationPath.mockResolvedValue(null); + it('propagates an error if create throws', async () => { + const error = new Error('create failed'); mockRepository.create.mockRejectedValue(error); - await expect( - useCases.create({ network, entropySource, index, addressType }), - ).rejects.toBe(error); + await expect(useCases.discover(discoverParams)).rejects.toBe(error); expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.create).toHaveBeenCalled(); - expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); + }); + + it('propagates an error if fullScan throws', async () => { + const error = new Error('fullScan failed'); + mockChain.fullScan.mockRejectedValue(error); + + await expect(useCases.discover(discoverParams)).rejects.toBe(error); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.create).toHaveBeenCalled(); + expect(mockChain.fullScan).toHaveBeenCalled(); }); }); describe('synchronize', () => { const mockAccount = mock({ id: 'some-id', - isScanned: true, listTransactions: jest.fn(), }); @@ -244,15 +393,6 @@ describe('AccountUseCases', () => { mockAccount.listTransactions.mockReturnValue([]); }); - it('does not sync if account is not scanned', async () => { - mockAccount.listTransactions.mockReturnValue([]); - - await useCases.synchronize({ ...mockAccount, isScanned: false }); - - expect(mockChain.sync).not.toHaveBeenCalled(); - expect(mockAccount.listTransactions).not.toHaveBeenCalled(); - }); - it('synchronizes', async () => { mockAccount.listTransactions.mockReturnValue([]); @@ -346,7 +486,6 @@ describe('AccountUseCases', () => { mockSnapClient, mockRepository, mockChain, - accountsConfig, undefined, ); const mockTransaction = mock(); @@ -363,7 +502,6 @@ describe('AccountUseCases', () => { describe('fullScan', () => { const mockAccount = mock({ id: 'some-id', - isScanned: false, }); const mockInscriptions = mock(); const mockTransactions = mock(); @@ -423,18 +561,16 @@ describe('AccountUseCases', () => { expect(mockRepository.update).toHaveBeenCalled(); }); - it('does not scans for assets if utxo protection is disabled', async () => { + it('does not scan for assets if utxo protection is disabled', async () => { const testUseCases = new AccountUseCases( mockLogger, mockSnapClient, mockRepository, mockChain, - accountsConfig, undefined, ); - mockAccount.listTransactions.mockReturnValue(mockTransactions); - await testUseCases.synchronize(mockAccount); + await testUseCases.fullScan(mockAccount); expect(mockMetaProtocols.fetchInscriptions).not.toHaveBeenCalled(); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 6f6234ea..2dafdceb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -8,7 +8,6 @@ import type { import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; import { - type AccountsConfig, type BitcoinAccount, type BitcoinAccountRepository, type BlockchainClient, @@ -19,11 +18,16 @@ import { networkToCoinType, } from '../entities'; -export type CreateAccountParams = { +export type DiscoverAccountParams = { network: Network; - index?: number; - entropySource?: string; - addressType?: AddressType; + index: number; + entropySource: string; + addressType: AddressType; +}; + +export type CreateAccountParams = DiscoverAccountParams & { + synchronize: boolean; + correlationId?: string; }; export class AccountUseCases { @@ -37,14 +41,11 @@ export class AccountUseCases { readonly #metaProtocols: MetaProtocolsClient | undefined; - readonly #accountConfig: AccountsConfig; - constructor( logger: Logger, snapClient: SnapClient, repository: BitcoinAccountRepository, chain: BlockchainClient, - accountConfig: AccountsConfig, metaProtocols?: MetaProtocolsClient, ) { this.#logger = logger; @@ -52,7 +53,6 @@ export class AccountUseCases { this.#repository = repository; this.#chain = chain; this.#metaProtocols = metaProtocols; - this.#accountConfig = accountConfig; } async list(): Promise { @@ -76,14 +76,44 @@ export class AccountUseCases { return account; } - async create(req: CreateAccountParams): Promise { - this.#logger.debug('Creating new Bitcoin account. Params: %o', req); - const { - addressType = this.#accountConfig.defaultAddressType, - index = 0, + async discover(req: DiscoverAccountParams): Promise { + this.#logger.debug('Discovering Bitcoin account. Request: %o', req); + + const { addressType, index, network, entropySource } = req; + + const derivationPath = [ + entropySource, + `${addressTypeToPurpose[addressType]}'`, + `${networkToCoinType[network]}'`, + `${index}'`, + ]; + + // Idempotent account creation + ensures only one account per derivation path + const account = await this.#repository.getByDerivationPath(derivationPath); + if (account && account.network === network) { + this.#logger.debug('Account already exists: %s,', account.id); + return account; + } + + const newAccount = await this.#repository.create( + derivationPath, network, - entropySource = 'm', - } = req; + addressType, + ); + + await this.#chain.fullScan(newAccount); + + this.#logger.info( + 'Bitcoin account discovered successfully. Request: %o', + req, + ); + return newAccount; + } + + async create(req: CreateAccountParams): Promise { + this.#logger.debug('Creating new Bitcoin account. Request: %o', req); + + const { addressType, index, network, entropySource } = req; const derivationPath = [ entropySource, @@ -94,7 +124,7 @@ export class AccountUseCases { // Idempotent account creation + ensures only one account per derivation path const account = await this.#repository.getByDerivationPath(derivationPath); - if (account) { + if (account && account.network === network) { this.#logger.debug('Account already exists: %s,', account.id); return account; } @@ -104,12 +134,23 @@ export class AccountUseCases { network, addressType, ); + await this.#repository.insert(newAccount); + // First notify the event has been created, then full scan. + await this.#snapClient.emitAccountCreatedEvent( + newAccount, + req.correlationId, + ); + + if (req.synchronize) { + await this.fullScan(newAccount); + } + this.#logger.info( - 'Bitcoin account created successfully: %s. derivationPath: %s', + 'Bitcoin account created successfully: %s. Request: %o', newAccount.id, - derivationPath.join('/'), + req, ); return newAccount; } @@ -117,14 +158,6 @@ export class AccountUseCases { async synchronize(account: BitcoinAccount): Promise { this.#logger.debug('Synchronizing account: %s', account.id); - if (!account.isScanned) { - this.#logger.debug( - 'Account has not yet performed initial full scan, skipping synchronization: %s', - account.id, - ); - return; - } - const txsBeforeSync = account.listTransactions(); await this.#chain.sync(account); const txsAfterSync = account.listTransactions(); @@ -181,7 +214,7 @@ export class AccountUseCases { account.listTransactions(), ); - this.#logger.debug( + this.#logger.info( 'initial full scan performed successfully: %s', account.id, ); From 02a83068a0a962724cebbbabe43b17d631ff3451 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 27 May 2025 19:26:18 +0200 Subject: [PATCH 235/362] 0.14.0 (#465) * 0.14.0 * changelog --------- Co-authored-by: github-actions Co-authored-by: Dario Anongba Varela --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 15 ++++++++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 2c534e50..bb0159ae 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.14.0] + +### Added + +- Discover accounts ([#464](https://github.com/MetaMask/snap-bitcoin-wallet/pull/464), [#460](https://github.com/MetaMask/snap-bitcoin-wallet/pull/460)) +- Use address types from `keyring-api` ([#461](https://github.com/MetaMask/snap-bitcoin-wallet/pull/461)) + +### Changed + +- Development environment cleanup ([#459](https://github.com/MetaMask/snap-bitcoin-wallet/pull/459)) +- Use `setState` and `getState` instead of `manageState` ([#463](https://github.com/MetaMask/snap-bitcoin-wallet/pull/463)) + ## [0.13.0] ### Added @@ -330,7 +342,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.13.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.0...HEAD +[0.14.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.13.0...v0.14.0 [0.13.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.1...v0.13.0 [0.12.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.0...v0.12.1 [0.12.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.11.0...v0.12.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 4fbc5087..973c5421 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.13.0", + "version": "0.14.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 3a8bd9abe48c863eff268c3d80580a8c7ed70b96 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 2 Jun 2025 19:37:55 +0200 Subject: [PATCH 236/362] fix: account name passthrough (#466) --- .../integration-test/keyring.test.ts | 3 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../bitcoin-wallet-snap/src/entities/snap.ts | 12 ++---- .../src/handlers/KeyringHandler.test.ts | 2 + .../src/handlers/KeyringHandler.ts | 2 + .../src/handlers/mappings.ts | 17 --------- .../src/infra/SnapClientAdapter.ts | 16 ++------ .../src/store/BdkAccountRepository.test.ts | 38 +++++++++++++------ .../src/store/BdkAccountRepository.ts | 16 +++----- .../src/use-cases/AccountUseCases.test.ts | 3 ++ .../src/use-cases/AccountUseCases.ts | 22 +++++++++-- 11 files changed, 67 insertions(+), 68 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index e149fa93..59e4352c 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -58,9 +58,8 @@ describe('Keyring', () => { method: 'keyring_createAccount', params: { options: { - addressType: BtcAccountType.P2wpkh, + derivationPath: "m/84'/1'/0'", scope: BtcScope.Regtest, - index: 0, synchronize: true, }, }, diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 1942a272..20d8d110 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.13.0", + "version": "0.14.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "GuDKvdGqDLqnllsXbghkxnU3h4+Q3hZ/rCYbH5EJQNA=", + "shasum": "H93YoL/XXjtD2fp8f6mWTonk3TY0WMO2R//7i7jge8s=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index a06e2adf..2c78da2a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -32,7 +32,7 @@ export type SnapClient = { /** * Get the Snap state for a given key. * - * @param key - The key to get the state for. + * @param key - The key to get the state for. Empty for the root. * @returns The Snap state. */ getState(key: string): Promise; @@ -40,18 +40,11 @@ export type SnapClient = { /** * Set the Snap state for a given key. * - * @param key - The key to set the state for. + * @param key - The key to set the state for. Empty for the root. * @param newState - The new state. */ setState(key: string, newState: Json | null): Promise; - /** - * Set the Snap state for a given key to null, effectively removing it. - * - * @param key - The key to set the state for. - */ - removeState(key: string): Promise; - /** * Get the private SLIP10 for a given derivation path from the Snap SRP. * @@ -77,6 +70,7 @@ export type SnapClient = { emitAccountCreatedEvent( account: BitcoinAccount, correlationId?: string, + accountName?: string, ): Promise; /** diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index be10d719..89b4d4d5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -82,6 +82,7 @@ describe('KeyringHandler', () => { metamask: { correlationId, }, + accountNameSuggestion: 'My account', }; const expectedCreateParams: CreateAccountParams = { network: scopeToNetwork[BtcScope.Signet], @@ -90,6 +91,7 @@ describe('KeyringHandler', () => { addressType: 'p2pkh', synchronize: false, correlationId, + accountName: 'My account', }; await handler.createAccount(options); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 248d2475..8ceacf12 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -102,6 +102,7 @@ export class KeyringHandler implements Keyring { derivationPath, addressType, synchronize = false, + accountNameSuggestion, } = options; const resolvedIndex = derivationPath @@ -122,6 +123,7 @@ export class KeyringHandler implements Keyring { addressType: resolvedAddressType ?? this.#defaultAddressType, correlationId: metamask?.correlationId, synchronize, + accountName: accountNameSuggestion, }); return mapToKeyringAccount(account); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index c0cc4685..90f7dd4b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -1,6 +1,5 @@ import { Address } from '@metamask/bitcoindevkit'; import type { - AddressType, Amount, Network, TxOut, @@ -44,22 +43,6 @@ type TransactionEvent = { timestamp: number | null; }; -export const addressTypeToName: Record = { - p2pkh: 'Legacy', - p2sh: 'Nested SegWit', - p2wpkh: 'Native SegWit', - p2tr: 'Taproot', - p2wsh: 'Multisig', -}; - -export const networkToName: Record = { - bitcoin: 'Bitcoin', - testnet: 'Bitcoin Testnet', - testnet4: 'Bitcoin Testnet4', - signet: 'Bitcoin Signet', - regtest: 'Bitcoin Regtest', -}; - /** * Maps a Bitcoin Account to a Keyring Account. * diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 9aae50cb..99ccac76 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -14,12 +14,7 @@ import type { import type { BitcoinAccount, SnapClient } from '../entities'; import { networkToCurrencyUnit } from '../entities'; import { networkToCaip19 } from '../handlers'; -import { - mapToKeyringAccount, - mapToTransaction, - addressTypeToName, - networkToName, -} from '../handlers/mappings'; +import { mapToKeyringAccount, mapToTransaction } from '../handlers/mappings'; export class SnapClientAdapter implements SnapClient { readonly #encrypt: boolean; @@ -49,10 +44,6 @@ export class SnapClientAdapter implements SnapClient { }); } - async removeState(key: string): Promise { - await this.setState(key, null); - } - async getPrivateEntropy(derivationPath: string[]): Promise { const source = derivationPath[0] === 'm' ? undefined : derivationPath[0]; const path = ['m', ...derivationPath.slice(1)]; @@ -75,12 +66,11 @@ export class SnapClientAdapter implements SnapClient { async emitAccountCreatedEvent( account: BitcoinAccount, correlationId?: string, + accountName?: string, ): Promise { return emitSnapKeyringEvent(snap, KeyringEvent.AccountCreated, { account: mapToKeyringAccount(account), - accountNameSuggestion: `${networkToName[account.network]} ${ - addressTypeToName[account.addressType] - }`, + accountNameSuggestion: accountName, displayConfirmation: false, displayAccountNameSuggestion: false, ...(correlationId ? { metamask: { correlationId } } : {}), diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts index 5350810b..f8530d81 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts @@ -15,6 +15,7 @@ import type { BitcoinAccount, Inscription, SnapClient, + SnapState, } from '../entities'; import { BdkAccountAdapter } from '../infra'; import { BdkAccountRepository } from './BdkAccountRepository'; @@ -120,8 +121,8 @@ describe('BdkAccountRepository', () => { const id1 = 'some-id-1'; const id2 = 'some-id-2'; const state = { - id1: { ...mockAccountState, id: id1 }, - id2: { ...mockAccountState, id: id2 }, + [id1]: { ...mockAccountState, id: id1 }, + [id2]: { ...mockAccountState, id: id2 }, }; const mockAccount1 = { ...mockAccount, id: id1 }; const mockAccount2 = { ...mockAccount, id: id2 }; @@ -281,21 +282,34 @@ describe('BdkAccountRepository', () => { await repo.delete('non-existent-id'); - expect(mockSnapClient.removeState).not.toHaveBeenCalled(); + expect(mockSnapClient.setState).not.toHaveBeenCalled(); }); it('removes wallet data from store', async () => { - mockSnapClient.getState.mockResolvedValue(mockAccountState); + const mockState: SnapState = { + accounts: { + 'some-id-1': { ...mockAccountState, derivationPath: ["m/84'/0'/0'"] }, + 'some-id-2': { ...mockAccountState, derivationPath: ["m/86'/0'/0'"] }, + }, + derivationPaths: { + "m/84'/0'/0'": 'some-id-1', + "m/86'/0'/0'": 'some-id-2', + }, + }; + const expectedState = { + accounts: { + 'some-id-2': { ...mockAccountState, derivationPath: ["m/86'/0'/0'"] }, + }, + derivationPaths: { + "m/86'/0'/0'": 'some-id-2', + }, + }; - await repo.delete('some-id'); + mockSnapClient.getState.mockResolvedValue(mockState); - expect(mockSnapClient.removeState).toHaveBeenNthCalledWith( - 1, - 'accounts.some-id', - ); - expect(mockSnapClient.removeState).toHaveBeenLastCalledWith( - "derivationPaths.m/84'/0'/0'", - ); + await repo.delete('some-id-1'); + + expect(mockSnapClient.setState).toHaveBeenCalledWith('', expectedState); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts index 61eced3a..74a849df 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts @@ -190,19 +190,15 @@ export class BdkAccountRepository implements BitcoinAccountRepository { } async delete(id: string): Promise { - const accountState = (await this.#snapClient.getState( - `accounts.${id}`, - )) as AccountState | null; - if (!accountState) { + const state = (await this.#snapClient.getState('')) as SnapState | null; + if (!state?.accounts[id]) { return; } - await Promise.all([ - this.#snapClient.removeState(`accounts.${id}`), - this.#snapClient.removeState( - `derivationPaths.${accountState.derivationPath.join('/')}`, - ), - ]); + delete state.derivationPaths[state.accounts[id].derivationPath.join('/')]; + delete state.accounts[id]; + + await this.#snapClient.setState('', state); } async fetchInscriptions(id: string): Promise { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 5cac921f..e9f36a5f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -104,6 +104,7 @@ describe('AccountUseCases', () => { addressType: 'p2wpkh', synchronize: false, correlationId: 'correlation-id', + accountName: 'My account', }; const mockAccount = mock({ network: createParams.network }); @@ -145,6 +146,7 @@ describe('AccountUseCases', () => { expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalledWith( mockAccount, createParams.correlationId, + createParams.accountName, ); expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); }, @@ -184,6 +186,7 @@ describe('AccountUseCases', () => { expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalledWith( mockAccount, createParams.correlationId, + createParams.accountName, ); expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); }, diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 2dafdceb..32c2b37a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -28,6 +28,7 @@ export type DiscoverAccountParams = { export type CreateAccountParams = DiscoverAccountParams & { synchronize: boolean; correlationId?: string; + accountName?: string; }; export class AccountUseCases { @@ -113,7 +114,15 @@ export class AccountUseCases { async create(req: CreateAccountParams): Promise { this.#logger.debug('Creating new Bitcoin account. Request: %o', req); - const { addressType, index, network, entropySource } = req; + const { + addressType, + index, + network, + entropySource, + correlationId, + accountName, + synchronize, + } = req; const derivationPath = [ entropySource, @@ -126,6 +135,12 @@ export class AccountUseCases { const account = await this.#repository.getByDerivationPath(derivationPath); if (account && account.network === network) { this.#logger.debug('Account already exists: %s,', account.id); + await this.#snapClient.emitAccountCreatedEvent( + account, + correlationId, + accountName, + ); + return account; } @@ -140,10 +155,11 @@ export class AccountUseCases { // First notify the event has been created, then full scan. await this.#snapClient.emitAccountCreatedEvent( newAccount, - req.correlationId, + correlationId, + accountName, ); - if (req.synchronize) { + if (synchronize) { await this.fullScan(newAccount); } From a8cec6fac5cee4561a074046031d3f42347c6bb9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 2 Jun 2025 20:53:30 +0200 Subject: [PATCH 237/362] 0.14.1 (#467) * 0.14.1 * changelog --------- Co-authored-by: github-actions Co-authored-by: Dario Anongba Varela --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 10 +++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index bb0159ae..263f9821 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.14.1] + +### Fixed + +- Account name passthrough ([#466](https://github.com/MetaMask/snap-bitcoin-wallet/pull/466)) +- Prevent account deletion key residues ([#466](https://github.com/MetaMask/snap-bitcoin-wallet/pull/466)) + ## [0.14.0] ### Added @@ -342,7 +349,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.1...HEAD +[0.14.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.0...v0.14.1 [0.14.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.13.0...v0.14.0 [0.13.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.1...v0.13.0 [0.12.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.0...v0.12.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 973c5421..045a522e 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.14.0", + "version": "0.14.1", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 20d8d110..88d6c824 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.14.0", + "version": "0.14.1", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "H93YoL/XXjtD2fp8f6mWTonk3TY0WMO2R//7i7jge8s=", + "shasum": "+rs39uDzPVVnXF7QDU369BovFdQnGhbvSekIHdirYD8=", "location": { "npm": { "filePath": "dist/bundle.js", From 6fe8ff844287c18bff45af145dac6d6fc2a09f10 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 3 Jun 2025 16:56:38 +0200 Subject: [PATCH 238/362] fix: delete account (#469) --- .../integration-test/run-integration.sh | 12 +++++++++--- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/entities/snap.ts | 8 ++++---- .../src/infra/SnapClientAdapter.ts | 4 ++-- .../src/store/BdkAccountRepository.test.ts | 5 ++++- .../src/store/BdkAccountRepository.ts | 4 ++-- 6 files changed, 22 insertions(+), 13 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh b/merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh index 46e018c6..1fcd07f3 100755 --- a/merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh +++ b/merged-packages/bitcoin-wallet-snap/integration-test/run-integration.sh @@ -2,6 +2,12 @@ set -e +cleanup() { + echo "Stopping Docker services..." + docker-compose -f integration-test/docker-compose.yml down +} +trap cleanup EXIT + echo "Starting Docker services..." docker-compose -f integration-test/docker-compose.yml up -d @@ -25,6 +31,6 @@ docker exec esplora bash /init-esplora.sh echo "Running integration tests..." set +e jest --config jest.integration.config.js - -echo "Stopping Docker services..." -docker-compose -f integration-test/docker-compose.yml down +TEST_EXIT_CODE=$? +set -e +exit $TEST_EXIT_CODE diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 88d6c824..b40c74ad 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "+rs39uDzPVVnXF7QDU369BovFdQnGhbvSekIHdirYD8=", + "shasum": "RQHdHEnbYoclZgGjvNdaFXlrMnPrkKMXwZ2nuMYPPU0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index 2c78da2a..2960db9d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -32,18 +32,18 @@ export type SnapClient = { /** * Get the Snap state for a given key. * - * @param key - The key to get the state for. Empty for the root. + * @param key - The key to get the state for. Undefined for the root. * @returns The Snap state. */ - getState(key: string): Promise; + getState(key?: string): Promise; /** * Set the Snap state for a given key. * - * @param key - The key to set the state for. Empty for the root. + * @param key - The key to set the state for. Undefined for the root. * @param newState - The new state. */ - setState(key: string, newState: Json | null): Promise; + setState(key?: string, newState?: Json): Promise; /** * Get the private SLIP10 for a given derivation path from the Snap SRP. diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 99ccac76..8f2724c4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -23,7 +23,7 @@ export class SnapClientAdapter implements SnapClient { this.#encrypt = encrypt; } - async getState(key: string): Promise { + async getState(key?: string): Promise { return snap.request({ method: 'snap_getState', params: { @@ -33,7 +33,7 @@ export class SnapClientAdapter implements SnapClient { }); } - async setState(key: string, newState: Json | null): Promise { + async setState(key?: string, newState: Json = {}): Promise { await snap.request({ method: 'snap_setState', params: { diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts index f8530d81..f61421d7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts @@ -309,7 +309,10 @@ describe('BdkAccountRepository', () => { await repo.delete('some-id-1'); - expect(mockSnapClient.setState).toHaveBeenCalledWith('', expectedState); + expect(mockSnapClient.setState).toHaveBeenCalledWith( + undefined, + expectedState, + ); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts index 74a849df..d0eeeaa8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts @@ -190,7 +190,7 @@ export class BdkAccountRepository implements BitcoinAccountRepository { } async delete(id: string): Promise { - const state = (await this.#snapClient.getState('')) as SnapState | null; + const state = (await this.#snapClient.getState()) as SnapState | null; if (!state?.accounts[id]) { return; } @@ -198,7 +198,7 @@ export class BdkAccountRepository implements BitcoinAccountRepository { delete state.derivationPaths[state.accounts[id].derivationPath.join('/')]; delete state.accounts[id]; - await this.#snapClient.setState('', state); + await this.#snapClient.setState(undefined, state); } async fetchInscriptions(id: string): Promise { From be6174ac94674475c73a283483d06007c6681780 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Fri, 6 Jun 2025 13:17:43 +0200 Subject: [PATCH 239/362] fix: entropy source in options (#473) --- .../bitcoin-wallet-snap/integration-test/keyring.test.ts | 8 ++++++-- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/KeyringHandler.test.ts | 9 +++++++-- .../bitcoin-wallet-snap/src/handlers/mappings.ts | 4 +++- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index 59e4352c..4cfc3b9b 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -69,7 +69,9 @@ describe('Keyring', () => { type: BtcAccountType.P2wpkh, id: expect.anything(), address: TEST_ADDRESS_REGTEST, - options: {}, + options: { + entropySource: 'm', + }, scopes: [BtcScope.Regtest], methods: [BtcMethod.SendBitcoin], }); @@ -144,7 +146,9 @@ describe('Keyring', () => { type: requestOpts.addressType, id: expect.anything(), address: expectedAddress, - options: {}, + options: { + entropySource: 'm', + }, scopes: [requestOpts.scope], methods: [BtcMethod.SendBitcoin], }); diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index b40c74ad..e01e8ca3 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "RQHdHEnbYoclZgGjvNdaFXlrMnPrkKMXwZ2nuMYPPU0=", + "shasum": "a4uu803ic5YoTP3LLoFSgwmrEtC8kjh/ovXBdhQuGCY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 89b4d4d5..11fa7865 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -54,6 +54,7 @@ describe('KeyringHandler', () => { addressType: 'p2wpkh', balance: { trusted_spendable: { to_btc: () => 1 } }, network: 'bitcoin', + derivationPath: ['myEntropy', "84'", "0'", "0'"], }); const defaultAddressType: AddressType = 'p2wpkh'; @@ -303,7 +304,9 @@ describe('KeyringHandler', () => { type: BtcAccountType.P2wpkh, scopes: [BtcScope.Mainnet], address: 'bc1qaddress...', - options: {}, + options: { + entropySource: 'myEntropy', + }, methods: [BtcMethod.SendBitcoin], }; @@ -330,7 +333,9 @@ describe('KeyringHandler', () => { type: BtcAccountType.P2wpkh, scopes: [BtcScope.Mainnet], address: 'bc1qaddress...', - options: {}, + options: { + entropySource: 'myEntropy', + }, methods: [BtcMethod.SendBitcoin], }, ]; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index 90f7dd4b..8d4622a0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -55,7 +55,9 @@ export function mapToKeyringAccount(account: BitcoinAccount): KeyringAccount { scopes: [networkToScope[account.network]], id: account.id, address: account.peekAddress(0).address.toString(), - options: {}, + options: { + entropySource: account.derivationPath[0] ?? null, + }, methods: [BtcMethod.SendBitcoin], }; } From 275a273a230c71a26e6963f3f301a46c2e8ddb4f Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 10 Jun 2025 18:05:18 +0200 Subject: [PATCH 240/362] refactor: improve send flow (#472) --- .../integration-test/send-flow.test.ts | 10 +- .../bitcoin-wallet-snap/locales/en.json | 44 +++----- .../bitcoin-wallet-snap/messages.json | 46 +++----- .../bitcoin-wallet-snap/package.json | 12 +-- .../bitcoin-wallet-snap/snap.config.ts | 5 + .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../bitcoin-wallet-snap/src/config.ts | 8 ++ .../bitcoin-wallet-snap/src/entities/chain.ts | 8 ++ .../src/entities/config.ts | 3 + .../src/entities/send-flow.ts | 6 +- .../src/infra/EsploraClientAdapter.ts | 4 + .../src/infra/jsx/ReviewTransactionView.tsx | 59 ++++++----- .../src/infra/jsx/SendFormView.tsx | 32 +++--- .../jsx/components/HeadingWithReturn.tsx | 11 +- .../src/infra/jsx/components/SendForm.tsx | 100 ++++++++++++++---- .../jsx/components/TransactionSummary.tsx | 58 ---------- .../src/infra/jsx/components/index.ts | 1 - .../src/infra/jsx/format.ts | 3 + .../src/infra/jsx/images/bitcoin.svg | 13 ++- .../src/infra/jsx/images/empty-space.svg | 3 - .../src/infra/jsx/images/signet.svg | 13 ++- .../src/infra/jsx/images/testnet.svg | 13 ++- .../src/use-cases/SendFlowUseCases.test.ts | 9 +- .../src/use-cases/SendFlowUseCases.ts | 28 ++++- 24 files changed, 277 insertions(+), 216 deletions(-) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx delete mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/empty-space.svg diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts index e0d0245f..981cf35d 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts @@ -12,7 +12,9 @@ import { import { Caip19Asset } from '../src/handlers/caip'; import { CronMethod } from '../src/handlers/CronHandler'; -describe('Send flow', () => { +// TODO: Unskip when the snaps-jest package adds support for AccountSelector and AssetSelector +// eslint-disable-next-line jest/no-disabled-tests +describe.skip('Send flow', () => { const recipient = 'bcrt1qyvhf2epk9s659206lq3rdvtf07uq3t9e7xtjje'; const sendAmount = '0.1'; @@ -64,9 +66,11 @@ describe('Send flow', () => { let ui = await response.getInterface(); assertIsCustomDialog(ui); - // Perform user interactions. - await ui.clickElement(SendFormEvent.SetMax); + // First the recipient so the view is updated and the other fields become available. await ui.typeInField(SendFormEvent.Recipient, recipient); + ui = await response.getInterface(); + + await ui.clickElement(SendFormEvent.Max); await ui.typeInField(SendFormEvent.Amount, sendAmount); const backgroundEventResponse = await snap.onBackgroundEvent({ diff --git a/merged-packages/bitcoin-wallet-snap/locales/en.json b/merged-packages/bitcoin-wallet-snap/locales/en.json index 7b93a3c9..027f405d 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/en.json +++ b/merged-packages/bitcoin-wallet-snap/locales/en.json @@ -1,33 +1,24 @@ { "locale": "en", "messages": { - "snapDescription": { - "message": "Manage Bitcoin using MetaMask" - }, - "snapProposedName": { - "message": "Bitcoin" - }, "reviewTransactionWarning": { "message": "Review the transaction before proceeding" }, - "loading": { - "message": "Loading" - }, "from": { "message": "From" }, "toAddress": { - "message": "To Address" + "message": "To" }, - "fromAccount": { - "message": "From Account" - }, - "review": { - "message": "Review" + "continue": { + "message": "Continue" }, "cancel": { "message": "Cancel" }, + "clear": { + "message": "Clear" + }, "amount": { "message": "Amount" }, @@ -44,13 +35,13 @@ "message": "min" }, "transactionSpeed": { - "message": "Transaction Speed" + "message": "Transaction speed" }, "transactionSpeedTooltip": { "message": "The estimated time of the transaction" }, "networkFee": { - "message": "Network Fee" + "message": "Network fee" }, "feeRate": { "message": "Fee rate" @@ -64,32 +55,23 @@ "send": { "message": "Send" }, + "asset": { + "message": "Asset" + }, "sending": { "message": "Sending" }, - "sendAmount": { - "message": "Send Amount" - }, - "amountPlaceholder": { - "message": "Enter amount to send" - }, "max": { "message": "Max" }, "recipientPlaceholder": { "message": "Enter receiving address" }, - "validAddress": { - "message": "Valid bitcoin address" - }, - "preparingTransaction": { - "message": "Preparing transaction" + "review": { + "message": "Review" }, "error": { "message": "Error" - }, - "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." } } } diff --git a/merged-packages/bitcoin-wallet-snap/messages.json b/merged-packages/bitcoin-wallet-snap/messages.json index b3b9b2b1..d5aa4f00 100644 --- a/merged-packages/bitcoin-wallet-snap/messages.json +++ b/merged-packages/bitcoin-wallet-snap/messages.json @@ -1,33 +1,22 @@ { - "snapDescription": { - "message": "Manage Bitcoin using MetaMask", - "description": "This is the description located in the snap manifest." - }, - "snapProposedName": { - "message": "Bitcoin", - "description": "This is the proposed name in the snap manifest." - }, "reviewTransactionWarning": { "message": "Review the transaction before proceeding" }, - "loading": { - "message": "Loading" - }, "from": { "message": "From" }, "toAddress": { - "message": "To Address" + "message": "To" }, - "fromAccount": { - "message": "From Account" - }, - "review": { - "message": "Review" + "continue": { + "message": "Continue" }, "cancel": { "message": "Cancel" }, + "clear": { + "message": "Clear" + }, "amount": { "message": "Amount" }, @@ -44,13 +33,13 @@ "message": "min" }, "transactionSpeed": { - "message": "Transaction Speed" + "message": "Transaction speed" }, "transactionSpeedTooltip": { "message": "The estimated time of the transaction" }, "networkFee": { - "message": "Network Fee" + "message": "Network fee" }, "feeRate": { "message": "Fee rate" @@ -64,31 +53,22 @@ "send": { "message": "Send" }, + "asset": { + "message": "Asset" + }, "sending": { "message": "Sending" }, - "sendAmount": { - "message": "Send Amount" - }, - "amountPlaceholder": { - "message": "Enter amount to send" - }, "max": { "message": "Max" }, "recipientPlaceholder": { "message": "Enter receiving address" }, - "validAddress": { - "message": "Valid bitcoin address" - }, - "preparingTransaction": { - "message": "Preparing transaction" + "review": { + "message": "Review" }, "error": { "message": "Error" - }, - "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." } } diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 045a522e..85891dfe 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -38,12 +38,12 @@ "@jest/globals": "^29.7.0", "@metamask/bitcoindevkit": "^0.1.9", "@metamask/key-tree": "^10.1.1", - "@metamask/keyring-api": "^17.6.0", - "@metamask/keyring-snap-sdk": "^3.2.0", - "@metamask/slip44": "^4.1.0", - "@metamask/snaps-cli": "^7.1.0", - "@metamask/snaps-jest": "^8.15.0", - "@metamask/snaps-sdk": "^6.23.0", + "@metamask/keyring-api": "^18.0.0", + "@metamask/keyring-snap-sdk": "^4.0.0", + "@metamask/slip44": "^4.2.0", + "@metamask/snaps-cli": "^7.2.0", + "@metamask/snaps-jest": "^8.16.0", + "@metamask/snaps-sdk": "^7.1.0", "@metamask/utils": "^11.4.0", "concurrently": "^9.1.2", "dotenv": "^16.5.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.config.ts b/merged-packages/bitcoin-wallet-snap/snap.config.ts index 8cfca7e8..5fd9a71e 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.config.ts +++ b/merged-packages/bitcoin-wallet-snap/snap.config.ts @@ -18,6 +18,11 @@ const config: SnapConfig = { ESPLORA_SIGNET: process.env.ESPLORA_SIGNET, ESPLORA_REGTEST: process.env.ESPLORA_REGTEST, PRICE_API_URL: process.env.PRICE_API_URL, + BITCOIN_EXPLORER: process.env.BITCOIN_EXPLORER, + TESTNET_EXPLORER: process.env.TESTNET_EXPLORER, + TESTNET4_EXPLORER: process.env.TESTNET4_EXPLORER, + SIGNET_EXPLORER: process.env.SIGNET_EXPLORER, + REGTEST_EXPLORER: process.env.REGTEST_EXPLORER, }, polyfills: true, experimental: { wasm: true }, diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index e01e8ca3..e5abbaf6 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "a4uu803ic5YoTP3LLoFSgwmrEtC8kjh/ovXBdhQuGCY=", + "shasum": "QUr9khdvLrxSS25BuxltTXnJ9O0zFwXV1UTf3nX6Gd0=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -97,6 +97,6 @@ ] } }, - "platformVersion": "6.24.0", + "platformVersion": "7.1.0", "manifestVersion": "0.1" } diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 31d3e69e..4d7a4eae 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -20,6 +20,14 @@ export const Config: SnapConfig = { regtest: process.env.ESPLORA_REGTEST ?? 'http://localhost:8094/regtest/api', }, + explorerUrl: { + bitcoin: process.env.BITCOIN_EXPLORER ?? 'https://mempool.space', + testnet: process.env.TESTNET_EXPLORER ?? 'https://mempool.space/testnet', + testnet4: + process.env.TESTNET4_EXPLORER ?? 'https://mempool.space/testnet4', + signet: process.env.SIGNET_EXPLORER ?? 'https://mutinynet.com', + regtest: process.env.REGTEST_EXPLORER ?? 'http://localhost:8094/regtest', + }, }, targetBlocksConfirmation: 3, fallbackFeeRate: 5.0, diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts b/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts index 0eed0515..112f916e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/chain.ts @@ -46,4 +46,12 @@ export type BlockchainClient = { * @returns the map of fee estimates */ getFeeEstimates(network: Network): Promise; + + /** + * Returns the explorer url for the given network. + * + * @param network - Network. + * @returns the base url of the explorer + */ + getExplorerUrl(network: Network): string; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts index 68a66c9e..f2a2ae4a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -20,6 +20,9 @@ export type ChainConfig = { url: { [network in Network]: string; }; + explorerUrl: { + [network in Network]: string; + }; }; export type PriceApiConfig = { diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts index 5e837b16..bcc29cc9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts @@ -32,9 +32,12 @@ export enum SendFormEvent { Amount = 'amount', Recipient = 'recipient', ClearRecipient = 'clearRecipient', + ClearAmount = 'clearAmount', Confirm = 'confirm', Cancel = 'cancel', - SetMax = 'max', + Max = 'max', + Account = 'account', + Asset = 'asset', } export type SendFormState = { @@ -44,6 +47,7 @@ export type SendFormState = { export type ReviewTransactionContext = { from: string; + explorerUrl: string; network: Network; currency: CurrencyUnit; exchangeRate?: CurrencyRate; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts index b5598f64..c32df4ff 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts @@ -56,4 +56,8 @@ export class EsploraClientAdapter implements BlockchainClient { async getFeeEstimates(network: Network): Promise { return this.#clients[network].get_fee_estimates(); } + + getExplorerUrl(network: Network): string { + return this.#config.explorerUrl[network]; + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx index 73f85e80..091c3913 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx @@ -11,11 +11,17 @@ import { Text as SnapText, Value, Address, + Link, } from '@metamask/snaps-sdk/jsx'; import type { CaipAccountId } from '@metamask/utils'; import { AssetIcon, HeadingWithReturn } from './components'; -import { displayAmount, displayExchangeAmount, translate } from './format'; +import { + displayAmount, + displayExchangeAmount, + displayExplorerUrl, + translate, +} from './format'; import { Config } from '../../config'; import type { Messages, ReviewTransactionContext } from '../../entities'; import { BlockTime, ReviewTransactionEvent } from '../../entities'; @@ -30,7 +36,15 @@ export const ReviewTransactionView: SnapComponent< ReviewTransactionViewProps > = ({ context, messages }) => { const t = translate(messages); - const { amount, currency, exchangeRate, recipient, network, from } = context; + const { + amount, + currency, + exchangeRate, + recipient, + network, + from, + explorerUrl, + } = context; const psbt = Psbt.from_string(context.psbt); const fee = psbt.fee().to_sat(); @@ -47,33 +61,30 @@ export const ReviewTransactionView: SnapComponent< - {`${t('sending')} ${displayAmount( - BigInt(amount), - currency, - )}`} - {t('reviewTransactionWarning')} + {displayAmount(BigInt(amount), currency)} + + {displayExchangeAmount(BigInt(amount), exchangeRate)} +
-
- - - + +
+ -
+ +
+
@@ -95,7 +106,7 @@ export const ReviewTransactionView: SnapComponent< /> - {`${feeRate ?? 'unknown'} sat/vB`} + {`${feeRate} sat/vB`} = ({ messages, }) => { const t = translate(messages); + const { errors } = context; return ( @@ -34,28 +35,19 @@ export const SendFormView: SnapComponent = ({ - {context.errors.tx !== undefined && ( - - {context.errors.tx} - - )} - - {context.fee !== undefined && context.amount !== undefined && ( - + {errors.tx && ( + + {null} + + {errors.tx} + + )}
-
diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/HeadingWithReturn.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/HeadingWithReturn.tsx index b60f39e2..6f25b8f0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/HeadingWithReturn.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/HeadingWithReturn.tsx @@ -1,7 +1,5 @@ import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; -import { Box, Button, Heading, Icon, Image } from '@metamask/snaps-sdk/jsx'; - -import emptySpace from '../images/empty-space.svg'; +import { Box, Button, Heading, Icon } from '@metamask/snaps-sdk/jsx'; export type HeadingWithReturnProps = { heading: string; @@ -21,6 +19,11 @@ export const HeadingWithReturn: SnapComponent = ({ * The Snap UI centers the text within its container, but the container * itself is misaligned in the header due to the back arrow. */} - + + {null} + {null} + {null} + {null} + ); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx index c275a389..452b5097 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx @@ -1,5 +1,7 @@ import type { JSXElement } from '@metamask/snaps-sdk/jsx'; import { + AccountSelector, + AssetSelector, Box, Button, Field, @@ -8,45 +10,51 @@ import { Input, Text as SnapText, } from '@metamask/snaps-sdk/jsx'; +import type { CaipAccountId } from '@metamask/utils'; import type { Messages, SendFormContext } from '../../../entities'; import { SENDFORM_NAME, SendFormEvent } from '../../../entities'; +import { networkToCaip19, networkToScope } from '../../../handlers'; import { displayAmount, translate } from '../format'; -import { AssetIcon } from './AssetIcon'; type SendFormProps = SendFormContext & { messages: Messages; }; export const SendForm = (props: SendFormProps): JSXElement => { - const { currency, balance, amount, recipient, errors, network, messages } = - props; + const { + currency, + balance, + amount, + recipient, + network, + account, + errors, + messages, + drain, + } = props; const t = translate(messages); const validAddress = Boolean(recipient && !errors.recipient); + const chainId = networkToScope[network]; + const caip10Address = `${chainId}:${account.address}` as CaipAccountId; return ( - - - + - - - - {`${t('balance')}: ${displayAmount(BigInt(balance), currency)}`} - - - - + {null} + {null} + {null} + { )} - {validAddress && } + + {validAddress && ( + + {null} + {null} + {null} + + + + + + + + + + + {currency} + + + + + + + + {`${t('balance')}: ${displayAmount(BigInt(balance), currency)}`} + + + {drain ? ( + + ) : ( + + )} + + + )} ); }; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx deleted file mode 100644 index 68884e4f..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/TransactionSummary.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { Network } from '@metamask/bitcoindevkit'; -import type { CurrencyRate } from '@metamask/snaps-sdk'; -import { - Row, - Section, - Text as SnapText, - Value, - type SnapComponent, -} from '@metamask/snaps-sdk/jsx'; - -import { Config } from '../../../config'; -import type { Messages, CurrencyUnit } from '../../../entities'; -import { BlockTime } from '../../../entities'; -import { displayAmount, displayExchangeAmount, translate } from '../format'; - -type TransactionSummaryProps = { - currency: CurrencyUnit; - exchangeRate?: CurrencyRate; - amount: string; - fee: string; - network: Network; - messages: Messages; -}; - -export const TransactionSummary: SnapComponent = ({ - fee, - amount, - currency, - exchangeRate, - network, - messages, -}) => { - const t = translate(messages); - - const total = BigInt(amount) + BigInt(fee); - - return ( -
- - {`${Config.targetBlocksConfirmation * BlockTime[network]} ${t( - 'minutes', - )}`} - - - - - - - -
- ); -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/index.ts index 93285822..c0bdcc6a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/index.ts @@ -1,4 +1,3 @@ export * from './SendForm'; -export * from './TransactionSummary'; export * from './HeadingWithReturn'; export * from './AssetIcon'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts index af7dad9e..7102978e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts @@ -30,3 +30,6 @@ export const translate = (messages: Messages) => (key: string): string => messages[key]?.message ?? `{${key}}`; + +export const displayExplorerUrl = (url: string, address: string): string => + `${url}/address/${address}`; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin.svg b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin.svg index 925525ec..cbed14c8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin.svg +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin.svg @@ -1 +1,12 @@ - \ No newline at end of file + + + + + + + \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/empty-space.svg b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/empty-space.svg deleted file mode 100644 index ddd505d0..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/empty-space.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/signet.svg b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/signet.svg index cb0c6cb2..54544af3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/signet.svg +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/signet.svg @@ -1 +1,12 @@ - \ No newline at end of file + + + + + + + \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/testnet.svg b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/testnet.svg index 336e8600..e422cc50 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/testnet.svg +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/testnet.svg @@ -1 +1,12 @@ - \ No newline at end of file + + + + + + + \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 657e6d4c..74c8fdf9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -51,6 +51,7 @@ describe('SendFlowUseCases', () => { const targetBlocksConfirmation = 3; const fallbackFeeRate = 5.0; const ratesRefreshInterval = 'PT30S'; + const explorerUrl = 'http://myeplorer.com'; const mockAccount = mock({ network: 'bitcoin', buildTx: jest.fn(), @@ -169,6 +170,7 @@ describe('SendFlowUseCases', () => { }; beforeEach(() => { + mockChain.getExplorerUrl.mockReturnValue(explorerUrl); mockAccount.buildTx.mockReturnValue(mockTxBuilder); mockTxBuilder.addRecipient.mockReturnThis(); mockTxBuilder.feeRate.mockReturnThis(); @@ -248,6 +250,7 @@ describe('SendFlowUseCases', () => { mockPsbt.toString.mockReturnValue('psbtBase64'); const expectedReviewContext: ReviewTransactionContext = { from: mockContext.account.address, + explorerUrl, network: mockContext.network, amount: '1000', recipient: 'recipientAddress', @@ -284,6 +287,7 @@ describe('SendFlowUseCases', () => { mockPsbt.toString.mockReturnValue('psbtBase64'); const expectedReviewContext: ReviewTransactionContext = { from: mockContext.account.address, + explorerUrl, network: mockContext.network, amount: '1000', recipient: 'recipientAddress', @@ -339,7 +343,7 @@ describe('SendFlowUseCases', () => { await useCases.onChangeForm( 'interface-id', - SendFormEvent.SetMax, + SendFormEvent.Max, testContext, ); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( @@ -446,7 +450,7 @@ describe('SendFlowUseCases', () => { await useCases.onChangeForm( 'interface-id', - SendFormEvent.SetMax, + SendFormEvent.Max, mockContext, ); @@ -510,6 +514,7 @@ describe('SendFlowUseCases', () => { describe('onChangeReview', () => { const mockContext: ReviewTransactionContext = { from: 'myAddress', + explorerUrl, network: 'bitcoin', amount: '10000', currency: CurrencyUnit.Bitcoin, diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index b481d1ba..791e4172 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -128,6 +128,16 @@ export class SendFlowUseCases { return this.#sendFlowRepository.updateForm(id, updatedContext); } + case SendFormEvent.ClearAmount: { + const updatedContext = { ...context }; + delete updatedContext.amount; + delete updatedContext.errors.amount; + delete updatedContext.errors.tx; + delete updatedContext.fee; + delete updatedContext.drain; + + return this.#sendFlowRepository.updateForm(id, updatedContext); + } case SendFormEvent.Confirm: { if (context.backgroundEventId) { await this.#snapClient.cancelBackgroundEvent( @@ -158,6 +168,7 @@ export class SendFlowUseCases { const reviewContext: ReviewTransactionContext = { from: context.account.address, + explorerUrl: this.#chainClient.getExplorerUrl(context.network), network: context.network, amount: context.amount, recipient: context.recipient, @@ -172,7 +183,7 @@ export class SendFlowUseCases { throw new Error('Inconsistent Send form context'); } - case SendFormEvent.SetMax: { + case SendFormEvent.Max: { return this.#handleSetMax(id, context); } case SendFormEvent.Recipient: { @@ -181,6 +192,14 @@ export class SendFlowUseCases { case SendFormEvent.Amount: { return this.#handleSetAmount(id, context); } + case SendFormEvent.Account: { + // TODO: Implement switching accounts + return undefined; + } + case SendFormEvent.Asset: { + // Do nothing as there are no other assets + return undefined; + } default: throw new Error('Unrecognized event'); } @@ -341,7 +360,7 @@ export class SendFlowUseCases { } async #computeFee(context: SendFormContext): Promise { - const { amount, recipient, drain } = context; + const { amount, recipient, drain, balance } = context; if (amount && recipient) { const account = await this.#accountRepository.get(context.account.id); if (!account) { @@ -360,16 +379,17 @@ export class SendFlowUseCases { if (drain) { const psbt = builder.drainWallet().drainTo(recipient).finish(); const fee = psbt.fee().to_sat(); - const realAmount = BigInt(amount) - fee; + const realAmount = BigInt(balance) - fee; return { ...context, fee: fee.toString(), amount: realAmount.toString(), + balance, }; } const psbt = builder.addRecipient(amount, recipient).finish(); - return { ...context, fee: psbt.fee().to_sat().toString() }; + return { ...context, fee: psbt.fee().to_sat().toString(), balance }; } catch (error) { return { ...context, From 4321064f8b49db7c67a82ba617329c96537a7d60 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 11 Jun 2025 16:40:52 +0200 Subject: [PATCH 241/362] feat: switch currencies (#477) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/entities/currency.ts | 2 +- .../src/entities/send-flow.ts | 1 + .../src/infra/jsx/components/SendForm.tsx | 41 +++++++++++++--- .../src/infra/jsx/format.ts | 15 ++++-- .../src/use-cases/SendFlowUseCases.test.ts | 48 +++++++++++++++++++ .../src/use-cases/SendFlowUseCases.ts | 29 ++++++++++- 7 files changed, 125 insertions(+), 13 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index e5abbaf6..e5489a80 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "QUr9khdvLrxSS25BuxltTXnJ9O0zFwXV1UTf3nX6Gd0=", + "shasum": "4Za/36SDZ7XDTNzuHPetofhUQvcAzW0xplsDRtF0VJA=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts b/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts index 70c03dd4..9bf091a6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts @@ -5,7 +5,7 @@ export enum CurrencyUnit { Testnet = 'tBTC', Signet = 'sBTC', Regtest = 'rBTC', - Fiat = '$', + Fiat = 'fiat', // Can also be cryptos like ETH, but will be fiat for 99% of users } export const networkToCurrencyUnit: Record = { diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts index bcc29cc9..548f6db1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts @@ -38,6 +38,7 @@ export enum SendFormEvent { Max = 'max', Account = 'account', Asset = 'asset', + SwitchCurrency = 'switchCurrency', } export type SendFormState = { diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx index 452b5097..f6906a70 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx @@ -13,9 +13,14 @@ import { import type { CaipAccountId } from '@metamask/utils'; import type { Messages, SendFormContext } from '../../../entities'; -import { SENDFORM_NAME, SendFormEvent } from '../../../entities'; +import { CurrencyUnit, SENDFORM_NAME, SendFormEvent } from '../../../entities'; import { networkToCaip19, networkToScope } from '../../../handlers'; -import { displayAmount, translate } from '../format'; +import { + displayAmount, + displayExchangeAmount, + exchangeAmount, + translate, +} from '../format'; type SendFormProps = SendFormContext & { messages: Messages; @@ -32,12 +37,22 @@ export const SendForm = (props: SendFormProps): JSXElement => { errors, messages, drain, + exchangeRate, } = props; const t = translate(messages); const validAddress = Boolean(recipient && !errors.recipient); const chainId = networkToScope[network]; const caip10Address = `${chainId}:${account.address}` as CaipAccountId; + const isCurrencySwapped = currency === CurrencyUnit.Fiat; + const amountValue = (): string => { + if (!amount) { + return ''; + } + return isCurrencySwapped + ? exchangeAmount(BigInt(amount), exchangeRate) + : displayAmount(BigInt(amount)); + }; return (
@@ -91,21 +106,35 @@ export const SendForm = (props: SendFormProps): JSXElement => { name={SendFormEvent.Amount} type="number" min={0} - step={0.00000001} + max={ + isCurrencySwapped + ? Number(exchangeAmount(BigInt(balance))) + : Number(balance) + } + step={isCurrencySwapped ? 0.01 : 0.00000001} placeholder="0" - value={amount ? displayAmount(BigInt(amount)) : ''} + value={amountValue()} /> - {currency} + + {isCurrencySwapped && exchangeRate + ? exchangeRate.currency + : currency} + + {exchangeRate && ( + + )} - {`${t('balance')}: ${displayAmount(BigInt(balance), currency)}`} + {`${t('balance')}: ${isCurrencySwapped ? displayExchangeAmount(BigInt(balance), exchangeRate) : displayAmount(BigInt(balance), currency)}`} {drain ? ( diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts index 7102978e..48d693bb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts @@ -15,14 +15,23 @@ export const displayAmount = ( return amount.toString(); }; +export const exchangeAmount = ( + amount: bigint, + exchangeRate?: CurrencyRate, +): string => { + if (!exchangeRate) { + return ''; + } + + return ((Number(amount) * exchangeRate.conversionRate) / 1e8).toFixed(2); +}; + export const displayExchangeAmount = ( amount: bigint, exchangeRate?: CurrencyRate, ): string => { return exchangeRate - ? `${((Number(amount) * exchangeRate.conversionRate) / 1e8).toFixed(2)} ${ - exchangeRate.currency - }` + ? `${exchangeAmount(amount, exchangeRate)} ${exchangeRate.currency}` : ''; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 74c8fdf9..7323fb0c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -36,6 +36,7 @@ jest.mock('@metamask/bitcoindevkit', () => { }, Amount: { from_btc: jest.fn(), + from_sat: jest.fn(), }, Psbt: { from_string: jest.fn() }, }; @@ -433,6 +434,53 @@ describe('SendFlowUseCases', () => { ); }); + it('sets amount from state on Amount: switched currencies', async () => { + (Amount.from_sat as jest.Mock).mockReturnValue({ + to_sat: () => BigInt('22222'), + }); + + const testContext = { + ...mockContext, + currency: CurrencyUnit.Fiat, + exchangeRate: { + currency: 'usd', + conversionRate: 11000, + conversionDate: 2025, + }, + recipient: undefined, // avoid computing the fee in this test + }; + + mockSendFlowRepository.getState.mockResolvedValue({ + recipient: '', + amount: '100', // this represents usd + }); + const expectedContext = { + ...testContext, + drain: undefined, + fee: undefined, + amount: '22222', + errors: { + ...mockContext.errors, + tx: undefined, + amount: undefined, + }, + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Amount, + testContext, + ); + + expect(mockSendFlowRepository.getState).toHaveBeenCalledWith( + 'interface-id', + ); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + it('computes the fee when amount and recipient are filled: drain', async () => { mockPsbt.fee.mockReturnValue({ to_sat: () => BigInt(10), diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index 791e4172..d78fc941 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -16,6 +16,7 @@ import { SendFormEvent, ReviewTransactionEvent, networkToCurrencyUnit, + CurrencyUnit, } from '../entities'; import { CronMethod } from '../handlers'; @@ -192,6 +193,19 @@ export class SendFlowUseCases { case SendFormEvent.Amount: { return this.#handleSetAmount(id, context); } + case SendFormEvent.SwitchCurrency: { + if (!context.exchangeRate) { + return undefined; + } + + const updatedContext = { ...context }; + updatedContext.currency = + context.currency === CurrencyUnit.Fiat + ? networkToCurrencyUnit[context.network] + : CurrencyUnit.Fiat; + + return this.#sendFlowRepository.updateForm(id, updatedContext); + } case SendFormEvent.Account: { // TODO: Implement switching accounts return undefined; @@ -289,8 +303,19 @@ export class SendFlowUseCases { delete updatedContext.drain; try { - // We expect amounts to be entered in Bitcoin - const amount = Amount.from_btc(Number(formState.amount)); + let amount: Amount; + if (context.currency === CurrencyUnit.Fiat && context.exchangeRate) { + // keep everything in sats (integers) to avoid FP drift + const sats = Math.round( + (Number(formState.amount) * 1e8) / + context.exchangeRate.conversionRate, + ); + amount = Amount.from_sat(BigInt(sats)); + } else { + // expects values to be entered in BTC and not satoshis + amount = Amount.from_btc(Number(formState.amount)); + } + updatedContext.amount = amount.to_sat().toString(); updatedContext = await this.#computeFee(updatedContext); } catch (error) { From a18519eb19d7cbd81c7b55edfd9df3460d40756c Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 12 Jun 2025 15:42:17 +0200 Subject: [PATCH 242/362] feat: Market data (#478) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/entities/rates.ts | 19 +++--- .../src/handlers/AssetsHandler.test.ts | 18 ++++-- .../src/handlers/AssetsHandler.ts | 4 +- .../bitcoin-wallet-snap/src/index.ts | 3 +- .../src/infra/PriceApiClientAdapter.ts | 59 +++++++++++++++---- .../src/use-cases/AssetsUseCases.test.ts | 49 ++++++++++----- .../src/use-cases/AssetsUseCases.ts | 39 ++++++------ .../src/use-cases/SendFlowUseCases.test.ts | 8 ++- .../src/use-cases/SendFlowUseCases.ts | 15 ++--- 10 files changed, 140 insertions(+), 76 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index e5489a80..953ddaf8 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "4Za/36SDZ7XDTNzuHPetofhUQvcAzW0xplsDRtF0VJA=", + "shasum": "p+sQZuFeJ9QT0f7dhbP7KBwpqbbyIIKPHitPyjqyOiw=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts b/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts index c2c34f59..e2342b6b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts @@ -1,22 +1,23 @@ -import type { HistoricalPriceValue } from '@metamask/snaps-sdk'; +import type { HistoricalPriceValue, MarketData } from '@metamask/snaps-sdk'; import type { CaipAssetType } from '@metamask/utils'; export type TimePeriod = 'P1D' | 'P7D' | 'P1M' | 'P3M' | 'P1Y' | 'P1000Y'; -export type AssetRate = [CaipAssetType, number | null]; -export type ExchangeRates = { - [ticker: string]: { - value: number; - }; +export type AssetRate = [CaipAssetType, SpotPrice | null]; + +export type SpotPrice = { + price: number; + marketData?: MarketData; }; export type AssetRatesClient = { /** - * Returns a list of exchange rates for all supported currencies. + * Returns the spot price of an asset relative to an other including market data. * - * @param baseCurrency - the currency to convert prices to. Defaults to 'btc'. + * @param vsCurrency - the currency to convert prices to. Defaults to 'usd'. + * @param baseCurrency - the currency to get prices for. Defaults to 'bitcoin'. */ - exchangeRates(baseCurrency?: string): Promise; + spotPrices(vsCurrency?: string, baseCurrency?: string): Promise; /** * Returns a list of historical prices for a token against another. diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts index bd679d13..eb381e1b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts @@ -1,10 +1,11 @@ -import type { HistoricalPriceIntervals } from '@metamask/snaps-sdk'; +import type { HistoricalPriceIntervals, MarketData } from '@metamask/snaps-sdk'; import { SnapError } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; import type { AssetsUseCases } from '../use-cases'; import { AssetsHandler } from './AssetsHandler'; import { Caip19Asset } from './caip'; +import type { SpotPrice } from '../entities'; describe('AssetsHandler', () => { const mockAssetsUseCases = mock(); @@ -32,9 +33,16 @@ describe('AssetsHandler', () => { describe('conversion', () => { it('returns rates for all networks successfully', async () => { + const mockMarketData = mock(); mockAssetsUseCases.getRates.mockResolvedValue([ - [Caip19Asset.Testnet, 0.1], - [Caip19Asset.Regtest, 0.2], + [ + Caip19Asset.Testnet, + mock({ price: 0.1, marketData: mockMarketData }), + ], + [ + Caip19Asset.Regtest, + mock({ price: 0.2, marketData: mockMarketData }), + ], ]); const conversions = [ @@ -45,7 +53,7 @@ describe('AssetsHandler', () => { { from: Caip19Asset.Signet, to: Caip19Asset.Bitcoin }, { from: Caip19Asset.Regtest, to: Caip19Asset.Bitcoin }, ]; - const result = await handler.conversion(conversions); + const result = await handler.conversion(conversions, true); expect(mockAssetsUseCases.getRates).toHaveBeenCalledTimes(1); expect(mockAssetsUseCases.getRates).toHaveBeenCalledWith([ @@ -56,11 +64,13 @@ describe('AssetsHandler', () => { [Caip19Asset.Bitcoin]: { [Caip19Asset.Testnet]: { rate: '0.1', + marketData: mockMarketData, conversionTime: expect.any(Number), expirationTime: expect.any(Number), }, [Caip19Asset.Regtest]: { rate: '0.2', + marketData: mockMarketData, conversionTime: expect.any(Number), expirationTime: expect.any(Number), }, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts index a8be47ea..641e4475 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts @@ -87,6 +87,7 @@ export class AssetsHandler { async conversion( conversions: OnAssetsConversionArguments['conversions'], + includeMarketData?: boolean, ): Promise { const conversionTime = getCurrentUnixTimestamp(); @@ -111,7 +112,8 @@ export class AssetsHandler { )) { conversionRates[fromKey][toAsset] = rate ? { - rate: rate.toString(), + rate: rate.price.toString(), + marketData: includeMarketData ? rate.marketData : undefined, conversionTime, expirationTime: conversionTime + this.#expirationInterval, } diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 2de49d9b..4309150f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -89,7 +89,8 @@ export const onAssetsLookup: OnAssetsLookupHandler = async () => export const onAssetsConversion: OnAssetsConversionHandler = async ({ conversions, -}) => assetsHandler.conversion(conversions); + includeMarketData, +}) => assetsHandler.conversion(conversions, includeMarketData); export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ({ from, diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts index f840b7c9..51de4a3c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts @@ -2,13 +2,29 @@ import type { HistoricalPriceValue } from '@metamask/snaps-sdk'; import type { AssetRatesClient, - ExchangeRates, PriceApiConfig, + SpotPrice, TimePeriod, } from '../entities'; -export type HistoricalPricesResponse = { - prices: [number, number][]; +type SpotPricesResponse = { + price: number; + marketCap: number; + allTimeHigh: number; + allTimeLow: number; + totalVolume: number; + circulatingSupply: number; + pricePercentChange1h: number; + pricePercentChange1d: number; + pricePercentChange7d: number; + pricePercentChange14d: number; + pricePercentChange30d: number; + pricePercentChange200d: number; + pricePercentChange1y: number; +}; + +type HistoricalPricesResponse = { + prices: [number, number | null][]; }; export class PriceApiClientAdapter implements AssetRatesClient { @@ -18,17 +34,39 @@ export class PriceApiClientAdapter implements AssetRatesClient { this.#endpoint = config.url; } - async exchangeRates(baseCurrency = 'btc'): Promise { + async spotPrices( + vsCurrency = 'usd', + baseCurrency = 'bitcoin', + ): Promise { const url = `${ this.#endpoint - }/v1/exchange-rates?baseCurrency=${baseCurrency}`; + }/v1/spot-prices/${baseCurrency}?vsCurrency=${vsCurrency}`; const response = await fetch(url); if (!response.ok) { - throw new Error(`Failed to fetch exchange rates: ${response.statusText}`); + throw new Error(`Failed to fetch spot prices: ${response.statusText}`); } - return await response.json(); + const prices: SpotPricesResponse = await response.json(); + return { + price: prices.price, + marketData: { + allTimeHigh: prices.allTimeHigh.toString(), + allTimeLow: prices.allTimeLow.toString(), + circulatingSupply: prices.circulatingSupply.toString(), + marketCap: prices.marketCap.toString(), + totalVolume: prices.totalVolume.toString(), + pricePercentChange: { + PT1H: prices.pricePercentChange1h, + P1D: prices.pricePercentChange1d, + P7D: prices.pricePercentChange7d, + P14D: prices.pricePercentChange14d, + P30D: prices.pricePercentChange30d, + P200D: prices.pricePercentChange200d, + P1Y: prices.pricePercentChange1y, + }, + }, + }; } async historicalPrices( @@ -50,9 +88,8 @@ export class PriceApiClientAdapter implements AssetRatesClient { } const prices: HistoricalPricesResponse = await response.json(); - return prices.prices.map(([timestamp, price]) => [ - timestamp, - price.toString(), - ]); + return prices.prices + .filter(([, price]) => price !== null) // keep only non-null prices + .map(([ts, price]) => [ts, (price as number).toString()]); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts index ae58fecb..58662fb6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts @@ -1,7 +1,7 @@ import type { HistoricalPriceValue } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; -import type { AssetRatesClient, ExchangeRates, Logger } from '../entities'; +import type { AssetRatesClient, Logger, SpotPrice } from '../entities'; import { AssetsUseCases } from './AssetsUseCases'; import { Caip19Asset } from '../handlers/caip'; @@ -13,13 +13,28 @@ describe('AssetsUseCases', () => { describe('getBtcRates', () => { it('returns rate for the known assets and null for unknown', async () => { - const mockExchangeRates = mock({ - usd: { value: 1 }, - eth: { value: 2 }, - btc: { value: 3 }, + const mockExchangeRatesUSD = mock({ + price: 1, + marketData: { + allTimeHigh: '110000', + }, + }); + const mockExchangeRatesETH = mock({ + price: 1, + marketData: { + allTimeHigh: '0.1', + }, + }); + const mockExchangeRatesBTC = mock({ + price: 1, + marketData: { + allTimeHigh: '1', + }, }); - mockAssetRates.exchangeRates.mockResolvedValue(mockExchangeRates); + mockAssetRates.spotPrices.mockResolvedValueOnce(mockExchangeRatesETH); + mockAssetRates.spotPrices.mockResolvedValueOnce(mockExchangeRatesBTC); + mockAssetRates.spotPrices.mockResolvedValueOnce(mockExchangeRatesUSD); const result = await useCases.getRates([ 'eip155:1/slip44:60', @@ -28,18 +43,21 @@ describe('AssetsUseCases', () => { 'swift:0/unknown:unknown', ]); - expect(mockAssetRates.exchangeRates).toHaveBeenCalled(); + expect(mockAssetRates.spotPrices).toHaveBeenCalled(); expect(result).toStrictEqual([ - ['eip155:1/slip44:60', 2], - ['bip122:000000000019d6689c085ae165831e93/slip44:0', 3], - ['swift:0/iso4217:USD', 1], ['swift:0/unknown:unknown', null], + ['eip155:1/slip44:60', mockExchangeRatesETH], + [ + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + mockExchangeRatesBTC, + ], + ['swift:0/iso4217:USD', mockExchangeRatesUSD], ]); }); - it('propagates an error if exchangeRates fails', async () => { + it('propagates an error if spotPrices fails', async () => { const error = new Error('getRates failed'); - mockAssetRates.exchangeRates.mockRejectedValue(error); + mockAssetRates.spotPrices.mockRejectedValue(error); await expect(useCases.getRates([Caip19Asset.Testnet])).rejects.toBe( error, @@ -65,12 +83,13 @@ describe('AssetsUseCases', () => { }); }); - it('does not fail if historicalPrices returns an error', async () => { + it('propagates an error if historicalPrices fails', async () => { const error = new Error('historicalPrices failed'); mockAssetRates.historicalPrices.mockRejectedValue(error); - const result = await useCases.getPriceIntervals('swift:0/iso4217:USD'); - expect(result).toStrictEqual({}); + await expect( + useCases.getPriceIntervals('swift:0/iso4217:USD'), + ).rejects.toBe(error); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts index d6276ba4..ffa9d3e7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts @@ -22,17 +22,20 @@ export class AssetsUseCases { async getRates(assets: CaipAssetType[]): Promise { this.#logger.debug('Fetching BTC rates for: %o', assets); - const exchangeRates = await this.#assetRates.exchangeRates(); - this.#logger.debug('BTC rates fetched successfully'); - return assets.map((asset): AssetRate => { - const ticker = this.#assetToTicker(asset); - if (ticker && exchangeRates[ticker]) { - return [asset, exchangeRates[ticker].value]; - } + const assetRates: AssetRate[] = []; + await Promise.all( + assets.map(async (asset) => { + const ticker = this.#assetToTicker(asset); + assetRates.push([ + asset, + ticker ? await this.#assetRates.spotPrices(ticker) : null, + ]); + }), + ); - return [asset, null]; - }); + this.#logger.debug('BTC rates fetched successfully'); + return assetRates; } async getPriceIntervals( @@ -52,19 +55,11 @@ export class AssetsUseCases { const historicalPrices: HistoricalPriceIntervals = {}; await Promise.all( timePeriods.map(async (timePeriod) => { - try { - const prices = await this.#assetRates.historicalPrices( - timePeriod, - vsCurrency, - ); - historicalPrices[timePeriod] = prices; - } catch (error) { - this.#logger.error( - `Failed to fetch historical prices. Time period: %s. Error: %s`, - timePeriod, - error, - ); - } + const prices = await this.#assetRates.historicalPrices( + timePeriod, + vsCurrency, + ); + historicalPrices[timePeriod] = prices; }), ); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 7323fb0c..078a4f33 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -641,14 +641,14 @@ describe('SendFlowUseCases', () => { locale: 'en', }; const mockExchangeRates = { - usd: { value: 200000 }, + price: 200000, }; const mockFeeRate = 4.4; beforeEach(() => { mockSendFlowRepository.getContext.mockResolvedValue(mockContext); mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); - mockRatesClient.exchangeRates.mockResolvedValue(mockExchangeRates); + mockRatesClient.spotPrices.mockResolvedValue(mockExchangeRates); mockSnapClient.scheduleBackgroundEvent.mockResolvedValue('event-id'); mockSnapClient.getPreferences.mockResolvedValue(mockPreferences); }); @@ -687,7 +687,7 @@ describe('SendFlowUseCases', () => { ...mockContext, backgroundEventId: 'event-id', exchangeRate: { - conversionRate: mockExchangeRates.usd.value, + conversionRate: mockExchangeRates.price, conversionDate: expect.any(Number), currency: 'USD', }, @@ -723,6 +723,8 @@ describe('SendFlowUseCases', () => { ...mockPreferences, currency: 'unknown', }); + const error = new Error('spotPrices failed'); + mockRatesClient.spotPrices.mockRejectedValue(error); await useCases.refresh('interface-id'); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index d78fc941..a05497fb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -352,15 +352,12 @@ export class SendFlowUseCases { // Exchange rate is only relevant for Bitcoin if (network === 'bitcoin') { - const exchangeRates = await this.#ratesClient.exchangeRates(); - const conversionRate = exchangeRates[currency]; - if (conversionRate) { - updatedContext.exchangeRate = { - conversionRate: conversionRate.value, - conversionDate: getCurrentUnixTimestamp(), - currency: currency.toUpperCase(), - }; - } + const spotPrice = await this.#ratesClient.spotPrices(currency); + updatedContext.exchangeRate = { + conversionRate: spotPrice.price, + conversionDate: getCurrentUnixTimestamp(), + currency: currency.toUpperCase(), + }; } updatedContext = await this.#computeFee(updatedContext); From 7d1b7581a549f8246c4e0821436b3c0e538537dc Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 12 Jun 2025 18:27:59 +0200 Subject: [PATCH 243/362] feat: account selector (#479) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/entities/send-flow.ts | 3 + .../src/use-cases/SendFlowUseCases.test.ts | 57 ++++++++ .../src/use-cases/SendFlowUseCases.ts | 122 +++++++++++------- 4 files changed, 138 insertions(+), 46 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 953ddaf8..a0ef1f9f 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "p+sQZuFeJ9QT0f7dhbP7KBwpqbbyIIKPHitPyjqyOiw=", + "shasum": "fg/uzwfshSdZdeX4O+vFjYqbP4vu8PfgXX5DuTWSsZk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts index 548f6db1..9f4f3e07 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts @@ -42,6 +42,9 @@ export enum SendFormEvent { } export type SendFormState = { + account: { + accountId: string; + }; recipient: string; amount: string; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 078a4f33..df1b9a9d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -367,6 +367,9 @@ describe('SendFlowUseCases', () => { mockSendFlowRepository.getState.mockResolvedValue({ recipient: 'newAddress', amount: '', + account: { + accountId: 'myAccount', + }, }); const expectedContext = { ...testContext, @@ -406,6 +409,9 @@ describe('SendFlowUseCases', () => { mockSendFlowRepository.getState.mockResolvedValue({ recipient: '', amount: '21000', + account: { + accountId: 'myAccount', + }, }); const expectedContext = { ...testContext, @@ -453,6 +459,9 @@ describe('SendFlowUseCases', () => { mockSendFlowRepository.getState.mockResolvedValue({ recipient: '', amount: '100', // this represents usd + account: { + accountId: 'myAccount', + }, }); const expectedContext = { ...testContext, @@ -530,6 +539,9 @@ describe('SendFlowUseCases', () => { mockSendFlowRepository.getState.mockResolvedValue({ recipient: 'newAddress', amount: '', + account: { + accountId: 'myAccount', + }, }); const expectedContext = { @@ -557,6 +569,51 @@ describe('SendFlowUseCases', () => { expectedContext, ); }); + + it('sets account from state on Account', async () => { + const accountId = 'myAccount2'; + mockAccount.peekAddress.mockReturnValue( + mock({ + address: mock
({ toString: () => 'myAddress2' }), + }), + ); + mockSendFlowRepository.getState.mockResolvedValue({ + recipient: '', + amount: '', + account: { + accountId, + }, + }); + mockAccountRepository.get.mockResolvedValue({ + ...mockAccount, + id: accountId, + }); + + const expectedContext = { + account: { id: accountId, address: 'myAddress2' }, + balance: '1234', + errors: {}, + currency: CurrencyUnit.Bitcoin, + network: 'bitcoin', + feeRate: 2.4, + locale: 'en', + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Account, + mockContext, + ); + + expect(mockSendFlowRepository.getState).toHaveBeenCalledWith( + 'interface-id', + ); + expect(mockAccountRepository.get).toHaveBeenCalledWith(accountId); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); }); describe('onChangeReview', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index a05497fb..0d14ddf4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -140,49 +140,7 @@ export class SendFlowUseCases { return this.#sendFlowRepository.updateForm(id, updatedContext); } case SendFormEvent.Confirm: { - if (context.backgroundEventId) { - await this.#snapClient.cancelBackgroundEvent( - context.backgroundEventId, - ); - } - - if (context.amount && context.recipient) { - const account = await this.#accountRepository.get(context.account.id); - if (!account) { - throw new Error('Account removed while confirming send flow'); - } - const frozenUTXOs = await this.#accountRepository.getFrozenUTXOs( - context.account.id, - ); - - const builder = account - .buildTx() - .feeRate(context.feeRate) - .unspendable(frozenUTXOs); - - if (context.drain) { - builder.drainWallet().drainTo(context.recipient); - } else { - builder.addRecipient(context.amount, context.recipient); - } - const psbt = builder.finish(); - - const reviewContext: ReviewTransactionContext = { - from: context.account.address, - explorerUrl: this.#chainClient.getExplorerUrl(context.network), - network: context.network, - amount: context.amount, - recipient: context.recipient, - exchangeRate: context.exchangeRate, - currency: context.currency, - psbt: psbt.toString(), - sendForm: context, - locale: context.locale, - }; - return this.#sendFlowRepository.updateReview(id, reviewContext); - } - - throw new Error('Inconsistent Send form context'); + return this.#handleConfirm(id, context); } case SendFormEvent.Max: { return this.#handleSetMax(id, context); @@ -207,8 +165,7 @@ export class SendFlowUseCases { return this.#sendFlowRepository.updateForm(id, updatedContext); } case SendFormEvent.Account: { - // TODO: Implement switching accounts - return undefined; + return this.#handleSetAccount(id, context); } case SendFormEvent.Asset: { // Do nothing as there are no other assets @@ -328,6 +285,81 @@ export class SendFlowUseCases { return await this.#sendFlowRepository.updateForm(id, updatedContext); } + async #handleConfirm(id: string, context: SendFormContext): Promise { + if (context.amount && context.recipient) { + if (context.backgroundEventId) { + await this.#snapClient.cancelBackgroundEvent(context.backgroundEventId); + } + + const account = await this.#accountRepository.get(context.account.id); + if (!account) { + throw new Error('Account removed while confirming send flow'); + } + const frozenUTXOs = await this.#accountRepository.getFrozenUTXOs( + context.account.id, + ); + + const builder = account + .buildTx() + .feeRate(context.feeRate) + .unspendable(frozenUTXOs); + + if (context.drain) { + builder.drainWallet().drainTo(context.recipient); + } else { + builder.addRecipient(context.amount, context.recipient); + } + const psbt = builder.finish(); + + const reviewContext: ReviewTransactionContext = { + from: context.account.address, + explorerUrl: this.#chainClient.getExplorerUrl(context.network), + network: context.network, + amount: context.amount, + recipient: context.recipient, + exchangeRate: context.exchangeRate, + currency: context.currency, + psbt: psbt.toString(), + sendForm: context, + locale: context.locale, + }; + + return this.#sendFlowRepository.updateReview(id, reviewContext); + } + + throw new Error('Inconsistent Send form context'); + } + + async #handleSetAccount(id: string, context: SendFormContext): Promise { + const formState = await this.#sendFlowRepository.getState(id); + if (!formState) { + throw new Error(`Form state not found when switching accounts: ${id}`); + } + + const account = await this.#accountRepository.get( + formState.account.accountId, + ); + if (!account) { + throw new Error('Account not found when switching'); + } + + // We "reset" the context with the new account + const newContext: SendFormContext = { + balance: account.balance.trusted_spendable.to_sat().toString(), + account: { + id: account.id, + address: account.peekAddress(0).address.toString(), // FIXME: Address should not be needed in the send flow + }, + errors: {}, + currency: context.currency, + network: context.network, + feeRate: context.feeRate, + locale: context.locale, + }; + + return await this.#sendFlowRepository.updateForm(id, newContext); + } + async refresh(id: string): Promise { const context = await this.#sendFlowRepository.getContext(id); if (!context) { From 76053941d78ba8a4f4b769d8e99a43c3662816a2 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Fri, 13 Jun 2025 14:31:52 +0200 Subject: [PATCH 244/362] feat: account index auto increment (#471) --- .../integration-test/keyring.test.ts | 1 + .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/entities/account.ts | 10 ++++ .../src/handlers/KeyringHandler.test.ts | 52 ++++++++++++++++++- .../src/handlers/KeyringHandler.ts | 50 ++++++++++++++++-- .../src/handlers/mappings.ts | 2 +- .../src/infra/BdkAccountAdapter.ts | 12 +++++ 7 files changed, 122 insertions(+), 7 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index 4cfc3b9b..01d69bfe 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -184,6 +184,7 @@ describe('Keyring', () => { options: { scope: BtcScope.Regtest, addressType: BtcAccountType.P2wpkh, + index: 0, }, }, }); diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index a0ef1f9f..cf17e992 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "fg/uzwfshSdZdeX4O+vFjYqbP4vu8PfgXX5DuTWSsZk=", + "shasum": "eHGmUI3SWN9UgFOEmCaoMBIMpJjfF/ATQgMeZZ288Zk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index 770071db..912c4404 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -32,6 +32,16 @@ export type BitcoinAccount = { */ derivationPath: string[]; + /** + * Account entropy source. + */ + entropySource: string; + + /** + * BIP44 Account index. + */ + accountIndex: number; + /** * The balance of the account. */ diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 11fa7865..8a76430a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -55,6 +55,8 @@ describe('KeyringHandler', () => { balance: { trusted_spendable: { to_btc: () => 1 } }, network: 'bitcoin', derivationPath: ['myEntropy', "84'", "0'", "0'"], + entropySource: 'myEntropy', + accountIndex: 0, }); const defaultAddressType: AddressType = 'p2wpkh'; @@ -129,6 +131,54 @@ describe('KeyringHandler', () => { }); }); + it('auto increment index', async () => { + // We should get index index 1 + mockAccounts.list.mockResolvedValue([ + mock({ + entropySource: 'entropy1', + accountIndex: 1, + addressType: 'p2wpkh', + network: 'signet', + }), + mock({ + entropySource: 'entropy2', + accountIndex: 2, + addressType: 'p2wpkh', + network: 'signet', + }), + mock({ + entropySource: 'entropy2', + accountIndex: 0, + addressType: 'p2wpkh', + network: 'signet', + }), + mock({ + entropySource: 'entropy2', + accountIndex: 3, + addressType: 'p2tr', + network: 'bitcoin', + }), + ]); + + const options = { + scope: BtcScope.Signet, + index: null, + entropySource: 'entropy2', + }; + const expectedCreateParams: CreateAccountParams = { + network: 'signet', + index: 1, + addressType: 'p2wpkh', + entropySource: 'entropy2', + synchronize: false, + }; + + await handler.createAccount(options); + + expect(mockAccounts.list).toHaveBeenCalled(); + expect(mockAccounts.create).toHaveBeenCalledWith(expectedCreateParams); + }); + it.each([ { purpose: Purpose.Legacy, addressType: 'p2pkh' }, { purpose: Purpose.Segwit, addressType: 'p2sh' }, @@ -183,7 +233,7 @@ describe('KeyringHandler', () => { mockAccounts.create.mockRejectedValue(error); await expect( - handler.createAccount({ scopes: [BtcScope.Mainnet] }), + handler.createAccount({ scopes: [BtcScope.Mainnet], index: 0 }), ).rejects.toThrow(error); expect(mockAccounts.create).toHaveBeenCalled(); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 8ceacf12..9169f7bb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -29,6 +29,7 @@ import { string, } from 'superstruct'; +import type { BitcoinAccount } from '../entities'; import { networkToCurrencyUnit, Purpose, @@ -98,29 +99,45 @@ export class KeyringHandler implements Keyring { metamask, scope, entropySource = 'm', - index = 0, + index, derivationPath, addressType, synchronize = false, accountNameSuggestion, } = options; - const resolvedIndex = derivationPath + let resolvedIndex = derivationPath ? this.#extractAccountIndex(derivationPath) : index; - let resolvedAddressType: AddressType | undefined; + let resolvedAddressType: AddressType; if (addressType) { resolvedAddressType = caipToAddressType[addressType]; } else if (derivationPath) { resolvedAddressType = this.#extractAddressType(derivationPath); + } else { + resolvedAddressType = this.#defaultAddressType; + } + + // FIXME: This if should be removed ASAP as the index should always be defined or be 0 + // The Snap automatically increasing the index per request creates significant issues + // such as: concurrency, lack of idempotency, dangling state (if MM crashes before saving the account), etc. + if (resolvedIndex === undefined || resolvedIndex === null) { + const accounts = (await this.#accountsUseCases.list()).filter( + (acc) => + acc.entropySource === entropySource && + acc.network === scopeToNetwork[scope] && + acc.addressType === resolvedAddressType, + ); + + resolvedIndex = this.#getLowestUnusedIndex(accounts); } const account = await this.#accountsUseCases.create({ network: scopeToNetwork[scope], entropySource, index: resolvedIndex, - addressType: resolvedAddressType ?? this.#defaultAddressType, + addressType: resolvedAddressType, correlationId: metamask?.correlationId, synchronize, accountName: accountNameSuggestion, @@ -268,4 +285,29 @@ export class KeyringHandler implements Keyring { return index; } + + #getLowestUnusedIndex(accounts: BitcoinAccount[]): number { + if (accounts.length === 0) { + return 0; + } + + const usedIndices = accounts + .map((acc) => acc.accountIndex) + .sort((idxA, idxB) => idxA - idxB); + + let lowestUnusedIndex = 0; + + for (const usedIndex of usedIndices) { + /** + * From lower to higher, the moment we find a gap, we can use it + */ + if (usedIndex !== lowestUnusedIndex) { + break; + } + + lowestUnusedIndex += 1; + } + + return lowestUnusedIndex; + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index 8d4622a0..b2c84c96 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -56,7 +56,7 @@ export function mapToKeyringAccount(account: BitcoinAccount): KeyringAccount { id: account.id, address: account.peekAddress(0).address.toString(), options: { - entropySource: account.derivationPath[0] ?? null, + entropySource: account.entropySource, }, methods: [BtcMethod.SendBitcoin], }; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index b44ca935..ee3b17d0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -77,6 +77,18 @@ export class BdkAccountAdapter implements BitcoinAccount { return this.#derivationPath; } + get entropySource(): string { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.#derivationPath[0]!; // Must be defined by assertion + } + + get accountIndex(): number { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const segment = this.#derivationPath[3]!; + const numericPart = segment.endsWith("'") ? segment.slice(0, -1) : segment; + return Number(numericPart); + } + get balance(): Balance { return this.#wallet.balance; } From a82adbd8283a69e007f373709a5b3d0299f0fb25 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 13 Jun 2025 19:18:04 +0200 Subject: [PATCH 245/362] 0.15.0 (#480) --------- Co-authored-by: github-actions Co-authored-by: Dario Anongba Varela --- .../bitcoin-wallet-snap/CHANGELOG.md | 21 ++++++++++++++++++- .../bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 263f9821..e8a1b1c0 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.15.0] + +### Added + +- Account index auto increment on creation ([#471](https://github.com/MetaMask/snap-bitcoin-wallet/pull/471)) +- Account selector in send flow ([#479](https://github.com/MetaMask/snap-bitcoin-wallet/pull/479)) +- Market data ([#478](https://github.com/MetaMask/snap-bitcoin-wallet/pull/478)) +- Switch currencies in send flow ([#477](https://github.com/MetaMask/snap-bitcoin-wallet/pull/477)) + +### Changed + +- Align Send flow ([#472](https://github.com/MetaMask/snap-bitcoin-wallet/pull/472)) + +### Fixed + +- Entropy source as part of the `KeyringAccount` options ([#473](https://github.com/MetaMask/snap-bitcoin-wallet/pull/473)) +- Delete account keeping residue in state ([#469](https://github.com/MetaMask/snap-bitcoin-wallet/pull/469)) + ## [0.14.1] ### Fixed @@ -349,7 +367,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.1...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.15.0...HEAD +[0.15.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.1...v0.15.0 [0.14.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.0...v0.14.1 [0.14.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.13.0...v0.14.0 [0.13.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.12.1...v0.13.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 85891dfe..f21f7d58 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.14.1", + "version": "0.15.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 9d26d4f295c5872ff9d05d30288d9c2376c3903b Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 1 Jul 2025 16:48:33 +0200 Subject: [PATCH 246/362] feat: send flow errors (#482) --- .../bitcoin-wallet-snap/locales/en.json | 72 +++++++++++++++++++ .../bitcoin-wallet-snap/messages.json | 72 +++++++++++++++++++ .../bitcoin-wallet-snap/package.json | 20 +++--- .../bitcoin-wallet-snap/snap.manifest.json | 8 +-- .../bitcoin-wallet-snap/src/config.ts | 2 +- .../bitcoin-wallet-snap/src/entities/error.ts | 7 ++ .../bitcoin-wallet-snap/src/entities/index.ts | 1 + .../src/entities/send-flow.ts | 7 +- .../src/infra/jsx/SendFormView.tsx | 6 +- .../src/infra/jsx/components/SendForm.tsx | 19 ++++- .../src/infra/jsx/format.ts | 12 +++- .../src/use-cases/SendFlowUseCases.test.ts | 53 +++++++++++++- .../src/use-cases/SendFlowUseCases.ts | 68 +++++++++++++----- 13 files changed, 303 insertions(+), 44 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/error.ts diff --git a/merged-packages/bitcoin-wallet-snap/locales/en.json b/merged-packages/bitcoin-wallet-snap/locales/en.json index 027f405d..92359d14 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/en.json +++ b/merged-packages/bitcoin-wallet-snap/locales/en.json @@ -72,6 +72,78 @@ }, "error": { "message": "Error" + }, + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" } } } diff --git a/merged-packages/bitcoin-wallet-snap/messages.json b/merged-packages/bitcoin-wallet-snap/messages.json index d5aa4f00..22b29caa 100644 --- a/merged-packages/bitcoin-wallet-snap/messages.json +++ b/merged-packages/bitcoin-wallet-snap/messages.json @@ -70,5 +70,77 @@ }, "error": { "message": "Error" + }, + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" } } diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index f21f7d58..76e6ca4b 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -35,23 +35,23 @@ "test:integration": "./integration-test/run-integration.sh" }, "devDependencies": { - "@jest/globals": "^29.7.0", - "@metamask/bitcoindevkit": "^0.1.9", + "@jest/globals": "^30.0.3", + "@metamask/bitcoindevkit": "^0.1.10", "@metamask/key-tree": "^10.1.1", "@metamask/keyring-api": "^18.0.0", "@metamask/keyring-snap-sdk": "^4.0.0", "@metamask/slip44": "^4.2.0", - "@metamask/snaps-cli": "^7.2.0", - "@metamask/snaps-jest": "^8.16.0", - "@metamask/snaps-sdk": "^7.1.0", + "@metamask/snaps-cli": "^8.1.0", + "@metamask/snaps-jest": "^9.2.0", + "@metamask/snaps-sdk": "^8.1.0", "@metamask/utils": "^11.4.0", - "concurrently": "^9.1.2", - "dotenv": "^16.5.0", - "jest": "^29.7.0", - "jest-mock-extended": "^4.0.0-beta1", + "concurrently": "^9.2.0", + "dotenv": "^17.0.0", + "jest": "^30.0.3", + "jest-mock-extended": "^4.0.0", "jest-transform-stub": "2.0.0", "superstruct": "^2.0.2", - "ts-jest": "^29.3.3", + "ts-jest": "^29.4.0", "uuid": "^11.1.0" }, "publishConfig": { diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index cf17e992..0956f67d 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.14.1", + "version": "0.15.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "eHGmUI3SWN9UgFOEmCaoMBIMpJjfF/ATQgMeZZ288Zk=", + "shasum": "+rIMq2Mzl6E98rCf/EbrqSz8lpuxxT9VnRNWsKQdvCQ=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -80,7 +80,7 @@ "endowment:cronjob": { "jobs": [ { - "expression": "* * * * *", + "duration": "PT30S", "request": { "method": "synchronizeAccounts" } @@ -97,6 +97,6 @@ ] } }, - "platformVersion": "7.1.0", + "platformVersion": "8.1.0", "manifestVersion": "0.1" } diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 4d7a4eae..a01e6d44 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -9,7 +9,7 @@ export const Config: SnapConfig = { encrypt: false, chain: { parallelRequests: 1, - stopGap: 2, + stopGap: 5, url: { bitcoin: process.env.ESPLORA_BITCOIN ?? 'https://blockstream.info/api', testnet: diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/error.ts b/merged-packages/bitcoin-wallet-snap/src/entities/error.ts new file mode 100644 index 00000000..9c0f388c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/error.ts @@ -0,0 +1,7 @@ +import type { Json } from '@metamask/utils'; + +export type CodifiedError = { + message: string; + code: number; + data: Json; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts index 565551bc..6634d792 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts @@ -9,3 +9,4 @@ export type * from './meta-protocols'; export type * from './translator'; export type * from './rates'; export * from './logger'; +export type * from './error'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts index 9f4f3e07..2c74cc3c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts @@ -2,6 +2,7 @@ import type { Network } from '@metamask/bitcoindevkit'; import type { CurrencyRate } from '@metamask/snaps-sdk'; import type { CurrencyUnit } from './currency'; +import type { CodifiedError } from './error'; export const SENDFORM_NAME = 'sendForm'; @@ -20,9 +21,9 @@ export type SendFormContext = { fee?: string; drain?: boolean; errors: { - tx?: string; - recipient?: string; - amount?: string; + tx?: CodifiedError; + recipient?: CodifiedError; + amount?: CodifiedError; }; backgroundEventId?: string; locale: string; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx index cbe5bcfa..928c3b27 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx @@ -9,7 +9,7 @@ import { } from '@metamask/snaps-sdk/jsx'; import { HeadingWithReturn, SendForm } from './components'; -import { translate } from './format'; +import { errorCodeToLabel, translate } from './format'; import type { Messages, SendFormContext } from '../../entities'; import { SendFormEvent } from '../../entities'; @@ -39,7 +39,9 @@ export const SendFormView: SnapComponent = ({ {null} - {errors.tx} + + {t(errorCodeToLabel(errors.tx.code))} + )} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx index f6906a70..1605958f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx @@ -18,6 +18,7 @@ import { networkToCaip19, networkToScope } from '../../../handlers'; import { displayAmount, displayExchangeAmount, + errorCodeToLabel, exchangeAmount, translate, } from '../format'; @@ -70,7 +71,14 @@ export const SendForm = (props: SendFormProps): JSXElement => { {null} {null} - + { /> - + `${url}/address/${address}`; + +export const errorCodeToLabel = (code: number): string => { + const raw = BdkErrorCode[code] as string | undefined; + if (!raw) { + return 'unknownError'; + } + + // lowercase the first letter to respect camelCase convention + return raw.charAt(0).toLowerCase() + raw.slice(1); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index df1b9a9d..3159091f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -18,6 +18,7 @@ import type { ReviewTransactionContext, AssetRatesClient, Logger, + CodifiedError, } from '../entities'; import { ReviewTransactionEvent, @@ -146,9 +147,9 @@ describe('SendFlowUseCases', () => { drain: false, recipient: 'recipientAddress', errors: { - recipient: 'invalid recipient', - tx: 'errors on tx', - amount: 'invalid amount', + recipient: mock(), + tx: mock(), + amount: mock(), }, feeRate: 2.4, network: 'bitcoin', @@ -440,6 +441,52 @@ describe('SendFlowUseCases', () => { ); }); + it('populate errors successfully', async () => { + const mockError = mock({ + code: 18, + message: 'base58 error', + }); + (Address.from_string as jest.Mock).mockImplementation(() => { + throw mockError as unknown as Error; + }); + + const testContext = { + ...mockContext, + amount: undefined, // avoid computing the fee in this test + }; + + mockSendFlowRepository.getState.mockResolvedValue({ + recipient: 'notAnAddress', + amount: '', + account: { + accountId: 'myAccount', + }, + }); + + const expectedContext = { + ...testContext, + errors: { + ...testContext.errors, + tx: undefined, + recipient: mockError, + }, + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Recipient, + testContext, + ); + + expect(mockSendFlowRepository.getState).toHaveBeenCalledWith( + 'interface-id', + ); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + it('sets amount from state on Amount: switched currencies', async () => { (Amount.from_sat as jest.Mock).mockReturnValue({ to_sat: () => BigInt('22222'), diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index 0d14ddf4..937bbb8e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -11,6 +11,7 @@ import type { ReviewTransactionContext, AssetRatesClient, Logger, + CodifiedError, } from '../entities'; import { SendFormEvent, @@ -238,9 +239,14 @@ export class SendFlowUseCases { ).toString(); updatedContext = await this.#computeFee(updatedContext); } catch (error) { + this.#logger.error( + `Invalid recipient. Error: %s`, + (error as CodifiedError).message, + ); + updatedContext.errors = { ...updatedContext.errors, - recipient: error instanceof Error ? error.message : String(error), + recipient: error as CodifiedError, }; } @@ -276,9 +282,14 @@ export class SendFlowUseCases { updatedContext.amount = amount.to_sat().toString(); updatedContext = await this.#computeFee(updatedContext); } catch (error) { + this.#logger.error( + `Invalid amount. Error: %s`, + (error as CodifiedError).message, + ); + updatedContext.errors = { ...updatedContext.errors, - amount: error instanceof Error ? error.message : String(error), + amount: error as CodifiedError, }; } @@ -309,22 +320,38 @@ export class SendFlowUseCases { } else { builder.addRecipient(context.amount, context.recipient); } - const psbt = builder.finish(); - - const reviewContext: ReviewTransactionContext = { - from: context.account.address, - explorerUrl: this.#chainClient.getExplorerUrl(context.network), - network: context.network, - amount: context.amount, - recipient: context.recipient, - exchangeRate: context.exchangeRate, - currency: context.currency, - psbt: psbt.toString(), - sendForm: context, - locale: context.locale, - }; - return this.#sendFlowRepository.updateReview(id, reviewContext); + try { + const psbt = builder.finish(); + const reviewContext: ReviewTransactionContext = { + from: context.account.address, + explorerUrl: this.#chainClient.getExplorerUrl(context.network), + network: context.network, + amount: context.amount, + recipient: context.recipient, + exchangeRate: context.exchangeRate, + currency: context.currency, + psbt: psbt.toString(), + sendForm: context, + locale: context.locale, + }; + + return this.#sendFlowRepository.updateReview(id, reviewContext); + } catch (error) { + this.#logger.error( + `Failed to build PSBT on Confirm. Error: %s`, + (error as CodifiedError).message, + ); + + const errContext = { + ...context, + errors: { + ...context.errors, + tx: error as CodifiedError, + }, + }; + return await this.#sendFlowRepository.updateForm(id, errContext); + } } throw new Error('Inconsistent Send form context'); @@ -445,11 +472,16 @@ export class SendFlowUseCases { const psbt = builder.addRecipient(amount, recipient).finish(); return { ...context, fee: psbt.fee().to_sat().toString(), balance }; } catch (error) { + this.#logger.error( + `Failed to build PSBT. Error: %s`, + (error as CodifiedError).message, + ); + return { ...context, errors: { ...context.errors, - tx: error instanceof Error ? error.message : String(error), + tx: error as CodifiedError, }, }; } From 722fd972e3f357616b3e1a2d59db07c30962e420 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 18:03:58 +0200 Subject: [PATCH 247/362] New Crowdin translations by Github Action (#483) --- .../bitcoin-wallet-snap/locales/de-DE.json | 110 +++++++++++++----- .../bitcoin-wallet-snap/locales/el-GR.json | 108 ++++++++++++----- .../bitcoin-wallet-snap/locales/en-GB.json | 108 ++++++++++++----- .../bitcoin-wallet-snap/locales/es-419.json | 108 ++++++++++++----- .../bitcoin-wallet-snap/locales/fr-FR.json | 108 ++++++++++++----- .../bitcoin-wallet-snap/locales/hi-IN.json | 108 ++++++++++++----- .../bitcoin-wallet-snap/locales/id-ID.json | 108 ++++++++++++----- .../bitcoin-wallet-snap/locales/ja-JP.json | 108 ++++++++++++----- .../bitcoin-wallet-snap/locales/ko-KR.json | 108 ++++++++++++----- .../bitcoin-wallet-snap/locales/pt-BR.json | 108 ++++++++++++----- .../bitcoin-wallet-snap/locales/ru-RU.json | 108 ++++++++++++----- .../bitcoin-wallet-snap/locales/tl-PH.json | 108 ++++++++++++----- .../bitcoin-wallet-snap/locales/tr-TR.json | 108 ++++++++++++----- .../bitcoin-wallet-snap/locales/vi-VN.json | 108 ++++++++++++----- .../bitcoin-wallet-snap/locales/zh-CN.json | 108 ++++++++++++----- 15 files changed, 1216 insertions(+), 406 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/locales/de-DE.json b/merged-packages/bitcoin-wallet-snap/locales/de-DE.json index e3a4090d..8e8c3a5e 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/de-DE.json +++ b/merged-packages/bitcoin-wallet-snap/locales/de-DE.json @@ -1,33 +1,24 @@ { "locale": "de", "messages": { - "snapDescription": { - "message": "Verwaltung von Bitcoin mit MetaMask" - }, - "snapProposedName": { - "message": "Bitcoin" - }, "reviewTransactionWarning": { "message": "Überprüfen Sie die Transaktion, bevor Sie fortfahren" }, - "loading": { - "message": "Wird geladen" - }, "from": { "message": "Von" }, "toAddress": { - "message": "To Address" + "message": "To" }, - "fromAccount": { - "message": "Von Konto" - }, - "review": { - "message": "Überprüfung" + "continue": { + "message": "Continue" }, "cancel": { "message": "Abbrechen" }, + "clear": { + "message": "Clear" + }, "amount": { "message": "Betrag" }, @@ -64,32 +55,95 @@ "send": { "message": "Senden" }, + "asset": { + "message": "Asset" + }, "sending": { "message": "Wird gesendet" }, - "sendAmount": { - "message": "Betrag senden" - }, - "amountPlaceholder": { - "message": "Enter amount to send" - }, "max": { "message": "Max." }, "recipientPlaceholder": { "message": "Enter receiving address" }, - "validAddress": { - "message": "Gültige Bitcoin-Adresse" - }, - "preparingTransaction": { - "message": "Transaktion wird vorbereitet" + "review": { + "message": "Überprüfung" }, "error": { "message": "Fehler" }, - "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/el-GR.json b/merged-packages/bitcoin-wallet-snap/locales/el-GR.json index 1e6f8d72..9ea58cfd 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/el-GR.json +++ b/merged-packages/bitcoin-wallet-snap/locales/el-GR.json @@ -1,33 +1,24 @@ { "locale": "el", "messages": { - "snapDescription": { - "message": "Διαχειριστείτε τα Bitcoins χρησιμοποιώντας το MetaMask" - }, - "snapProposedName": { - "message": "Bitcoin" - }, "reviewTransactionWarning": { "message": "Ελέγξτε τη συναλλαγή πριν προχωρήσετε" }, - "loading": { - "message": "Φόρτωση" - }, "from": { "message": "Από" }, "toAddress": { "message": "To Address" }, - "fromAccount": { - "message": "Από λογαριασμό" - }, - "review": { - "message": "Έλεγχος" + "continue": { + "message": "Continue" }, "cancel": { "message": "Άκυρο" }, + "clear": { + "message": "Clear" + }, "amount": { "message": "Ποσό" }, @@ -64,32 +55,95 @@ "send": { "message": "Εστάλη" }, + "asset": { + "message": "Asset" + }, "sending": { "message": "Αποστολή" }, - "sendAmount": { - "message": "Ποσό προς αποστολή" - }, - "amountPlaceholder": { - "message": "Enter amount to send" - }, "max": { "message": "Μεγ" }, "recipientPlaceholder": { "message": "Enter receiving address" }, - "validAddress": { - "message": "Έγκυρη διεύθυνση των bitcoins" - }, - "preparingTransaction": { - "message": "Προετοιμασία συναλλαγής" + "review": { + "message": "Έλεγχος" }, "error": { "message": "Σφάλμα" }, - "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/en-GB.json b/merged-packages/bitcoin-wallet-snap/locales/en-GB.json index 7b93a3c9..3c7fd265 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/en-GB.json +++ b/merged-packages/bitcoin-wallet-snap/locales/en-GB.json @@ -1,33 +1,24 @@ { "locale": "en", "messages": { - "snapDescription": { - "message": "Manage Bitcoin using MetaMask" - }, - "snapProposedName": { - "message": "Bitcoin" - }, "reviewTransactionWarning": { "message": "Review the transaction before proceeding" }, - "loading": { - "message": "Loading" - }, "from": { "message": "From" }, "toAddress": { "message": "To Address" }, - "fromAccount": { - "message": "From Account" - }, - "review": { - "message": "Review" + "continue": { + "message": "Continue" }, "cancel": { "message": "Cancel" }, + "clear": { + "message": "Clear" + }, "amount": { "message": "Amount" }, @@ -64,32 +55,95 @@ "send": { "message": "Send" }, + "asset": { + "message": "Asset" + }, "sending": { "message": "Sending" }, - "sendAmount": { - "message": "Send Amount" - }, - "amountPlaceholder": { - "message": "Enter amount to send" - }, "max": { "message": "Max" }, "recipientPlaceholder": { "message": "Enter receiving address" }, - "validAddress": { - "message": "Valid bitcoin address" - }, - "preparingTransaction": { - "message": "Preparing transaction" + "review": { + "message": "Review" }, "error": { "message": "Error" }, - "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/es-419.json b/merged-packages/bitcoin-wallet-snap/locales/es-419.json index f7e1cb21..f60f41ad 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/es-419.json +++ b/merged-packages/bitcoin-wallet-snap/locales/es-419.json @@ -1,33 +1,24 @@ { "locale": "es", "messages": { - "snapDescription": { - "message": "Gestione Bitcoin con MetaMask" - }, - "snapProposedName": { - "message": "Bitcoin" - }, "reviewTransactionWarning": { "message": "Revise la transacción antes de continuar" }, - "loading": { - "message": "Cargando" - }, "from": { "message": "De" }, "toAddress": { "message": "To Address" }, - "fromAccount": { - "message": "Desde la cuenta" - }, - "review": { - "message": "Revisar" + "continue": { + "message": "Continue" }, "cancel": { "message": "Cancelar" }, + "clear": { + "message": "Clear" + }, "amount": { "message": "Monto" }, @@ -64,32 +55,95 @@ "send": { "message": "Enviar" }, + "asset": { + "message": "Asset" + }, "sending": { "message": "Enviando" }, - "sendAmount": { - "message": "Enviar monto" - }, - "amountPlaceholder": { - "message": "Enter amount to send" - }, "max": { "message": "Máx." }, "recipientPlaceholder": { "message": "Enter receiving address" }, - "validAddress": { - "message": "Dirección de bitcoin válida" - }, - "preparingTransaction": { - "message": "Preparando la transacción" + "review": { + "message": "Revisar" }, "error": { "message": "Error" }, - "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/fr-FR.json b/merged-packages/bitcoin-wallet-snap/locales/fr-FR.json index 0ce8348e..ef0b52d8 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/fr-FR.json +++ b/merged-packages/bitcoin-wallet-snap/locales/fr-FR.json @@ -1,33 +1,24 @@ { "locale": "fr", "messages": { - "snapDescription": { - "message": "Gérer les bitcoins avec MetaMask" - }, - "snapProposedName": { - "message": "Bitcoin" - }, "reviewTransactionWarning": { "message": "Vérifiez la transaction avant de continuer" }, - "loading": { - "message": "Chargement" - }, "from": { "message": "De" }, "toAddress": { "message": "To Address" }, - "fromAccount": { - "message": "Depuis le compte" - }, - "review": { - "message": "Vérifier" + "continue": { + "message": "Continue" }, "cancel": { "message": "Annuler" }, + "clear": { + "message": "Clear" + }, "amount": { "message": "Montant" }, @@ -64,32 +55,95 @@ "send": { "message": "Envoyer" }, + "asset": { + "message": "Asset" + }, "sending": { "message": "Envoi" }, - "sendAmount": { - "message": "Montant à envoyer" - }, - "amountPlaceholder": { - "message": "Enter amount to send" - }, "max": { "message": "Max." }, "recipientPlaceholder": { "message": "Enter receiving address" }, - "validAddress": { - "message": "Adresse bitcoin valide" - }, - "preparingTransaction": { - "message": "Préparation de la transaction" + "review": { + "message": "Vérifier" }, "error": { "message": "Erreur" }, - "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/hi-IN.json b/merged-packages/bitcoin-wallet-snap/locales/hi-IN.json index 0baf0117..f7f90c9d 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/hi-IN.json +++ b/merged-packages/bitcoin-wallet-snap/locales/hi-IN.json @@ -1,33 +1,24 @@ { "locale": "hi", "messages": { - "snapDescription": { - "message": "MetaMask का उपयोग करके बिटकॉइन को मैनेज करें" - }, - "snapProposedName": { - "message": "बिटकॉइन" - }, "reviewTransactionWarning": { "message": "आगे बढ़ने से पहले ट्रांसेक्शन की समीक्षा करें" }, - "loading": { - "message": "लोड हो रहा है" - }, "from": { "message": "इनके द्वारा" }, "toAddress": { "message": "To Address" }, - "fromAccount": { - "message": "अकाउंट से" - }, - "review": { - "message": "समीक्षा करें" + "continue": { + "message": "Continue" }, "cancel": { "message": "कैंसिल करें" }, + "clear": { + "message": "Clear" + }, "amount": { "message": "अमाउंट" }, @@ -64,32 +55,95 @@ "send": { "message": "भेजें" }, + "asset": { + "message": "Asset" + }, "sending": { "message": "भेजा जा रहा है" }, - "sendAmount": { - "message": "अमाउंट भेजें" - }, - "amountPlaceholder": { - "message": "Enter amount to send" - }, "max": { "message": "अधिकतम" }, "recipientPlaceholder": { "message": "Enter receiving address" }, - "validAddress": { - "message": "वैध बिटकॉइन एड्रेस" - }, - "preparingTransaction": { - "message": "ट्रांसेक्शन तैयार किया जा रहा है" + "review": { + "message": "समीक्षा करें" }, "error": { "message": "एरर" }, - "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/id-ID.json b/merged-packages/bitcoin-wallet-snap/locales/id-ID.json index 9c0e3c67..9468b4b3 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/id-ID.json +++ b/merged-packages/bitcoin-wallet-snap/locales/id-ID.json @@ -1,33 +1,24 @@ { "locale": "id", "messages": { - "snapDescription": { - "message": "Kelola Bitcoin menggunakan MetaMask" - }, - "snapProposedName": { - "message": "Bitcoin" - }, "reviewTransactionWarning": { "message": "Tinjau transaksi sebelum melanjutkan" }, - "loading": { - "message": "Memuat" - }, "from": { "message": "Dari" }, "toAddress": { "message": "To Address" }, - "fromAccount": { - "message": "Dari Akun" - }, - "review": { - "message": "Tinjau" + "continue": { + "message": "Continue" }, "cancel": { "message": "Batal" }, + "clear": { + "message": "Clear" + }, "amount": { "message": "Jumlah" }, @@ -64,32 +55,95 @@ "send": { "message": "Kirim" }, + "asset": { + "message": "Asset" + }, "sending": { "message": "Mengirim" }, - "sendAmount": { - "message": "Kirim Jumlah" - }, - "amountPlaceholder": { - "message": "Enter amount to send" - }, "max": { "message": "Maks" }, "recipientPlaceholder": { "message": "Enter receiving address" }, - "validAddress": { - "message": "Alamat bitcoin yang valid" - }, - "preparingTransaction": { - "message": "Mempersiapkan transaksi" + "review": { + "message": "Tinjau" }, "error": { "message": "Kesalahan" }, - "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/ja-JP.json b/merged-packages/bitcoin-wallet-snap/locales/ja-JP.json index 9a21891d..b626675a 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/ja-JP.json +++ b/merged-packages/bitcoin-wallet-snap/locales/ja-JP.json @@ -1,33 +1,24 @@ { "locale": "en", "messages": { - "snapDescription": { - "message": "MetaMaskでビットコインを管理" - }, - "snapProposedName": { - "message": "ビットコイン" - }, "reviewTransactionWarning": { "message": "進める前にトランザクションを確認してください" }, - "loading": { - "message": "読み込み中" - }, "from": { "message": "送金元" }, "toAddress": { "message": "To Address" }, - "fromAccount": { - "message": "送金元アカウント" - }, - "review": { - "message": "確認" + "continue": { + "message": "Continue" }, "cancel": { "message": "キャンセル" }, + "clear": { + "message": "Clear" + }, "amount": { "message": "金額" }, @@ -64,32 +55,95 @@ "send": { "message": "送金" }, + "asset": { + "message": "Asset" + }, "sending": { "message": "送金中" }, - "sendAmount": { - "message": "送金額" - }, - "amountPlaceholder": { - "message": "Enter amount to send" - }, "max": { "message": "最大" }, "recipientPlaceholder": { "message": "Enter receiving address" }, - "validAddress": { - "message": "有効なビットコインアドレス" - }, - "preparingTransaction": { - "message": "トランザクションを準備中" + "review": { + "message": "確認" }, "error": { "message": "エラー" }, - "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/ko-KR.json b/merged-packages/bitcoin-wallet-snap/locales/ko-KR.json index 23bcec62..393c927e 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/ko-KR.json +++ b/merged-packages/bitcoin-wallet-snap/locales/ko-KR.json @@ -1,33 +1,24 @@ { "locale": "ko", "messages": { - "snapDescription": { - "message": "MetaMask로 비트코인 관리" - }, - "snapProposedName": { - "message": "비트코인" - }, "reviewTransactionWarning": { "message": "계속하기 전에 트랜잭션을 검토하세요" }, - "loading": { - "message": "불러오는 중" - }, "from": { "message": "보내는 사람" }, "toAddress": { "message": "To Address" }, - "fromAccount": { - "message": "보내는 계정" - }, - "review": { - "message": "검토" + "continue": { + "message": "Continue" }, "cancel": { "message": "취소" }, + "clear": { + "message": "Clear" + }, "amount": { "message": "금액" }, @@ -64,32 +55,95 @@ "send": { "message": "보내기" }, + "asset": { + "message": "Asset" + }, "sending": { "message": "보내는 중" }, - "sendAmount": { - "message": "보낼 금액" - }, - "amountPlaceholder": { - "message": "Enter amount to send" - }, "max": { "message": "최대" }, "recipientPlaceholder": { "message": "Enter receiving address" }, - "validAddress": { - "message": "유효한 비트코인 주소" - }, - "preparingTransaction": { - "message": "트랜잭션 준비 중" + "review": { + "message": "검토" }, "error": { "message": "오류" }, - "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/pt-BR.json b/merged-packages/bitcoin-wallet-snap/locales/pt-BR.json index e947b92e..7a15fa09 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/pt-BR.json +++ b/merged-packages/bitcoin-wallet-snap/locales/pt-BR.json @@ -1,33 +1,24 @@ { "locale": "pt-BR", "messages": { - "snapDescription": { - "message": "Gerencie Bitcoins usando MetaMask" - }, - "snapProposedName": { - "message": "Bitcoin" - }, "reviewTransactionWarning": { "message": "Revise a transação antes de prosseguir" }, - "loading": { - "message": "Carregando" - }, "from": { "message": "De" }, "toAddress": { "message": "To Address" }, - "fromAccount": { - "message": "Da conta" - }, - "review": { - "message": "Revisar" + "continue": { + "message": "Continue" }, "cancel": { "message": "Cancelar" }, + "clear": { + "message": "Clear" + }, "amount": { "message": "Valor" }, @@ -64,32 +55,95 @@ "send": { "message": "Enviar" }, + "asset": { + "message": "Asset" + }, "sending": { "message": "Enviando" }, - "sendAmount": { - "message": "Enviar valor" - }, - "amountPlaceholder": { - "message": "Enter amount to send" - }, "max": { "message": "Máx." }, "recipientPlaceholder": { "message": "Enter receiving address" }, - "validAddress": { - "message": "Endereço de bitcoin válido" - }, - "preparingTransaction": { - "message": "Preparando transação" + "review": { + "message": "Revisar" }, "error": { "message": "Erro" }, - "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/ru-RU.json b/merged-packages/bitcoin-wallet-snap/locales/ru-RU.json index 1b43b68c..cd29a778 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/ru-RU.json +++ b/merged-packages/bitcoin-wallet-snap/locales/ru-RU.json @@ -1,33 +1,24 @@ { "locale": "ru", "messages": { - "snapDescription": { - "message": "Управляйте биткойнами с помощью MetaMask" - }, - "snapProposedName": { - "message": "Биткойн" - }, "reviewTransactionWarning": { "message": "Проверьте транзакцию, прежде чем продолжить" }, - "loading": { - "message": "Загрузка..." - }, "from": { "message": "От" }, "toAddress": { "message": "To Address" }, - "fromAccount": { - "message": "Со счета" - }, - "review": { - "message": "Просмотр" + "continue": { + "message": "Continue" }, "cancel": { "message": "Отмена" }, + "clear": { + "message": "Clear" + }, "amount": { "message": "Сумма" }, @@ -64,32 +55,95 @@ "send": { "message": "Отправить" }, + "asset": { + "message": "Asset" + }, "sending": { "message": "Отправка..." }, - "sendAmount": { - "message": "Отправляемая сумма" - }, - "amountPlaceholder": { - "message": "Enter amount to send" - }, "max": { "message": "Макс." }, "recipientPlaceholder": { "message": "Enter receiving address" }, - "validAddress": { - "message": "Действительный адрес биткойна" - }, - "preparingTransaction": { - "message": "Подготовка транзакции..." + "review": { + "message": "Просмотр" }, "error": { "message": "Ошибка" }, - "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/tl-PH.json b/merged-packages/bitcoin-wallet-snap/locales/tl-PH.json index 726da283..b96afd40 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/tl-PH.json +++ b/merged-packages/bitcoin-wallet-snap/locales/tl-PH.json @@ -1,33 +1,24 @@ { "locale": "tl", "messages": { - "snapDescription": { - "message": "Pamahalaan ang Bitcoin gamit ang MetaMask" - }, - "snapProposedName": { - "message": "Bitcoin" - }, "reviewTransactionWarning": { "message": "Suriin ang transaksyon bago magpatuloy" }, - "loading": { - "message": "Naglo-load" - }, "from": { "message": "Mula sa" }, "toAddress": { "message": "To Address" }, - "fromAccount": { - "message": "Mula sa Account" - }, - "review": { - "message": "Suriin" + "continue": { + "message": "Continue" }, "cancel": { "message": "Kanselahin" }, + "clear": { + "message": "Clear" + }, "amount": { "message": "Halaga" }, @@ -64,32 +55,95 @@ "send": { "message": "Ipadala" }, + "asset": { + "message": "Asset" + }, "sending": { "message": "Ipinapadala" }, - "sendAmount": { - "message": "Ipadala ang Halaga" - }, - "amountPlaceholder": { - "message": "Enter amount to send" - }, "max": { "message": "Max" }, "recipientPlaceholder": { "message": "Enter receiving address" }, - "validAddress": { - "message": "Valid na address ng bitcoin" - }, - "preparingTransaction": { - "message": "Inihahanda ang transaksyon" + "review": { + "message": "Suriin" }, "error": { "message": "Error" }, - "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/tr-TR.json b/merged-packages/bitcoin-wallet-snap/locales/tr-TR.json index a08757dd..01efb72b 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/tr-TR.json +++ b/merged-packages/bitcoin-wallet-snap/locales/tr-TR.json @@ -1,33 +1,24 @@ { "locale": "tr", "messages": { - "snapDescription": { - "message": "MetaMask'i kullanarak Bitcoin'i yönetin" - }, - "snapProposedName": { - "message": "Bitcoin" - }, "reviewTransactionWarning": { "message": "Devam etmeden önce işlemi inceleyin" }, - "loading": { - "message": "Yükleniyor" - }, "from": { "message": "Gönderen" }, "toAddress": { "message": "To Address" }, - "fromAccount": { - "message": "Gönderen Hesap" - }, - "review": { - "message": "İncele" + "continue": { + "message": "Continue" }, "cancel": { "message": "İptal" }, + "clear": { + "message": "Clear" + }, "amount": { "message": "Miktar" }, @@ -64,32 +55,95 @@ "send": { "message": "Gönder" }, + "asset": { + "message": "Asset" + }, "sending": { "message": "Gönderiliyor" }, - "sendAmount": { - "message": "Gönderilecek Miktar" - }, - "amountPlaceholder": { - "message": "Enter amount to send" - }, "max": { "message": "Maksimum" }, "recipientPlaceholder": { "message": "Enter receiving address" }, - "validAddress": { - "message": "Geçerli bitcoin adresi" - }, - "preparingTransaction": { - "message": "İşlem hazırlanıyor" + "review": { + "message": "İncele" }, "error": { "message": "Hata" }, - "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/vi-VN.json b/merged-packages/bitcoin-wallet-snap/locales/vi-VN.json index 937507c9..7f2b8cda 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/vi-VN.json +++ b/merged-packages/bitcoin-wallet-snap/locales/vi-VN.json @@ -1,33 +1,24 @@ { "locale": "vi", "messages": { - "snapDescription": { - "message": "Quản lý Bitcoin bằng MetaMask" - }, - "snapProposedName": { - "message": "Bitcoin" - }, "reviewTransactionWarning": { "message": "Xem lại giao dịch trước khi tiếp tục" }, - "loading": { - "message": "Đang tải" - }, "from": { "message": "Từ" }, "toAddress": { "message": "To Address" }, - "fromAccount": { - "message": "Từ tài khoản" - }, - "review": { - "message": "Xem lại" + "continue": { + "message": "Continue" }, "cancel": { "message": "Hủy" }, + "clear": { + "message": "Clear" + }, "amount": { "message": "Số tiền" }, @@ -64,32 +55,95 @@ "send": { "message": "Gửi" }, + "asset": { + "message": "Asset" + }, "sending": { "message": "Đang gửi" }, - "sendAmount": { - "message": "Số tiền gửi" - }, - "amountPlaceholder": { - "message": "Enter amount to send" - }, "max": { "message": "Tối đa" }, "recipientPlaceholder": { "message": "Enter receiving address" }, - "validAddress": { - "message": "Địa chỉ bitcoin hợp lệ" - }, - "preparingTransaction": { - "message": "Đang chuẩn bị giao dịch" + "review": { + "message": "Xem lại" }, "error": { "message": "Lỗi" }, - "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/zh-CN.json b/merged-packages/bitcoin-wallet-snap/locales/zh-CN.json index e9be25c9..6ec9a606 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/zh-CN.json +++ b/merged-packages/bitcoin-wallet-snap/locales/zh-CN.json @@ -1,33 +1,24 @@ { "locale": "中文", "messages": { - "snapDescription": { - "message": "使用 MetaMask 管理比特币" - }, - "snapProposedName": { - "message": "比特币" - }, "reviewTransactionWarning": { "message": "继续之前,请先审查交易" }, - "loading": { - "message": "加载中" - }, "from": { "message": "从" }, "toAddress": { "message": "To Address" }, - "fromAccount": { - "message": "从账户" - }, - "review": { - "message": "审查" + "continue": { + "message": "Continue" }, "cancel": { "message": "取消" }, + "clear": { + "message": "Clear" + }, "amount": { "message": "金额" }, @@ -64,32 +55,95 @@ "send": { "message": "发送" }, + "asset": { + "message": "Asset" + }, "sending": { "message": "发送中" }, - "sendAmount": { - "message": "发送金额" - }, - "amountPlaceholder": { - "message": "Enter amount to send" - }, "max": { "message": "最多" }, "recipientPlaceholder": { "message": "Enter receiving address" }, - "validAddress": { - "message": "有效的比特币地址" - }, - "preparingTransaction": { - "message": "准备交易" + "review": { + "message": "审查" }, "error": { "message": "错误" }, - "satProtectionTooltip": { - "message": "MetaMask is protecting your Ordinals, Rare SATs, and Runes to be send in Bitcoin Transactions." + "unknownError": { + "message": "An unknown error occurred" + }, + "feeTooLow": { + "message": "Fee too low" + }, + "feeRateTooLow": { + "message": "Fee rate too low" + }, + "noUtxosSelected": { + "message": "Failed to build transaction: missing UTXOs" + }, + "outputBelowDustLimit": { + "message": "Amount below dust limit" + }, + "insufficientFunds": { + "message": "Funds are insufficient to cover amount plus fee" + }, + "noRecipients": { + "message": "Missing recipients" + }, + "psbt": { + "message": "Invalid PSBT" + }, + "unknownUtxo": { + "message": "Failed to build transaction: unknown UTXO" + }, + "MmssingNonWitnessUtxo": { + "message": "Failed to build transaction: missing non-witness UTXO" + }, + "base58": { + "message": "Invalid Bitcoin address" + }, + "bech32": { + "message": "Invalid Bitcoin address" + }, + "witnessVersion": { + "message": "Invalid Bitcoin address: witness version" + }, + "witnessProgram": { + "message": "Invalid Bitcoin address: witness program" + }, + "legacyAddressTooLong": { + "message": "Invalid Bitcoin address" + }, + "invalidBase58PayloadLength": { + "message": "Invalid Bitcoin address: invalid length" + }, + "invalidLegacyPrefix": { + "message": "Invalid Bitcoin address: invalid legacy prefix" + }, + "networkValidation": { + "message": "Invalid Bitcoin address: wrong network" + }, + "outOfRange": { + "message": "Invalid amount: out of range" + }, + "tooPrecise": { + "message": "Invalid amount: too precise" + }, + "missingDigits": { + "message": "Invalid amount: missing digits" + }, + "inputTooLarge": { + "message": "Invalid amount: too large" + }, + "invalidCharacter": { + "message": "Invalid amount: invalid character" + }, + "unexpected": { + "message": "An unexpected error occurred" } } } From 03d8ca9caaeb590f6f44992a80bee3f04505745a Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 1 Jul 2025 18:44:52 +0200 Subject: [PATCH 248/362] feat: use event value (#485) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/entities/send-flow.ts | 18 ----- .../src/handlers/UserInputHandler.test.ts | 8 ++- .../src/handlers/UserInputHandler.ts | 12 +++- .../src/infra/jsx/components/SendForm.tsx | 4 +- .../src/store/JSXSendFlowRepository.test.tsx | 31 -------- .../src/store/JSXSendFlowRepository.tsx | 11 --- .../src/use-cases/SendFlowUseCases.test.ts | 72 +++---------------- .../src/use-cases/SendFlowUseCases.ts | 55 +++++++------- 9 files changed, 57 insertions(+), 156 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 0956f67d..cd4f4ce7 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "+rIMq2Mzl6E98rCf/EbrqSz8lpuxxT9VnRNWsKQdvCQ=", + "shasum": "pzSZe4x+uuGS1r3efYkXwfKtw+Jx/BMCiKOj/2s1li8=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts index 2c74cc3c..fe3c1229 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts @@ -4,8 +4,6 @@ import type { CurrencyRate } from '@metamask/snaps-sdk'; import type { CurrencyUnit } from './currency'; import type { CodifiedError } from './error'; -export const SENDFORM_NAME = 'sendForm'; - export type SendFormContext = { account: { id: string; @@ -42,14 +40,6 @@ export enum SendFormEvent { SwitchCurrency = 'switchCurrency', } -export type SendFormState = { - account: { - accountId: string; - }; - recipient: string; - amount: string; -}; - export type ReviewTransactionContext = { from: string; explorerUrl: string; @@ -78,14 +68,6 @@ export enum ReviewTransactionEvent { * SendFlowRepository is a repository that manages Bitcoin Send flow interfaces. */ export type SendFlowRepository = { - /** - * Get the form state. - * - * @param id - the interface ID - * @returns the form state or null - */ - getState(id: string): Promise; - /** * Get the form context. * diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts index ecd11cea..d155de7f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts @@ -57,16 +57,18 @@ describe('UserInputHandler', () => { await handler.route( 'interface-id', { - type: UserInputEventType.ButtonClickEvent, - name: SendFormEvent.ClearRecipient, + type: UserInputEventType.InputChangeEvent, + name: SendFormEvent.Recipient, + value: 'recipient-address', }, mockContext, ); expect(mockSendFlowUseCases.onChangeForm).toHaveBeenCalledWith( 'interface-id', - SendFormEvent.ClearRecipient, + SendFormEvent.Recipient, mockContext, + 'recipient-address', ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts index a2d723c0..546bcb22 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts @@ -1,4 +1,8 @@ -import type { Json, UserInputEvent } from '@metamask/snaps-sdk'; +import type { + InputChangeEvent, + Json, + UserInputEvent, +} from '@metamask/snaps-sdk'; import type { ReviewTransactionContext, SendFormContext } from '../entities'; import { ReviewTransactionEvent, SendFormEvent } from '../entities'; @@ -21,7 +25,6 @@ export class UserInputHandler { if (!context) { throw new Error('Missing context'); } - if (!event.name) { throw new Error('Missing event name'); } @@ -31,6 +34,7 @@ export class UserInputHandler { interfaceId, event.name, context as SendFormContext, + this.#hasValue(event) ? event.value : undefined, ); } else if (this.#isReviewTransactionEvent(event.name)) { return this.#sendFlowUseCases.onChangeReview( @@ -53,4 +57,8 @@ export class UserInputHandler { name as ReviewTransactionEvent, ); } + + #hasValue(event: UserInputEvent): event is InputChangeEvent { + return 'value' in event; + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx index 1605958f..14c1383b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/SendForm.tsx @@ -13,7 +13,7 @@ import { import type { CaipAccountId } from '@metamask/utils'; import type { Messages, SendFormContext } from '../../../entities'; -import { CurrencyUnit, SENDFORM_NAME, SendFormEvent } from '../../../entities'; +import { CurrencyUnit, SendFormEvent } from '../../../entities'; import { networkToCaip19, networkToScope } from '../../../handlers'; import { displayAmount, @@ -56,7 +56,7 @@ export const SendForm = (props: SendFormProps): JSXElement => { }; return ( - + { const repo = new JSXSendFlowRepository(mockSnapClient, mockTranslator); - describe('getState', () => { - it('returns send form state if found', async () => { - const id = 'test-id'; - const state = { [SENDFORM_NAME]: 'bar' }; - mockSnapClient.getInterfaceState.mockResolvedValue(state); - - const result = await repo.getState(id); - - expect(mockSnapClient.getInterfaceState).toHaveBeenCalledWith(id); - expect(result).toStrictEqual(state[SENDFORM_NAME]); - }); - - it('returns null if state is null', async () => { - mockSnapClient.getInterfaceState.mockResolvedValue(null); - - const result = await repo.getState('test-id'); - - expect(result).toBeNull(); - }); - - it('returns null if send form state is not present in interface state', async () => { - const state = { unknownField: 'bar' }; - mockSnapClient.getInterfaceState.mockResolvedValue(state); - - const result = await repo.getState('test-id'); - - expect(result).toBeNull(); - }); - }); - describe('getContext', () => { it('returns context if found', async () => { const context = { foo: 'bar' }; diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx index 5deb62e4..129a6db5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx @@ -1,12 +1,10 @@ import type { SendFormContext, SendFlowRepository, - SendFormState, SnapClient, ReviewTransactionContext, Translator, } from '../entities'; -import { SENDFORM_NAME } from '../entities'; import { ReviewTransactionView, SendFormView } from '../infra/jsx'; export class JSXSendFlowRepository implements SendFlowRepository { @@ -19,15 +17,6 @@ export class JSXSendFlowRepository implements SendFlowRepository { this.#translator = translator; } - async getState(id: string): Promise { - const state = await this.#snapClient.getInterfaceState(id); - if (!state) { - return null; - } - - return (state[SENDFORM_NAME] as SendFormState) ?? null; - } - async getContext(id: string): Promise { return (await this.#snapClient.getInterfaceContext( id, diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 3159091f..d5fb9d4e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -354,7 +354,7 @@ describe('SendFlowUseCases', () => { ); }); - it('sets recipient from state on Recipient', async () => { + it('sets recipient on Recipient event', async () => { (Address.from_string as jest.Mock).mockReturnValue({ toString: () => 'newAddressValidated', }); @@ -365,13 +365,6 @@ describe('SendFlowUseCases', () => { amount: undefined, }; - mockSendFlowRepository.getState.mockResolvedValue({ - recipient: 'newAddress', - amount: '', - account: { - accountId: 'myAccount', - }, - }); const expectedContext = { ...testContext, recipient: 'newAddressValidated', @@ -386,18 +379,16 @@ describe('SendFlowUseCases', () => { 'interface-id', SendFormEvent.Recipient, testContext, + 'newAddress', ); - expect(mockSendFlowRepository.getState).toHaveBeenCalledWith( - 'interface-id', - ); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( 'interface-id', expectedContext, ); }); - it('sets amount from state on Amount', async () => { + it('sets amount on Amount event', async () => { (Amount.from_btc as jest.Mock).mockReturnValue({ to_sat: () => BigInt('1111'), // Use different amount than state to verify that we get the result from the toString of the bigint }); @@ -407,13 +398,6 @@ describe('SendFlowUseCases', () => { recipient: undefined, // avoid computing the fee in this test }; - mockSendFlowRepository.getState.mockResolvedValue({ - recipient: '', - amount: '21000', - account: { - accountId: 'myAccount', - }, - }); const expectedContext = { ...testContext, drain: undefined, @@ -430,11 +414,9 @@ describe('SendFlowUseCases', () => { 'interface-id', SendFormEvent.Amount, testContext, + '21000', ); - expect(mockSendFlowRepository.getState).toHaveBeenCalledWith( - 'interface-id', - ); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( 'interface-id', expectedContext, @@ -455,14 +437,6 @@ describe('SendFlowUseCases', () => { amount: undefined, // avoid computing the fee in this test }; - mockSendFlowRepository.getState.mockResolvedValue({ - recipient: 'notAnAddress', - amount: '', - account: { - accountId: 'myAccount', - }, - }); - const expectedContext = { ...testContext, errors: { @@ -476,18 +450,16 @@ describe('SendFlowUseCases', () => { 'interface-id', SendFormEvent.Recipient, testContext, + 'notAnAddress', ); - expect(mockSendFlowRepository.getState).toHaveBeenCalledWith( - 'interface-id', - ); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( 'interface-id', expectedContext, ); }); - it('sets amount from state on Amount: switched currencies', async () => { + it('sets amount on Amount: switched currencies', async () => { (Amount.from_sat as jest.Mock).mockReturnValue({ to_sat: () => BigInt('22222'), }); @@ -503,13 +475,6 @@ describe('SendFlowUseCases', () => { recipient: undefined, // avoid computing the fee in this test }; - mockSendFlowRepository.getState.mockResolvedValue({ - recipient: '', - amount: '100', // this represents usd - account: { - accountId: 'myAccount', - }, - }); const expectedContext = { ...testContext, drain: undefined, @@ -526,11 +491,9 @@ describe('SendFlowUseCases', () => { 'interface-id', SendFormEvent.Amount, testContext, + '100', ); - expect(mockSendFlowRepository.getState).toHaveBeenCalledWith( - 'interface-id', - ); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( 'interface-id', expectedContext, @@ -583,13 +546,6 @@ describe('SendFlowUseCases', () => { } as unknown as Amount); mockAccountRepository.get.mockResolvedValue(mockAccount); mockAccountRepository.getFrozenUTXOs.mockResolvedValue([]); - mockSendFlowRepository.getState.mockResolvedValue({ - recipient: 'newAddress', - amount: '', - account: { - accountId: 'myAccount', - }, - }); const expectedContext = { ...mockContext, @@ -624,13 +580,6 @@ describe('SendFlowUseCases', () => { address: mock
({ toString: () => 'myAddress2' }), }), ); - mockSendFlowRepository.getState.mockResolvedValue({ - recipient: '', - amount: '', - account: { - accountId, - }, - }); mockAccountRepository.get.mockResolvedValue({ ...mockAccount, id: accountId, @@ -650,11 +599,12 @@ describe('SendFlowUseCases', () => { 'interface-id', SendFormEvent.Account, mockContext, + { + accountId, + addresses: [], + }, ); - expect(mockSendFlowRepository.getState).toHaveBeenCalledWith( - 'interface-id', - ); expect(mockAccountRepository.get).toHaveBeenCalledWith(accountId); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( 'interface-id', diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index 937bbb8e..87ea2dcf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -1,5 +1,6 @@ import { Psbt, Address, Amount } from '@metamask/bitcoindevkit'; import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; +import type { InputChangeEvent } from '@metamask/snaps-sdk'; import { UserRejectedRequestError } from '@metamask/snaps-sdk'; import type { @@ -21,6 +22,10 @@ import { } from '../entities'; import { CronMethod } from '../handlers'; +type SetAccountEventValue = { + accountId: string; +}; + export class SendFlowUseCases { readonly #logger: Logger; @@ -105,6 +110,7 @@ export class SendFlowUseCases { id: string, event: SendFormEvent, context: SendFormContext, + value?: InputChangeEvent['value'], ): Promise { this.#logger.debug( 'Event triggered on send form: %s. Event: %s', @@ -147,10 +153,10 @@ export class SendFlowUseCases { return this.#handleSetMax(id, context); } case SendFormEvent.Recipient: { - return this.#handleSetRecipient(id, context); + return this.#handleSetRecipient(id, context, value as string); } case SendFormEvent.Amount: { - return this.#handleSetAmount(id, context); + return this.#handleSetAmount(id, context, value as string); } case SendFormEvent.SwitchCurrency: { if (!context.exchangeRate) { @@ -166,7 +172,11 @@ export class SendFlowUseCases { return this.#sendFlowRepository.updateForm(id, updatedContext); } case SendFormEvent.Account: { - return this.#handleSetAccount(id, context); + return this.#handleSetAccount( + id, + context, + value as SetAccountEventValue, + ); } case SendFormEvent.Asset: { // Do nothing as there are no other assets @@ -222,19 +232,15 @@ export class SendFlowUseCases { async #handleSetRecipient( id: string, context: SendFormContext, + formState: string, ): Promise { - const formState = await this.#sendFlowRepository.getState(id); - if (!formState) { - throw new Error(`Form state not found when setting recipient: ${id}`); - } - let updatedContext = { ...context }; delete updatedContext.errors.recipient; delete updatedContext.errors.tx; try { updatedContext.recipient = Address.from_string( - formState.recipient, + formState, context.network, ).toString(); updatedContext = await this.#computeFee(updatedContext); @@ -253,12 +259,11 @@ export class SendFlowUseCases { return await this.#sendFlowRepository.updateForm(id, updatedContext); } - async #handleSetAmount(id: string, context: SendFormContext): Promise { - const formState = await this.#sendFlowRepository.getState(id); - if (!formState) { - throw new Error(`Form state not found when setting amount: ${id}`); - } - + async #handleSetAmount( + id: string, + context: SendFormContext, + formState: string, + ): Promise { let updatedContext = { ...context }; delete updatedContext.errors.amount; delete updatedContext.errors.tx; @@ -270,13 +275,12 @@ export class SendFlowUseCases { if (context.currency === CurrencyUnit.Fiat && context.exchangeRate) { // keep everything in sats (integers) to avoid FP drift const sats = Math.round( - (Number(formState.amount) * 1e8) / - context.exchangeRate.conversionRate, + (Number(formState) * 1e8) / context.exchangeRate.conversionRate, ); amount = Amount.from_sat(BigInt(sats)); } else { // expects values to be entered in BTC and not satoshis - amount = Amount.from_btc(Number(formState.amount)); + amount = Amount.from_btc(Number(formState)); } updatedContext.amount = amount.to_sat().toString(); @@ -357,15 +361,12 @@ export class SendFlowUseCases { throw new Error('Inconsistent Send form context'); } - async #handleSetAccount(id: string, context: SendFormContext): Promise { - const formState = await this.#sendFlowRepository.getState(id); - if (!formState) { - throw new Error(`Form state not found when switching accounts: ${id}`); - } - - const account = await this.#accountRepository.get( - formState.account.accountId, - ); + async #handleSetAccount( + id: string, + context: SendFormContext, + formState: SetAccountEventValue, + ): Promise { + const account = await this.#accountRepository.get(formState.accountId); if (!account) { throw new Error('Account not found when switching'); } From b5b64b5db0ef8940cc6d4f79a1b6183897e8ba06 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 1 Jul 2025 21:05:05 +0200 Subject: [PATCH 249/362] feat: on asset market data (#486) --- .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../bitcoin-wallet-snap/src/entities/rates.ts | 7 +- .../src/handlers/AssetsHandler.test.ts | 82 ++++++++++++++++--- .../src/handlers/AssetsHandler.ts | 40 ++++++++- .../bitcoin-wallet-snap/src/index.ts | 8 +- .../src/infra/PriceApiClientAdapter.ts | 1 + .../src/use-cases/SendFlowUseCases.test.ts | 5 +- 8 files changed, 126 insertions(+), 23 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 76e6ca4b..5f320087 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -43,7 +43,7 @@ "@metamask/slip44": "^4.2.0", "@metamask/snaps-cli": "^8.1.0", "@metamask/snaps-jest": "^9.2.0", - "@metamask/snaps-sdk": "^8.1.0", + "@metamask/snaps-sdk": "^9.0.0", "@metamask/utils": "^11.4.0", "concurrently": "^9.2.0", "dotenv": "^17.0.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index cd4f4ce7..15dc0771 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "pzSZe4x+uuGS1r3efYkXwfKtw+Jx/BMCiKOj/2s1li8=", + "shasum": "KTRWlq9gJlaPjsur/OyWRudVBzcYQUB5loItb8SjuVQ=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -97,6 +97,6 @@ ] } }, - "platformVersion": "8.1.0", + "platformVersion": "9.0.0", "manifestVersion": "0.1" } diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts b/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts index e2342b6b..b694ed82 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/rates.ts @@ -1,4 +1,7 @@ -import type { HistoricalPriceValue, MarketData } from '@metamask/snaps-sdk'; +import type { + HistoricalPriceValue, + FungibleAssetMarketData, +} from '@metamask/snaps-sdk'; import type { CaipAssetType } from '@metamask/utils'; export type TimePeriod = 'P1D' | 'P7D' | 'P1M' | 'P3M' | 'P1Y' | 'P1000Y'; @@ -7,7 +10,7 @@ export type AssetRate = [CaipAssetType, SpotPrice | null]; export type SpotPrice = { price: number; - marketData?: MarketData; + marketData: FungibleAssetMarketData; }; export type AssetRatesClient = { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts index eb381e1b..31f666e1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts @@ -1,4 +1,7 @@ -import type { HistoricalPriceIntervals, MarketData } from '@metamask/snaps-sdk'; +import type { + HistoricalPriceIntervals, + FungibleAssetMarketData, +} from '@metamask/snaps-sdk'; import { SnapError } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; @@ -33,16 +36,9 @@ describe('AssetsHandler', () => { describe('conversion', () => { it('returns rates for all networks successfully', async () => { - const mockMarketData = mock(); mockAssetsUseCases.getRates.mockResolvedValue([ - [ - Caip19Asset.Testnet, - mock({ price: 0.1, marketData: mockMarketData }), - ], - [ - Caip19Asset.Regtest, - mock({ price: 0.2, marketData: mockMarketData }), - ], + [Caip19Asset.Testnet, mock({ price: 0.1 })], + [Caip19Asset.Regtest, mock({ price: 0.2 })], ]); const conversions = [ @@ -53,7 +49,7 @@ describe('AssetsHandler', () => { { from: Caip19Asset.Signet, to: Caip19Asset.Bitcoin }, { from: Caip19Asset.Regtest, to: Caip19Asset.Bitcoin }, ]; - const result = await handler.conversion(conversions, true); + const result = await handler.conversion(conversions); expect(mockAssetsUseCases.getRates).toHaveBeenCalledTimes(1); expect(mockAssetsUseCases.getRates).toHaveBeenCalledWith([ @@ -64,13 +60,11 @@ describe('AssetsHandler', () => { [Caip19Asset.Bitcoin]: { [Caip19Asset.Testnet]: { rate: '0.1', - marketData: mockMarketData, conversionTime: expect.any(Number), expirationTime: expect.any(Number), }, [Caip19Asset.Regtest]: { rate: '0.2', - marketData: mockMarketData, conversionTime: expect.any(Number), expirationTime: expect.any(Number), }, @@ -152,4 +146,66 @@ describe('AssetsHandler', () => { ).rejects.toThrow(new SnapError(error)); }); }); + + describe('marketData', () => { + it('returns market data for all assets successfully', async () => { + const mockMarketData = mock(); + mockAssetsUseCases.getRates.mockResolvedValue([ + [ + Caip19Asset.Testnet, + mock({ price: 0.1, marketData: mockMarketData }), + ], + [ + Caip19Asset.Regtest, + mock({ price: 0.2, marketData: mockMarketData }), + ], + ]); + + const assets = [ + { asset: Caip19Asset.Bitcoin, unit: Caip19Asset.Testnet }, + { asset: Caip19Asset.Bitcoin, unit: Caip19Asset.Regtest }, + { asset: Caip19Asset.Testnet, unit: Caip19Asset.Bitcoin }, + { asset: Caip19Asset.Testnet4, unit: Caip19Asset.Bitcoin }, + { asset: Caip19Asset.Signet, unit: Caip19Asset.Bitcoin }, + { asset: Caip19Asset.Regtest, unit: Caip19Asset.Bitcoin }, + ]; + const result = await handler.marketData(assets); + + expect(mockAssetsUseCases.getRates).toHaveBeenCalledTimes(1); + expect(mockAssetsUseCases.getRates).toHaveBeenCalledWith([ + Caip19Asset.Testnet, + Caip19Asset.Regtest, + ]); + expect(result.marketData).toStrictEqual({ + [Caip19Asset.Bitcoin]: { + [Caip19Asset.Testnet]: mockMarketData, + [Caip19Asset.Regtest]: mockMarketData, + }, + [Caip19Asset.Testnet]: { + [Caip19Asset.Bitcoin]: null, + }, + [Caip19Asset.Testnet4]: { + [Caip19Asset.Bitcoin]: null, + }, + [Caip19Asset.Signet]: { + [Caip19Asset.Bitcoin]: null, + }, + [Caip19Asset.Regtest]: { + [Caip19Asset.Bitcoin]: null, + }, + }); + }); + + it('propagates errors from getRates', async () => { + const assets = [ + { asset: Caip19Asset.Bitcoin, unit: Caip19Asset.Testnet }, + ]; + const error = new Error(); + mockAssetsUseCases.getRates.mockRejectedValue(error); + + await expect(handler.marketData(assets)).rejects.toThrow( + new SnapError(error), + ); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts index 641e4475..97930571 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts @@ -7,6 +7,8 @@ import type { OnAssetsConversionArguments, OnAssetsConversionResponse, OnAssetsLookupResponse, + OnAssetsMarketDataArguments, + OnAssetsMarketDataResponse, } from '@metamask/snaps-sdk'; import { CaipAssetTypeStruct } from '@metamask/utils'; import { assert } from 'superstruct'; @@ -87,7 +89,6 @@ export class AssetsHandler { async conversion( conversions: OnAssetsConversionArguments['conversions'], - includeMarketData?: boolean, ): Promise { const conversionTime = getCurrentUnixTimestamp(); @@ -113,7 +114,6 @@ export class AssetsHandler { conversionRates[fromKey][toAsset] = rate ? { rate: rate.price.toString(), - marketData: includeMarketData ? rate.marketData : undefined, conversionTime, expirationTime: conversionTime + this.#expirationInterval, } @@ -159,4 +159,40 @@ export class AssetsHandler { }; }); } + + async marketData( + assets: OnAssetsMarketDataArguments['assets'], + ): Promise { + // Group market data by "asset" + const assetMap: Record = {}; + for (const { asset, unit } of assets) { + assetMap[asset] ??= []; + assetMap[asset].push(unit); + } + + const marketData: OnAssetsMarketDataResponse['marketData'] = {}; + + return handle(async () => { + for (const [fromAsset, toAssets] of Object.entries(assetMap)) { + const fromKey = fromAsset as keyof typeof marketData; + marketData[fromKey] = {}; + + if (fromKey === (Caip19Asset.Bitcoin as CaipAssetType)) { + // For Bitcoin, fetch market data. + for (const [toAsset, rate] of await this.#assetsUseCases.getRates( + toAssets, + )) { + marketData[fromKey][toAsset] = rate ? rate.marketData : null; + } + } else { + // For every other assets, there is no market data. + for (const toAsset of toAssets) { + marketData[fromKey][toAsset] = null; + } + } + } + + return { marketData }; + }); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 4309150f..dc5508da 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -6,6 +6,7 @@ import type { OnKeyringRequestHandler, OnUserInputHandler, OnAssetHistoricalPriceHandler, + OnAssetsMarketDataHandler, } from '@metamask/snaps-sdk'; import { Config } from './config'; @@ -89,10 +90,13 @@ export const onAssetsLookup: OnAssetsLookupHandler = async () => export const onAssetsConversion: OnAssetsConversionHandler = async ({ conversions, - includeMarketData, -}) => assetsHandler.conversion(conversions, includeMarketData); +}) => assetsHandler.conversion(conversions); export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ({ from, to, }) => assetsHandler.historicalPrice(from, to); + +export const onAssetMarketData: OnAssetsMarketDataHandler = async ({ + assets, +}) => assetsHandler.marketData(assets); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts index 51de4a3c..b515cf74 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts @@ -51,6 +51,7 @@ export class PriceApiClientAdapter implements AssetRatesClient { return { price: prices.price, marketData: { + fungible: true, allTimeHigh: prices.allTimeHigh.toString(), allTimeLow: prices.allTimeLow.toString(), circulatingSupply: prices.circulatingSupply.toString(), diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index d5fb9d4e..8dd5538b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -19,6 +19,7 @@ import type { AssetRatesClient, Logger, CodifiedError, + SpotPrice, } from '../entities'; import { ReviewTransactionEvent, @@ -702,7 +703,9 @@ describe('SendFlowUseCases', () => { beforeEach(() => { mockSendFlowRepository.getContext.mockResolvedValue(mockContext); mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); - mockRatesClient.spotPrices.mockResolvedValue(mockExchangeRates); + mockRatesClient.spotPrices.mockResolvedValue( + mockExchangeRates as SpotPrice, + ); mockSnapClient.scheduleBackgroundEvent.mockResolvedValue('event-id'); mockSnapClient.getPreferences.mockResolvedValue(mockPreferences); }); From 7cfe21c7bc5f1e761ce2d26724074369fb1885b4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 11:05:39 +0200 Subject: [PATCH 250/362] 0.16.0 (#487) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 15 ++++++++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index e8a1b1c0..ad562b74 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.16.0] + +### Added + +- Asset market data on new handler ([#486](https://github.com/MetaMask/snap-bitcoin-wallet/pull/486)) +- Send flow error handling ([#482](https://github.com/MetaMask/snap-bitcoin-wallet/pull/482)) +- Translations ([#483](https://github.com/MetaMask/snap-bitcoin-wallet/pull/483)) + +### Changed + +- Use event value on user input instead of fetching the form state ([#485](https://github.com/MetaMask/snap-bitcoin-wallet/pull/485)) + ## [0.15.0] ### Added @@ -367,7 +379,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.15.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.0...HEAD +[0.16.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.15.0...v0.16.0 [0.15.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.1...v0.15.0 [0.14.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.0...v0.14.1 [0.14.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.13.0...v0.14.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 5f320087..36ce2c80 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.15.0", + "version": "0.16.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 15dc0771..b1eee366 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.15.0", + "version": "0.16.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "KTRWlq9gJlaPjsur/OyWRudVBzcYQUB5loItb8SjuVQ=", + "shasum": "jShiMPOfTMIQaUFUVqe8qJJgpDHElsQGBhDAfxzuQLM=", "location": { "npm": { "filePath": "dist/bundle.js", From 445f5850a3f17c7a8e26acf9db51f63370bf0dfa Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 2 Jul 2025 14:23:23 +0200 Subject: [PATCH 251/362] fix: typo onAssetsMarketData (#489) --- merged-packages/bitcoin-wallet-snap/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index dc5508da..8363d14c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -97,6 +97,6 @@ export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ({ to, }) => assetsHandler.historicalPrice(from, to); -export const onAssetMarketData: OnAssetsMarketDataHandler = async ({ +export const onAssetsMarketData: OnAssetsMarketDataHandler = async ({ assets, }) => assetsHandler.marketData(assets); From d36894c1dba9b204bc07575513676632bf2b3041 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 14:31:23 +0200 Subject: [PATCH 252/362] 0.16.1 (#490) --------- Co-authored-by: github-actions Co-authored-by: Dario Anongba Varela --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index ad562b74..231df03c 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.16.1] + +### Fixed + +- Typo `onAssetsMarketData` ([#489](https://github.com/MetaMask/snap-bitcoin-wallet/pull/489)) + ## [0.16.0] ### Added @@ -379,7 +385,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.1...HEAD +[0.16.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.0...v0.16.1 [0.16.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.15.0...v0.16.0 [0.15.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.1...v0.15.0 [0.14.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.0...v0.14.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 36ce2c80..d6235dad 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.16.0", + "version": "0.16.1", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index b1eee366..87576201 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.16.0", + "version": "0.16.1", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "jShiMPOfTMIQaUFUVqe8qJJgpDHElsQGBhDAfxzuQLM=", + "shasum": "e000UALMbvCh4/m50TO2Pzkn7IdmVrPMZLoKiXEoBlo=", "location": { "npm": { "filePath": "dist/bundle.js", From 2849096e7e2cc1c391cc2ac1927f6867aedc3a58 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 3 Jul 2025 14:16:11 +0200 Subject: [PATCH 253/362] fix: synchronize empty accounts (#491) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/config.ts | 2 +- .../src/entities/account.ts | 6 ++++++ .../src/handlers/KeyringHandler.test.ts | 7 +------ .../src/handlers/mappings.ts | 2 +- .../src/infra/BdkAccountAdapter.ts | 5 +++++ .../src/infra/jsx/ReviewTransactionView.tsx | 18 ++++++++---------- .../src/use-cases/AccountUseCases.test.ts | 1 + .../src/use-cases/AccountUseCases.ts | 5 ++++- .../src/use-cases/SendFlowUseCases.test.ts | 19 +++---------------- .../src/use-cases/SendFlowUseCases.ts | 4 ++-- 11 files changed, 33 insertions(+), 38 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 87576201..a3739cca 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "e000UALMbvCh4/m50TO2Pzkn7IdmVrPMZLoKiXEoBlo=", + "shasum": "IUZJm6RtHYnM5l2i0mUVBK7HBIWIJ8h56Tml9ZB6E4o=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index a01e6d44..4d7a4eae 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -9,7 +9,7 @@ export const Config: SnapConfig = { encrypt: false, chain: { parallelRequests: 1, - stopGap: 5, + stopGap: 2, url: { bitcoin: process.env.ESPLORA_BITCOIN ?? 'https://blockstream.info/api', testnet: diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index 912c4404..fd189663 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -13,6 +13,7 @@ import type { WalletTx, Amount, ScriptBuf, + Address, } from '@metamask/bitcoindevkit'; import type { Inscription } from './meta-protocols'; @@ -57,6 +58,11 @@ export type BitcoinAccount = { */ network: Network; + /** + * The public address representing this account. Usually address at index 0. + */ + publicAddress: Address; + /** * Get an address at a given index. * diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 8a76430a..eab3ebe6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -1,5 +1,4 @@ import type { - AddressInfo, Amount, Transaction, Txid, @@ -57,6 +56,7 @@ describe('KeyringHandler', () => { derivationPath: ['myEntropy', "84'", "0'", "0'"], entropySource: 'myEntropy', accountIndex: 0, + publicAddress: mockAddress, }); const defaultAddressType: AddressType = 'p2wpkh'; @@ -64,11 +64,6 @@ describe('KeyringHandler', () => { beforeEach(() => { mockAccounts.create.mockResolvedValue(mockAccount); - mockAccount.peekAddress.mockReturnValue( - mock({ - address: mockAddress, - }), - ); }); describe('createAccount', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index b2c84c96..90ab0583 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -54,7 +54,7 @@ export function mapToKeyringAccount(account: BitcoinAccount): KeyringAccount { type: addressTypeToCaip[account.addressType] as KeyringAccount['type'], scopes: [networkToScope[account.network]], id: account.id, - address: account.peekAddress(0).address.toString(), + address: account.publicAddress.toString(), options: { entropySource: account.entropySource, }, diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index ee3b17d0..af5d632e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -14,6 +14,7 @@ import type { WalletTx, Amount, ScriptBuf, + Address, } from '@metamask/bitcoindevkit'; import { UnconfirmedTx, @@ -108,6 +109,10 @@ export class BdkAccountAdapter implements BitcoinAccount { return this.#wallet.network; } + get publicAddress(): Address { + return this.peekAddress(0).address; + } + peekAddress(index: number): AddressInfo { return this.#wallet.peek_address('external', index); } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx index 091c3913..c1fdb2af 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx @@ -24,7 +24,11 @@ import { } from './format'; import { Config } from '../../config'; import type { Messages, ReviewTransactionContext } from '../../entities'; -import { BlockTime, ReviewTransactionEvent } from '../../entities'; +import { + BlockTime, + networkToCurrencyUnit, + ReviewTransactionEvent, +} from '../../entities'; import { networkToScope } from '../../handlers'; type ReviewTransactionViewProps = { @@ -36,20 +40,14 @@ export const ReviewTransactionView: SnapComponent< ReviewTransactionViewProps > = ({ context, messages }) => { const t = translate(messages); - const { - amount, - currency, - exchangeRate, - recipient, - network, - from, - explorerUrl, - } = context; + const { amount, exchangeRate, recipient, network, from, explorerUrl } = + context; const psbt = Psbt.from_string(context.psbt); const fee = psbt.fee().to_sat(); const feeRate = psbt.fee_rate()?.to_sat_per_vb_floor(); const total = BigInt(amount) + fee; + const currency = networkToCurrencyUnit[network]; return ( diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index e9f36a5f..f39b40e1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -142,6 +142,7 @@ describe('AccountUseCases', () => { createParams.network, tAddressType, ); + expect(mockAccount.revealNextAddress).toHaveBeenCalled(); expect(mockRepository.insert).toHaveBeenCalledWith(mockAccount); expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalledWith( mockAccount, diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 32c2b37a..4df18fe5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -150,6 +150,8 @@ export class AccountUseCases { addressType, ); + newAccount.revealNextAddress(); + await this.#repository.insert(newAccount); // First notify the event has been created, then full scan. @@ -164,8 +166,9 @@ export class AccountUseCases { } this.#logger.info( - 'Bitcoin account created successfully: %s. Request: %o', + 'Bitcoin account created successfully: %s. Public address: %s, Request: %o', newAccount.id, + newAccount.publicAddress, req, ); return newAccount; diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 8dd5538b..30ad7230 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -1,8 +1,4 @@ -import type { - FeeEstimates, - Network, - AddressInfo, -} from '@metamask/bitcoindevkit'; +import type { FeeEstimates, Network } from '@metamask/bitcoindevkit'; import { Psbt, Address, Amount } from '@metamask/bitcoindevkit'; import type { GetPreferencesResult } from '@metamask/snaps-sdk'; import { UserRejectedRequestError } from '@metamask/snaps-sdk'; @@ -63,7 +59,7 @@ describe('SendFlowUseCases', () => { // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 /* eslint-disable @typescript-eslint/naming-convention */ balance: { trusted_spendable: { to_sat: () => BigInt(1234) } }, - peekAddress: jest.fn(), + publicAddress: mock
({ toString: () => 'myAddress' }), }); const mockPreferences = mock({ currency: 'usd', @@ -88,11 +84,6 @@ describe('SendFlowUseCases', () => { describe('displayForm', () => { beforeEach(() => { mockAccountRepository.get.mockResolvedValue(mockAccount); - mockAccount.peekAddress.mockReturnValue( - mock({ - address: mock
({ toString: () => 'myAddress' }), - }), - ); mockSendFlowRepository.insertForm.mockResolvedValue('interface-id'); mockSnapClient.getPreferences.mockResolvedValue(mockPreferences); }); @@ -576,11 +567,7 @@ describe('SendFlowUseCases', () => { it('sets account from state on Account', async () => { const accountId = 'myAccount2'; - mockAccount.peekAddress.mockReturnValue( - mock({ - address: mock
({ toString: () => 'myAddress2' }), - }), - ); + mockAccount.publicAddress.toString = () => 'myAddress2'; mockAccountRepository.get.mockResolvedValue({ ...mockAccount, id: accountId, diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index 87ea2dcf..781389ab 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -82,7 +82,7 @@ export class SendFlowUseCases { currency: networkToCurrencyUnit[account.network], account: { id: account.id, - address: account.peekAddress(0).address.toString(), // FIXME: Address should not be needed in the send flow + address: account.publicAddress.toString(), // FIXME: Address should not be needed in the send flow }, network: account.network, feeRate: this.#fallbackFeeRate, @@ -376,7 +376,7 @@ export class SendFlowUseCases { balance: account.balance.trusted_spendable.to_sat().toString(), account: { id: account.id, - address: account.peekAddress(0).address.toString(), // FIXME: Address should not be needed in the send flow + address: account.publicAddress.toString(), // FIXME: Address should not be needed in the send flow }, errors: {}, currency: context.currency, From 3db23a2f3634694cd8be3677e40d0849a4518e72 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 3 Jul 2025 15:01:11 +0200 Subject: [PATCH 254/362] feat: synchronize by default (#492) --- .../bitcoin-wallet-snap/integration-test/keyring.test.ts | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/KeyringHandler.test.ts | 7 ++++--- .../bitcoin-wallet-snap/src/handlers/KeyringHandler.ts | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index 01d69bfe..172b4c2e 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -139,7 +139,7 @@ describe('Keyring', () => { const response = await snap.onKeyringRequest({ origin: ORIGIN, method: 'keyring_createAccount', - params: { options: { ...requestOpts } }, + params: { options: { ...requestOpts, synchronize: false } }, }); expect(response).toRespondWith({ diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index a3739cca..921e0fa5 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "IUZJm6RtHYnM5l2i0mUVBK7HBIWIJ8h56Tml9ZB6E4o=", + "shasum": "LKMcZ608krmeF0+156yADypfXY9ZqI9RwmaIwe5QnI8=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index eab3ebe6..968cf325 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -81,6 +81,7 @@ describe('KeyringHandler', () => { correlationId, }, accountNameSuggestion: 'My account', + synchronize: false, }; const expectedCreateParams: CreateAccountParams = { network: scopeToNetwork[BtcScope.Signet], @@ -109,7 +110,7 @@ describe('KeyringHandler', () => { index: 5, addressType: 'p2pkh', entropySource: 'm', - synchronize: false, + synchronize: true, }; await handler.createAccount(options); @@ -165,7 +166,7 @@ describe('KeyringHandler', () => { index: 1, addressType: 'p2wpkh', entropySource: 'entropy2', - synchronize: false, + synchronize: true, }; await handler.createAccount(options); @@ -192,7 +193,7 @@ describe('KeyringHandler', () => { index: 0, addressType, entropySource: 'm', - synchronize: false, + synchronize: true, }; await handler.createAccount(options); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 9169f7bb..bec2c92b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -102,7 +102,7 @@ export class KeyringHandler implements Keyring { index, derivationPath, addressType, - synchronize = false, + synchronize = true, accountNameSuggestion, } = options; From c2169c922ad5a833183b4ddb76bd9de018f515e1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 3 Jul 2025 15:33:59 +0200 Subject: [PATCH 255/362] 0.17.0 (#493) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 13 ++++++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 231df03c..15176ff5 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.17.0] + +### Changed + +- Synchronize by default ([#492](https://github.com/MetaMask/snap-bitcoin-wallet/pull/492)) + +### Fixed + +- Synchronize accounts with no history ([#491](https://github.com/MetaMask/snap-bitcoin-wallet/pull/491)) + ## [0.16.1] ### Fixed @@ -385,7 +395,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.1...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.17.0...HEAD +[0.17.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.1...v0.17.0 [0.16.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.0...v0.16.1 [0.16.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.15.0...v0.16.0 [0.15.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.14.1...v0.15.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index d6235dad..fce43b8a 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.16.1", + "version": "0.17.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 921e0fa5..9e0992c0 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.16.1", + "version": "0.17.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "LKMcZ608krmeF0+156yADypfXY9ZqI9RwmaIwe5QnI8=", + "shasum": "weFoDvDJA+TqiOtC+9/vMECTfvcJISIVt/3fQPX4J+U=", "location": { "npm": { "filePath": "dist/bundle.js", From 0e6d3b6b26ac9d5febdcecce420eda261135558f Mon Sep 17 00:00:00 2001 From: orestis Date: Thu, 24 Jul 2025 14:38:21 +0100 Subject: [PATCH 256/362] feat: track transaction events on snap (#495) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/entities/index.ts | 2 +- .../bitcoin-wallet-snap/src/entities/snap.ts | 22 +++ .../src/handlers/CronHandler.ts | 2 +- .../src/handlers/RpcHandler.test.ts | 1 + .../src/handlers/RpcHandler.ts | 9 +- .../src/infra/SnapClientAdapter.ts | 56 +++++- .../src/use-cases/AccountUseCases.test.ts | 173 ++++++++++++++++-- .../src/use-cases/AccountUseCases.ts | 72 ++++++-- 9 files changed, 300 insertions(+), 39 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 9e0992c0..2e840b16 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "weFoDvDJA+TqiOtC+9/vMECTfvcJISIVt/3fQPX4J+U=", + "shasum": "9mxVmlyxyPwixBcQh44RAbTJdeAaEWpkBaYxqcsBN40=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts index 6634d792..ab044784 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts @@ -4,7 +4,7 @@ export * from './chain'; export * from './currency'; export * from './send-flow'; export type * from './transaction'; -export type * from './snap'; +export * from './snap'; export type * from './meta-protocols'; export type * from './translator'; export type * from './rates'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index 2960db9d..f5a7485a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -25,6 +25,13 @@ export type AccountState = { inscriptions: Inscription[]; }; +export enum TrackingSnapEvent { + TransactionFinalized = 'Transaction Finalized', + TransactionReceived = 'Transaction Received', + TransactionReorged = 'Transaction Reorged', + TransactionSubmitted = 'Transaction Submitted', +} + /** * The SnapClient represents the MetaMask Snap state and manages the BIP-32 entropy from the Wallet SRP. */ @@ -182,4 +189,19 @@ export type SnapClient = { * @returns the user's preferences. */ getPreferences(): Promise; + + /** + * Track events that comply with the SIP-32 spec (https://metamask.github.io/SIPs/SIPS/sip-32) + * + * @param eventType The event type we want to track + * @param account The correlated bitcoin account + * @param tx The transaction we want to capture metrics for + * @param origin The origin/source that triggered this event + */ + emitTrackingEvent( + eventType: TrackingSnapEvent, + account: BitcoinAccount, + tx: WalletTx, + origin: string, + ): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index a82a1c38..f6254aa5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -53,7 +53,7 @@ export class CronHandler { const accounts = await this.#accountsUseCases.list(); const results = await Promise.allSettled( accounts.map(async (account) => { - return this.#accountsUseCases.synchronize(account); + return this.#accountsUseCases.synchronize(account, 'cron'); }), ); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index b0fff1f7..0157d8e5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -66,6 +66,7 @@ describe('RpcHandler', () => { expect(mockAccountsUseCases.sendPsbt).toHaveBeenCalledWith( 'account-id', mockPsbt, + 'metamask', ); expect(result).toStrictEqual({ txId: 'txId' }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index 8d5fa4b2..dffdb9de 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -43,7 +43,7 @@ export class RpcHandler { switch (method as RpcMethod) { case RpcMethod.StartSendTransactionFlow: { assert(params, CreateSendFormRequest); - return this.#executeSendFlow(params.account); + return this.#executeSendFlow(params.account, origin); } default: @@ -52,9 +52,12 @@ export class RpcHandler { }); } - async #executeSendFlow(account: string): Promise { + async #executeSendFlow( + account: string, + origin: string, + ): Promise { const psbt = await this.#sendFlowUseCases.display(account); - const txId = await this.#accountUseCases.sendPsbt(account, psbt); + const txId = await this.#accountUseCases.sendPsbt(account, psbt, origin); return { txId: txId.toString() }; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 8f2724c4..dea4e560 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -4,16 +4,20 @@ import { SLIP10Node } from '@metamask/key-tree'; import { KeyringEvent } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; import type { + ComponentOrElement, GetInterfaceContextResult, GetInterfaceStateResult, - Json, - ComponentOrElement, GetPreferencesResult, + Json, } from '@metamask/snaps-sdk'; import type { BitcoinAccount, SnapClient } from '../entities'; -import { networkToCurrencyUnit } from '../entities'; -import { networkToCaip19 } from '../handlers'; +import { TrackingSnapEvent, networkToCurrencyUnit } from '../entities'; +import { + addressTypeToCaip, + networkToCaip19, + networkToScope, +} from '../handlers'; import { mapToKeyringAccount, mapToTransaction } from '../handlers/mappings'; export class SnapClientAdapter implements SnapClient { @@ -190,4 +194,48 @@ export class SnapClientAdapter implements SnapClient { method: 'snap_getPreferences', }); } + + async emitTrackingEvent( + eventType: TrackingSnapEvent, + account: BitcoinAccount, + tx: WalletTx, + origin: string, + ): Promise { + const createMessage = (): string => { + switch (eventType) { + case TrackingSnapEvent.TransactionFinalized: + return 'Snap transaction finalized'; + case TrackingSnapEvent.TransactionSubmitted: + return 'Snap transaction submitted'; + case TrackingSnapEvent.TransactionReorged: + return 'Snap transaction reorged'; + case TrackingSnapEvent.TransactionReceived: + return 'Snap transaction received'; + default: + throw new Error( + `Unhandled tracking event type: ${eventType as string}`, + ); + } + }; + + /* eslint-disable @typescript-eslint/naming-convention */ + await snap.request({ + method: 'snap_trackEvent', + params: { + event: { + event: eventType, + properties: { + origin, + message: createMessage(), + chain_id: networkToScope[account.network], + account_id: account.id, + account_address: account.publicAddress.toString(), + account_type: addressTypeToCaip[account.addressType], + tx_id: tx.txid.toString(), + }, + }, + }, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index f39b40e1..252fa299 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -17,6 +17,7 @@ import type { MetaProtocolsClient, SnapClient, } from '../entities'; +import { TrackingSnapEvent } from '../entities'; import type { CreateAccountParams, DiscoverAccountParams, @@ -400,7 +401,7 @@ describe('AccountUseCases', () => { it('synchronizes', async () => { mockAccount.listTransactions.mockReturnValue([]); - await useCases.synchronize(mockAccount); + await useCases.synchronize(mockAccount, 'test'); expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); expect(mockAccount.listTransactions).toHaveBeenCalledTimes(2); @@ -415,7 +416,7 @@ describe('AccountUseCases', () => { .mockReturnValueOnce([mockTransaction]); mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); - await useCases.synchronize(mockAccount); + await useCases.synchronize(mockAccount, 'test'); expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); expect(mockAccount.listTransactions).toHaveBeenCalledTimes(2); @@ -432,6 +433,13 @@ describe('AccountUseCases', () => { expect( mockSnapClient.emitAccountTransactionsUpdatedEvent, ).toHaveBeenCalledWith(mockAccount, [mockTransaction]); + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionReceived, + mockAccount, + mockTransaction, + 'test', + ); + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledTimes(1); }); it('synchronizes with confirmed transactions', async () => { @@ -451,7 +459,7 @@ describe('AccountUseCases', () => { .mockReturnValueOnce([mockTxPending]) .mockReturnValueOnce([mockTxConfirmed]); - await useCases.synchronize(mockAccount); + await useCases.synchronize(mockAccount, 'test'); expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); expect(mockAccount.listTransactions).toHaveBeenCalledTimes(2); @@ -462,13 +470,135 @@ describe('AccountUseCases', () => { expect( mockSnapClient.emitAccountTransactionsUpdatedEvent, ).toHaveBeenCalledWith(mockAccount, [mockTxConfirmed]); + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionFinalized, + mockAccount, + mockTxConfirmed, + 'test', + ); + }); + + it('synchronizes with both new and confirmed transactions', async () => { + const mockTxPending = mock({ + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_position: { is_confirmed: false }, + txid: { + toString: () => 'txid1', + }, + }); + const mockTxNew = mock({ + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_position: { is_confirmed: false }, + txid: { + toString: () => 'txid2', + }, + }); + const mockTxConfirmed = mock({ + ...mockTxPending, + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_position: { is_confirmed: true }, + }); + + const mockTxPreviouslyConfirmed = mock({ + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_position: { is_confirmed: true }, + }); + const mockTxReorged = mock({ + ...mockTxPreviouslyConfirmed, + // eslint-disable-next-line @typescript-eslint/naming-convention + chain_position: { is_confirmed: false }, + }); + + mockAccount.listTransactions + .mockReturnValueOnce([mockTxPending, mockTxPreviouslyConfirmed]) + .mockReturnValueOnce([mockTxConfirmed, mockTxNew, mockTxReorged]); + const mockInscriptions = mock(); + mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); + const origin = 'test'; + + await useCases.synchronize(mockAccount, origin); + + expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); + expect(mockAccount.listTransactions).toHaveBeenCalledTimes(2); + expect(mockMetaProtocols.fetchInscriptions).toHaveBeenCalledWith( + mockAccount, + ); + expect(mockRepository.update).toHaveBeenCalledWith( + mockAccount, + mockInscriptions, + ); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount, [ + mockTxConfirmed, + mockTxNew, + mockTxReorged, + ]); + + // Check for TransactionFinalized event for confirmed transaction + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionFinalized, + mockAccount, + mockTxConfirmed, + origin, + ); + + // Check for TransactionReceived event for new transaction + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionReceived, + mockAccount, + mockTxNew, + origin, + ); + + // Check for TransactionReorged event for reorged transaction + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionReorged, + mockAccount, + mockTxReorged, + origin, + ); + + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledTimes(3); + }); + + it('should emit TransactionReorged when a confirmed transaction becomes unconfirmed', async () => { + /* eslint-disable @typescript-eslint/naming-convention */ + const mockTxConfirmed = mock({ + chain_position: { is_confirmed: true }, + }); + const mockTxReorged = mock({ + ...mockTxConfirmed, + chain_position: { is_confirmed: false }, + }); + mockAccount.listTransactions + .mockReturnValueOnce([mockTxConfirmed]) + .mockReturnValueOnce([mockTxReorged]); + + await useCases.synchronize(mockAccount, 'test'); + + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledTimes(1); + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionReorged, + mockAccount, + mockTxReorged, + 'test', + ); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount, [mockTxReorged]); }); it('propagates an error if the chain sync fails', async () => { const error = new Error('Sync failed'); mockChain.sync.mockRejectedValue(error); - await expect(useCases.synchronize(mockAccount)).rejects.toBe(error); + await expect(useCases.synchronize(mockAccount, 'test')).rejects.toBe( + error, + ); expect(mockChain.sync).toHaveBeenCalled(); }); @@ -478,7 +608,9 @@ describe('AccountUseCases', () => { const error = new Error('Update failed'); mockRepository.update.mockRejectedValue(error); - await expect(useCases.synchronize(mockAccount)).rejects.toBe(error); + await expect(useCases.synchronize(mockAccount, 'test')).rejects.toBe( + error, + ); expect(mockChain.sync).toHaveBeenCalled(); expect(mockRepository.update).toHaveBeenCalled(); @@ -497,7 +629,7 @@ describe('AccountUseCases', () => { .mockReturnValueOnce([]) .mockReturnValueOnce([mockTransaction]); - await testUseCases.synchronize(mockAccount); + await testUseCases.synchronize(mockAccount, 'test'); expect(mockMetaProtocols.fetchInscriptions).not.toHaveBeenCalled(); }); @@ -664,15 +796,16 @@ describe('AccountUseCases', () => { mockRepository.getWithSigner.mockResolvedValue(null); await expect( - useCases.sendPsbt('non-existent-id', mockPsbt), + useCases.sendPsbt('non-existent-id', mockPsbt, 'metamask'), ).rejects.toThrow('Account not found: non-existent-id'); }); it('sends transaction', async () => { mockAccount.sign.mockReturnValue(mockTransaction); mockAccount.getTransaction.mockReturnValue(mockWalletTx); + mockTransaction.compute_txid.mockReturnValue(mockTxid); - const txId = await useCases.sendPsbt('account-id', mockPsbt); + const txId = await useCases.sendPsbt('account-id', mockPsbt, 'metamask'); expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); expect(mockAccount.sign).toHaveBeenCalledWith(mockPsbt); @@ -688,6 +821,12 @@ describe('AccountUseCases', () => { expect( mockSnapClient.emitAccountTransactionsUpdatedEvent, ).toHaveBeenCalledWith(mockAccount, [mockWalletTx]); + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionSubmitted, + mockAccount, + mockWalletTx, + 'metamask', + ); expect(txId).toBe(mockTxid); }); @@ -695,9 +834,9 @@ describe('AccountUseCases', () => { const error = new Error('getWithSigner failed'); mockRepository.getWithSigner.mockRejectedValueOnce(error); - await expect(useCases.sendPsbt('account-id', mockPsbt)).rejects.toBe( - error, - ); + await expect( + useCases.sendPsbt('account-id', mockPsbt, 'metamask'), + ).rejects.toBe(error); }); it('propagates an error if broadcast fails', async () => { @@ -705,9 +844,9 @@ describe('AccountUseCases', () => { mockAccount.sign.mockReturnValue(mockTransaction); mockChain.broadcast.mockRejectedValueOnce(error); - await expect(useCases.sendPsbt('account-id', mockPsbt)).rejects.toBe( - error, - ); + await expect( + useCases.sendPsbt('account-id', mockPsbt, 'metamask'), + ).rejects.toBe(error); }); it('propagates an error if update fails', async () => { @@ -715,9 +854,9 @@ describe('AccountUseCases', () => { mockAccount.sign.mockReturnValue(mockTransaction); mockRepository.update.mockRejectedValue(error); - await expect(useCases.sendPsbt('account-id', mockPsbt)).rejects.toBe( - error, - ); + await expect( + useCases.sendPsbt('account-id', mockPsbt, 'metamask'), + ).rejects.toBe(error); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 4df18fe5..c993b016 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -8,14 +8,15 @@ import type { import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; import { + addressTypeToPurpose, type BitcoinAccount, type BitcoinAccountRepository, type BlockchainClient, - type SnapClient, - type MetaProtocolsClient, type Logger, - addressTypeToPurpose, + type MetaProtocolsClient, networkToCoinType, + type SnapClient, + TrackingSnapEvent, } from '../entities'; export type DiscoverAccountParams = { @@ -174,7 +175,7 @@ export class AccountUseCases { return newAccount; } - async synchronize(account: BitcoinAccount): Promise { + async synchronize(account: BitcoinAccount, origin: string): Promise { this.#logger.debug('Synchronizing account: %s', account.id); const txsBeforeSync = account.listTransactions(); @@ -197,14 +198,54 @@ export class AccountUseCases { txMapBefore.set(tx.txid.toString(), tx); } - // Identify transactions that are either new or whose confirmation status changed - const txsToNotify = txsAfterSync.filter((tx) => { + const txsToNotify: WalletTx[] = []; + + for (const tx of txsAfterSync) { const prevTx = txMapBefore.get(tx.txid.toString()); - return ( - !prevTx || - prevTx.chain_position.is_confirmed !== tx.chain_position.is_confirmed - ); - }); + + if (!prevTx) { + txsToNotify.push(tx); + + await this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionReceived, + account, + tx, + origin, + ); + + continue; + } + + const prevConfirmed = prevTx.chain_position.is_confirmed; + const currConfirmed = tx.chain_position.is_confirmed; + + const statusChanged = + (prevConfirmed && !currConfirmed) || (!prevConfirmed && currConfirmed); + + if (statusChanged) { + if (tx.chain_position.is_confirmed) { + txsToNotify.push(tx); + + await this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionFinalized, + account, + tx, + origin, + ); + } else { + // if the status was changed, and now it's NOT confirmed + // it means the tx was reorged. + txsToNotify.push(tx); + + await this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionReorged, + account, + tx, + origin, + ); + } + } + } if (txsToNotify.length > 0) { await this.#snapClient.emitAccountBalancesUpdatedEvent(account); @@ -253,7 +294,7 @@ export class AccountUseCases { this.#logger.info('Account deleted successfully: %s', account.id); } - async sendPsbt(id: string, psbt: Psbt): Promise { + async sendPsbt(id: string, psbt: Psbt, origin: string): Promise { this.#logger.debug('Sending transaction: %s', id); const account = await this.#repository.getWithSigner(id); @@ -275,6 +316,13 @@ export class AccountUseCases { await this.#snapClient.emitAccountTransactionsUpdatedEvent(account, [ walletTx, ]); + + await this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionSubmitted, + account, + walletTx, + origin, + ); } this.#logger.info( From a0cc024000209c5fb1d6d24a798cccc04620be02 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 28 Jul 2025 21:58:46 +0200 Subject: [PATCH 257/362] feat: error handling (#496) --- .../integration-test/keyring.test.ts | 2 +- .../integration-test/send-flow.test.ts | 172 ------------------ .../bitcoin-wallet-snap/package.json | 18 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../bitcoin-wallet-snap/src/entities/error.ts | 142 ++++++++++++++- .../bitcoin-wallet-snap/src/entities/index.ts | 2 +- .../bitcoin-wallet-snap/src/entities/snap.ts | 8 + .../src/handlers/AssetsHandler.test.ts | 11 +- .../src/handlers/AssetsHandler.ts | 111 ++++++----- .../src/handlers/CronHandler.test.ts | 9 +- .../src/handlers/CronHandler.ts | 25 ++- .../src/handlers/HandlerMiddleware.test.ts | 67 +++++++ .../src/handlers/HandlerMiddleware.ts | 89 +++++++++ .../src/handlers/KeyringHandler.ts | 8 +- .../src/handlers/RpcHandler.test.ts | 11 +- .../src/handlers/RpcHandler.ts | 25 ++- .../src/handlers/UserInputHandler.test.ts | 6 +- .../src/handlers/UserInputHandler.ts | 49 +++-- .../src/handlers/errors.ts | 19 -- .../src/handlers/permissions.ts | 4 +- .../bitcoin-wallet-snap/src/index.ts | 18 +- .../src/infra/SnapClientAdapter.ts | 16 +- .../src/use-cases/SendFlowUseCases.test.ts | 6 +- .../src/use-cases/SendFlowUseCases.ts | 6 +- 24 files changed, 461 insertions(+), 367 deletions(-) delete mode 100644 merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index 172b4c2e..e8b6f8a2 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -292,7 +292,7 @@ describe('Keyring', () => { expect(response).toRespondWithError({ code: -32603, - message: `Account not found: ${id}`, + message: `An unexpected error occurred`, stack: expect.anything(), }); }); diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts deleted file mode 100644 index 981cf35d..00000000 --- a/merged-packages/bitcoin-wallet-snap/integration-test/send-flow.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import type { KeyringAccount } from '@metamask/keyring-api'; -import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; -import type { Snap } from '@metamask/snaps-jest'; -import { assertIsCustomDialog, installSnap } from '@metamask/snaps-jest'; - -import { FUNDING_TX, MNEMONIC, ORIGIN } from './constants'; -import { - CurrencyUnit, - ReviewTransactionEvent, - SendFormEvent, -} from '../src/entities'; -import { Caip19Asset } from '../src/handlers/caip'; -import { CronMethod } from '../src/handlers/CronHandler'; - -// TODO: Unskip when the snaps-jest package adds support for AccountSelector and AssetSelector -// eslint-disable-next-line jest/no-disabled-tests -describe.skip('Send flow', () => { - const recipient = 'bcrt1qyvhf2epk9s659206lq3rdvtf07uq3t9e7xtjje'; - const sendAmount = '0.1'; - - let account: KeyringAccount; - let snap: Snap; - - beforeAll(async () => { - snap = await installSnap({ - options: { - secretRecoveryPhrase: MNEMONIC, - }, - }); - - snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); - snap.mockJsonRpc({ - method: 'snap_scheduleBackgroundEvent', - result: 'background-event-id', - }); - snap.mockJsonRpc({ - method: 'snap_cancelBackgroundEvent', - result: {}, - }); - - const response = await snap.onKeyringRequest({ - origin: ORIGIN, - method: 'keyring_createAccount', - params: { - options: { - addressType: BtcAccountType.P2wpkh, - scope: BtcScope.Regtest, - synchronize: true, - }, - }, - }); - - if ('result' in response.response) { - account = response.response.result as KeyringAccount; - } - }); - - it('sends a transaction', async () => { - const response = snap.request({ - origin: ORIGIN, - method: 'startSendTransactionFlow', - params: { - account: account.id, - }, - }); - let ui = await response.getInterface(); - assertIsCustomDialog(ui); - - // First the recipient so the view is updated and the other fields become available. - await ui.typeInField(SendFormEvent.Recipient, recipient); - ui = await response.getInterface(); - - await ui.clickElement(SendFormEvent.Max); - await ui.typeInField(SendFormEvent.Amount, sendAmount); - - const backgroundEventResponse = await snap.onBackgroundEvent({ - method: CronMethod.RefreshRates, - params: { interfaceId: ui.id }, - }); - expect(backgroundEventResponse).toRespondWith(null); - - ui = await response.getInterface(); - await ui.clickElement(SendFormEvent.Confirm); - - // Test that we can successfully revert to send form. - ui = await response.getInterface(); - await ui.clickElement(ReviewTransactionEvent.HeaderBack); - - ui = await response.getInterface(); - await ui.clickElement(SendFormEvent.Confirm); - - ui = await response.getInterface(); - await ui.clickElement(ReviewTransactionEvent.Send); - - const result = await response; - expect(result).toRespondWith({ txId: expect.any(String) }); - - // TODO: To be improved once listAccountTransactions is implemented to check the tx confirmation status. - const cronJobResponse = await snap.onCronjob({ - method: 'synchronizeAccounts', - }); - expect(cronJobResponse).toRespondWith(null); - - const listTxsResponse = await snap.onKeyringRequest({ - origin: ORIGIN, - method: 'keyring_listAccountTransactions', - params: { - id: account.id, - pagination: { limit: 10, next: null }, - }, - }); - - expect(listTxsResponse).toRespondWith({ - data: [ - { ...FUNDING_TX, account: account.id }, - { - account: account.id, - chain: BtcScope.Regtest, - events: [{ status: 'unconfirmed', timestamp: expect.any(Number) }], - fees: [ - { - asset: { - amount: expect.any(String), - fungible: true, - type: Caip19Asset.Regtest, - unit: CurrencyUnit.Regtest, - }, - type: 'priority', - }, - ], - from: [], - id: expect.any(String), - status: 'unconfirmed', - timestamp: expect.any(Number), - to: [ - { - address: recipient, - asset: { - amount: sendAmount, - fungible: true, - type: Caip19Asset.Regtest, - unit: CurrencyUnit.Regtest, - }, - }, - ], - type: 'send', - }, - ], - next: null, - }); - }); - - it('cancels by return button', async () => { - const response = snap.request({ - origin: ORIGIN, - method: 'startSendTransactionFlow', - params: { - account: account.id, - }, - }); - - const ui = await response.getInterface(); - await ui.clickElement(SendFormEvent.Cancel); - - const result = await response; - expect(result).toRespondWithError({ - code: -32603, - message: 'User rejected the request.', - stack: expect.anything(), - }); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index fce43b8a..708ff3a2 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -36,18 +36,18 @@ }, "devDependencies": { "@jest/globals": "^30.0.3", - "@metamask/bitcoindevkit": "^0.1.10", + "@metamask/bitcoindevkit": "^0.1.11", "@metamask/key-tree": "^10.1.1", - "@metamask/keyring-api": "^18.0.0", - "@metamask/keyring-snap-sdk": "^4.0.0", + "@metamask/keyring-api": "^19.1.0", + "@metamask/keyring-snap-sdk": "^5.0.0", "@metamask/slip44": "^4.2.0", - "@metamask/snaps-cli": "^8.1.0", - "@metamask/snaps-jest": "^9.2.0", - "@metamask/snaps-sdk": "^9.0.0", - "@metamask/utils": "^11.4.0", + "@metamask/snaps-cli": "^8.1.1", + "@metamask/snaps-jest": "^9.3.0", + "@metamask/snaps-sdk": "^9.3.0", + "@metamask/utils": "^11.4.2", "concurrently": "^9.2.0", - "dotenv": "^17.0.0", - "jest": "^30.0.3", + "dotenv": "^17.2.1", + "jest": "^30.0.5", "jest-mock-extended": "^4.0.0", "jest-transform-stub": "2.0.0", "superstruct": "^2.0.2", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 2e840b16..6358e7f0 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "9mxVmlyxyPwixBcQh44RAbTJdeAaEWpkBaYxqcsBN40=", + "shasum": "h95BaDxsF74v4Pp1KRXrv65sJFEJtCqHMyvFqqcEo8c=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -97,6 +97,6 @@ ] } }, - "platformVersion": "9.0.0", + "platformVersion": "9.3.0", "manifestVersion": "0.1" } diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/error.ts b/merged-packages/bitcoin-wallet-snap/src/entities/error.ts index 9c0f388c..ead7b30e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/error.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/error.ts @@ -1,7 +1,147 @@ import type { Json } from '@metamask/utils'; +export class BaseError extends Error { + code: number; + + data?: Record; + + constructor(message: string, code: number, data?: Record) { + super(message); + this.name = this.constructor.name; + this.code = code; + this.data = data; + Object.setPrototypeOf(this, new.target.prototype); + } + + toJSON(): CodifiedError { + return { + name: this.name, + message: this.message, + code: this.code, + data: this.data, + }; + } +} + export type CodifiedError = { + name: string; message: string; code: number; - data: Json; + data?: Record; }; + +/** + * Error thrown when the format of input data is invalid. Should never be thrown outside handlers. + * Useful for signaling parsing or type errors. + * + * @example + * throw new FormatError('Invalid address format'); + */ +export class FormatError extends BaseError { + constructor(message: string, data?: Record) { + super(message, 0, data); + } +} + +/** + * Error thrown when input data fails validation rules. Should never be thrown outside use cases. + * Useful for signaling failed schema or business logic validation. + * + * @example + * throw new ValidationError('Amount must be positive'); + */ +export class ValidationError extends BaseError { + constructor(message: string, data?: Record) { + super(message, 1000, data); + } +} + +/** + * Error thrown when a requested resource cannot be found. + * Useful for signaling missing data or entities. + * + * @example + * throw new NotFoundError('Account not found'); + */ +export class NotFoundError extends BaseError { + constructor(message: string, data?: Record) { + super(message, 2000, data); + } +} + +/** + * Error thrown when an external service or dependency fails. + * Useful for signaling issues with APIs, blockchain explorers, etc. + * + * @example + * throw new ExternalServiceError('Price API unavailable'); + */ +export class ExternalServiceError extends BaseError { + constructor(message: string, data?: Record) { + super(message, 3000, data); + } +} + +/** + * Error thrown for wallet-specific failures. + * Useful for signaling wallet operation errors. + * + * @example + * throw new WalletError('Insufficient funds'); + */ +export class WalletError extends BaseError { + constructor(message: string, data?: Record) { + super(message, 4000, data); + } +} + +/** + * Error thrown when storage operations fail. + * Useful for signaling database or persistence errors. + * + * @example + * throw new StorageError('Failed to insert account'); + */ +export class StorageError extends BaseError { + constructor(message: string, data?: Record) { + super(message, 5000, data); + } +} + +/** + * Error thrown when a requested method or resource does not exist or is not implemented. + * Useful for signaling "method not found" or "not implemented" cases. + * + * @example + * throw new InexistentError('Method not implemented'); + */ +export class InexistentMethodError extends BaseError { + constructor(message: string, data?: Record) { + super(message, 6000, data); + } +} + +/** + * Error thrown when an operation is not permitted due to insufficient permissions or authorization failure. + * Useful for signaling access control violations, such as invalid origin. + * + * @example + * throw new PermissionError('Invalid origin'); + */ +export class PermissionError extends BaseError { + constructor(message: string, data?: Record) { + super(message, 7000, data); + } +} + +/** + * Error thrown when an operation is canceled by the user. + * + * @example + * throw new UserActionCanceledError('User canceled the send flow'); + */ +export class UserActionCanceledError extends BaseError { + constructor(message: string, data?: Record) { + super(message, 8000, data); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts index ab044784..c1aeb327 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts @@ -9,4 +9,4 @@ export type * from './meta-protocols'; export type * from './translator'; export type * from './rates'; export * from './logger'; -export type * from './error'; +export * from './error'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index f5a7485a..e62f691b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -7,6 +7,7 @@ import type { import type { Json } from '@metamask/utils'; import type { BitcoinAccount } from './account'; +import type { BaseError } from './error'; import type { Inscription } from './meta-protocols'; export type SnapState = { @@ -204,4 +205,11 @@ export type SnapClient = { tx: WalletTx, origin: string, ): Promise; + + /** + * Track errors + * + * @param error The error to track + */ + emitTrackingError(error: BaseError): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts index 31f666e1..1b6a7942 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts @@ -2,7 +2,6 @@ import type { HistoricalPriceIntervals, FungibleAssetMarketData, } from '@metamask/snaps-sdk'; -import { SnapError } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; import type { AssetsUseCases } from '../use-cases'; @@ -107,9 +106,7 @@ describe('AssetsHandler', () => { const error = new Error(); mockAssetsUseCases.getRates.mockRejectedValue(error); - await expect(handler.conversion(conversions)).rejects.toThrow( - new SnapError(error), - ); + await expect(handler.conversion(conversions)).rejects.toThrow(error); }); }); @@ -143,7 +140,7 @@ describe('AssetsHandler', () => { await expect( handler.historicalPrice(Caip19Asset.Bitcoin, Caip19Asset.Testnet), - ).rejects.toThrow(new SnapError(error)); + ).rejects.toThrow(error); }); }); @@ -203,9 +200,7 @@ describe('AssetsHandler', () => { const error = new Error(); mockAssetsUseCases.getRates.mockRejectedValue(error); - await expect(handler.marketData(assets)).rejects.toThrow( - new SnapError(error), - ); + await expect(handler.marketData(assets)).rejects.toThrow(error); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts index 97930571..16957d80 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts @@ -15,7 +15,6 @@ import { assert } from 'superstruct'; import type { AssetsUseCases } from '../use-cases'; import { Caip19Asset } from './caip'; -import { handle } from './errors'; import { networkToIcon } from './icons'; export class AssetsHandler { @@ -101,38 +100,36 @@ export class AssetsHandler { const conversionRates: OnAssetsConversionResponse['conversionRates'] = {}; - return handle(async () => { - for (const [fromAsset, toAssets] of Object.entries(assetMap)) { - const fromKey = fromAsset as keyof typeof conversionRates; - conversionRates[fromKey] = {}; - - if (fromKey === (Caip19Asset.Bitcoin as CaipAssetType)) { - // For Bitcoin, fetch rates. - for (const [toAsset, rate] of await this.#assetsUseCases.getRates( - toAssets, - )) { - conversionRates[fromKey][toAsset] = rate - ? { - rate: rate.price.toString(), - conversionTime, - expirationTime: conversionTime + this.#expirationInterval, - } - : null; - } - } else { - // For every other conversions, we just use a rate of 0. - for (const toAsset of toAssets) { - conversionRates[fromKey][toAsset] = { - rate: '0', - conversionTime, - expirationTime: conversionTime + 60 * 60 * 24, // Long expiration time (1 day) to avoid unnecessary requests - }; - } + for (const [fromAsset, toAssets] of Object.entries(assetMap)) { + const fromKey = fromAsset as keyof typeof conversionRates; + conversionRates[fromKey] = {}; + + if (fromKey === (Caip19Asset.Bitcoin as CaipAssetType)) { + // For Bitcoin, fetch rates. + for (const [toAsset, rate] of await this.#assetsUseCases.getRates( + toAssets, + )) { + conversionRates[fromKey][toAsset] = rate + ? { + rate: rate.price.toString(), + conversionTime, + expirationTime: conversionTime + this.#expirationInterval, + } + : null; + } + } else { + // For every other conversions, we just use a rate of 0. + for (const toAsset of toAssets) { + conversionRates[fromKey][toAsset] = { + rate: '0', + conversionTime, + expirationTime: conversionTime + 60 * 60 * 24, // Long expiration time (1 day) to avoid unnecessary requests + }; } } + } - return { conversionRates }; - }); + return { conversionRates }; } async historicalPrice( @@ -147,17 +144,15 @@ export class AssetsHandler { } const updateTime = getCurrentUnixTimestamp(); - return handle(async () => { - const intervals = await this.#assetsUseCases.getPriceIntervals(to); + const intervals = await this.#assetsUseCases.getPriceIntervals(to); - return { - historicalPrice: { - intervals, - updateTime, - expirationTime: updateTime + this.#expirationInterval, - }, - }; - }); + return { + historicalPrice: { + intervals, + updateTime, + expirationTime: updateTime + this.#expirationInterval, + }, + }; } async marketData( @@ -172,27 +167,25 @@ export class AssetsHandler { const marketData: OnAssetsMarketDataResponse['marketData'] = {}; - return handle(async () => { - for (const [fromAsset, toAssets] of Object.entries(assetMap)) { - const fromKey = fromAsset as keyof typeof marketData; - marketData[fromKey] = {}; - - if (fromKey === (Caip19Asset.Bitcoin as CaipAssetType)) { - // For Bitcoin, fetch market data. - for (const [toAsset, rate] of await this.#assetsUseCases.getRates( - toAssets, - )) { - marketData[fromKey][toAsset] = rate ? rate.marketData : null; - } - } else { - // For every other assets, there is no market data. - for (const toAsset of toAssets) { - marketData[fromKey][toAsset] = null; - } + for (const [fromAsset, toAssets] of Object.entries(assetMap)) { + const fromKey = fromAsset as keyof typeof marketData; + marketData[fromKey] = {}; + + if (fromKey === (Caip19Asset.Bitcoin as CaipAssetType)) { + // For Bitcoin, fetch market data. + for (const [toAsset, rate] of await this.#assetsUseCases.getRates( + toAssets, + )) { + marketData[fromKey][toAsset] = rate ? rate.marketData : null; + } + } else { + // For every other assets, there is no market data. + for (const toAsset of toAssets) { + marketData[fromKey][toAsset] = null; } } + } - return { marketData }; - }); + return { marketData }; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts index 64d08c80..e8b88c14 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -1,4 +1,3 @@ -import { SnapError } from '@metamask/snaps-sdk'; import type { JsonRpcRequest } from '@metamask/utils'; import { mock } from 'jest-mock-extended'; @@ -36,9 +35,7 @@ describe('CronHandler', () => { const error = new Error(); mockAccountUseCases.list.mockRejectedValue(error); - await expect(handler.route(request)).rejects.toThrow( - new SnapError(error), - ); + await expect(handler.route(request)).rejects.toThrow(error); }); it('does not propagate errors from synchronize', async () => { @@ -76,9 +73,7 @@ describe('CronHandler', () => { const error = new Error(); mockSendFlowUseCases.refresh.mockRejectedValue(error); - await expect(handler.route(request)).rejects.toThrow( - new SnapError(error), - ); + await expect(handler.route(request)).rejects.toThrow(error); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index f6254aa5..a211a98f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -1,9 +1,8 @@ import type { JsonRpcRequest } from '@metamask/utils'; import { assert, object, string } from 'superstruct'; -import type { Logger } from '../entities'; +import { InexistentMethodError, type Logger } from '../entities'; import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; -import { handle } from './errors'; export enum CronMethod { SynchronizeAccounts = 'synchronizeAccounts', @@ -34,19 +33,17 @@ export class CronHandler { async route(request: JsonRpcRequest): Promise { const { method, params } = request; - return handle(async () => { - switch (method as CronMethod) { - case CronMethod.SynchronizeAccounts: { - return this.synchronizeAccounts(); - } - case CronMethod.RefreshRates: { - assert(params, SendFormRefreshRatesRequest); - return this.#sendFlowUseCases.refresh(params.interfaceId); - } - default: - throw new Error(`Method not found: ${method}`); + switch (method as CronMethod) { + case CronMethod.SynchronizeAccounts: { + return this.synchronizeAccounts(); } - }); + case CronMethod.RefreshRates: { + assert(params, SendFormRefreshRatesRequest); + return this.#sendFlowUseCases.refresh(params.interfaceId); + } + default: + throw new InexistentMethodError(`Method not found: ${method}`); + } } async synchronizeAccounts(): Promise { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts new file mode 100644 index 00000000..4be840fb --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts @@ -0,0 +1,67 @@ +import type { GetPreferencesResult } from '@metamask/snaps-sdk'; +import { mock } from 'jest-mock-extended'; + +import { + type Logger, + type SnapClient, + type Translator, + BaseError, +} from '../entities'; +import { HandlerMiddleware } from './HandlerMiddleware'; + +describe('HandlerMiddleware', () => { + const mockLogger = mock(); + const mockSnapClient = mock({ + getPreferences: jest.fn(), + }); + const mockTranslator = mock({ + load: jest.fn(), + }); + + const middleware = new HandlerMiddleware( + mockLogger, + mockSnapClient, + mockTranslator, + ); + + beforeEach(() => { + mockSnapClient.getPreferences.mockResolvedValue({ + locale: 'en', + } as GetPreferencesResult); + mockTranslator.load.mockResolvedValue({}); + }); + + describe('handle', () => { + it('executes the function successfully', async () => { + const mockFn = jest.fn().mockResolvedValue('success'); + + const result = await middleware.handle(mockFn); + + expect(result).toBe('success'); + }); + + it('throws internal error if error is unexpected', async () => { + const mockFn = jest.fn().mockRejectedValue(new Error()); + + await expect(middleware.handle(mockFn)).rejects.toThrow( + 'Unexpected error', + ); + expect(mockSnapClient.getPreferences).toHaveBeenCalled(); + expect(mockTranslator.load).toHaveBeenCalledWith('en'); + }); + + it('handles error successfully if instance of BaseError', async () => { + const error = new BaseError('Test error', 1); + const mockFn = jest.fn().mockRejectedValue(error); + mockTranslator.load.mockResolvedValue({ + 'error.1': { message: 'Test error' }, + }); + + await expect(middleware.handle(mockFn)).rejects.toThrow('Test error'); + expect(mockSnapClient.getPreferences).toHaveBeenCalled(); + expect(mockTranslator.load).toHaveBeenCalledWith('en'); + expect(mockLogger.error).toHaveBeenCalledWith(error); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts new file mode 100644 index 00000000..440122ee --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts @@ -0,0 +1,89 @@ +import { + DisconnectedError, + InternalError, + InvalidInputError, + InvalidParamsError, + MethodNotFoundError, + ResourceNotFoundError, + UnauthorizedError, + UserRejectedRequestError, +} from '@metamask/snaps-sdk'; + +import type { Translator, Logger, SnapClient } from '../entities'; +import { + BaseError, + ExternalServiceError, + FormatError, + InexistentMethodError, + NotFoundError, + PermissionError, + StorageError, + UserActionCanceledError, + ValidationError, + WalletError, +} from '../entities'; + +export class HandlerMiddleware { + readonly #logger: Logger; + + readonly #snapClient: SnapClient; + + readonly #translator: Translator; + + constructor(logger: Logger, snapClient: SnapClient, translator: Translator) { + this.#logger = logger; + this.#snapClient = snapClient; + this.#translator = translator; + } + + async handle(fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + const { locale } = await this.#snapClient.getPreferences(); + const messages = await this.#translator.load(locale); + + if (error instanceof BaseError) { + this.#logger.error(error); + await this.#snapClient.emitTrackingError(error); + + const errMsg = + messages[`error.${error.code}`]?.message ?? + messages['error.internal']?.message ?? + 'Internal error'; + + /* eslint-disable @typescript-eslint/only-throw-error */ + // User errors that he can rectify: Equivalent to 4xx errors + if (error instanceof FormatError) { + throw new InvalidInputError(errMsg, error.data); + } else if (error instanceof ValidationError) { + throw new InvalidParamsError(errMsg, error.data); + } else if (error instanceof NotFoundError) { + throw new ResourceNotFoundError(errMsg, error.data); + } else if (error instanceof InexistentMethodError) { + throw new MethodNotFoundError(errMsg, error.data); + } else if (error instanceof PermissionError) { + throw new UnauthorizedError(errMsg, error.data); + } else if (error instanceof UserActionCanceledError) { + throw new UserRejectedRequestError(errMsg); + + // Internal errors that we should not expose to the user: Equivalent to 5xx errors + } else if (error instanceof ExternalServiceError) { + throw new DisconnectedError(errMsg); + } else if ( + error instanceof WalletError || + error instanceof StorageError + ) { + throw new InternalError(errMsg); + } else { + throw new InternalError(errMsg); + } + } else { + // this should never happen unless a BaseError is not thrown + const errMsg = messages.unexpected?.message ?? 'Unexpected error'; + this.#logger.error(errMsg, error); + throw new InternalError(errMsg); + } + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index bec2c92b..a91e00b9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -41,7 +41,6 @@ import { scopeToNetwork, networkToScope, } from './caip'; -import { handle } from './errors'; import { mapToDiscoveredAccount, mapToKeyringAccount, @@ -73,12 +72,7 @@ export class KeyringHandler implements Keyring { async route(origin: string, request: JsonRpcRequest): Promise { validateOrigin(origin); - - const result = await handle(async () => - handleKeyringRequest(this, request), - ); - - return result ?? null; // Use `null` since `undefined` is not valid in JSON. + return (await handleKeyringRequest(this, request)) ?? null; } async listAccounts(): Promise { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index 0157d8e5..a36afb96 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -1,5 +1,4 @@ import type { Psbt, Txid } from '@metamask/bitcoindevkit'; -import { SnapError } from '@metamask/snaps-sdk'; import type { JsonRpcRequest } from '@metamask/utils'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; @@ -30,7 +29,7 @@ describe('RpcHandler', () => { describe('route', () => { it('throws error if invalid origin', async () => { await expect(handler.route('invalidOrigin', mockRequest)).rejects.toThrow( - 'Permission denied', + 'Invalid origin', ); }); @@ -75,9 +74,7 @@ describe('RpcHandler', () => { const error = new Error(); mockSendFlowUseCases.display.mockRejectedValue(error); - await expect(handler.route(origin, mockRequest)).rejects.toThrow( - new SnapError(error), - ); + await expect(handler.route(origin, mockRequest)).rejects.toThrow(error); expect(mockSendFlowUseCases.display).toHaveBeenCalled(); expect(mockAccountsUseCases.sendPsbt).not.toHaveBeenCalled(); @@ -88,9 +85,7 @@ describe('RpcHandler', () => { mockSendFlowUseCases.display.mockResolvedValue(mockPsbt); mockAccountsUseCases.sendPsbt.mockRejectedValue(error); - await expect(handler.route(origin, mockRequest)).rejects.toThrow( - new SnapError(error), - ); + await expect(handler.route(origin, mockRequest)).rejects.toThrow(error); expect(mockSendFlowUseCases.display).toHaveBeenCalled(); expect(mockAccountsUseCases.sendPsbt).toHaveBeenCalled(); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index dffdb9de..af0d1aa1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -3,7 +3,6 @@ import type { Json, JsonRpcRequest } from '@metamask/utils'; import { assert, enums, object, optional, string } from 'superstruct'; import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; -import { handle } from './errors'; import { validateOrigin } from './permissions'; export enum RpcMethod { @@ -35,21 +34,19 @@ export class RpcHandler { const { method, params } = request; - return handle(async () => { - if (!params) { - throw new Error('Missing params'); - } - - switch (method as RpcMethod) { - case RpcMethod.StartSendTransactionFlow: { - assert(params, CreateSendFormRequest); - return this.#executeSendFlow(params.account, origin); - } + if (!params) { + throw new Error('Missing params'); + } - default: - throw new Error(`Method not found: ${method}`); + switch (method as RpcMethod) { + case RpcMethod.StartSendTransactionFlow: { + assert(params, CreateSendFormRequest); + return this.#executeSendFlow(params.account, origin); } - }); + + default: + throw new Error(`Method not found: ${method}`); + } } async #executeSendFlow( diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts index d155de7f..ab9174f2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts @@ -1,5 +1,5 @@ import type { UserInputEvent } from '@metamask/snaps-sdk'; -import { SnapError, UserInputEventType } from '@metamask/snaps-sdk'; +import { UserInputEventType } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; import type { SendFormContext } from '../entities'; @@ -85,7 +85,7 @@ describe('UserInputHandler', () => { }, mockContext, ), - ).rejects.toThrow(new SnapError(error)); + ).rejects.toThrow(error); }); }); @@ -120,7 +120,7 @@ describe('UserInputHandler', () => { }, mockContext, ), - ).rejects.toThrow(new SnapError(error)); + ).rejects.toThrow(error); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts index 546bcb22..5b61c2e8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts @@ -7,7 +7,6 @@ import type { import type { ReviewTransactionContext, SendFormContext } from '../entities'; import { ReviewTransactionEvent, SendFormEvent } from '../entities'; import type { SendFlowUseCases } from '../use-cases'; -import { handle } from './errors'; export class UserInputHandler { readonly #sendFlowUseCases: SendFlowUseCases; @@ -21,31 +20,29 @@ export class UserInputHandler { event: UserInputEvent, context: Record | null, ): Promise { - return handle(async () => { - if (!context) { - throw new Error('Missing context'); - } - if (!event.name) { - throw new Error('Missing event name'); - } - - if (this.#isSendFormEvent(event.name)) { - return this.#sendFlowUseCases.onChangeForm( - interfaceId, - event.name, - context as SendFormContext, - this.#hasValue(event) ? event.value : undefined, - ); - } else if (this.#isReviewTransactionEvent(event.name)) { - return this.#sendFlowUseCases.onChangeReview( - interfaceId, - event.name, - context as ReviewTransactionContext, - ); - } - - throw new Error(`Unsupported event: ${event.name}`); - }); + if (!context) { + throw new Error('Missing context'); + } + if (!event.name) { + throw new Error('Missing event name'); + } + + if (this.#isSendFormEvent(event.name)) { + return this.#sendFlowUseCases.onChangeForm( + interfaceId, + event.name, + context as SendFormContext, + this.#hasValue(event) ? event.value : undefined, + ); + } else if (this.#isReviewTransactionEvent(event.name)) { + return this.#sendFlowUseCases.onChangeReview( + interfaceId, + event.name, + context as ReviewTransactionContext, + ); + } + + throw new Error(`Unsupported event: ${event.name}`); } #isSendFormEvent(name: string): name is SendFormEvent { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts deleted file mode 100644 index fd6a58a2..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/errors.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { SnapError } from '@metamask/snaps-sdk'; - -export const handle = async ( - fn: () => Promise, -): Promise => { - try { - return await fn(); - } catch (error) { - // TODO: Improve error handling in the following way: - // 1. Use custom error types in the use cases with the initial error message (+context if necessary). - // 2. Log the error using the context from the custom error. - // 3. Map the error to a user-friendly message. - // 4. Throw the more aligned error type from the Snaps SDK. - // 5. Default to InternalError('an internal error occurred') if no custom error is thrown. - - // console.error('Error occurred:', (error as Error).message); - throw new SnapError(error as Error); - } -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts index ee2314f5..02886b5e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts @@ -1,5 +1,7 @@ +import { PermissionError } from '../entities'; + export const validateOrigin = (origin: string): void => { if (origin !== 'metamask') { - throw new Error('Permission denied'); + throw new PermissionError('Invalid origin', { origin }); } }; diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 8363d14c..2e0a154d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -17,6 +17,7 @@ import { RpcHandler, AssetsHandler, } from './handlers'; +import { HandlerMiddleware } from './handlers/HandlerMiddleware'; import { SnapClientAdapter, EsploraClientAdapter, @@ -33,6 +34,7 @@ const snapClient = new SnapClientAdapter(Config.encrypt); const chainClient = new EsploraClientAdapter(Config.chain); const assetRatesClient = new PriceApiClientAdapter(Config.priceApi); const translator = new LocalTranslatorAdapter(); +const middleware = new HandlerMiddleware(logger, snapClient, translator); // Data layer const accountRepository = new BdkAccountRepository(snapClient); @@ -72,31 +74,31 @@ const assetsHandler = new AssetsHandler( ); export const onCronjob: OnCronjobHandler = async ({ request }) => - cronHandler.route(request); + middleware.handle(async () => cronHandler.route(request)); export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request }) => - rpcHandler.route(origin, request); + middleware.handle(async () => rpcHandler.route(origin, request)); export const onKeyringRequest: OnKeyringRequestHandler = async ({ origin, request, -}) => keyringHandler.route(origin, request); +}) => middleware.handle(async () => keyringHandler.route(origin, request)); export const onUserInput: OnUserInputHandler = async ({ id, event, context }) => - userInputHandler.route(id, event, context); + middleware.handle(async () => userInputHandler.route(id, event, context)); export const onAssetsLookup: OnAssetsLookupHandler = async () => - assetsHandler.lookup(); + middleware.handle(async () => assetsHandler.lookup()); export const onAssetsConversion: OnAssetsConversionHandler = async ({ conversions, -}) => assetsHandler.conversion(conversions); +}) => middleware.handle(async () => assetsHandler.conversion(conversions)); export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ({ from, to, -}) => assetsHandler.historicalPrice(from, to); +}) => middleware.handle(async () => assetsHandler.historicalPrice(from, to)); export const onAssetsMarketData: OnAssetsMarketDataHandler = async ({ assets, -}) => assetsHandler.marketData(assets); +}) => middleware.handle(async () => assetsHandler.marketData(assets)); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index dea4e560..b24a8b48 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -11,7 +11,7 @@ import type { Json, } from '@metamask/snaps-sdk'; -import type { BitcoinAccount, SnapClient } from '../entities'; +import type { BaseError, BitcoinAccount, SnapClient } from '../entities'; import { TrackingSnapEvent, networkToCurrencyUnit } from '../entities'; import { addressTypeToCaip, @@ -238,4 +238,18 @@ export class SnapClientAdapter implements SnapClient { }); /* eslint-enable @typescript-eslint/naming-convention */ } + + async emitTrackingError(error: BaseError): Promise { + await snap.request({ + method: 'snap_trackError', + params: { + error: { + message: error.message, + name: error.name, + stack: error.stack ?? null, + cause: null, + }, + }, + }); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 30ad7230..126ed695 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -1,7 +1,6 @@ import type { FeeEstimates, Network } from '@metamask/bitcoindevkit'; import { Psbt, Address, Amount } from '@metamask/bitcoindevkit'; import type { GetPreferencesResult } from '@metamask/snaps-sdk'; -import { UserRejectedRequestError } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; import type { @@ -21,6 +20,7 @@ import { ReviewTransactionEvent, CurrencyUnit, SendFormEvent, + UserActionCanceledError, } from '../entities'; import { SendFlowUseCases } from './SendFlowUseCases'; import { CronMethod } from '../handlers'; @@ -95,11 +95,11 @@ describe('SendFlowUseCases', () => { ); }); - it('throws UserRejectedRequestError if displayInterface returns null', async () => { + it('throws UserActionCanceledError if displayInterface returns null', async () => { mockSnapClient.displayInterface.mockResolvedValue(null); await expect(useCases.display('account-id')).rejects.toThrow( - UserRejectedRequestError, + UserActionCanceledError, ); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index 781389ab..c70201dd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -1,7 +1,6 @@ import { Psbt, Address, Amount } from '@metamask/bitcoindevkit'; import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; import type { InputChangeEvent } from '@metamask/snaps-sdk'; -import { UserRejectedRequestError } from '@metamask/snaps-sdk'; import type { BitcoinAccountRepository, @@ -19,6 +18,7 @@ import { ReviewTransactionEvent, networkToCurrencyUnit, CurrencyUnit, + UserActionCanceledError, } from '../entities'; import { CronMethod } from '../handlers'; @@ -99,7 +99,7 @@ export class SendFlowUseCases { // Blocks and waits for user actions const psbt = await this.#snapClient.displayInterface(interfaceId); if (!psbt) { - throw new UserRejectedRequestError() as unknown as Error; + throw new UserActionCanceledError('User canceled the send flow'); } this.#logger.info('PSBT generated successfully'); @@ -423,7 +423,7 @@ export class SendFlowUseCases { updatedContext = await this.#computeFee(updatedContext); } catch (error) { // We do not throw so we can reschedule. Previous fetched values or fallbacks will be used. - this.#logger.error( + this.#logger.warn( `Failed to fetch rates in send form: %s. Error: %s`, id, error, From 2dfcc64791fe9e45cdd610e37a33ad8910fdd699 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 30 Jul 2025 17:08:19 +0200 Subject: [PATCH 258/362] feat: error handling (#498) --- .../integration-test/keyring.test.ts | 6 +- .../bitcoin-wallet-snap/locales/en.json | 33 ++++++ .../bitcoin-wallet-snap/messages.json | 33 ++++++ .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/entities/error.ts | 86 +++++++++----- .../src/entities/send-flow.ts | 4 +- .../src/handlers/HandlerMiddleware.test.ts | 2 +- .../src/handlers/HandlerMiddleware.ts | 64 +++++++--- .../src/handlers/KeyringHandler.ts | 91 +++++++++++++-- .../src/handlers/RpcHandler.ts | 10 +- .../src/handlers/UserInputHandler.ts | 13 ++- .../src/infra/BdkAccountAdapter.ts | 10 +- .../src/infra/EsploraClientAdapter.ts | 72 ++++++++---- .../src/infra/PriceApiClientAdapter.ts | 109 ++++++++++++------ .../src/infra/SnapClientAdapter.ts | 25 +++- .../src/store/BdkAccountRepository.ts | 19 +-- .../src/store/JSXSendFlowRepository.test.tsx | 9 +- .../src/store/JSXSendFlowRepository.tsx | 24 ++-- .../src/use-cases/AccountUseCases.test.ts | 6 +- .../src/use-cases/AccountUseCases.ts | 7 +- .../src/use-cases/SendFlowUseCases.test.ts | 8 +- .../src/use-cases/SendFlowUseCases.ts | 43 ++++--- 22 files changed, 483 insertions(+), 193 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index e8b6f8a2..d0b9f47a 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -29,6 +29,7 @@ describe('Keyring', () => { beforeEach(() => { snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); + snap.mockJsonRpc({ method: 'snap_trackError', result: {} }); }); it('discover accounts successfully', async () => { @@ -291,8 +292,9 @@ describe('Keyring', () => { }); expect(response).toRespondWithError({ - code: -32603, - message: `An unexpected error occurred`, + code: -32001, + message: `Resource not found: Account not found`, + data: { id, cause: null }, stack: expect.anything(), }); }); diff --git a/merged-packages/bitcoin-wallet-snap/locales/en.json b/merged-packages/bitcoin-wallet-snap/locales/en.json index 92359d14..7a921d77 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/en.json +++ b/merged-packages/bitcoin-wallet-snap/locales/en.json @@ -144,6 +144,39 @@ }, "unexpected": { "message": "An unexpected error occurred" + }, + "error.0": { + "message": "Invalid format" + }, + "error.1000": { + "message": "Validation failed" + }, + "error.2000": { + "message": "Resource not found" + }, + "error.3000": { + "message": "Connection error" + }, + "error.4000": { + "message": "Wallet state corrupted" + }, + "error.5000": { + "message": "Storage error" + }, + "error.6000": { + "message": "Method not implemented or not supported" + }, + "error.7000": { + "message": "Permission denied or insufficient authorization" + }, + "error.8000": { + "message": "User action error" + }, + "error.9000": { + "message": "Corrupted state, invariant not respected or failed assertion." + }, + "error.internal": { + "message": "Internal error. Please try again later or contact support" } } } diff --git a/merged-packages/bitcoin-wallet-snap/messages.json b/merged-packages/bitcoin-wallet-snap/messages.json index 22b29caa..80aa5734 100644 --- a/merged-packages/bitcoin-wallet-snap/messages.json +++ b/merged-packages/bitcoin-wallet-snap/messages.json @@ -142,5 +142,38 @@ }, "unexpected": { "message": "An unexpected error occurred" + }, + "error.0": { + "message": "Invalid format" + }, + "error.1000": { + "message": "Validation failed" + }, + "error.2000": { + "message": "Resource not found" + }, + "error.3000": { + "message": "Connection error" + }, + "error.4000": { + "message": "Wallet state corrupted" + }, + "error.5000": { + "message": "Storage error" + }, + "error.6000": { + "message": "Method not implemented or not supported" + }, + "error.7000": { + "message": "Permission denied or insufficient authorization" + }, + "error.8000": { + "message": "User action error" + }, + "error.9000": { + "message": "Corrupted state, invariant not respected or failed assertion." + }, + "error.internal": { + "message": "Internal error. Please try again later or contact support" } } diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 6358e7f0..edf858fa 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "h95BaDxsF74v4Pp1KRXrv65sJFEJtCqHMyvFqqcEo8c=", + "shasum": "HVa+ZDnc20usQMTbRcF0hFKbHJg3+CffZScpQOiW58c=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/error.ts b/merged-packages/bitcoin-wallet-snap/src/entities/error.ts index ead7b30e..e8c73d1d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/error.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/error.ts @@ -5,22 +5,20 @@ export class BaseError extends Error { data?: Record; - constructor(message: string, code: number, data?: Record) { + cause?: unknown; + + constructor( + message: string, + code: number, + data?: Record, + cause?: unknown, + ) { super(message); - this.name = this.constructor.name; this.code = code; this.data = data; + this.cause = cause; Object.setPrototypeOf(this, new.target.prototype); } - - toJSON(): CodifiedError { - return { - name: this.name, - message: this.message, - code: this.code, - data: this.data, - }; - } } export type CodifiedError = { @@ -28,6 +26,7 @@ export type CodifiedError = { message: string; code: number; data?: Record; + stack: string | null; }; /** @@ -38,8 +37,9 @@ export type CodifiedError = { * throw new FormatError('Invalid address format'); */ export class FormatError extends BaseError { - constructor(message: string, data?: Record) { - super(message, 0, data); + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 0, data, cause); + this.name = 'FormatError'; } } @@ -51,8 +51,9 @@ export class FormatError extends BaseError { * throw new ValidationError('Amount must be positive'); */ export class ValidationError extends BaseError { - constructor(message: string, data?: Record) { - super(message, 1000, data); + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 1000, data, cause); + this.name = 'ValidationError'; } } @@ -64,8 +65,9 @@ export class ValidationError extends BaseError { * throw new NotFoundError('Account not found'); */ export class NotFoundError extends BaseError { - constructor(message: string, data?: Record) { - super(message, 2000, data); + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 2000, data, cause); + this.name = 'NotFoundError'; } } @@ -77,8 +79,9 @@ export class NotFoundError extends BaseError { * throw new ExternalServiceError('Price API unavailable'); */ export class ExternalServiceError extends BaseError { - constructor(message: string, data?: Record) { - super(message, 3000, data); + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 3000, data, cause); + this.name = 'ExternalServiceError'; } } @@ -90,8 +93,9 @@ export class ExternalServiceError extends BaseError { * throw new WalletError('Insufficient funds'); */ export class WalletError extends BaseError { - constructor(message: string, data?: Record) { - super(message, 4000, data); + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 4000, data, cause); + this.name = 'WalletError'; } } @@ -103,8 +107,9 @@ export class WalletError extends BaseError { * throw new StorageError('Failed to insert account'); */ export class StorageError extends BaseError { - constructor(message: string, data?: Record) { - super(message, 5000, data); + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 5000, data, cause); + this.name = 'StorageError'; } } @@ -116,8 +121,9 @@ export class StorageError extends BaseError { * throw new InexistentError('Method not implemented'); */ export class InexistentMethodError extends BaseError { - constructor(message: string, data?: Record) { - super(message, 6000, data); + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 6000, data, cause); + this.name = 'InexistentMethodError'; } } @@ -129,19 +135,35 @@ export class InexistentMethodError extends BaseError { * throw new PermissionError('Invalid origin'); */ export class PermissionError extends BaseError { - constructor(message: string, data?: Record) { - super(message, 7000, data); + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 7000, data, cause); + this.name = 'PermissionError'; + } +} + +/** + * Error thrown when an operation is failing in a user interface, such as forms, prompts, or confirmations. + * + * @example + * throw new UserActionError('User canceled the send flow'); + */ +export class UserActionError extends BaseError { + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 8000, data, cause); + this.name = 'UserActionError'; } } /** - * Error thrown when an operation is canceled by the user. + * Error thrown when an assertion fails. These are errors that should never happen outside of developer errors or bugs. + * Useful for signaling unexpected conditions that should be fixed in the code. * * @example - * throw new UserActionCanceledError('User canceled the send flow'); + * throw new AssertionError('Inconsistent state detected. Expected X, got Y', { state }); */ -export class UserActionCanceledError extends BaseError { - constructor(message: string, data?: Record) { - super(message, 8000, data); +export class AssertionError extends BaseError { + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 9000, data, cause); + this.name = 'AssertionError'; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts index fe3c1229..a012ac84 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts @@ -72,9 +72,9 @@ export type SendFlowRepository = { * Get the form context. * * @param id - the interface ID - * @returns the form context or null + * @returns the form context */ - getContext(id: string): Promise; + getContext(id: string): Promise; /** * Insert a new send form interface. diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts index 4be840fb..8459d518 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts @@ -60,7 +60,7 @@ describe('HandlerMiddleware', () => { await expect(middleware.handle(mockFn)).rejects.toThrow('Test error'); expect(mockSnapClient.getPreferences).toHaveBeenCalled(); expect(mockTranslator.load).toHaveBeenCalledWith('en'); - expect(mockLogger.error).toHaveBeenCalledWith(error); + expect(mockLogger.error).toHaveBeenCalledWith(error, error.data); expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts index 440122ee..a1d0d990 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts @@ -8,6 +8,7 @@ import { UnauthorizedError, UserRejectedRequestError, } from '@metamask/snaps-sdk'; +import { StructError } from 'superstruct'; import type { Translator, Logger, SnapClient } from '../entities'; import { @@ -18,9 +19,10 @@ import { NotFoundError, PermissionError, StorageError, - UserActionCanceledError, + UserActionError, ValidationError, WalletError, + AssertionError, } from '../entities'; export class HandlerMiddleware { @@ -44,8 +46,14 @@ export class HandlerMiddleware { const messages = await this.#translator.load(locale); if (error instanceof BaseError) { - this.#logger.error(error); - await this.#snapClient.emitTrackingError(error); + this.#logger.error(error, error.data); + + try { + await this.#snapClient.emitTrackingError(error); + } catch (trackingError) { + // The tracking pipeline is non‑critical; log and proceed so we don’t mask the original failure. + this.#logger.error('Failed to track error', trackingError); + } const errMsg = messages[`error.${error.code}`]?.message ?? @@ -55,33 +63,59 @@ export class HandlerMiddleware { /* eslint-disable @typescript-eslint/only-throw-error */ // User errors that he can rectify: Equivalent to 4xx errors if (error instanceof FormatError) { - throw new InvalidInputError(errMsg, error.data); + throw new InvalidInputError( + `${errMsg}: ${error.message}`, + error.data, + ); } else if (error instanceof ValidationError) { - throw new InvalidParamsError(errMsg, error.data); + throw new InvalidParamsError( + `${errMsg}: ${error.message}`, + error.data, + ); } else if (error instanceof NotFoundError) { - throw new ResourceNotFoundError(errMsg, error.data); + throw new ResourceNotFoundError( + `${errMsg}: ${error.message}`, + error.data, + ); } else if (error instanceof InexistentMethodError) { - throw new MethodNotFoundError(errMsg, error.data); + throw new MethodNotFoundError( + `${errMsg}: ${error.message}`, + error.data, + ); } else if (error instanceof PermissionError) { - throw new UnauthorizedError(errMsg, error.data); - } else if (error instanceof UserActionCanceledError) { - throw new UserRejectedRequestError(errMsg); + throw new UnauthorizedError( + `${errMsg}: ${error.message}`, + error.data, + ); + } else if (error instanceof UserActionError) { + throw new UserRejectedRequestError( + `${errMsg}: ${error.message}`, + error.data, + ); // Internal errors that we should not expose to the user: Equivalent to 5xx errors } else if (error instanceof ExternalServiceError) { - throw new DisconnectedError(errMsg); + throw new DisconnectedError(errMsg, error.data); } else if ( error instanceof WalletError || - error instanceof StorageError + error instanceof StorageError || + error instanceof AssertionError ) { - throw new InternalError(errMsg); + throw new InternalError(errMsg, error.data); } else { - throw new InternalError(errMsg); + throw new InternalError(errMsg, error.data); } } else { + if (error instanceof StructError) { + const errMsg = messages['error.0']?.message ?? 'Invalid format'; + throw new InvalidInputError( + `${errMsg}: ${error.message}`, + error.data, + ); + } // this should never happen unless a BaseError is not thrown const errMsg = messages.unexpected?.message ?? 'Unexpected error'; - this.#logger.error(errMsg, error); + this.#logger.error(error); throw new InternalError(errMsg); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index a91e00b9..ed744bae 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -2,6 +2,16 @@ import type { AddressType } from '@metamask/bitcoindevkit'; import { BtcAccountType, BtcScope, + CreateAccountRequestStruct, + DeleteAccountRequestStruct, + DiscoverAccountsRequestStruct, + FilterAccountChainsStruct, + GetAccountBalancesRequestStruct, + GetAccountRequestStruct, + KeyringRpcMethod, + ListAccountAssetsRequestStruct, + ListAccountsRequestStruct, + ListAccountTransactionsRequestStruct, MetaMaskOptionsStruct, } from '@metamask/keyring-api'; import type { @@ -17,7 +27,6 @@ import type { MetaMaskOptions, DiscoveredAccount, } from '@metamask/keyring-api'; -import { handleKeyringRequest } from '@metamask/keyring-snap-sdk'; import type { Json, JsonRpcRequest } from '@metamask/utils'; import { assert, @@ -31,6 +40,8 @@ import { import type { BitcoinAccount } from '../entities'; import { + FormatError, + InexistentMethodError, networkToCurrencyUnit, Purpose, purposeToAddressType, @@ -72,7 +83,62 @@ export class KeyringHandler implements Keyring { async route(origin: string, request: JsonRpcRequest): Promise { validateOrigin(origin); - return (await handleKeyringRequest(this, request)) ?? null; + + switch (request.method) { + case `${KeyringRpcMethod.ListAccounts}`: { + assert(request, ListAccountsRequestStruct); + return this.listAccounts(); + } + case `${KeyringRpcMethod.GetAccount}`: { + assert(request, GetAccountRequestStruct); + return this.getAccount(request.params.id); + } + case `${KeyringRpcMethod.CreateAccount}`: { + assert(request, CreateAccountRequestStruct); + return this.createAccount(request.params.options); + } + case `${KeyringRpcMethod.DiscoverAccounts}`: { + assert(request, DiscoverAccountsRequestStruct); + return this.discoverAccounts( + request.params.scopes as BtcScope[], + request.params.entropySource, + request.params.groupIndex, + ); + } + case `${KeyringRpcMethod.ListAccountTransactions}`: { + assert(request, ListAccountTransactionsRequestStruct); + return this.listAccountTransactions( + request.params.id, + request.params.pagination, + ); + } + case `${KeyringRpcMethod.ListAccountAssets}`: { + assert(request, ListAccountAssetsRequestStruct); + return this.listAccountAssets(request.params.id); + } + case `${KeyringRpcMethod.GetAccountBalances}`: { + assert(request, GetAccountBalancesRequestStruct); + return this.getAccountBalances(request.params.id); + } + case `${KeyringRpcMethod.FilterAccountChains}`: { + assert(request, FilterAccountChainsStruct); + return this.filterAccountChains( + request.params.id, + request.params.chains, + ); + } + case `${KeyringRpcMethod.DeleteAccount}`: { + assert(request, DeleteAccountRequestStruct); + await this.deleteAccount(request.params.id); + return null; + } + + default: { + throw new InexistentMethodError('Keyring method not supported', { + method: request.method, + }); + } + } } async listAccounts(): Promise { @@ -80,7 +146,7 @@ export class KeyringHandler implements Keyring { return accounts.map(mapToKeyringAccount); } - async getAccount(id: string): Promise { + async getAccount(id: string): Promise { const account = await this.#accountsUseCases.get(id); return mapToKeyringAccount(account); } @@ -89,6 +155,7 @@ export class KeyringHandler implements Keyring { options: Record & MetaMaskOptions, ): Promise { assert(options, CreateAccountRequest); + const { metamask, scope, @@ -185,7 +252,7 @@ export class KeyringHandler implements Keyring { } async updateAccount(): Promise { - throw new Error('Method not supported.'); + throw new InexistentMethodError('Method not supported.'); } async deleteAccount(id: string): Promise { @@ -228,30 +295,30 @@ export class KeyringHandler implements Keyring { } async submitRequest(): Promise { - throw new Error('Method not implemented.'); + throw new InexistentMethodError('Method not supported.'); } #extractAddressType(path: string): AddressType { const segments = path.split('/'); if (segments.length < 4) { - throw new Error(`Invalid derivation path: ${path}`); + throw new FormatError(`Invalid derivation path: ${path}`); } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const purposePart = segments[1]!; const match = purposePart.match(/^(\d+)/u); if (!match) { - throw new Error(`Invalid purpose segment: ${purposePart}`); + throw new FormatError(`Invalid purpose segment: ${purposePart}`); } const purpose = Number(match[1]); if (!Object.values(Purpose).includes(purpose)) { - throw new Error(`Invalid BIP-purpose: ${purpose}`); + throw new FormatError(`Invalid BIP-purpose: ${purpose}`); } const addressType = purposeToAddressType[purpose as Purpose]; if (!addressType) { - throw new Error(`No address-type mapping for purpose: ${purpose}`); + throw new FormatError(`No address-type mapping for purpose: ${purpose}`); } return addressType; @@ -260,19 +327,19 @@ export class KeyringHandler implements Keyring { #extractAccountIndex(path: string): number { const segments = path.split('/'); if (segments.length < 4) { - throw new Error(`Invalid derivation path: ${path}`); + throw new FormatError(`Invalid derivation path: ${path}`); } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const accountPart = segments[3]!; const match = accountPart.match(/^(\d+)/u); if (!match) { - throw new Error(`Invalid account index: ${accountPart}`); + throw new FormatError(`Invalid account index: ${accountPart}`); } const index = Number(match[1]); if (!Number.isInteger(index) || index < 0) { - throw new Error( + throw new FormatError( `Account index must be a non-negative integer, got: ${index}`, ); } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index af0d1aa1..dbd11cd0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -4,6 +4,7 @@ import { assert, enums, object, optional, string } from 'superstruct'; import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; import { validateOrigin } from './permissions'; +import { FormatError, InexistentMethodError } from '../entities'; export enum RpcMethod { StartSendTransactionFlow = 'startSendTransactionFlow', @@ -35,7 +36,7 @@ export class RpcHandler { const { method, params } = request; if (!params) { - throw new Error('Missing params'); + throw new FormatError('Missing params'); } switch (method as RpcMethod) { @@ -45,15 +46,18 @@ export class RpcHandler { } default: - throw new Error(`Method not found: ${method}`); + throw new InexistentMethodError(`Method not found: ${method}`); } } async #executeSendFlow( account: string, origin: string, - ): Promise { + ): Promise { const psbt = await this.#sendFlowUseCases.display(account); + if (!psbt) { + return null; + } const txId = await this.#accountUseCases.sendPsbt(account, psbt, origin); return { txId: txId.toString() }; } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts index 5b61c2e8..3cd5502d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts @@ -5,7 +5,12 @@ import type { } from '@metamask/snaps-sdk'; import type { ReviewTransactionContext, SendFormContext } from '../entities'; -import { ReviewTransactionEvent, SendFormEvent } from '../entities'; +import { + FormatError, + InexistentMethodError, + ReviewTransactionEvent, + SendFormEvent, +} from '../entities'; import type { SendFlowUseCases } from '../use-cases'; export class UserInputHandler { @@ -21,10 +26,10 @@ export class UserInputHandler { context: Record | null, ): Promise { if (!context) { - throw new Error('Missing context'); + throw new FormatError('Missing context'); } if (!event.name) { - throw new Error('Missing event name'); + throw new FormatError('Missing event name'); } if (this.#isSendFormEvent(event.name)) { @@ -42,7 +47,7 @@ export class UserInputHandler { ); } - throw new Error(`Unsupported event: ${event.name}`); + throw new InexistentMethodError(`Unsupported event: ${event.name}`); } #isSendFormEvent(name: string): name is SendFormEvent { diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index af5d632e..e54063ff 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -23,7 +23,11 @@ import { Wallet, } from '@metamask/bitcoindevkit'; -import type { BitcoinAccount, TransactionBuilder } from '../entities'; +import { + WalletError, + type BitcoinAccount, + type TransactionBuilder, +} from '../entities'; import { BdkTxBuilderAdapter } from './BdkTxBuilderAdapter'; export class BdkAccountAdapter implements BitcoinAccount { @@ -97,7 +101,7 @@ export class BdkAccountAdapter implements BitcoinAccount { get addressType(): AddressType { const addressType = this.peekAddress(0).address_type; if (!addressType) { - throw new Error( + throw new WalletError( 'unknown, non-standard or related to the future witness version.', ); } @@ -148,7 +152,7 @@ export class BdkAccountAdapter implements BitcoinAccount { sign(psbt: Psbt): Transaction { const success = this.#wallet.sign(psbt, new SignOptions()); if (!success) { - throw new Error('failed to sign PSBT'); + throw new WalletError('failed to sign PSBT'); } return psbt.extract_tx(); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts index c32df4ff..5b3a02f4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts @@ -5,10 +5,11 @@ import type { } from '@metamask/bitcoindevkit'; import { EsploraClient } from '@metamask/bitcoindevkit'; -import type { - BitcoinAccount, - ChainConfig, - BlockchainClient, +import { + type BitcoinAccount, + type ChainConfig, + type BlockchainClient, + ExternalServiceError, } from '../entities'; export class EsploraClientAdapter implements BlockchainClient { @@ -30,31 +31,62 @@ export class EsploraClientAdapter implements BlockchainClient { } async fullScan(account: BitcoinAccount): Promise { - const request = account.startFullScan(); - const update = await this.#clients[account.network].full_scan( - request, - this.#config.stopGap, - this.#config.parallelRequests, - ); - - account.applyUpdate(update); + try { + const request = account.startFullScan(); + const update = await this.#clients[account.network].full_scan( + request, + this.#config.stopGap, + this.#config.parallelRequests, + ); + account.applyUpdate(update); + } catch (error) { + throw new ExternalServiceError( + `Failed to perform initial full scan`, + { account: account.id }, + error, + ); + } } async sync(account: BitcoinAccount): Promise { - const request = account.startSync(); - const update = await this.#clients[account.network].sync( - request, - this.#config.parallelRequests, - ); - account.applyUpdate(update); + try { + const request = account.startSync(); + const update = await this.#clients[account.network].sync( + request, + this.#config.parallelRequests, + ); + account.applyUpdate(update); + } catch (error) { + throw new ExternalServiceError( + `Failed to synchronize account`, + { account: account.id }, + error, + ); + } } async broadcast(network: Network, transaction: Transaction): Promise { - await this.#clients[network].broadcast(transaction); + try { + await this.#clients[network].broadcast(transaction); + } catch (error) { + throw new ExternalServiceError( + `Failed to broadcast transaction`, + { network, txid: transaction.compute_txid().toString() }, + error, + ); + } } async getFeeEstimates(network: Network): Promise { - return this.#clients[network].get_fee_estimates(); + try { + return await this.#clients[network].get_fee_estimates(); + } catch (error) { + throw new ExternalServiceError( + `Failed to fetch fee estimates`, + { network }, + error, + ); + } } getExplorerUrl(network: Network): string { diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts index b515cf74..c080d193 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/PriceApiClientAdapter.ts @@ -1,10 +1,11 @@ import type { HistoricalPriceValue } from '@metamask/snaps-sdk'; -import type { - AssetRatesClient, - PriceApiConfig, - SpotPrice, - TimePeriod, +import { + ExternalServiceError, + type AssetRatesClient, + type PriceApiConfig, + type SpotPrice, + type TimePeriod, } from '../entities'; type SpotPricesResponse = { @@ -41,33 +42,49 @@ export class PriceApiClientAdapter implements AssetRatesClient { const url = `${ this.#endpoint }/v1/spot-prices/${baseCurrency}?vsCurrency=${vsCurrency}`; - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch spot prices: ${response.statusText}`); - } + try { + const response = await fetch(url); + + if (!response.ok) { + throw new ExternalServiceError(`Failed to fetch spot prices`, { + vsCurrency, + baseCurrency, + response: response.statusText, + }); + } - const prices: SpotPricesResponse = await response.json(); - return { - price: prices.price, - marketData: { - fungible: true, - allTimeHigh: prices.allTimeHigh.toString(), - allTimeLow: prices.allTimeLow.toString(), - circulatingSupply: prices.circulatingSupply.toString(), - marketCap: prices.marketCap.toString(), - totalVolume: prices.totalVolume.toString(), - pricePercentChange: { - PT1H: prices.pricePercentChange1h, - P1D: prices.pricePercentChange1d, - P7D: prices.pricePercentChange7d, - P14D: prices.pricePercentChange14d, - P30D: prices.pricePercentChange30d, - P200D: prices.pricePercentChange200d, - P1Y: prices.pricePercentChange1y, + const prices: SpotPricesResponse = await response.json(); + return { + price: prices.price, + marketData: { + fungible: true, + allTimeHigh: prices.allTimeHigh.toString(), + allTimeLow: prices.allTimeLow.toString(), + circulatingSupply: prices.circulatingSupply.toString(), + marketCap: prices.marketCap.toString(), + totalVolume: prices.totalVolume.toString(), + pricePercentChange: { + PT1H: prices.pricePercentChange1h, + P1D: prices.pricePercentChange1d, + P7D: prices.pricePercentChange7d, + P14D: prices.pricePercentChange14d, + P30D: prices.pricePercentChange30d, + P200D: prices.pricePercentChange200d, + P1Y: prices.pricePercentChange1y, + }, + }, + }; + } catch (error) { + throw new ExternalServiceError( + `Network failure while fetching spot prices`, + { + vsCurrency, + baseCurrency, }, - }, - }; + error, + ); + } } async historicalPrices( @@ -80,17 +97,33 @@ export class PriceApiClientAdapter implements AssetRatesClient { }/v1/historical-prices/${baseCurrency}?timePeriod=${timePeriod.slice( 1, )}&vsCurrency=${vsCurrency}`; - const response = await fetch(url); - if (!response.ok) { - throw new Error( - `Failed to fetch historical rates: ${response.statusText}`, + try { + const response = await fetch(url); + + if (!response.ok) { + throw new ExternalServiceError(`Failed to fetch historical rates`, { + timePeriod, + vsCurrency, + baseCurrency, + response: response.statusText, + }); + } + + const prices: HistoricalPricesResponse = await response.json(); + return prices.prices + .filter(([, price]) => price !== null) // keep only non-null prices + .map(([ts, price]) => [ts, (price as number).toString()]); + } catch (error) { + throw new ExternalServiceError( + `Network failure while fetching historical prices`, + { + timePeriod, + vsCurrency, + baseCurrency, + }, + error, ); } - - const prices: HistoricalPricesResponse = await response.json(); - return prices.prices - .filter(([, price]) => price !== null) // keep only non-null prices - .map(([ts, price]) => [ts, (price as number).toString()]); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index b24a8b48..c31c3e4e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -12,7 +12,11 @@ import type { } from '@metamask/snaps-sdk'; import type { BaseError, BitcoinAccount, SnapClient } from '../entities'; -import { TrackingSnapEvent, networkToCurrencyUnit } from '../entities'; +import { + TrackingSnapEvent, + networkToCurrencyUnit, + AssertionError, +} from '../entities'; import { addressTypeToCaip, networkToCaip19, @@ -212,9 +216,10 @@ export class SnapClientAdapter implements SnapClient { case TrackingSnapEvent.TransactionReceived: return 'Snap transaction received'; default: - throw new Error( - `Unhandled tracking event type: ${eventType as string}`, - ); + throw new AssertionError(`Unhandled tracking event type`, { + eventType, + origin, + }); } }; @@ -244,10 +249,18 @@ export class SnapClientAdapter implements SnapClient { method: 'snap_trackError', params: { error: { - message: error.message, name: error.name, + message: error.message, stack: error.stack ?? null, - cause: null, + cause: + error.cause && error.cause instanceof Error + ? { + cause: null, + message: error.cause.message, + name: error.cause.name, + stack: error.cause.stack ?? null, + } + : null, }, }, }); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts index d0eeeaa8..920c6d40 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts @@ -10,13 +10,14 @@ import { } from '@metamask/bitcoindevkit'; import { v4 } from 'uuid'; -import type { - BitcoinAccountRepository, - BitcoinAccount, - SnapClient, - Inscription, - AccountState, - SnapState, +import { + type BitcoinAccountRepository, + type BitcoinAccount, + type SnapClient, + type Inscription, + type AccountState, + type SnapState, + StorageError, } from '../entities'; import { BdkAccountAdapter } from '../infra'; @@ -135,7 +136,7 @@ export class BdkAccountRepository implements BitcoinAccountRepository { const walletData = account.takeStaged(); if (!walletData) { - throw new Error( + throw new StorageError( `Missing changeset data for account "${id}" for insertion.`, ); } @@ -169,7 +170,7 @@ export class BdkAccountRepository implements BitcoinAccountRepository { const walletData = await this.#snapClient.getState(`accounts.${id}.wallet`); if (!walletData) { - throw new Error( + throw new StorageError( `Inconsistent state: account "${id}" not found for update`, ); } diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx index da2ef236..c2e662d6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx @@ -34,12 +34,11 @@ describe('JSXSendFlowRepository', () => { expect(result).toStrictEqual(context); }); - it('returns null if context is null', async () => { + it('throws AssertionError if context is null', async () => { mockSnapClient.getInterfaceContext.mockResolvedValue(null); - - const result = await repo.getContext('test-id'); - - expect(result).toBeNull(); + await expect(repo.getContext('test-id')).rejects.toThrow( + 'Missing context in send flow interface', + ); }); it('propagates error from getInterfaceContext', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx index 129a6db5..d6dc2c21 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx @@ -1,9 +1,10 @@ -import type { - SendFormContext, - SendFlowRepository, - SnapClient, - ReviewTransactionContext, - Translator, +import { + type SendFormContext, + type SendFlowRepository, + type SnapClient, + type ReviewTransactionContext, + type Translator, + AssertionError, } from '../entities'; import { ReviewTransactionView, SendFormView } from '../infra/jsx'; @@ -17,10 +18,13 @@ export class JSXSendFlowRepository implements SendFlowRepository { this.#translator = translator; } - async getContext(id: string): Promise { - return (await this.#snapClient.getInterfaceContext( - id, - )) as SendFormContext | null; + async getContext(id: string): Promise { + const context = await this.#snapClient.getInterfaceContext(id); + if (!context) { + throw new AssertionError('Missing context in send flow interface'); + } + + return context as SendFormContext; } async insertForm(context: SendFormContext): Promise { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 252fa299..ea4da0a6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -56,7 +56,7 @@ describe('AccountUseCases', () => { mockRepository.get.mockResolvedValue(null); await expect(useCases.get('some-id')).rejects.toThrow( - 'Account not found: some-id', + 'Account not found', ); expect(mockRepository.get).toHaveBeenCalledWith('some-id'); @@ -717,7 +717,7 @@ describe('AccountUseCases', () => { mockRepository.get.mockResolvedValue(null); await expect(useCases.delete('non-existent-id')).rejects.toThrow( - 'Account not found: non-existent-id', + 'Account not found', ); expect(mockRepository.get).toHaveBeenCalledWith('non-existent-id'); @@ -797,7 +797,7 @@ describe('AccountUseCases', () => { await expect( useCases.sendPsbt('non-existent-id', mockPsbt, 'metamask'), - ).rejects.toThrow('Account not found: non-existent-id'); + ).rejects.toThrow('Account not found'); }); it('sends transaction', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index c993b016..4cb35085 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -15,6 +15,7 @@ import { type Logger, type MetaProtocolsClient, networkToCoinType, + NotFoundError, type SnapClient, TrackingSnapEvent, } from '../entities'; @@ -71,7 +72,7 @@ export class AccountUseCases { const account = await this.#repository.get(id); if (!account) { - throw new Error(`Account not found: ${id}`); + throw new NotFoundError('Account not found', { id }); } this.#logger.debug('Account found: %s', account.id); @@ -285,7 +286,7 @@ export class AccountUseCases { const account = await this.#repository.get(id); if (!account) { - throw new Error(`Account not found: ${id}`); + throw new NotFoundError('Account not found', { id }); } await this.#snapClient.emitAccountDeletedEvent(id); @@ -299,7 +300,7 @@ export class AccountUseCases { const account = await this.#repository.getWithSigner(id); if (!account) { - throw new Error(`Account not found: ${id}`); + throw new NotFoundError('Account not found', { id }); } const tx = account.sign(psbt); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 126ed695..426fec32 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -20,7 +20,6 @@ import { ReviewTransactionEvent, CurrencyUnit, SendFormEvent, - UserActionCanceledError, } from '../entities'; import { SendFlowUseCases } from './SendFlowUseCases'; import { CronMethod } from '../handlers'; @@ -95,12 +94,11 @@ describe('SendFlowUseCases', () => { ); }); - it('throws UserActionCanceledError if displayInterface returns null', async () => { + it('returns undefined if displayInterface returns null', async () => { mockSnapClient.displayInterface.mockResolvedValue(null); - await expect(useCases.display('account-id')).rejects.toThrow( - UserActionCanceledError, - ); + const result = await useCases.display('account-id'); + expect(result).toBeUndefined(); }); it('displays Send form and returns PSBT when resolved', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index c70201dd..798f1121 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -18,7 +18,9 @@ import { ReviewTransactionEvent, networkToCurrencyUnit, CurrencyUnit, - UserActionCanceledError, + UserActionError, + NotFoundError, + AssertionError, } from '../entities'; import { CronMethod } from '../handlers'; @@ -67,12 +69,12 @@ export class SendFlowUseCases { this.#ratesRefreshInterval = ratesRefreshInterval; } - async display(accountId: string): Promise { + async display(accountId: string): Promise { this.#logger.debug('Displaying Send form. Account: %s', accountId); const account = await this.#accountRepository.get(accountId); if (!account) { - throw new Error('Account not found'); + throw new NotFoundError('Account not found'); } const { locale } = await this.#snapClient.getPreferences(); @@ -99,7 +101,7 @@ export class SendFlowUseCases { // Blocks and waits for user actions const psbt = await this.#snapClient.displayInterface(interfaceId); if (!psbt) { - throw new UserActionCanceledError('User canceled the send flow'); + return undefined; } this.#logger.info('PSBT generated successfully'); @@ -183,7 +185,7 @@ export class SendFlowUseCases { return undefined; } default: - throw new Error('Unrecognized event'); + throw new UserActionError('Unrecognized event'); } } @@ -211,7 +213,7 @@ export class SendFlowUseCases { return this.#snapClient.resolveInterface(id, context.psbt); } default: - throw new Error('Unrecognized event'); + throw new UserActionError('Unrecognized event'); } } @@ -308,7 +310,7 @@ export class SendFlowUseCases { const account = await this.#accountRepository.get(context.account.id); if (!account) { - throw new Error('Account removed while confirming send flow'); + throw new NotFoundError('Account removed while confirming send flow'); } const frozenUTXOs = await this.#accountRepository.getFrozenUTXOs( context.account.id, @@ -358,7 +360,7 @@ export class SendFlowUseCases { } } - throw new Error('Inconsistent Send form context'); + throw new AssertionError('Inconsistent Send form context'); } async #handleSetAccount( @@ -368,7 +370,9 @@ export class SendFlowUseCases { ): Promise { const account = await this.#accountRepository.get(formState.accountId); if (!account) { - throw new Error('Account not found when switching'); + throw new NotFoundError('Account not found when switching', { + id: formState.accountId, + }); } // We "reset" the context with the new account @@ -389,12 +393,14 @@ export class SendFlowUseCases { } async refresh(id: string): Promise { - const context = await this.#sendFlowRepository.getContext(id); - if (!context) { - throw new Error(`Context not found in send form: ${id}`); + try { + const context = await this.#sendFlowRepository.getContext(id); + return this.#refreshRates(id, context); + } catch (error) { + // We do not throw as this is probably due to a scheduled event executing after the interface has been removed. + this.#logger.debug('Context not found in send flow:', id, error); + return undefined; } - - return this.#refreshRates(id, context); } async #refreshRates(id: string, context: SendFormContext): Promise { @@ -444,13 +450,12 @@ export class SendFlowUseCases { async #computeFee(context: SendFormContext): Promise { const { amount, recipient, drain, balance } = context; if (amount && recipient) { - const account = await this.#accountRepository.get(context.account.id); + const { id } = context.account; + const account = await this.#accountRepository.get(id); if (!account) { - throw new Error('Account removed while sending'); + throw new NotFoundError('Account removed while sending', { id }); } - const frozenUTXOs = await this.#accountRepository.getFrozenUTXOs( - context.account.id, - ); + const frozenUTXOs = await this.#accountRepository.getFrozenUTXOs(id); try { const builder = account From 1c0f23798427e1333cb5cf8e5bf852149c11bf65 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 30 Jul 2025 18:11:49 +0200 Subject: [PATCH 259/362] feat: add account options (#499) --- .../integration-test/constants.ts | 17 +++++++++++++++- .../integration-test/keyring.test.ts | 16 +++++++++++++++ .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/KeyringHandler.test.ts | 17 +++++++++++++++- .../src/handlers/KeyringHandler.ts | 2 +- .../src/handlers/mappings.ts | 20 +++++++++---------- 6 files changed, 60 insertions(+), 14 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts b/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts index 61746bdb..e19d50f1 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/constants.ts @@ -1,4 +1,4 @@ -import { BtcScope } from '@metamask/keyring-api'; +import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; import { CurrencyUnit } from '../src/entities'; import { Caip19Asset } from '../src/handlers/caip'; @@ -35,3 +35,18 @@ export const FUNDING_TX = { ], type: 'receive', }; + +export const accountTypeToPurpose: Record = { + [BtcAccountType.P2pkh]: "44'", + [BtcAccountType.P2sh]: "49'", + [BtcAccountType.P2wpkh]: "84'", + [BtcAccountType.P2tr]: "86'", +}; + +export const scopeToCoinType: Record = { + [BtcScope.Mainnet]: "0'", + [BtcScope.Testnet]: "1'", + [BtcScope.Testnet4]: "1'", + [BtcScope.Signet]: "1'", + [BtcScope.Regtest]: "1'", +}; diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index d0b9f47a..02ed1440 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -9,6 +9,8 @@ import { ORIGIN, TEST_ADDRESS_REGTEST, TEST_ADDRESS_MAINNET, + scopeToCoinType, + accountTypeToPurpose, } from './constants'; import { CurrencyUnit } from '../src/entities'; import { Caip19Asset } from '../src/handlers/caip'; @@ -72,6 +74,13 @@ describe('Keyring', () => { address: TEST_ADDRESS_REGTEST, options: { entropySource: 'm', + entropy: { + type: 'mnemonic', + id: 'm', + groupIndex: 0, + derivationPath: "m/84'/1'/0'", + }, + exportable: false, }, scopes: [BtcScope.Regtest], methods: [BtcMethod.SendBitcoin], @@ -149,6 +158,13 @@ describe('Keyring', () => { address: expectedAddress, options: { entropySource: 'm', + entropy: { + type: 'mnemonic', + id: 'm', + groupIndex: requestOpts.index, + derivationPath: `m/${accountTypeToPurpose[requestOpts.addressType]}/${scopeToCoinType[requestOpts.scope]}/${requestOpts.index}'`, + }, + exportable: false, }, scopes: [requestOpts.scope], methods: [BtcMethod.SendBitcoin], diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index edf858fa..04e496b5 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "HVa+ZDnc20usQMTbRcF0hFKbHJg3+CffZScpQOiW58c=", + "shasum": "1JX8CkzNZKtn2kBz/lVEViAX+x8x2XQbzbJkHBRyGo8=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 968cf325..79a58457 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -251,9 +251,10 @@ describe('KeyringHandler', () => { addressType: caipToAddressType[addrType], network: scopeToNetwork[scope], listTransactions: jest.fn().mockReturnValue([{}]), // has history + derivationPath: ['m', "84'", "0'", "0'"], }); - expected.push(mapToDiscoveredAccount(acc, groupIndex)); + expected.push(mapToDiscoveredAccount(acc)); mockAccounts.discover.mockResolvedValueOnce(acc); }); }); @@ -352,6 +353,13 @@ describe('KeyringHandler', () => { address: 'bc1qaddress...', options: { entropySource: 'myEntropy', + entropy: { + derivationPath: "m/84'/0'/0'", + groupIndex: 0, + id: 'myEntropy', + type: 'mnemonic', + }, + exportable: false, }, methods: [BtcMethod.SendBitcoin], }; @@ -381,6 +389,13 @@ describe('KeyringHandler', () => { address: 'bc1qaddress...', options: { entropySource: 'myEntropy', + entropy: { + derivationPath: "m/84'/0'/0'", + groupIndex: 0, + id: 'myEntropy', + type: 'mnemonic', + }, + exportable: false, }, methods: [BtcMethod.SendBitcoin], }, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index ed744bae..184cfd66 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -228,7 +228,7 @@ export class KeyringHandler implements Keyring { // Return only accounts with history. return accounts .filter((account) => account.listTransactions().length > 0) - .map((account) => mapToDiscoveredAccount(account, groupIndex)); + .map(mapToDiscoveredAccount); } async getAccountBalances( diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index 90ab0583..b4e141a8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -17,12 +17,7 @@ import { DiscoveredAccountType, } from '@metamask/keyring-api'; -import { - addressTypeToPurpose, - networkToCoinType, - networkToCurrencyUnit, - type BitcoinAccount, -} from '../entities'; +import { networkToCurrencyUnit, type BitcoinAccount } from '../entities'; import type { Caip19Asset } from './caip'; import { addressTypeToCaip, networkToCaip19, networkToScope } from './caip'; @@ -56,7 +51,14 @@ export function mapToKeyringAccount(account: BitcoinAccount): KeyringAccount { id: account.id, address: account.publicAddress.toString(), options: { - entropySource: account.entropySource, + entropySource: account.entropySource, // TODO: Legacy field. To be removed once multichain accounts are out. + exportable: false, + entropy: { + type: 'mnemonic', + id: account.entropySource, + derivationPath: `m/${account.derivationPath.slice(1).join('/')}`, + groupIndex: account.accountIndex, + }, }, methods: [BtcMethod.SendBitcoin], }; @@ -170,16 +172,14 @@ export function mapToTransaction( * Maps a Bitcoin Account to a Discovered Account. * * @param account - The Bitcoin account. - * @param groupIndex - The group index. * @returns The Discovered account. */ export function mapToDiscoveredAccount( account: BitcoinAccount, - groupIndex: number, ): DiscoveredAccount { return { type: DiscoveredAccountType.Bip44, scopes: [networkToScope[account.network]], - derivationPath: `m/${addressTypeToPurpose[account.addressType]}'/${networkToCoinType[account.network]}'/${groupIndex}'`, + derivationPath: `m/${account.derivationPath.slice(1).join('/')}`, }; } From dbb9d5cc5cab62db4e64f5388cd552b133aaa226 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 30 Jul 2025 18:15:08 +0200 Subject: [PATCH 260/362] fix: retry mechanism esplora (#500) --- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/src/config.ts | 1 + .../bitcoin-wallet-snap/src/entities/config.ts | 1 + .../src/infra/EsploraClientAdapter.ts | 10 +++++----- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 708ff3a2..619a2ae4 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -36,7 +36,7 @@ }, "devDependencies": { "@jest/globals": "^30.0.3", - "@metamask/bitcoindevkit": "^0.1.11", + "@metamask/bitcoindevkit": "^0.1.12", "@metamask/key-tree": "^10.1.1", "@metamask/keyring-api": "^19.1.0", "@metamask/keyring-snap-sdk": "^5.0.0", diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 4d7a4eae..4c4d5603 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -10,6 +10,7 @@ export const Config: SnapConfig = { chain: { parallelRequests: 1, stopGap: 2, + maxRetries: 3, url: { bitcoin: process.env.ESPLORA_BITCOIN ?? 'https://blockstream.info/api', testnet: diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts index f2a2ae4a..ea7e5d59 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/config.ts @@ -17,6 +17,7 @@ export type SnapConfig = { export type ChainConfig = { parallelRequests: number; stopGap: number; + maxRetries: number; url: { [network in Network]: string; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts index 5b3a02f4..2207af2b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/EsploraClientAdapter.ts @@ -20,11 +20,11 @@ export class EsploraClientAdapter implements BlockchainClient { constructor(config: ChainConfig) { this.#clients = { - bitcoin: new EsploraClient(config.url.bitcoin), - testnet: new EsploraClient(config.url.testnet), - testnet4: new EsploraClient(config.url.testnet4), - signet: new EsploraClient(config.url.signet), - regtest: new EsploraClient(config.url.regtest), + bitcoin: new EsploraClient(config.url.bitcoin, config.maxRetries), + testnet: new EsploraClient(config.url.testnet, config.maxRetries), + testnet4: new EsploraClient(config.url.testnet4, config.maxRetries), + signet: new EsploraClient(config.url.signet, config.maxRetries), + regtest: new EsploraClient(config.url.regtest, config.maxRetries), }; this.#config = config; From 7cd547af18302c20b12444a74ddef846dd8e9f5e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 18:27:31 +0200 Subject: [PATCH 261/362] 0.18.0 (#502) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 15 ++++++++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 15176ff5..e61b214b 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.18.0] + +### Added + +- Fine-grained error handling with tracking and translations ([#496](https://github.com/MetaMask/snap-bitcoin-wallet/pull/496), [#498](https://github.com/MetaMask/snap-bitcoin-wallet/pull/498)) +- Track transaction events ([#495](https://github.com/MetaMask/snap-bitcoin-wallet/pull/495)) +- Add account options in `KeyringAccount` ([#499](https://github.com/MetaMask/snap-bitcoin-wallet/pull/499)) + +### Fixed + +- Retry mechanism for Esplora indexer ([#500](https://github.com/MetaMask/snap-bitcoin-wallet/pull/500)) + ## [0.17.0] ### Changed @@ -395,7 +407,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.17.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.18.0...HEAD +[0.18.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.17.0...v0.18.0 [0.17.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.1...v0.17.0 [0.16.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.0...v0.16.1 [0.16.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.15.0...v0.16.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 619a2ae4..b6dcbc41 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.17.0", + "version": "0.18.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From baf4fd0ae4776127c9af5d65d1abddb5e9b37f80 Mon Sep 17 00:00:00 2001 From: orestis Date: Mon, 11 Aug 2025 16:45:42 +0100 Subject: [PATCH 262/362] test: verify snap track events on account syncing (#503) --- .../integration-test/blockchain-utils.ts | 139 ++++++++++++++++++ .../integration-test/cron-sync.test.ts | 132 +++++++++++++++++ .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 +- 4 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/integration-test/blockchain-utils.ts create mode 100644 merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/blockchain-utils.ts b/merged-packages/bitcoin-wallet-snap/integration-test/blockchain-utils.ts new file mode 100644 index 00000000..d19d2d26 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/integration-test/blockchain-utils.ts @@ -0,0 +1,139 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { execSync } from 'child_process'; +import process from 'process'; +/* eslint-enable import-x/no-nodejs-modules */ + +/** + * Minimal utility for Bitcoin regtest operations in integration tests + */ +export class BlockchainTestUtils { + readonly #containerName: string; + + readonly #esploraHost: string; + + readonly #esploraPort: number; + + readonly #esploraBaseUrl: string; + + constructor(options?: { + containerName?: string; + esploraHost?: string; + esploraPort?: number; + }) { + this.#containerName = + options?.containerName ?? process.env.ESPLORA_CONTAINER ?? 'esplora'; + + this.#esploraHost = + options?.esploraHost ?? process.env.ESPLORA_HOST ?? 'localhost'; + + this.#esploraPort = + options?.esploraPort ?? + (process.env.ESPLORA_PORT + ? parseInt(process.env.ESPLORA_PORT, 10) + : 8094); + + this.#esploraBaseUrl = `http://${this.#esploraHost}:${this.#esploraPort}/regtest/api`; + } + + /** + * Execute a bitcoin-cli command in the Docker container + * + * @param command - The bitcoin-cli command to execute + * @returns The command output as a string + */ + #execCli(command: string): string { + const fullCommand = `docker exec ${this.#containerName} cli -regtest ${command}`; + try { + return execSync(fullCommand, { encoding: 'utf8' }).trim(); + } catch (error) { + throw new Error( + `Failed to execute CLI command: ${command}\n${String(error)}`, + ); + } + } + + /** + * Poll until Esplora has indexed up to a specific block height + * + * @param targetHeight - The block height to wait for + * @param maxRetries - Maximum number of polling attempts + */ + async #waitForEsploraHeight( + targetHeight: number, + maxRetries = 20, + ): Promise { + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const response = await fetch( + `${this.#esploraBaseUrl}/blocks/tip/height`, + ); + if (response.ok) { + const height = parseInt(await response.text(), 10); + if (height >= targetHeight) { + return; + } + } + } catch { + // Esplora not ready yet + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error( + `Esplora did not reach height ${targetHeight} after ${maxRetries} attempts`, + ); + } + + /** + * Wait for Esplora to see a transaction + * + * @param txid - The transaction ID to wait for + * @param maxRetries - Maximum number of polling attempts + */ + async #waitForEsploraTx(txid: string, maxRetries = 20): Promise { + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const response = await fetch(`${this.#esploraBaseUrl}/tx/${txid}`); + if (response.ok) { + return; + } + } catch { + // Not ready yet + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error( + `Esplora did not index transaction ${txid} after ${maxRetries} attempts`, + ); + } + + /** + * Send Bitcoin to an address and wait for Esplora to see it + * + * @param address - The Bitcoin address to send to + * @param amount - The amount of BTC to send + * @returns The transaction ID + */ + async sendToAddress(address: string, amount: number): Promise { + const txid = this.#execCli( + `-rpcwallet=default sendtoaddress "${address}" ${amount}`, + ); + + await this.#waitForEsploraTx(txid); + return txid; + } + + /** + * Mine blocks and wait for Esplora to index them + * + * @param count - The number of blocks to mine + */ + async mineBlocks(count: number): Promise { + const currentHeight = parseInt(this.#execCli('getblockcount'), 10); + const targetHeight = currentHeight + count; + + const minerAddress = this.#execCli('getnewaddress'); + this.#execCli(`generatetoaddress ${count} ${minerAddress}`); + + await this.#waitForEsploraHeight(targetHeight); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts new file mode 100644 index 00000000..32029fc2 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts @@ -0,0 +1,132 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; +import type { Snap } from '@metamask/snaps-jest'; +import { installSnap } from '@metamask/snaps-jest'; + +import { BlockchainTestUtils } from './blockchain-utils'; +import { MNEMONIC, ORIGIN } from './constants'; + +describe('CronHandler Synchronization', () => { + let snap: Snap; + let blockchain: BlockchainTestUtils; + + beforeAll(async () => { + blockchain = new BlockchainTestUtils(); + snap = await installSnap({ + options: { + secretRecoveryPhrase: MNEMONIC, + }, + }); + }); + + beforeEach(() => { + snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); + snap.mockJsonRpc({ method: 'snap_trackError', result: {} }); + }); + + it('should synchronize the account', async () => { + // sanity test + const response = await snap.onCronjob({ + method: 'synchronizeAccounts', + }); + expect(response).toBeDefined(); + }); + + it('tracks TransactionReceived for new unconfirmed transaction', async () => { + // create account without initial sync + const createResponse = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope: BtcScope.Regtest, + addressType: BtcAccountType.P2wpkh, + index: 0, + synchronize: false, + }, + }, + }); + + expect(createResponse.response).toBeDefined(); + expect('result' in createResponse.response).toBe(true); + + const account = (createResponse.response as { result: KeyringAccount }) + .result; + + // send a new transaction to the new account + const txid = await blockchain.sendToAddress(account.address, 10); + expect(txid).toBeDefined(); + + // run cron sync to discover the unconfirmed transaction + const syncResponse = await snap.onCronjob({ + method: 'synchronizeAccounts', + }); + expect(syncResponse).toRespondWith(null); + + /* eslint-disable @typescript-eslint/naming-convention */ + expect(syncResponse).toTrackEvent({ + event: 'Transaction Received', + properties: { + origin: 'cron', + message: 'Snap transaction received', + chain_id: BtcScope.Regtest, + account_id: account.id, + account_address: account.address, + account_type: BtcAccountType.P2wpkh, + tx_id: txid, + }, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + }); + + it('tracks TransactionFinalized when transaction gets confirmed', async () => { + // new account to avoid potential flaky-ness and conflicts + const createResponse = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope: BtcScope.Regtest, + addressType: BtcAccountType.P2wpkh, + index: 2, + synchronize: false, + }, + }, + }); + + const account = (createResponse.response as { result: KeyringAccount }) + .result; + + const txid = await blockchain.sendToAddress(account.address, 25); + + // discover unconfirmed transaction + await snap.onCronjob({ + method: 'synchronizeAccounts', + }); + + // mine blocks to confirm the transaction + await blockchain.mineBlocks(6); + + // should now detect transaction as finalised + const finalSyncResponse = await snap.onCronjob({ + method: 'synchronizeAccounts', + }); + + expect(finalSyncResponse).toRespondWith(null); + + /* eslint-disable @typescript-eslint/naming-convention */ + expect(finalSyncResponse).toTrackEvent({ + event: 'Transaction Finalized', + properties: { + origin: 'cron', + message: 'Snap transaction finalized', + chain_id: BtcScope.Regtest, + account_id: account.id, + account_address: account.address, + account_type: BtcAccountType.P2wpkh, + tx_id: txid, + }, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index b6dcbc41..65325162 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -42,7 +42,7 @@ "@metamask/keyring-snap-sdk": "^5.0.0", "@metamask/slip44": "^4.2.0", "@metamask/snaps-cli": "^8.1.1", - "@metamask/snaps-jest": "^9.3.0", + "@metamask/snaps-jest": "^9.4.0", "@metamask/snaps-sdk": "^9.3.0", "@metamask/utils": "^11.4.2", "concurrently": "^9.2.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 04e496b5..79a005cc 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.17.0", + "version": "0.18.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "1JX8CkzNZKtn2kBz/lVEViAX+x8x2XQbzbJkHBRyGo8=", + "shasum": "R/JDhWVFY6AsPESNGMhMY+2gvoFmUjSaxL+NmymDLnY=", "location": { "npm": { "filePath": "dist/bundle.js", From de97e18d184d49b9ac207ef1c2d6eaa5ccea2885 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 12 Aug 2025 17:33:27 +0200 Subject: [PATCH 263/362] feat: fill, sign and send PSBT (#504) --- .../integration-test/client-request.test.ts | 139 ++++++++++++ .../integration-test/cron-sync.test.ts | 57 +---- .../integration-test/keyring.test.ts | 20 +- .../bitcoin-wallet-snap/package.json | 12 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/config.ts | 6 +- .../src/entities/account.ts | 2 +- .../src/entities/transaction.ts | 29 ++- .../src/handlers/KeyringHandler.test.ts | 11 +- .../src/handlers/RpcHandler.test.ts | 76 ++++++- .../src/handlers/RpcHandler.ts | 40 +++- .../src/handlers/mappings.ts | 31 ++- .../bitcoin-wallet-snap/src/index.ts | 6 + .../src/infra/BdkAccountAdapter.ts | 40 +++- .../src/infra/BdkTxBuilderAdapter.ts | 29 ++- .../src/use-cases/AccountUseCases.test.ts | 203 +++++++++++++++++- .../src/use-cases/AccountUseCases.ts | 102 ++++++++- .../src/use-cases/SendFlowUseCases.test.ts | 5 +- 18 files changed, 690 insertions(+), 120 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts new file mode 100644 index 00000000..44ad227a --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts @@ -0,0 +1,139 @@ +import type { KeyringAccount } from '@metamask/keyring-api'; +import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; +import type { Snap } from '@metamask/snaps-jest'; +import { installSnap } from '@metamask/snaps-jest'; + +import { BlockchainTestUtils } from './blockchain-utils'; +import { MNEMONIC, ORIGIN } from './constants'; +import { TrackingSnapEvent } from '../src/entities'; + +const ACCOUNT_INDEX = 1; + +describe('OnClientRequestHandler', () => { + let account: KeyringAccount; + let snap: Snap; + let blockchain: BlockchainTestUtils; + + beforeAll(async () => { + blockchain = new BlockchainTestUtils(); + snap = await installSnap({ + options: { + secretRecoveryPhrase: MNEMONIC, + }, + }); + + snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); + snap.mockJsonRpc({ method: 'snap_trackError', result: {} }); + + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope: BtcScope.Regtest, + synchronize: false, + index: ACCOUNT_INDEX, + }, + }, + }); + + if ('result' in response.response) { + account = response.response.result as KeyringAccount; + } + + await blockchain.sendToAddress(account.address, 10); + await blockchain.mineBlocks(6); + await snap.onCronjob({ method: 'synchronizeAccounts' }); + }); + + it('fills inputs, signs and sends an output-only PSBT', async () => { + const response = await snap.onClientRequest({ + method: 'fillAndSendPsbt', + params: { + account: account.id, + psbt: 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgu3FEiFNy9ZR/zSpTo9nHREjrSoAAAAAAAAAAAA=', + }, + }); + + expect(response).toRespondWith({ + txid: expect.any(String), + }); + const { txid } = (response.response as { result: { txid: string } }).result; + + /* eslint-disable @typescript-eslint/naming-convention */ + expect(response).toTrackEvent({ + event: TrackingSnapEvent.TransactionSubmitted, + properties: { + account_address: account.address, + account_id: account.id, + account_type: BtcAccountType.P2wpkh, + chain_id: BtcScope.Regtest, + message: 'Snap transaction submitted', + origin: ORIGIN, + tx_id: txid, + }, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + + await blockchain.mineBlocks(6); + + // should now detect transaction as finalised + const finalSyncResponse = await snap.onCronjob({ + method: 'synchronizeAccounts', + }); + + expect(finalSyncResponse).toRespondWith(null); + + /* eslint-disable @typescript-eslint/naming-convention */ + expect(finalSyncResponse).toTrackEvent({ + event: TrackingSnapEvent.TransactionFinalized, + properties: { + origin: 'cron', + message: 'Snap transaction finalized', + chain_id: BtcScope.Regtest, + account_id: account.id, + account_address: account.address, + account_type: BtcAccountType.P2wpkh, + tx_id: txid, + }, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + }); + + it('fails if incorrect PSBT', async () => { + const response = await snap.onClientRequest({ + method: 'fillAndSendPsbt', + params: { + account: account.id, + psbt: 'notAPsbt', + }, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: 'Invalid format: Invalid PSBT', + data: { + account: account.id, + cause: null, + psbtBase64: 'notAPsbt', + }, + stack: expect.anything(), + }); + }); + + it('fails if missing params', async () => { + const response = await snap.onClientRequest({ + method: 'fillAndSendPsbt', + params: { + account: null, + }, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: + 'Invalid format: At path: account -- Expected a string, but received: null', + stack: expect.anything(), + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts index 32029fc2..5080afb0 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts @@ -6,7 +6,9 @@ import { installSnap } from '@metamask/snaps-jest'; import { BlockchainTestUtils } from './blockchain-utils'; import { MNEMONIC, ORIGIN } from './constants'; -describe('CronHandler Synchronization', () => { +const ACCOUNT_INDEX = 2; + +describe('CronHandler', () => { let snap: Snap; let blockchain: BlockchainTestUtils; @@ -41,8 +43,8 @@ describe('CronHandler Synchronization', () => { options: { scope: BtcScope.Regtest, addressType: BtcAccountType.P2wpkh, - index: 0, synchronize: false, + index: ACCOUNT_INDEX, }, }, }); @@ -78,55 +80,4 @@ describe('CronHandler Synchronization', () => { }); /* eslint-enable @typescript-eslint/naming-convention */ }); - - it('tracks TransactionFinalized when transaction gets confirmed', async () => { - // new account to avoid potential flaky-ness and conflicts - const createResponse = await snap.onKeyringRequest({ - origin: ORIGIN, - method: 'keyring_createAccount', - params: { - options: { - scope: BtcScope.Regtest, - addressType: BtcAccountType.P2wpkh, - index: 2, - synchronize: false, - }, - }, - }); - - const account = (createResponse.response as { result: KeyringAccount }) - .result; - - const txid = await blockchain.sendToAddress(account.address, 25); - - // discover unconfirmed transaction - await snap.onCronjob({ - method: 'synchronizeAccounts', - }); - - // mine blocks to confirm the transaction - await blockchain.mineBlocks(6); - - // should now detect transaction as finalised - const finalSyncResponse = await snap.onCronjob({ - method: 'synchronizeAccounts', - }); - - expect(finalSyncResponse).toRespondWith(null); - - /* eslint-disable @typescript-eslint/naming-convention */ - expect(finalSyncResponse).toTrackEvent({ - event: 'Transaction Finalized', - properties: { - origin: 'cron', - message: 'Snap transaction finalized', - chain_id: BtcScope.Regtest, - account_id: account.id, - account_address: account.address, - account_type: BtcAccountType.P2wpkh, - tx_id: txid, - }, - }); - /* eslint-enable @typescript-eslint/naming-convention */ - }); }); diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index 02ed1440..fabeb124 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -1,5 +1,5 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import { BtcAccountType, BtcMethod, BtcScope } from '@metamask/keyring-api'; +import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; import type { Snap } from '@metamask/snaps-jest'; import { installSnap } from '@metamask/snaps-jest'; @@ -15,6 +15,8 @@ import { import { CurrencyUnit } from '../src/entities'; import { Caip19Asset } from '../src/handlers/caip'; +const ACCOUNT_INDEX = 0; + /* eslint-disable @typescript-eslint/no-non-null-assertion */ describe('Keyring', () => { @@ -50,7 +52,7 @@ describe('Keyring', () => { { type: 'bip44', scopes: [BtcScope.Regtest], - derivationPath: "m/84'/1'/0'", + derivationPath: `m/84'/1'/${ACCOUNT_INDEX}'`, }, ]); }); @@ -61,7 +63,7 @@ describe('Keyring', () => { method: 'keyring_createAccount', params: { options: { - derivationPath: "m/84'/1'/0'", + derivationPath: `m/84'/1'/${ACCOUNT_INDEX}'`, scope: BtcScope.Regtest, synchronize: true, }, @@ -77,13 +79,13 @@ describe('Keyring', () => { entropy: { type: 'mnemonic', id: 'm', - groupIndex: 0, - derivationPath: "m/84'/1'/0'", + groupIndex: ACCOUNT_INDEX, + derivationPath: `m/84'/1'/${ACCOUNT_INDEX}'`, }, exportable: false, }, scopes: [BtcScope.Regtest], - methods: [BtcMethod.SendBitcoin], + methods: [], }); // eslint-disable-next-line jest/no-conditional-in-test @@ -98,7 +100,7 @@ describe('Keyring', () => { // tests creation of multiple accounts of same address type and network addressType: BtcAccountType.P2wpkh, scope: BtcScope.Regtest, - index: 1, // index incremented by 1 + index: ACCOUNT_INDEX + 1, // index incremented by 1 expectedAddress: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', }, { @@ -167,7 +169,7 @@ describe('Keyring', () => { exportable: false, }, scopes: [requestOpts.scope], - methods: [BtcMethod.SendBitcoin], + methods: [], }); // eslint-disable-next-line jest/no-conditional-in-test @@ -201,7 +203,7 @@ describe('Keyring', () => { options: { scope: BtcScope.Regtest, addressType: BtcAccountType.P2wpkh, - index: 0, + index: ACCOUNT_INDEX, }, }, }); diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 65325162..e9081876 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -35,13 +35,13 @@ "test:integration": "./integration-test/run-integration.sh" }, "devDependencies": { - "@jest/globals": "^30.0.3", - "@metamask/bitcoindevkit": "^0.1.12", + "@jest/globals": "^30.0.5", + "@metamask/bitcoindevkit": "^0.1.13", "@metamask/key-tree": "^10.1.1", - "@metamask/keyring-api": "^19.1.0", - "@metamask/keyring-snap-sdk": "^5.0.0", + "@metamask/keyring-api": "^20.0.0", + "@metamask/keyring-snap-sdk": "^6.0.0", "@metamask/slip44": "^4.2.0", - "@metamask/snaps-cli": "^8.1.1", + "@metamask/snaps-cli": "^8.2.0", "@metamask/snaps-jest": "^9.4.0", "@metamask/snaps-sdk": "^9.3.0", "@metamask/utils": "^11.4.2", @@ -51,7 +51,7 @@ "jest-mock-extended": "^4.0.0", "jest-transform-stub": "2.0.0", "superstruct": "^2.0.2", - "ts-jest": "^29.4.0", + "ts-jest": "^29.4.1", "uuid": "^11.1.0" }, "publishConfig": { diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 79a005cc..fe868b7e 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "R/JDhWVFY6AsPESNGMhMY+2gvoFmUjSaxL+NmymDLnY=", + "shasum": "0SW7OJym47/Lo2akGC+GLX2TCoWIg865zeyrE49aRZc=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 4c4d5603..aa38bd4a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -8,8 +8,8 @@ export const Config: SnapConfig = { logLevel: (process.env.LOG_LEVEL ?? LogLevel.INFO) as LogLevel, encrypt: false, chain: { - parallelRequests: 1, - stopGap: 2, + parallelRequests: 5, + stopGap: 10, maxRetries: 3, url: { bitcoin: process.env.ESPLORA_BITCOIN ?? 'https://blockstream.info/api', @@ -32,7 +32,7 @@ export const Config: SnapConfig = { }, targetBlocksConfirmation: 3, fallbackFeeRate: 5.0, - ratesRefreshInterval: 'PT30S', + ratesRefreshInterval: 'PT20S', priceApi: { url: process.env.PRICE_API_URL ?? 'https://price.api.cx.metamask.io', }, diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index fd189663..8978b99e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -126,7 +126,7 @@ export type BitcoinAccount = { * @param psbt - The PSBT to be signed. * @returns the signed transaction */ - sign(psbt: Psbt): Transaction; + sign(psbt: Psbt, maxFeeRate?: number): Transaction; /** * Get the list of UTXOs diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts b/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts index 49104ff3..e647523b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/transaction.ts @@ -1,4 +1,4 @@ -import type { Psbt } from '@metamask/bitcoindevkit'; +import type { Amount, Psbt, ScriptBuf } from '@metamask/bitcoindevkit'; /** * A Bitcoin transaction builder. @@ -12,6 +12,17 @@ export type TransactionBuilder = { */ addRecipient(amount: string, recipientAddress: string): TransactionBuilder; + /** + * Add a new recipient the PSBT by script pubkey. + * + * @param amount - The amount in satoshis + * @param recipientScriptPubkey - The recipient script public key + */ + addRecipientByScript( + amount: Amount, + recipientScriptPubkey: ScriptBuf, + ): TransactionBuilder; + /** * Set the PSBT fee rate. * @@ -35,6 +46,17 @@ export type TransactionBuilder = { */ drainTo(address: string): TransactionBuilder; + /** + * Sets the script to *drain* excess coins to. + * + * Usually, when there are excess coins they are sent to a change address generated by the + * wallet. This option replaces the usual change address with an arbitrary `script_pubkey` of + * your choosing. + * + * @param scriptPubKey - The recipient script + */ + drainToByScript(scriptPubKey: ScriptBuf): TransactionBuilder; + /** * Set the list of unspendable UTXOs. These outpoints won't be selected by the coin selection algorithm. * @@ -42,6 +64,11 @@ export type TransactionBuilder = { */ unspendable(unspendable: string[]): TransactionBuilder; + /** + * Set the ordering for inputs and outputs of the transaction to untouched. + */ + untouchedOrdering(): TransactionBuilder; + /** * Creates the PSBT. * diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 79a58457..fff7926d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -6,13 +6,14 @@ import type { Network, WalletTx, AddressType, + ScriptBuf, } from '@metamask/bitcoindevkit'; import { Address } from '@metamask/bitcoindevkit'; import type { DiscoveredAccount, Transaction as KeyringTransaction, } from '@metamask/keyring-api'; -import { BtcAccountType, BtcMethod, BtcScope } from '@metamask/keyring-api'; +import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; @@ -361,7 +362,7 @@ describe('KeyringHandler', () => { }, exportable: false, }, - methods: [BtcMethod.SendBitcoin], + methods: [], }; const result = await handler.getAccount('some-id'); @@ -397,7 +398,7 @@ describe('KeyringHandler', () => { }, exportable: false, }, - methods: [BtcMethod.SendBitcoin], + methods: [], }, ]; @@ -498,8 +499,12 @@ describe('KeyringHandler', () => { const mockAmount = mock({ to_btc: () => 21, }); + const mockScriptPubkey = mock({ + is_op_return: () => false, + }); const mockOutput = mock({ value: mockAmount, + script_pubkey: mockScriptPubkey, }); const mockTxid = mock({ toString: () => 'txid', diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index a36afb96..4d3bc910 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -4,7 +4,12 @@ import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; import type { SendFlowUseCases } from '../use-cases'; -import { CreateSendFormRequest, RpcHandler, RpcMethod } from './RpcHandler'; +import { + CreateSendFormRequest, + RpcHandler, + RpcMethod, + SendPsbtRequest, +} from './RpcHandler'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; jest.mock('superstruct', () => ({ @@ -12,21 +17,28 @@ jest.mock('superstruct', () => ({ assert: jest.fn(), })); +const mockPsbt = mock(); +// TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => ({ + Psbt: { from_string: () => mockPsbt }, +})); + describe('RpcHandler', () => { const mockSendFlowUseCases = mock(); const mockAccountsUseCases = mock(); - const mockPsbt = mock(); const origin = 'metamask'; - const mockRequest = mock({ - method: RpcMethod.StartSendTransactionFlow, - params: { - account: 'account-id', - }, - }); const handler = new RpcHandler(mockSendFlowUseCases, mockAccountsUseCases); describe('route', () => { + const mockRequest = mock({ + method: RpcMethod.StartSendTransactionFlow, + params: { + account: 'account-id', + }, + }); + it('throws error if invalid origin', async () => { await expect(handler.route('invalidOrigin', mockRequest)).rejects.toThrow( 'Invalid origin', @@ -47,6 +59,13 @@ describe('RpcHandler', () => { }); describe('executeSendFlow', () => { + const mockRequest = mock({ + method: RpcMethod.StartSendTransactionFlow, + params: { + account: 'account-id', + }, + }); + it('executes startSendTransactionFlow', async () => { mockSendFlowUseCases.display.mockResolvedValue(mockPsbt); mockAccountsUseCases.sendPsbt.mockResolvedValue( @@ -67,7 +86,7 @@ describe('RpcHandler', () => { mockPsbt, 'metamask', ); - expect(result).toStrictEqual({ txId: 'txId' }); + expect(result).toStrictEqual({ txid: 'txId' }); }); it('propagates errors from display', async () => { @@ -91,4 +110,43 @@ describe('RpcHandler', () => { expect(mockAccountsUseCases.sendPsbt).toHaveBeenCalled(); }); }); + + describe('fillAndSendPsbt', () => { + const psbt = + 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgu3FEiFNy9ZR/zSpTo9nHREjrSoAAAAAAAAAAAA='; + const mockRequest = mock({ + method: RpcMethod.FillAndSendPsbt, + params: { + account: 'account-id', + psbt, + }, + }); + + it('executes fillAndSendPsbt', async () => { + mockAccountsUseCases.fillAndSendPsbt.mockResolvedValue( + mock({ + toString: jest.fn().mockReturnValue('txId'), + }), + ); + + const result = await handler.route(origin, mockRequest); + + expect(assert).toHaveBeenCalledWith(mockRequest.params, SendPsbtRequest); + expect(mockAccountsUseCases.fillAndSendPsbt).toHaveBeenCalledWith( + 'account-id', + mockPsbt, + 'metamask', + ); + expect(result).toStrictEqual({ txid: 'txId' }); + }); + + it('propagates errors from fillAndSendPsbt', async () => { + const error = new Error(); + mockAccountsUseCases.fillAndSendPsbt.mockRejectedValue(error); + + await expect(handler.route(origin, mockRequest)).rejects.toThrow(error); + + expect(mockAccountsUseCases.fillAndSendPsbt).toHaveBeenCalled(); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index dbd11cd0..365fa8f3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -1,3 +1,4 @@ +import { Psbt } from '@metamask/bitcoindevkit'; import { BtcScope } from '@metamask/keyring-api'; import type { Json, JsonRpcRequest } from '@metamask/utils'; import { assert, enums, object, optional, string } from 'superstruct'; @@ -8,6 +9,7 @@ import { FormatError, InexistentMethodError } from '../entities'; export enum RpcMethod { StartSendTransactionFlow = 'startSendTransactionFlow', + FillAndSendPsbt = 'fillAndSendPsbt', } export const CreateSendFormRequest = object({ @@ -16,8 +18,13 @@ export const CreateSendFormRequest = object({ assetId: optional(string()), // We don't use the Caip19 but need to define it for validation }); -type SendTransactionResponse = { - txId: string; +export const SendPsbtRequest = object({ + account: string(), + psbt: string(), +}); + +export type SendTransactionResponse = { + txid: string; }; export class RpcHandler { @@ -44,6 +51,10 @@ export class RpcHandler { assert(params, CreateSendFormRequest); return this.#executeSendFlow(params.account, origin); } + case RpcMethod.FillAndSendPsbt: { + assert(params, SendPsbtRequest); + return this.#fillAndSend(params.account, params.psbt, origin); + } default: throw new InexistentMethodError(`Method not found: ${method}`); @@ -58,7 +69,28 @@ export class RpcHandler { if (!psbt) { return null; } - const txId = await this.#accountUseCases.sendPsbt(account, psbt, origin); - return { txId: txId.toString() }; + const txid = await this.#accountUseCases.sendPsbt(account, psbt, origin); + return { txid: txid.toString() }; + } + + async #fillAndSend( + account: string, + psbtBase64: string, + origin: string, + ): Promise { + let psbt: Psbt; + try { + psbt = Psbt.from_string(psbtBase64); + } catch (error) { + throw new FormatError('Invalid PSBT', { account, psbtBase64 }, error); + } + + const txid = await this.#accountUseCases.fillAndSendPsbt( + account, + psbt, + origin, + ); + + return { txid: txid.toString() }; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index b4e141a8..35127186 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -13,7 +13,6 @@ import type { } from '@metamask/keyring-api'; import { TransactionStatus, - BtcMethod, DiscoveredAccountType, } from '@metamask/keyring-api'; @@ -60,7 +59,7 @@ export function mapToKeyringAccount(account: BitcoinAccount): KeyringAccount { groupIndex: account.accountIndex, }, }, - methods: [BtcMethod.SendBitcoin], + methods: [], }; } @@ -76,11 +75,19 @@ const mapToAmount = (amount: Amount, network: Network): TransactionAmount => { const mapToAssetMovement = ( output: TxOut, network: Network, -): TransactionRecipient => { - return { - address: Address.from_script(output.script_pubkey, network).toString(), - asset: mapToAmount(output.value, network), - }; +): TransactionRecipient | null => { + if (output.script_pubkey.is_op_return()) { + return null; + } + + try { + return { + address: Address.from_script(output.script_pubkey, network).toString(), + asset: mapToAmount(output.value, network), + }; + } catch { + return null; + } }; const mapToEvents = ( @@ -154,13 +161,19 @@ export function mapToTransaction( if (isSend) { for (const txout of tx.output) { if (!account.isMine(txout.script_pubkey)) { - transaction.to.push(mapToAssetMovement(txout, network)); + const recipient = mapToAssetMovement(txout, network); + if (recipient) { + transaction.to.push(recipient); + } } } } else { for (const txout of tx.output) { if (account.isMine(txout.script_pubkey)) { - transaction.to.push(mapToAssetMovement(txout, network)); + const recipient = mapToAssetMovement(txout, network); + if (recipient) { + transaction.to.push(recipient); + } } } } diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 2e0a154d..7430f097 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -7,6 +7,7 @@ import type { OnUserInputHandler, OnAssetHistoricalPriceHandler, OnAssetsMarketDataHandler, + OnClientRequestHandler, } from '@metamask/snaps-sdk'; import { Config } from './config'; @@ -46,6 +47,8 @@ const accountsUseCases = new AccountUseCases( snapClient, accountRepository, chainClient, + Config.fallbackFeeRate, + Config.targetBlocksConfirmation, ); const sendFlowUseCases = new SendFlowUseCases( logger, @@ -79,6 +82,9 @@ export const onCronjob: OnCronjobHandler = async ({ request }) => export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request }) => middleware.handle(async () => rpcHandler.route(origin, request)); +export const onClientRequest: OnClientRequestHandler = async ({ request }) => + middleware.handle(async () => rpcHandler.route('metamask', request)); + export const onKeyringRequest: OnKeyringRequestHandler = async ({ origin, request, diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index e54063ff..e35bd1f3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -17,6 +17,7 @@ import type { Address, } from '@metamask/bitcoindevkit'; import { + FeeRate, UnconfirmedTx, SignOptions, Txid, @@ -24,6 +25,7 @@ import { } from '@metamask/bitcoindevkit'; import { + ValidationError, WalletError, type BitcoinAccount, type TransactionBuilder, @@ -149,13 +151,41 @@ export class BdkAccountAdapter implements BitcoinAccount { return new BdkTxBuilderAdapter(this.#wallet.build_tx(), this.network); } - sign(psbt: Psbt): Transaction { - const success = this.#wallet.sign(psbt, new SignOptions()); - if (!success) { - throw new WalletError('failed to sign PSBT'); + sign(psbt: Psbt, maxFeeRate?: number): Transaction { + try { + const finalized = this.#wallet.sign(psbt, new SignOptions()); + // Signing a PSBT does not mean that the tx/psbt is final, like in a multi-sig setup. + // This ensures we only support signing where the psbt is final after. Will add comment. + if (!finalized) { + throw new WalletError('PSBT not finalized', { + psbt: psbt.toString(), + id: this.#id, + }); + } + } catch (error) { + throw new WalletError( + 'failed to sign PSBT', + { + psbt: psbt.toString(), + id: this.#id, + }, + error, + ); } - return psbt.extract_tx(); + try { + return maxFeeRate + ? psbt.extract_tx_with_fee_rate_limit( + new FeeRate(BigInt(Math.floor(maxFeeRate))), + ) + : psbt.extract_tx(); + } catch (error) { + throw new ValidationError( + 'failed to extract transaction from PSBT', + { id: this.#id }, + error, + ); + } } listUnspent(): LocalOutput[] { diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts index 6cde90be..0b45b470 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts @@ -1,5 +1,11 @@ -import type { Network, Psbt, TxBuilder } from '@metamask/bitcoindevkit'; +import type { + Network, + Psbt, + ScriptBuf, + TxBuilder, +} from '@metamask/bitcoindevkit'; import { + TxOrdering, OutPoint, Address, Amount, @@ -21,9 +27,17 @@ export class BdkTxBuilderAdapter implements TransactionBuilder { addRecipient(amount: string, recipientAddress: string): TransactionBuilder { const recipient = new Recipient( - Address.from_string(recipientAddress, this.#network), + Address.from_string(recipientAddress, this.#network).script_pubkey, Amount.from_sat(BigInt(amount)), ); + return this.addRecipientByScript(recipient.amount, recipient.script_pubkey); + } + + addRecipientByScript( + amount: Amount, + recipientScriptPubkey: ScriptBuf, + ): TransactionBuilder { + const recipient = new Recipient(recipientScriptPubkey, amount); this.#builder = this.#builder.add_recipient(recipient); return this; } @@ -42,7 +56,11 @@ export class BdkTxBuilderAdapter implements TransactionBuilder { drainTo(address: string): BdkTxBuilderAdapter { const to = Address.from_string(address, this.#network); - this.#builder = this.#builder.drain_to(to); + return this.drainToByScript(to.script_pubkey); + } + + drainToByScript(scriptPubKey: ScriptBuf): BdkTxBuilderAdapter { + this.#builder = this.#builder.drain_to(scriptPubKey); return this; } @@ -58,6 +76,11 @@ export class BdkTxBuilderAdapter implements TransactionBuilder { return this; } + untouchedOrdering(): BdkTxBuilderAdapter { + this.#builder = this.#builder.ordering(TxOrdering.Untouched); + return this; + } + finish(): Psbt { return this.#builder.finish(); } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index ea4da0a6..c0fa2dec 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -1,4 +1,8 @@ import type { + FeeEstimates, + TxOut, + ScriptBuf, + Amount, Transaction, Txid, WalletTx, @@ -16,8 +20,9 @@ import type { Logger, MetaProtocolsClient, SnapClient, + TransactionBuilder, } from '../entities'; -import { TrackingSnapEvent } from '../entities'; +import { TrackingSnapEvent, ValidationError } from '../entities'; import type { CreateAccountParams, DiscoverAccountParams, @@ -30,12 +35,16 @@ describe('AccountUseCases', () => { const mockRepository = mock(); const mockChain = mock(); const mockMetaProtocols = mock(); + const fallbackFeeRate = 5.0; + const targetBlocksConfirmation = 3; const useCases = new AccountUseCases( mockLogger, mockSnapClient, mockRepository, mockChain, + fallbackFeeRate, + targetBlocksConfirmation, mockMetaProtocols, ); @@ -622,6 +631,8 @@ describe('AccountUseCases', () => { mockSnapClient, mockRepository, mockChain, + fallbackFeeRate, + targetBlocksConfirmation, undefined, ); const mockTransaction = mock(); @@ -703,6 +714,8 @@ describe('AccountUseCases', () => { mockSnapClient, mockRepository, mockChain, + fallbackFeeRate, + targetBlocksConfirmation, undefined, ); @@ -859,4 +872,192 @@ describe('AccountUseCases', () => { ).rejects.toBe(error); }); }); + + describe('fillAndSendPsbt', () => { + const mockTxid = mock(); + const mockOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + const mockTemplatePsbt = mock({ + unsigned_tx: { + output: [mockOutput], + }, + toString: () => 'base64Psbt', + }); + const mockTransaction = mock({ + // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 + /* eslint-disable @typescript-eslint/naming-convention */ + compute_txid: jest.fn(), + clone: jest.fn(), + }); + const mockAccount = mock({ + id: 'account-id', + network: 'bitcoin', + sign: jest.fn(), + isMine: () => false, + }); + const mockWalletTx = mock(); + const mockFeeRate = 3; + const mockFeeEstimates = mock({ + get: () => mockFeeRate, + }); + const mockFrozenUTXOs = ['utxo1', 'utxo2']; + const mockFilledPsbt = mock(); + const mockTxBuilder = mock({ + addRecipientByScript: jest.fn(), + feeRate: jest.fn(), + drainToByScript: jest.fn(), + drainWallet: jest.fn(), + finish: jest.fn(), + unspendable: jest.fn(), + }); + + beforeEach(() => { + mockRepository.getWithSigner.mockResolvedValue(mockAccount); + mockRepository.getFrozenUTXOs.mockResolvedValue(mockFrozenUTXOs); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + mockTransaction.clone.mockReturnThis(); + mockAccount.buildTx.mockReturnValue(mockTxBuilder); + mockTxBuilder.addRecipientByScript.mockReturnThis(); + mockTxBuilder.feeRate.mockReturnThis(); + mockTxBuilder.drainToByScript.mockReturnThis(); + mockTxBuilder.untouchedOrdering.mockReturnThis(); + mockTxBuilder.finish.mockReturnValue(mockFilledPsbt); + mockTxBuilder.unspendable.mockReturnThis(); + mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); + }); + + it('throws error if account is not found', async () => { + mockRepository.getWithSigner.mockResolvedValue(null); + + await expect( + useCases.fillAndSendPsbt( + 'non-existent-id', + mockTemplatePsbt, + 'metamask', + ), + ).rejects.toThrow('Account not found'); + }); + + it('fills PSBT without change output, signs and sends transaction', async () => { + mockAccount.sign.mockReturnValue(mockTransaction); + mockAccount.getTransaction.mockReturnValue(mockWalletTx); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + + const txid = await useCases.fillAndSendPsbt( + 'account-id', + mockTemplatePsbt, + 'metamask', + ); + + expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); + expect(mockRepository.getFrozenUTXOs).toHaveBeenCalledWith( + mockAccount.id, + ); + expect(mockChain.getFeeEstimates).toHaveBeenCalledWith( + mockAccount.network, + ); + expect(mockTxBuilder.unspendable).toHaveBeenCalledWith(mockFrozenUTXOs); + expect(mockTxBuilder.addRecipientByScript).toHaveBeenCalledWith( + mockOutput.value, + mockOutput.script_pubkey, + ); + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith(mockFeeRate); + expect(mockTxBuilder.untouchedOrdering).toHaveBeenCalled(); + expect(mockTxBuilder.finish).toHaveBeenCalled(); + + expect(mockAccount.sign).toHaveBeenCalledWith(mockFilledPsbt); + expect(mockChain.broadcast).toHaveBeenCalledWith( + mockAccount.network, + mockTransaction, + ); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(mockTransaction.compute_txid).toHaveBeenCalled(); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount, [mockWalletTx]); + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionSubmitted, + mockAccount, + mockWalletTx, + 'metamask', + ); + expect(txid).toBe(mockTxid); + }); + + it('fills PSBT with change output, signs and sends transaction', async () => { + mockRepository.getWithSigner.mockResolvedValueOnce({ + ...mockAccount, + isMine: () => true, + }); + mockAccount.sign.mockReturnValue(mockTransaction); + mockAccount.getTransaction.mockReturnValue(mockWalletTx); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + + const txId = await useCases.fillAndSendPsbt( + 'account-id', + mockTemplatePsbt, + 'metamask', + ); + + expect(mockTxBuilder.drainToByScript).toHaveBeenCalledWith( + mockOutput.script_pubkey, + ); + expect(txId).toBe(mockTxid); + }); + + it('propagates an error if getWithSigner fails', async () => { + const error = new Error('getWithSigner failed'); + mockRepository.getWithSigner.mockRejectedValueOnce(error); + + await expect( + useCases.fillAndSendPsbt('account-id', mockTemplatePsbt, 'metamask'), + ).rejects.toBe(error); + }); + + it('throws ValidationError if tx building fails', async () => { + const error = new Error('builder error'); + mockTxBuilder.finish.mockImplementation(() => { + throw error; + }); + + await expect( + useCases.fillAndSendPsbt('account-id', mockTemplatePsbt, 'metamask'), + ).rejects.toThrow( + new ValidationError( + 'Failed to build PSBT from template', + { + id: 'account-id', + templatePsbt: 'base64Psbt', + feeRate: mockFeeRate, + }, + error, + ), + ); + }); + + it('propagates an error if broadcast fails', async () => { + const error = new Error('broadcast failed'); + mockAccount.sign.mockReturnValue(mockTransaction); + mockChain.broadcast.mockRejectedValueOnce(error); + + await expect( + useCases.fillAndSendPsbt('account-id', mockTemplatePsbt, 'metamask'), + ).rejects.toBe(error); + }); + + it('propagates an error if update fails', async () => { + const error = new Error('update failed'); + mockAccount.sign.mockReturnValue(mockTransaction); + mockRepository.update.mockRejectedValue(error); + + await expect( + useCases.fillAndSendPsbt('account-id', mockTemplatePsbt, 'metamask'), + ).rejects.toBe(error); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 4cb35085..5a5521b1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -18,6 +18,7 @@ import { NotFoundError, type SnapClient, TrackingSnapEvent, + ValidationError, } from '../entities'; export type DiscoverAccountParams = { @@ -44,17 +45,25 @@ export class AccountUseCases { readonly #metaProtocols: MetaProtocolsClient | undefined; + readonly #fallbackFeeRate: number; + + readonly #targetBlocksConfirmation: number; + constructor( logger: Logger, snapClient: SnapClient, repository: BitcoinAccountRepository, chain: BlockchainClient, + fallbackFeeRate: number, + targetBlocksConfirmation: number, metaProtocols?: MetaProtocolsClient, ) { this.#logger = logger; this.#snapClient = snapClient; this.#repository = repository; this.#chain = chain; + this.#fallbackFeeRate = fallbackFeeRate; + this.#targetBlocksConfirmation = targetBlocksConfirmation; this.#metaProtocols = metaProtocols; } @@ -303,15 +312,95 @@ export class AccountUseCases { throw new NotFoundError('Account not found', { id }); } + const txid = await this.#signAndSendPsbt(account, psbt, origin); + + this.#logger.info( + 'Transaction sent successfully: %s. Account: %s, Network: %s', + txid, + account.id, + account.network, + ); + + return txid; + } + + async fillAndSendPsbt( + id: string, + templatePsbt: Psbt, + origin: string, + ): Promise { + this.#logger.debug('Filling and sending transaction: %s', id); + + const account = await this.#repository.getWithSigner(id); + if (!account) { + throw new NotFoundError('Account not found', { id }); + } + + const psbt = await this.#fillPsbt(account, templatePsbt); + const txid = await this.#signAndSendPsbt(account, psbt, origin); + + this.#logger.info( + 'Transaction filled and sent successfully: %s. Account: %s, Network: %s', + txid, + account.id, + account.network, + ); + + return txid; + } + + async #fillPsbt(account: BitcoinAccount, templatePsbt: Psbt): Promise { + const frozenUTXOs = await this.#repository.getFrozenUTXOs(account.id); + const feeEstimates = await this.#chain.getFeeEstimates(account.network); + const feeRate = + feeEstimates.get(this.#targetBlocksConfirmation) ?? this.#fallbackFeeRate; + + try { + let builder = account + .buildTx() + .feeRate(feeRate) + .unspendable(frozenUTXOs) + .untouchedOrdering(); // we need to strictly adhere to the template output order. Many protocols use the order (e.g: 1: deposit, 2: OP_RETURN, 3: change) + + for (const txout of templatePsbt.unsigned_tx.output) { + // if the PSBT contains an output that is sending to ourselves, we change its value. If the PSBT contains no change outputs, one will automatically be added. + if (account.isMine(txout.script_pubkey)) { + builder = builder.drainToByScript(txout.script_pubkey); + } else { + builder = builder.addRecipientByScript( + txout.value, + txout.script_pubkey, + ); + } + } + return builder.finish(); + } catch (error) { + throw new ValidationError( + 'Failed to build PSBT from template', + { + id: account.id, + templatePsbt: templatePsbt.toString(), + feeRate, + }, + error, + ); + } + } + + async #signAndSendPsbt( + account: BitcoinAccount, + psbt: Psbt, + origin: string, + ): Promise { const tx = account.sign(psbt); - const txId = tx.compute_txid(); + const txid = tx.compute_txid(); await this.#chain.broadcast(account.network, tx.clone()); account.applyUnconfirmedTx(tx, getCurrentUnixTimestamp()); await this.#repository.update(account); await this.#snapClient.emitAccountBalancesUpdatedEvent(account); - const walletTx = account.getTransaction(txId.toString()); + const walletTx = account.getTransaction(txid.toString()); if (walletTx) { // should always be true by assertion but needed for type checking await this.#snapClient.emitAccountTransactionsUpdatedEvent(account, [ @@ -326,13 +415,6 @@ export class AccountUseCases { ); } - this.#logger.info( - 'Transaction sent successfully: %s. Account: %s, Network: %s', - txId, - id, - account.network, - ); - - return txId; + return txid; } } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 426fec32..3eb3e052 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -15,6 +15,7 @@ import type { Logger, CodifiedError, SpotPrice, + TransactionBuilder, } from '../entities'; import { ReviewTransactionEvent, @@ -152,14 +153,14 @@ describe('SendFlowUseCases', () => { backgroundEventId: 'backgroundEventId', locale: 'en', }; - const mockTxBuilder = { + const mockTxBuilder = mock({ addRecipient: jest.fn(), feeRate: jest.fn(), drainTo: jest.fn(), drainWallet: jest.fn(), finish: jest.fn(), unspendable: jest.fn(), - }; + }); beforeEach(() => { mockChain.getExplorerUrl.mockReturnValue(explorerUrl); From bfff371f74b7f5446e8d35bc3b8d06fd4abea299 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 13 Aug 2025 14:48:57 +0200 Subject: [PATCH 264/362] docs: complete docs refactor (#505) --- merged-packages/bitcoin-wallet-snap/README.md | 49 ++++++++++-------- .../bitcoin-wallet-snap/docs/ui.png | Bin 0 -> 59399 bytes 2 files changed, 28 insertions(+), 21 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/docs/ui.png diff --git a/merged-packages/bitcoin-wallet-snap/README.md b/merged-packages/bitcoin-wallet-snap/README.md index cd8d4026..8849a024 100644 --- a/merged-packages/bitcoin-wallet-snap/README.md +++ b/merged-packages/bitcoin-wallet-snap/README.md @@ -1,28 +1,35 @@ -# Bitcoin Snap +# Bitcoin Wallet Snap -## Configuration +This package contains the source code for the Bitcoin Wallet Snap - a MetaMask Snap that enables Bitcoin blockchain functionality directly within your MetaMask wallet. The Snap allows users to: -Rename `.env.example` to `.env` -Configurations are setup though `.env`, +- Create and manage Bitcoin accounts accross all address types (p2pkh, p2sh, p2wpkh, p2tr) +- View BTC balances and history +- Sign transactions (PSBTs) +- Send and receive transactions +- Connect to Bitcoin dApps +- Support all networks (Bitcoin, Testnet3, Testnet4, Signet via Mutinynet and Regtest) -## API: +The Snap is built using the MetaMask Snaps SDK and integrates with [Bitcoindevkit](https://bitcoindevkit.org/). It follows best practices for security and provides a seamless user experience within the familiar MetaMask interface. -### `keyring_createAccount` +![Snap UI](./docs/ui.png) -example: +## Running the snap locally -```typescript -provider.request({ - method: 'wallet_invokeKeyring', - params: { - snapId, - request: { - method: 'keyring_createAccount', - params: { - scope: 'bip122:000000000933ea01ad0ee984209779ba', // the CAIP-2 chain ID of the network - addressType: 'bip122:p2wpkh', // the CAIP-like address type - }, - }, - }, -}); +```bash +yarn start ``` + +> [!WARNING] +> When snap updates you will need to still reconnect from the dapp to see changes + +## Building + +```bash +yarn build:snap +``` + +Further reading: + +- [Development](../../docs/development.md) +- [Contributing](../../docs/contributing.md) +- [Releasing](../../docs/release.md) diff --git a/merged-packages/bitcoin-wallet-snap/docs/ui.png b/merged-packages/bitcoin-wallet-snap/docs/ui.png new file mode 100644 index 0000000000000000000000000000000000000000..a738b7e4c8b03c36774af4dae2fe3de03990b955 GIT binary patch literal 59399 zcmdq|WprGzvM!36-L_-Km^o%}VVeN~fvvI> z7FLiH7A911vNyA|F$DpUh)hX^R!CaG7`}Wg3J<}E2uPmEyu%QX0?@h~6H*bukS1e+ z3;v3wsqT#;CYIHlMMr{EFB;K8g2TW7_1++ij%ugFuqo|C#OlB3_Pm^YonBwx%*yh< zXfwaa<|F_yVQrR3MxX|ZCz?PBfAyR-+TF2iRSW=C0Rgo(=sz%t!ZI`z1era1bMNX2 z^&oDyV?;mQ{QRs@+PPuk1Q9R1$btkZ2dz7;nLv?sr0=IPA{7iN zQqE!;xGd(d%$R+$%pGwc@6Q0)lC){&fCNc|F)A^2R*L5!^$)2E)d@t}#tqqEX8Pfb zd?D}~;u@KV2R%3EE|r7QKRTT>dep3B#N&WwzTX>;N%Z(k_0yd5qlIaXtic*k5a?t{150cjWjeAu5qx7#!r@8vJb_EiBXfGX(ux;2@68@Tazn!0NL%z4ArPtN3xANHMa^_N` z_fU*WH>XnY${b8fKPIg(H;|ZEH^h%~wy?>l4XC&Y{H6gXuLXbTC720h7O=Cxp#Nrl zf;6$8uWBR+%O5=?pobkdgyKYlIb&00iwBbC)xx~IbDn+t2vEFsX8RxkIYUL-E#}M= zfDb^B-fN?nT=`N6qEHXYq1u-a(O_^GSB7A#L7A^_wk zA z2B<&5Fn}Njq_6n`C-Dd-fOO$zBs`~ZpFBbe+^ArbpJ);Q$voB>@=^d*Xn9_j0?8e- z3m6xiZWvTv#|#n+Y*!%P07V)^MXy#ZvJK?CArvk^ykB|?%>yzE?r=bR8@3gJ52B^d ze7oXGRs*&tU|11^EIdEoMG=F7D=HFJbg&>%(P)87g{T}?HQY+fHQ!5NSy5?0df^)j zhOFTH42FezBR?1Hq+pFejWA|G#SHur+nwi~>m8EUmv?yKAq%4kMmj7Ea*U)P%}C1L zWMc#aEk;ZRc1Du4M-7@EQLIB;hVONbzg5nxY#80xn<3Xi@p=)AhaEYxxTbIq!{7$% z95q|PbjU7{F22|yu?M|Iqz;^J*hSLud{K_3d+{xYf28e}Sr=K-sF;bo6Ax!}XUe(+pSYjM2Zek>OS1W>^ZxR< z`gqGI;#iLoAq5r{1KJO&J*s4CT;sNk@+SCXywU2 zRVrN+j_c=`O|$9~>f@NyIrTkrZ#ef2VWaZp4V5^OWs~2N;gXTjE2HXQrO8x_B=&2i za>vrj>&v^W8m(fjnocNHDbYn)^Xp5G7f?O`ov165O-gpP!tcAhOP2n_)+@s9BxOw&qiX+OUg&M`ZNTsvcTsflbYXObzliv@`_=oU`?Y+e1;z^2_F)H;2YZJ> z2)7e$I;0xLZ9|h77hn}kik*v1e_#BrIn*{JJA^1RLXJ;PEpsAcmu4h;Tx?m~A^SRM zzzogYt=Y7~>im0sR>z=w&Bp-LEbta=JE$G9g;oONOSDZic{Gu9xoNy;#K5X<(cPZ@mBWQ_6G0P`AYkIc|YzN z@ow|NKeK3Xrxsh&7*r0_8hi>m5sU<^0*nAU9WpYQD|mY=U<{_j0@}3>a2}xhAe-<>~QTkUe8`b?+jzU()?-jAnhaiNw~d)vP;G(O-H||k?hcu|N0+~nlNv>Ar3lS|#6`w5=FL@Ukcy2 z3SnZkgJzxcU>b*l4JQ4otn7YgdAd*V-<$Fq~OIO+m*{BgxGX?_0sK6~K-v7MpDn4Sa8!<2)r zHME8@W_@aZ;g2hKzfD&35zGiUJA1bcombu$@4iQ0?XzYTyJxi#+D;`CT@}5%qBLcn zedtUAD!;V#jLxQ-Uv(zaY-eoS&Dv|c&FslkDF;6gE&+dL)x@Z&X}nvUSEk1IANHF% zB05Mr3eg48@lv2t)~I?^t}E=+yj5EkES7A_o@+BZydB~Wa}KnsR`XY<`C?XKiLXTS8ZrTp(~0a6Lbqccxonq43Z7#Q2wd)~`Hdsf(Iw;jOe6*_Nj4Q{8^K ze#ruG%x)a7uDnF=)8Bt{l~>DE;#GCZ@ZkEq+e=?(-qp>oPBmAaLU5-)?e?d8SdY1l zo_f>;bUGa8Uk*C+e$Qb}U8l?LJac;fh5nNAP!X%X=7r~_?eF^3)HGdJbDlBZ6zg~R zF%=jJUINVvHLds9?RO1zI<%Nc#13sIy3y{;z1@6=@Ho}baqO++#W(EZ$5nR&cOgBfK3|i)3D(1#Hj$QvRyTNdtOgSLg@f~J0Q)^i{BFG7 zPXdsgo%C)mt(l3IUY5YmU(8*PjkuttmtcfW;2_Gjie&v^IUp1yxM33sIUvmqNii$< z9_{+MO7W0$OkbId-v|8XE5SY&WavLrJ3l$WH5Ym&Kf#RPA=hT6i0JTVLe8u*I>{EFm({hwOU#60l-QwB5q+fYzhSW*)B zt8DCKYHH_fVee9FzyRa}x@4)M;i4fc!)0u5OJ`_eZ)8g6Ve9ah1cb+f3s|%@bulFL zu(h#s=JMbr{?~#FSpNH%o|y1o6BlbTK*}>EL2%Z%6o-uc48>s|znN@!yXA@8>^qntE9NPfvEv{~Z=^fb@TB=o#r4 z=>IQoASut^r(6n_9;P;$B9^wm=>hh^$Hu_P^RNB?qvn5l{9lwB|3k^b!1TW<|5wfb zKc%X(sgtn1EwD=$zW-U7{}TW2%KsAb(ElCz|C)*aO!L2|z4P?m=@$>Nymj&SQ$1Lr@i~K`1zXf800+5BJQyYYb8us0gSNFp zIN`ayt}sf~s)qU97Y5>j&qp-j z4V4s)*b$6}k{AF03lb0!C-m>zaXAzu6pcL+kobfI{YQ``zIC6uEJQjr@Qv)hkNyA> zMq~;95(N-$p@7-{i4faC2~g7b%RUeg3vwHQBni<2UHA&DCj_)c1Xz&Lfl&Um+5>ozOBQMw`N*hUQN&5 z?_T#7hq31>`67iJ&WqMuhXjoVhiXIGAz~b7;`wTif?o)6( z!fd`MTr=g&Rrk>gWN0$|Z>haJV8*N2_plKk%5P-Ps>IE?;Jwtd}PiW52$CW3^g{pi(cBF&>Up zKJMyy8S`16=SC#pEBsNV)p5?k>3op){VVevLFIPi>k&!0eh>a+4)2=ny|=e$fmm#z z*<{8|6?*;B&be>G0}R-okbq%U6n)sjRY|a8q2Qy&4mX>Z1``^qEF$cp=yTzO=g04e z4+&w$Bf*^%!Sw`6AFuX-T)tqR5C>)fItLSs@ZJ0fxDp#3?&`yS*enrC)ta)p?JjJG z=fCSnXrC_i+a34* zEyi?JnjK3o@w%JdkM)~q2NHT6a&+ksp!8zB1tLmgpJb3M5k&y75W^ZmdI?kGwTZpW z#Sem-uKTqlL^l`St17*QAstfZgNbAkZ@2sRu|;Z^+XMI}|N9eFu1WBNs?3&!M||G) z`DuQ5rC86jiG1-mLH;jT0`IT>HLy~knDJ<ubSR=`oCR&GwdBpr6-6n34bqFCEtYcp_(CnI9rikv|do)i!4arvhCvmk1KvDE$`mIK|YxR8FfuI zbN}XWTE0xVq?plosC<`Nty<+gD9-$3!SaB~|Kl3E%j-UBETu-E#V%dq`f@Z$)%$0k z0U;vgG?3&dv!=(d<`ejq73zl}sGj>>Z3RAOJ)HfDLXU<;rxU8t?=kCH)X{3RAVRfZ zU8-EFpDv(&zhCAMWytk?;N!YaD#x)^veui z5)Gms2h46+BA+?3vIbW-y<7*<+I|POJSrf$?P#%-b1#WHw?Y+4n@=z(B$>mJIBJj_ zxX?M2wqOkhAuy;=+EXutP_`pZOH5n8!+88Zvanh0gzj-zR4gH3(G-rOG3gbx-?%Z6 z1jOU;3bHtzQCRZx_6DOd&E|x1HacV#P11jVA}0&r1kz|4ac#c8K3dKfPg>1@g9;)& zCliZ0t#J z{~SA?_+kVi7(iiTNiIa}21dEkj|XPAD3KRgX@nv<&WGp@U_qgMEEp_k<&XK6JB{Nl z!)!TI`*URlp+PLZ6&+l>|L zVpnjMf&zSAb4cbZCE-@*b-d^2R5thB(>3?}q%4ANmq#l;6YXkU z87vqxiVvHs)pGImy1a9TCN&c*7?KN;P@B{K!q4o!J;r6@MgQdEg))-dZ#YCRlgMo& z+wEEcH%C3Qn}uj8fzJj(`kusRKMT>x}@_{F8agY3R-=aA4 z(#eyg(Ws4=%i@r1vRZPom}z&|4zeO>{90fzl*=ZIy>H#>a2-^x%$?%eO`~%FFWw4N zznb6LItR7V_C}MacgoeOs1{9}arnM66{@=~`&LvzEZSAF_i24iOVt@IxNiOBhfO=1 zpYB8S;;o^vW>l39h&-zJwiXc;uoeLk>nfthNli~IT8ye_G=HNpYT@$seSy)KwH=4` z!h3N;A~7`IhyAL{MV_=!A|YtNl(|&GiMElXf>=j?MORV+)ek&wM^lD*lXl#)cKjW{Ppv8uF#{ddHfsec5cb`IF!6J2Wd0 z`8(BVYfCVw&1y8UXpDvJXqaFzs2{r$)MPRv^yB@Ke;-L4i%LlM_2D^;6^*~$?H;?y zc1=uF2_B#4mjl$Y8}hSiiJYzLRF!s1xBAN4Gr73$M!T!U-4WxM;CDdT@q7t#f5`3r zv^cF+qrzU80hom}mPqh8_&ll)HUoVAC%4;$K@dd=zmL(H57w0FCXkzNV_f{!mi(bCwDvhyxz8GMz>(O?taKwNGC{0L%4-OT$Zz^ zs}3%J5=B_sL>l9r5bQ?SQ?Y%nm$i3E_7xSS(L%fh`hk(Wt#h;7RN5J@wokv+L6P=1 z4)YS)u?e5WZx{~$9KYv%b*q^?K5=7sMMVwohqIrt*<7;#z027~OVpF~wj?LC=`7CZ z?i~wq0C7Gxn~eyG*xQiFoOpT%aK{ZR3U*mu@PA@WZLQLojXM;}r!mZ)RkAI$MhpbZ z+F8&NqKLQ-ZYk`u*{+r%8Sia$8$-LEug2u?c;-i~CY1<_kAG}-`(_Sgf2-U5v*54N zX!q`=)#C@ALaQAnRfo|Sxg7q+WrGEOw5gvxoZwG*u`?KTI}e~@tcEZ1yv4x(hSDex z_?1wB#&@&B4tYFD^hdinjocjhoiAc9mv9?Lo$eARcSPmLnaz{I(`Cgc?1zbj*r^gu z+ThCAZ3{_MCi%!*!e(c2YtyY#Vaa9Il%Uy10CXO5p zD49(ZsY`t+8m0CEG(${*G^ltdttA1l3OTPJ5$rADIpfc9WQYPMRSmQicRYirsE>mL zEQkzX%PHizln{RfB0^~6jbdt@Ul-rD+N?$;%-&hV@~y?Dy)Nz?dG;Ts_G?-i)Zx1D zm*+mG>2lY#PK^+yMk3 z13d2;&8IPyIC-kpktO395=d6?rcUcEsi({rDc;`&v<5OFXxngTqll9XL+gKY*>KZx z(l3oGtvxUbTdhim9|>x&QGbVDF;V~LTBRb*fV1Z~fPj`^JtmmFA@t8~I!Nk29@W*;o-to}ofT*gzRvB+(_cO>aD$$niyLg1pRf^p<#qWA+gDk1yCItv zwcc>2xJ;q_&kdtNo|%@J53WlPp>YoT!@lujhTHhc+$mkcDyLOypI!&?gT{wt`23Z{ zc$Q6+0zCRK555cqT6ai}*b|EtVGynzgY%!87od&Q+gs&JG9!mnRWzwii18$U=Xgev zU%{5y&O^o#PLjWNAn{fFrv1$3(`>C{*zdRVPSjz|{7bsEup5T4 zwc?Okzh4^mXNPoRVPC0}eE$v5hL9nRq&^Q6s|n;xJ2UmpG3l7v!nr3#nImh@5`JJ7 zs!fJ8)mW6o{~?%zy2}l};30gfUYL*jT`eB(2El^F(Oy4ad@4s{4CbF(3I*|<5A{sA zab+BSajjA)clbaMCvvIKoFrOWk`8tBI!%!x^|%RLj-9k`{*c9|YLrO7!`>9Owt+m? zdH8&Sa^;`b>3{(GrQa(%LK2n1l0u7mh7UEZ1A~sCD|)Dnc|~8!n*ZU)mCIAjxJW(a z6*_Bu3p>%?xa%n&e+~)Ow@%gPl2=*+cRN>&ZrFb^VL;>=_NoVg>d*@<%<6D}O8mMT zNv6eL7IWZ}M+KkSj%e+SCB=AUn^Gi=!krj16~}JmwbonB=KOX(m7Jt@I3b>zR1Ba0 zWF4i?&M)d^GGIs`0wam=2`HS74=q=zGHoaTI;Djv)EKKgYIQ0$T5tU6oYPaC?iELz-4Nfwj&;T5?Dn>v$ zkQsl+l0PKI%;$;}X*7eCden3t!;5L>LbxTpr7|J%eUC9FlQk2H?k&!5Z@B6^O?-L; z0n#zEVW6v8Nzdasdx)Fe=GmML`X$cs{k?!zhZp7f>N6e7%pbAm=jUs}-_a=KQbGk{ zU-C2At5;~EhF2eP7_!#d1K8|!jG5z@Bg^IsOJ3l$mFo8>DX;e7xE$XAv|1eD7k}E$ zWn2jwzl`+%_=5M4T4e%MZn$E-^HVP2ICp_?z^R8g=XZN$l$ALB?LCQi?!NPF+VWRM zSEK)&Q+b;kxM;h9cb;pb-rlfunU5!-yO_~ntnfaSN`V#X~OgHd0T?Nh+DdeJQH?3WiMIxgq_Le zZn2mTTXZq_q`Ot6)F|aZr&CHqA(N9Dj>XT{#_)BK!>p1@DZ?M`#E_{hK0)dq@8ax1GZufSCt!-C)c4q#{uGR& z`my&k?2QRpwQ?EJu>@xN!AR89scc@^ zp7!rE0=9A-yGe05n57Rv|Z!HBQXYCrv_IS7EM8Iy>{Ht?lv2Rw8V!D*#1`|GN@5GjY>PJQK1{=i75bIf5~HyuyH3RT|)eszED} zbhK1=n0~>)W&(5FZ^?#}GGLPprvIgVT7IF)TfbbP4ywoKci~sjNR$+<<_j6d=Qb;8 z%;^H|mggd!7;WX|v*H$wu!XQ4vN6RKC)Knx_xYjw3bbkh*Qf8J_h4j_i8_WM4}G1f z?D~ddQ};m*goK6$VV%T(gL2t`))WbY9V^sll~pTsqN{b#zNs!-$S(d%%CpDtI`a)i-;e84Q`wR_HEXFNP=yx4zyA;*>8R1jVAP@s>s zU$2cSmi<~Xl^M=T?G0gqOs$5C-wUTWB(w6AeWR_4CboFuT4v=s-|^>XXaLJ{rB=R# zXw*@_W{|)6T+FHZ@nVI#OW-J@adBmNndQ8x(%yZ@ zyO9MPsq%r1lyK+Nv*_O_CpWS~rlqvI{ptOy3b&vP_GEBU$AnHe%8+8l2xIk{a!?-@ zI1Yqaxils?g=IYYy&I14iEvDaOV|q zIPT6qIg;3PKfxS^XYpKLc$y8p%ru8YCYECwv)ZhW>Cop_Cw`MR{lH&GXoTmu%Du@X z4~KvMW3zhli!UjEP-_0B=yPsg8WXLJ#cDka7#t{;@oq4eQ?hL?fj@GOR6;r*t$-wSNWVslGn)N z{i1SN4q7iUJ7+Tmn|Fg=QE?{{DHF{|tx_(j%~qp&={OA9C>rgk-vVz>v_Kl+)8S@U ze7by=*~jnyArtzF&+9ZJLtMK1t8O4UvMC7YVo~RgjBG+FD%#n1zloMQrL3L>w@IN6 z#S|XOv;ztdH%9Roe1IhgkldKZ zAZV>P+C@hI%{UKwFnl7E4PB&O(j~d}p*UH1y7gx7*&S>Aw1XnKy#A=TP_W3nuGP*U z-%fKL-4_{`DD$Z}&%RyWB>^V0iF_Gmj#!EsgzS#lDZETho2Tr7ufvVKXsDsSL}f}P z1-GE)Do#+b0BG#Ty3L1F4l(OwI&1pt zt-^DmRINSaDmn|cxCn78uAuY{ELb(kmLjp>ZCt`t{CsoQEAIep~$(EMja4Z%ruHb~Ng@9N6uR(OHebs8rJ=H5M+9856&i z25agRT76Yf9Xzhq)GZ#lzTiKR1~PQwv1jTHCtmOw)IDNKY_5i4)sW=)ry~?klw0wi z))Nk3$bIX4&5niYriYx~7xNUQ%Qy2N`?8WFeTf|kgC`n`6${jaqo$BeI=;fC0k@WVVBiUE zUlS;@-c=o%A@f%so(P^`l6UwPm}35s$ldUj?`mQAe62Msn1#BA@WoiKrAQ`MH-p?& z2*W`rG$OyCqfu-l`Rq>93jdmhw*ZeU372{NEvg~BgGz}eepNvt%W3R_BqfpreSH#MB1ei0VMV@T*V&Vx z6(?b(_+7=Ku&h*Wl$V7=_^<(sPF`_425f~8iLR7XQJCA zy;(=dSUQ}aXXH8bL;6a}P>@gpV*Mo|nQ$5f3Q@orAjOdBmMo&M49>RFO4Zi{=UQM} z9S=AO;O}o$K(Q2qF;P}?h?9XSgP$@m=kZr~tx%&;3THBzz*WJJ!~~x8h{S~0k3l2okOn6N;eN(O0?1{9`vG z2yE=)@T1|MuY_cWj6@96lB&i<{ukyFI3ff#=HeLF1XBJ#gd=o8x{^&~5k1P1{7=sT zgh+JAS6zPRPOWEYE>i!TPQl&=K%qpsShj$ScFv%t)!(@S3PA$odqH34qVTWLG*G4; zO`jsSf5YkL1rs9O{y>q2{)dxY(E$JE466Tslav)KH0lQ_QKExyWRdf~m&F4kPvY-z;*Ob7*7H-6Q-?S3|wYhk5k!(5&5@NrGg2W*V;80Wu* zLV^&22~?Pspi-?AvDIoRjzXsnQEJ&^`Ntms_A^wV)PnTl;(JXEeUV&tM!r~#6!7pa z0D57ZzEME%Q6YoH^60M_v)vDwdLl$Z48$cPQONR5B$MW{JKT?N_Q&sD?yXDKzClJ3 zi^s}*e0%_7=0-Q$@7BvT^FXu&QoRi*3B6#iv4~Q>ha(jbN!p)E%4~sDad$o#5d_9z zi^-&a%mL%$ctM0fMDlJ?y$j~PST^&AkB^VaGrRqt8DRI8Yt6PSSHpr)Uxdmzfbh^l z#qwvV%4lLTgQ1YK+k)%uf$VCdCo}w4rewKXK3hHvU}Q9lvs5@7L8V8JH|i^MG3V=e zD*f@%Y(aVVKDAofJl;wq1}(O1Hq&4NG1cW3k+8+%#Rkz2>$z4&R-4}n`>)-fk1l&7 zvqz z^%9Xzr7Hr4_2++7Y1788yfB$h*+l*FZ{7ojDqRjHEV(>SSb$3=<$JN&V^3Z2Pv65s z5I=vDcp4IGpO^f~Y*zSF7)tqgt|++5c=2TM?zklPX5vP!_L*Vea;b^h_43c8Q9!fm z(ev%$-C6{))l9Q1t}Fb!|;3Jj?EDmV_hhb*Y~>F zlZ;;h;?!UF7`34>==Jie-+}O~4c-qH;MO*PxWS~6vtFunv467vczd?IEsh~d1XK$Y zMWI`sXEcRqnmgPK9_A_o1C@VBkkg{Uz`<#w`WF^E)ak93D$_;VKZ5$od|%xz696Zm zj#!Z3ST{RweUguM8%M)|;F~yC45gaOnr%*|*gQ{%TjdR&G^*t)Z1WolSY%RYRNBSg zxyzlj@p!#lO6Ysep7D#*D7K7g3Hu^ZzX6jHxN=E0fwBp%HQW7+TWwZ7kP;u0(i0@i zC)#40$MAtS9{x_w&(F^s27F}-JZ}$_flxLDbY>H9W`*jE2J<-_cS?CEm5cMPaR*h4 zWi-7$c0${-{!prvB2BaO%<8Ae`n-Jq%3`;!Zxh8)5J9Rsmb~zT^?O{03*&S=5UEG9 zU2Tk{(``4Qi)On!TN!wJ2ZnuFOpfRq9kzwq9e2ZsQ_(1u7+_e7s`Y(-zR{0-zcp6# z2Tr)NW|wlIgyGA}Y0aJbVvb<2A8}f(0dXJ{M8h8iHpZWyNDz1v<}z&dD@Jsfa|jMh zV*#N2GvmGSO&PnxaX8VMXS-geCbEbApRY>KnKTG~z4VmNUVe)mEp3oj{_h{J1b6J2 z4+i}Z#Usisa(Ihwci=&hqu&VpWeWU0H+0xa@hrj^^sOR+5N$uPahqhy^-G3iUB0L-%4kWhk5vvNzsFSo z5KENQUQstr#^ZKV>UhAjm>+@Z4AeSJVcmMl-cWJ&aXyqJ$jAc-k)u=reEq^9ag zxWX&%9Z1)1vauXOD|y+8u4b|S@VV7AH2dX>&1scP{KQ(Jp5CNtk^g`+x>U7xG|exb zdCGDTpz`u?Jn(CFwz7?_R6iG(Xdr97T+M2h@;A8X5iMZiP6S%g{sZnzg5YU4j;-m7 z2;-vtOnmnFWh3OoToq(P9~k6KW>PAx_5*%o8sZH?BU-^FQFzMemwP-8hX5cPG%mMl zia}~r3H=JxlT8fL`=gn|T+va)z}$^QPT#!|#mj^UK0t}DJJM0EztZOiIKbCYgaLfn z>3RpU>vRDF0)-|rWDC*hq!0mZ><4Wo=OYkyp^^CIvvrQp$R|VGXfm3Z{*7kkUcIE* zq4V*0E!AqFQuOyS)6-!t#mQP^fSmI(n{OD6N}H7J%hNplBJ}H11OUts612sVG$LdU zrI)C!kF#`pGmX!eoPGXg%xay_C_=0q$Oeq_q^_#6YS&QG|$MXcadM$i<@Y6=)z_;xBJ}Dtg%M$MRmQHA>Aop3w(D zoH9HZUroEvcZ43GPNzJCb{%ff#A2zW?63#(J(JbGI^zw9^R@eJ_$sO~5H(xQui|>? zG+C*(Ai+d{twQgerK2_)Jrg(8d=kl8lM)xQ!W}YuboV)xac!v#;do7**ihb&6j5CJ7(IG7s(d?SA_nzPh^2an6=x6aTXym%#G zI>zJ*W9g1}u6?&+G5n4g&vg~OaZ#C@wZ z9Douo-@m|){kRP=a0;+qZjPZ8`TFai97|Fp34E@y3AKP6j2QG9YhK4TJQ)*WJran} znhb=+jNw8k!|WBr@#O=PFy?jiN`El?+&5x!cLQ+^>_rNyfc)8_eHIbb5AybGo?9kC z$nhS1Yc#A<_UD-dX=O1{Sh`AF3XVQeWg)*&nV_q0Mwt}`d|VPrZrhs*x&aG_q?DK`s5JT8~Q;W&cKSFklcjFkN; zTc=;z1$I7spv-!;tzM-uGnrk~=m_OZb~S?%`Hc z-H9Gk1|jckO~5TE#-iCs{OnE#iGE`nYzAZmc>X*~T>wQbV&D(3S<+x%S&`qE$Pk@C zWr7UD6>^UXj7{v6mT+Fe+i@TmJ>SM{9z(q1ejLo`&=|OgF$fqbthAdlt5z!X2O56LZCQ#K2AeC7ppxkqjQ}Z zKXS(pDg_EaD6yKNzI|Vvm2IoIa4r8I>gnzyWQmw({X4)F3nPa9Sb%rT`h6Q0~*+ zCGA~o6x{hZP0bd&Y|Yo;bm-#T@;4c(0!hSTHl9ox8dx7bRC2k!NJ&YmVNft|=#SEz z60>&}aElg=hzM6M4Z;(<>&G~I#eG^ojlu>nSqKpWSt!AxY|eK(WEvLCJz+dxIJkvq z%Z+#z?-wFMMS}L#ZBDbzN_k?2M=}4L+Z?1J;P$&imsq~NUW{&ZJ4^^!d1(uclBhxpi=vnAhk-a=cqaxxqM6yYt zZ2HG-ow@D|nx#Obx1zD0YD$VFD2NbK~2C{0SB=hC) z#znP&CV{fw7aA#HtU7K8k;nbi&jt;aC!i1DQ0fHce4h#*rzg1cV^Vu!IB0dH%{ld1 z0jNu98YyoxQRm@dJH|_xQb?qUZw}z?X<&jW#24Ms+h| zXatXm8KR{?P_}yRl#mF9P7(;C_lU7j*sN4lPJLKo~a(J=^Ir)rs-(~AxXS^{hjWG5gFg1qwc{;0HkE(5lk zFzzFkX~_>&e46$d5>%cDnBft)L7*t4WiZ#z+!U0=>-}&=B8`x-shYSkR6u(roWtZA z1sS>J+C{6?3~)=xxc4*^AMTB!<>PictMzybb@lV%cfY6TghCa-a=*&lbn=4 z+-N$@9x~)9evIix_#);GkDWecYz;RDs%Y#m_{wrdL|br-NWXj&BA`4clE|3kn;-)) z>XrW0n1@gRbhuOe2w3N`l6(0<@0oadXMK8W!Eg6&Ovfihz&0^PP!V@rRIl#9@EjxB zzaL1dpFj}>aOBwr$b2uRKK1%p6~Q@>LVV1JCWU9Pd6(!$Y=2)ofh9iv$^t^@e)8Wl0#x%YfVv$G{>Q#-|5ij zqvw8+wP&5cY%Zs23bbPvz+?*A=9b!Ojdc0PAcm`Q`9P9zUx4Z&o=Uac!Q-KYEqA4u zsq@|aHMR(Ror|MCZTXMy)831dMJq-K+2@_}NH+N>5V>}nGn8nHVSULJBODBWC|97J3aV!v)=^69Kb zZJn*_LS)%~eJ8Dnf9S42XDFOa=u`+T^V#^`KwuY4 z>`QITpYBnHQs;LDgMdMUuCz_DW0>@D#O4LC8zBxi2e2x1di43Adok&!LA52O`70Tw z5I#g;_OmNS^kr|2QE1cwiwHo=n(H9g?IM|4J@#EM6DFe=Lu6C;3W?HMs1Jjj#wJvR z<)$7U4$vAS0p+}x!bl7k=Q12!cvm3Af(2Nq(kVlEl`PJbCE+l`Ww^!zmscC`iUzta z)X=U=m71eDakG-JxnqC2UKOfPvIwk^zRNEz4kO(+r1`RI>{mm^+F9oh%!ge|xC3O^ zJW{Odj6_|LZ<`$p#O^Y;&jTYWgO26lrrh^$$Zo$8Ze{TmHdCut)z1iseSeBsZLv1@ z(~p|^-h}Qk|I<$fey$FxUqXwkhP&DNDdA*BsA+3oDTzZz{ngBctGU+{o_lb@ZY}!{rDG0go}tzAu;jRlKQ#P0k7wr2pn!57Gi_Q6ST3Qc*Jp@0wbc`%| z%^S3riq#!a?M>guDp72SFY?me17_JOWy%$p%z_oOL4X3Srqht4jygXJV>~~5X@{Q^ z?S3}1Jt>`lMGrlLD1(rBTymTweXh_COXU>0=*PLOYfc8tmBezJxs0)H#)2h44B9Wq z@W=7gz^GcM{V~hBA!yXFo62krXyGl~*lsKJ7C3r|(?4C}@qHv*xi*l25fK4?i`Cjb zq}T!?ibPRR4Q6xdB zn@hi5|Mq_2oL$^&L=AA~M7U?Q9gVopr;1Ia&9sY6lrz?Eb{RoLP zGVe=H{7m|$$1@zi7ZkYyfJL*@3WPvAND@|0%`95PW*?CfUgRAw^R$xS_g5uOPC9^y z2*kSC>fF|LQ=^9r>;(H#T2Ml-eL&(18FhHD&vw4i2)OW$GhXC2XuLFOV3 zR*dNt0y+;VYMi;_JtO0gSMb^}>}z6?RE22P>Vi2ne+2HSFVOR~yoNQ=_Do>&|LC!b zL8=yZxbN@dcRS;7twOkp9?wFy;GVhoQOj@nbhUYen&W`BuTBJrWju{eV9Gm&BT6<*(~1Mdq4cVHl`a+UB}0?K5E8xB}3=M}L1$T1*JxwZ;^H(9rM z(RA?MW8L=32Y$Qi-vbjwLlMd{jCCyOZ3!05u;A8%b$ea1ZSSv{0cJ^N%Ju|vPvhz6 z&hQ}jg*}k%yLEXy3mFEuFG957leTg>e&M4Kxh(R~rXP7C7oW`L^$yG17NAB0LIi;` z;sTk?JaVK**g_h>=&5+ca^?27W;P&3Jofd!7gyF}1ISTYGMjCh?v|=D$0l4)4>}HQ zR>rnr1A^(%? z1rg{F&q#LS#teU)l9$I*H6J=CdleGP&$u zf38sYFNW;xnwP&`G##>B`wt&og>fJFEOD3iOyTRvTG+l?o#66tc@hhW5V zh`(a-(-I!~MZ?Wa$2`uiydxfMXJdXBCl+1pY2q$z2yLxWwujiCe7tyPlFwQHsm(60 z*xN?iXgLpn^?AO?KGSSaD-x3(ny)9@oSDKy6{^%``inyS7TkJA9u>Bw?*(5p-*uX; zNUb@(86B3wp->@VL@!TI3v1#N5NAt=x|RvuQ7YN2pWpQkY_wZ7ZTEftie8o-a-$|C zVM|FJV`J}~cghm9*`+s@8*AGpO+BPvxW}V`QI!<3iZCUG8ONU=5R9lgEkGPcR_b$_ykMx|cA{m#4B71=oACdm z?ysWS`o1?_xD=Nbr?>6in~+X-QC^Y-QC^Y-A}&#{om{N;#{3E7z0AG z)=qZzT$!2kc|Hpuk`wvYz|f+S9EBh6t%qNIQq|90*S5`egcWMw7V9yRKx3ei)>E88 zADBlXR;zHrEJSWx;mYhFuHWMH=sWUqwavom>CUm2t^ ziMJVh{;}VvfzinR*B+Ulund4?CMcgBK1_0Fm+KKnE@i)fKQj}@Lson^$E{dkYReSu z%}l>E{`5g&`Fh#XA2M{A_aXg~T$n(<;P1G;-EdR%(cBy*S{+ymA(7f82H5dZiIbeapj{ zpYt3Usb`9&Us305IFj2$=T)ah?ry2$?#6O8%y5@1yrkno>rbrFcXmT=gX+u_DMhk| z&3w1A=yP>fKQoVL<>VJWR{HUN!*jEZsagy})=#@|?7I;@C9ZtsmE`_zqz-DWH*_Cz zLV7)=qNi`2*6*m4I@{(cpOb@>?>hln%x=Os^#sTtK7y=euK_x>ZTsyP6&hTM`SDbr zosTg4?PD>7F#cfweE~PM9Z&`)U#~~b1ms3iw`9^AGP~M8lBORdGY3I@rP(7lM{1@b zk@bu!R2&pXbo4S)9lxVZC1xuPxitW4lA1!tY*?-nFG z7yyAV?oiW7Xju*>DEu#^3#x<+yQ>e026V`oEMUAVjW_8oqUg@Am7g`g>JBbP>!!2H zxxHRO8phd>^&;f2DXdUXVKGP5F8wTOubZ=i4Jk1bf4^$HpuWW>pETLsrwqALyB-|K$)JCb`_xsE*jK)KphPTR;vw7RAa8s;u1q257sZO4%(3#GMe8eRf zuT6#FYdp_!Zr>b+BWM1ibr|X!wT&cVzkxGfB)d6Xf5#L3iA10L@=1Sh- z&BZv5E{DGz?T0$4YXr+@Dm>uUn{0cnAHUQS$C3ZRpwxcT7thQ__~~^WG?At)a_Z9M z^|6*Wj6t&yY`bqNdV64B5R>1TwNxEDb5}e*c3xKxj>17ub>A)^>NoJU9O89hW!Mdp zgdge37UJx7U}YJ0Y)zqB3{u0qx$mG%m0MQ7>h7NDo9z%&**Mrqj03ojAfXHa)XX@% z%(%KpT@sIC$sfjjn2pX8)^>NR8GzzJVVP8+bVY&rV&qMicI--n4j)h1JfC>{vGBYZLXL^t?$oy5?eHWWr&7gACZ)+IHUaDWK-CNUBmN^+F|7{QNCvTzg8j z&hfXg+%OQvne;y&lkX`}+J>oJe?bDTK>;8B6obCV-NJTb2eO7?XIe&L4SHndn40-V zhal4{wB^L~el13*%+HUBacNhA{iI7ByB}KI57J!Nx6g79fqTj+sJJUgNj-lXe?Dbk zP+$QNgjUZ&O5dMQcn|7^>o5eR4s-ILs zk;s`NN>3;4+*&+ZGAw4_3W!B8X106b(Vh)PwL>FtwTmXee}UX)=$4zD3GV`x#q#;q z6yIZq_ktbN1eOYJ;wV;QM-ndk)s?iYHhB+t*4k0|6h5s4eS990*~cL_FEGU*R#Fzk z%Wj9AK;{;rEyn1)alBOMVuO;&>zE{?ns}GLI!y_~E=~KHrD!%sL|O-GE}J1-Mf6;Y zqxF*f#mfi_@0Z;6uQN<9p;CHej%#j+jk&3pl+X-A*{bV#_8F%P!n*A$-l+I%?)+Ro zHnZ+rc4QeB#IYYH_)y*OR64#rX`9t{ewo&{*&!CEk~=XuahvVHn(qp8RF{aUV>o(_ zRWS-D6UNoqcbk6J6&~q5;1G1=m%eP4;lfB6Q|FYiS;fxG%f5TwnK~nonSCY(Duk53 z{b5(c0v3leRZbxQmDq=~hrKaRy^=E?sM}3hS7|Zdz0KL-|-2z`e_-0!d0#GKMqFj!f#UmQ1hG2X11b2Hz z;2@nkqiD)91*3aW6MuMD%u*(2pJy;^TMScUXUwJmTo84C&nXsVG~tgDfIcj?yoq~0 z`e>K9ds`OpbwRKVAJ;;Piij3{xXj#VMd5#^x0K2-IiczZ1vSf%!4iWZ{G^WyfPm?a zAZ~P&8$x4S=gi-&TtN`NYIiSKDs#FERZgzzEXOf$%l#H_UZf2rKGQHeGt-Br$0iS! zHc1zcXMubW3m+_vN89I#0~Q#sS6*S4eEY;)Jm}8hBH~2Q=6u54TzotT*t$dDEA?p(YqYvvIfGkX13iA|K(JITIO7gq=y3AjI7l4i~` z#9eOL3aN3i-47o)IPHvnTCOsx3ptYN&HjKW0Ood~<&ze1+no7{+Tdmn5r*AiiGv!@ z=toP_q;WKEQY{{*2wKgyeC6iXk1~a0HtD=-xuvYu+d2#*;H+*O#0x1w72w#gIZYtf zKQUmnD##mTLP&1R@Gz#um03OyWZe`gblhay_31{cH5zj!b2r6?p=YNh7(GBdNCk3x z3!Jhy+|!bZ{qRfQiH?KHrGnXu((^BDz>G@1VH8s{C!InU-?m3|U%y(_1fSE$l)hb2@FTSw(LA$T za2g*&%oX-H`gX!1TkzWTtt7W2l1uLtooOD*nrKUW?aupRdw<7OSSN0?qqA_vqF9{D zCcQ-ct?(@;KxgJ;p7-X9PV`J6ozU|Qn?gYzuSk4>+aqm>Ei9^$5#y3s1+nrO$=)GO zI6ik1ia~~8Fk|W4qw1(PuKuBCxViwE&)U9?ffs9Ixek{go*Vj;FFtR_u=KqMv%Z;K zywO`B3zdFK?kgcYUO%K=LJ%R+29ix_?N4$W-H6~pKMaf&)4}?QQsZu$n_nGIR6pLU za_IAiP!x>iR{N}p+v$S@=(o%Zc@`oOwiwI3#&RN#y^6SiA{y&%8p2$=cJWeKwM8_N z@}^5*pJ&rlsUNWHT|{Ct!YUdnpZvYQR*`)i|BmYh&D4DGQGKxz?d&&V7ft0?Grb!4 z-Ig^w(_FDwoVNBH(H2c017Xsi)MWsb8R&ImvNx(8KY)}yCN|T4iuwXQF7hfa8`#@p zhF0eT`UL!QbZWHUS)~LGXySBK6YG$KyNZ5$y!?T?eGe}biLdi4TA|5m`ge+CtjD3z zj46=kvO%CcrZG8?sZd|umv7gb+v|akd3U%|4XzlcQYk~-)vAbqs$ zerHz;^w2@GO7Wk}u)F0Y&ALzzJT^L>ec2^vN?Z63#3y;Aca{@A3YMdKnMUaVWPO{y zFYpQ&Cm{oUiWwdZ(JobgCn0LQ?k)G3`1e%5;wwhl@t80s)$4n&_X6qKp@cxG=_T{z zMmC4xR)4?%`py7%#2MQ=E3wknJ?;vBM`~_anNZ-T^z*@bnCEU9l^K|EsgIhX^A$@3 zfJ&7H*^mZX=Q*-!XolRTLC1J@v0xeK1yNUw113{MUOB(~a_KNKpuQEuJ6Q_2BFG=b z2~595T#CH%G)Sw~K79{~e<^_sw(;ym^XGr=_A50*Z*|aq$Ad=?#W+0+u6SjTS*h zyDeKhM6G5)9+X>@krF9l67>+m(`c}U%VW#kCd`^nUQmog%GWvn2*GB-uw0|pG`2H^ zXGBJZ=pp_p^8MV2E;^0(EvlomCAK1yDOo)%>s-w<_T$JlDdlP_A$FW9YUeC(@Z@G5 zl@58qmoFi*HS3pbsl8-A#thN7+P&S?i)2wDFvffUO6+rJJS-5gHgmiD~CBYx$Rk8)My8^5Co#y-!Z-vPw4W7<;HMX6D#duO}Mtbmu_+JmL)eDsV7){;U23A(#6(MHX3m&9ZQ5q{z5RAQUV9NrAV#O>yL|F2t2K7< zkocjUeJZV1FjDAJELop8i67yW5AuILRHDgx0hKId#nYIS4%*iBgBGMZpm^O-9KDh~ zD}$i&cLR-65sP|kbHcaH7l{jPmgH`WeZS5xdhM#0J}|VolikXm)D3T*rbH=S;2d=Y zh!j&bNEXq&8Pjpg4^XUzM#I=ioiPI zf3i~gK#(vvsnuXc_uu_(@Suv>Weffc_P?7wxFFfB-bIX<-~U9Xv>>ca+o`Ll`TwNA zcK_*=nxxsN^8bm9$B01s-UHfX=6}>3u>a2-xFf@&Ix-UR`B{jF zS4XGEGjC|yuW_TzQ>s$CJtxt}=k8`7zW!1S*I^x0wgF@g@t~=B0OUaS{=011%?_8_ zzuKJxhv@A)lQ_yo?--Fc_Wcvt+q_2a}?spAb3qc-Sx{Dgv|L+=PmG2Xlo?6z{Pg1d4M501K;RCDCHQQIGx+UsKcnxDl5kl( z)uGeFsNB>I37YmGJDfoM)DaMj%|h89ggnba(xYmM1muy);4V0v%xtZ{=i%wlY|PX+ zy%RJf3}?(i_1 zB;1!tFfl~?mUQZVoI57^AG(dKgT@TNJ;s8@RF14aVQK8L0=gw(j52~~yS}J4QBDcI zE!ksWK%NV~YQ+cH$TXjN2E(Xsd!n>yzA81t^PVb$I+D7vkgLH-NMA6z`qX@6NXkqB z3m!tR(}>2TfOUn64LW34sx=wk#M(|u1rC+#S_j8Qq+qUbjs|Qjj9JZYh=}_u!iu|I z^tT>>W5wNKe2pG5Ro!wtPpdp@*^c45m6JZ`i;XTTgmFX=>DR74ta8DCCs#T91j9<) zSE$2QCUr5vw!*Y_9RTRffrq3U^bB2SPVb*=!GB*j1axyEBd@}+`+Zvo_9y#Oz&o*p zflqH&usIP2i>R87IB~b0%(x9nck0=M3cncK7mm!ifcNcmvl?kuuKRcI70JZiAl6zB zxY5MjD(Hb=RCnFU9b5{)wk^YTM5o`|jM41+9n}}I&hHlptkGIFvTno?|Aw?xUq&qQWZ6g)9koVOj9(>C0F=_S7pHzw9DdRn*UL ziF3_b8;>}PSZi4aFnKr?gq_mdPhhVF2UK}GY3rND|0}ZVL4j-n^mfI0+5c+d&SYOB z-2B>W31%q&7ynQ`bEY#2DRo#D4TtVsSS>am)bW%G6id6Th_0r;lYl6iLCo&mXYsf0 z*MzC8Wi^eW{=F^}-CH6w^^S|K%U={p$B1TA3 zsxkBuB!tmhwEpFx-l5RZ1OcR+&9`WIAVXHEAQ7sB1>lA4C*e+GgXyl>^Etb-z!xSY z=se~~HCKe0dCW-jvis_)x^D`CzM)ykD@DPon_C0!FTdSy&vn~NX$vX_(=dIUY;yf1 znG2A^CGR3NQUP_xL|->iSX;j{BLAv}m5K@;aD&A__Nc^y#~cKRkPQyF^6Ysg5us>$ zCzNw_vQee58kQw%ej&Lz3N_@0s_Et{j`R?n;=OCGf6%jlwzAzqjUIw{^!<|%M8G_A z7rE@}`@4@S=X|#jBB%ddj|6Rt5ZhC*6=&npI*BOeG1aPuSiPz_{v?S1EAN+}mgVw@ z)e?l#^k7+f87L8?XZ@#nEKeu9IXm&?m;B@N>2naLm%VYu#9~j ziZi+CPAtI1aKLr|PxqQ42=zX-YLrNC@|8?dnBGSCPK^yp@Tcjls%|OlVo^TkI0tKY zIA3szh2II|K+zSwbO04#d5En6pU`HTULxek*Mu60I%s>#XC3aldCZq-6BMXmesa z=AA(cl3B&7shvGkJe-)|kB}GWyOHcgJg1CiF+EJq5iYK=W^%^X(nuZVjj=S)hz0?i7BcpY;wrL;sB z^L~$^Zan6;U5qP(z8YYY8#*Q8kG)X3HY#tqjg^ie4Cn_*a!NPRD^f$wBD z4AO-8u5M+`nLezF~ZuZ;{sgW+^NAwG^AYh!V z+22)ZRHGn4raEFHYGQG60%f|GYJ!L}jVRd7B2Kf>XwDuD?KS8Z?V3hwWR{zuAVhl9 z8+yR+%?@BtmTj*fjAr=G(Yq=2YxYAgLi88HpG!D@{X1JPV5EA z6-KwgqVfU5wH%Ji7|v`5zNV`WqkzW5%{9i{zAG?Y8zq1S;4-?Vszvpi_vH#KH^?|cQVA(dACcfTMg6(Rv>Sss`ETN?5CK$#JEmU#TXl~IrclcOf|8Iu!6N*%{q0t8CDGWv^=7}PQo?;Ta+KWdK`Ut73exgvw8-NtX z%Bq(G(57CXtb*Kj&&K7IE8IASL~LkqaPas>e#!%8YoqTcHpbpi_ItJf$!wuizK5Gr z=_Cl;WxBpH&+Is6LMcnwx%y`$g$4fpORVh6X0%VFz$$33CA(eB1}>m2aM{4m1uZgP!7vC#_8xWukxY??T*Xp&8bOX)!X z4KCi;5@fIUqt)q<+#Lv4p-!b#q68_;fKtzHj7pVqp*lVG8R0NsK7c5#QaEOHwLWT)wFx_)()(ON*6pJN? zV}SYhIIL5>fru1hJAH~pHhLl0G}z^;)jI+#r+#UR%WmfHE%)}X3pB0|XKGRB%8MN0 zafFe9f#CmP;Ual2e)W3c@V`Es=hm6d$hSHY6#LZ5@0Z-|^n)Zjvx|c&(M^><%=qM< zQ$&p62iQDbN;-N|CLE2{*2vq%ik5^BT!pSrSpv%2y5fUsC=#vph zqggkEFaR=mnVc<6$1xCh8UzLBfryH8*#d4Gtn{00ULVg~kPvBwydO`oMiWWWb_Wyx zd5}_1F&X!aLoisdJ*MkZIm(U3u|d2*M%VWj7Q^wFfSvwO5nhD1=mZ*#ikmX6#)wTB z{KT8R2`OQK=~@#qi^Y`pOumE!NZkgazIo*`8B0|TF{@mlb48OIj-c7!>``#K-W8cm z3jxK+bxmjUiFiB76^rGCTwm@e%6B_{#Q?>`_o?CvK_&vm3^2-X1)Yi%3NyU72UNGJ z9c?b;9)Iul4o^k?Go6K}vd)es7VzhR#7Fl2lksuGR30t%_J=At}5-(^nL@=?ckd3x=q3M_G6th;~Bzd5_h+?}rmiYL+vsZ?m@>v-ZlXg8K9 zm5QIQ))PzmD_5us4L;*ns8r&!Sj?&JL-Oc6obiJ!L4_KU^6)=s3OYZqKec~u#L1SK z$o&TqdkN3h0x8IHLDFP#37>{9wmxXIilU|+Am7#Q>jkD%3Ul(6hVNG6YLT7*_WkSS z-T0zTHEV1T27LwZJGUy{U?+$GC+n0kwm+Jvvd$HXLB~pPJCF{t!%n|Ff2O3eTD~BR zBvX;;xlaqC-~!2)viLpB`X2E-nNAlf3!Cjew_z{vmB^SkGTbW_$#o)fghDHT0lc4g zOh30bY4S{GfgnEy$~{i6f%66L?Gh!U5?*g-het>Dm>Q$P>4GIiUdvbjchfC@SRWRn zLDs=|YVq>c!#S7YW7nM;qj5tOe1Sx21PB8l97(`iz~yuyi;90%-xrP(crsTZRVb6W zAQs0agO6{ZFax3^Dz1p(AIUbjHCFiZe_?_bU9?(1KDB-+@^};Y$@~4y2Lwlw&K(R> z+GF*6V;A~$AgNt@`?o@?RT?DR+95FHS%~{xl5oD9khLDHh|=i;_#d{TUjY$ z$`zWyD7C;nOO&~G5ZBBK8)Sj$fx<4me`k#|PIWk$Q;rP+0rqBxkb7xbl#6uvv3br^ zd>vmFpRVJGMI3!$NfZjgCNDQ&?}bQSw%ejic69x;`p*w*Pc{L}A90 z93vz==KXx~drt^BCcsD1f$C5^RDkXs;?>kK`5=qPl%@F&i;6VyAIefW8lY*}Qm^O4S4Y6KdZ+1#UY10vd$rI9+bkW4`884M6yEIz6Om zIRrrUWfw%pQL4p&%ojyQzMh_*+?d+AD-I`=S!c&bz5(#2M^W8xD@)AceiRxFWwr>k zMhgqRq0p!+AK=i6C`S7<8uiLQ?B|A%B{e5a=TDTFjVCl$8Z29Bg)=>07|z$4LwP=P zO2t>(T$pIIYLZG+N`;w>CYTRz6Yxw_ra0+uX@o zqf*#Pjx!w<%4DE4Ah92T>O!j2;ta6VsBNV4*l?9x-YZ(eeN zB1Hur!ClA!L_Wo4f(o?MXsm)dD!hr?O*RAx$%|YS4pV)iK*_}I_3@kmd9ckz5hR;rAv(LcCaZJ3$5uML%oUbb!sl*Rq945+K$R6O zOJaEbATI>gqHd(r|LWPho#e>}v1zH`;ZIbnY>?j{FWvWzefsmnVh5OQ_sFq1PMmO+ z9UfmVH1gY3HGW}K8@-~&qQDTW5PRM-G)qmLpa3P0WVKEV@SlE|3HlNo>~%`y2YZ#FHxZbcF5jR_QmZUc-^(XgfH0L3K?6Q!S{cw>V?pwHqF_RT$c*j~ zAXU4Px-kC2QI=uXr!NkAgeqR0O~5IH7=PA1h-EA=F53=<>2^s|{;uALwSonK3PY2E zaOY$~c-3l}31$Ow+=lkxx!4|ziO~nn8I~7FpR1Q(dhUTjM>gU#mCgFNq|Gdp)lYYu zxcesrf&OI1da+*b%Uq5j)vs2)1lOyrY(|aafnEGDtNYib29H!ugj&Y23{W?VzEwB{ z`4dmFVMCH6Nc*%)9UbB|i5}#qRyKteh5wHFMmt+5A+t*+EvQVI#QuXuS@XPC2EMxw zJOK%2>>MeKV?nUScd!HOY_VKDA1?g$l&(a+zJ{^$V!1XEM6a`?G&a_vtcCsvNHIMH z8ULBx9&qn{o{LB)*XWu4TKAA+w$))e&P&38YtNS`#kZ)oC{onI!au{;9E15~v6>Un zc)Zw`cqVmkDLgqW*_3GJr`(`#4f^6hFx`ko6BjQ76&>Gw;J!0OHo|^|u>l9fJ;SCJ zWU=t|uGE{e8R~i|fI!dBBCjE|T5W<93i*m3Xh|qrvA)Cb4<2J()@yCWBe`Dal#03< z=2Rcu#>xi+rxq_n4pEaL;b%)$>F8YvKEt2jUZ4)4Lb*KxZ)iC&h_ zboil#{SLG6JqF+V`gQRU3CKCS+z?@YP%fV#Jq0vcx`A=xmag1^%?LjGA7p7$`>bPF;d#wq@reM z+Qcm%RjuTD6p$jWwz{Lr$Zm5*7fohNIji}C5(AVn!|#$Q!TO_~p+~@maA30#`5!pi zd_@tg$zWLCuAbh!TPzGr0p6m~&l9%@N~>rcX>F?N_6L=6STJ>gJjFIn_w%dArp`WC zcNeS+ZI^+cVzBlN;sLtgyxHRK7&wT-epy zGkl2XmJ_G#UeQRP8nIErs9Nmw#O=x%Q%yqL)b9;Ace>$N(p(7(`CH-Yj>cbRyTh?$ zN(npeGP()SJ~Dme^ob3IsE9^6SEQf`6X2@B)nie8k#)cGe}NtgLy|N=c_Ndo6^Y*p z_|*M94C=Rm$eT%I7*8JFFuJ*cUCr7hielJTy?pr&MBhH554#21h$1nzLc3XbU+)|* zR`O%YujgIb|Ad0!NZ)|+;Zj|&ItF&ht;ql=`7nXB`zAk_E-_THB5xvyTRxl0ytuWk-ZoQ+L)s z-P~Wmb~{|&|IQCTj1(Y<#MSF!Ksf$o{d2X&-!cSF0s+pOrZf;ln|Hb18Y!h?TudzD zr>6|chJ{ClA_N2lad%R``cdy~dVMMoMXC-p1&`xMJr6=k6@#4XCr=&?=)@}!a@bEq zS-21Nox+B2`Cf#ZsBgXg#gGLhAU{89nl&UmhO7aY7c2=OA>Vjbjp6vn_n)(4$kTsh zBR$+bj<`j7`Q6zOP`{od9Z(~=jl=q4n!MQ>>BAyTH#W;SOWy+p6u}_+@CLqbgvd)w*&ww~3Qz65&p%LSe++ zy-NGCbsfmcV37^9!0bgj^Qk`0ja->pcXL|qbN6w5zmzcatx!9C7sH)^uEnN*dpX@W z(Vj;&dXmH~hre&u_%&V^5Jb&Bdi{J>JLyc{Ap_!*Pa@N!DrVY%N8+-Ge^dT^=aPvA zA7c^jriL{DbFixB$Dz<}O9GJQs=@E;CR)zaHP7(1*k1_R8BOL8s0sLdyNQ+IVN&@S zCg7nr(gUa$g8x)t8t3uoR63wH@D=-B@>HeQS;QE$(rs}YI=MNoS3!;DxKOT9`2z6? zgCVvpg7woC!SR5GuxJnI$vw-EVn+9?51}NcQJb;FK$GWgi+Jlm%C|IMAr4&3Q|KDQ z$q~JfTQohK%FJHSP zkulAhN28g-Q<-xwpE87Wp~iTpl{a0*pidfb?pyFx_}bqz{+YH9n`VZIk4uqZXF^De;zo14!)tm41y zx-5pCh!>VYDSt(ebLYu?sCYa@>6{2I4)+sTenxLGew^+?MnbRdc98I1FpWNQV?oY_c(yEn$K|lFJcqFe z@t8C%qZ>_>L>I9^chLuTs{1jy;qXOR$>N-@oh%QT(|^N_4uEHxzVnIp z>K5|NqR2ZZXUQZd>n`GM!%c<|OkVVIpVv5{)uQLZD0W@yXKtd~WgXY;`ndaJT=I4& z>c`d=*odlbE0R}osyLhS0DZ)H(wmS~scek1sRw-^Y$U~-+ZLjszK@b;STluNNHt@@ zPKdmU-u39`S;Kj2%v?Kof5FPg<`;!WZgMjnm%lEUXI>2!@|>m#nosbu@Z6?<#!u>3 z%BPu5Yd|Ppb7rYf1K5EClAL+-N~TYS+r%20uWpwH#)o)U$$~e~=CD*=9{J}Pot)v2 z8M=$*<|-+O*YWbPH7Rm#gJ=pQ zJ?DFy$OH{A4P~WO=M)Yo!OhUvn6^eAF~?xXXg#>S{Q8AI-d zYDl8d=J*p5mRrN|P|vQpsLw9@6HvtXJ?O%ZeG?t5^Q)frz0`~2MQ_;Vt}?WgNzr(} z?rXtOAmo%)aC*Ku>xGDF^JlFQ%UTRP*hc34kKf}17Xs=eLV8@!SpMrISOkCs_T^D= zuK!wvMOiSaQY6iL@r2#%Uxm60imc)XG-Pk8g^p$9Y^a_y)Hi)|?pnzW%dAb0i?Yc* z=){v}rZrX@dlFPZLhVI7y{~fS^Du#TBv3IHH(ndGZ7J6nYO!B|yh1i+-?^%t2 zE3XCS+_i)19`M@FuX+;HWk3{+$TJ28DzC!Pm7n_ZXw%U@D#i-XJx2w$JZ-$-a42`I zONu%ZLxRvhGaxE^K9u&ybrj!j7HnT9%mCXZ2#dxoS`PgkHBhl*XJfWt87%KrP@Od} zpx^Zc7HWMVs}@q3I^{O9KNHBWN^p(m;3lSg@E&;+rdK9x><`PJwqTq-iG%GeG{t(MTY zH7AXbur?WCwOGlf_BAVwfSb3}n)}OqZvFmFvhLBAPD+hk<0b4=I^szkVeY7=R<|OD zhi?SR+Lg;T{a$?wMYWQ8y*dx3$ICWe!l_d4Xc zboR>_@hd~0*s`Q8<5tx97cvnt_L5#7)(l(vG3ByFZvAvJoEvUr$Lo4%A56z0>d%6Z z*GKt;>K!LqXRMDKcrUHBr$6xLzev;;RJcV)eSTHx{@8^w2`|C;T; zK~Szd)$N49e*j?F4^Y3H!s}AemE>Pdxf%q(k}tCv?fn-qe8SZ>loTgVCtgZs`BT-UtDxZNGn+E*|;6sgxno-rvFJ zz~{fZ*UeZcD+6atd4qA)$wn8tmzN=}>L{yHOSzs_qA_ga{%@BWU3@V7$oZwT=)d;r zrfHRyc+T98c7K{#Sh;3*suJknN_Fa@;W;?vNfnX9?}tPu`OI8Pq+t4kMSE9Af8b|m zH(a#Nk^OsB%_82=E-E~0YumdDu*G$ca`LucYb^Q5H5&tQEMK<|)D3X&1!Fp)D&ivJ z_6+ij^A2Sio;GHCaXRAd`YAly-sTY?7zQAY{&$*GBfog%-Lh-Mb?vs$h~}wryIU{})><0i)SUY}@ z#8+ulIvb-Opo7-W|8@#06Y(C@!uJ}7#P2M>o?ujJ6sx3BfLxknpC7|T%^>?NW@B%| zCv8u)AnXfb zM(<8o9yN3mduvbZPMfCIwj}LTYfpv#;jzR}eU7Ct4)y;|&k`0GzE^FGB>$!AZo8 zjEk%LtlrsgJnrYmV7zoUk~VgJ@X^5n^0>A!JIL{IKC{(WLE^(5eq%58W`SCgBnkGv zZvJ_&WJ8d)uGz3)ff|ZYeZOmT!C(V`C?7FwKd7yW0%Cz$Pc%(IoTMJxE_PqvV#yOMA_^PC$B zoub_^KRRbxRJnrTz5y{S!#A=@uC7Fqh^Q|mVLM6GxVU!qf-w@1!IHLklqQol_F}0wJD~}f$;%hVi-XLhkaBxOfyH5Ez^(r7(3bI9X#6_G@m^r&=1)jQt%$qIeABp zD{d_l(pg#nQlqB&3n~2Za-1~us?r?4OPcAk&(%=Un^e5pziSHuj0q;r44{X7Wi#|w z#1S|QkREaXm=cpgTag$lI@%Tm6Px+5}V?+1E7~Hp2XJ2FGAFc-5JnBgH$YyKawg@ zTqe3=L#P2OLGzs?d5+K{8F5*Sw$GS|ngO1IR}QFv?F zID_%o|2y7FvLsbFEU7R_f_L}cb|k`6^r>B|E*nDWsUHRy?G*`nVomTB%7`F7CnHgD z01R<@5Mg2-;*Xm|M}t*ZG2-MpPcP{nDfX$Fr!7WFPWvWecox&aWOdv z1F=<-$bZX`kdSxqPO=!PUYI2N#bC2tSONOI%LA>7nUV&#QyvPkFe;r$UGYu;kUzub zaQ{XvT!^wl6FPk_YV?FML3Ajdjh5@jG4pbiKhnw@DW0@+#E?;{xH4ti?CzGJJggxw z_vba4l&Yc`UCTp3u9Aa}MU$uBQ@|<6?DSu7z9TKQI`JBfXXFm~XENF?u$|~h0wn3v z3i_8T(Hlye=TL$Z6=+wYY4UWG2oxW)^VM*d`x7mR><@y8B%w&Ei`aoEF~4$ZG?7z} z`C?$j@Wy}~d)-2d6=H1L$+oBwWh=i&x%Wxm6Cz~E5m#m8k9atTS#eyJFYZO99)5ZA z0-lEC2!Ic+A#+qPNy2=@aKP_0aY88D4TEo^ppVuZ}sB#EC#^?r$_ zdS8hab;Bh!gH+dMceYYd!n?v!_lnU&#jAhFJ+J(6CL8P`VglzdARgW)7H~3BQlZ70 z4yFha6DuTJ=B-+4aXZ8a!+Y(*L$u4iA7iytEuUGfH@6dMsfMAQtrOE%Z`d4Et(k5D zu}F%H%e3Cv#H^@4_rL8D5ERZBPrd-Ge<5Es$qe*4OO&22RkxcVKF-F-PTc~zpV=eu zH{c1Z*Kbx?N>oJFTe1@rEf=|CGcvqsQWsY4vE*Hj`Mrk)Vx~RSy|FYRs@5vp9P?5fLRDTY^bm$y<7SY52p|rR#5Kq}AdHQ; zQ>+P$DhL4~`oseA(T!t7xow$DH^eyCj%^C+>ydCG$DWw;qEoHSegkKY)1uP?#td2t z`p0W#Od{ZZ5{I0KiAyVJYdfB=m5=Q1qE6MB#&UJ|27bdskRBxvj=+r2@%jK;ZM5n# z&Xg7~F~N(^7kG7i`aM8)@)ED2-7~|B@hjE<>Kisp0{O$)3$z5rq=*wB?9C^WB`(7h zGiBXO<-@VgY9584?J>fvH$}jz*=j-QpwK4Ze8T@DaE%W+GKN4wr zV*_^xcjNIpj;{-^XDDqt7e(0L?^t;y2aNEZudo`8WrLp|uTbS1>Xe=52zpWGH~?J} z)M*k_a}p;U*72Q^iA*jHP9=>D0|HnF;#_tyVk|*`=`b_S&)|tVmOqqQh51OuNYMt- zKLyJZ`>}#WlO%1AIYBF(vYX-b4`bGGc(L&zK6_hAE0X+e>}CF#S=IjdfV4YFK)fVU z;udzayt?b({w=lC>!s@5pr9}a)If}{Z}0QEosR3SG~(%j2p!O$91gm1Rhrx zcxKd|%9q+d?{-D$DS++Y=Y<~lF?V*j_KNwEt;Yo?NaB+G6pwLNqf`yy# z9ZVHSQMEk}p!o;t$W7{t9jtONA!A||&3yYjUdd%Zn@#vE`gjaEN~F!+{B)=%FWOr<9gOixt1V>Gh+IGYJ+^ zAw!DcFW1evxhm7r)Fg%PQ3GH`wuDZ?`PAPRW9JmU=jHs-?QJpgFGOfI2%7}T4@rv) z1JfsGur+?f%tUt!yUJV6m+l1*fYqVXm(T8tj*gPeAUxTqBeRE|QWWrE5R|FcLva&M zJpC?+@qYs0C01HZXEDOCT{=5RWq9vNK;35VVY>F59gs>SINyu5$NS^G%4YiV$zxbP z%x#aTOs7+>a%G237oU&!?Mi>YxxsBx`>o!dfz#0>GQHLD^i*5K@B)t&4b;i)kKs+A z%)zps{PM=?Ma1cJQ8c+21*L_r+xce33TiZ0oLI@Uyn!iR+#NG9cRi>W+;<1gDiJ)K ztp|$9Nb$u}66O7BX+M($h2Yh*@obgHtF&bLOLm86Z|Fw*g)pf7a`o1!-7_GSs)Qn; z_x>#}(U*Sv*K*ziE~AA%Acx@a{nyMm5VUn9T;O*CIR~>(YK;cmpO+^-vaX+-9-p}w z>4)x4llbDk?iV_-^RL@|)GwHNWgVGZh@c%AX^^bh)B{{s?qsUUx{Lk`56t`o-45HWLW&9)0dY#VA{^ic*ckYh(&pe{6+RUwcdW)uC&fgKxEN}(p+ ziNAFkz$`Dq{`A^SGlfXIv9ZT`7B&>SZAL$-5S?zFP^lVDHoyz@adSX0UX|@P*Oe?T z?~kGC&_kI>0uWO6Fd_2GD;o%RnDNFicGiAaw58VRKmj3CiA}TXUw$9lHCSpK62iBjTzlf0PpOr-@Uq3Eu=dSH?FC}E-!noFAI+c}iQ?OF&RLPV4nZ*pK zMnk3Tjg{lbD=64msz%lQMJ=wYgo8S(?JA@GWV1rH<>#00e7S7b>h5uJc)d$@Un2QH z$Yrxdc+pbY`T)a`r~r{fFNqu=49rg#d?FTwZ>+3KLg8se;!wQ4^_Q_BOgkF7y6sk~ z@f4i>9hOYukryo(Eyx;@6S1s zy@?{K$0`e7WrUP$JwFWq#VbZsGkR}FsCFgxVkxz6Ll&e9!8n}MA;jq?G`!WlAA$q? z=-ramsXi+h@x$~2Gan@IqV7$uZB&x^YxtC+Bt@0*8C-nX5e{k~MnhBy%y-+F-vsUp zVq2&bE0>qlBwl&rU|?jB;gtUMOabXfG9KFjKu^}v16=F*!I<$_)C{?ja^vCjXiIV( z&udeBa4re4vIV{Bn`#k)35Cw>Z>7^onT+6c-h-<#58~$OldcE=r6xS*3{O{FP*cCIotdN03#99 zw35?loWQZ#9pog0d}{^UNlZzWtD%>(%Mp|z{?O8w-V;)2K+&mM#2s{+<{1hsTT16! zCgvx(7_L}QXlQRldTZQV)+y369H^^lfzMD~?fm#Noid0G@)WekAgxwMnW2I)OZSa( zSV=5NjoT$ozM>S3I&*6;PY(J<%|1b1UfyqNtkGrdo9D2~I+FL~;R?gh15EZK?fjMB zmpOs>xB`xl>H1%AB3(FA7PY-JkTb+tsE>S>NVe{SOB zLm48V5%HC$BO}~koF3cYV>3MXAHUP|X_VelM>9wegps0>LHBx^J?~M�f(ZCU(EEgbjlLu*D|tX@!I=?Y{tG|-9d$6PtGzpK5C=p^AK)e2J| z17(Wxy#sI_g99Gb2Z?2hSMZyCdit*tB6-v%Xvi!qF+A$#qpyWbt2S0Z)RscV!yJ@o z?O!SN4`{DMf;4uXyN3;3;gqRDq?Ro&s{koqzX2Fb#xG`bk5VV|gpBdgGbKp`F;eY_J z#7b_9*bEnT+6mv2{~z++vMr7;de;o@?(PyicyNMyaCZ;x?!hfI9^BnMxLa@t?(Xh3 zmHhta%=rX!%{B9~p{u&9YWLo2J^NYr5em=Qwa_awHM`faYfga(T-D;+9eI^;tvhAlg% z)n=o0ZvVo;%SFCd*Wgi}e(Cn6IdlMuQ$VPjAQ#P&vM$%f^^b|?U;96nq?chwgYeHu zHW1wOYjaVEiNHIe+ugGBdA=idR0bM03YK zc{-goh%I5^*CP{6*T}NhKnDOe*-4Uo1q!S&5cV8dHR<*I0*sd$>iM0kV=G?IS>rk=CGo>3tm_Vec($nHS)WnM&n&j3DC;r#*Spcc;7-n6X{~upK)Q(GgEN$CZXj*{T8g%Ta)tO*JRd01{)FW5z^Kyf!S=`GO=N0Rw$Ktz zLh9DVeO*&2AY(WW+KEEOYrk~Oz0d_Vu-#%xj2v#QFQO%|DivxjHH`Ec8p+j!=CW9L z`9z6F2KPlDS&n728BK(83k-*~n3tBQVfNkNS4?jL2#yuHFMF0h=xfb z0XNm68PnNp8@n|Q-%}V%g_@QYmFrOE=RDzd7aj5Guk6}^X&+1b`9)@DWUv=`?gL8x zt73|)ySuw?7e|>!v5n!>lW}W0jX=0~U2pF;ay8{!OzO;4KnS-Cn9vUK?yVK;US3?p zi?U)UVWxrw|3hxi$oks^>+KDUdImmi`sWMFg*#`7*J*V};&Wk*e^-85A#=g0`V01<$w zWhRZl*%W7ig|kh5J1R0km9pe0RcU)%J{QBfpR@axwo3j`0W80Y;S@`!1 zc;Zxc+;=>|JQm%=za2;brH&U;8Y+%f#iNU6S$v>{h2wZ7d{)PLbg0m!Rx-3JE7Shs zQkfx_v)WulfwLl8%p^PhtHl}YBD@e}PduXL%1}YOv?Ip$4;dv%Te_B5CH`+xSaf;T z)cHxDh2yc<784hGveCjo#S9D-|8?)9oq$cCBOeaWBZ6nvH{GY_K73Pj5*DKSNDW1k zgzruNk#*RCELloRojz_QkZpLIE=aN`O(zuMrEL+_f2AIiJT5Ilbm%=XOvIdml=r+s z2BgpDWB+J|Udt}ik;=sgX#`Cu1#BP$z()&}kb!irJ`BOfQqC!t)&^hDwU;-KkR@VO zU8JS;T=P|;rA`J$+v+uYQxKc?R?Sp~)YrqINl|-PJp!IQr%2CYFXhcx3K^SHdgFJF+A%sygaGJek;Z^`c)alRBbDW!1Di0Mj2*xWfCZE;rV zuGIKI19^-=f!9+UZ`xzhyojN|AvfIRb2mqv zHWel^*jyamR|qcotp4a(3d-PvS=Dj`W7f+`B_dl!PtrUQiQ44+u8&FB>fs%TgWqwv z^*a4m=##@Goo&ljo=3wi>t`eO9t$q~QNxg79{=mPDFW&Kw$>~mVQ`U6d~=y0r_A=t zsAuhvPNswuq@#EB>WRo&HD&EWj+II90|PTKO8nG+qydclht7Lx{SKUm?PneeE*`8F zrn4q0IJ(|%)xU^Z^S(V355eWUPNal3y*QnJV^Gs}d_e*-61 zva|zfhq#iV_GU%tAOON1=?|O$&s(cJpVqyL{6kz>%5t{pFTmFztc~r_aC&-l z!#L`h02Os9OKztFx;wj=u0v(pA<6QnURwbRQ3MWR5p?{<%E%f8q}eg6sjvU@hyp(# z<(Zg~&H?Tk*UmDE(~RC{giCWTJvL$B8>wh*`OL)N5_V!orQy}%+g0*<*P^2(dBIk+ zn&+6k;$P(Mu79r12Zl)G#@;FF5L34(xh!=XkClNWEd7^2}V+rAcgcDFtPOT6v_!4jHZD0W}Se~u625`Tq$SLCD;*;c?i31Zg-#;i06 zxGcGE_gb--SDW=}Fu<8FawO6hilj3U)6N%Zm`CFp$6(fb2UR)0K3R@2d?t%-=|Wx>2E zQI5;;_FEhZw#K?H|8uU$V5RtVstlW!Ke^ZKS?sv3XNq*A=8Fi5pm3zP8ndGh(+;a1 zO}$>+sJ2pdb9Hq-BS-?Z8VcNU7!MY)^t)#c$0KjXfa0y7!rV+Kda`8g@bQtjJKvV^ zjProYH!7~A!Iy+Sm2_TTxEd~7^1z{Nkt|nO8#V0;hdvqOYW6R!zk10+hra)EzBQtZ zu@wx!sE?Vyy`asf%twE$F2lZ0rrrSs|_e@O)SE%D)?opf7op$VZ zXN$tm4f=VnD_%K z9|lUFiN9Jel|waf5P7+tXYUiwMI27F+1RZ626g#F#l-ZOE#2J@+*;A`(>AkuuC2!2 zw5VE~KVC017}LM%DcP@w)m&U&_HS>~OxcEq!p>(rJpm0V#ipfw3dmht9lE8LFv}fx zQCD2t^ygwPsz)_s=s!9MY9_k7RrwlNqoZ%Bb_N9o)>v+9Eydh(Vz=FN{1MkXaDHwa zJIbixkEI&KxRL+mdR*dar_=gmr**)p)DXJvuzZ(s&{$Wf_{H$ZRfhla66Z1q22_ikzqB6i2j~lThiN_e2lSWcY4$)+6YD0+{I-k zqPt$z&K^l$DKt^%-XojeYcGKZAi)>n<2rB<-V(4Ey1coxK3rz2XFxYw&68X7Qml5> zx=%54JDjjUBNCP#Vb=^2Kwm z{^ss!v|js@mDH1yDSnuyYMt?*V7{~Y`zQdOI>$$Gg?2)RaHSJQBs5wD8N;Sn*CmYb?d{l2jEUcp)Vy|- z+%42{>~IRsJCx#*RhY^endw{4|tRo z=>>^Doy(*g2&~^5J8G; zr6~yJZZgFDu)al26+gS|W4Ph5Ai>;q)6{3a7f^&T`erN$(vuoP=B?|{OHvMp07Fhx zk*K6z_xjDJ)%g}x+~vzL-5~NnFjYZV7`^lskKb98QU?B?K^qfUE?XTDTz&P0{^@5k zxCjUgXqiM==TMY$iHi!i;qQH;PJ=%Z`Uj;!@{e4k!l<;Ckd0NmF$8d%VZ(gULkaw0 z@-wG-eW!GabJ%utSYC{AL!`o?XihLvsJ}KdtnqKMAeiHFW0hBO+11iRcd;6MST@}s zT6^kh5p=w34{=>D`%X3gkD~J$fM>>+P z?3b3Sw&_|w1*ObRx^WzCT>$`c+Uv8cmFDkEq~swKL|7%rhn`5~tArBKBWjX0-tR-= z2rt52pfJM?&ceb%+Ue;n3X90^LPsPpuK0%JyFb2Y-|$Q|7RbI`Qlg!%H^IclJw>0I zF$`CadVLjukIFs9zr6$-z*`M8;nu?pRncHx+M@7-G&|)ok+&8}V3H3Q> z6S*AoproP#1@=8srI;I1rKQqyE1T#-7z9pr8z)kwWd?qbkTcXWZ?+eOV@WSTuR{hT z(P>=hXiB3bnF36FbNLGpDA-5@9DpY@^cLb-4xdk}(K=GjM5fvP4H54<%%p+X^Pd^@ z=iO1BR^iS!5lO~)q0(ag*$?bzHQ{E<tgG4IL*0y4WzTJ7KXdXMH@*cXcdkaIykSxm7iy~=$W z`_ORT`Q~N`tQLj6EzYvmZz#Y`z|)M=>SiNWv6M4H5Ocf;bR};w0#Uz7~4rh z2ld$1ihjK5qF*XY{c~xwn=zELb|fU8U?RdTTa3gbkX(x9#%072sTH1gQU?tOB%3~D zTqfRY(Cm?f7+MP-3DbSL%=xhau1wxN+lA zJ>djh4b9GRal_uOl;*XFZD-;OjfS0_ohY@6rP3;QM!)Ev4c&B{YTfm=tC<~Jy{bw~ zj1j}=C@^YpHJ4d79E6@La}q>H10#M5UZrx&$n3a-#R2ilXJ0@FW&K);Ud>1=jf#0B z!mFN0vonsxHrAsCJ*`r^K$E$uj;%zn*KqUM@u+fiW5?+eR=KnWQG(iIT3!Mv6Kh^0 zR%Y7jqgd6@xzhQsnk`6qg94=^wGma?_pOOJ-W%CoQUz+KF-IEUQRIo(%L&aSs*-dj zL2q;Pd*OXqu1?+;o0mYekn?&~5b2q=NQ4pxE1aEz*6hfMou-~4af7rpKP=ot#lw|j zBChPqdMT`>1l^+`(LL7-)!pzqBtoCo7PPXlwHNh!*7_;0pu!ZwCk{0YJWG1bS-xku zJ-NAF9^X)Y^HX=gDg6$#ERj=wFu~y$w6G#BnXn`1%Tv?1Rvh+E_BEHwzUWq6)=#b$^2Jvu=zWh4RtX(Y$;>Bo4)NkaDv z{4W!Rx0qg7!IPxJ(+Xy0*oP-}29N80#omA3>WrVwSa(peU-w%l?a0&@QI(XH6Wh&( zCehqMDtekXzn#n#N;xmDaVgYbm>y0gOry+=xxK9%c;{YW)Zl*xWX|R(^X5`?naoxJH5pfOuqhdp-n6$il7|s zUwFZG#ogd+EWGdXG110cmbvo#fZG^gqxfP=>~r$*Np1#yZg$U91_#e9&Pq626m6k( zm)z3X-?!K5a9NSutMPi~@El%!TztDdbf1Ee3PZkA6*XIEV~wQMlsPyc3>aRD+rD6YMN zeJ(Q21gqZ_Ch0=sTdj)$T!1d`E@5Z&%QM~F6HgqS*us2 zq);A76OL&p2+b`Cf5R7;crO;6x%3zhL&2L^{FQSwOcSQV2$`G*moKe7wQbzI$lr+5>G|He@ap7nuCDIoPN=hN_1 zitZ6ON@FmQhAdL8);>Hr+V~*EFMdFtGXt?wR$_K~uYWq~Qtt|u_-XrjXr+ImSVB{*XW;YW zr&vZSV$$s`2&##&aI7Wzkvgz^yO(P?`qRXZ%s4_bcapzHZQ>?_+s z;#1Io`>GO6f_zK5Wc04?a6DhrN@7k1USM>ZH;YV--1q47@Omz^l&73krA1U~;$P`>^|rTs*{+5P0{PFTWO`vWhd*qRzqcHy(u-d0jq91d+RQiOWo zcXA9}&NZ`SRlQ-jdNF1x3R%KZzPK>f7W!$*FF7!O?1V=R>XH2;CBO#IY}3!AJkm(r z{z;Iq;BKT}jYNBFn|rg)LMl3EOxTnZYeJ(PS9j|`w9d~__H8`0g3R~hCOKD*e2elb zdJ)E!Aq5z^L@HS$6E0VqA38~yXYI7{RPi4$Ell<~9sphV-B>voB8_CX7!J zY)ir&Rz>TFpKh*UG&Y*4?r?QV9j#5~_t`Y#ft&m92n$$XcWD8&1aWN%<9E787iY?h z5p@wi$eBI=jS-MA^FAvT{S(_vF~Qj6s|URL*3|aC=tS8nrL^z?OXR=P7HhN$!h!>)ulZ^=97lNE&ap&}WHk%&kHtC9ZiH<2 zd{Y0c)IzA$7~qF^9yoeq? z%RHD7l1i2|f-*h7QGAt;*)&1w|M^u(VKrifl&&g}z!8c^rXAY+zgXqzgNF)LRq(lOF^9iO?j&A`4DO#t4=g2Cuotw_6y4#6&^j+96k&3&C*Lo$mxr zh0IK{Xv6gX-KejUkm!JR3>u-&(^SMC~@Z9B9bi#t- z`$+Nk5Fs$wYD$o-wmlBjMYAo!aK7FvG)qej7J-RIvRMhwsE$Osg)*fbv;NZS=JM0BXdhT;u^K$BaP=6VP*5{06KoAz z3>2ylbu{1JHvSVG~23mqU=!9Ok)%>4Vq0y03VTQ>a2@t*;n46w(S{HW69 zVf=S9PXTLv4N;O({C_9@f90X}l|R~(9z(Rhzc8)Wo4rKW50bwwP+( ztbmx-YfWWjJ$6lR&e!!D+)kGo9xn1MxgB>TQ!P3h9yDjAhACljNjFeYaTBcUnUe$k zRbekk_m1}!T)212XmH>zxezGhK}-{rwGO?W+}t}hezvwup7)2^q^Swc&lNtt9m&*9 zf&QA1OO5BHjZ0W_Nt!8?HPyhla-<2jXGE5k{#e`r91oK8MOUn#G1{yT{36w~zO;`s zD+shaj{@o&j1aNH=~=;l@{DRg?M>Ie>|LeE)Tw&NWfaVryK^QN?y}8`J6RSKwEaN_ zCeUD@)p$OOh17orc*p==;6b%OpodpO1A{UR9ykH(|D51|zg)6ls;EzxUm`OX&Sv>r z=K#$(tW64jk^zN#y*{LxsSTz2b$WM^HxlXi5n|9mOE@Qg+3xfj;V3H~0ek>B|EG|M zJ}f*i*Gtp$^71I>(jb@}9T%JML2SYL{L#8dW-s#!%{p;3cnny5?X4PB{3Sa^-xV0R zKp{gk)cf@wa%!MI*gsD-CivDHy&&ZO_^SctVku)<7j5FdY3{4T-x1h+uII`0Zv@Ky z9f5FBO5y*FKpbH3Mm(Pt?Eg1-A%VgB|II@&vB2Ei++HAwO#e|tI;+EKYu7$xQX(c@ zJ_$Zh&wKuYqTTcsgfSRv_~&=3 z#Y%HchkG(pMh)=8ueV#*{z7qFRro2rTWWuX&6p)o2R8bs9HoWQSs z0a~K20l*7E$@sn#QAk8}#Srn@Urdrk5pW?#Vl#cu%DN<}>+I~522deXg>qRT3kw?D zyu90kB)(mB6MR7r&D`Yi70vf+h8k@aWDj_R-ag(_rK!ImS+qZ{_c<;-AOcyO-TuK$VFHYap9se%drv_t#}U@W5ni4 zIc8ln7NBN}H33=Im9<QkUdK(`lLrv}veiz1 zSxn~#cQ7^1I3IcA5K4|nONPx4jt08`H%KEiORQks_gD@jPzro>W*^&xK-sKbyuN2R z^r?6X-(x@Tk$oO>WtQI?9MCGUzpp5B0Psz!W&+PW+kB$+VpD>|aA?xd{pe5%m*WUb zrA!T}s)L70fq!jn?O?J7L(kiRbs)wE)%rUj`vGxc5kQzKR;{DPW`5J1d+$MbADD@! zj>Czf4?)CBFCuO!S%77MtEL067xrS14h}W2D4pNU#GWuz>*%LGA^Hk;HFg*y*jd`FDtpuHgneTNU0MO6Vu6&e8f?s z0G29_?@;QKF&%fxN=nt1*JeI@s%2{QK2EKTHn$Gmn?mnn=^PlkJZ=NPuP+9`HXY9_ zE9w1FJYV?WK@zTt(sAx5D(8j3+(Pl`d}8V zYIzGtg8I6IdlLiRdxF_4rfM^;aE;;esy9Exp+>3E|jlOTX>@E zbbz#t$tIB}YEBDhJWJf$dsFIgp(bd=x95nlG2Gqj+358b7Ih}Wl$e;1inf?oCF(A6 zoCAM~PT(SEPOy9iPl@0c&NhM&ecx9s0IP(T>GkGO;Am9EAP3_1KBxvs{^YQV0NB5! z6m)d(7&Nj%vBay1L$(^pCC5`JeEvws^JoDp6)IX)?Qc8-{7)&K@|_*8vRLsC@F5@k zhU?(1dVlS@-|k9z#JE_!krRZ|$4SwPN~{Zg`$l)fWsxosLCo$BspsoLS2FmMey1LV zNMt$Uqa+IPf?N!3;$+b|>Fy`B9{@mjwbG+>=QGs_X&1EH9k#K{`KHM8)lMA2F%aC; z5ch&xrCj#o+^8nRw5-)tQy}Z3X&+(VFulc=0I3G+B zPYB+pM`#P_=_NDkQ%mjI?1H~^6)Qg6CTD#z?1`srSjkLJEc!yhRV1JBB@&zQqC1qt zU^>6(lYQ5Rm*-%=lJtE$DM9fFDHSLYq3b`aW(t$g+kbYZ`Z-~!v0c%8U&8li`-Cz{ z7jl%{8nhq?mHf*Mm*#Ai&sI8XTy-Qw~DEH^Lj(^0?_x5HL5=!@AzCRC>SRn~i(4(;;5 zCWSg!38tpWVY7rjUnxe*T>26wy4qaWR;;nK z;Zh51^k(kyy-MAI@ZAsM;F=@D0?jl1-dAyMS{3GT_VG?n9yy#BXm3u|NK~?j4er zuqQoyo+sNT^>&eNYrZ8yKqE98CVCr-!sOI9<38(B9ocPO>~3$b^Jfe_pAJ)$7!zYrUU zxY3`t=o|hMZO;CdPv2K*@X-E?&VBxl=l>@Tt@f;_NI58WL;*QhZ;?$VG`b}MSLg;= zso(l7o+(1q2m`kO{a->s#{{k>Lm434jfKO1*z)g^0zZh7$C3Q6?ue>?7kQ{G%zvj#g~-sV z;o`h+cawp2|F-1!z^>h}sIAt3=|-XxJYj9)A=c(kyAH4vDJ ztLE3&{nmXJ>b!7%xg3|q*3F5Di&LwiIS_H%cd4mL^Zy%w32>0Wss->%oEvxJ*F2AH zbXr~I^mpg$%nfIY6%Pcn1v?(iVj}zimZs0Ic4l-8jLQp(?UPsB68*n2R)A7ySIVb6 z)ii7djj-x!me$6JLUgIOBqrTovSLuskVo+;I@pX_#b@hjJXp*f54peIHeT=lM*DoP zH_A_K7}T;|wFQ3UCs$M_ODzx+d~9&?^72p4%*@PfuXoxDwH_Gq8GHesojQa80_V*k z5GR$6&+B{1kql0}18K)3fFdc^Y57vp;rnJu3_wzICya;t>6tsNr8pCdSUeajRaoHx z4XQ-A>uZDEa6>zREp7BdnrJgKXQCuxw}X>Qf66CgmDwjGf*UEo+$}088TKnSEJ{da z774h1qPV!Yqp+9=HOqLKPFQMx3W*qhRkL=l+kw{ewjkifZANpgeXpxy^L;R6db;@+ zpKo?`2jMzT@Z)kDd;o&X2nvZwF-NHJI6P1e|;?i05EH;?rpIIEhb4JVblkN z=LbumKz}LQiBp$eVztIv4gO}e^ZL^l+AK`f7(Xy{6p-0 zw^(#A)08!3?r1_(`VOQE->E;<%+9m}W}$ck@mRQW=Qcj^EEX!lJfF@)+OSOSz#m~o+AaOeg}VKMMM7t@sske*0+krqGE{^ zs$!%}{z)`+PH{BpHF$N*HUk#3sLv2NV5>21 zVa}?|^Y*A%zmwP^utf@exUB%*$?bk}+wowM%w-jzBKiz3VVwHQX`)M?T;W|P_w_cw50&1AFx3oLbo zah=y~wvUryXtJBZE6Mb_(}av?+-32wtoik=KO#|@qO zPUa((VH9&6CrEWnV^?lzz+4Vg z;2HPyg{KH#^W#PpW&R8UQlH;hogb9-d{kA@h=`)&C?pIAq9(#+^=Mcf`uihsh8gd% z7~Wp*Ut&Kdd6E4v3eY4bhXrszDcs;efimS8{WcU1u&{_Dj5TXo7Y2IY3IP7qw-`9D zKs9cWf0L;SCFDuP+PD9r3q$RDeXlmsBvFlN7Mv7=@1noSC@q1v zeMLi?BuqY8b!)|6K3#SHZyp5`&ffXE_CLCjGX84|oGlAvf=Y$@S?qfM=2?&gph)hl z2h{&1azF;KMgs(wR9~NI_kXK`^xvog1gMypCY(*Jt-Sag-~P&6y;D;+)km9~0l@!) z2GN|lhlk++a%oUqU0sWH0ri-3tc)DS>H3eVju-S#Q;JX9fM1c@^#*qD{f-_mL!}gf zm8n^$!^qhWLfPsA2erc&o;kQb9^8i#pVADy$|GjYCdmHIuoi~)k?*Yth z)n%Rt#Vj!OdThQKoj8POxoLA~Q{>wTuEYDQ$5jmBCvZT0(tYK(TW`clX431j>wE*J zrt9qj0l@N!fqsPj8uNRm^v^!<>%P1aXbFY_y~haq6NiILy$;r6M{^;Ivo7;Yx+-gD ztG`y`%;ZEDvw5vNHNt9T-X0)IN=hOyf4Q7S02x#s0oYpI{%W)PA*j?6n7=8h9uBSA z-#eZ!hG|HQe=pQ%G}*QbtYPEe;iYPb3GP6^ATQ+dl!yQ$m_f+DAYv?yv(&-={jOBK zN_<*e_y@)q5P?eeR9B{J|bx>J%;~dt(od(>`C*v%pp8wqnz(Rf+;v z^~v)Fq8J@LFsJuyZhwa90picZgaW_c`C7YHJ&!)OAK8RY#!|!Otg^yxc-@>_4H{gT zWvQCuU@_-eF~LEU0W-{s?!45k@pZ7ZDAm4EZz|#m!l;mm4!)z4lg*q&a*0PQMLvbr33 zX*64)#Snjvnlc{PSpK|jw9+iW^12-xmyr>TJ(fm~-r~4R$K!kuR$k7aI=Wju>V7(> z{>lASev|+v`B_ADG5dE;aeyU8Jxf(z6*fdtZSAZh$ScLFX3`74aR(@J_=CLPK?(Vr0oFzc!qZKd<+f>D*mE`K2(SL$Sd zid_19Unt)n$=p#~Dd_(0W4%&MtRU!tD>*g+4QqNS{#ra;$LzLuFWs=5P&!6)gSo*v7KcQio46bEqos(j0$fF#D^cBaE0Zr=k4->Qdzrlz`I za^4;aOz7LOg6}H#FA7>(_)Kw0L4qk9yhA_8f>fV?Fsqv~H+U*OpEjS};O5W-KwsPg|ROT`JJqnD^xjZq=8jIJgigDCa2cA5jg181TBG4NxmO zx}QQizHpbXR}1Uw_J$zMC4)(bii(rc9YF)0c7XNYp&n zYrj0>Juk+2A0V(RC7D1G{4JKX)?VYf%0tNQjv92<0s|qnLd0r98#9TpUzMU(p|!cv z;^dJzk;@P{)9QMzb{PbXM6PRPikN=|WbeXVJdpC4JSBAQcd6zqX(w1(58eZa7dc2* zoy?lx0#~7W?PqF=OY1gYzJNbW1~xTu0rcAlv$3Cshqb1X3JU5{Znp;kJ3A%?dgd-3 zb%kU0Cl8-qZs*rU<=-v(uhTimhb1RI`2B9QSPVR&$Fcma6>B>BHofn2J=QdP8~Ogm zrB!c{6WcTwtQ+>d;75tbOJahdjYw+Pn$B@x3-4CKgSm~*-bmdPLxx^hQF0MFm1far zAd(%b@|rzL0g919@t~Th^tRSbt83~N)^|(Z&GoY|s{b%@jXI7d7 zQGaRK@87cJ8gETcVQu!C;KY6>p8#|K+LKq7eWk?c&cYGaPk7=}7E-jwh;mA3Zdn_}ag~)Sw4~zT11Lm9l#caQ=9VvF2ReBlG z!Y|_DA({2u6CFN0%(c+B$7yurmq0XPKnBjhf3bc@VUo3CFGFy-33I-^6TXf6{;(}> zbvRwX)@b`GQPTK$?f{t>8y($F!7PJKz-cQgEe$UoLnzF-5ckCQLN;{vCMzT-iGR3S z#O%4|)AZ#3_EI;|<+}Gm7B;}(ifW`}bgMqm>XoCs&(!RGnOi$^@t{iBT`1ZE8j8UI zR~&vFMg8XNL3#=u9lt}3bmJE!w(3Z= zhZmYoUyTx%?5_9AtWQ3v{k`I=eT<}dShw+-S4wU*yQ-(*{GQO3E)&)cD=H^V>!bk? zKyYwyiR;cgzQI(Y?uCxj5-dfhQzaWaJG(*}!4wYUJLv+3=--eT zU|(8iZ9Gz(oa6>~zU#$YD)5!$gO9n6SeA<$dC_7>yuU#Vow_xyh2$ohCT~#7{j_L0 z`mGgAvOlJ5{J|^O5tatWC`rY3FWv-Dv@55h<%~?=LI!`lOpol!B&*^jX+%&gL`g1G zukrPHyM%}ux{nc=D%FVc7;>m@e_QJhi2ezw(&@|ZDaB%$HT-)-#m2#{^EHpx`QS&y zG0-WlBjY9aowRqQ!^bOey{U%prT26)C+KR=z^CZ%Ml~%SB#*O2mc(ga#fETN5NCGX zNE$zv$3>^xgU^VtxarXS1he3K6hJWT0NQ2^hfrZ41GH9Um;1vR34I7ZYh(gW*^;G2 zhfu*U9%o^VCJ6Cs0<~ppUgs=6cpc5>9h*nK`;{3UTc<9@7{`Ege~cNlx+fcay4+Zn zzn|%Y-F?%`Bg>pNr%FFmmG(NcPBSlB*8wrVHXe7S6&IB)0pp^m}d& zYW`sGX2Cd?uHlaai5%N4clh7%o0sBZ(LBo-v!q2Z=aHOVy;4abjs4IPk$^`w`tylp ze-4GuO$|}vs{8wO*1NJp$a{5#i9kup1F+|}UI-T{NDG6PX+B+IOhYwR?erViZB(gA ztux!2k{B!*jzYj@=v%BaAC|wauAQpA={ahQ6?j2a5O{=Kdlxd7G#~W$3b!a!NGm+z zQvgj>=ZH)u7Q(PxR`)76K&GKeRfXt5HFE6JVGLS}QKv8F{FE=~-%(natO=p`sdC*{ zV?RTT8C(&6EqHa&`($JtWUzh-Y+i32+jUF<+=XW6M^=Hi2aMg-Pd-oVyTJ{9@4m7r z+-PhT9fN>ml!ya!EC}!={6DfQhKo2mv*Rs`|4(YBu8(g`>h-v*eiNT`B`9+SBr#oY zusSV{Q1?G%UTJRJ#z=w)Gjlu5Gi~0sc7`Xdv-o1aixAbO@`+2lZAjrmQ^!@AjjMp5 zCn&rQ3x??kmcKOGt;ZD6@aMU`w|jp^yk8d#q!#pxhQr8Ic?5w#7QTy39&N3NS#@!U z_Y?f@fcEq!Err`5P}-;guj#g=h$f&al7br&5?YfZI9-*KFW|?%uLe5%aG%avx=&qP z`+ehkV@+FYuM^A5GJz*Vd<|a#S zv--tIZI<2o$8~uP8bqgSLqhv0K_o2EEGaq*0!Fk2)sV*Wb7C)Io?q0#Ww}ym{8kHg zGpW#$*ndM&EW!d$q@A+h1H^ao@@Lv)wN!YXPIHxVGFj0(`d#&g)>;f zUv)*_?=_mW7^lOz|Dz#C+>NEhqe136{b+ihA(NaJe+ThhnsP3`2w=_pcgP-EwN?X1 zcFt~43#YlL6=<)b9~3U%UclC6bqJNQs>|T#D(oWl>)(3=g{Dxee@c3tt0)zXy_egOw@5Pp;fuHoo)beC^CW7WvBmG2sADxJ*@h{Y*NK zYFn<#r++$_ima1t4a6E+35ei<@&Ebyx-$QM(HbdD>gB;{Sc38EWg+*ioY8y=4@wLh z)?78bvUY4j$?p4BC42U1Zo?h@OC@@G^M5%$WWbh4*EjO;C>aQ5_34tm3@uhuh!#^A zL#1Knj2#-3ie;QPUlQhb%t$%&u?emDKkM@&z{>aEwLG8!x6+-(&qo#hj{pi+ETn?#*x1SJdVmx~gX2r{nMfo&L7EU#lV|E! zNs=b$%Ae9=FD%H-72+{fBuHPv3oXb>)nCe|b-|Iq(-R2Mi-Z);?jhYc-Hy!fgN%GW z-rJLZ4lyaiav8&-vu@YY1za;j`A`@#qv-N{NuJLSq+|l)Ove~na+HWG!(o{C8~k%Z zRbZ*_<#GbETblTWd9C9^zFq<1YUVYRJw*FdKlk}JKt+1g@i83E4bCN}!4fzvflUMT zSgDA9pD?3&p38!=jObZgtYkS5f2trTd!9{`vUm{R`%*X)V+TShPi$sEq0#Bjd1ksh zX?DPIB{+IpF__8o6kgSL(%|;eg!T;gqm^|a&m`~m!K~Z*m;csb@O)1np??PySt78t zt<6l~`yA6_X_8^+q%+HO9Kdl;Md+xVQ_@5c7LR)L6Xt)xCsYm4~Ti-2#9B zP`XJy2(%$5)Ij&BjC3MKH)NgOOR?9a#-jcSdQsp zLJ!Z2;lan+fYy=jju~E)>h9&?b)xM7@exkbVu}Al#S`>qMSw@s8>6#%KJcTKMm2ao zSP0D&4GNL7H+W}C=k>w|sP{#}b<92<&ql*rS?3m(6-zFcfThR&_8a$`&K3RX>vrHB z(J!N|nfOm$d(KN32c=7v)!Cj_f67~R#*{yNz^{-J6;=rnzbOwvN-NdHaNLyxtyPdy12Sh3*w%CAFgk@ysz!wnCn6k35{9(`Z*sS9sSzYWN_K+THMV_jadZI zreF+R1*mbE^y^~L`pEYI*fUh0*!WedQX#MX{eCdSg&s52aV7dV&SJiUO+4BIu}Ge< z|2)z+cP?b63wLGJ>cg~>aYa(F&7!>gcsG$Cxt>Cf{`DKA}~(3>i86T~+@$MoWQ`A$kYpUam3-g`_%4l9)=0 zoRgKv&(A|jKiZ87r}-BzIR#^@5P6BxJVvbVq=pnmP-aO*RS=AufCID*sDPcQ7G{`Q;o1`R zem}*Ll;RZd&G_t-p8qqlbOEUJ{955Dr&6Vez-2cuIUdHV!Y4x{OIajO)LRhQ#DUx! z65H)TzL4eH*8X}Pz8yNZ=i&2&RCsqHuA@_K7$4!CkxU$^6J5EnLget^Zj~g~fJ=ea z`+v1})n8FC;hGWwrDOF&Bt$x-ySuv=5D=tgX%;Ez?gl|5rKNKL3F!veC1jU|1?gP4 z>v#W$`@=oo{5tc@ob$XhbKd8iH$F`$3JP0!Wue8BH)V5yF?E*w(-P8ZNjZT`s`q;@ zw8v=zAJ$g0rImDS1MZmKy&}mt2n!1!{7IJdx{!{P#>7Bd$B+_`(L3F%qm=k{P534N z4w@qN8IUnqAxTrX5ReHYNm9U431(!Z$7iIcHrZZTtSIehC+}#GmbjMEnmxDZ0(X)!(G(|sU4N$TMzqF_`6)atEP)0_V7)9Yf~q% z%`F|+QHr!bfwcRr;j_V;tF9$x*lK)FZ)hT4rgt36h{iXyg>R^Pu!Ag7En}%7*P24 zKsAuB4M`5Hw=H?+R6(HR<+D@bxttB5T5G0Nsd&Mf2aC^Zj%E1-waVc0L+-F*!h90; zxHWdp095fW8kE4ome9uH!xd-O$qC;%6N9r_7Xf}pE0t@>ZCbF&g_q$$h)zFTMDKcMJ6rGk4>>zg|D~^zAc<<`FA6g&z7B9C^%T&hmYfGS}7oZnb>5OyD@1&V|s$QR%msfcR(qwFR zI_o+cz3-ag8s#~Ib$B}$#R3}S4+aeem5qb19-|hjEWR8$8gO$7wk`OkdvdCF)hP3a z`prl#Y%#6U!y_-DIB=&-<>k282<#KYO zP4nskL56HIkAz%(z}LOlPugg!XkQuqJFOL^BSGju2nE-H{_(Oyyv`B|6vJIy)IrpA;I{=&ta$9q?t`~CS+xQ$68STVu@ z(wu0+uw43=2AEM?vW39s283zC53cG-Nx#T$(vW+P?TIN(dMwLWI-yLpO!bG_6Yt1` zd%h1KL=|%J8a6 z!wi;HXDju-nOa_%A6xkM*^SoH+ydM3@N4q={k4>-0JQFvwjPu?s6P zJ`x(%*Qar`E&jbwY&;NB=F8hAYU;n<3{OdZ$7$Tzx6V=9bo80td#MCp20{06IfH|M z7Vhtb;T#fNgflZy_LJInEh@y&sK+ZXscCygg!_9Fx^4pN2RKxQQy%Ijmh3Wc+d5|l zbm6$y@Gh|9w1>1F2Q{5|K$~qPyht2c4oXzWpIe!({}%4Zb+vB?HGBDS#WF|i0pe>;DzhAWN>4KRR$RLGW8SHhH^J`+w`s4;o1XC4rMQUI>2WRIVv z`Wn5P9c*Io|JI_8xh0I1pNF&1ZCX(5#IZOslLpwjaLD3KcHk!Yy3y5=MQ#vA9A@ebTjK1s zLf}d7Y?Z;G$k?afvv%?YOJF?g3gdnA`tatSRdvFqMn1i@-z8VRy8wvE~XFvjpFniu>jF*lj_2 zrmqU3u7QCsk#Z>A3$cfOdALp4?#z5k!9viLiVIY=V1u zv~&5~b%8eDdf%%^X39(5|A|)~pkcLPyu(24C(AY3j*3>Uz7VUA%Qi;N87Qvu7&iYw z@o{p76CEU$Vm~fYc>0s#)r?omw^GJ}FWi|QSuA@#{_c(3RE?lLxFnFIUI=*f zu#|eJ^!d`BU^qE+N{$W5`gxS*CR%691nB7Yo!4EIO17RfH(0TqJKyZz8}w?<58cmf zP^&19kjko{ogM#?KBD@9Tm)5L9vYmCUspLa$t~R-HzWj9ib>S+U;_j(l(b(8`Nl!x zD)V7dblRP>DVtN@UhS@zLOE=F6RaeH%hu=-S)hVNpp(>)-OmfFi9{% zZkP-4RYGxL`)kf|cvy58Safgsu}H|ij^iC*qUSLO@T8R~a^GY<`8%ih3WJ^p8rSpk zFIGDaBTT*?KeX*X(0@eycO(7}p2pW)Sb4J8^UAsSk z*a%X_B1w_jXtcPEJ^ACTN-W+OX5q^ky>;hDEb#z$$Psw|_|cVDLV1EUK`N5hlfLf+uCKlL$0+lqg0d1`+z&cy zCB8)LR+I(tEbe>!SpAiG%DeMBgu9|uxJ6QHbIO>H)=Uc4}B;9}ZLuy63a zfqZAE>9lrn7i2+VPl6@`w-;fhdpXL{1HpQ``TD$Sg-UMD!f3D;u_KO(fGiDntirP*Vanvl92IBx4M@aut6> zJf!>PDaMQM)H7?qzk8osvb;jH9yncV*&Jn1On)EP;Or;Y;3FQSX1aYLEJly(j=3vhKXZ<*;YqeepdgQmg;M z&Tdc-G03@b?z#6up@u;--3=_}vUUS;k1N%{-tA>o8!j_>v%=x)eZ+&#JSCpF@#5Z5 zRG`FhRKk$e6>}9H`GnEoz{{Oi{PT;)fCiExU#w%Y(+8C}ncL#`W3e_gzMxwr6@!3? zsAX?aB}!(St7W!(hq$7+I0=_9@RMoNoKoG=1{hU$YU zwy%m1`l0w(bIlUlfw{6t#vD-;yXl!LV|qH3)5_S|eEdq0A4_=H85Mk!>(>3F-N_uK zr-)Rj_Ysgq!SUym$jspU+PFaC`?g;A5WMZ;wyY$owY_~hfcT?oh|J2a+p>|qg$2VS zUb@030c{(SY^`rfFcVQ&0 zxtxf|u;<;5sVJjsyUDzGy%~*jS)bN4ngX@q?TgW_=;)4jB#;mN?`~`#td|yMS~#n( z?^#9tnG_dtTkyVOhT6@Xl%($J-%%|sRGHv;?UC71ku4dYYgzYco*d(s9s9oYRsaBpr`@J6F5?5w1{-b9&WG^%`ic;zb(YP;cB zm(I^JhNLz6GDy9{hqG~g(W3v$V4Q!fx3eYPwfjRZScA^>VF$&cswFj|;R(M2gMn4w z!DpRjRnYE?MIq%Vw$rLz)6L$i$2fV6!EZZKd!|+-F}-cNAUlRPcMYI4mSvTITM2oO zEo4b#DMy1z$S>O7_Vg9Yk8<;>8OFPHW|an&pH0cl+uTi-pVi`y=;_&P&id-ky!0sW zfDbN}j$?Rq2~qu8EG$Be!o!D1fyVG> zYkzP)4d@*iue&bQ9Ui5nxNAerbS&7g*T-|3imX~B)Z&-L`}N%M@x79IT}ETwFaI=G zjUa=AYXn{H@AGZdd+e03h3eeK@&OtL7Km1y$IvGyd=B>>OhURYT9e0lot^y8x2F?( zxji$|D6(=z?E35DIdkt-$PaI$G-Jm2ybD0XqbenHX~w2E1B@O)E}=%OEOb*-IKQEe z_I*M2yj;cO%6c9s-Azp>JkG2|ud#fX9^CItP+71Z!j0 zyP~fUSqu8yYajWx(?hPxpvQgwacHOA!XW?7pIy{Vbi5MRdeT@)D_xWYe#6F}xri<@9rR7ze6|J&t;-OO-Ojx=N!Ml1q zU7*;-gpT)lr+Sp{)5T=?Ldv8>uRtsQYYO}IjG)DJTWdSn`xKDK&AuZku}bOf;e$LQ zQCzaD#QmX9`9tvYG)}mfsg=K|&vTV8Ur<%@rm(~hB;}l#t<8>VL_;Q|zkR5#RD|7s zI`7T<7cCDtl;4rKR34-Mb#9%^o}zlypxL?G8LE5^#fzFWj6 ziUrAmdEKVhESsC3TlS0=37GK@mQY+P?)!TEGM<{;%};-yUcFDb7!k&W9c<3$am8Lr z1(F?Aq7%pT=6(VyyzIm9vW}X023CJTN7VPnO!U_R_;Oui!3nrTV0FQUT*ui#qed>l zwJ|^B&oM8HLtWDESh-kukI)cco=U`?3*b126*GN z7aKsQZG=UNdc|sI$#12gYm_3q8iBE9&-tNQkVan4Dg*_1I15l47>&B)kzMa;x^Lf8 zlfSxA&wlIGp*xN;Xwh-w#}ih<;33=g{UU@HjDZ&%oPbsUk8uqCl~kD9Xvvg^2w(lH zi!iW6(Q1l@L|+C2M;Zes5A7v_5&+;(GzbH42fff!TKmyookik?-lJc{Vu$cw5mty^ zIE^Vo`4=SJBZA)J|L0*KEUqH6((JiLj`)wLEtHkBJBU=1^LYJXIfvgt7M(1j zucJecc0()s!_k1{B%GL&(P0I==+5btB%GRQ1vOIAZ89)?3xPm8EVf(oc(tn9e<_zX zz*4xT+}N{VD#$SC!u<*z5Ao8gfTmblE5MORmj<$sEc?1YXj>Kj6Y{B(mz9x=>*wos zDvzO;TtZq}lTIM6JnX-=tHDP{2FHF2+sMf~#FdJ6u!S?lP~uf7R=E{op~25EjTRL* z_Dt!kYTO|#c958@_O|w%jG&xgI_VNPf1e%tX2fHx%{JcVEJPedy#8bPs6K?3vI~fX PfgUOfn({TWZ$JGH?yw(9 literal 0 HcmV?d00001 From 2d8cb6d7171badcbee7d2a8a900d644498ef7295 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 14 Aug 2025 17:03:00 +0200 Subject: [PATCH 265/362] docs: openrpc onclientrequest (#506) --- .../integration-test/client-request.test.ts | 33 ++--- .../bitcoin-wallet-snap/openrpc.json | 124 ++++++++++++++++++ .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/RpcHandler.test.ts | 16 +-- .../src/handlers/RpcHandler.ts | 29 ++-- 5 files changed, 166 insertions(+), 38 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/openrpc.json diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts index 44ad227a..082c9687 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts @@ -48,17 +48,20 @@ describe('OnClientRequestHandler', () => { it('fills inputs, signs and sends an output-only PSBT', async () => { const response = await snap.onClientRequest({ - method: 'fillAndSendPsbt', + method: 'signAndSendTransaction', params: { - account: account.id, - psbt: 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgu3FEiFNy9ZR/zSpTo9nHREjrSoAAAAAAAAAAAA=', + accountId: account.id, + transaction: + 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgu3FEiFNy9ZR/zSpTo9nHREjrSoAAAAAAAAAAAA=', }, }); expect(response).toRespondWith({ - txid: expect.any(String), + transactionId: expect.any(String), }); - const { txid } = (response.response as { result: { txid: string } }).result; + const { transactionId } = ( + response.response as { result: { transactionId: string } } + ).result; /* eslint-disable @typescript-eslint/naming-convention */ expect(response).toTrackEvent({ @@ -70,7 +73,7 @@ describe('OnClientRequestHandler', () => { chain_id: BtcScope.Regtest, message: 'Snap transaction submitted', origin: ORIGIN, - tx_id: txid, + tx_id: transactionId, }, }); /* eslint-enable @typescript-eslint/naming-convention */ @@ -94,7 +97,7 @@ describe('OnClientRequestHandler', () => { account_id: account.id, account_address: account.address, account_type: BtcAccountType.P2wpkh, - tx_id: txid, + tx_id: transactionId, }, }); /* eslint-enable @typescript-eslint/naming-convention */ @@ -102,10 +105,10 @@ describe('OnClientRequestHandler', () => { it('fails if incorrect PSBT', async () => { const response = await snap.onClientRequest({ - method: 'fillAndSendPsbt', + method: 'signAndSendTransaction', params: { - account: account.id, - psbt: 'notAPsbt', + accountId: account.id, + transaction: 'notAPsbt', }, }); @@ -113,9 +116,9 @@ describe('OnClientRequestHandler', () => { code: -32000, message: 'Invalid format: Invalid PSBT', data: { - account: account.id, + accountId: account.id, cause: null, - psbtBase64: 'notAPsbt', + transaction: 'notAPsbt', }, stack: expect.anything(), }); @@ -123,16 +126,16 @@ describe('OnClientRequestHandler', () => { it('fails if missing params', async () => { const response = await snap.onClientRequest({ - method: 'fillAndSendPsbt', + method: 'signAndSendTransaction', params: { - account: null, + accountId: null, }, }); expect(response).toRespondWithError({ code: -32000, message: - 'Invalid format: At path: account -- Expected a string, but received: null', + 'Invalid format: At path: accountId -- Expected a string, but received: null', stack: expect.anything(), }); }); diff --git a/merged-packages/bitcoin-wallet-snap/openrpc.json b/merged-packages/bitcoin-wallet-snap/openrpc.json new file mode 100644 index 00000000..1b431351 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/openrpc.json @@ -0,0 +1,124 @@ +{ + "openrpc": "1.2.6", + "info": { + "title": "Bitcoin Snap Client RPC API", + "description": "RPC methods exposed via the onClientRequest handler in the Bitcoin Wallet Snap.", + "version": "1.0.0" + }, + "methods": [ + { + "name": "startSendTransactionFlow", + "description": "Initiates the send transaction flow for the specified account, potentially displaying UI and returning the transaction ID if successful.", + "paramStructure": "by-name", + "params": [ + { + "name": "account", + "description": "The account ID to use for the transaction.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scope", + "description": "The Bitcoin scope (network). Not used internally but validated.", + "required": false, + "schema": { + "type": "string", + "enum": [ + "bip122:000000000019d6689c085ae165831e93", + "bip122:000000000933ea01ad0ee984209779ba", + "bip122:00000000da84f2bafbbc53dee25a72ae", + "bip122:00000008819873e925422c1ff0f99f7c", + "bip122:regtest" + ] + } + }, + { + "name": "assetId", + "description": "The asset ID. Not used internally but validated.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "sendTransactionResponse", + "description": "The result of the send operation, or null if cancelled.", + "schema": { + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "required": ["transactionId"], + "properties": { + "transactionId": { + "type": "string", + "description": "The transaction ID." + } + } + } + ] + } + } + }, + { + "name": "signAndSendTransaction", + "description": "Signs and sends a partially signed Bitcoin transaction (PSBT) for the specified account.", + "paramStructure": "by-name", + "params": [ + { + "name": "accountId", + "description": "The account ID to use for signing and sending.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scope", + "description": "The Bitcoin scope (network). Not used internally but validated.", + "required": false, + "schema": { + "type": "string", + "enum": [ + "bip122:000000000019d6689c085ae165831e93", + "bip122:000000000933ea01ad0ee984209779ba", + "bip122:00000000da84f2bafbbc53dee25a72ae", + "bip122:00000008819873e925422c1ff0f99f7c", + "bip122:regtest" + ] + } + }, + { + "name": "transaction", + "description": "The base64-encoded PSBT string.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "sendTransactionResponse", + "description": "The result of the send operation, or null if failed.", + "schema": { + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "required": ["transactionId"], + "properties": { + "transactionId": { + "type": "string", + "description": "The transaction ID." + } + } + } + ] + } + } + } + ] +} diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index fe868b7e..a9c269fb 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "0SW7OJym47/Lo2akGC+GLX2TCoWIg865zeyrE49aRZc=", + "shasum": "ZiIpUVauMY2V996N0XzpK0gW2iUatif9o7h2+662IE4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index 4d3bc910..b2a155c1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -86,7 +86,7 @@ describe('RpcHandler', () => { mockPsbt, 'metamask', ); - expect(result).toStrictEqual({ txid: 'txId' }); + expect(result).toStrictEqual({ transactionId: 'txId' }); }); it('propagates errors from display', async () => { @@ -111,18 +111,18 @@ describe('RpcHandler', () => { }); }); - describe('fillAndSendPsbt', () => { + describe('signAndSendTransaction', () => { const psbt = 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgu3FEiFNy9ZR/zSpTo9nHREjrSoAAAAAAAAAAAA='; const mockRequest = mock({ - method: RpcMethod.FillAndSendPsbt, + method: RpcMethod.SignAndSendTransaction, params: { - account: 'account-id', - psbt, + accountId: 'account-id', + transaction: psbt, }, }); - it('executes fillAndSendPsbt', async () => { + it('executes signAndSendTransaction', async () => { mockAccountsUseCases.fillAndSendPsbt.mockResolvedValue( mock({ toString: jest.fn().mockReturnValue('txId'), @@ -137,10 +137,10 @@ describe('RpcHandler', () => { mockPsbt, 'metamask', ); - expect(result).toStrictEqual({ txid: 'txId' }); + expect(result).toStrictEqual({ transactionId: 'txId' }); }); - it('propagates errors from fillAndSendPsbt', async () => { + it('propagates errors from signAndSendTransaction', async () => { const error = new Error(); mockAccountsUseCases.fillAndSendPsbt.mockRejectedValue(error); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index 365fa8f3..f531d735 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -9,7 +9,7 @@ import { FormatError, InexistentMethodError } from '../entities'; export enum RpcMethod { StartSendTransactionFlow = 'startSendTransactionFlow', - FillAndSendPsbt = 'fillAndSendPsbt', + SignAndSendTransaction = 'signAndSendTransaction', } export const CreateSendFormRequest = object({ @@ -19,12 +19,13 @@ export const CreateSendFormRequest = object({ }); export const SendPsbtRequest = object({ - account: string(), - psbt: string(), + accountId: string(), + transaction: string(), + scope: optional(enums(Object.values(BtcScope))), // We don't use the scope but need to define it for validation }); export type SendTransactionResponse = { - txid: string; + transactionId: string; }; export class RpcHandler { @@ -51,9 +52,9 @@ export class RpcHandler { assert(params, CreateSendFormRequest); return this.#executeSendFlow(params.account, origin); } - case RpcMethod.FillAndSendPsbt: { + case RpcMethod.SignAndSendTransaction: { assert(params, SendPsbtRequest); - return this.#fillAndSend(params.account, params.psbt, origin); + return this.#signAndSend(params.accountId, params.transaction, origin); } default: @@ -70,27 +71,27 @@ export class RpcHandler { return null; } const txid = await this.#accountUseCases.sendPsbt(account, psbt, origin); - return { txid: txid.toString() }; + return { transactionId: txid.toString() }; } - async #fillAndSend( - account: string, - psbtBase64: string, + async #signAndSend( + accountId: string, + transaction: string, origin: string, ): Promise { let psbt: Psbt; try { - psbt = Psbt.from_string(psbtBase64); + psbt = Psbt.from_string(transaction); } catch (error) { - throw new FormatError('Invalid PSBT', { account, psbtBase64 }, error); + throw new FormatError('Invalid PSBT', { accountId, transaction }, error); } const txid = await this.#accountUseCases.fillAndSendPsbt( - account, + accountId, psbt, origin, ); - return { txid: txid.toString() }; + return { transactionId: txid.toString() }; } } From 9f9480e200e68658607b1c679e94c5a82dbe626e Mon Sep 17 00:00:00 2001 From: orestis Date: Tue, 19 Aug 2025 14:54:10 +0100 Subject: [PATCH 266/362] feat: compute transaction fees for PSBTs (#507) --- .../integration-test/client-request.test.ts | 67 ++++++++++++- .../bitcoin-wallet-snap/openrpc.json | 78 +++++++++++++++ .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/RpcHandler.test.ts | 87 ++++++++++++++++- .../src/handlers/RpcHandler.ts | 44 +++++++-- .../src/handlers/mappings.ts | 41 +++++--- .../src/use-cases/AccountUseCases.test.ts | 97 +++++++++++++++++++ .../src/use-cases/AccountUseCases.ts | 13 +++ 8 files changed, 404 insertions(+), 25 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts index 082c9687..7c981f1a 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts @@ -1,11 +1,12 @@ import type { KeyringAccount } from '@metamask/keyring-api'; -import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; +import { FeeType, BtcAccountType, BtcScope } from '@metamask/keyring-api'; import type { Snap } from '@metamask/snaps-jest'; import { installSnap } from '@metamask/snaps-jest'; import { BlockchainTestUtils } from './blockchain-utils'; import { MNEMONIC, ORIGIN } from './constants'; -import { TrackingSnapEvent } from '../src/entities'; +import { CurrencyUnit, TrackingSnapEvent } from '../src/entities'; +import { Caip19Asset } from '../src/handlers/caip'; const ACCOUNT_INDEX = 1; @@ -116,7 +117,6 @@ describe('OnClientRequestHandler', () => { code: -32000, message: 'Invalid format: Invalid PSBT', data: { - accountId: account.id, cause: null, transaction: 'notAPsbt', }, @@ -139,4 +139,65 @@ describe('OnClientRequestHandler', () => { stack: expect.anything(), }); }); + + it('computes fee for valid PSBT', async () => { + const response = await snap.onClientRequest({ + method: 'computeFee', + params: { + accountId: account.id, + transaction: + 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgu3FEiFNy9ZR/zSpTo9nHREjrSoAAAAAAAAAAAA=', + scope: BtcScope.Regtest, + }, + }); + + expect(response).toRespondWith([ + { + type: FeeType.Priority, + asset: { + unit: CurrencyUnit.Regtest, + type: Caip19Asset.Regtest, + amount: '0.00001053', + fungible: true, + }, + }, + ]); + }); + + it('fails to compute fee for invalid PSBT', async () => { + const response = await snap.onClientRequest({ + method: 'computeFee', + params: { + accountId: account.id, + transaction: 'notAPsbt', + scope: BtcScope.Regtest, + }, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: 'Invalid format: Invalid PSBT', + data: { + cause: null, + transaction: 'notAPsbt', + }, + stack: expect.anything(), + }); + }); + + it('fails to compute fee if missing params', async () => { + const response = await snap.onClientRequest({ + method: 'computeFee', + params: { + accountId: null, + }, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: + 'Invalid format: At path: accountId -- Expected a string, but received: null', + stack: expect.anything(), + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/openrpc.json b/merged-packages/bitcoin-wallet-snap/openrpc.json index 1b431351..6e182e55 100644 --- a/merged-packages/bitcoin-wallet-snap/openrpc.json +++ b/merged-packages/bitcoin-wallet-snap/openrpc.json @@ -119,6 +119,84 @@ ] } } + }, + { + "name": "computeFee", + "description": "Computes the fee for a partially signed Bitcoin transaction (PSBT) for the specified account.", + "paramStructure": "by-name", + "params": [ + { + "name": "accountId", + "description": "The account ID to use for fee computation.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "transaction", + "description": "The base64-encoded PSBT string.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "scope", + "description": "The Bitcoin scope (network) for CAIP-19 asset identification.", + "required": true, + "schema": { + "type": "string", + "enum": [ + "bip122:000000000019d6689c085ae165831e93", + "bip122:000000000933ea01ad0ee984209779ba", + "bip122:00000000da84f2bafbbc53dee25a72ae", + "bip122:00000008819873e925422c1ff0f99f7c", + "bip122:regtest" + ] + } + } + ], + "result": { + "name": "computeFeeResponse", + "description": "Array of computed fee information.", + "schema": { + "type": "array", + "items": { + "type": "object", + "required": ["type", "asset"], + "properties": { + "type": { + "type": "string", + "enum": ["base", "priority"], + "description": "The fee type." + }, + "asset": { + "type": "object", + "required": ["unit", "type", "amount", "fungible"], + "properties": { + "unit": { + "type": "string", + "description": "The currency unit for the account's network." + }, + "type": { + "type": "string", + "description": "The CAIP-19 asset identifier for the account's network." + }, + "amount": { + "type": "string", + "description": "The fee amount as a string." + }, + "fungible": { + "type": "boolean", + "description": "Whether the asset is fungible." + } + } + } + } + } + } + } } ] } diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index a9c269fb..0929cecb 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "ZiIpUVauMY2V996N0XzpK0gW2iUatif9o7h2+662IE4=", + "shasum": "mZdt6wusDhGA7MW079gCs2gYPuDfp2KouIqVJZ2yxDc=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index b2a155c1..9cc8a4df 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -1,16 +1,19 @@ -import type { Psbt, Txid } from '@metamask/bitcoindevkit'; +import { Psbt } from '@metamask/bitcoindevkit'; +import type { Amount, Txid } from '@metamask/bitcoindevkit'; +import { BtcScope, FeeType } from '@metamask/keyring-api'; import type { JsonRpcRequest } from '@metamask/utils'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; -import type { SendFlowUseCases } from '../use-cases'; +import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; +import { Caip19Asset } from './caip'; import { CreateSendFormRequest, + ComputeFeeRequest, RpcHandler, RpcMethod, SendPsbtRequest, } from './RpcHandler'; -import type { AccountUseCases } from '../use-cases/AccountUseCases'; jest.mock('superstruct', () => ({ ...jest.requireActual('superstruct'), @@ -21,7 +24,7 @@ const mockPsbt = mock(); // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 /* eslint-disable @typescript-eslint/naming-convention */ jest.mock('@metamask/bitcoindevkit', () => ({ - Psbt: { from_string: () => mockPsbt }, + Psbt: { from_string: jest.fn() }, })); describe('RpcHandler', () => { @@ -31,6 +34,10 @@ describe('RpcHandler', () => { const handler = new RpcHandler(mockSendFlowUseCases, mockAccountsUseCases); + beforeEach(() => { + jest.mocked(Psbt.from_string).mockReturnValue(mockPsbt); + }); + describe('route', () => { const mockRequest = mock({ method: RpcMethod.StartSendTransactionFlow, @@ -149,4 +156,76 @@ describe('RpcHandler', () => { expect(mockAccountsUseCases.fillAndSendPsbt).toHaveBeenCalled(); }); }); + + describe('computeFee', () => { + const psbt = 'someEncodedPsbt'; + const mockRequest = mock({ + method: RpcMethod.ComputeFee, + params: { + accountId: 'account-id', + transaction: psbt, + scope: BtcScope.Mainnet, + }, + }); + + it('executes computeFee', async () => { + const mockAmount = mock({ + to_btc: jest.fn().mockReturnValue('0.00001'), + }); + mockAccountsUseCases.computeFee.mockResolvedValue(mockAmount); + + const result = await handler.route(origin, mockRequest); + + expect(assert).toHaveBeenCalledWith( + mockRequest.params, + ComputeFeeRequest, + ); + expect(Psbt.from_string).toHaveBeenCalledWith(psbt); + expect(mockAccountsUseCases.computeFee).toHaveBeenCalledWith( + 'account-id', + mockPsbt, + ); + expect(result).toStrictEqual([ + { + type: FeeType.Priority, + asset: { + unit: 'BTC', + type: Caip19Asset.Bitcoin, + amount: '0.00001', + fungible: true, + }, + }, + ]); + }); + + it('propagates errors from computeFee', async () => { + const error = new Error('Insufficient funds'); + mockAccountsUseCases.computeFee.mockRejectedValue(error); + + await expect(handler.route(origin, mockRequest)).rejects.toThrow(error); + + expect(mockAccountsUseCases.computeFee).toHaveBeenCalled(); + }); + + it('throws FormatError for invalid PSBT', async () => { + const invalidRequest = mock({ + method: RpcMethod.ComputeFee, + params: { + accountId: 'account-id', + transaction: 'invalid-psbt-base64', + scope: BtcScope.Mainnet, + }, + }); + + jest.mocked(Psbt.from_string).mockImplementationOnce(() => { + throw new Error('Invalid PSBT'); + }); + + await expect(handler.route(origin, invalidRequest)).rejects.toThrow( + 'Invalid PSBT', + ); + + expect(mockAccountsUseCases.computeFee).not.toHaveBeenCalled(); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index f531d735..c3707a3e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -6,10 +6,14 @@ import { assert, enums, object, optional, string } from 'superstruct'; import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; import { validateOrigin } from './permissions'; import { FormatError, InexistentMethodError } from '../entities'; +import { scopeToNetwork } from './caip'; +import type { TransactionFee } from './mappings'; +import { mapToTransactionFees } from './mappings'; export enum RpcMethod { StartSendTransactionFlow = 'startSendTransactionFlow', SignAndSendTransaction = 'signAndSendTransaction', + ComputeFee = 'computeFee', } export const CreateSendFormRequest = object({ @@ -24,6 +28,12 @@ export const SendPsbtRequest = object({ scope: optional(enums(Object.values(BtcScope))), // We don't use the scope but need to define it for validation }); +export const ComputeFeeRequest = object({ + accountId: string(), + transaction: string(), + scope: enums(Object.values(BtcScope)), +}); + export type SendTransactionResponse = { transactionId: string; }; @@ -56,6 +66,14 @@ export class RpcHandler { assert(params, SendPsbtRequest); return this.#signAndSend(params.accountId, params.transaction, origin); } + case RpcMethod.ComputeFee: { + assert(params, ComputeFeeRequest); + return this.#computeFee( + params.accountId, + params.transaction, + params.scope, + ); + } default: throw new InexistentMethodError(`Method not found: ${method}`); @@ -79,12 +97,7 @@ export class RpcHandler { transaction: string, origin: string, ): Promise { - let psbt: Psbt; - try { - psbt = Psbt.from_string(transaction); - } catch (error) { - throw new FormatError('Invalid PSBT', { accountId, transaction }, error); - } + const psbt: Psbt = this.#parsePsbt(transaction); const txid = await this.#accountUseCases.fillAndSendPsbt( accountId, @@ -94,4 +107,23 @@ export class RpcHandler { return { transactionId: txid.toString() }; } + + async #computeFee( + accountId: string, + transaction: string, + scope: BtcScope, + ): Promise { + const psbt = this.#parsePsbt(transaction); + const amount = await this.#accountUseCases.computeFee(accountId, psbt); + + return [mapToTransactionFees(amount, scopeToNetwork[scope])]; + } + + #parsePsbt(transaction: string): Psbt { + try { + return Psbt.from_string(transaction); + } catch (error) { + throw new FormatError('Invalid PSBT', { transaction }, error); + } + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index 35127186..22cdf737 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -1,26 +1,32 @@ -import { Address } from '@metamask/bitcoindevkit'; import type { Amount, + ChainPosition, Network, TxOut, - ChainPosition, WalletTx, } from '@metamask/bitcoindevkit'; +import { Address } from '@metamask/bitcoindevkit'; import type { DiscoveredAccount, KeyringAccount, Transaction as KeyringTransaction, } from '@metamask/keyring-api'; import { - TransactionStatus, DiscoveredAccountType, + FeeType, + TransactionStatus, } from '@metamask/keyring-api'; -import { networkToCurrencyUnit, type BitcoinAccount } from '../entities'; +import { type BitcoinAccount, networkToCurrencyUnit } from '../entities'; import type { Caip19Asset } from './caip'; import { addressTypeToCaip, networkToCaip19, networkToScope } from './caip'; -type TransactionAmount = { +export type TransactionFee = { + type: FeeType; + asset: TransactionAmount; +}; + +export type TransactionAmount = { amount: string; fungible: true; unit: string; @@ -114,6 +120,24 @@ const mapToEvents = ( return [events, timestamp, status]; }; +/** + * Maps an amount & network to a Fee struct + * + * @param amount The fee amount + * @param network The network relevant to the fee amount + * + * @returns The transaction fee object + */ +export function mapToTransactionFees( + amount: Amount, + network: Network, +): TransactionFee { + return { + type: FeeType.Priority, + asset: mapToAmount(amount, network), + }; +} + /** * Maps a Bitcoin Transaction to a Keyring Transaction. * @@ -143,12 +167,7 @@ export function mapToTransaction( to: [], from: [], fees: isSend - ? [ - { - type: 'priority', - asset: mapToAmount(account.calculateFee(tx), network), - }, - ] + ? [mapToTransactionFees(account.calculateFee(tx), network)] : [], }; diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index c0fa2dec..a2913ce2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -1060,4 +1060,101 @@ describe('AccountUseCases', () => { ).rejects.toBe(error); }); }); + + describe('computeFee', () => { + const mockFee = mock(); + const mockOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + const mockTemplatePsbt = mock({ + unsigned_tx: { + output: [mockOutput], + }, + toString: () => 'base64Psbt', + }); + const mockAccount = mock({ + id: 'account-id', + network: 'bitcoin', + isMine: () => false, + }); + const mockFeeRate = 3; + const mockFeeEstimates = mock({ + get: () => mockFeeRate, + }); + const mockFrozenUTXOs = ['utxo1', 'utxo2']; + const mockFilledPsbt = mock(); + const mockTxBuilder = mock({ + addRecipientByScript: jest.fn(), + feeRate: jest.fn(), + drainToByScript: jest.fn(), + finish: jest.fn(), + unspendable: jest.fn(), + }); + + beforeEach(() => { + mockRepository.getWithSigner.mockResolvedValue(mockAccount); + mockRepository.getFrozenUTXOs.mockResolvedValue(mockFrozenUTXOs); + mockAccount.buildTx.mockReturnValue(mockTxBuilder); + mockTxBuilder.addRecipientByScript.mockReturnThis(); + mockTxBuilder.feeRate.mockReturnThis(); + mockTxBuilder.drainToByScript.mockReturnThis(); + mockTxBuilder.untouchedOrdering.mockReturnThis(); + mockTxBuilder.finish.mockReturnValue(mockFilledPsbt); + mockTxBuilder.unspendable.mockReturnThis(); + mockFilledPsbt.fee.mockReturnValue(mockFee); + mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); + }); + + it('throws error if account is not found', async () => { + mockRepository.getWithSigner.mockResolvedValue(null); + + await expect( + useCases.computeFee('account-id', mockTemplatePsbt), + ).rejects.toThrow('Account not found'); + }); + + it('computes fee for PSBT without change output', async () => { + const fee = await useCases.computeFee('account-id', mockTemplatePsbt); + + expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); + expect(mockRepository.getFrozenUTXOs).toHaveBeenCalledWith('account-id'); + expect(mockChain.getFeeEstimates).toHaveBeenCalledWith('bitcoin'); + expect(mockTxBuilder.unspendable).toHaveBeenCalledWith(mockFrozenUTXOs); + expect(mockTxBuilder.addRecipientByScript).toHaveBeenCalledWith( + mockOutput.value, + mockOutput.script_pubkey, + ); + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith(mockFeeRate); + expect(mockTxBuilder.untouchedOrdering).toHaveBeenCalled(); + expect(mockTxBuilder.finish).toHaveBeenCalled(); + expect(fee).toBe(mockFee); + }); + + it('computes fee for PSBT with change output', async () => { + mockRepository.getWithSigner.mockResolvedValueOnce({ + ...mockAccount, + isMine: () => true, + }); + + const fee = await useCases.computeFee('account-id', mockTemplatePsbt); + + expect(mockTxBuilder.drainToByScript).toHaveBeenCalledWith( + mockOutput.script_pubkey, + ); + expect(mockTxBuilder.addRecipientByScript).not.toHaveBeenCalled(); + expect(fee).toBe(mockFee); + }); + + it('uses fallback fee rate when estimate is not available', async () => { + mockChain.getFeeEstimates.mockResolvedValueOnce({ + ...mockFeeEstimates, + get: () => undefined, + }); + + await useCases.computeFee('account-id', mockTemplatePsbt); + + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith(fallbackFeeRate); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 5a5521b1..3322bb30 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -1,5 +1,6 @@ import type { AddressType, + Amount, Network, Psbt, Txid, @@ -349,6 +350,18 @@ export class AccountUseCases { return txid; } + async computeFee(id: string, templatePsbt: Psbt): Promise { + this.#logger.debug('Getting fee amount for Psbt for account id: %s', id); + + const account = await this.#repository.getWithSigner(id); + if (!account) { + throw new NotFoundError('Account not found', { id }); + } + + const psbt = await this.#fillPsbt(account, templatePsbt); + return psbt.fee(); + } + async #fillPsbt(account: BitcoinAccount, templatePsbt: Psbt): Promise { const frozenUTXOs = await this.#repository.getFrozenUTXOs(account.id); const feeEstimates = await this.#chain.getFeeEstimates(account.network); From ec42c29cd0c835f4d4032184ab25663d24b49aed Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 15:55:23 +0100 Subject: [PATCH 267/362] 0.19.0 (#509) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 16 +++++++++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index e61b214b..20f9f12f 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.0] + +### Added + +- PSBT transaction fee computation ([#507](https://github.com/MetaMask/snap-bitcoin-wallet/pull/507)) +- Fill, sign and send PSBT functionality ([#504](https://github.com/MetaMask/snap-bitcoin-wallet/pull/504)) +- OpenRPC documentation for onClientRequest ([#506](https://github.com/MetaMask/snap-bitcoin-wallet/pull/506)) +- Event tracking verification for account synchronization ([#503](https://github.com/MetaMask/snap-bitcoin-wallet/pull/503)) + +### Changed + +- Complete documentation refactor ([#505](https://github.com/MetaMask/snap-bitcoin-wallet/pull/505)) + ## [0.18.0] ### Added @@ -407,7 +420,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.18.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.0...HEAD +[0.19.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.18.0...v0.19.0 [0.18.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.17.0...v0.18.0 [0.17.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.1...v0.17.0 [0.16.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.0...v0.16.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index e9081876..f526d430 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.18.0", + "version": "0.19.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 0929cecb..78ea127a 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.18.0", + "version": "0.19.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "mZdt6wusDhGA7MW079gCs2gYPuDfp2KouIqVJZ2yxDc=", + "shasum": "mY6xbn6SD2WTrc/skndjHt3TEgRBH9wC8eyBAcgQXTc=", "location": { "npm": { "filePath": "dist/bundle.js", From 77d37fd064c28aa518a6e5025af4c3d6b7eba2b8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 17:10:23 +0200 Subject: [PATCH 268/362] 0.19.1 (#511) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 5 ++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 20f9f12f..737b4edb 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.1] + ## [0.19.0] ### Added @@ -420,7 +422,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.1...HEAD +[0.19.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.0...v0.19.1 [0.19.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.18.0...v0.19.0 [0.18.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.17.0...v0.18.0 [0.17.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.1...v0.17.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index f526d430..9161666a 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.19.0", + "version": "0.19.1", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 78ea127a..f8b0f8b1 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.19.0", + "version": "0.19.1", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "mY6xbn6SD2WTrc/skndjHt3TEgRBH9wC8eyBAcgQXTc=", + "shasum": "JAS5a+9a4L4bopu0D5ly2yJUoceOgAgmVuBdM+2nlXs=", "location": { "npm": { "filePath": "dist/bundle.js", From bdf600c7a84cb18eb741b7de79d9a5679a39444f Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 20 Aug 2025 17:25:02 +0200 Subject: [PATCH 269/362] Revert "0.19.1 (#511)" (#512) This reverts commit 77d37fd064c28aa518a6e5025af4c3d6b7eba2b8. --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 5 +---- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 737b4edb..20f9f12f 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,8 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.19.1] - ## [0.19.0] ### Added @@ -422,8 +420,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.1...HEAD -[0.19.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.0...v0.19.1 +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.0...HEAD [0.19.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.18.0...v0.19.0 [0.18.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.17.0...v0.18.0 [0.17.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.1...v0.17.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 9161666a..f526d430 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.19.1", + "version": "0.19.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index f8b0f8b1..78ea127a 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.19.1", + "version": "0.19.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "JAS5a+9a4L4bopu0D5ly2yJUoceOgAgmVuBdM+2nlXs=", + "shasum": "mY6xbn6SD2WTrc/skndjHt3TEgRBH9wC8eyBAcgQXTc=", "location": { "npm": { "filePath": "dist/bundle.js", From a92432c5fbc02a2bd826039c1651cdd736ef60ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 17:45:17 +0200 Subject: [PATCH 270/362] 0.19.1 (#513) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 20f9f12f..79694780 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.1] + +### Changed + +- Use DIN endpoints for Bitcoin and Bitcoin Testnet (no PR, just a change in CI secrets) + ## [0.19.0] ### Added @@ -420,7 +426,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.1...HEAD +[0.19.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.0...v0.19.1 [0.19.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.18.0...v0.19.0 [0.18.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.17.0...v0.18.0 [0.17.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.16.1...v0.17.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index f526d430..9161666a 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.19.0", + "version": "0.19.1", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 78ea127a..93e09e88 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.19.0", + "version": "0.19.1", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "mY6xbn6SD2WTrc/skndjHt3TEgRBH9wC8eyBAcgQXTc=", + "shasum": "Atxm6x8Q9zLjG2cmRQwe68y80LmKw/rWtIlK+lj6Gg0=", "location": { "npm": { "filePath": "dist/bundle.js", From 1da6f9fe93873d27da9a0c4e788b812975f338c5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 19:03:25 +0200 Subject: [PATCH 271/362] 0.19.2 (#516) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 79694780..b3cf844b 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.2] + +### Fixed + +- Fix issue where DIN endpoints where not used in `v0.19.1` + ## [0.19.1] ### Changed @@ -426,7 +432,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.1...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.2...HEAD +[0.19.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.1...v0.19.2 [0.19.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.0...v0.19.1 [0.19.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.18.0...v0.19.0 [0.18.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.17.0...v0.18.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 9161666a..30242c33 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.19.1", + "version": "0.19.2", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 93e09e88..7b754789 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.19.1", + "version": "0.19.2", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "Atxm6x8Q9zLjG2cmRQwe68y80LmKw/rWtIlK+lj6Gg0=", + "shasum": "w59SrhkoKVFGtb33xyFWmrZyDLcSB0vJSvvj/5pSVDg=", "location": { "npm": { "filePath": "dist/bundle.js", From e501916d8718a3ae645a9b17a27e9e92bab06019 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 20 Aug 2025 20:28:11 +0200 Subject: [PATCH 272/362] fix: treat empty strings as unset in config (#517) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/config.ts | 58 +++++++++++++------ 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 7b754789..64e10ae0 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "w59SrhkoKVFGtb33xyFWmrZyDLcSB0vJSvvj/5pSVDg=", + "shasum": "CX9QsM7HM8DzZk2qH350XdbbNeMtTOK56HKyxmJx6fQ=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index aa38bd4a..7a954c25 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -4,39 +4,61 @@ import type { AddressType } from '@metamask/bitcoindevkit'; import { LogLevel, type SnapConfig } from './entities'; +const ENV = { + LOG_LEVEL: process.env.LOG_LEVEL, + DEFAULT_ADDRESS_TYPE: process.env.DEFAULT_ADDRESS_TYPE, + ESPLORA_BITCOIN: process.env.ESPLORA_BITCOIN, + ESPLORA_TESTNET: process.env.ESPLORA_TESTNET, + ESPLORA_TESTNET4: process.env.ESPLORA_TESTNET4, + ESPLORA_SIGNET: process.env.ESPLORA_SIGNET, + ESPLORA_REGTEST: process.env.ESPLORA_REGTEST, + PRICE_API_URL: process.env.PRICE_API_URL, + BITCOIN_EXPLORER: process.env.BITCOIN_EXPLORER, + TESTNET_EXPLORER: process.env.TESTNET_EXPLORER, + TESTNET4_EXPLORER: process.env.TESTNET4_EXPLORER, + SIGNET_EXPLORER: process.env.SIGNET_EXPLORER, + REGTEST_EXPLORER: process.env.REGTEST_EXPLORER, +} as const; + +const fromEnv = (key: string, fallback: string): string => { + const value = ENV[key as keyof typeof ENV]; + return value && value.trim() !== '' ? value : fallback; +}; + export const Config: SnapConfig = { - logLevel: (process.env.LOG_LEVEL ?? LogLevel.INFO) as LogLevel, + logLevel: fromEnv('LOG_LEVEL', LogLevel.INFO) as LogLevel, encrypt: false, chain: { parallelRequests: 5, stopGap: 10, maxRetries: 3, url: { - bitcoin: process.env.ESPLORA_BITCOIN ?? 'https://blockstream.info/api', - testnet: - process.env.ESPLORA_TESTNET ?? 'https://blockstream.info/testnet/api', - testnet4: - process.env.ESPLORA_TESTNET4 ?? 'https://mempool.space/testnet4/api/v1', - signet: process.env.ESPLORA_SIGNET ?? 'https://mutinynet.com/api', - regtest: - process.env.ESPLORA_REGTEST ?? 'http://localhost:8094/regtest/api', + bitcoin: fromEnv('ESPLORA_BITCOIN', 'https://blockstream.info/api'), + testnet: fromEnv( + 'ESPLORA_TESTNET', + 'https://blockstream.info/testnet/api', + ), + testnet4: fromEnv( + 'ESPLORA_TESTNET4', + 'https://mempool.space/testnet4/api/v1', + ), + signet: fromEnv('ESPLORA_SIGNET', 'https://mutinynet.com/api'), + regtest: fromEnv('ESPLORA_REGTEST', 'http://localhost:8094/regtest/api'), }, explorerUrl: { - bitcoin: process.env.BITCOIN_EXPLORER ?? 'https://mempool.space', - testnet: process.env.TESTNET_EXPLORER ?? 'https://mempool.space/testnet', - testnet4: - process.env.TESTNET4_EXPLORER ?? 'https://mempool.space/testnet4', - signet: process.env.SIGNET_EXPLORER ?? 'https://mutinynet.com', - regtest: process.env.REGTEST_EXPLORER ?? 'http://localhost:8094/regtest', + bitcoin: fromEnv('BITCOIN_EXPLORER', 'https://mempool.space'), + testnet: fromEnv('TESTNET_EXPLORER', 'https://mempool.space/testnet'), + testnet4: fromEnv('TESTNET4_EXPLORER', 'https://mempool.space/testnet4'), + signet: fromEnv('SIGNET_EXPLORER', 'https://mutinynet.com'), + regtest: fromEnv('REGTEST_EXPLORER', 'http://localhost:8094/regtest'), }, }, targetBlocksConfirmation: 3, fallbackFeeRate: 5.0, ratesRefreshInterval: 'PT20S', priceApi: { - url: process.env.PRICE_API_URL ?? 'https://price.api.cx.metamask.io', + url: fromEnv('PRICE_API_URL', 'https://price.api.cx.metamask.io'), }, conversionsExpirationInterval: 60, - defaultAddressType: (process.env.DEFAULT_ADDRESS_TYPE ?? - 'p2wpkh') as AddressType, + defaultAddressType: fromEnv('DEFAULT_ADDRESS_TYPE', 'p2wpkh') as AddressType, }; From 8d97bd05c3707b3453a6ea965aa59078630b6a69 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 20:47:49 +0200 Subject: [PATCH 273/362] 0.19.3 (#518) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index b3cf844b..1b0e34a7 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.3] + +### Fixed + +- Treat empty strings as unset in config ([#517](https://github.com/MetaMask/snap-bitcoin-wallet/pull/517)) + ## [0.19.2] ### Fixed @@ -432,7 +438,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.2...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.3...HEAD +[0.19.3]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.2...v0.19.3 [0.19.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.1...v0.19.2 [0.19.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.0...v0.19.1 [0.19.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.18.0...v0.19.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 30242c33..18b5aa07 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.19.2", + "version": "0.19.3", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 64e10ae0..5696fc2e 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.19.2", + "version": "0.19.3", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "CX9QsM7HM8DzZk2qH350XdbbNeMtTOK56HKyxmJx6fQ=", + "shasum": "OmwSAjbEuVKC/Hb5aewaWV/1HVOamuh/mdnAX/JX4zs=", "location": { "npm": { "filePath": "dist/bundle.js", From c34b32f3dd88c146a4bba8682b8b4b4f06b22583 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 21 Aug 2025 14:34:05 +0200 Subject: [PATCH 274/362] feat: submit request psbt management (#514) --- .../integration-test/keyring-request.test.ts | 477 ++++++++++++++++++ .../integration-test/keyring.test.ts | 6 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/entities/account.ts | 25 +- .../src/handlers/KeyringHandler.test.ts | 30 +- .../src/handlers/KeyringHandler.ts | 20 +- .../handlers/KeyringRequestHandler.test.ts | 306 +++++++++++ .../src/handlers/KeyringRequestHandler.ts | 162 ++++++ .../src/handlers/RpcHandler.test.ts | 30 +- .../src/handlers/RpcHandler.ts | 40 +- .../src/handlers/mappings.ts | 2 +- .../src/handlers/parsers.ts | 18 + .../bitcoin-wallet-snap/src/index.ts | 3 + .../src/infra/BdkAccountAdapter.ts | 16 +- .../src/use-cases/AccountUseCases.test.ts | 257 ++++++---- .../src/use-cases/AccountUseCases.ts | 130 +++-- 16 files changed, 1350 insertions(+), 174 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/parsers.ts diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts new file mode 100644 index 00000000..4fc4c0f9 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts @@ -0,0 +1,477 @@ +import type { KeyringAccount, KeyringRequest } from '@metamask/keyring-api'; +import { BtcScope } from '@metamask/keyring-api'; +import type { Snap } from '@metamask/snaps-jest'; +import { installSnap } from '@metamask/snaps-jest'; + +import { BlockchainTestUtils } from './blockchain-utils'; +import { MNEMONIC, ORIGIN } from './constants'; +import { AccountCapability } from '../src/entities'; +import type { FillPsbtResponse } from '../src/handlers/KeyringRequestHandler'; + +const ACCOUNT_INDEX = 3; +const submitRequestMethod = 'keyring_submitRequest'; + +describe('KeyringRequestHandler', () => { + let account: KeyringAccount; + let snap: Snap; + let blockchain: BlockchainTestUtils; + const origin = 'integration-tests'; + + beforeAll(async () => { + blockchain = new BlockchainTestUtils(); + snap = await installSnap({ + options: { + secretRecoveryPhrase: MNEMONIC, + }, + }); + + snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); + snap.mockJsonRpc({ method: 'snap_trackError', result: {} }); + + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope: BtcScope.Regtest, + synchronize: false, + index: ACCOUNT_INDEX, + }, + }, + }); + + if ('result' in response.response) { + account = response.response.result as KeyringAccount; + } + + await blockchain.sendToAddress(account.address, 10); + await blockchain.mineBlocks(6); + await snap.onCronjob({ method: 'synchronizeAccounts' }); + }); + + it('fails if invalid params', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: 'notAUUID', + request: { + method: AccountCapability.SignPsbt, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: + 'Invalid format: At path: params.account -- Expected a value of type `UuidV4`, but received: `"notAUUID"`', + stack: expect.anything(), + }); + }); + + it('fails if unrecognized method', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: 'invalidMethod', + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32601, + data: { + account: account.id, + cause: null, + method: 'invalidMethod', + }, + message: + 'Method not implemented or not supported: Unrecognized Bitcoin account capability', + stack: expect.anything(), + }); + }); + + describe('signPsbt', () => { + // PSBTs can be decoded here: https://bitcoincore.tech/apps/bitcoinjs-ui/index.html + const TEMPLATE_PSBT = + 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgpMvYEJ/dp36svRJyRtNnpSo7bQAAAAAAAAAAAA='; + const SIGNED_PSBT = + 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgpMvYEJ/dp36svRJyRtNnpSo7bQAAAAAAAAAAA=='; + + it('signs a PSBT successfully: sign', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SignPsbt, + params: { + psbt: TEMPLATE_PSBT, + feeRate: 3, + options: { + fill: false, + broadcast: false, + }, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWith({ + pending: false, + result: { + psbt: SIGNED_PSBT, + txid: null, + }, + }); + }); + + it('signs a PSBT successfully: fill and sign', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SignPsbt, + params: { + psbt: TEMPLATE_PSBT, + feeRate: 3, + options: { + fill: true, + broadcast: false, + }, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWith({ + pending: false, + result: { + psbt: expect.any(String), // non deterministic + txid: null, + }, + }); + }); + + it('signs a PSBT successfully: fill, sign and broadcast', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SignPsbt, + params: { + psbt: TEMPLATE_PSBT, + feeRate: 3, + options: { + fill: true, + broadcast: true, + }, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWith({ + pending: false, + result: { + psbt: expect.any(String), // non deterministic + txid: expect.any(String), + }, + }); + }); + + it('fails if invalid PSBT', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SignPsbt, + params: { + psbt: 'notAPsbt', + options: { + fill: true, + broadcast: true, + }, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: 'Invalid format: Invalid PSBT', + data: { + cause: null, + transaction: 'notAPsbt', + }, + stack: expect.anything(), + }); + }); + + it('fails if missing options', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SignPsbt, + params: { + psbt: TEMPLATE_PSBT, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: + 'Invalid format: At path: options -- Expected an object, but received: undefined', + stack: expect.anything(), + }); + }); + }); + + describe('fillPsbt', () => { + // PSBTs can be decoded here: https://bitcoincore.tech/apps/bitcoinjs-ui/index.html + const TEMPLATE_PSBT = + 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgpMvYEJ/dp36svRJyRtNnpSo7bQAAAAAAAAAAAA='; + + it('fills a PSBT successfully', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.FillPsbt, + params: { + psbt: TEMPLATE_PSBT, + feeRate: 3, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWith({ + pending: false, + result: { + psbt: expect.any(String), // non deterministic + }, + }); + }); + + it('fails if invalid PSBT', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.FillPsbt, + params: { + psbt: 'notAPsbt', + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: 'Invalid format: Invalid PSBT', + data: { + cause: null, + transaction: 'notAPsbt', + }, + stack: expect.anything(), + }); + }); + }); + + describe('computeFee', () => { + // PSBTs can be decoded here: https://bitcoincore.tech/apps/bitcoinjs-ui/index.html + const TEMPLATE_PSBT = + 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgpMvYEJ/dp36svRJyRtNnpSo7bQAAAAAAAAAAAA='; + + it('computes the fee for a PSBT successfully', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.ComputeFee, + params: { + psbt: TEMPLATE_PSBT, + feeRate: 3, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWith({ + pending: false, + result: { + fee: '632', + }, + }); + }); + + it('fails if invalid PSBT', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.ComputeFee, + params: { + psbt: 'notAPsbt', + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: 'Invalid format: Invalid PSBT', + data: { + cause: null, + transaction: 'notAPsbt', + }, + stack: expect.anything(), + }); + }); + }); + + describe('broadcastPsbt', () => { + // PSBTs can be decoded here: https://bitcoincore.tech/apps/bitcoinjs-ui/index.html + const TEMPLATE_PSBT = + 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgpMvYEJ/dp36svRJyRtNnpSo7bQAAAAAAAAAAAA='; + + it('broadcasts a PSBT successfully', async () => { + // Prepare the PSBT to broadcast so we have a valid PSBT to broadcast + let response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SignPsbt, + params: { + psbt: TEMPLATE_PSBT, + feeRate: 3, + options: { + fill: true, + broadcast: false, + }, + }, + }, + } as KeyringRequest, + }); + + const { result } = ( + response.response as { result: { result: FillPsbtResponse } } + ).result; + + response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.BroadcastPsbt, + params: { + psbt: result.psbt, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWith({ + pending: false, + result: { + txid: expect.any(String), + }, + }); + }); + + it('fails if invalid PSBT', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.BroadcastPsbt, + params: { + psbt: 'notAPsbt', + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: 'Invalid format: Invalid PSBT', + data: { + cause: null, + transaction: 'notAPsbt', + }, + stack: expect.anything(), + }); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index fabeb124..a25fcc97 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -12,7 +12,7 @@ import { scopeToCoinType, accountTypeToPurpose, } from './constants'; -import { CurrencyUnit } from '../src/entities'; +import { AccountCapability, CurrencyUnit } from '../src/entities'; import { Caip19Asset } from '../src/handlers/caip'; const ACCOUNT_INDEX = 0; @@ -85,7 +85,7 @@ describe('Keyring', () => { exportable: false, }, scopes: [BtcScope.Regtest], - methods: [], + methods: Object.values(AccountCapability), }); // eslint-disable-next-line jest/no-conditional-in-test @@ -169,7 +169,7 @@ describe('Keyring', () => { exportable: false, }, scopes: [requestOpts.scope], - methods: [], + methods: Object.values(AccountCapability), }); // eslint-disable-next-line jest/no-conditional-in-test diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 5696fc2e..69434e1b 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "OmwSAjbEuVKC/Hb5aewaWV/1HVOamuh/mdnAX/JX4zs=", + "shasum": "KRiQgPkojTCG8vGhlfvsa59UwLoNJZas4x/65Ti48Xo=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index 8978b99e..429f4f8f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -63,6 +63,11 @@ export type BitcoinAccount = { */ publicAddress: Address; + /** + * The capabilities of the account. + */ + capabilities: AccountCapability[]; + /** * Get an address at a given index. * @@ -124,9 +129,18 @@ export type BitcoinAccount = { * Sign a PSBT with all the registered signers * * @param psbt - The PSBT to be signed. - * @returns the signed transaction + * @returns the signed PSBT */ - sign(psbt: Psbt, maxFeeRate?: number): Transaction; + sign(psbt: Psbt): Psbt; + + /** + * Extract the transaction from a PSBT. + * + * @param psbt - The PSBT. + * @param maxFeeRate - The maximum fee rate to use for the transaction. + * @returns the transaction + */ + extractTransaction(psbt: Psbt, maxFeeRate?: number): Transaction; /** * Get the list of UTXOs @@ -188,6 +202,13 @@ export type BitcoinAccount = { applyUnconfirmedTx(tx: Transaction, lastSeen: number): void; }; +export enum AccountCapability { + SignPsbt = 'signPsbt', + ComputeFee = 'computeFee', + FillPsbt = 'fillPsbt', + BroadcastPsbt = 'broadcastPsbt', +} + /** * BitcoinAccountRepository is a repository that manages Bitcoin accounts. */ diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index fff7926d..f2ec9233 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -11,16 +11,19 @@ import type { import { Address } from '@metamask/bitcoindevkit'; import type { DiscoveredAccount, + KeyringResponse, Transaction as KeyringTransaction, + KeyringRequest, } from '@metamask/keyring-api'; import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; import type { BitcoinAccount } from '../entities'; -import { CurrencyUnit, Purpose } from '../entities'; +import { AccountCapability, CurrencyUnit, Purpose } from '../entities'; import { scopeToNetwork, caipToAddressType, Caip19Asset } from './caip'; import { KeyringHandler, CreateAccountRequest } from './KeyringHandler'; +import type { KeyringRequestHandler } from './KeyringRequestHandler'; import { mapToDiscoveredAccount } from './mappings'; import type { AccountUseCases, @@ -43,6 +46,7 @@ jest.mock('@metamask/bitcoindevkit', () => { }); describe('KeyringHandler', () => { + const mockKeyringRequest = mock(); const mockAccounts = mock(); const mockAddress = mock
({ toString: () => 'bc1qaddress...', @@ -58,10 +62,15 @@ describe('KeyringHandler', () => { entropySource: 'myEntropy', accountIndex: 0, publicAddress: mockAddress, + capabilities: [AccountCapability.SignPsbt, AccountCapability.ComputeFee], }); const defaultAddressType: AddressType = 'p2wpkh'; - const handler = new KeyringHandler(mockAccounts, defaultAddressType); + const handler = new KeyringHandler( + mockKeyringRequest, + mockAccounts, + defaultAddressType, + ); beforeEach(() => { mockAccounts.create.mockResolvedValue(mockAccount); @@ -362,7 +371,7 @@ describe('KeyringHandler', () => { }, exportable: false, }, - methods: [], + methods: mockAccount.capabilities, }; const result = await handler.getAccount('some-id'); @@ -398,7 +407,7 @@ describe('KeyringHandler', () => { }, exportable: false, }, - methods: [], + methods: mockAccount.capabilities, }, ]; @@ -656,4 +665,17 @@ describe('KeyringHandler', () => { ); }); }); + + describe('submitRequest', () => { + it('calls KeyringRequestHandler', async () => { + const mockRequest = mock(); + const expectedResponse = mock(); + mockKeyringRequest.route.mockResolvedValue(expectedResponse); + + const result = await handler.submitRequest(mockRequest); + + expect(mockKeyringRequest.route).toHaveBeenCalledWith(mockRequest); + expect(result).toStrictEqual(expectedResponse); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 184cfd66..59d53b38 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -13,6 +13,7 @@ import { ListAccountsRequestStruct, ListAccountTransactionsRequestStruct, MetaMaskOptionsStruct, + SubmitRequestRequestStruct, } from '@metamask/keyring-api'; import type { Keyring, @@ -26,6 +27,7 @@ import type { Pagination, MetaMaskOptions, DiscoveredAccount, + KeyringRequest, } from '@metamask/keyring-api'; import type { Json, JsonRpcRequest } from '@metamask/utils'; import { @@ -52,6 +54,7 @@ import { scopeToNetwork, networkToScope, } from './caip'; +import type { KeyringRequestHandler } from './KeyringRequestHandler'; import { mapToDiscoveredAccount, mapToKeyringAccount, @@ -74,9 +77,16 @@ export const CreateAccountRequest = object({ export class KeyringHandler implements Keyring { readonly #accountsUseCases: AccountUseCases; + readonly #keyringRequest: KeyringRequestHandler; + readonly #defaultAddressType: AddressType; - constructor(accounts: AccountUseCases, defaultAddressType: AddressType) { + constructor( + keyringRequest: KeyringRequestHandler, + accounts: AccountUseCases, + defaultAddressType: AddressType, + ) { + this.#keyringRequest = keyringRequest; this.#accountsUseCases = accounts; this.#defaultAddressType = defaultAddressType; } @@ -132,6 +142,10 @@ export class KeyringHandler implements Keyring { await this.deleteAccount(request.params.id); return null; } + case `${KeyringRpcMethod.SubmitRequest}`: { + assert(request, SubmitRequestRequestStruct); + return this.submitRequest(request.params); + } default: { throw new InexistentMethodError('Keyring method not supported', { @@ -294,8 +308,8 @@ export class KeyringHandler implements Keyring { }; } - async submitRequest(): Promise { - throw new InexistentMethodError('Method not supported.'); + async submitRequest(request: KeyringRequest): Promise { + return this.#keyringRequest.route(request); } #extractAddressType(path: string): AddressType { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts new file mode 100644 index 00000000..30203fe2 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts @@ -0,0 +1,306 @@ +import type { Txid, Psbt, Amount } from '@metamask/bitcoindevkit'; +import type { KeyringRequest } from '@metamask/keyring-api'; +import { mock } from 'jest-mock-extended'; +import { assert } from 'superstruct'; + +import type { AccountUseCases } from '../use-cases'; +import { + BroadcastPsbtRequest, + ComputeFeeRequest, + FillPsbtRequest, + KeyringRequestHandler, + SignPsbtRequest, +} from './KeyringRequestHandler'; +import { AccountCapability } from '../entities'; +import { parsePsbt } from './parsers'; + +jest.mock('superstruct', () => ({ + ...jest.requireActual('superstruct'), + assert: jest.fn(), +})); + +const mockPsbt = mock(); +jest.mock('./parsers', () => ({ + parsePsbt: jest.fn(), +})); + +describe('KeyringRequestHandler', () => { + const mockAccountsUseCases = mock(); + const origin = 'metamask'; + + const handler = new KeyringRequestHandler(mockAccountsUseCases); + + beforeEach(() => { + jest.mocked(parsePsbt).mockReturnValue(mockPsbt); + }); + + describe('route', () => { + const mockRequest = mock({ + origin, + request: {}, + id: 'account-id', + scope: 'scope', + account: 'account-id', + }); + + it('throws error if unrecognized method', async () => { + await expect( + handler.route({ ...mockRequest, request: { method: 'randomMethod' } }), + ).rejects.toThrow('Unrecognized Bitcoin account capability'); + }); + }); + + describe('signPsbt', () => { + const mockOptions = { fill: false, broadcast: true }; + const mockRequest = mock({ + origin, + request: { + method: AccountCapability.SignPsbt, + params: { + psbt: 'psbtBase64', + feeRate: 3, + options: mockOptions, + }, + }, + account: 'account-id', + }); + + it('executes signPsbt', async () => { + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + txid: mock({ + toString: jest.fn().mockReturnValue('txid'), + }), + }); + + const result = await handler.route(mockRequest); + + expect(assert).toHaveBeenCalledWith( + mockRequest.request.params, + SignPsbtRequest, + ); + expect(mockAccountsUseCases.signPsbt).toHaveBeenCalledWith( + 'account-id', + mockPsbt, + 'metamask', + mockOptions, + 3, + ); + expect(result).toStrictEqual({ + pending: false, + result: { psbt: 'psbtBase64', txid: 'txid' }, + }); + }); + + it('propagates errors from parsePsbt', async () => { + const error = new Error('parsePsbt'); + jest.mocked(parsePsbt).mockImplementationOnce(() => { + throw error; + }); + + await expect( + handler.route({ + ...mockRequest, + request: { ...mockRequest.request, params: { psbt: 'invalidPsbt' } }, + }), + ).rejects.toThrow(error); + + expect(mockAccountsUseCases.signPsbt).not.toHaveBeenCalled(); + }); + + it('propagates errors from signPsbt', async () => { + const error = new Error(); + mockAccountsUseCases.signPsbt.mockRejectedValue(error); + + await expect(handler.route(mockRequest)).rejects.toThrow(error); + + expect(mockAccountsUseCases.signPsbt).toHaveBeenCalled(); + }); + }); + + describe('computeFee', () => { + const mockRequest = mock({ + request: { + method: AccountCapability.ComputeFee, + params: { + psbt: 'psbtBase64', + feeRate: 3, + }, + }, + account: 'account-id', + }); + + it('executes computeFee', async () => { + mockAccountsUseCases.computeFee.mockResolvedValue( + mock({ + // eslint-disable-next-line @typescript-eslint/naming-convention + to_sat: () => BigInt(1000), + }), + ); + + const result = await handler.route(mockRequest); + + expect(assert).toHaveBeenCalledWith( + mockRequest.request.params, + ComputeFeeRequest, + ); + expect(mockAccountsUseCases.computeFee).toHaveBeenCalledWith( + 'account-id', + mockPsbt, + 3, + ); + expect(result).toStrictEqual({ + pending: false, + result: { fee: '1000' }, + }); + }); + + it('propagates errors from parsePsbt', async () => { + const error = new Error('parsePsbt'); + jest.mocked(parsePsbt).mockImplementationOnce(() => { + throw error; + }); + + await expect( + handler.route({ + ...mockRequest, + request: { ...mockRequest.request, params: { psbt: 'invalidPsbt' } }, + }), + ).rejects.toThrow(error); + + expect(mockAccountsUseCases.computeFee).not.toHaveBeenCalled(); + }); + + it('propagates errors from computeFee', async () => { + const error = new Error(); + mockAccountsUseCases.computeFee.mockRejectedValue(error); + + await expect(handler.route(mockRequest)).rejects.toThrow(error); + + expect(mockAccountsUseCases.computeFee).toHaveBeenCalled(); + }); + }); + + describe('fillPsbt', () => { + const mockRequest = mock({ + request: { + method: AccountCapability.FillPsbt, + params: { + psbt: 'psbtBase64', + feeRate: 3, + }, + }, + account: 'account-id', + }); + + it('executes fillPsbt', async () => { + const mockFilledPsbt = mock({ + toString: jest.fn().mockReturnValue('filledPsbtBase64'), + }); + mockAccountsUseCases.fillPsbt.mockResolvedValue(mockFilledPsbt); + + const result = await handler.route(mockRequest); + + expect(assert).toHaveBeenCalledWith( + mockRequest.request.params, + FillPsbtRequest, + ); + expect(mockAccountsUseCases.fillPsbt).toHaveBeenCalledWith( + 'account-id', + mockPsbt, + 3, + ); + expect(result).toStrictEqual({ + pending: false, + result: { psbt: 'filledPsbtBase64' }, + }); + }); + + it('propagates errors from parsePsbt', async () => { + const error = new Error('parsePsbt'); + jest.mocked(parsePsbt).mockImplementationOnce(() => { + throw error; + }); + + await expect( + handler.route({ + ...mockRequest, + request: { ...mockRequest.request, params: { psbt: 'invalidPsbt' } }, + }), + ).rejects.toThrow(error); + + expect(mockAccountsUseCases.fillPsbt).not.toHaveBeenCalled(); + }); + + it('propagates errors from fillPsbt', async () => { + const error = new Error(); + mockAccountsUseCases.fillPsbt.mockRejectedValue(error); + + await expect(handler.route(mockRequest)).rejects.toThrow(error); + + expect(mockAccountsUseCases.fillPsbt).toHaveBeenCalled(); + }); + }); + + describe('broadcastPsbt', () => { + const mockRequest = mock({ + origin, + request: { + method: AccountCapability.BroadcastPsbt, + params: { + psbt: 'psbtBase64', + feeRate: 3, + }, + }, + account: 'account-id', + }); + + it('executes broadcastPsbt', async () => { + const mockTxid = mock({ + toString: jest.fn().mockReturnValue('txid'), + }); + mockAccountsUseCases.broadcastPsbt.mockResolvedValue(mockTxid); + + const result = await handler.route(mockRequest); + + expect(assert).toHaveBeenCalledWith( + mockRequest.request.params, + BroadcastPsbtRequest, + ); + expect(mockAccountsUseCases.broadcastPsbt).toHaveBeenCalledWith( + 'account-id', + mockPsbt, + origin, + ); + expect(result).toStrictEqual({ + pending: false, + result: { txid: 'txid' }, + }); + }); + + it('propagates errors from parsePsbt', async () => { + const error = new Error('parsePsbt'); + jest.mocked(parsePsbt).mockImplementationOnce(() => { + throw error; + }); + + await expect( + handler.route({ + ...mockRequest, + request: { ...mockRequest.request, params: { psbt: 'invalidPsbt' } }, + }), + ).rejects.toThrow(error); + + expect(mockAccountsUseCases.broadcastPsbt).not.toHaveBeenCalled(); + }); + + it('propagates errors from fillPsbt', async () => { + const error = new Error(); + mockAccountsUseCases.broadcastPsbt.mockRejectedValue(error); + + await expect(handler.route(mockRequest)).rejects.toThrow(error); + + expect(mockAccountsUseCases.broadcastPsbt).toHaveBeenCalled(); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts new file mode 100644 index 00000000..5b1abf71 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts @@ -0,0 +1,162 @@ +import type { KeyringRequest, KeyringResponse } from '@metamask/keyring-api'; +import type { Json } from '@metamask/utils'; +import { assert, boolean, number, object, optional, string } from 'superstruct'; + +import { AccountCapability, InexistentMethodError } from '../entities'; +import { parsePsbt } from './parsers'; +import type { AccountUseCases } from '../use-cases/AccountUseCases'; + +export const SignPsbtRequest = object({ + psbt: string(), + feeRate: optional(number()), + options: object({ + fill: boolean(), + broadcast: boolean(), + }), +}); + +export type SignPsbtResponse = { + psbt: string; + txid: string | null; +}; + +export const ComputeFeeRequest = object({ + psbt: string(), + feeRate: optional(number()), +}); + +export type ComputeFeeResponse = { + // Fee in satoshis + fee: string; +}; + +export const BroadcastPsbtRequest = object({ + psbt: string(), +}); + +export type BroadcastPsbtResponse = { + txid: string; +}; + +export const FillPsbtRequest = object({ + psbt: string(), + feeRate: optional(number()), +}); + +export type FillPsbtResponse = { + psbt: string; +}; + +export class KeyringRequestHandler { + readonly #accountsUseCases: AccountUseCases; + + constructor(accounts: AccountUseCases) { + this.#accountsUseCases = accounts; + } + + async route(request: KeyringRequest): Promise { + const { account, request: requestData, origin } = request; + const { method, params } = requestData; + + switch (method as AccountCapability) { + case AccountCapability.SignPsbt: { + assert(params, SignPsbtRequest); + const { psbt, feeRate, options } = params; + return this.#signPsbt(account, psbt, origin, options, feeRate); + } + case AccountCapability.FillPsbt: { + assert(params, FillPsbtRequest); + return this.#fillPsbt(account, params.psbt, params.feeRate); + } + case AccountCapability.ComputeFee: { + assert(params, ComputeFeeRequest); + return this.#computeFee(account, params.psbt, params.feeRate); + } + case AccountCapability.BroadcastPsbt: { + assert(params, BroadcastPsbtRequest); + return this.#broadcastPsbt(account, params.psbt, origin); + } + default: { + throw new InexistentMethodError( + 'Unrecognized Bitcoin account capability', + { + account, + method, + }, + ); + } + } + } + + async #signPsbt( + id: string, + psbtBase64: string, + origin: string, + options: { fill: boolean; broadcast: boolean }, + feeRate?: number, + ): Promise { + const { psbt, txid } = await this.#accountsUseCases.signPsbt( + id, + parsePsbt(psbtBase64), + origin, + options, + feeRate, + ); + return this.#toKeyringResponse({ + psbt: psbt.toString(), + txid: txid?.toString() ?? null, + } as SignPsbtResponse); + } + + async #fillPsbt( + id: string, + psbtBase64: string, + feeRate?: number, + ): Promise { + const psbt = await this.#accountsUseCases.fillPsbt( + id, + parsePsbt(psbtBase64), + feeRate, + ); + return this.#toKeyringResponse({ + psbt: psbt.toString(), + } as FillPsbtResponse); + } + + async #computeFee( + id: string, + psbtBase64: string, + feeRate?: number, + ): Promise { + const fee = await this.#accountsUseCases.computeFee( + id, + parsePsbt(psbtBase64), + feeRate, + ); + return this.#toKeyringResponse({ + fee: fee.to_sat().toString(), + } as ComputeFeeResponse); + } + + async #broadcastPsbt( + id: string, + psbtBase64: string, + origin: string, + ): Promise { + const txid = await this.#accountsUseCases.broadcastPsbt( + id, + parsePsbt(psbtBase64), + origin, + ); + return this.#toKeyringResponse({ + txid: txid.toString(), + } as BroadcastPsbtResponse); + } + + #toKeyringResponse(result: Json): KeyringResponse { + return { + pending: false, + result, + }; + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index 9cc8a4df..7f3d9a65 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -75,11 +75,12 @@ describe('RpcHandler', () => { it('executes startSendTransactionFlow', async () => { mockSendFlowUseCases.display.mockResolvedValue(mockPsbt); - mockAccountsUseCases.sendPsbt.mockResolvedValue( - mock({ + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + txid: mock({ toString: jest.fn().mockReturnValue('txId'), }), - ); + }); const result = await handler.route(origin, mockRequest); @@ -88,10 +89,11 @@ describe('RpcHandler', () => { CreateSendFormRequest, ); expect(mockSendFlowUseCases.display).toHaveBeenCalledWith('account-id'); - expect(mockAccountsUseCases.sendPsbt).toHaveBeenCalledWith( + expect(mockAccountsUseCases.signPsbt).toHaveBeenCalledWith( 'account-id', mockPsbt, 'metamask', + { broadcast: true, fill: false }, ); expect(result).toStrictEqual({ transactionId: 'txId' }); }); @@ -103,18 +105,18 @@ describe('RpcHandler', () => { await expect(handler.route(origin, mockRequest)).rejects.toThrow(error); expect(mockSendFlowUseCases.display).toHaveBeenCalled(); - expect(mockAccountsUseCases.sendPsbt).not.toHaveBeenCalled(); + expect(mockAccountsUseCases.signPsbt).not.toHaveBeenCalled(); }); it('propagates errors from send', async () => { const error = new Error(); mockSendFlowUseCases.display.mockResolvedValue(mockPsbt); - mockAccountsUseCases.sendPsbt.mockRejectedValue(error); + mockAccountsUseCases.signPsbt.mockRejectedValue(error); await expect(handler.route(origin, mockRequest)).rejects.toThrow(error); expect(mockSendFlowUseCases.display).toHaveBeenCalled(); - expect(mockAccountsUseCases.sendPsbt).toHaveBeenCalled(); + expect(mockAccountsUseCases.signPsbt).toHaveBeenCalled(); }); }); @@ -130,30 +132,32 @@ describe('RpcHandler', () => { }); it('executes signAndSendTransaction', async () => { - mockAccountsUseCases.fillAndSendPsbt.mockResolvedValue( - mock({ + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + txid: mock({ toString: jest.fn().mockReturnValue('txId'), }), - ); + }); const result = await handler.route(origin, mockRequest); expect(assert).toHaveBeenCalledWith(mockRequest.params, SendPsbtRequest); - expect(mockAccountsUseCases.fillAndSendPsbt).toHaveBeenCalledWith( + expect(mockAccountsUseCases.signPsbt).toHaveBeenCalledWith( 'account-id', mockPsbt, 'metamask', + { broadcast: true, fill: true }, ); expect(result).toStrictEqual({ transactionId: 'txId' }); }); it('propagates errors from signAndSendTransaction', async () => { const error = new Error(); - mockAccountsUseCases.fillAndSendPsbt.mockRejectedValue(error); + mockAccountsUseCases.signPsbt.mockRejectedValue(error); await expect(handler.route(origin, mockRequest)).rejects.toThrow(error); - expect(mockAccountsUseCases.fillAndSendPsbt).toHaveBeenCalled(); + expect(mockAccountsUseCases.signPsbt).toHaveBeenCalled(); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index c3707a3e..8032ca4a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -1,14 +1,18 @@ -import { Psbt } from '@metamask/bitcoindevkit'; import { BtcScope } from '@metamask/keyring-api'; import type { Json, JsonRpcRequest } from '@metamask/utils'; import { assert, enums, object, optional, string } from 'superstruct'; import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; import { validateOrigin } from './permissions'; -import { FormatError, InexistentMethodError } from '../entities'; +import { + AssertionError, + FormatError, + InexistentMethodError, +} from '../entities'; import { scopeToNetwork } from './caip'; import type { TransactionFee } from './mappings'; import { mapToTransactionFees } from './mappings'; +import { parsePsbt } from './parsers'; export enum RpcMethod { StartSendTransactionFlow = 'startSendTransactionFlow', @@ -88,7 +92,16 @@ export class RpcHandler { if (!psbt) { return null; } - const txid = await this.#accountUseCases.sendPsbt(account, psbt, origin); + const { txid } = await this.#accountUseCases.signPsbt( + account, + psbt, + origin, + { fill: false, broadcast: true }, + ); + if (!txid) { + throw new AssertionError('Missing transaction ID '); + } + return { transactionId: txid.toString() }; } @@ -97,13 +110,20 @@ export class RpcHandler { transaction: string, origin: string, ): Promise { - const psbt: Psbt = this.#parsePsbt(transaction); + const psbt = parsePsbt(transaction); - const txid = await this.#accountUseCases.fillAndSendPsbt( + const { txid } = await this.#accountUseCases.signPsbt( accountId, psbt, origin, + { + fill: true, + broadcast: true, + }, ); + if (!txid) { + throw new AssertionError('Missing transaction ID '); + } return { transactionId: txid.toString() }; } @@ -113,17 +133,9 @@ export class RpcHandler { transaction: string, scope: BtcScope, ): Promise { - const psbt = this.#parsePsbt(transaction); + const psbt = parsePsbt(transaction); const amount = await this.#accountUseCases.computeFee(accountId, psbt); return [mapToTransactionFees(amount, scopeToNetwork[scope])]; } - - #parsePsbt(transaction: string): Psbt { - try { - return Psbt.from_string(transaction); - } catch (error) { - throw new FormatError('Invalid PSBT', { transaction }, error); - } - } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index 22cdf737..3bd37d85 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -65,7 +65,7 @@ export function mapToKeyringAccount(account: BitcoinAccount): KeyringAccount { groupIndex: account.accountIndex, }, }, - methods: [], + methods: account.capabilities, }; } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/parsers.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/parsers.ts new file mode 100644 index 00000000..3b96a918 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/parsers.ts @@ -0,0 +1,18 @@ +import { Psbt } from '@metamask/bitcoindevkit'; + +import { FormatError } from '../entities'; + +/** + * Parses a PSBT encoded as a base64 string into a PSBT object. + * + * @param psbtBase64 - A PSBT encoded as a base64 string + * @returns A PSBT object + * @throws {FormatError} If the PSBT is invalid. + */ +export function parsePsbt(psbtBase64: string): Psbt { + try { + return Psbt.from_string(psbtBase64); + } catch (error) { + throw new FormatError('Invalid PSBT', { transaction: psbtBase64 }, error); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 7430f097..cbb48fee 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -19,6 +19,7 @@ import { AssetsHandler, } from './handlers'; import { HandlerMiddleware } from './handlers/HandlerMiddleware'; +import { KeyringRequestHandler } from './handlers/KeyringRequestHandler'; import { SnapClientAdapter, EsploraClientAdapter, @@ -64,7 +65,9 @@ const sendFlowUseCases = new SendFlowUseCases( const assetsUseCases = new AssetsUseCases(logger, assetRatesClient); // Application layer +const keyringRequestHandler = new KeyringRequestHandler(accountsUseCases); const keyringHandler = new KeyringHandler( + keyringRequestHandler, accountsUseCases, Config.defaultAddressType, ); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index e35bd1f3..828016b4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -25,6 +25,7 @@ import { } from '@metamask/bitcoindevkit'; import { + AccountCapability, ValidationError, WalletError, type BitcoinAccount, @@ -39,10 +40,13 @@ export class BdkAccountAdapter implements BitcoinAccount { readonly #wallet: Wallet; + readonly #capabilities: AccountCapability[]; + constructor(id: string, derivationPath: string[], wallet: Wallet) { this.#id = id; this.#derivationPath = derivationPath; this.#wallet = wallet; + this.#capabilities = Object.values(AccountCapability); } static create( @@ -119,6 +123,10 @@ export class BdkAccountAdapter implements BitcoinAccount { return this.peekAddress(0).address; } + get capabilities(): AccountCapability[] { + return this.#capabilities; + } + peekAddress(index: number): AddressInfo { return this.#wallet.peek_address('external', index); } @@ -151,17 +159,19 @@ export class BdkAccountAdapter implements BitcoinAccount { return new BdkTxBuilderAdapter(this.#wallet.build_tx(), this.network); } - sign(psbt: Psbt, maxFeeRate?: number): Transaction { + sign(psbt: Psbt): Psbt { try { const finalized = this.#wallet.sign(psbt, new SignOptions()); // Signing a PSBT does not mean that the tx/psbt is final, like in a multi-sig setup. - // This ensures we only support signing where the psbt is final after. Will add comment. + // This ensures we only support signing where the psbt is final after. if (!finalized) { throw new WalletError('PSBT not finalized', { psbt: psbt.toString(), id: this.#id, }); } + + return psbt; } catch (error) { throw new WalletError( 'failed to sign PSBT', @@ -172,7 +182,9 @@ export class BdkAccountAdapter implements BitcoinAccount { error, ); } + } + extractTransaction(psbt: Psbt, maxFeeRate?: number): Transaction { try { return maxFeeRate ? psbt.extract_tx_with_fee_rate_limit( diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index a2913ce2..2d5dc225 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -22,7 +22,11 @@ import type { SnapClient, TransactionBuilder, } from '../entities'; -import { TrackingSnapEvent, ValidationError } from '../entities'; +import { + AccountCapability, + TrackingSnapEvent, + ValidationError, +} from '../entities'; import type { CreateAccountParams, DiscoverAccountParams, @@ -784,41 +788,107 @@ describe('AccountUseCases', () => { }); }); - describe('sendPsbt', () => { + describe('signPsbt', () => { const mockTxid = mock(); - const mockPsbt = mock(); + const mockOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + const mockPsbt = mock({ + unsigned_tx: { + output: [mockOutput], + }, + toString, + }); const mockTransaction = mock({ // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 /* eslint-disable @typescript-eslint/naming-convention */ compute_txid: jest.fn(), clone: jest.fn(), }); + const mockSignedPsbt = mock({ + toString: () => 'mockSignedPsbt', + }); const mockAccount = mock({ network: 'bitcoin', sign: jest.fn(), + capabilities: [AccountCapability.SignPsbt], }); const mockWalletTx = mock(); + const mockFeeRate = 3; + const mockFeeEstimates = mock({ + get: () => mockFeeRate, + }); + const mockFilledPsbt = mock(); + const mockTxBuilder = mock({ + addRecipientByScript: jest.fn(), + feeRate: jest.fn(), + drainToByScript: jest.fn(), + drainWallet: jest.fn(), + finish: jest.fn(), + unspendable: jest.fn(), + }); beforeEach(() => { mockRepository.getWithSigner.mockResolvedValue(mockAccount); mockTransaction.compute_txid.mockReturnValue(mockTxid); mockTransaction.clone.mockReturnThis(); + mockAccount.buildTx.mockReturnValue(mockTxBuilder); + mockAccount.sign.mockReturnValue(mockSignedPsbt); + mockAccount.extractTransaction.mockReturnValue(mockTransaction); + mockTxBuilder.addRecipientByScript.mockReturnThis(); + mockTxBuilder.feeRate.mockReturnThis(); + mockTxBuilder.drainToByScript.mockReturnThis(); + mockTxBuilder.untouchedOrdering.mockReturnThis(); + mockTxBuilder.finish.mockReturnValue(mockFilledPsbt); + mockTxBuilder.unspendable.mockReturnThis(); + mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); }); it('throws error if account is not found', async () => { mockRepository.getWithSigner.mockResolvedValue(null); await expect( - useCases.sendPsbt('non-existent-id', mockPsbt, 'metamask'), + useCases.signPsbt('non-existent-id', mockPsbt, 'metamask', { + fill: false, + broadcast: false, + }), ).rejects.toThrow('Account not found'); }); - it('sends transaction', async () => { - mockAccount.sign.mockReturnValue(mockTransaction); + it('signs a PSBT', async () => { mockAccount.getTransaction.mockReturnValue(mockWalletTx); mockTransaction.compute_txid.mockReturnValue(mockTxid); - const txId = await useCases.sendPsbt('account-id', mockPsbt, 'metamask'); + const { txid, psbt } = await useCases.signPsbt( + 'account-id', + mockPsbt, + 'metamask', + { + fill: false, + broadcast: false, + }, + ); + + expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); + expect(mockAccount.sign).toHaveBeenCalledWith(mockPsbt); + expect(txid).toBeUndefined(); + expect(psbt).toBe('mockSignedPsbt'); + }); + + it('signs and broadcasts a PSBT', async () => { + mockAccount.getTransaction.mockReturnValue(mockWalletTx); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + + const { txid, psbt } = await useCases.signPsbt( + 'account-id', + mockPsbt, + 'metamask', + { + fill: false, + broadcast: true, + }, + ); expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); expect(mockAccount.sign).toHaveBeenCalledWith(mockPsbt); @@ -840,7 +910,49 @@ describe('AccountUseCases', () => { mockWalletTx, 'metamask', ); - expect(txId).toBe(mockTxid); + expect(txid).toBe(mockTxid); + expect(psbt).toBe('mockSignedPsbt'); + }); + + it('fills, signs and broadcasts a PSBT', async () => { + mockAccount.getTransaction.mockReturnValue(mockWalletTx); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + + const { psbt, txid } = await useCases.signPsbt( + 'account-id', + mockPsbt, + 'metamask', + { + fill: true, + broadcast: true, + }, + ); + + expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); + expect(mockChain.getFeeEstimates).toHaveBeenCalledWith( + mockAccount.network, + ); + expect(mockAccount.sign).toHaveBeenCalledWith(mockFilledPsbt); + expect(mockChain.broadcast).toHaveBeenCalledWith( + mockAccount.network, + mockTransaction, + ); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(mockTransaction.compute_txid).toHaveBeenCalled(); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount, [mockWalletTx]); + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionSubmitted, + mockAccount, + mockWalletTx, + 'metamask', + ); + expect(txid).toBe(mockTxid); + expect(psbt).toBe('mockSignedPsbt'); }); it('propagates an error if getWithSigner fails', async () => { @@ -848,33 +960,39 @@ describe('AccountUseCases', () => { mockRepository.getWithSigner.mockRejectedValueOnce(error); await expect( - useCases.sendPsbt('account-id', mockPsbt, 'metamask'), + useCases.signPsbt('account-id', mockPsbt, 'metamask', { + fill: false, + broadcast: false, + }), ).rejects.toBe(error); }); it('propagates an error if broadcast fails', async () => { const error = new Error('broadcast failed'); - mockAccount.sign.mockReturnValue(mockTransaction); mockChain.broadcast.mockRejectedValueOnce(error); await expect( - useCases.sendPsbt('account-id', mockPsbt, 'metamask'), + useCases.signPsbt('account-id', mockPsbt, 'metamask', { + fill: false, + broadcast: true, + }), ).rejects.toBe(error); }); it('propagates an error if update fails', async () => { const error = new Error('update failed'); - mockAccount.sign.mockReturnValue(mockTransaction); mockRepository.update.mockRejectedValue(error); await expect( - useCases.sendPsbt('account-id', mockPsbt, 'metamask'), + useCases.signPsbt('account-id', mockPsbt, 'metamask', { + fill: false, + broadcast: true, + }), ).rejects.toBe(error); }); }); - describe('fillAndSendPsbt', () => { - const mockTxid = mock(); + describe('fillPsbt', () => { const mockOutput = mock({ script_pubkey: mock(), value: mock(), @@ -885,19 +1003,13 @@ describe('AccountUseCases', () => { }, toString: () => 'base64Psbt', }); - const mockTransaction = mock({ - // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 - /* eslint-disable @typescript-eslint/naming-convention */ - compute_txid: jest.fn(), - clone: jest.fn(), - }); const mockAccount = mock({ id: 'account-id', network: 'bitcoin', sign: jest.fn(), isMine: () => false, + capabilities: [AccountCapability.FillPsbt], }); - const mockWalletTx = mock(); const mockFeeRate = 3; const mockFeeEstimates = mock({ get: () => mockFeeRate, @@ -914,10 +1026,8 @@ describe('AccountUseCases', () => { }); beforeEach(() => { - mockRepository.getWithSigner.mockResolvedValue(mockAccount); + mockRepository.get.mockResolvedValue(mockAccount); mockRepository.getFrozenUTXOs.mockResolvedValue(mockFrozenUTXOs); - mockTransaction.compute_txid.mockReturnValue(mockTxid); - mockTransaction.clone.mockReturnThis(); mockAccount.buildTx.mockReturnValue(mockTxBuilder); mockTxBuilder.addRecipientByScript.mockReturnThis(); mockTxBuilder.feeRate.mockReturnThis(); @@ -929,29 +1039,17 @@ describe('AccountUseCases', () => { }); it('throws error if account is not found', async () => { - mockRepository.getWithSigner.mockResolvedValue(null); + mockRepository.get.mockResolvedValue(null); await expect( - useCases.fillAndSendPsbt( - 'non-existent-id', - mockTemplatePsbt, - 'metamask', - ), + useCases.fillPsbt('non-existent-id', mockTemplatePsbt), ).rejects.toThrow('Account not found'); }); - it('fills PSBT without change output, signs and sends transaction', async () => { - mockAccount.sign.mockReturnValue(mockTransaction); - mockAccount.getTransaction.mockReturnValue(mockWalletTx); - mockTransaction.compute_txid.mockReturnValue(mockTxid); - - const txid = await useCases.fillAndSendPsbt( - 'account-id', - mockTemplatePsbt, - 'metamask', - ); + it('fills PSBT without change output', async () => { + const psbt = await useCases.fillPsbt('account-id', mockTemplatePsbt); - expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); + expect(mockRepository.get).toHaveBeenCalledWith('account-id'); expect(mockRepository.getFrozenUTXOs).toHaveBeenCalledWith( mockAccount.id, ); @@ -967,55 +1065,29 @@ describe('AccountUseCases', () => { expect(mockTxBuilder.untouchedOrdering).toHaveBeenCalled(); expect(mockTxBuilder.finish).toHaveBeenCalled(); - expect(mockAccount.sign).toHaveBeenCalledWith(mockFilledPsbt); - expect(mockChain.broadcast).toHaveBeenCalledWith( - mockAccount.network, - mockTransaction, - ); - expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); - expect(mockTransaction.compute_txid).toHaveBeenCalled(); - expect( - mockSnapClient.emitAccountBalancesUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount); - expect( - mockSnapClient.emitAccountTransactionsUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount, [mockWalletTx]); - expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( - TrackingSnapEvent.TransactionSubmitted, - mockAccount, - mockWalletTx, - 'metamask', - ); - expect(txid).toBe(mockTxid); + expect(psbt).toBe(mockFilledPsbt); }); - it('fills PSBT with change output, signs and sends transaction', async () => { - mockRepository.getWithSigner.mockResolvedValueOnce({ + it('fills PSBT with change output', async () => { + mockRepository.get.mockResolvedValueOnce({ ...mockAccount, isMine: () => true, }); - mockAccount.sign.mockReturnValue(mockTransaction); - mockAccount.getTransaction.mockReturnValue(mockWalletTx); - mockTransaction.compute_txid.mockReturnValue(mockTxid); - const txId = await useCases.fillAndSendPsbt( - 'account-id', - mockTemplatePsbt, - 'metamask', - ); + const psbt = await useCases.fillPsbt('account-id', mockTemplatePsbt); expect(mockTxBuilder.drainToByScript).toHaveBeenCalledWith( mockOutput.script_pubkey, ); - expect(txId).toBe(mockTxid); + expect(psbt).toBe(mockFilledPsbt); }); - it('propagates an error if getWithSigner fails', async () => { - const error = new Error('getWithSigner failed'); - mockRepository.getWithSigner.mockRejectedValueOnce(error); + it('propagates an error if get fails', async () => { + const error = new Error('get failed'); + mockRepository.get.mockRejectedValueOnce(error); await expect( - useCases.fillAndSendPsbt('account-id', mockTemplatePsbt, 'metamask'), + useCases.fillPsbt('account-id', mockTemplatePsbt), ).rejects.toBe(error); }); @@ -1026,7 +1098,7 @@ describe('AccountUseCases', () => { }); await expect( - useCases.fillAndSendPsbt('account-id', mockTemplatePsbt, 'metamask'), + useCases.fillPsbt('account-id', mockTemplatePsbt), ).rejects.toThrow( new ValidationError( 'Failed to build PSBT from template', @@ -1039,26 +1111,6 @@ describe('AccountUseCases', () => { ), ); }); - - it('propagates an error if broadcast fails', async () => { - const error = new Error('broadcast failed'); - mockAccount.sign.mockReturnValue(mockTransaction); - mockChain.broadcast.mockRejectedValueOnce(error); - - await expect( - useCases.fillAndSendPsbt('account-id', mockTemplatePsbt, 'metamask'), - ).rejects.toBe(error); - }); - - it('propagates an error if update fails', async () => { - const error = new Error('update failed'); - mockAccount.sign.mockReturnValue(mockTransaction); - mockRepository.update.mockRejectedValue(error); - - await expect( - useCases.fillAndSendPsbt('account-id', mockTemplatePsbt, 'metamask'), - ).rejects.toBe(error); - }); }); describe('computeFee', () => { @@ -1077,6 +1129,7 @@ describe('AccountUseCases', () => { id: 'account-id', network: 'bitcoin', isMine: () => false, + capabilities: [AccountCapability.ComputeFee], }); const mockFeeRate = 3; const mockFeeEstimates = mock({ @@ -1093,7 +1146,7 @@ describe('AccountUseCases', () => { }); beforeEach(() => { - mockRepository.getWithSigner.mockResolvedValue(mockAccount); + mockRepository.get.mockResolvedValue(mockAccount); mockRepository.getFrozenUTXOs.mockResolvedValue(mockFrozenUTXOs); mockAccount.buildTx.mockReturnValue(mockTxBuilder); mockTxBuilder.addRecipientByScript.mockReturnThis(); @@ -1107,7 +1160,7 @@ describe('AccountUseCases', () => { }); it('throws error if account is not found', async () => { - mockRepository.getWithSigner.mockResolvedValue(null); + mockRepository.get.mockResolvedValue(null); await expect( useCases.computeFee('account-id', mockTemplatePsbt), @@ -1117,7 +1170,7 @@ describe('AccountUseCases', () => { it('computes fee for PSBT without change output', async () => { const fee = await useCases.computeFee('account-id', mockTemplatePsbt); - expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); + expect(mockRepository.get).toHaveBeenCalledWith('account-id'); expect(mockRepository.getFrozenUTXOs).toHaveBeenCalledWith('account-id'); expect(mockChain.getFeeEstimates).toHaveBeenCalledWith('bitcoin'); expect(mockTxBuilder.unspendable).toHaveBeenCalledWith(mockFrozenUTXOs); @@ -1132,7 +1185,7 @@ describe('AccountUseCases', () => { }); it('computes fee for PSBT with change output', async () => { - mockRepository.getWithSigner.mockResolvedValueOnce({ + mockRepository.get.mockResolvedValueOnce({ ...mockAccount, isMine: () => true, }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 3322bb30..86e100cf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -3,12 +3,14 @@ import type { Amount, Network, Psbt, + Transaction, Txid, WalletTx, } from '@metamask/bitcoindevkit'; import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; import { + AccountCapability, addressTypeToPurpose, type BitcoinAccount, type BitcoinAccountRepository, @@ -17,6 +19,7 @@ import { type MetaProtocolsClient, networkToCoinType, NotFoundError, + PermissionError, type SnapClient, TrackingSnapEvent, ValidationError, @@ -305,73 +308,130 @@ export class AccountUseCases { this.#logger.info('Account deleted successfully: %s', account.id); } - async sendPsbt(id: string, psbt: Psbt, origin: string): Promise { - this.#logger.debug('Sending transaction: %s', id); + async fillPsbt( + id: string, + templatePsbt: Psbt, + feeRate?: number, + ): Promise { + this.#logger.debug('Filling PSBT inputs: %s', id); - const account = await this.#repository.getWithSigner(id); + const account = await this.#repository.get(id); if (!account) { throw new NotFoundError('Account not found', { id }); } + this.#checkCapability(account, AccountCapability.FillPsbt); - const txid = await this.#signAndSendPsbt(account, psbt, origin); + const psbt = await this.#fillPsbt(account, templatePsbt, feeRate); this.#logger.info( - 'Transaction sent successfully: %s. Account: %s, Network: %s', - txid, + 'PSBT filled successfully: %s. Account: %s, Network: %s', account.id, account.network, ); - return txid; + return psbt; } - async fillAndSendPsbt( + async signPsbt( id: string, - templatePsbt: Psbt, + psbt: Psbt, origin: string, - ): Promise { - this.#logger.debug('Filling and sending transaction: %s', id); + options: { fill: boolean; broadcast: boolean }, + feeRate?: number, + ): Promise<{ psbt: string; txid?: Txid }> { + this.#logger.debug('Signing PSBT: %s', id, options); const account = await this.#repository.getWithSigner(id); if (!account) { throw new NotFoundError('Account not found', { id }); } - - const psbt = await this.#fillPsbt(account, templatePsbt); - const txid = await this.#signAndSendPsbt(account, psbt, origin); + this.#checkCapability(account, AccountCapability.SignPsbt); + + const psbtToSign = options.fill + ? await this.#fillPsbt(account, psbt, feeRate) + : psbt; + const signedPsbt = account.sign(psbtToSign); + + if (options.broadcast) { + const psbtString = signedPsbt.toString(); + const tx = account.extractTransaction(signedPsbt); + const txid = await this.#broadcast(account, tx, origin); + + this.#logger.info( + 'Transaction sent successfully: %s. Account: %s, Network: %s, Options: %o', + txid.toString(), + account.id, + account.network, + options, + ); + return { psbt: psbtString, txid }; + } this.#logger.info( - 'Transaction filled and sent successfully: %s. Account: %s, Network: %s', - txid, + 'PSBT signed successfully. Account: %s, Network: %s, Options: %o', account.id, account.network, + options, ); - - return txid; + return { psbt: signedPsbt.toString() }; } - async computeFee(id: string, templatePsbt: Psbt): Promise { + async computeFee( + id: string, + templatePsbt: Psbt, + feeRate?: number, + ): Promise { this.#logger.debug('Getting fee amount for Psbt for account id: %s', id); - const account = await this.#repository.getWithSigner(id); + const account = await this.#repository.get(id); if (!account) { throw new NotFoundError('Account not found', { id }); } + this.#checkCapability(account, AccountCapability.ComputeFee); - const psbt = await this.#fillPsbt(account, templatePsbt); + const psbt = await this.#fillPsbt(account, templatePsbt, feeRate); return psbt.fee(); } - async #fillPsbt(account: BitcoinAccount, templatePsbt: Psbt): Promise { + async broadcastPsbt(id: string, psbt: Psbt, origin: string): Promise { + this.#logger.debug('Sending transaction: %s', id); + + const account = await this.#repository.get(id); + if (!account) { + throw new NotFoundError('Account not found', { id }); + } + this.#checkCapability(account, AccountCapability.BroadcastPsbt); + + const tx = account.extractTransaction(psbt); + const txid = await this.#broadcast(account, tx, origin); + + this.#logger.info( + 'Transaction sent successfully: %s. Account: %s, Network: %s', + txid, + account.id, + account.network, + ); + + return txid; + } + + async #fillPsbt( + account: BitcoinAccount, + templatePsbt: Psbt, + feeRate?: number, + ): Promise { const frozenUTXOs = await this.#repository.getFrozenUTXOs(account.id); const feeEstimates = await this.#chain.getFeeEstimates(account.network); - const feeRate = - feeEstimates.get(this.#targetBlocksConfirmation) ?? this.#fallbackFeeRate; + + const feeRateToUse = + feeRate ?? + feeEstimates.get(this.#targetBlocksConfirmation) ?? + this.#fallbackFeeRate; try { let builder = account .buildTx() - .feeRate(feeRate) + .feeRate(feeRateToUse) .unspendable(frozenUTXOs) .untouchedOrdering(); // we need to strictly adhere to the template output order. Many protocols use the order (e.g: 1: deposit, 2: OP_RETURN, 3: change) @@ -393,19 +453,18 @@ export class AccountUseCases { { id: account.id, templatePsbt: templatePsbt.toString(), - feeRate, + feeRate: feeRateToUse, }, error, ); } } - async #signAndSendPsbt( + async #broadcast( account: BitcoinAccount, - psbt: Psbt, + tx: Transaction, origin: string, ): Promise { - const tx = account.sign(psbt); const txid = tx.compute_txid(); await this.#chain.broadcast(account.network, tx.clone()); account.applyUnconfirmedTx(tx, getCurrentUnixTimestamp()); @@ -430,4 +489,17 @@ export class AccountUseCases { return txid; } + + #checkCapability( + account: BitcoinAccount, + capability: AccountCapability, + ): void { + if (!account.capabilities.includes(capability)) { + throw new PermissionError('Account missing given capability', { + id: account.id, + capability, + capabilities: account.capabilities, + }); + } + } } From f485f13f8c1895f9f9a2f4832c3f2d87b2c709ea Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Fri, 22 Aug 2025 13:03:15 +0200 Subject: [PATCH 275/362] feat: send transfer (#519) --- .../integration-test/keyring-request.test.ts | 64 ++++++++ .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/entities/account.ts | 1 + .../handlers/KeyringRequestHandler.test.ts | 54 +++++++ .../src/handlers/KeyringRequestHandler.ts | 46 +++++- .../src/infra/BdkTxBuilderAdapter.ts | 33 +++- .../src/use-cases/AccountUseCases.test.ts | 152 ++++++++++++++++++ .../src/use-cases/AccountUseCases.ts | 40 +++++ 8 files changed, 384 insertions(+), 8 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts index 4fc4c0f9..7290b288 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts @@ -474,4 +474,68 @@ describe('KeyringRequestHandler', () => { }); }); }); + + describe('sendTransfer', () => { + it('sends funds successfully', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SendTransfer, + params: { + recipients: [ + { + address: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', + amount: '1000', + }, + { + address: 'bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y', + amount: '1000', + }, + ], + feeRate: 3, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWith({ + pending: false, + result: { + txid: expect.any(String), + }, + }); + }); + + it('fails if invalid recipients', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SendTransfer, + params: { + recipients: [{ address: 'notAnAddress', amount: '1000' }], + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32602, + data: { address: 'notAnAddress', amount: '1000', cause: null }, + message: 'Validation failed: Invalid recipient', + stack: expect.anything(), + }); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 69434e1b..8f18ca46 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "KRiQgPkojTCG8vGhlfvsa59UwLoNJZas4x/65Ti48Xo=", + "shasum": "dQsAaHhtEzROifsEyrizAbLtoxcDNyWTHe8hCnBzd0I=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index 429f4f8f..91d6c39c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -207,6 +207,7 @@ export enum AccountCapability { ComputeFee = 'computeFee', FillPsbt = 'fillPsbt', BroadcastPsbt = 'broadcastPsbt', + SendTransfer = 'sendTransfer', } /** diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts index 30203fe2..69a6f441 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts @@ -9,6 +9,7 @@ import { ComputeFeeRequest, FillPsbtRequest, KeyringRequestHandler, + SendTransferRequest, SignPsbtRequest, } from './KeyringRequestHandler'; import { AccountCapability } from '../entities'; @@ -303,4 +304,57 @@ describe('KeyringRequestHandler', () => { expect(mockAccountsUseCases.broadcastPsbt).toHaveBeenCalled(); }); }); + + describe('sendTransfer', () => { + const recipients = [ + { + address: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', + amount: '1000', + }, + ]; + const mockRequest = mock({ + origin, + request: { + method: AccountCapability.SendTransfer, + params: { + recipients, + feeRate: 3, + }, + }, + account: 'account-id', + }); + + it('executes sendTransferq', async () => { + const mockTxid = mock({ + toString: jest.fn().mockReturnValue('txid'), + }); + mockAccountsUseCases.sendTransfer.mockResolvedValue(mockTxid); + + const result = await handler.route(mockRequest); + + expect(assert).toHaveBeenCalledWith( + mockRequest.request.params, + SendTransferRequest, + ); + expect(mockAccountsUseCases.sendTransfer).toHaveBeenCalledWith( + 'account-id', + recipients, + origin, + 3, + ); + expect(result).toStrictEqual({ + pending: false, + result: { txid: 'txid' }, + }); + }); + + it('propagates errors from sendTransfer', async () => { + const error = new Error(); + mockAccountsUseCases.sendTransfer.mockRejectedValue(error); + + await expect(handler.route(mockRequest)).rejects.toThrow(error); + + expect(mockAccountsUseCases.sendTransfer).toHaveBeenCalled(); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts index 5b1abf71..3d103b39 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts @@ -1,6 +1,14 @@ import type { KeyringRequest, KeyringResponse } from '@metamask/keyring-api'; import type { Json } from '@metamask/utils'; -import { assert, boolean, number, object, optional, string } from 'superstruct'; +import { + array, + assert, + boolean, + number, + object, + optional, + string, +} from 'superstruct'; import { AccountCapability, InexistentMethodError } from '../entities'; import { parsePsbt } from './parsers'; @@ -47,6 +55,16 @@ export type FillPsbtResponse = { psbt: string; }; +export const SendTransferRequest = object({ + recipients: array( + object({ + address: string(), + amount: string(), + }), + ), + feeRate: optional(number()), +}); + export class KeyringRequestHandler { readonly #accountsUseCases: AccountUseCases; @@ -76,6 +94,15 @@ export class KeyringRequestHandler { assert(params, BroadcastPsbtRequest); return this.#broadcastPsbt(account, params.psbt, origin); } + case AccountCapability.SendTransfer: { + assert(params, SendTransferRequest); + return this.#sendTransfer( + account, + params.recipients, + origin, + params.feeRate, + ); + } default: { throw new InexistentMethodError( 'Unrecognized Bitcoin account capability', @@ -153,6 +180,23 @@ export class KeyringRequestHandler { } as BroadcastPsbtResponse); } + async #sendTransfer( + id: string, + recipients: { address: string; amount: string }[], + origin: string, + feeRate?: number, + ): Promise { + const txid = await this.#accountsUseCases.sendTransfer( + id, + recipients, + origin, + feeRate, + ); + return this.#toKeyringResponse({ + txid: txid.toString(), + } as BroadcastPsbtResponse); + } + #toKeyringResponse(result: Json): KeyringResponse { return { pending: false, diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts index 0b45b470..f03d46e3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkTxBuilderAdapter.ts @@ -13,7 +13,11 @@ import { Recipient, } from '@metamask/bitcoindevkit'; -import type { TransactionBuilder } from '../entities'; +import { + type CodifiedError, + ValidationError, + type TransactionBuilder, +} from '../entities'; export class BdkTxBuilderAdapter implements TransactionBuilder { #builder: TxBuilder; @@ -26,11 +30,28 @@ export class BdkTxBuilderAdapter implements TransactionBuilder { } addRecipient(amount: string, recipientAddress: string): TransactionBuilder { - const recipient = new Recipient( - Address.from_string(recipientAddress, this.#network).script_pubkey, - Amount.from_sat(BigInt(amount)), - ); - return this.addRecipientByScript(recipient.amount, recipient.script_pubkey); + let recipient: Recipient; + try { + recipient = new Recipient( + Address.from_string(recipientAddress, this.#network).script_pubkey, + Amount.from_sat(BigInt(amount)), + ); + return this.addRecipientByScript( + recipient.amount, + recipient.script_pubkey, + ); + } catch (error) { + throw new ValidationError( + 'Invalid recipient', + { + amount, + address: recipientAddress, + }, + // Because of an issue with BDK, the error returned is not an Error object + // so we need to wrap it in an Error object + new Error((error as CodifiedError).message), + ); + } } addRecipientByScript( diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 2d5dc225..6553500a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -1210,4 +1210,156 @@ describe('AccountUseCases', () => { expect(mockTxBuilder.feeRate).toHaveBeenCalledWith(fallbackFeeRate); }); }); + + describe('sendTransfer', () => { + const recipients = [ + { + address: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', + amount: '1000', + }, + { + address: 'bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y', + amount: '2000', + }, + ]; + const mockTxid = mock(); + const mockOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + const mockPsbt = mock({ + unsigned_tx: { + output: [mockOutput], + }, + toString, + }); + const mockTransaction = mock({ + // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 + /* eslint-disable @typescript-eslint/naming-convention */ + compute_txid: jest.fn(), + clone: jest.fn(), + }); + const mockSignedPsbt = mock({ + toString: () => 'mockSignedPsbt', + }); + const mockAccount = mock({ + network: 'bitcoin', + sign: jest.fn(), + capabilities: [AccountCapability.SendTransfer], + }); + const mockWalletTx = mock(); + const mockFeeRate = 3; + const mockFeeEstimates = mock({ + get: () => mockFeeRate, + }); + const mockFilledPsbt = mock(); + const mockTxBuilder = mock({ + addRecipient: jest.fn(), + addRecipientByScript: jest.fn(), + feeRate: jest.fn(), + drainToByScript: jest.fn(), + drainWallet: jest.fn(), + finish: jest.fn(), + unspendable: jest.fn(), + }); + + beforeEach(() => { + mockRepository.getWithSigner.mockResolvedValue(mockAccount); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + mockTransaction.clone.mockReturnThis(); + mockAccount.buildTx.mockReturnValue(mockTxBuilder); + mockAccount.sign.mockReturnValue(mockSignedPsbt); + mockAccount.extractTransaction.mockReturnValue(mockTransaction); + mockTxBuilder.addRecipient.mockReturnThis(); + mockTxBuilder.addRecipientByScript.mockReturnThis(); + mockTxBuilder.feeRate.mockReturnThis(); + mockTxBuilder.drainToByScript.mockReturnThis(); + mockTxBuilder.untouchedOrdering.mockReturnThis(); + mockTxBuilder.finish.mockReturnValue(mockFilledPsbt); + mockTxBuilder.unspendable.mockReturnThis(); + mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); + }); + + it('throws error if account is not found', async () => { + mockRepository.getWithSigner.mockResolvedValue(null); + + await expect( + useCases.sendTransfer('non-existent-id', recipients, 'metamask'), + ).rejects.toThrow('Account not found'); + }); + + it('sends funds', async () => { + mockAccount.getTransaction.mockReturnValue(mockWalletTx); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + mockTxBuilder.finish.mockReturnValueOnce(mockPsbt); + + const txid = await useCases.sendTransfer( + 'account-id', + recipients, + 'metamask', + ); + + expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); + expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith( + '1000', + 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', + ); + expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith( + '2000', + 'bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y', + ); + expect(mockChain.getFeeEstimates).toHaveBeenCalledWith( + mockAccount.network, + ); + expect(mockAccount.sign).toHaveBeenCalledWith(mockFilledPsbt); + expect(mockChain.broadcast).toHaveBeenCalledWith( + mockAccount.network, + mockTransaction, + ); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(mockTransaction.compute_txid).toHaveBeenCalled(); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount, [mockWalletTx]); + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionSubmitted, + mockAccount, + mockWalletTx, + 'metamask', + ); + expect(txid).toBe(mockTxid); + }); + + it('propagates an error if getWithSigner fails', async () => { + const error = new Error('getWithSigner failed'); + mockRepository.getWithSigner.mockRejectedValueOnce(error); + + await expect( + useCases.sendTransfer('account-id', recipients, 'metamask'), + ).rejects.toBe(error); + }); + + it('propagates an error if broadcast fails', async () => { + const error = new Error('broadcast failed'); + mockChain.broadcast.mockRejectedValueOnce(error); + mockTxBuilder.finish.mockReturnValueOnce(mockPsbt); + + await expect( + useCases.sendTransfer('account-id', recipients, 'metamask'), + ).rejects.toBe(error); + }); + + it('propagates an error if update fails', async () => { + const error = new Error('update failed'); + mockRepository.update.mockRejectedValue(error); + mockTxBuilder.finish.mockReturnValueOnce(mockPsbt); + + await expect( + useCases.sendTransfer('account-id', recipients, 'metamask'), + ).rejects.toBe(error); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 86e100cf..6bdbd7f4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -415,6 +415,46 @@ export class AccountUseCases { return txid; } + async sendTransfer( + id: string, + recipients: { address: string; amount: string }[], + origin: string, + feeRate?: number, + ): Promise { + this.#logger.debug( + 'Transferring funds: %s. Recipients: %o', + id, + recipients, + ); + + const account = await this.#repository.getWithSigner(id); + if (!account) { + throw new NotFoundError('Account not found', { id }); + } + this.#checkCapability(account, AccountCapability.SendTransfer); + + // Create a template PSBT with the recipients as outputs + let builder = account.buildTx(); + for (const { address, amount } of recipients) { + builder = builder.addRecipient(amount, address); + } + const templatePsbt = builder.finish(); + + // Complete the PSBT with the necessary inputs, fee rate, etc. + const psbt = await this.#fillPsbt(account, templatePsbt, feeRate); + const signedPsbt = account.sign(psbt); + const tx = account.extractTransaction(signedPsbt); + const txid = await this.#broadcast(account, tx, origin); + + this.#logger.info( + 'Funds transferred successfully: %s. Account: %s, Network: %s', + txid.toString(), + account.id, + account.network, + ); + return txid; + } + async #fillPsbt( account: BitcoinAccount, templatePsbt: Psbt, From d60700a051586a483a76d7f9746dd05869be4de3 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Fri, 22 Aug 2025 15:25:55 +0200 Subject: [PATCH 276/362] feat: utxo management in submitRequest (#520) --- .../integration-test/keyring-request.test.ts | 82 +++++++++++++ .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/entities/account.ts | 16 +++ .../handlers/KeyringRequestHandler.test.ts | 108 +++++++++++++++++- .../src/handlers/KeyringRequestHandler.ts | 42 ++++++- .../src/handlers/mappings.ts | 39 +++++++ .../src/infra/BdkAccountAdapter.ts | 13 +++ 7 files changed, 299 insertions(+), 3 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts index 7290b288..d0dc90b4 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts @@ -100,6 +100,88 @@ describe('KeyringRequestHandler', () => { }); }); + // Keep order of tests as UTXOs are modified by other tests + describe('UTXO management', () => { + it('listUtxos', async () => { + let response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.ListUtxos, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWith({ + pending: false, + result: [ + { + address: 'bcrt1qs2fj7czz0amfm74j73yujx6dn6223md56gkkuy', + derivationIndex: 0, + outpoint: expect.any(String), + scriptPubkey: + 'OP_0 OP_PUSHBYTES_20 82932f60427f769dfab2f449c91b4d9e94a8edb4', + scriptPubkeyHex: '001482932f60427f769dfab2f449c91b4d9e94a8edb4', + value: '1000000000', + }, + ], + }); + + const utxos = ( + response.response as { result: { result: { outpoint: string }[] } } + ).result.result; + + response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.GetUtxo, + params: { + outpoint: utxos[0]?.outpoint, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWith({ + pending: false, + result: utxos[0], + }); + }); + + it('publicDescriptor', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.PublicDescriptor, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWith({ + pending: false, + result: + "wpkh([27f9035f/84'/1'/0']tpubDCkv2fHDfPg5ok9EPv6CDozH72rvY2jgEPm79szMeBwCBwUf2T6n5nLrWFfhuuD48SgzrELezoiyDM9KbZaVen4wuuGwrqQANDhzB7E8yDh/0/*)#sx899xk6", + }); + }); + }); + describe('signPsbt', () => { // PSBTs can be decoded here: https://bitcoincore.tech/apps/bitcoinjs-ui/index.html const TEMPLATE_PSBT = diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 8f18ca46..93a32681 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "dQsAaHhtEzROifsEyrizAbLtoxcDNyWTHe8hCnBzd0I=", + "shasum": "QedWcB/grGnkSnSmnE3CEJZLrfkpwR5z7HbF8B/HKk4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index 91d6c39c..99b7e1f8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -63,6 +63,11 @@ export type BitcoinAccount = { */ publicAddress: Address; + /** + * The public descriptor of the account. + */ + publicDescriptor: string; + /** * The capabilities of the account. */ @@ -142,6 +147,14 @@ export type BitcoinAccount = { */ extractTransaction(psbt: Psbt, maxFeeRate?: number): Transaction; + /** + * Get a UTXO by outpoint. + * + * @param outpoint - Outpoint of the utxo in the format :. + * @returns the wallet UTXO or undefined if the UTXO is not found + */ + getUtxo(outpoint: string): LocalOutput | undefined; + /** * Get the list of UTXOs * @@ -208,6 +221,9 @@ export enum AccountCapability { FillPsbt = 'fillPsbt', BroadcastPsbt = 'broadcastPsbt', SendTransfer = 'sendTransfer', + GetUtxo = 'getUtxo', + ListUtxos = 'listUtxos', + PublicDescriptor = 'publicDescriptor', } /** diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts index 69a6f441..fd9c744a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts @@ -1,4 +1,4 @@ -import type { Txid, Psbt, Amount } from '@metamask/bitcoindevkit'; +import type { Txid, Psbt, Amount, LocalOutput } from '@metamask/bitcoindevkit'; import type { KeyringRequest } from '@metamask/keyring-api'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; @@ -8,11 +8,15 @@ import { BroadcastPsbtRequest, ComputeFeeRequest, FillPsbtRequest, + GetUtxoRequest, KeyringRequestHandler, SendTransferRequest, SignPsbtRequest, } from './KeyringRequestHandler'; +import type { BitcoinAccount } from '../entities'; import { AccountCapability } from '../entities'; +import type { Utxo } from './mappings'; +import { mapToUtxo } from './mappings'; import { parsePsbt } from './parsers'; jest.mock('superstruct', () => ({ @@ -24,6 +28,9 @@ const mockPsbt = mock(); jest.mock('./parsers', () => ({ parsePsbt: jest.fn(), })); +jest.mock('./mappings', () => ({ + mapToUtxo: jest.fn(), +})); describe('KeyringRequestHandler', () => { const mockAccountsUseCases = mock(); @@ -357,4 +364,103 @@ describe('KeyringRequestHandler', () => { expect(mockAccountsUseCases.sendTransfer).toHaveBeenCalled(); }); }); + + describe('getUtxo', () => { + const mockLocalOutput = mock(); + const mockAccount = mock({ + getUtxo: () => mockLocalOutput, + network: 'bitcoin', + }); + const mockRequest = mock({ + origin, + request: { + method: AccountCapability.GetUtxo, + params: { + outpoint: 'mytxid:0', + }, + }, + account: 'account-id', + }); + + it('executes getUtxo', async () => { + const expectedUtxo = { + derivationIndex: 0, + outpoint: 'mytxid:0', + value: '1000', + scriptPubkey: 'scriptPubkey', + scriptPubkeyHex: 'scriptPubkeyHex', + }; + mockAccountsUseCases.get.mockResolvedValue(mockAccount); + jest.mocked(mapToUtxo).mockReturnValue(expectedUtxo); + const result = await handler.route(mockRequest); + + expect(assert).toHaveBeenCalledWith( + mockRequest.request.params, + GetUtxoRequest, + ); + expect(mockAccountsUseCases.get).toHaveBeenCalledWith('account-id'); + expect(result).toStrictEqual({ + pending: false, + result: expectedUtxo, + }); + }); + }); + + describe('listUtxos', () => { + const mockLocalOutput = mock(); + const mockAccount = mock({ + listUnspent: () => [mockLocalOutput, mockLocalOutput], + network: 'bitcoin', + }); + const mockRequest = mock({ + origin, + request: { + method: AccountCapability.ListUtxos, + }, + account: 'account-id', + }); + + it('executes listUtxos', async () => { + const mockUtxo = mock({ + derivationIndex: 0, + outpoint: 'mytxid:0', + value: '1000', + scriptPubkey: 'scriptPubkey', + scriptPubkeyHex: 'scriptPubkeyHex', + }); + mockAccountsUseCases.get.mockResolvedValue(mockAccount); + jest.mocked(mapToUtxo).mockReturnValue(mockUtxo); + const result = await handler.route(mockRequest); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith('account-id'); + expect(result).toStrictEqual({ + pending: false, + result: [mockUtxo, mockUtxo], + }); + }); + }); + + describe('publicDescriptor', () => { + const mockAccount = mock({ + publicDescriptor: 'publicDescriptor', + }); + const mockRequest = mock({ + origin, + request: { + method: AccountCapability.PublicDescriptor, + }, + account: 'account-id', + }); + + it('executes publicDescriptor', async () => { + mockAccountsUseCases.get.mockResolvedValue(mockAccount); + const result = await handler.route(mockRequest); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith('account-id'); + expect(result).toStrictEqual({ + pending: false, + result: 'publicDescriptor', + }); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts index 3d103b39..eff1c709 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts @@ -10,7 +10,12 @@ import { string, } from 'superstruct'; -import { AccountCapability, InexistentMethodError } from '../entities'; +import { + AccountCapability, + InexistentMethodError, + NotFoundError, +} from '../entities'; +import { mapToUtxo } from './mappings'; import { parsePsbt } from './parsers'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; @@ -65,6 +70,10 @@ export const SendTransferRequest = object({ feeRate: optional(number()), }); +export const GetUtxoRequest = object({ + outpoint: string(), +}); + export class KeyringRequestHandler { readonly #accountsUseCases: AccountUseCases; @@ -103,6 +112,16 @@ export class KeyringRequestHandler { params.feeRate, ); } + case AccountCapability.GetUtxo: { + assert(params, GetUtxoRequest); + return this.#getUtxo(account, params.outpoint); + } + case AccountCapability.ListUtxos: { + return this.#listUtxos(account); + } + case AccountCapability.PublicDescriptor: { + return this.#publicDescriptor(account); + } default: { throw new InexistentMethodError( 'Unrecognized Bitcoin account capability', @@ -197,6 +216,27 @@ export class KeyringRequestHandler { } as BroadcastPsbtResponse); } + async #getUtxo(id: string, outpoint: string): Promise { + const account = await this.#accountsUseCases.get(id); + const utxo = account.getUtxo(outpoint); + if (!utxo) { + throw new NotFoundError('UTXO not found', { id }); + } + return this.#toKeyringResponse(mapToUtxo(utxo, account.network)); + } + + async #listUtxos(id: string): Promise { + const account = await this.#accountsUseCases.get(id); + return this.#toKeyringResponse( + account.listUnspent().map((utxo) => mapToUtxo(utxo, account.network)), + ); + } + + async #publicDescriptor(id: string): Promise { + const account = await this.#accountsUseCases.get(id); + return this.#toKeyringResponse(account.publicDescriptor); + } + #toKeyringResponse(result: Json): KeyringResponse { return { pending: false, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index 3bd37d85..01ec914e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -1,6 +1,7 @@ import type { Amount, ChainPosition, + LocalOutput, Network, TxOut, WalletTx, @@ -43,6 +44,19 @@ type TransactionEvent = { timestamp: number | null; }; +export type Utxo = { + // Outpoint of the utxo in the format : + outpoint: string; + // Value of output in satoshis + value: string; + derivationIndex: number; + // scriptPubley in ASM format + scriptPubkey: string; + scriptPubkeyHex: string; + // If the script can be represented as an address, omitted otherwise + address?: string; +}; + /** * Maps a Bitcoin Account to a Keyring Account. * @@ -215,3 +229,28 @@ export function mapToDiscoveredAccount( derivationPath: `m/${account.derivationPath.slice(1).join('/')}`, }; } + +/** + * Maps a Bitcoin UTXO to a Keyring UTXO. + * + * @param utxo - The wallet UTXO. + * @param network - The network of the UTXO. + * @returns The Keyring UTXO. + */ +export function mapToUtxo(utxo: LocalOutput, network: Network): Utxo { + let address: Address | undefined; + try { + address = Address.from_script(utxo.txout.script_pubkey, network); + } catch { + address = undefined; + } + + return { + derivationIndex: utxo.derivation_index, + outpoint: utxo.outpoint.toString(), + value: utxo.txout.value.to_sat().toString(), + scriptPubkey: utxo.txout.script_pubkey.to_asm_string(), + scriptPubkeyHex: utxo.txout.script_pubkey.to_hex_string(), + address: address?.toString(), + }; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index 828016b4..a8c8a86d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -22,6 +22,7 @@ import { SignOptions, Txid, Wallet, + OutPoint, } from '@metamask/bitcoindevkit'; import { @@ -123,6 +124,10 @@ export class BdkAccountAdapter implements BitcoinAccount { return this.peekAddress(0).address; } + get publicDescriptor(): string { + return this.#wallet.public_descriptor('external'); + } + get capabilities(): AccountCapability[] { return this.#capabilities; } @@ -200,6 +205,14 @@ export class BdkAccountAdapter implements BitcoinAccount { } } + getUtxo(outpoint: string): LocalOutput | undefined { + try { + return this.#wallet.get_utxo(OutPoint.from_string(outpoint)); + } catch (error) { + throw new ValidationError('Invalid outpoint', { id: this.#id }, error); + } + } + listUnspent(): LocalOutput[] { return this.#wallet.list_unspent(); } From 4741db1b8015fbf55d65de21aeed7fdcc4b4903d Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 25 Aug 2025 12:01:38 +0200 Subject: [PATCH 277/362] feat: sign message (#521) --- .../integration-test/keyring-request.test.ts | 29 ++++++++ .../bitcoin-wallet-snap/package.json | 4 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/entities/account.ts | 1 + .../src/handlers/KeyringRequestHandler.ts | 19 +++++ .../src/handlers/RpcHandler.test.ts | 45 +++++++++++ .../src/handlers/RpcHandler.ts | 34 +++++++++ .../src/use-cases/AccountUseCases.test.ts | 74 +++++++++++++++++++ .../src/use-cases/AccountUseCases.ts | 56 ++++++++++++++ 9 files changed, 262 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts index d0dc90b4..69c1ffbc 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts @@ -620,4 +620,33 @@ describe('KeyringRequestHandler', () => { }); }); }); + + describe('signMessage', () => { + it('signs a message successfully', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SignMessage, + params: { + message: 'Hello, world!', + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWith({ + pending: false, + result: { + signature: + 'AkcwRAIgZxodJQ60t9Rr/hABEHZ1zPUJ4m5hdM5QLpysH8fDSzgCIENOEuZtYf9/Nn/ZW15PcImkknol403dmZrgoOQ+6K+TASECwDKypXm/ElmVTxTLJ7nao6X5mB/iGbU2Q2qtot0QRL4=', + }, + }); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 18b5aa07..735d50f6 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -45,6 +45,7 @@ "@metamask/snaps-jest": "^9.4.0", "@metamask/snaps-sdk": "^9.3.0", "@metamask/utils": "^11.4.2", + "bip322-js": "^3.0.0", "concurrently": "^9.2.0", "dotenv": "^17.2.1", "jest": "^30.0.5", @@ -52,7 +53,8 @@ "jest-transform-stub": "2.0.0", "superstruct": "^2.0.2", "ts-jest": "^29.4.1", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "wif": "^5.0.0" }, "publishConfig": { "access": "public", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 93a32681..385a0576 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "QedWcB/grGnkSnSmnE3CEJZLrfkpwR5z7HbF8B/HKk4=", + "shasum": "vRZKNyyxmZPV/QgltpmhtVdUayAjaHKOKIDzBMDod9s=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index 99b7e1f8..f7f30af6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -224,6 +224,7 @@ export enum AccountCapability { GetUtxo = 'getUtxo', ListUtxos = 'listUtxos', PublicDescriptor = 'publicDescriptor', + SignMessage = 'signMessage', } /** diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts index eff1c709..1b210ed8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts @@ -74,6 +74,14 @@ export const GetUtxoRequest = object({ outpoint: string(), }); +export const SignMessageRequest = object({ + message: string(), +}); + +export type SignMessageResponse = { + signature: string; +}; + export class KeyringRequestHandler { readonly #accountsUseCases: AccountUseCases; @@ -122,6 +130,10 @@ export class KeyringRequestHandler { case AccountCapability.PublicDescriptor: { return this.#publicDescriptor(account); } + case AccountCapability.SignMessage: { + assert(params, SignMessageRequest); + return this.#signMessage(account, params.message); + } default: { throw new InexistentMethodError( 'Unrecognized Bitcoin account capability', @@ -237,6 +249,13 @@ export class KeyringRequestHandler { return this.#toKeyringResponse(account.publicDescriptor); } + async #signMessage(id: string, message: string): Promise { + const signature = await this.#accountsUseCases.signMessage(id, message); + return this.#toKeyringResponse({ + signature, + } as SignMessageResponse); + } + #toKeyringResponse(result: Json): KeyringResponse { return { pending: false, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index 7f3d9a65..7df93715 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -13,6 +13,7 @@ import { RpcHandler, RpcMethod, SendPsbtRequest, + VerifyMessageRequest, } from './RpcHandler'; jest.mock('superstruct', () => ({ @@ -232,4 +233,48 @@ describe('RpcHandler', () => { expect(mockAccountsUseCases.computeFee).not.toHaveBeenCalled(); }); }); + + describe('verifyMessage', () => { + const mockRequest = mock({ + method: RpcMethod.VerifyMessage, + params: { + address: 'bcrt1qs2fj7czz0amfm74j73yujx6dn6223md56gkkuy', + message: 'Hello, world!', + signature: + 'AkcwRAIgZxodJQ60t9Rr/hABEHZ1zPUJ4m5hdM5QLpysH8fDSzgCIENOEuZtYf9/Nn/ZW15PcImkknol403dmZrgoOQ+6K+TASECwDKypXm/ElmVTxTLJ7nao6X5mB/iGbU2Q2qtot0QRL4=', + }, + }); + + it('executes verifyMessage successfully with valid signature', async () => { + const result = await handler.route(origin, mockRequest); + + expect(assert).toHaveBeenCalledWith( + mockRequest.params, + VerifyMessageRequest, + ); + + expect(result).toStrictEqual({ valid: true }); + }); + + it('executes verifyMessage successfully with invalid signature', async () => { + const result = await handler.route(origin, { + ...mockRequest, + params: { + ...mockRequest.params, + address: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', // wrong address for given signature + }, + } as JsonRpcRequest); + + expect(result).toStrictEqual({ valid: false }); + }); + + it('throws ValidationError for invalid signature', async () => { + await expect( + handler.route(origin, { + ...mockRequest, + params: { ...mockRequest.params, signature: 'invalidaSignature' }, + } as JsonRpcRequest), + ).rejects.toThrow('Failed to verify signature'); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index 8032ca4a..397139b8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -1,5 +1,6 @@ import { BtcScope } from '@metamask/keyring-api'; import type { Json, JsonRpcRequest } from '@metamask/utils'; +import { Verifier } from 'bip322-js'; import { assert, enums, object, optional, string } from 'superstruct'; import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; @@ -8,6 +9,7 @@ import { AssertionError, FormatError, InexistentMethodError, + ValidationError, } from '../entities'; import { scopeToNetwork } from './caip'; import type { TransactionFee } from './mappings'; @@ -18,6 +20,7 @@ export enum RpcMethod { StartSendTransactionFlow = 'startSendTransactionFlow', SignAndSendTransaction = 'signAndSendTransaction', ComputeFee = 'computeFee', + VerifyMessage = 'verifyMessage', } export const CreateSendFormRequest = object({ @@ -42,6 +45,12 @@ export type SendTransactionResponse = { transactionId: string; }; +export const VerifyMessageRequest = object({ + address: string(), + message: string(), + signature: string(), +}); + export class RpcHandler { readonly #sendFlowUseCases: SendFlowUseCases; @@ -78,6 +87,14 @@ export class RpcHandler { params.scope, ); } + case RpcMethod.VerifyMessage: { + assert(params, VerifyMessageRequest); + return this.#verifyMessage( + params.address, + params.message, + params.signature, + ); + } default: throw new InexistentMethodError(`Method not found: ${method}`); @@ -138,4 +155,21 @@ export class RpcHandler { return [mapToTransactionFees(amount, scopeToNetwork[scope])]; } + + #verifyMessage( + address: string, + message: string, + signature: string, + ): { valid: boolean } { + try { + const valid = Verifier.verifySignature(address, message, signature); + return { valid }; + } catch (error) { + throw new ValidationError( + 'Failed to verify signature', + { address, message, signature }, + error, + ); + } + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 6553500a..8324e4ae 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -9,7 +9,9 @@ import type { AddressType, Network, Psbt, + Address, } from '@metamask/bitcoindevkit'; +import type { JsonSLIP10Node } from '@metamask/key-tree'; import { mock } from 'jest-mock-extended'; import type { @@ -1362,4 +1364,76 @@ describe('AccountUseCases', () => { ).rejects.toBe(error); }); }); + + describe('signMessage', () => { + const mockAccount = mock({ + publicAddress: mock
({ + toString: () => 'bcrt1qs2fj7czz0amfm74j73yujx6dn6223md56gkkuy', + }), + capabilities: [AccountCapability.SignMessage], + derivationPath: ['m', "84'", "0'"], + }); + const mockMessage = 'Hello, world!'; + + beforeEach(() => { + mockRepository.get.mockResolvedValue(mockAccount); + mockSnapClient.getPrivateEntropy.mockResolvedValue({ + privateKey: + '0xdf23b869a1395aec3bf878797daac998ed4acf404fa26ff622eef7f30dc46791', + } as JsonSLIP10Node); + }); + + it('throws error if account is not found', async () => { + mockRepository.get.mockResolvedValue(null); + + await expect( + useCases.signMessage('non-existent-id', mockMessage), + ).rejects.toThrow('Account not found'); + }); + + it('signs a message', async () => { + const expectedSignature = + 'AkcwRAIgZxodJQ60t9Rr/hABEHZ1zPUJ4m5hdM5QLpysH8fDSzgCIENOEuZtYf9/Nn/ZW15PcImkknol403dmZrgoOQ+6K+TASECwDKypXm/ElmVTxTLJ7nao6X5mB/iGbU2Q2qtot0QRL4='; + + const signature = await useCases.signMessage('account-id', mockMessage); + + expect(mockRepository.get).toHaveBeenCalledWith('account-id'); + expect(mockSnapClient.getPrivateEntropy).toHaveBeenCalledWith([ + 'm', + "84'", + "0'", + '0', + '0', + ]); + expect(signature).toBe(expectedSignature); + }); + + it('throws WalletError if fails to sign message', async () => { + mockSnapClient.getPrivateEntropy.mockResolvedValue({ + privateKey: '0x1234567890abcdef', // wrong private key returned + } as JsonSLIP10Node); + + await expect( + useCases.signMessage('account-id', mockMessage), + ).rejects.toThrow('Failed to sign message'); + }); + + it('propagates an error if get fails', async () => { + const error = new Error('get failed'); + mockRepository.get.mockRejectedValueOnce(error); + + await expect( + useCases.signMessage('account-id', mockMessage), + ).rejects.toBe(error); + }); + + it('propagates an error if getPrivateEntropy fails', async () => { + const error = new Error('getPrivateEntropy failed'); + mockSnapClient.getPrivateEntropy.mockRejectedValue(error); + + await expect( + useCases.signMessage('account-id', mockMessage), + ).rejects.toBe(error); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 6bdbd7f4..013c637f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -8,10 +8,13 @@ import type { WalletTx, } from '@metamask/bitcoindevkit'; import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; +import { Signer } from 'bip322-js'; +import { encode } from 'wif'; import { AccountCapability, addressTypeToPurpose, + AssertionError, type BitcoinAccount, type BitcoinAccountRepository, type BlockchainClient, @@ -23,6 +26,7 @@ import { type SnapClient, TrackingSnapEvent, ValidationError, + WalletError, } from '../entities'; export type DiscoverAccountParams = { @@ -455,6 +459,58 @@ export class AccountUseCases { return txid; } + async signMessage(id: string, message: string): Promise { + this.#logger.debug('Signing message: %s. Message: %s', id, message); + + const account = await this.#repository.get(id); + if (!account) { + throw new NotFoundError('Account not found', { id }); + } + this.#checkCapability(account, AccountCapability.SignMessage); + + const entropy = await this.#snapClient.getPrivateEntropy( + account.derivationPath.concat(['0', '0']), // We sign with address index 0, which is the public address + ); + if (!entropy.privateKey) { + // Should never happen when getting the private entropy + throw new AssertionError('Failed to get private entropy', { + id, + }); + } + + try { + // Private key is returned in "0x..." format, transform into WIF: + const wifPrivateKey = encode({ + version: account.network === 'bitcoin' ? 128 : 239, // 128 for mainnet, 239 for testnets + // eslint-disable-next-line no-restricted-globals + privateKey: Buffer.from(entropy.privateKey.slice(2), 'hex'), + compressed: true, + }); + const signature = Signer.sign( + wifPrivateKey, + account.publicAddress.toString(), + message, + ); + + this.#logger.info( + 'Message signed successfully: %s. Message: %s, Signature: %s.', + id, + message, + signature, + ); + return signature; + } catch (error) { + throw new WalletError( + 'Failed to sign message', + { + id, + message, + }, + error, + ); + } + } + async #fillPsbt( account: BitcoinAccount, templatePsbt: Psbt, From 765a2b858d79d6cf8d3c7bb2fd640d514fd9210a Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 25 Aug 2025 12:44:55 +0200 Subject: [PATCH 278/362] feat: OpenRPC for dApp connectivity (#522) --- .../integration-test/client-request.test.ts | 2 +- .../bitcoin-wallet-snap/keyring.openrpc.json | 315 ++++++++++++++++++ 2 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 merged-packages/bitcoin-wallet-snap/keyring.openrpc.json diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts index 7c981f1a..c51abacd 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts @@ -157,7 +157,7 @@ describe('OnClientRequestHandler', () => { asset: { unit: CurrencyUnit.Regtest, type: Caip19Asset.Regtest, - amount: '0.00001053', + amount: expect.stringContaining('0.00001'), fungible: true, }, }, diff --git a/merged-packages/bitcoin-wallet-snap/keyring.openrpc.json b/merged-packages/bitcoin-wallet-snap/keyring.openrpc.json new file mode 100644 index 00000000..fa2f2867 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/keyring.openrpc.json @@ -0,0 +1,315 @@ +{ + "openrpc": "1.2.6", + "info": { + "title": "Bitcoin Keyring SubmitRequest API", + "description": "Methods available through the submitRequest endpoint of the Bitcoin Wallet Snap keyring. These are intended for dApp developers. Each method is called by passing it as request.method in the submitRequest params, with request.params containing the method parameters. The account ID is specified in the top-level account field of the submitRequest params.", + "version": "1.0.0" + }, + "methods": [ + { + "name": "signPsbt", + "description": "Signs a partially signed Bitcoin transaction (PSBT) and optionally fills/broadcasts it.", + "paramStructure": "by-name", + "params": [ + { + "name": "psbt", + "description": "The base64-encoded PSBT string.", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "feeRate", + "description": "Optional fee rate in sat/vB.", + "required": false, + "schema": { "type": "number" } + }, + { + "name": "options", + "description": "Options for filling and broadcasting.", + "required": true, + "schema": { + "type": "object", + "properties": { + "fill": { "type": "boolean" }, + "broadcast": { "type": "boolean" } + }, + "required": ["fill", "broadcast"] + } + } + ], + "result": { + "name": "signPsbtResponse", + "description": "The signed PSBT and optional txid if broadcast.", + "schema": { + "type": "object", + "properties": { + "psbt": { "type": "string" }, + "txid": { "type": ["string", "null"] } + }, + "required": ["psbt", "txid"] + } + } + }, + { + "name": "fillPsbt", + "description": "Fills a partially signed Bitcoin transaction (PSBT) with inputs and outputs.", + "paramStructure": "by-name", + "params": [ + { + "name": "psbt", + "description": "The base64-encoded PSBT string.", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "feeRate", + "description": "Optional fee rate in sat/vB.", + "required": false, + "schema": { "type": "number" } + } + ], + "result": { + "name": "fillPsbtResponse", + "description": "The filled PSBT.", + "schema": { + "type": "object", + "properties": { + "psbt": { "type": "string" } + }, + "required": ["psbt"] + } + } + }, + { + "name": "computeFee", + "description": "Computes the fee for a partially signed Bitcoin transaction (PSBT).", + "paramStructure": "by-name", + "params": [ + { + "name": "psbt", + "description": "The base64-encoded PSBT string.", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "feeRate", + "description": "Optional fee rate in sat/vB.", + "required": false, + "schema": { "type": "number" } + } + ], + "result": { + "name": "computeFeeResponse", + "description": "The computed fee in satoshis.", + "schema": { + "type": "object", + "properties": { + "fee": { "type": "string" } + }, + "required": ["fee"] + } + } + }, + { + "name": "broadcastPsbt", + "description": "Broadcasts a signed Bitcoin transaction (PSBT).", + "paramStructure": "by-name", + "params": [ + { + "name": "psbt", + "description": "The base64-encoded signed PSBT string.", + "required": true, + "schema": { "type": "string" } + } + ], + "result": { + "name": "broadcastPsbtResponse", + "description": "The transaction ID.", + "schema": { + "type": "object", + "properties": { + "txid": { "type": "string" } + }, + "required": ["txid"] + } + } + }, + { + "name": "sendTransfer", + "description": "Creates, signs, and broadcasts a transfer to multiple recipients.", + "paramStructure": "by-name", + "params": [ + { + "name": "recipients", + "description": "Array of recipients with address and amount.", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "address": { "type": "string" }, + "amount": { "type": "string" } + }, + "required": ["address", "amount"] + } + } + }, + { + "name": "feeRate", + "description": "Optional fee rate in sat/vB.", + "required": false, + "schema": { "type": "number" } + } + ], + "result": { + "name": "sendTransferResponse", + "description": "The transaction ID.", + "schema": { + "type": "object", + "properties": { + "txid": { "type": "string" } + }, + "required": ["txid"] + } + } + }, + { + "name": "getUtxo", + "description": "Retrieves a specific UTXO by outpoint.", + "paramStructure": "by-name", + "params": [ + { + "name": "outpoint", + "description": "The UTXO outpoint in txid:vout format.", + "required": true, + "schema": { "type": "string" } + } + ], + "result": { + "name": "getUtxoResponse", + "description": "The UTXO details.", + "schema": { + "type": "object", + "properties": { + "outpoint": { + "type": "string", + "description": "Outpoint of the utxo in the format :" + }, + "value": { + "type": "string", + "description": "Value of output in satoshis" + }, + "derivationIndex": { + "type": "number", + "description": "Derivation index" + }, + "scriptPubkey": { + "type": "string", + "description": "scriptPubkey in ASM format" + }, + "scriptPubkeyHex": { + "type": "string", + "description": "scriptPubkey in hex format" + }, + "address": { + "type": "string", + "description": "Address if the script can be represented as one (optional)" + } + }, + "required": [ + "outpoint", + "value", + "derivationIndex", + "scriptPubkey", + "scriptPubkeyHex" + ] + } + } + }, + { + "name": "listUtxos", + "description": "Lists all unspent UTXOs for the account.", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "listUtxosResponse", + "description": "Array of UTXO details.", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "outpoint": { + "type": "string", + "description": "Outpoint of the utxo in the format :" + }, + "value": { + "type": "string", + "description": "Value of output in satoshis" + }, + "derivationIndex": { + "type": "number", + "description": "Derivation index" + }, + "scriptPubkey": { + "type": "string", + "description": "scriptPubkey in ASM format" + }, + "scriptPubkeyHex": { + "type": "string", + "description": "scriptPubkey in hex format" + }, + "address": { + "type": "string", + "description": "Address if the script can be represented as one (optional)" + } + }, + "required": [ + "outpoint", + "value", + "derivationIndex", + "scriptPubkey", + "scriptPubkeyHex" + ] + } + } + } + }, + { + "name": "publicDescriptor", + "description": "Retrieves the public descriptor for the account.", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "publicDescriptorResponse", + "description": "The public descriptor string.", + "schema": { "type": "string" } + } + }, + { + "name": "signMessage", + "description": "Signs a message with the account's private key.", + "paramStructure": "by-name", + "params": [ + { + "name": "message", + "description": "The message to sign.", + "required": true, + "schema": { "type": "string" } + } + ], + "result": { + "name": "signMessageResponse", + "description": "The signature.", + "schema": { + "type": "object", + "properties": { + "signature": { "type": "string" } + }, + "required": ["signature"] + } + } + } + ] +} From d14bc55b3b92846f71468cee988e84e6e08eaf6f Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Mon, 25 Aug 2025 18:40:22 +0200 Subject: [PATCH 279/362] feat: confirmation for sign message (#523) --- .../integration-test/keyring-request.test.ts | 14 ++- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/entities/confirmation.ts | 36 +++++++ .../bitcoin-wallet-snap/src/entities/index.ts | 1 + .../bitcoin-wallet-snap/src/entities/snap.ts | 8 ++ .../handlers/KeyringRequestHandler.test.ts | 28 +++++ .../src/handlers/KeyringRequestHandler.ts | 14 ++- .../src/handlers/UserInputHandler.test.ts | 64 ++++++++++- .../src/handlers/UserInputHandler.ts | 14 ++- .../src/handlers/permissions.ts | 4 +- .../bitcoin-wallet-snap/src/index.ts | 19 +++- .../src/infra/SnapClientAdapter.ts | 22 ++-- .../SignMessageConfirmationView.tsx | 101 ++++++++++++++++++ .../src/infra/jsx/confirmations/index.ts | 1 + .../src/infra/jsx/format.ts | 20 +++- .../src/infra/jsx/index.ts | 4 +- .../{ => send-flow}/ReviewTransactionView.tsx | 30 +++--- .../jsx/{ => send-flow}/SendFormView.tsx | 8 +- .../src/infra/jsx/send-flow/index.ts | 2 + .../store/JSXConfirmationRepository.test.tsx | 70 ++++++++++++ .../src/store/JSXConfirmationRepository.tsx | 51 +++++++++ .../src/use-cases/AccountUseCases.test.ts | 25 ++++- .../src/use-cases/AccountUseCases.ts | 31 ++++-- .../use-cases/ConfirmationUseCases.test.ts | 44 ++++++++ .../src/use-cases/ConfirmationUseCases.ts | 32 ++++++ .../src/use-cases/index.ts | 1 + 26 files changed, 587 insertions(+), 59 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/confirmation.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignMessageConfirmationView.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/index.ts rename merged-packages/bitcoin-wallet-snap/src/infra/jsx/{ => send-flow}/ReviewTransactionView.tsx (83%) rename merged-packages/bitcoin-wallet-snap/src/infra/jsx/{ => send-flow}/SendFormView.tsx (82%) create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/index.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/use-cases/ConfirmationUseCases.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/use-cases/ConfirmationUseCases.ts diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts index 69c1ffbc..1d3d6fa4 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts @@ -1,7 +1,7 @@ import type { KeyringAccount, KeyringRequest } from '@metamask/keyring-api'; import { BtcScope } from '@metamask/keyring-api'; import type { Snap } from '@metamask/snaps-jest'; -import { installSnap } from '@metamask/snaps-jest'; +import { assertIsConfirmationDialog, installSnap } from '@metamask/snaps-jest'; import { BlockchainTestUtils } from './blockchain-utils'; import { MNEMONIC, ORIGIN } from './constants'; @@ -15,7 +15,7 @@ describe('KeyringRequestHandler', () => { let account: KeyringAccount; let snap: Snap; let blockchain: BlockchainTestUtils; - const origin = 'integration-tests'; + const origin = 'http://my-dapp.com'; beforeAll(async () => { blockchain = new BlockchainTestUtils(); @@ -623,7 +623,7 @@ describe('KeyringRequestHandler', () => { describe('signMessage', () => { it('signs a message successfully', async () => { - const response = await snap.onKeyringRequest({ + const response = snap.onKeyringRequest({ origin: ORIGIN, method: submitRequestMethod, params: { @@ -640,7 +640,13 @@ describe('KeyringRequestHandler', () => { } as KeyringRequest, }); - expect(response).toRespondWith({ + const ui = await response.getInterface(); + assertIsConfirmationDialog(ui); + await ui.ok(); + + const result = await response; + + expect(result).toRespondWith({ pending: false, result: { signature: diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 385a0576..05352b35 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "vRZKNyyxmZPV/QgltpmhtVdUayAjaHKOKIDzBMDod9s=", + "shasum": "C2DDFSYvbsGwQYfe81zcgTnYG0kUhGK66IJr7Vx+XW4=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/confirmation.ts b/merged-packages/bitcoin-wallet-snap/src/entities/confirmation.ts new file mode 100644 index 00000000..f7f5836b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/confirmation.ts @@ -0,0 +1,36 @@ +import type { Network } from '@metamask/bitcoindevkit'; + +import type { BitcoinAccount } from './account'; + +export type SignMessageConfirmationContext = { + message: string; + account: { + id: string; + address: string; // FIXME: Address should not be needed to identify an account + }; + network: Network; + origin: string; +}; + +export enum ConfirmationEvent { + Confirm = 'confirmation-confirm', + Cancel = 'confirmation-cancel', +} + +/** + * ConfirmationRepository is a repository that manages request confirmations for dApps. + */ +export type ConfirmationRepository = { + /** + * Inserts a sign message confirmation interface. + * + * @param account - The account to sign the message. + * @param message - The message to sign. + * @param origin - The origin of the request. + */ + insertSignMessage( + account: BitcoinAccount, + message: string, + origin: string, + ): Promise; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts index c1aeb327..75ba7457 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts @@ -10,3 +10,4 @@ export type * from './translator'; export type * from './rates'; export * from './logger'; export * from './error'; +export * from './confirmation'; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index e62f691b..f07342c2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -139,6 +139,14 @@ export type SnapClient = { */ displayInterface(id: string): Promise; + /** + * Display a Confirmation Dialog. + * + * @param id - The interface id. + * @returns the resolved value or null. + */ + displayConfirmation(id: string): Promise; + /** * Resolve a User Interface. * diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts index fd9c744a..5394e688 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts @@ -463,4 +463,32 @@ describe('KeyringRequestHandler', () => { }); }); }); + + describe('signMessage', () => { + const mockRequest = mock({ + origin, + request: { + method: AccountCapability.SignMessage, + params: { + message: 'message', + }, + }, + account: 'account-id', + }); + + it('executes signMessage', async () => { + mockAccountsUseCases.signMessage.mockResolvedValue('signature'); + const result = await handler.route(mockRequest); + + expect(mockAccountsUseCases.signMessage).toHaveBeenCalledWith( + 'account-id', + 'message', + 'metamask', + ); + expect(result).toStrictEqual({ + pending: false, + result: { signature: 'signature' }, + }); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts index 1b210ed8..eab70f5f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts @@ -132,7 +132,7 @@ export class KeyringRequestHandler { } case AccountCapability.SignMessage: { assert(params, SignMessageRequest); - return this.#signMessage(account, params.message); + return this.#signMessage(account, params.message, origin); } default: { throw new InexistentMethodError( @@ -249,8 +249,16 @@ export class KeyringRequestHandler { return this.#toKeyringResponse(account.publicDescriptor); } - async #signMessage(id: string, message: string): Promise { - const signature = await this.#accountsUseCases.signMessage(id, message); + async #signMessage( + id: string, + message: string, + origin: string, + ): Promise { + const signature = await this.#accountsUseCases.signMessage( + id, + message, + origin, + ); return this.#toKeyringResponse({ signature, } as SignMessageResponse); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts index ab9174f2..f08eec3e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.test.ts @@ -3,18 +3,26 @@ import { UserInputEventType } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; import type { SendFormContext } from '../entities'; -import { ReviewTransactionEvent, SendFormEvent } from '../entities'; -import type { SendFlowUseCases } from '../use-cases'; +import { + ConfirmationEvent, + ReviewTransactionEvent, + SendFormEvent, +} from '../entities'; +import type { ConfirmationUseCases, SendFlowUseCases } from '../use-cases'; import { UserInputHandler } from './UserInputHandler'; describe('UserInputHandler', () => { const mockSendFlowUseCases = mock(); + const mockConfirmationUseCases = mock(); const mockContext = mock(); let handler: UserInputHandler; beforeEach(() => { - handler = new UserInputHandler(mockSendFlowUseCases); + handler = new UserInputHandler( + mockSendFlowUseCases, + mockConfirmationUseCases, + ); }); describe('route', () => { @@ -123,4 +131,54 @@ describe('UserInputHandler', () => { ).rejects.toThrow(error); }); }); + + describe('handle confirmation', () => { + it('executes on confirm', async () => { + await handler.route( + 'interface-id', + { + type: UserInputEventType.ButtonClickEvent, + name: ConfirmationEvent.Confirm, + }, + {}, + ); + + expect(mockConfirmationUseCases.onChange).toHaveBeenCalledWith( + 'interface-id', + ConfirmationEvent.Confirm, + ); + }); + + it('executes on cancel', async () => { + await handler.route( + 'interface-id', + { + type: UserInputEventType.ButtonClickEvent, + name: ConfirmationEvent.Cancel, + }, + {}, + ); + + expect(mockConfirmationUseCases.onChange).toHaveBeenCalledWith( + 'interface-id', + ConfirmationEvent.Cancel, + ); + }); + + it('propagates errors from onChange', async () => { + const error = new Error(); + mockConfirmationUseCases.onChange.mockRejectedValue(error); + + await expect( + handler.route( + 'interface-id', + { + type: UserInputEventType.ButtonClickEvent, + name: ConfirmationEvent.Cancel, + }, + {}, + ), + ).rejects.toThrow(error); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts index 3cd5502d..94b1d5f9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/UserInputHandler.ts @@ -6,18 +6,22 @@ import type { import type { ReviewTransactionContext, SendFormContext } from '../entities'; import { + ConfirmationEvent, FormatError, InexistentMethodError, ReviewTransactionEvent, SendFormEvent, } from '../entities'; -import type { SendFlowUseCases } from '../use-cases'; +import type { ConfirmationUseCases, SendFlowUseCases } from '../use-cases'; export class UserInputHandler { readonly #sendFlowUseCases: SendFlowUseCases; - constructor(sendFlow: SendFlowUseCases) { + readonly #confirmationUseCases: ConfirmationUseCases; + + constructor(sendFlow: SendFlowUseCases, confirmation: ConfirmationUseCases) { this.#sendFlowUseCases = sendFlow; + this.#confirmationUseCases = confirmation; } async route( @@ -45,6 +49,8 @@ export class UserInputHandler { event.name, context as ReviewTransactionContext, ); + } else if (this.#isConfirmationEvent(event.name)) { + return this.#confirmationUseCases.onChange(interfaceId, event.name); } throw new InexistentMethodError(`Unsupported event: ${event.name}`); @@ -60,6 +66,10 @@ export class UserInputHandler { ); } + #isConfirmationEvent(name: string): name is ConfirmationEvent { + return Object.values(ConfirmationEvent).includes(name as ConfirmationEvent); + } + #hasValue(event: UserInputEvent): event is InputChangeEvent { return 'value' in event; } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts index 02886b5e..45284e1c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts @@ -1,7 +1,9 @@ import { PermissionError } from '../entities'; +export const METAMASK_ORIGIN = 'metamask'; + export const validateOrigin = (origin: string): void => { - if (origin !== 'metamask') { + if (origin !== METAMASK_ORIGIN) { throw new PermissionError('Invalid origin', { origin }); } }; diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index cbb48fee..c0bfd8fa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -28,7 +28,13 @@ import { LocalTranslatorAdapter, } from './infra'; import { BdkAccountRepository, JSXSendFlowRepository } from './store'; -import { AccountUseCases, AssetsUseCases, SendFlowUseCases } from './use-cases'; +import { JSXConfirmationRepository } from './store/JSXConfirmationRepository'; +import { + AccountUseCases, + AssetsUseCases, + ConfirmationUseCases, + SendFlowUseCases, +} from './use-cases'; // Infra layer const logger = new ConsoleLoggerAdapter(Config.logLevel); @@ -41,12 +47,17 @@ const middleware = new HandlerMiddleware(logger, snapClient, translator); // Data layer const accountRepository = new BdkAccountRepository(snapClient); const sendFlowRepository = new JSXSendFlowRepository(snapClient, translator); +const confirmationRepository = new JSXConfirmationRepository( + snapClient, + translator, +); // Business layer const accountsUseCases = new AccountUseCases( logger, snapClient, accountRepository, + confirmationRepository, chainClient, Config.fallbackFeeRate, Config.targetBlocksConfirmation, @@ -63,6 +74,7 @@ const sendFlowUseCases = new SendFlowUseCases( Config.ratesRefreshInterval, ); const assetsUseCases = new AssetsUseCases(logger, assetRatesClient); +const confirmationUseCases = new ConfirmationUseCases(logger, snapClient); // Application layer const keyringRequestHandler = new KeyringRequestHandler(accountsUseCases); @@ -73,7 +85,10 @@ const keyringHandler = new KeyringHandler( ); const cronHandler = new CronHandler(logger, accountsUseCases, sendFlowUseCases); const rpcHandler = new RpcHandler(sendFlowUseCases, accountsUseCases); -const userInputHandler = new UserInputHandler(sendFlowUseCases); +const userInputHandler = new UserInputHandler( + sendFlowUseCases, + confirmationUseCases, +); const assetsHandler = new AssetsHandler( assetsUseCases, Config.conversionsExpirationInterval, diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index c31c3e4e..6540ded7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -3,12 +3,13 @@ import type { JsonSLIP10Node } from '@metamask/key-tree'; import { SLIP10Node } from '@metamask/key-tree'; import { KeyringEvent } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; -import type { - ComponentOrElement, - GetInterfaceContextResult, - GetInterfaceStateResult, - GetPreferencesResult, - Json, +import { + DialogType, + type ComponentOrElement, + type GetInterfaceContextResult, + type GetInterfaceStateResult, + type GetPreferencesResult, + type Json, } from '@metamask/snaps-sdk'; import type { BaseError, BitcoinAccount, SnapClient } from '../entities'; @@ -146,6 +147,15 @@ export class SnapClientAdapter implements SnapClient { })) as unknown as ResolveType; } + async displayConfirmation( + id: string, + ): Promise { + return (await snap.request({ + method: 'snap_dialog', + params: { type: DialogType.Confirmation, id }, + })) as unknown as ResolveType; + } + async getInterfaceState(id: string): Promise { return snap.request({ method: 'snap_getInterfaceState', diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignMessageConfirmationView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignMessageConfirmationView.tsx new file mode 100644 index 00000000..814c4d86 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignMessageConfirmationView.tsx @@ -0,0 +1,101 @@ +import { + Address, + Box, + Button, + Container, + Footer, + Heading, + Icon, + Section, + Text as SnapText, + Tooltip, + type SnapComponent, +} from '@metamask/snaps-sdk/jsx'; + +import type { + Messages, + SignMessageConfirmationContext, +} from '../../../entities'; +import { ConfirmationEvent } from '../../../entities'; +import { networkToScope } from '../../../handlers'; +import { AssetIcon } from '../components'; +import { displayCaip10, displayOrigin, translate } from '../format'; + +type SignMessageConfirmationViewProps = { + context: SignMessageConfirmationContext; + messages: Messages; +}; + +export const SignMessageConfirmationView: SnapComponent< + SignMessageConfirmationViewProps +> = ({ context, messages }) => { + const t = translate(messages); + const { account, network, origin, message } = context; + const originHostname = origin ? displayOrigin(origin) : null; + + return ( + + + + {null} + {t('confirmation.signMessage.title')} + + +
+ + + {t('confirmation.signMessage.message')} + + + + {message} + +
+ +
+ {originHostname ? ( + + + + {t('confirmation.origin')} + + + + + + {originHostname} + + ) : null} + + + {t('confirmation.account')} + +
+ + + + {t('confirmation.network')} + + + + + + {networkToScope[network]} + + +
+
+
+ + +
+
+ ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/index.ts new file mode 100644 index 00000000..bcd5e740 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/index.ts @@ -0,0 +1 @@ +export * from './SignMessageConfirmationView'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts index f8fb232a..4382bb66 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts @@ -1,7 +1,10 @@ +import type { Network } from '@metamask/bitcoindevkit'; import { Amount, BdkErrorCode } from '@metamask/bitcoindevkit'; -import type { CurrencyRate } from '@metamask/snaps-sdk'; +import type { CaipAccountId, CurrencyRate } from '@metamask/snaps-sdk'; import type { CurrencyUnit, Messages } from '../../entities'; +import { networkToScope } from '../../handlers'; +import { METAMASK_ORIGIN } from '../../handlers/permissions'; export const displayAmount = ( amountSats: bigint, @@ -52,3 +55,18 @@ export const errorCodeToLabel = (code: number): string => { // lowercase the first letter to respect camelCase convention return raw.charAt(0).toLowerCase() + raw.slice(1); }; + +export const displayOrigin = (origin: string): string => { + if (origin === METAMASK_ORIGIN) { + return 'MetaMask'; + } + + return new URL(origin).hostname; +}; + +export const displayCaip10 = ( + network: Network, + address: string, +): CaipAccountId => { + return `${networkToScope[network]}:${address}`; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/index.ts index 34d7abff..39c17769 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/index.ts @@ -1,2 +1,2 @@ -export * from './SendFormView'; -export * from './ReviewTransactionView'; +export * from './send-flow'; +export * from './confirmations'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/ReviewTransactionView.tsx similarity index 83% rename from merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx rename to merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/ReviewTransactionView.tsx index c1fdb2af..4bffcc6b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/ReviewTransactionView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/ReviewTransactionView.tsx @@ -13,23 +13,22 @@ import { Address, Link, } from '@metamask/snaps-sdk/jsx'; -import type { CaipAccountId } from '@metamask/utils'; -import { AssetIcon, HeadingWithReturn } from './components'; +import { Config } from '../../../config'; +import type { Messages, ReviewTransactionContext } from '../../../entities'; +import { + BlockTime, + networkToCurrencyUnit, + ReviewTransactionEvent, +} from '../../../entities'; +import { AssetIcon, HeadingWithReturn } from '../components'; import { displayAmount, + displayCaip10, displayExchangeAmount, displayExplorerUrl, translate, -} from './format'; -import { Config } from '../../config'; -import type { Messages, ReviewTransactionContext } from '../../entities'; -import { - BlockTime, - networkToCurrencyUnit, - ReviewTransactionEvent, -} from '../../entities'; -import { networkToScope } from '../../handlers'; +} from '../format'; type ReviewTransactionViewProps = { context: ReviewTransactionContext; @@ -68,18 +67,13 @@ export const ReviewTransactionView: SnapComponent<
-
+
diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/SendFormView.tsx similarity index 82% rename from merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx rename to merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/SendFormView.tsx index 928c3b27..e709d208 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/SendFormView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/SendFormView.tsx @@ -8,10 +8,10 @@ import { Text as SnapText, } from '@metamask/snaps-sdk/jsx'; -import { HeadingWithReturn, SendForm } from './components'; -import { errorCodeToLabel, translate } from './format'; -import type { Messages, SendFormContext } from '../../entities'; -import { SendFormEvent } from '../../entities'; +import type { Messages, SendFormContext } from '../../../entities'; +import { SendFormEvent } from '../../../entities'; +import { HeadingWithReturn, SendForm } from '../components'; +import { errorCodeToLabel, translate } from '../format'; export type SendFormViewProps = { context: SendFormContext; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/index.ts new file mode 100644 index 00000000..34d7abff --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/index.ts @@ -0,0 +1,2 @@ +export * from './SendFormView'; +export * from './ReviewTransactionView'; diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx new file mode 100644 index 00000000..c1e084b1 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx @@ -0,0 +1,70 @@ +import type { Address } from '@metamask/bitcoindevkit'; +import type { GetPreferencesResult } from '@metamask/snaps-sdk'; +import { mock } from 'jest-mock-extended'; + +import type { SnapClient, Translator, BitcoinAccount } from '../entities'; +import { JSXConfirmationRepository } from './JSXConfirmationRepository'; +import { SignMessageConfirmationView } from '../infra/jsx'; + +jest.mock('../infra/jsx', () => ({ + SignMessageConfirmationView: jest.fn(), +})); + +describe('JSXConfirmationRepository', () => { + const mockMessages = { foo: { message: 'bar' } }; + const mockSnapClient = mock(); + const mockTranslator = mock(); + + const repo = new JSXConfirmationRepository(mockSnapClient, mockTranslator); + + describe('insertSignMessage', () => { + const mockAccount = mock({ + id: 'account-id', + publicAddress: mock
({ toString: () => 'myAddress' }), + }); + const message = 'message'; + const origin = 'origin'; + const expectedContext = { + message, + origin, + account: { + id: mockAccount.id, + address: mockAccount.publicAddress.toString(), + }, + network: mockAccount.network, + }; + + beforeEach(() => { + mockSnapClient.createInterface.mockResolvedValue('interface-id'); + mockSnapClient.displayConfirmation.mockResolvedValue(true); + mockTranslator.load.mockResolvedValue(mockMessages); + mockSnapClient.getPreferences.mockResolvedValue({ + locale: 'en', + } as GetPreferencesResult); + }); + + it('creates and displays a sign message interface', async () => { + await repo.insertSignMessage(mockAccount, message, origin); + + expect(mockSnapClient.getPreferences).toHaveBeenCalled(); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + , + expectedContext, + ); + expect(mockTranslator.load).toHaveBeenCalledWith('en'); + expect(mockSnapClient.displayConfirmation).toHaveBeenCalledWith( + 'interface-id', + ); + }); + + it('throws UserActionError if the interface returns false', async () => { + mockSnapClient.displayConfirmation.mockResolvedValue(false); + await expect( + repo.insertSignMessage(mockAccount, message, origin), + ).rejects.toThrow('User canceled the confirmation'); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx new file mode 100644 index 00000000..73ee9874 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx @@ -0,0 +1,51 @@ +import type { + BitcoinAccount, + ConfirmationRepository, + SignMessageConfirmationContext, + SnapClient, + Translator, +} from '../entities'; +import { UserActionError } from '../entities'; +import { SignMessageConfirmationView } from '../infra/jsx'; + +export class JSXConfirmationRepository implements ConfirmationRepository { + readonly #snapClient: SnapClient; + + readonly #translator: Translator; + + constructor(snapClient: SnapClient, translator: Translator) { + this.#snapClient = snapClient; + this.#translator = translator; + } + + async insertSignMessage( + account: BitcoinAccount, + message: string, + origin: string, + ): Promise { + const { locale } = await this.#snapClient.getPreferences(); + const context: SignMessageConfirmationContext = { + message, + origin, + account: { + id: account.id, + address: account.publicAddress.toString(), // FIXME: Address should not be needed in the send flow + }, + network: account.network, + }; + + const messages = await this.#translator.load(locale); + const interfaceId = await this.#snapClient.createInterface( + , + context, + ); + + // Blocks and waits for user actions. This logic can live here instead of in the use case + // because it's common to all confirmations. Move to use case if needed. + const confirmed = + await this.#snapClient.displayConfirmation(interfaceId); + if (!confirmed) { + throw new UserActionError('User canceled the confirmation'); + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 8324e4ae..508f14f7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -18,6 +18,7 @@ import type { BitcoinAccount, BitcoinAccountRepository, BlockchainClient, + ConfirmationRepository, Inscription, Logger, MetaProtocolsClient, @@ -39,6 +40,7 @@ describe('AccountUseCases', () => { const mockLogger = mock(); const mockSnapClient = mock(); const mockRepository = mock(); + const mockConfirmationRepository = mock(); const mockChain = mock(); const mockMetaProtocols = mock(); const fallbackFeeRate = 5.0; @@ -48,6 +50,7 @@ describe('AccountUseCases', () => { mockLogger, mockSnapClient, mockRepository, + mockConfirmationRepository, mockChain, fallbackFeeRate, targetBlocksConfirmation, @@ -636,6 +639,7 @@ describe('AccountUseCases', () => { mockLogger, mockSnapClient, mockRepository, + mockConfirmationRepository, mockChain, fallbackFeeRate, targetBlocksConfirmation, @@ -719,6 +723,7 @@ describe('AccountUseCases', () => { mockLogger, mockSnapClient, mockRepository, + mockConfirmationRepository, mockChain, fallbackFeeRate, targetBlocksConfirmation, @@ -1374,6 +1379,7 @@ describe('AccountUseCases', () => { derivationPath: ['m', "84'", "0'"], }); const mockMessage = 'Hello, world!'; + const mockOrigin = 'metamask'; beforeEach(() => { mockRepository.get.mockResolvedValue(mockAccount); @@ -1387,7 +1393,7 @@ describe('AccountUseCases', () => { mockRepository.get.mockResolvedValue(null); await expect( - useCases.signMessage('non-existent-id', mockMessage), + useCases.signMessage('non-existent-id', mockMessage, mockOrigin), ).rejects.toThrow('Account not found'); }); @@ -1395,7 +1401,11 @@ describe('AccountUseCases', () => { const expectedSignature = 'AkcwRAIgZxodJQ60t9Rr/hABEHZ1zPUJ4m5hdM5QLpysH8fDSzgCIENOEuZtYf9/Nn/ZW15PcImkknol403dmZrgoOQ+6K+TASECwDKypXm/ElmVTxTLJ7nao6X5mB/iGbU2Q2qtot0QRL4='; - const signature = await useCases.signMessage('account-id', mockMessage); + const signature = await useCases.signMessage( + 'account-id', + mockMessage, + mockOrigin, + ); expect(mockRepository.get).toHaveBeenCalledWith('account-id'); expect(mockSnapClient.getPrivateEntropy).toHaveBeenCalledWith([ @@ -1405,6 +1415,11 @@ describe('AccountUseCases', () => { '0', '0', ]); + expect(mockConfirmationRepository.insertSignMessage).toHaveBeenCalledWith( + mockAccount, + mockMessage, + mockOrigin, + ); expect(signature).toBe(expectedSignature); }); @@ -1414,7 +1429,7 @@ describe('AccountUseCases', () => { } as JsonSLIP10Node); await expect( - useCases.signMessage('account-id', mockMessage), + useCases.signMessage('account-id', mockMessage, mockOrigin), ).rejects.toThrow('Failed to sign message'); }); @@ -1423,7 +1438,7 @@ describe('AccountUseCases', () => { mockRepository.get.mockRejectedValueOnce(error); await expect( - useCases.signMessage('account-id', mockMessage), + useCases.signMessage('account-id', mockMessage, mockOrigin), ).rejects.toBe(error); }); @@ -1432,7 +1447,7 @@ describe('AccountUseCases', () => { mockSnapClient.getPrivateEntropy.mockRejectedValue(error); await expect( - useCases.signMessage('account-id', mockMessage), + useCases.signMessage('account-id', mockMessage, mockOrigin), ).rejects.toBe(error); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 013c637f..b90289a4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -15,19 +15,22 @@ import { AccountCapability, addressTypeToPurpose, AssertionError, - type BitcoinAccount, - type BitcoinAccountRepository, - type BlockchainClient, - type Logger, - type MetaProtocolsClient, networkToCoinType, NotFoundError, PermissionError, - type SnapClient, TrackingSnapEvent, ValidationError, WalletError, } from '../entities'; +import type { + BitcoinAccount, + BitcoinAccountRepository, + BlockchainClient, + Logger, + MetaProtocolsClient, + SnapClient, + ConfirmationRepository, +} from '../entities'; export type DiscoverAccountParams = { network: Network; @@ -49,6 +52,8 @@ export class AccountUseCases { readonly #repository: BitcoinAccountRepository; + readonly #confirmationRepository: ConfirmationRepository; + readonly #chain: BlockchainClient; readonly #metaProtocols: MetaProtocolsClient | undefined; @@ -61,6 +66,7 @@ export class AccountUseCases { logger: Logger, snapClient: SnapClient, repository: BitcoinAccountRepository, + confirmationRepository: ConfirmationRepository, chain: BlockchainClient, fallbackFeeRate: number, targetBlocksConfirmation: number, @@ -69,6 +75,7 @@ export class AccountUseCases { this.#logger = logger; this.#snapClient = snapClient; this.#repository = repository; + this.#confirmationRepository = confirmationRepository; this.#chain = chain; this.#fallbackFeeRate = fallbackFeeRate; this.#targetBlocksConfirmation = targetBlocksConfirmation; @@ -459,7 +466,11 @@ export class AccountUseCases { return txid; } - async signMessage(id: string, message: string): Promise { + async signMessage( + id: string, + message: string, + origin: string, + ): Promise { this.#logger.debug('Signing message: %s. Message: %s', id, message); const account = await this.#repository.get(id); @@ -468,6 +479,12 @@ export class AccountUseCases { } this.#checkCapability(account, AccountCapability.SignMessage); + await this.#confirmationRepository.insertSignMessage( + account, + message, + origin, + ); + const entropy = await this.#snapClient.getPrivateEntropy( account.derivationPath.concat(['0', '0']), // We sign with address index 0, which is the public address ); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/ConfirmationUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/ConfirmationUseCases.test.ts new file mode 100644 index 00000000..42f5c86f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/ConfirmationUseCases.test.ts @@ -0,0 +1,44 @@ +import { mock } from 'jest-mock-extended'; + +import { ConfirmationEvent, type Logger, type SnapClient } from '../entities'; +import { ConfirmationUseCases } from './ConfirmationUseCases'; + +describe('ConfirmationUseCases', () => { + const mockLogger = mock(); + const mockSnapClient = mock(); + + const useCases = new ConfirmationUseCases(mockLogger, mockSnapClient); + + describe('onChange', () => { + it('interface resolves to false on cancel', async () => { + await useCases.onChange('interface-id', ConfirmationEvent.Cancel); + expect(mockSnapClient.resolveInterface).toHaveBeenCalledWith( + 'interface-id', + false, + ); + }); + + it('interface resolves to true on confirm', async () => { + await useCases.onChange('interface-id', ConfirmationEvent.Confirm); + expect(mockSnapClient.resolveInterface).toHaveBeenCalledWith( + 'interface-id', + true, + ); + }); + + it('throws an error if the event is not recognized', async () => { + await expect( + useCases.onChange('interface-id', 'unknown' as ConfirmationEvent), + ).rejects.toThrow('Unrecognized confirmation event'); + }); + + it('propagates an error if resolveInterface fails', async () => { + const error = new Error('resolveInterface failed'); + mockSnapClient.resolveInterface.mockRejectedValue(error); + + await expect( + useCases.onChange('interface-id', ConfirmationEvent.Cancel), + ).rejects.toBe(error); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/ConfirmationUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/ConfirmationUseCases.ts new file mode 100644 index 00000000..8044712c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/ConfirmationUseCases.ts @@ -0,0 +1,32 @@ +import type { SnapClient, Logger } from '../entities'; +import { UserActionError, ConfirmationEvent } from '../entities'; + +export class ConfirmationUseCases { + readonly #logger: Logger; + + readonly #snapClient: SnapClient; + + constructor(logger: Logger, snapClient: SnapClient) { + this.#logger = logger; + this.#snapClient = snapClient; + } + + async onChange(interfaceId: string, event: ConfirmationEvent): Promise { + this.#logger.debug( + 'Event triggered on confirmation: %s. Event: %s', + interfaceId, + event, + ); + + switch (event) { + case ConfirmationEvent.Cancel: { + return this.#snapClient.resolveInterface(interfaceId, false); + } + case ConfirmationEvent.Confirm: { + return this.#snapClient.resolveInterface(interfaceId, true); + } + default: + throw new UserActionError('Unrecognized confirmation event'); + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts index 0a765c79..15721d73 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/index.ts @@ -1,3 +1,4 @@ export * from './AccountUseCases'; export * from './SendFlowUseCases'; export * from './AssetsUseCases'; +export * from './ConfirmationUseCases'; From 45f93c51e13b0368d464f942b1aa739f5c763e2f Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 26 Aug 2025 13:16:56 +0200 Subject: [PATCH 280/362] feat: events on active (#524) --- .../bitcoin-wallet-snap/snap.manifest.json | 18 ++------ .../bitcoin-wallet-snap/src/config.ts | 2 +- .../bitcoin-wallet-snap/src/entities/snap.ts | 8 ++++ .../src/handlers/CronHandler.test.ts | 43 ++++++++++++++++--- .../src/handlers/CronHandler.ts | 33 +++++++++----- .../src/handlers/KeyringHandler.ts | 5 +-- .../src/handlers/RpcHandler.test.ts | 6 --- .../src/handlers/RpcHandler.ts | 6 +-- .../src/handlers/permissions.ts | 9 ---- .../bitcoin-wallet-snap/src/index.ts | 16 ++++--- .../src/infra/SnapClientAdapter.ts | 21 ++++++--- .../src/infra/jsx/format.ts | 5 --- 12 files changed, 98 insertions(+), 74 deletions(-) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 05352b35..45a2baa5 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "C2DDFSYvbsGwQYfe81zcgTnYG0kUhGK66IJr7Vx+XW4=", + "shasum": "ZQif6XorIIX1p5KFAO3K+Ju0xecutTtgzWBpBcVJpok=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -18,26 +18,13 @@ }, "locales": ["locales/en.json"] }, - "initialConnections": { - "https://portfolio.metamask.io": {}, - "https://portfolio-builds.metafi-dev.codefi.network": {}, - "https://dev.portfolio.metamask.io": {}, - "https://ramps-dev.portfolio.metamask.io": {} - }, "initialPermissions": { "endowment:webassembly": {}, "endowment:rpc": { "dapps": true, "snaps": false }, - "endowment:keyring": { - "allowedOrigins": [ - "https://portfolio.metamask.io", - "https://portfolio-builds.metafi-dev.codefi.network", - "https://dev.portfolio.metamask.io", - "https://ramps-dev.portfolio.metamask.io" - ] - }, + "endowment:keyring": {}, "snap_getBip32Entropy": [ { "path": ["m", "44'", "0'"], @@ -72,6 +59,7 @@ "curve": "secp256k1" } ], + "endowment:lifecycle-hooks": {}, "endowment:network-access": {}, "snap_manageAccounts": {}, "snap_manageState": {}, diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index 7a954c25..f4993f2c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -30,7 +30,7 @@ export const Config: SnapConfig = { encrypt: false, chain: { parallelRequests: 5, - stopGap: 10, + stopGap: 5, maxRetries: 3, url: { bitcoin: fromEnv('ESPLORA_BITCOIN', 'https://blockstream.info/api'), diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index f07342c2..9c059669 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -2,6 +2,7 @@ import type { WalletTx } from '@metamask/bitcoindevkit'; import type { JsonSLIP10Node, SLIP10Node } from '@metamask/key-tree'; import type { ComponentOrElement, + GetClientStatusResult, GetPreferencesResult, } from '@metamask/snaps-sdk'; import type { Json } from '@metamask/utils'; @@ -199,6 +200,13 @@ export type SnapClient = { */ getPreferences(): Promise; + /** + * Get user's client status. + * + * @returns the user's client status. + */ + getClientStatus(): Promise; + /** * Track events that comply with the SIP-32 spec (https://metamask.github.io/SIPs/SIPS/sip-32) * diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts index e8b88c14..28e44f44 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -1,21 +1,28 @@ import type { JsonRpcRequest } from '@metamask/utils'; import { mock } from 'jest-mock-extended'; -import type { Logger, BitcoinAccount } from '../entities'; +import type { BitcoinAccount, SnapClient } from '../entities'; import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; import { CronHandler, CronMethod } from './CronHandler'; describe('CronHandler', () => { - const mockLogger = mock(); const mockSendFlowUseCases = mock(); const mockAccountUseCases = mock(); + const mockSnapClient = mock(); const handler = new CronHandler( - mockLogger, mockAccountUseCases, mockSendFlowUseCases, + mockSnapClient, ); + beforeEach(() => { + mockSnapClient.getClientStatus.mockResolvedValue({ + active: true, + locked: false, + }); + }); + describe('synchronizeAccounts', () => { const mockAccounts = [mock(), mock()]; const request = { method: 'synchronizeAccounts' } as JsonRpcRequest; @@ -25,6 +32,7 @@ describe('CronHandler', () => { await handler.route(request); + expect(mockSnapClient.getClientStatus).toHaveBeenCalled(); expect(mockAccountUseCases.list).toHaveBeenCalled(); expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes( mockAccounts.length, @@ -38,12 +46,23 @@ describe('CronHandler', () => { await expect(handler.route(request)).rejects.toThrow(error); }); - it('does not propagate errors from synchronize', async () => { + it('returns early if the client is not active', async () => { + mockSnapClient.getClientStatus.mockResolvedValue({ + active: false, + locked: true, + }); + await handler.route(request); + + expect(mockAccountUseCases.synchronize).not.toHaveBeenCalled(); + }); + + it('throws error if some account fails to synchronize', async () => { mockAccountUseCases.list.mockResolvedValue(mockAccounts); - const error = new Error(); - mockAccountUseCases.synchronize.mockRejectedValue(error); + mockAccountUseCases.synchronize.mockRejectedValue(new Error('error')); - await handler.route(request); + await expect(handler.route(request)).rejects.toThrow( + 'Account synchronization failures', + ); expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes( mockAccounts.length, @@ -69,6 +88,16 @@ describe('CronHandler', () => { expect(mockSendFlowUseCases.refresh).toHaveBeenCalledWith('id'); }); + it('returns early if the client is not active', async () => { + mockSnapClient.getClientStatus.mockResolvedValue({ + active: false, + locked: true, + }); + await handler.route(request); + + expect(mockSendFlowUseCases.refresh).not.toHaveBeenCalled(); + }); + it('propagates errors from refresh', async () => { const error = new Error(); mockSendFlowUseCases.refresh.mockRejectedValue(error); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index a211a98f..774d3f0e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -1,7 +1,11 @@ import type { JsonRpcRequest } from '@metamask/utils'; import { assert, object, string } from 'superstruct'; -import { InexistentMethodError, type Logger } from '../entities'; +import { + InexistentMethodError, + type SnapClient, + WalletError, +} from '../entities'; import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; export enum CronMethod { @@ -14,25 +18,30 @@ export const SendFormRefreshRatesRequest = object({ }); export class CronHandler { - readonly #logger: Logger; - readonly #accountsUseCases: AccountUseCases; readonly #sendFlowUseCases: SendFlowUseCases; + readonly #snapClient: SnapClient; + constructor( - logger: Logger, accounts: AccountUseCases, sendFlow: SendFlowUseCases, + snapClient: SnapClient, ) { - this.#logger = logger; this.#accountsUseCases = accounts; this.#sendFlowUseCases = sendFlow; + this.#snapClient = snapClient; } async route(request: JsonRpcRequest): Promise { const { method, params } = request; + const { active } = await this.#snapClient.getClientStatus(); + if (!active) { + return undefined; + } + switch (method as CronMethod) { case CronMethod.SynchronizeAccounts: { return this.synchronizeAccounts(); @@ -54,14 +63,18 @@ export class CronHandler { }), ); + const errors: Record = {}; results.forEach((result, index) => { if (result.status === 'rejected') { - this.#logger.error( - `Account failed to sync. ID: %s. Error: %s`, - accounts[index]?.id, - result.reason, - ); + const id = accounts[index]?.id; + if (id) { + errors[id] = result.reason; + } } }); + + if (Object.keys(errors).length > 0) { + throw new WalletError('Account synchronization failures', errors); + } } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 59d53b38..44ae8019 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -60,7 +60,6 @@ import { mapToKeyringAccount, mapToTransaction, } from './mappings'; -import { validateOrigin } from './permissions'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; export const CreateAccountRequest = object({ @@ -91,9 +90,7 @@ export class KeyringHandler implements Keyring { this.#defaultAddressType = defaultAddressType; } - async route(origin: string, request: JsonRpcRequest): Promise { - validateOrigin(origin); - + async route(request: JsonRpcRequest): Promise { switch (request.method) { case `${KeyringRpcMethod.ListAccounts}`: { assert(request, ListAccountsRequestStruct); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index 7df93715..d50c458d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -47,12 +47,6 @@ describe('RpcHandler', () => { }, }); - it('throws error if invalid origin', async () => { - await expect(handler.route('invalidOrigin', mockRequest)).rejects.toThrow( - 'Invalid origin', - ); - }); - it('throws error if missing params', async () => { await expect( handler.route(origin, { ...mockRequest, params: undefined }), diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index 397139b8..7561442b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -3,14 +3,13 @@ import type { Json, JsonRpcRequest } from '@metamask/utils'; import { Verifier } from 'bip322-js'; import { assert, enums, object, optional, string } from 'superstruct'; -import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; -import { validateOrigin } from './permissions'; import { AssertionError, FormatError, InexistentMethodError, ValidationError, } from '../entities'; +import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; import { scopeToNetwork } from './caip'; import type { TransactionFee } from './mappings'; import { mapToTransactionFees } from './mappings'; @@ -62,10 +61,7 @@ export class RpcHandler { } async route(origin: string, request: JsonRpcRequest): Promise { - validateOrigin(origin); - const { method, params } = request; - if (!params) { throw new FormatError('Missing params'); } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts deleted file mode 100644 index 45284e1c..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/permissions.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { PermissionError } from '../entities'; - -export const METAMASK_ORIGIN = 'metamask'; - -export const validateOrigin = (origin: string): void => { - if (origin !== METAMASK_ORIGIN) { - throw new PermissionError('Invalid origin', { origin }); - } -}; diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index c0bfd8fa..5ed6195b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -8,6 +8,7 @@ import type { OnAssetHistoricalPriceHandler, OnAssetsMarketDataHandler, OnClientRequestHandler, + OnActiveHandler, } from '@metamask/snaps-sdk'; import { Config } from './config'; @@ -83,7 +84,11 @@ const keyringHandler = new KeyringHandler( accountsUseCases, Config.defaultAddressType, ); -const cronHandler = new CronHandler(logger, accountsUseCases, sendFlowUseCases); +const cronHandler = new CronHandler( + accountsUseCases, + sendFlowUseCases, + snapClient, +); const rpcHandler = new RpcHandler(sendFlowUseCases, accountsUseCases); const userInputHandler = new UserInputHandler( sendFlowUseCases, @@ -103,10 +108,8 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request }) => export const onClientRequest: OnClientRequestHandler = async ({ request }) => middleware.handle(async () => rpcHandler.route('metamask', request)); -export const onKeyringRequest: OnKeyringRequestHandler = async ({ - origin, - request, -}) => middleware.handle(async () => keyringHandler.route(origin, request)); +export const onKeyringRequest: OnKeyringRequestHandler = async ({ request }) => + middleware.handle(async () => keyringHandler.route(request)); export const onUserInput: OnUserInputHandler = async ({ id, event, context }) => middleware.handle(async () => userInputHandler.route(id, event, context)); @@ -126,3 +129,6 @@ export const onAssetHistoricalPrice: OnAssetHistoricalPriceHandler = async ({ export const onAssetsMarketData: OnAssetsMarketDataHandler = async ({ assets, }) => middleware.handle(async () => assetsHandler.marketData(assets)); + +export const onActive: OnActiveHandler = async () => + middleware.handle(async () => cronHandler.synchronizeAccounts()); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 6540ded7..6fc21433 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -3,14 +3,15 @@ import type { JsonSLIP10Node } from '@metamask/key-tree'; import { SLIP10Node } from '@metamask/key-tree'; import { KeyringEvent } from '@metamask/keyring-api'; import { emitSnapKeyringEvent } from '@metamask/keyring-snap-sdk'; -import { - DialogType, - type ComponentOrElement, - type GetInterfaceContextResult, - type GetInterfaceStateResult, - type GetPreferencesResult, - type Json, +import type { + GetClientStatusResult, + ComponentOrElement, + GetInterfaceContextResult, + GetInterfaceStateResult, + GetPreferencesResult, + Json, } from '@metamask/snaps-sdk'; +import { DialogType } from '@metamask/snaps-sdk'; import type { BaseError, BitcoinAccount, SnapClient } from '../entities'; import { @@ -209,6 +210,12 @@ export class SnapClientAdapter implements SnapClient { }); } + async getClientStatus(): Promise { + return snap.request({ + method: 'snap_getClientStatus', + }); + } + async emitTrackingEvent( eventType: TrackingSnapEvent, account: BitcoinAccount, diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts index 4382bb66..c74ad114 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts @@ -4,7 +4,6 @@ import type { CaipAccountId, CurrencyRate } from '@metamask/snaps-sdk'; import type { CurrencyUnit, Messages } from '../../entities'; import { networkToScope } from '../../handlers'; -import { METAMASK_ORIGIN } from '../../handlers/permissions'; export const displayAmount = ( amountSats: bigint, @@ -57,10 +56,6 @@ export const errorCodeToLabel = (code: number): string => { }; export const displayOrigin = (origin: string): string => { - if (origin === METAMASK_ORIGIN) { - return 'MetaMask'; - } - return new URL(origin).hostname; }; From 9d3a33b9181744ba0bb8c37ce7d89ded81359216 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 26 Aug 2025 15:43:38 +0200 Subject: [PATCH 281/362] feat: v1 alignment (#525) --- .../bitcoin-wallet-snap/locales/de-DE.json | 121 +++++++++++----- .../bitcoin-wallet-snap/locales/el-GR.json | 125 ++++++++++++----- .../bitcoin-wallet-snap/locales/en-GB.json | 57 +++++++- .../bitcoin-wallet-snap/locales/en.json | 18 +++ .../bitcoin-wallet-snap/locales/es-419.json | 117 +++++++++++----- .../bitcoin-wallet-snap/locales/fr-FR.json | 119 +++++++++++----- .../bitcoin-wallet-snap/locales/hi-IN.json | 127 +++++++++++------ .../bitcoin-wallet-snap/locales/id-ID.json | 119 +++++++++++----- .../bitcoin-wallet-snap/locales/ja-JP.json | 129 ++++++++++++------ .../bitcoin-wallet-snap/locales/ko-KR.json | 21 +++ .../bitcoin-wallet-snap/locales/pt-BR.json | 21 +++ .../bitcoin-wallet-snap/locales/ru-RU.json | 21 +++ .../bitcoin-wallet-snap/locales/tl-PH.json | 21 +++ .../bitcoin-wallet-snap/locales/tr-TR.json | 21 +++ .../bitcoin-wallet-snap/locales/vi-VN.json | 21 +++ .../bitcoin-wallet-snap/locales/zh-CN.json | 21 +++ .../bitcoin-wallet-snap/messages.json | 18 +++ .../bitcoin-wallet-snap/package.json | 6 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../SignMessageConfirmationView.tsx | 29 +--- 20 files changed, 855 insertions(+), 279 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/locales/de-DE.json b/merged-packages/bitcoin-wallet-snap/locales/de-DE.json index 8e8c3a5e..9c339aa5 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/de-DE.json +++ b/merged-packages/bitcoin-wallet-snap/locales/de-DE.json @@ -8,16 +8,16 @@ "message": "Von" }, "toAddress": { - "message": "To" + "message": "An" }, "continue": { - "message": "Continue" + "message": "Fortfahren" }, "cancel": { "message": "Abbrechen" }, "clear": { - "message": "Clear" + "message": "Löschen" }, "amount": { "message": "Betrag" @@ -38,19 +38,19 @@ "message": "Transaktionsgeschwindigkeit" }, "transactionSpeedTooltip": { - "message": "Die geschätzte Transaktionsdauer" + "message": "Die geschätzte Zeit der Transaktion" }, "networkFee": { - "message": "Network Fee" + "message": "Netzwerkgebühr" }, "feeRate": { - "message": "Fee rate" + "message": "Gebührenrate" }, "networkFeeTooltip": { - "message": "The total network fee" + "message": "Die gesamte Netzwerkgebühr" }, "total": { - "message": "Insgesamt" + "message": "Gesamt" }, "send": { "message": "Senden" @@ -62,88 +62,139 @@ "message": "Wird gesendet" }, "max": { - "message": "Max." + "message": "Max" }, "recipientPlaceholder": { - "message": "Enter receiving address" + "message": "Empfängeradresse eingeben" }, "review": { - "message": "Überprüfung" + "message": "Überprüfen" }, "error": { "message": "Fehler" }, "unknownError": { - "message": "An unknown error occurred" + "message": "Ein unbekannter Fehler ist aufgetreten" }, "feeTooLow": { - "message": "Fee too low" + "message": "Gebühr zu niedrig" }, "feeRateTooLow": { - "message": "Fee rate too low" + "message": "Gebührenrate zu niedrig" }, "noUtxosSelected": { - "message": "Failed to build transaction: missing UTXOs" + "message": "Transaktion konnte nicht erstellt werden: fehlende UTXOs" }, "outputBelowDustLimit": { - "message": "Amount below dust limit" + "message": "Betrag unter Dust-Limit" }, "insufficientFunds": { - "message": "Funds are insufficient to cover amount plus fee" + "message": "Unzureichende Mittel, um Betrag plus Gebühr zu decken" }, "noRecipients": { - "message": "Missing recipients" + "message": "Fehlende Empfänger" }, "psbt": { - "message": "Invalid PSBT" + "message": "Ungültiges PSBT" }, "unknownUtxo": { - "message": "Failed to build transaction: unknown UTXO" + "message": "Transaktion konnte nicht erstellt werden: unbekannter UTXO" }, "MmssingNonWitnessUtxo": { - "message": "Failed to build transaction: missing non-witness UTXO" + "message": "Transaktion konnte nicht erstellt werden: fehlender Non-Witness UTXO" }, "base58": { - "message": "Invalid Bitcoin address" + "message": "Ungültige Bitcoin-Adresse" }, "bech32": { - "message": "Invalid Bitcoin address" + "message": "Ungültige Bitcoin-Adresse" }, "witnessVersion": { - "message": "Invalid Bitcoin address: witness version" + "message": "Ungültige Bitcoin-Adresse: Witness-Version" }, "witnessProgram": { - "message": "Invalid Bitcoin address: witness program" + "message": "Ungültige Bitcoin-Adresse: Witness-Programm" }, "legacyAddressTooLong": { - "message": "Invalid Bitcoin address" + "message": "Ungültige Bitcoin-Adresse" }, "invalidBase58PayloadLength": { - "message": "Invalid Bitcoin address: invalid length" + "message": "Ungültige Bitcoin-Adresse: ungültige Länge" }, "invalidLegacyPrefix": { - "message": "Invalid Bitcoin address: invalid legacy prefix" + "message": "Ungültige Bitcoin-Adresse: ungültiges Legacy-Prefix" }, "networkValidation": { - "message": "Invalid Bitcoin address: wrong network" + "message": "Ungültige Bitcoin-Adresse: falsches Netzwerk" }, "outOfRange": { - "message": "Invalid amount: out of range" + "message": "Ungültiger Betrag: außerhalb des Bereichs" }, "tooPrecise": { - "message": "Invalid amount: too precise" + "message": "Ungültiger Betrag: zu präzise" }, "missingDigits": { - "message": "Invalid amount: missing digits" + "message": "Ungültiger Betrag: fehlende Ziffern" }, "inputTooLarge": { - "message": "Invalid amount: too large" + "message": "Ungültiger Betrag: zu groß" }, "invalidCharacter": { - "message": "Invalid amount: invalid character" + "message": "Ungültiger Betrag: ungültiges Zeichen" }, "unexpected": { - "message": "An unexpected error occurred" + "message": "Ein unerwarteter Fehler ist aufgetreten" + }, + "error.0": { + "message": "Ungültiges Format" + }, + "error.1000": { + "message": "Validierung fehlgeschlagen" + }, + "error.2000": { + "message": "Ressource nicht gefunden" + }, + "error.3000": { + "message": "Verbindungsfehler" + }, + "error.4000": { + "message": "Wallet-Zustand beschädigt" + }, + "error.5000": { + "message": "Speicherfehler" + }, + "error.6000": { + "message": "Methode nicht implementiert oder nicht unterstützt" + }, + "error.7000": { + "message": "Zugriff verweigert oder unzureichende Berechtigung" + }, + "error.8000": { + "message": "Benutzeraktionsfehler" + }, + "error.9000": { + "message": "Beschädigter Zustand, Invariante nicht eingehalten oder fehlgeschlagene Assertion" + }, + "error.internal": { + "message": "Interner Fehler. Bitte versuchen Sie es später erneut oder kontaktieren Sie den Support" + }, + "confirmation.origin": { + "message": "Ursprung" + }, + "confirmation.origin.tooltip": { + "message": "Die dApp, die die Signatur anfordert" + }, + "confirmation.account": { + "message": "Konto" + }, + "confirmation.signMessage.confirmButton": { + "message": "Signieren" + }, + "confirmation.signMessage.title": { + "message": "Nachricht signieren" + }, + "confirmation.signMessage.message": { + "message": "Nachricht" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/el-GR.json b/merged-packages/bitcoin-wallet-snap/locales/el-GR.json index 9ea58cfd..98172923 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/el-GR.json +++ b/merged-packages/bitcoin-wallet-snap/locales/el-GR.json @@ -8,16 +8,16 @@ "message": "Από" }, "toAddress": { - "message": "To Address" + "message": "Προς" }, "continue": { - "message": "Continue" + "message": "Συνέχεια" }, "cancel": { - "message": "Άκυρο" + "message": "Ακύρωση" }, "clear": { - "message": "Clear" + "message": "Καθαρισμός" }, "amount": { "message": "Ποσό" @@ -32,7 +32,7 @@ "message": "Δίκτυο" }, "minutes": { - "message": "min" + "message": "λεπτά" }, "transactionSpeed": { "message": "Ταχύτητα συναλλαγής" @@ -41,109 +41,160 @@ "message": "Ο εκτιμώμενος χρόνος της συναλλαγής" }, "networkFee": { - "message": "Network Fee" + "message": "Χρέωση δικτύου" }, "feeRate": { - "message": "Fee rate" + "message": "Ρυθμός χρέωσης" }, "networkFeeTooltip": { - "message": "The total network fee" + "message": "Η συνολική χρέωση δικτύου" }, "total": { "message": "Σύνολο" }, "send": { - "message": "Εστάλη" + "message": "Αποστολή" }, "asset": { - "message": "Asset" + "message": "Περιουσιακό στοιχείο" }, "sending": { "message": "Αποστολή" }, "max": { - "message": "Μεγ" + "message": "Μέγ" }, "recipientPlaceholder": { - "message": "Enter receiving address" + "message": "Εισαγάγετε διεύθυνση παραλήπτη" }, "review": { - "message": "Έλεγχος" + "message": "Επανεξέταση" }, "error": { "message": "Σφάλμα" }, "unknownError": { - "message": "An unknown error occurred" + "message": "Προέκυψε ένα άγνωστο σφάλμα" }, "feeTooLow": { - "message": "Fee too low" + "message": "Χρέωση πολύ χαμηλή" }, "feeRateTooLow": { - "message": "Fee rate too low" + "message": "Ρυθμός χρέωσης πολύ χαμηλός" }, "noUtxosSelected": { - "message": "Failed to build transaction: missing UTXOs" + "message": "Αποτυχία κατασκευής συναλλαγής: λείπουν UTXOs" }, "outputBelowDustLimit": { - "message": "Amount below dust limit" + "message": "Ποσό κάτω από το όριο dust" }, "insufficientFunds": { - "message": "Funds are insufficient to cover amount plus fee" + "message": "Ανεπαρκή κεφάλαια για κάλυψη ποσού συν χρέωση" }, "noRecipients": { - "message": "Missing recipients" + "message": "Λείπουν παραλήπτες" }, "psbt": { - "message": "Invalid PSBT" + "message": "Άκυρο PSBT" }, "unknownUtxo": { - "message": "Failed to build transaction: unknown UTXO" + "message": "Αποτυχία κατασκευής συναλλαγής: άγνωστο UTXO" }, "MmssingNonWitnessUtxo": { - "message": "Failed to build transaction: missing non-witness UTXO" + "message": "Αποτυχία κατασκευής συναλλαγής: λείπει non-witness UTXO" }, "base58": { - "message": "Invalid Bitcoin address" + "message": "Άκυρη διεύθυνση Bitcoin" }, "bech32": { - "message": "Invalid Bitcoin address" + "message": "Άκυρη διεύθυνση Bitcoin" }, "witnessVersion": { - "message": "Invalid Bitcoin address: witness version" + "message": "Άκυρη διεύθυνση Bitcoin: έκδοση witness" }, "witnessProgram": { - "message": "Invalid Bitcoin address: witness program" + "message": "Άκυρη διεύθυνση Bitcoin: πρόγραμμα witness" }, "legacyAddressTooLong": { - "message": "Invalid Bitcoin address" + "message": "Άκυρη διεύθυνση Bitcoin" }, "invalidBase58PayloadLength": { - "message": "Invalid Bitcoin address: invalid length" + "message": "Άκυρη διεύθυνση Bitcoin: άκυρο μήκος" }, "invalidLegacyPrefix": { - "message": "Invalid Bitcoin address: invalid legacy prefix" + "message": "Άκυρη διεύθυνση Bitcoin: άκυρο legacy prefix" }, "networkValidation": { - "message": "Invalid Bitcoin address: wrong network" + "message": "Άκυρη διεύθυνση Bitcoin: λάθος δίκτυο" }, "outOfRange": { - "message": "Invalid amount: out of range" + "message": "Άκυρο ποσό: εκτός εμβέλειας" }, "tooPrecise": { - "message": "Invalid amount: too precise" + "message": "Άκυρο ποσό: πολύ ακριβές" }, "missingDigits": { - "message": "Invalid amount: missing digits" + "message": "Άκυρο ποσό: λείπουν ψηφία" }, "inputTooLarge": { - "message": "Invalid amount: too large" + "message": "Άκυρο ποσό: πολύ μεγάλο" }, "invalidCharacter": { - "message": "Invalid amount: invalid character" + "message": "Άκυρο ποσό: άκυρος χαρακτήρας" }, "unexpected": { - "message": "An unexpected error occurred" + "message": "Προέκυψε ένα απροσδόκητο σφάλμα" + }, + "error.0": { + "message": "Άκυρη μορφή" + }, + "error.1000": { + "message": "Η επικύρωση απέτυχε" + }, + "error.2000": { + "message": "Ο πόρος δεν βρέθηκε" + }, + "error.3000": { + "message": "Σφάλμα σύνδεσης" + }, + "error.4000": { + "message": "Η κατάσταση πορτοφολιού είναι κατεστραμμένη" + }, + "error.5000": { + "message": "Σφάλμα αποθήκευσης" + }, + "error.6000": { + "message": "Η μέθοδος δεν έχει υλοποιηθεί ή δεν υποστηρίζεται" + }, + "error.7000": { + "message": "Άρνηση άδειας ή ανεπαρκής εξουσιοδότηση" + }, + "error.8000": { + "message": "Σφάλμα ενέργειας χρήστη" + }, + "error.9000": { + "message": "Κατεστραμμένη κατάσταση, η αμετάβλητη δεν τηρήθηκε ή απέτυχε η επιβεβαίωση." + }, + "error.internal": { + "message": "Εσωτερικό σφάλμα. Παρακαλώ δοκιμάστε ξανά αργότερα ή επικοινωνήστε με την υποστήριξη" + }, + "confirmation.origin": { + "message": "Προέλευση" + }, + "confirmation.origin.tooltip": { + "message": "Η dApp που ζητά την υπογραφή" + }, + "confirmation.account": { + "message": "Λογαριασμός" + }, + "confirmation.signMessage.confirmButton": { + "message": "Υπογραφή" + }, + "confirmation.signMessage.title": { + "message": "Υπογραφή μηνύματος" + }, + "confirmation.signMessage.message": { + "message": "Μήνυμα" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/en-GB.json b/merged-packages/bitcoin-wallet-snap/locales/en-GB.json index 3c7fd265..7e2a6e40 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/en-GB.json +++ b/merged-packages/bitcoin-wallet-snap/locales/en-GB.json @@ -8,7 +8,7 @@ "message": "From" }, "toAddress": { - "message": "To Address" + "message": "To" }, "continue": { "message": "Continue" @@ -35,13 +35,13 @@ "message": "min" }, "transactionSpeed": { - "message": "Transaction Speed" + "message": "Transaction speed" }, "transactionSpeedTooltip": { "message": "The estimated time of the transaction" }, "networkFee": { - "message": "Network Fee" + "message": "Network fee" }, "feeRate": { "message": "Fee rate" @@ -144,6 +144,57 @@ }, "unexpected": { "message": "An unexpected error occurred" + }, + "error.0": { + "message": "Invalid format" + }, + "error.1000": { + "message": "Validation failed" + }, + "error.2000": { + "message": "Resource not found" + }, + "error.3000": { + "message": "Connection error" + }, + "error.4000": { + "message": "Wallet state corrupted" + }, + "error.5000": { + "message": "Storage error" + }, + "error.6000": { + "message": "Method not implemented or not supported" + }, + "error.7000": { + "message": "Permission denied or insufficient authorization" + }, + "error.8000": { + "message": "User action error" + }, + "error.9000": { + "message": "Corrupted state, invariant not respected or failed assertion." + }, + "error.internal": { + "message": "Internal error. Please try again later or contact support" + }, + "confirmation.origin": { + "message": "Origin" + }, + "confirmation.origin.tooltip": { + "message": "The dApp requesting the signature" + }, + "confirmation.account": { + "message": "Account" + }, + "confirmation.signMessage.confirmButton": { + "message": "Sign" + }, + "confirmation.signMessage.title": { + "message": "Sign Message" + }, + "confirmation.signMessage.message": { + "message": "Message" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/en.json b/merged-packages/bitcoin-wallet-snap/locales/en.json index 7a921d77..7e2a6e40 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/en.json +++ b/merged-packages/bitcoin-wallet-snap/locales/en.json @@ -177,6 +177,24 @@ }, "error.internal": { "message": "Internal error. Please try again later or contact support" + }, + "confirmation.origin": { + "message": "Origin" + }, + "confirmation.origin.tooltip": { + "message": "The dApp requesting the signature" + }, + "confirmation.account": { + "message": "Account" + }, + "confirmation.signMessage.confirmButton": { + "message": "Sign" + }, + "confirmation.signMessage.title": { + "message": "Sign Message" + }, + "confirmation.signMessage.message": { + "message": "Message" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/es-419.json b/merged-packages/bitcoin-wallet-snap/locales/es-419.json index f60f41ad..bb82522b 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/es-419.json +++ b/merged-packages/bitcoin-wallet-snap/locales/es-419.json @@ -8,16 +8,16 @@ "message": "De" }, "toAddress": { - "message": "To Address" + "message": "A" }, "continue": { - "message": "Continue" + "message": "Continuar" }, "cancel": { "message": "Cancelar" }, "clear": { - "message": "Clear" + "message": "Borrar" }, "amount": { "message": "Monto" @@ -41,13 +41,13 @@ "message": "El tiempo estimado de la transacción" }, "networkFee": { - "message": "Network Fee" + "message": "Tarifa de red" }, "feeRate": { - "message": "Fee rate" + "message": "Tasa de tarifa" }, "networkFeeTooltip": { - "message": "The total network fee" + "message": "La tarifa total de red" }, "total": { "message": "Total" @@ -56,16 +56,16 @@ "message": "Enviar" }, "asset": { - "message": "Asset" + "message": "Activo" }, "sending": { "message": "Enviando" }, "max": { - "message": "Máx." + "message": "Máx" }, "recipientPlaceholder": { - "message": "Enter receiving address" + "message": "Ingrese la dirección de recepción" }, "review": { "message": "Revisar" @@ -74,76 +74,127 @@ "message": "Error" }, "unknownError": { - "message": "An unknown error occurred" + "message": "Se produjo un error desconocido" }, "feeTooLow": { - "message": "Fee too low" + "message": "Tarifa demasiado baja" }, "feeRateTooLow": { - "message": "Fee rate too low" + "message": "Tasa de tarifa demasiado baja" }, "noUtxosSelected": { - "message": "Failed to build transaction: missing UTXOs" + "message": "No se pudo construir la transacción: faltan UTXOs" }, "outputBelowDustLimit": { - "message": "Amount below dust limit" + "message": "Monto por debajo del límite de dust" }, "insufficientFunds": { - "message": "Funds are insufficient to cover amount plus fee" + "message": "Fondos insuficientes para cubrir el monto más la tarifa" }, "noRecipients": { - "message": "Missing recipients" + "message": "Faltan destinatarios" }, "psbt": { - "message": "Invalid PSBT" + "message": "PSBT inválido" }, "unknownUtxo": { - "message": "Failed to build transaction: unknown UTXO" + "message": "No se pudo construir la transacción: UTXO desconocido" }, "MmssingNonWitnessUtxo": { - "message": "Failed to build transaction: missing non-witness UTXO" + "message": "No se pudo construir la transacción: falta UTXO non-witness" }, "base58": { - "message": "Invalid Bitcoin address" + "message": "Dirección de Bitcoin inválida" }, "bech32": { - "message": "Invalid Bitcoin address" + "message": "Dirección de Bitcoin inválida" }, "witnessVersion": { - "message": "Invalid Bitcoin address: witness version" + "message": "Dirección de Bitcoin inválida: versión witness" }, "witnessProgram": { - "message": "Invalid Bitcoin address: witness program" + "message": "Dirección de Bitcoin inválida: programa witness" }, "legacyAddressTooLong": { - "message": "Invalid Bitcoin address" + "message": "Dirección de Bitcoin inválida" }, "invalidBase58PayloadLength": { - "message": "Invalid Bitcoin address: invalid length" + "message": "Dirección de Bitcoin inválida: longitud inválida" }, "invalidLegacyPrefix": { - "message": "Invalid Bitcoin address: invalid legacy prefix" + "message": "Dirección de Bitcoin inválida: prefijo legacy inválido" }, "networkValidation": { - "message": "Invalid Bitcoin address: wrong network" + "message": "Dirección de Bitcoin inválida: red incorrecta" }, "outOfRange": { - "message": "Invalid amount: out of range" + "message": "Monto inválido: fuera de rango" }, "tooPrecise": { - "message": "Invalid amount: too precise" + "message": "Monto inválido: demasiado preciso" }, "missingDigits": { - "message": "Invalid amount: missing digits" + "message": "Monto inválido: faltan dígitos" }, "inputTooLarge": { - "message": "Invalid amount: too large" + "message": "Monto inválido: demasiado grande" }, "invalidCharacter": { - "message": "Invalid amount: invalid character" + "message": "Monto inválido: carácter inválido" }, "unexpected": { - "message": "An unexpected error occurred" + "message": "Se produjo un error inesperado" + }, + "error.0": { + "message": "Formato inválido" + }, + "error.1000": { + "message": "Validación fallida" + }, + "error.2000": { + "message": "Recurso no encontrado" + }, + "error.3000": { + "message": "Error de conexión" + }, + "error.4000": { + "message": "Estado de la billetera corrompido" + }, + "error.5000": { + "message": "Error de almacenamiento" + }, + "error.6000": { + "message": "Método no implementado o no soportado" + }, + "error.7000": { + "message": "Permiso denegado o autorización insuficiente" + }, + "error.8000": { + "message": "Error de acción del usuario" + }, + "error.9000": { + "message": "Estado corrompido, invariante no respetada o aserción fallida." + }, + "error.internal": { + "message": "Error interno. Por favor intente de nuevo más tarde o contacte al soporte" + }, + "confirmation.origin": { + "message": "Origen" + }, + "confirmation.origin.tooltip": { + "message": "La dApp solicitando la firma" + }, + "confirmation.account": { + "message": "Cuenta" + }, + "confirmation.signMessage.confirmButton": { + "message": "Firmar" + }, + "confirmation.signMessage.title": { + "message": "Firmar mensaje" + }, + "confirmation.signMessage.message": { + "message": "Mensaje" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/fr-FR.json b/merged-packages/bitcoin-wallet-snap/locales/fr-FR.json index ef0b52d8..b7e7589d 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/fr-FR.json +++ b/merged-packages/bitcoin-wallet-snap/locales/fr-FR.json @@ -8,16 +8,16 @@ "message": "De" }, "toAddress": { - "message": "To Address" + "message": "À" }, "continue": { - "message": "Continue" + "message": "Continuer" }, "cancel": { "message": "Annuler" }, "clear": { - "message": "Clear" + "message": "Effacer" }, "amount": { "message": "Montant" @@ -38,16 +38,16 @@ "message": "Vitesse de transaction" }, "transactionSpeedTooltip": { - "message": "Heure estimée de la transaction" + "message": "Le temps estimé de la transaction" }, "networkFee": { - "message": "Network Fee" + "message": "Frais de réseau" }, "feeRate": { - "message": "Fee rate" + "message": "Taux de frais" }, "networkFeeTooltip": { - "message": "The total network fee" + "message": "Les frais de réseau totaux" }, "total": { "message": "Total" @@ -56,16 +56,16 @@ "message": "Envoyer" }, "asset": { - "message": "Asset" + "message": "Actif" }, "sending": { "message": "Envoi" }, "max": { - "message": "Max." + "message": "Max" }, "recipientPlaceholder": { - "message": "Enter receiving address" + "message": "Entrez l'adresse de réception" }, "review": { "message": "Vérifier" @@ -74,76 +74,127 @@ "message": "Erreur" }, "unknownError": { - "message": "An unknown error occurred" + "message": "Une erreur inconnue s'est produite" }, "feeTooLow": { - "message": "Fee too low" + "message": "Frais trop bas" }, "feeRateTooLow": { - "message": "Fee rate too low" + "message": "Taux de frais trop bas" }, "noUtxosSelected": { - "message": "Failed to build transaction: missing UTXOs" + "message": "Échec de la construction de la transaction : UTXOs manquants" }, "outputBelowDustLimit": { - "message": "Amount below dust limit" + "message": "Montant en dessous de la limite dust" }, "insufficientFunds": { - "message": "Funds are insufficient to cover amount plus fee" + "message": "Fonds insuffisants pour couvrir le montant plus les frais" }, "noRecipients": { - "message": "Missing recipients" + "message": "Destinataires manquants" }, "psbt": { - "message": "Invalid PSBT" + "message": "PSBT invalide" }, "unknownUtxo": { - "message": "Failed to build transaction: unknown UTXO" + "message": "Échec de la construction de la transaction : UTXO inconnu" }, "MmssingNonWitnessUtxo": { - "message": "Failed to build transaction: missing non-witness UTXO" + "message": "Échec de la construction de la transaction : UTXO non-witness manquant" }, "base58": { - "message": "Invalid Bitcoin address" + "message": "Adresse Bitcoin invalide" }, "bech32": { - "message": "Invalid Bitcoin address" + "message": "Adresse Bitcoin invalide" }, "witnessVersion": { - "message": "Invalid Bitcoin address: witness version" + "message": "Adresse Bitcoin invalide : version witness" }, "witnessProgram": { - "message": "Invalid Bitcoin address: witness program" + "message": "Adresse Bitcoin invalide : programme witness" }, "legacyAddressTooLong": { - "message": "Invalid Bitcoin address" + "message": "Adresse Bitcoin invalide" }, "invalidBase58PayloadLength": { - "message": "Invalid Bitcoin address: invalid length" + "message": "Adresse Bitcoin invalide : longueur invalide" }, "invalidLegacyPrefix": { - "message": "Invalid Bitcoin address: invalid legacy prefix" + "message": "Adresse Bitcoin invalide : préfixe legacy invalide" }, "networkValidation": { - "message": "Invalid Bitcoin address: wrong network" + "message": "Adresse Bitcoin invalide : réseau incorrect" }, "outOfRange": { - "message": "Invalid amount: out of range" + "message": "Montant invalide : hors plage" }, "tooPrecise": { - "message": "Invalid amount: too precise" + "message": "Montant invalide : trop précis" }, "missingDigits": { - "message": "Invalid amount: missing digits" + "message": "Montant invalide : chiffres manquants" }, "inputTooLarge": { - "message": "Invalid amount: too large" + "message": "Montant invalide : trop grand" }, "invalidCharacter": { - "message": "Invalid amount: invalid character" + "message": "Montant invalide : caractère invalide" }, "unexpected": { - "message": "An unexpected error occurred" + "message": "Une erreur inattendue s'est produite" + }, + "error.0": { + "message": "Format invalide" + }, + "error.1000": { + "message": "Validation échouée" + }, + "error.2000": { + "message": "Ressource non trouvée" + }, + "error.3000": { + "message": "Erreur de connexion" + }, + "error.4000": { + "message": "État du portefeuille corrompu" + }, + "error.5000": { + "message": "Erreur de stockage" + }, + "error.6000": { + "message": "Méthode non implémentée ou non supportée" + }, + "error.7000": { + "message": "Permission refusée ou autorisation insuffisante" + }, + "error.8000": { + "message": "Erreur d'action de l'utilisateur" + }, + "error.9000": { + "message": "État corrompu, invariante non respectée ou assertion échouée." + }, + "error.internal": { + "message": "Erreur interne. Veuillez réessayer plus tard ou contacter le support" + }, + "confirmation.origin": { + "message": "Origine" + }, + "confirmation.origin.tooltip": { + "message": "Le dApp demandant la signature" + }, + "confirmation.account": { + "message": "Compte" + }, + "confirmation.signMessage.confirmButton": { + "message": "Signer" + }, + "confirmation.signMessage.title": { + "message": "Signer le message" + }, + "confirmation.signMessage.message": { + "message": "Message" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/hi-IN.json b/merged-packages/bitcoin-wallet-snap/locales/hi-IN.json index f7f90c9d..47f9ea9d 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/hi-IN.json +++ b/merged-packages/bitcoin-wallet-snap/locales/hi-IN.json @@ -5,25 +5,25 @@ "message": "आगे बढ़ने से पहले ट्रांसेक्शन की समीक्षा करें" }, "from": { - "message": "इनके द्वारा" + "message": "से" }, "toAddress": { - "message": "To Address" + "message": "को" }, "continue": { - "message": "Continue" + "message": "जारी रखें" }, "cancel": { - "message": "कैंसिल करें" + "message": "रद्द करें" }, "clear": { - "message": "Clear" + "message": "साफ़ करें" }, "amount": { - "message": "अमाउंट" + "message": "राशि" }, "balance": { - "message": "बैलेंस" + "message": "शेष राशि" }, "recipient": { "message": "प्राप्तकर्ता" @@ -32,7 +32,7 @@ "message": "नेटवर्क" }, "minutes": { - "message": "min" + "message": "मिनट" }, "transactionSpeed": { "message": "ट्रांसेक्शन की गति" @@ -41,13 +41,13 @@ "message": "ट्रांसेक्शन का अनुमानित समय" }, "networkFee": { - "message": "Network Fee" + "message": "नेटवर्क शुल्क" }, "feeRate": { - "message": "Fee rate" + "message": "शुल्क दर" }, "networkFeeTooltip": { - "message": "The total network fee" + "message": "कुल नेटवर्क शुल्क" }, "total": { "message": "कुल" @@ -56,7 +56,7 @@ "message": "भेजें" }, "asset": { - "message": "Asset" + "message": "एसेट" }, "sending": { "message": "भेजा जा रहा है" @@ -65,85 +65,136 @@ "message": "अधिकतम" }, "recipientPlaceholder": { - "message": "Enter receiving address" + "message": "प्राप्त करने वाला पता दर्ज करें" }, "review": { "message": "समीक्षा करें" }, "error": { - "message": "एरर" + "message": "त्रुटि" }, "unknownError": { - "message": "An unknown error occurred" + "message": "एक अज्ञात त्रुटि हुई" }, "feeTooLow": { - "message": "Fee too low" + "message": "शुल्क बहुत कम है" }, "feeRateTooLow": { - "message": "Fee rate too low" + "message": "शुल्क दर बहुत कम है" }, "noUtxosSelected": { - "message": "Failed to build transaction: missing UTXOs" + "message": "ट्रांसेक्शन बनाने में विफल: UTXOs गायब हैं" }, "outputBelowDustLimit": { - "message": "Amount below dust limit" + "message": "राशि डस्ट लिमिट से कम है" }, "insufficientFunds": { - "message": "Funds are insufficient to cover amount plus fee" + "message": "राशि और शुल्क कवर करने के लिए अपर्याप्त फंड" }, "noRecipients": { - "message": "Missing recipients" + "message": "प्राप्तकर्ता गायब हैं" }, "psbt": { - "message": "Invalid PSBT" + "message": "अमान्य PSBT" }, "unknownUtxo": { - "message": "Failed to build transaction: unknown UTXO" + "message": "ट्रांसेक्शन बनाने में विफल: अज्ञात UTXO" }, "MmssingNonWitnessUtxo": { - "message": "Failed to build transaction: missing non-witness UTXO" + "message": "ट्रांसेक्शन बनाने में विफल: गैर-गवाह UTXO गायब" }, "base58": { - "message": "Invalid Bitcoin address" + "message": "अमान्य बिटकॉइन पता" }, "bech32": { - "message": "Invalid Bitcoin address" + "message": "अमान्य बिटकॉइन पता" }, "witnessVersion": { - "message": "Invalid Bitcoin address: witness version" + "message": "अमान्य बिटकॉइन पता: गवाह संस्करण" }, "witnessProgram": { - "message": "Invalid Bitcoin address: witness program" + "message": "अमान्य बिटकॉइन पता: गवाह कार्यक्रम" }, "legacyAddressTooLong": { - "message": "Invalid Bitcoin address" + "message": "अमान्य बिटकॉइन पता" }, "invalidBase58PayloadLength": { - "message": "Invalid Bitcoin address: invalid length" + "message": "अमान्य बिटकॉइन पता: अमान्य लंबाई" }, "invalidLegacyPrefix": { - "message": "Invalid Bitcoin address: invalid legacy prefix" + "message": "अमान्य बिटकॉइन पता: अमान्य लिगेसी उपसर्ग" }, "networkValidation": { - "message": "Invalid Bitcoin address: wrong network" + "message": "अमान्य बिटकॉइन पता: गलत नेटवर्क" }, "outOfRange": { - "message": "Invalid amount: out of range" + "message": "अमान्य राशि: रेंज के बाहर" }, "tooPrecise": { - "message": "Invalid amount: too precise" + "message": "अमान्य राशि: बहुत सटीक" }, "missingDigits": { - "message": "Invalid amount: missing digits" + "message": "अमान्य राशि: अंक गायब" }, "inputTooLarge": { - "message": "Invalid amount: too large" + "message": "अमान्य राशि: बहुत बड़ा" }, "invalidCharacter": { - "message": "Invalid amount: invalid character" + "message": "अमान्य राशि: अमान्य अक्षर" }, "unexpected": { - "message": "An unexpected error occurred" + "message": "एक अप्रत्याशित त्रुटि हुई" + }, + "error.0": { + "message": "अमान्य प्रारूप" + }, + "error.1000": { + "message": "मान्यकरण विफल" + }, + "error.2000": { + "message": "संसाधन नहीं मिला" + }, + "error.3000": { + "message": "कनेक्शन त्रुटि" + }, + "error.4000": { + "message": "वॉलेट स्थिति दूषित" + }, + "error.5000": { + "message": "भंडारण त्रुटि" + }, + "error.6000": { + "message": "विधि लागू नहीं या समर्थित नहीं" + }, + "error.7000": { + "message": "अनुमति अस्वीकृत या अपर्याप्त प्राधिकरण" + }, + "error.8000": { + "message": "उपयोगकर्ता क्रिया त्रुटि" + }, + "error.9000": { + "message": "दूषित स्थिति, अपरिवर्तनीय नहीं माना गया या असफल दावा." + }, + "error.internal": { + "message": "आंतरिक त्रुटि. कृपया बाद में प्रयास करें या समर्थन से संपर्क करें" + }, + "confirmation.origin": { + "message": "उत्पत्ति" + }, + "confirmation.origin.tooltip": { + "message": "हस्ताक्षर का अनुरोध करने वाला dApp" + }, + "confirmation.account": { + "message": "खाता" + }, + "confirmation.signMessage.confirmButton": { + "message": "हस्ताक्षर" + }, + "confirmation.signMessage.title": { + "message": "संदेश पर हस्ताक्षर करें" + }, + "confirmation.signMessage.message": { + "message": "संदेश" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/id-ID.json b/merged-packages/bitcoin-wallet-snap/locales/id-ID.json index 9468b4b3..c853b941 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/id-ID.json +++ b/merged-packages/bitcoin-wallet-snap/locales/id-ID.json @@ -8,16 +8,16 @@ "message": "Dari" }, "toAddress": { - "message": "To Address" + "message": "Ke" }, "continue": { - "message": "Continue" + "message": "Lanjutkan" }, "cancel": { "message": "Batal" }, "clear": { - "message": "Clear" + "message": "Hapus" }, "amount": { "message": "Jumlah" @@ -32,22 +32,22 @@ "message": "Jaringan" }, "minutes": { - "message": "min" + "message": "menit" }, "transactionSpeed": { - "message": "Kecepatan Transaksi" + "message": "Kecepatan transaksi" }, "transactionSpeedTooltip": { "message": "Estimasi waktu transaksi" }, "networkFee": { - "message": "Network Fee" + "message": "Biaya jaringan" }, "feeRate": { - "message": "Fee rate" + "message": "Tingkat biaya" }, "networkFeeTooltip": { - "message": "The total network fee" + "message": "Total biaya jaringan" }, "total": { "message": "Total" @@ -56,7 +56,7 @@ "message": "Kirim" }, "asset": { - "message": "Asset" + "message": "Aset" }, "sending": { "message": "Mengirim" @@ -65,7 +65,7 @@ "message": "Maks" }, "recipientPlaceholder": { - "message": "Enter receiving address" + "message": "Masukkan alamat penerima" }, "review": { "message": "Tinjau" @@ -74,76 +74,127 @@ "message": "Kesalahan" }, "unknownError": { - "message": "An unknown error occurred" + "message": "Terjadi kesalahan yang tidak diketahui" }, "feeTooLow": { - "message": "Fee too low" + "message": "Biaya terlalu rendah" }, "feeRateTooLow": { - "message": "Fee rate too low" + "message": "Tingkat biaya terlalu rendah" }, "noUtxosSelected": { - "message": "Failed to build transaction: missing UTXOs" + "message": "Gagal membangun transaksi: UTXOs hilang" }, "outputBelowDustLimit": { - "message": "Amount below dust limit" + "message": "Jumlah di bawah batas dust" }, "insufficientFunds": { - "message": "Funds are insufficient to cover amount plus fee" + "message": "Dana tidak cukup untuk menutupi jumlah plus biaya" }, "noRecipients": { - "message": "Missing recipients" + "message": "Penerima hilang" }, "psbt": { - "message": "Invalid PSBT" + "message": "PSBT tidak valid" }, "unknownUtxo": { - "message": "Failed to build transaction: unknown UTXO" + "message": "Gagal membangun transaksi: UTXO tidak dikenal" }, "MmssingNonWitnessUtxo": { - "message": "Failed to build transaction: missing non-witness UTXO" + "message": "Gagal membangun transaksi: UTXO non-witness hilang" }, "base58": { - "message": "Invalid Bitcoin address" + "message": "Alamat Bitcoin tidak valid" }, "bech32": { - "message": "Invalid Bitcoin address" + "message": "Alamat Bitcoin tidak valid" }, "witnessVersion": { - "message": "Invalid Bitcoin address: witness version" + "message": "Alamat Bitcoin tidak valid: versi witness" }, "witnessProgram": { - "message": "Invalid Bitcoin address: witness program" + "message": "Alamat Bitcoin tidak valid: program witness" }, "legacyAddressTooLong": { - "message": "Invalid Bitcoin address" + "message": "Alamat Bitcoin tidak valid" }, "invalidBase58PayloadLength": { - "message": "Invalid Bitcoin address: invalid length" + "message": "Alamat Bitcoin tidak valid: panjang tidak valid" }, "invalidLegacyPrefix": { - "message": "Invalid Bitcoin address: invalid legacy prefix" + "message": "Alamat Bitcoin tidak valid: prefix legacy tidak valid" }, "networkValidation": { - "message": "Invalid Bitcoin address: wrong network" + "message": "Alamat Bitcoin tidak valid: jaringan salah" }, "outOfRange": { - "message": "Invalid amount: out of range" + "message": "Jumlah tidak valid: di luar jangkauan" }, "tooPrecise": { - "message": "Invalid amount: too precise" + "message": "Jumlah tidak valid: terlalu presisi" }, "missingDigits": { - "message": "Invalid amount: missing digits" + "message": "Jumlah tidak valid: digit hilang" }, "inputTooLarge": { - "message": "Invalid amount: too large" + "message": "Jumlah tidak valid: terlalu besar" }, "invalidCharacter": { - "message": "Invalid amount: invalid character" + "message": "Jumlah tidak valid: karakter tidak valid" }, "unexpected": { - "message": "An unexpected error occurred" + "message": "Terjadi kesalahan tak terduga" + }, + "error.0": { + "message": "Format tidak valid" + }, + "error.1000": { + "message": "Validasi gagal" + }, + "error.2000": { + "message": "Sumber daya tidak ditemukan" + }, + "error.3000": { + "message": "Kesalahan koneksi" + }, + "error.4000": { + "message": "Keadaan wallet rusak" + }, + "error.5000": { + "message": "Kesalahan penyimpanan" + }, + "error.6000": { + "message": "Metode tidak diimplementasikan atau tidak didukung" + }, + "error.7000": { + "message": "Izin ditolak atau otorisasi tidak cukup" + }, + "error.8000": { + "message": "Kesalahan tindakan pengguna" + }, + "error.9000": { + "message": "Keadaan rusak, invarianta tidak dihormati atau asersi gagal." + }, + "error.internal": { + "message": "Kesalahan internal. Silakan coba lagi nanti atau hubungi dukungan" + }, + "confirmation.origin": { + "message": "Asal" + }, + "confirmation.origin.tooltip": { + "message": "DApp yang meminta tanda tangan" + }, + "confirmation.account": { + "message": "Akun" + }, + "confirmation.signMessage.confirmButton": { + "message": "Tanda tangani" + }, + "confirmation.signMessage.title": { + "message": "Tanda tangani Pesan" + }, + "confirmation.signMessage.message": { + "message": "Pesan" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/ja-JP.json b/merged-packages/bitcoin-wallet-snap/locales/ja-JP.json index b626675a..8bbd2f1b 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/ja-JP.json +++ b/merged-packages/bitcoin-wallet-snap/locales/ja-JP.json @@ -1,5 +1,5 @@ { - "locale": "en", + "locale": "ja", "messages": { "reviewTransactionWarning": { "message": "進める前にトランザクションを確認してください" @@ -8,22 +8,22 @@ "message": "送金元" }, "toAddress": { - "message": "To Address" + "message": "宛先" }, "continue": { - "message": "Continue" + "message": "続ける" }, "cancel": { "message": "キャンセル" }, "clear": { - "message": "Clear" + "message": "クリア" }, "amount": { "message": "金額" }, "balance": { - "message": "残額" + "message": "残高" }, "recipient": { "message": "受取人" @@ -32,118 +32,169 @@ "message": "ネットワーク" }, "minutes": { - "message": "min" + "message": "分" }, "transactionSpeed": { - "message": "トランザクションの速度" + "message": "トランザクション速度" }, "transactionSpeedTooltip": { "message": "トランザクションの予想時間" }, "networkFee": { - "message": "Network Fee" + "message": "ネットワーク手数料" }, "feeRate": { - "message": "Fee rate" + "message": "手数料率" }, "networkFeeTooltip": { - "message": "The total network fee" + "message": "総ネットワーク手数料" }, "total": { "message": "合計" }, "send": { - "message": "送金" + "message": "送信" }, "asset": { - "message": "Asset" + "message": "資産" }, "sending": { - "message": "送金中" + "message": "送信中" }, "max": { "message": "最大" }, "recipientPlaceholder": { - "message": "Enter receiving address" + "message": "受信アドレスを入力" }, "review": { - "message": "確認" + "message": "レビュー" }, "error": { "message": "エラー" }, "unknownError": { - "message": "An unknown error occurred" + "message": "不明なエラーが発生しました" }, "feeTooLow": { - "message": "Fee too low" + "message": "手数料が低すぎます" }, "feeRateTooLow": { - "message": "Fee rate too low" + "message": "手数料率が低すぎます" }, "noUtxosSelected": { - "message": "Failed to build transaction: missing UTXOs" + "message": "トランザクションの構築に失敗: UTXO が不足しています" }, "outputBelowDustLimit": { - "message": "Amount below dust limit" + "message": "金額がダスト制限以下です" }, "insufficientFunds": { - "message": "Funds are insufficient to cover amount plus fee" + "message": "金額プラス手数料をカバーするのに資金が不足しています" }, "noRecipients": { - "message": "Missing recipients" + "message": "受取人が不足しています" }, "psbt": { - "message": "Invalid PSBT" + "message": "無効な PSBT" }, "unknownUtxo": { - "message": "Failed to build transaction: unknown UTXO" + "message": "トランザクションの構築に失敗: 不明な UTXO" }, "MmssingNonWitnessUtxo": { - "message": "Failed to build transaction: missing non-witness UTXO" + "message": "トランザクションの構築に失敗: non-witness UTXO が不足しています" }, "base58": { - "message": "Invalid Bitcoin address" + "message": "無効な Bitcoin アドレス" }, "bech32": { - "message": "Invalid Bitcoin address" + "message": "無効な Bitcoin アドレス" }, "witnessVersion": { - "message": "Invalid Bitcoin address: witness version" + "message": "無効な Bitcoin アドレス: witness バージョン" }, "witnessProgram": { - "message": "Invalid Bitcoin address: witness program" + "message": "無効な Bitcoin アドレス: witness プログラム" }, "legacyAddressTooLong": { - "message": "Invalid Bitcoin address" + "message": "無効な Bitcoin アドレス" }, "invalidBase58PayloadLength": { - "message": "Invalid Bitcoin address: invalid length" + "message": "無効な Bitcoin アドレス: 無効な長さ" }, "invalidLegacyPrefix": { - "message": "Invalid Bitcoin address: invalid legacy prefix" + "message": "無効な Bitcoin アドレス: 無効な legacy プレフィックス" }, "networkValidation": { - "message": "Invalid Bitcoin address: wrong network" + "message": "無効な Bitcoin アドレス: 間違ったネットワーク" }, "outOfRange": { - "message": "Invalid amount: out of range" + "message": "無効な金額: 範囲外" }, "tooPrecise": { - "message": "Invalid amount: too precise" + "message": "無効な金額: 精度が高すぎる" }, "missingDigits": { - "message": "Invalid amount: missing digits" + "message": "無効な金額: 桁が不足" }, "inputTooLarge": { - "message": "Invalid amount: too large" + "message": "無効な金額: 大きすぎる" }, "invalidCharacter": { - "message": "Invalid amount: invalid character" + "message": "無効な金額: 無効な文字" }, "unexpected": { - "message": "An unexpected error occurred" + "message": "予期せぬエラーが発生しました" + }, + "error.0": { + "message": "無効な形式" + }, + "error.1000": { + "message": "検証に失敗しました" + }, + "error.2000": { + "message": "リソースが見つかりません" + }, + "error.3000": { + "message": "接続エラー" + }, + "error.4000": { + "message": "ウォレット状態が破損しています" + }, + "error.5000": { + "message": "ストレージエラー" + }, + "error.6000": { + "message": "メソッドが実装されていないかサポートされていません" + }, + "error.7000": { + "message": "権限が拒否されたか十分な認証がありません" + }, + "error.8000": { + "message": "ユーザーアクションエラー" + }, + "error.9000": { + "message": "破損した状態、不変条件が守られていないかアサーションに失敗しました。" + }, + "error.internal": { + "message": "内部エラー。後ほどお試しくださいまたはサポートにご連絡ください" + }, + "confirmation.origin": { + "message": "オリジン" + }, + "confirmation.origin.tooltip": { + "message": "署名をリクエストしている dApp" + }, + "confirmation.account": { + "message": "アカウント" + }, + "confirmation.signMessage.confirmButton": { + "message": "署名" + }, + "confirmation.signMessage.title": { + "message": "メッセージに署名" + }, + "confirmation.signMessage.message": { + "message": "メッセージ" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/ko-KR.json b/merged-packages/bitcoin-wallet-snap/locales/ko-KR.json index 393c927e..b0c18816 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/ko-KR.json +++ b/merged-packages/bitcoin-wallet-snap/locales/ko-KR.json @@ -144,6 +144,27 @@ }, "unexpected": { "message": "An unexpected error occurred" + }, + "error.internal": { + "message": "내부 오류. 나중에 다시 시도하거나 지원팀에 문의하세요" + }, + "confirmation.origin": { + "message": "출처" + }, + "confirmation.origin.tooltip": { + "message": "서명을 요청하는 dApp" + }, + "confirmation.account": { + "message": "계정" + }, + "confirmation.signMessage.confirmButton": { + "message": "서명" + }, + "confirmation.signMessage.title": { + "message": "메시지 서명" + }, + "confirmation.signMessage.message": { + "message": "메시지" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/pt-BR.json b/merged-packages/bitcoin-wallet-snap/locales/pt-BR.json index 7a15fa09..56c15f61 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/pt-BR.json +++ b/merged-packages/bitcoin-wallet-snap/locales/pt-BR.json @@ -144,6 +144,27 @@ }, "unexpected": { "message": "An unexpected error occurred" + }, + "error.internal": { + "message": "Erro interno. Por favor, tente novamente mais tarde ou contate o suporte" + }, + "confirmation.origin": { + "message": "Origem" + }, + "confirmation.origin.tooltip": { + "message": "O dApp solicitando a assinatura" + }, + "confirmation.account": { + "message": "Conta" + }, + "confirmation.signMessage.confirmButton": { + "message": "Assinar" + }, + "confirmation.signMessage.title": { + "message": "Assinar Mensagem" + }, + "confirmation.signMessage.message": { + "message": "Mensagem" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/ru-RU.json b/merged-packages/bitcoin-wallet-snap/locales/ru-RU.json index cd29a778..00151dcd 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/ru-RU.json +++ b/merged-packages/bitcoin-wallet-snap/locales/ru-RU.json @@ -144,6 +144,27 @@ }, "unexpected": { "message": "An unexpected error occurred" + }, + "error.internal": { + "message": "Внутренняя ошибка. Пожалуйста, попробуйте позже или обратитесь в поддержку" + }, + "confirmation.origin": { + "message": "Источник" + }, + "confirmation.origin.tooltip": { + "message": "dApp, запрашивающий подпись" + }, + "confirmation.account": { + "message": "Аккаунт" + }, + "confirmation.signMessage.confirmButton": { + "message": "Подписать" + }, + "confirmation.signMessage.title": { + "message": "Подписать сообщение" + }, + "confirmation.signMessage.message": { + "message": "Сообщение" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/tl-PH.json b/merged-packages/bitcoin-wallet-snap/locales/tl-PH.json index b96afd40..bb90b085 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/tl-PH.json +++ b/merged-packages/bitcoin-wallet-snap/locales/tl-PH.json @@ -144,6 +144,27 @@ }, "unexpected": { "message": "An unexpected error occurred" + }, + "error.internal": { + "message": "Panloob na error. Subukang muli mamaya o makipag-ugnayan sa suporta" + }, + "confirmation.origin": { + "message": "Pinagmulan" + }, + "confirmation.origin.tooltip": { + "message": "Ang dApp na humihingi ng lagda" + }, + "confirmation.account": { + "message": "Account" + }, + "confirmation.signMessage.confirmButton": { + "message": "Lagdaan" + }, + "confirmation.signMessage.title": { + "message": "Lagdaan ang Mensahe" + }, + "confirmation.signMessage.message": { + "message": "Mensahe" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/tr-TR.json b/merged-packages/bitcoin-wallet-snap/locales/tr-TR.json index 01efb72b..2e843791 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/tr-TR.json +++ b/merged-packages/bitcoin-wallet-snap/locales/tr-TR.json @@ -144,6 +144,27 @@ }, "unexpected": { "message": "An unexpected error occurred" + }, + "error.internal": { + "message": "İç hata. Lütfen daha sonra tekrar deneyin veya destekle iletişime geçin" + }, + "confirmation.origin": { + "message": "Kaynak" + }, + "confirmation.origin.tooltip": { + "message": "İmzayı isteyen dApp" + }, + "confirmation.account": { + "message": "Hesap" + }, + "confirmation.signMessage.confirmButton": { + "message": "İmzala" + }, + "confirmation.signMessage.title": { + "message": "Mesajı İmzala" + }, + "confirmation.signMessage.message": { + "message": "Mesaj" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/vi-VN.json b/merged-packages/bitcoin-wallet-snap/locales/vi-VN.json index 7f2b8cda..ae6659b1 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/vi-VN.json +++ b/merged-packages/bitcoin-wallet-snap/locales/vi-VN.json @@ -144,6 +144,27 @@ }, "unexpected": { "message": "An unexpected error occurred" + }, + "error.internal": { + "message": "Lỗi nội bộ. Vui lòng thử lại sau hoặc liên hệ hỗ trợ" + }, + "confirmation.origin": { + "message": "Nguồn gốc" + }, + "confirmation.origin.tooltip": { + "message": "DApp yêu cầu chữ ký" + }, + "confirmation.account": { + "message": "Tài khoản" + }, + "confirmation.signMessage.confirmButton": { + "message": "Ký" + }, + "confirmation.signMessage.title": { + "message": "Ký Tin nhắn" + }, + "confirmation.signMessage.message": { + "message": "Tin nhắn" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/zh-CN.json b/merged-packages/bitcoin-wallet-snap/locales/zh-CN.json index 6ec9a606..c8f4a09f 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/zh-CN.json +++ b/merged-packages/bitcoin-wallet-snap/locales/zh-CN.json @@ -144,6 +144,27 @@ }, "unexpected": { "message": "An unexpected error occurred" + }, + "error.internal": { + "message": "内部错误。请稍后重试或联系支持" + }, + "confirmation.origin": { + "message": "来源" + }, + "confirmation.origin.tooltip": { + "message": "请求签名的 dApp" + }, + "confirmation.account": { + "message": "账户" + }, + "confirmation.signMessage.confirmButton": { + "message": "签名" + }, + "confirmation.signMessage.title": { + "message": "签名消息" + }, + "confirmation.signMessage.message": { + "message": "消息" } } } diff --git a/merged-packages/bitcoin-wallet-snap/messages.json b/merged-packages/bitcoin-wallet-snap/messages.json index 80aa5734..382057f9 100644 --- a/merged-packages/bitcoin-wallet-snap/messages.json +++ b/merged-packages/bitcoin-wallet-snap/messages.json @@ -175,5 +175,23 @@ }, "error.internal": { "message": "Internal error. Please try again later or contact support" + }, + "confirmation.origin": { + "message": "Origin" + }, + "confirmation.origin.tooltip": { + "message": "The dApp requesting the signature" + }, + "confirmation.account": { + "message": "Account" + }, + "confirmation.signMessage.confirmButton": { + "message": "Sign" + }, + "confirmation.signMessage.title": { + "message": "Sign Message" + }, + "confirmation.signMessage.message": { + "message": "Message" } } diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 735d50f6..9ec738ed 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -38,15 +38,15 @@ "@jest/globals": "^30.0.5", "@metamask/bitcoindevkit": "^0.1.13", "@metamask/key-tree": "^10.1.1", - "@metamask/keyring-api": "^20.0.0", + "@metamask/keyring-api": "^20.1.1", "@metamask/keyring-snap-sdk": "^6.0.0", "@metamask/slip44": "^4.2.0", - "@metamask/snaps-cli": "^8.2.0", + "@metamask/snaps-cli": "^8.3.0", "@metamask/snaps-jest": "^9.4.0", "@metamask/snaps-sdk": "^9.3.0", "@metamask/utils": "^11.4.2", "bip322-js": "^3.0.0", - "concurrently": "^9.2.0", + "concurrently": "^9.2.1", "dotenv": "^17.2.1", "jest": "^30.0.5", "jest-mock-extended": "^4.0.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 45a2baa5..233910ac 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "ZQif6XorIIX1p5KFAO3K+Ju0xecutTtgzWBpBcVJpok=", + "shasum": "Qqyp3wm4csoOj5KYFHRfCKhAj0iX9H3uefgMGdv5r2s=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignMessageConfirmationView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignMessageConfirmationView.tsx index 814c4d86..2388c56a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignMessageConfirmationView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignMessageConfirmationView.tsx @@ -4,7 +4,6 @@ import { Button, Container, Footer, - Heading, Icon, Section, Text as SnapText, @@ -17,8 +16,7 @@ import type { SignMessageConfirmationContext, } from '../../../entities'; import { ConfirmationEvent } from '../../../entities'; -import { networkToScope } from '../../../handlers'; -import { AssetIcon } from '../components'; +import { HeadingWithReturn } from '../components'; import { displayCaip10, displayOrigin, translate } from '../format'; type SignMessageConfirmationViewProps = { @@ -36,10 +34,10 @@ export const SignMessageConfirmationView: SnapComponent< return ( - - {null} - {t('confirmation.signMessage.title')} - +
@@ -75,25 +73,12 @@ export const SignMessageConfirmationView: SnapComponent< displayName /> - - - {t('confirmation.network')} - - - - - - {networkToScope[network]} - -
- +
From 7c004e5deee672d1814240817a038d71cdc0f81a Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Tue, 26 Aug 2025 18:29:27 +0200 Subject: [PATCH 282/362] fix: locales (#528) --- .../locales/{de-DE.json => de.json} | 0 .../locales/{el-GR.json => el.json} | 0 .../bitcoin-wallet-snap/locales/en-GB.json | 200 ------------------ .../locales/{es-419.json => es.json} | 0 .../bitcoin-wallet-snap/locales/es_419.json | 200 ++++++++++++++++++ .../locales/{fr-FR.json => fr.json} | 0 .../locales/{hi-IN.json => hi.json} | 0 .../locales/{id-ID.json => id.json} | 0 .../locales/{ja-JP.json => ja.json} | 0 .../locales/{ko-KR.json => ko.json} | 0 .../locales/{pt-BR.json => pt.json} | 0 .../locales/{ru-RU.json => ru.json} | 0 .../locales/{tl-PH.json => tl.json} | 0 .../locales/{tr-TR.json => tr.json} | 0 .../locales/{vi-VN.json => vi.json} | 0 .../locales/{zh-CN.json => zh_CN.json} | 0 .../scripts/build-preinstalled-snap.js | 15 +- .../bitcoin-wallet-snap/snap.manifest.json | 15 +- .../bitcoin-wallet-snap/src/config.ts | 2 +- 19 files changed, 223 insertions(+), 209 deletions(-) rename merged-packages/bitcoin-wallet-snap/locales/{de-DE.json => de.json} (100%) rename merged-packages/bitcoin-wallet-snap/locales/{el-GR.json => el.json} (100%) delete mode 100644 merged-packages/bitcoin-wallet-snap/locales/en-GB.json rename merged-packages/bitcoin-wallet-snap/locales/{es-419.json => es.json} (100%) create mode 100644 merged-packages/bitcoin-wallet-snap/locales/es_419.json rename merged-packages/bitcoin-wallet-snap/locales/{fr-FR.json => fr.json} (100%) rename merged-packages/bitcoin-wallet-snap/locales/{hi-IN.json => hi.json} (100%) rename merged-packages/bitcoin-wallet-snap/locales/{id-ID.json => id.json} (100%) rename merged-packages/bitcoin-wallet-snap/locales/{ja-JP.json => ja.json} (100%) rename merged-packages/bitcoin-wallet-snap/locales/{ko-KR.json => ko.json} (100%) rename merged-packages/bitcoin-wallet-snap/locales/{pt-BR.json => pt.json} (100%) rename merged-packages/bitcoin-wallet-snap/locales/{ru-RU.json => ru.json} (100%) rename merged-packages/bitcoin-wallet-snap/locales/{tl-PH.json => tl.json} (100%) rename merged-packages/bitcoin-wallet-snap/locales/{tr-TR.json => tr.json} (100%) rename merged-packages/bitcoin-wallet-snap/locales/{vi-VN.json => vi.json} (100%) rename merged-packages/bitcoin-wallet-snap/locales/{zh-CN.json => zh_CN.json} (100%) diff --git a/merged-packages/bitcoin-wallet-snap/locales/de-DE.json b/merged-packages/bitcoin-wallet-snap/locales/de.json similarity index 100% rename from merged-packages/bitcoin-wallet-snap/locales/de-DE.json rename to merged-packages/bitcoin-wallet-snap/locales/de.json diff --git a/merged-packages/bitcoin-wallet-snap/locales/el-GR.json b/merged-packages/bitcoin-wallet-snap/locales/el.json similarity index 100% rename from merged-packages/bitcoin-wallet-snap/locales/el-GR.json rename to merged-packages/bitcoin-wallet-snap/locales/el.json diff --git a/merged-packages/bitcoin-wallet-snap/locales/en-GB.json b/merged-packages/bitcoin-wallet-snap/locales/en-GB.json deleted file mode 100644 index 7e2a6e40..00000000 --- a/merged-packages/bitcoin-wallet-snap/locales/en-GB.json +++ /dev/null @@ -1,200 +0,0 @@ -{ - "locale": "en", - "messages": { - "reviewTransactionWarning": { - "message": "Review the transaction before proceeding" - }, - "from": { - "message": "From" - }, - "toAddress": { - "message": "To" - }, - "continue": { - "message": "Continue" - }, - "cancel": { - "message": "Cancel" - }, - "clear": { - "message": "Clear" - }, - "amount": { - "message": "Amount" - }, - "balance": { - "message": "Balance" - }, - "recipient": { - "message": "Recipient" - }, - "network": { - "message": "Network" - }, - "minutes": { - "message": "min" - }, - "transactionSpeed": { - "message": "Transaction speed" - }, - "transactionSpeedTooltip": { - "message": "The estimated time of the transaction" - }, - "networkFee": { - "message": "Network fee" - }, - "feeRate": { - "message": "Fee rate" - }, - "networkFeeTooltip": { - "message": "The total network fee" - }, - "total": { - "message": "Total" - }, - "send": { - "message": "Send" - }, - "asset": { - "message": "Asset" - }, - "sending": { - "message": "Sending" - }, - "max": { - "message": "Max" - }, - "recipientPlaceholder": { - "message": "Enter receiving address" - }, - "review": { - "message": "Review" - }, - "error": { - "message": "Error" - }, - "unknownError": { - "message": "An unknown error occurred" - }, - "feeTooLow": { - "message": "Fee too low" - }, - "feeRateTooLow": { - "message": "Fee rate too low" - }, - "noUtxosSelected": { - "message": "Failed to build transaction: missing UTXOs" - }, - "outputBelowDustLimit": { - "message": "Amount below dust limit" - }, - "insufficientFunds": { - "message": "Funds are insufficient to cover amount plus fee" - }, - "noRecipients": { - "message": "Missing recipients" - }, - "psbt": { - "message": "Invalid PSBT" - }, - "unknownUtxo": { - "message": "Failed to build transaction: unknown UTXO" - }, - "MmssingNonWitnessUtxo": { - "message": "Failed to build transaction: missing non-witness UTXO" - }, - "base58": { - "message": "Invalid Bitcoin address" - }, - "bech32": { - "message": "Invalid Bitcoin address" - }, - "witnessVersion": { - "message": "Invalid Bitcoin address: witness version" - }, - "witnessProgram": { - "message": "Invalid Bitcoin address: witness program" - }, - "legacyAddressTooLong": { - "message": "Invalid Bitcoin address" - }, - "invalidBase58PayloadLength": { - "message": "Invalid Bitcoin address: invalid length" - }, - "invalidLegacyPrefix": { - "message": "Invalid Bitcoin address: invalid legacy prefix" - }, - "networkValidation": { - "message": "Invalid Bitcoin address: wrong network" - }, - "outOfRange": { - "message": "Invalid amount: out of range" - }, - "tooPrecise": { - "message": "Invalid amount: too precise" - }, - "missingDigits": { - "message": "Invalid amount: missing digits" - }, - "inputTooLarge": { - "message": "Invalid amount: too large" - }, - "invalidCharacter": { - "message": "Invalid amount: invalid character" - }, - "unexpected": { - "message": "An unexpected error occurred" - }, - "error.0": { - "message": "Invalid format" - }, - "error.1000": { - "message": "Validation failed" - }, - "error.2000": { - "message": "Resource not found" - }, - "error.3000": { - "message": "Connection error" - }, - "error.4000": { - "message": "Wallet state corrupted" - }, - "error.5000": { - "message": "Storage error" - }, - "error.6000": { - "message": "Method not implemented or not supported" - }, - "error.7000": { - "message": "Permission denied or insufficient authorization" - }, - "error.8000": { - "message": "User action error" - }, - "error.9000": { - "message": "Corrupted state, invariant not respected or failed assertion." - }, - "error.internal": { - "message": "Internal error. Please try again later or contact support" - }, - "confirmation.origin": { - "message": "Origin" - }, - "confirmation.origin.tooltip": { - "message": "The dApp requesting the signature" - }, - "confirmation.account": { - "message": "Account" - }, - "confirmation.signMessage.confirmButton": { - "message": "Sign" - }, - "confirmation.signMessage.title": { - "message": "Sign Message" - }, - "confirmation.signMessage.message": { - "message": "Message" - } - } -} diff --git a/merged-packages/bitcoin-wallet-snap/locales/es-419.json b/merged-packages/bitcoin-wallet-snap/locales/es.json similarity index 100% rename from merged-packages/bitcoin-wallet-snap/locales/es-419.json rename to merged-packages/bitcoin-wallet-snap/locales/es.json diff --git a/merged-packages/bitcoin-wallet-snap/locales/es_419.json b/merged-packages/bitcoin-wallet-snap/locales/es_419.json new file mode 100644 index 00000000..d9bfc653 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/locales/es_419.json @@ -0,0 +1,200 @@ +{ + "locale": "es_419", + "messages": { + "reviewTransactionWarning": { + "message": "Revise la transacción antes de continuar" + }, + "from": { + "message": "De" + }, + "toAddress": { + "message": "A" + }, + "continue": { + "message": "Continuar" + }, + "cancel": { + "message": "Cancelar" + }, + "clear": { + "message": "Borrar" + }, + "amount": { + "message": "Monto" + }, + "balance": { + "message": "Saldo" + }, + "recipient": { + "message": "Destinatario" + }, + "network": { + "message": "Red" + }, + "minutes": { + "message": "min" + }, + "transactionSpeed": { + "message": "Velocidad de la transacción" + }, + "transactionSpeedTooltip": { + "message": "El tiempo estimado de la transacción" + }, + "networkFee": { + "message": "Tarifa de red" + }, + "feeRate": { + "message": "Tasa de tarifa" + }, + "networkFeeTooltip": { + "message": "La tarifa total de red" + }, + "total": { + "message": "Total" + }, + "send": { + "message": "Enviar" + }, + "asset": { + "message": "Activo" + }, + "sending": { + "message": "Enviando" + }, + "max": { + "message": "Máx" + }, + "recipientPlaceholder": { + "message": "Ingrese la dirección de recepción" + }, + "review": { + "message": "Revisar" + }, + "error": { + "message": "Error" + }, + "unknownError": { + "message": "Se produjo un error desconocido" + }, + "feeTooLow": { + "message": "Tarifa demasiado baja" + }, + "feeRateTooLow": { + "message": "Tasa de tarifa demasiado baja" + }, + "noUtxosSelected": { + "message": "No se pudo construir la transacción: faltan UTXOs" + }, + "outputBelowDustLimit": { + "message": "Monto por debajo del límite de dust" + }, + "insufficientFunds": { + "message": "Fondos insuficientes para cubrir el monto más la tarifa" + }, + "noRecipients": { + "message": "Faltan destinatarios" + }, + "psbt": { + "message": "PSBT inválido" + }, + "unknownUtxo": { + "message": "No se pudo construir la transacción: UTXO desconocido" + }, + "MmssingNonWitnessUtxo": { + "message": "No se pudo construir la transacción: falta UTXO non-witness" + }, + "base58": { + "message": "Dirección de Bitcoin inválida" + }, + "bech32": { + "message": "Dirección de Bitcoin inválida" + }, + "witnessVersion": { + "message": "Dirección de Bitcoin inválida: versión witness" + }, + "witnessProgram": { + "message": "Dirección de Bitcoin inválida: programa witness" + }, + "legacyAddressTooLong": { + "message": "Dirección de Bitcoin inválida" + }, + "invalidBase58PayloadLength": { + "message": "Dirección de Bitcoin inválida: longitud inválida" + }, + "invalidLegacyPrefix": { + "message": "Dirección de Bitcoin inválida: prefijo legacy inválido" + }, + "networkValidation": { + "message": "Dirección de Bitcoin inválida: red incorrecta" + }, + "outOfRange": { + "message": "Monto inválido: fuera de rango" + }, + "tooPrecise": { + "message": "Monto inválido: demasiado preciso" + }, + "missingDigits": { + "message": "Monto inválido: faltan dígitos" + }, + "inputTooLarge": { + "message": "Monto inválido: demasiado grande" + }, + "invalidCharacter": { + "message": "Monto inválido: carácter inválido" + }, + "unexpected": { + "message": "Se produjo un error inesperado" + }, + "error.0": { + "message": "Formato inválido" + }, + "error.1000": { + "message": "Validación fallida" + }, + "error.2000": { + "message": "Recurso no encontrado" + }, + "error.3000": { + "message": "Error de conexión" + }, + "error.4000": { + "message": "Estado de la billetera corrompido" + }, + "error.5000": { + "message": "Error de almacenamiento" + }, + "error.6000": { + "message": "Método no implementado o no soportado" + }, + "error.7000": { + "message": "Permiso denegado o autorización insuficiente" + }, + "error.8000": { + "message": "Error de acción del usuario" + }, + "error.9000": { + "message": "Estado corrompido, invariante no respetada o aserción fallida." + }, + "error.internal": { + "message": "Error interno. Por favor intente de nuevo más tarde o contacte al soporte" + }, + "confirmation.origin": { + "message": "Origen" + }, + "confirmation.origin.tooltip": { + "message": "La dApp solicitando la firma" + }, + "confirmation.account": { + "message": "Cuenta" + }, + "confirmation.signMessage.confirmButton": { + "message": "Firmar" + }, + "confirmation.signMessage.title": { + "message": "Firmar mensaje" + }, + "confirmation.signMessage.message": { + "message": "Mensaje" + } + } +} diff --git a/merged-packages/bitcoin-wallet-snap/locales/fr-FR.json b/merged-packages/bitcoin-wallet-snap/locales/fr.json similarity index 100% rename from merged-packages/bitcoin-wallet-snap/locales/fr-FR.json rename to merged-packages/bitcoin-wallet-snap/locales/fr.json diff --git a/merged-packages/bitcoin-wallet-snap/locales/hi-IN.json b/merged-packages/bitcoin-wallet-snap/locales/hi.json similarity index 100% rename from merged-packages/bitcoin-wallet-snap/locales/hi-IN.json rename to merged-packages/bitcoin-wallet-snap/locales/hi.json diff --git a/merged-packages/bitcoin-wallet-snap/locales/id-ID.json b/merged-packages/bitcoin-wallet-snap/locales/id.json similarity index 100% rename from merged-packages/bitcoin-wallet-snap/locales/id-ID.json rename to merged-packages/bitcoin-wallet-snap/locales/id.json diff --git a/merged-packages/bitcoin-wallet-snap/locales/ja-JP.json b/merged-packages/bitcoin-wallet-snap/locales/ja.json similarity index 100% rename from merged-packages/bitcoin-wallet-snap/locales/ja-JP.json rename to merged-packages/bitcoin-wallet-snap/locales/ja.json diff --git a/merged-packages/bitcoin-wallet-snap/locales/ko-KR.json b/merged-packages/bitcoin-wallet-snap/locales/ko.json similarity index 100% rename from merged-packages/bitcoin-wallet-snap/locales/ko-KR.json rename to merged-packages/bitcoin-wallet-snap/locales/ko.json diff --git a/merged-packages/bitcoin-wallet-snap/locales/pt-BR.json b/merged-packages/bitcoin-wallet-snap/locales/pt.json similarity index 100% rename from merged-packages/bitcoin-wallet-snap/locales/pt-BR.json rename to merged-packages/bitcoin-wallet-snap/locales/pt.json diff --git a/merged-packages/bitcoin-wallet-snap/locales/ru-RU.json b/merged-packages/bitcoin-wallet-snap/locales/ru.json similarity index 100% rename from merged-packages/bitcoin-wallet-snap/locales/ru-RU.json rename to merged-packages/bitcoin-wallet-snap/locales/ru.json diff --git a/merged-packages/bitcoin-wallet-snap/locales/tl-PH.json b/merged-packages/bitcoin-wallet-snap/locales/tl.json similarity index 100% rename from merged-packages/bitcoin-wallet-snap/locales/tl-PH.json rename to merged-packages/bitcoin-wallet-snap/locales/tl.json diff --git a/merged-packages/bitcoin-wallet-snap/locales/tr-TR.json b/merged-packages/bitcoin-wallet-snap/locales/tr.json similarity index 100% rename from merged-packages/bitcoin-wallet-snap/locales/tr-TR.json rename to merged-packages/bitcoin-wallet-snap/locales/tr.json diff --git a/merged-packages/bitcoin-wallet-snap/locales/vi-VN.json b/merged-packages/bitcoin-wallet-snap/locales/vi.json similarity index 100% rename from merged-packages/bitcoin-wallet-snap/locales/vi-VN.json rename to merged-packages/bitcoin-wallet-snap/locales/vi.json diff --git a/merged-packages/bitcoin-wallet-snap/locales/zh-CN.json b/merged-packages/bitcoin-wallet-snap/locales/zh_CN.json similarity index 100% rename from merged-packages/bitcoin-wallet-snap/locales/zh-CN.json rename to merged-packages/bitcoin-wallet-snap/locales/zh_CN.json diff --git a/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js b/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js index 41f461a9..74a220b2 100644 --- a/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js +++ b/merged-packages/bitcoin-wallet-snap/scripts/build-preinstalled-snap.js @@ -26,13 +26,19 @@ function readFileContents(filePath) { const bundlePath = require.resolve('../dist/bundle.js'); const iconPath = require.resolve('../images/icon.svg'); const manifestPath = require.resolve('../snap.manifest.json'); -const englishLocalePath = require.resolve('../locales/en.json'); // File Contents const bundle = readFileContents(bundlePath); const icon = readFileContents(iconPath); const manifest = readFileContents(manifestPath); -const englishLocale = readFileContents(englishLocalePath); + +const parsedManifest = JSON.parse(manifest); + +const localeFiles = parsedManifest.source.locales.map((localePath) => { + const fullPath = require.resolve(`../${localePath}`); + const contents = readFileContents(fullPath); + return { path: localePath, value: contents }; +}); const snapId = /** @type {import('@metamask/snaps-controllers').PreinstalledSnap['snapId']} */ ( @@ -54,10 +60,7 @@ const preinstalledSnap = { path: 'dist/bundle.js', value: bundle, }, - { - path: 'locales/en.json', - value: englishLocale, - }, + ...localeFiles, ], removable: false, hideSnapBranding: true, diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 233910ac..38553648 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "Qqyp3wm4csoOj5KYFHRfCKhAj0iX9H3uefgMGdv5r2s=", + "shasum": "Bmzz8Hmtf2Ta6E5WjXh8DIShQ9YkRMHD6xJGD2dphis=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -16,7 +16,18 @@ "registry": "https://registry.npmjs.org/" } }, - "locales": ["locales/en.json"] + "locales": [ + "locales/de.json", + "locales/en.json", + "locales/es.json", + "locales/fr.json", + "locales/ja.json", + "locales/ru.json", + "locales/tl.json", + "locales/tr.json", + "locales/vi.json", + "locales/zh_CN.json" + ] }, "initialPermissions": { "endowment:webassembly": {}, diff --git a/merged-packages/bitcoin-wallet-snap/src/config.ts b/merged-packages/bitcoin-wallet-snap/src/config.ts index f4993f2c..1022af74 100644 --- a/merged-packages/bitcoin-wallet-snap/src/config.ts +++ b/merged-packages/bitcoin-wallet-snap/src/config.ts @@ -53,7 +53,7 @@ export const Config: SnapConfig = { regtest: fromEnv('REGTEST_EXPLORER', 'http://localhost:8094/regtest'), }, }, - targetBlocksConfirmation: 3, + targetBlocksConfirmation: 1, fallbackFeeRate: 5.0, ratesRefreshInterval: 'PT20S', priceApi: { From bb4818e3f0b4be8b97ab4369c54ac17c47013796 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 26 Aug 2025 18:51:46 +0200 Subject: [PATCH 283/362] 1.0.0 (#527) --- .../bitcoin-wallet-snap/CHANGELOG.md | 23 ++++++++++++++++++- .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 1b0e34a7..87879b06 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0] + +### Added + +- Background events executed only when client is active ([#524](https://github.com/MetaMask/snap-bitcoin-wallet/pull/524)) +- Confirmation displayed on `signMessage` ([#523](https://github.com/MetaMask/snap-bitcoin-wallet/pull/523)) +- OpenRPC for dApp connectivity ([#522](https://github.com/MetaMask/snap-bitcoin-wallet/pull/522)) +- Sign message on `submitRequest` ([#521](https://github.com/MetaMask/snap-bitcoin-wallet/pull/521)) +- UTXO management on `submitRequest` ([#520](https://github.com/MetaMask/snap-bitcoin-wallet/pull/520)) +- Send transfer on `submitRequest` ([#519](https://github.com/MetaMask/snap-bitcoin-wallet/pull/519)) +- PSBT management on `submitRequest` ([#514](https://github.com/MetaMask/snap-bitcoin-wallet/pull/514)) + +### Changed + +- Translations updated in all languages ([#525](https://github.com/MetaMask/snap-bitcoin-wallet/pull/525)) + +### Fixed + +- Translations aligned with locales ([#528](https://github.com/MetaMask/snap-bitcoin-wallet/pull/528)) + ## [0.19.3] ### Fixed @@ -438,7 +458,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.3...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.3...v1.0.0 [0.19.3]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.2...v0.19.3 [0.19.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.1...v0.19.2 [0.19.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.0...v0.19.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 9ec738ed..d373ed2f 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "0.19.3", + "version": "1.0.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 38553648..0d0090a4 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "0.19.3", + "version": "1.0.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "Bmzz8Hmtf2Ta6E5WjXh8DIShQ9YkRMHD6xJGD2dphis=", + "shasum": "4DHnW1h034iirb1LW/qKvR5Uxr+dk2XeALbsm0V7L+k=", "location": { "npm": { "filePath": "dist/bundle.js", From 7ebf47425d736d9f857f8378ddcf5570ff17d700 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Fri, 29 Aug 2025 14:18:47 +0200 Subject: [PATCH 284/362] fix: error message on synchronization (#529) --- merged-packages/bitcoin-wallet-snap/locales/en.json | 3 +++ merged-packages/bitcoin-wallet-snap/messages.json | 3 +++ .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/entities/error.ts | 13 +++++++++++++ .../bitcoin-wallet-snap/src/handlers/CronHandler.ts | 7 +++++-- 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/locales/en.json b/merged-packages/bitcoin-wallet-snap/locales/en.json index 7e2a6e40..6d6d3bd4 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/en.json +++ b/merged-packages/bitcoin-wallet-snap/locales/en.json @@ -157,6 +157,9 @@ "error.3000": { "message": "Connection error" }, + "error.3100": { + "message": "One or more accounts failed to synchronize" + }, "error.4000": { "message": "Wallet state corrupted" }, diff --git a/merged-packages/bitcoin-wallet-snap/messages.json b/merged-packages/bitcoin-wallet-snap/messages.json index 382057f9..546fcd28 100644 --- a/merged-packages/bitcoin-wallet-snap/messages.json +++ b/merged-packages/bitcoin-wallet-snap/messages.json @@ -155,6 +155,9 @@ "error.3000": { "message": "Connection error" }, + "error.3100": { + "message": "One or more accounts failed to synchronize" + }, "error.4000": { "message": "Wallet state corrupted" }, diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 0d0090a4..d46881f8 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "4DHnW1h034iirb1LW/qKvR5Uxr+dk2XeALbsm0V7L+k=", + "shasum": "iepLD8ZiyzdEtcJt1oNIOsrj+1CiZBGk7v+Z05tK/QY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/error.ts b/merged-packages/bitcoin-wallet-snap/src/entities/error.ts index e8c73d1d..456132e6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/error.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/error.ts @@ -85,6 +85,19 @@ export class ExternalServiceError extends BaseError { } } +/** + * Error thrown when accounts fail to synchronize. Used to gather multiple potential errors. + * + * @example + * throw new SynchronizationError('Accounts failed to synchronize'); + */ +export class SynchronizationError extends BaseError { + constructor(message: string, data?: Record, cause?: unknown) { + super(message, 3100, data, cause); // Under ExternalServiceError + this.name = 'SynchronizationError'; + } +} + /** * Error thrown for wallet-specific failures. * Useful for signaling wallet operation errors. diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index 774d3f0e..1cf6118f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -4,7 +4,7 @@ import { assert, object, string } from 'superstruct'; import { InexistentMethodError, type SnapClient, - WalletError, + SynchronizationError, } from '../entities'; import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; @@ -74,7 +74,10 @@ export class CronHandler { }); if (Object.keys(errors).length > 0) { - throw new WalletError('Account synchronization failures', errors); + throw new SynchronizationError( + 'Account synchronization failures', + errors, + ); } } } From a4f990a9644d8271e69a6172707601fa3436e4a6 Mon Sep 17 00:00:00 2001 From: orestis Date: Mon, 22 Sep 2025 11:04:52 +0100 Subject: [PATCH 285/362] feat: add onAmountInput and onAddressInput rpc methods (#532) --- .../integration-test/client-request.test.ts | 47 ++- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/RpcHandler.test.ts | 366 +++++++++++++++++- .../src/handlers/RpcHandler.ts | 90 ++++- .../bitcoin-wallet-snap/src/handlers/types.ts | 9 + .../src/handlers/validation.ts | 52 +++ .../bitcoin-wallet-snap/src/index.ts | 2 +- 7 files changed, 552 insertions(+), 16 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/types.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts index c51abacd..5e3360cb 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts @@ -4,7 +4,7 @@ import type { Snap } from '@metamask/snaps-jest'; import { installSnap } from '@metamask/snaps-jest'; import { BlockchainTestUtils } from './blockchain-utils'; -import { MNEMONIC, ORIGIN } from './constants'; +import { MNEMONIC, ORIGIN, TEST_ADDRESS_REGTEST } from './constants'; import { CurrencyUnit, TrackingSnapEvent } from '../src/entities'; import { Caip19Asset } from '../src/handlers/caip'; @@ -200,4 +200,49 @@ describe('OnClientRequestHandler', () => { stack: expect.anything(), }); }); + + it('validates a valid regtest address', async () => { + const response = await snap.onClientRequest({ + method: 'onAddressInput', + params: { + value: TEST_ADDRESS_REGTEST, + accountId: account.id, + }, + }); + + expect(response).toRespondWith({ + valid: true, + errors: [], + }); + }); + + it('rejects an invalid address format', async () => { + const response = await snap.onClientRequest({ + method: 'onAddressInput', + params: { + value: 'not-a-valid-bitcoin-address', + accountId: account.id, + }, + }); + + expect(response).toRespondWith({ + valid: false, + errors: [{ code: 'Invalid' }], + }); + }); + + it('rejects a testnet address when using regtest account', async () => { + const response = await snap.onClientRequest({ + method: 'onAddressInput', + params: { + value: 'tb1qrn9d5qewjqq5syc4nrjprkfq8gge0cjdaznwcn', // testnet address (tb1) + accountId: account.id, // regtest account + }, + }); + + expect(response).toRespondWith({ + valid: false, + errors: [{ code: 'Invalid' }], + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index d46881f8..b8c8ee86 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "iepLD8ZiyzdEtcJt1oNIOsrj+1CiZBGk7v+Z05tK/QY=", + "shasum": "ObDQQY4iLjatrZvVnrgYZiSDZtg5ha1xg+MLm/ccuAs=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index d50c458d..aee1570c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -1,20 +1,22 @@ -import { Psbt } from '@metamask/bitcoindevkit'; +import { Psbt, Address } from '@metamask/bitcoindevkit'; import type { Amount, Txid } from '@metamask/bitcoindevkit'; import { BtcScope, FeeType } from '@metamask/keyring-api'; import type { JsonRpcRequest } from '@metamask/utils'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; +import type { Logger } from '../entities'; import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; import { Caip19Asset } from './caip'; import { - CreateSendFormRequest, ComputeFeeRequest, + CreateSendFormRequest, RpcHandler, RpcMethod, SendPsbtRequest, VerifyMessageRequest, } from './RpcHandler'; +import { SendErrorCodes } from './validation'; jest.mock('superstruct', () => ({ ...jest.requireActual('superstruct'), @@ -26,24 +28,250 @@ const mockPsbt = mock(); /* eslint-disable @typescript-eslint/naming-convention */ jest.mock('@metamask/bitcoindevkit', () => ({ Psbt: { from_string: jest.fn() }, + Address: { + from_string: jest.fn(), + }, })); describe('RpcHandler', () => { const mockSendFlowUseCases = mock(); const mockAccountsUseCases = mock(); + const mockLogger = mock(); const origin = 'metamask'; + const validAccountId = '724ac464-6572-4d9c-a8e2-4075c8846d65'; - const handler = new RpcHandler(mockSendFlowUseCases, mockAccountsUseCases); + const handler = new RpcHandler( + mockSendFlowUseCases, + mockAccountsUseCases, + mockLogger, + ); beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(Psbt.from_string).mockReturnValue(mockPsbt); + + // setup Address mock with validation logic + const validAddresses = [ + 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8', // bech32 mainnet + 'bc1qtest123address', // test address + '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', // P2PKH mainnet + '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy', // P2SH mainnet + ]; + + jest + .mocked(Address.from_string) + .mockImplementation((address: string, _: string) => { + if (validAddresses.includes(address)) { + return { toString: () => address } as any; + } + throw new Error(`Invalid address: ${address}`); + }); + }); + + describe('parameter validation', () => { + beforeEach(() => { + const { assert: realAssert } = jest.requireActual('superstruct'); + jest.mocked(assert).mockImplementation(realAssert); + }); + + describe('onAddressInput validation', () => { + it('rejects invalid address format', async () => { + const invalidAddressRequest = mock({ + method: RpcMethod.OnAddressInput, + params: { + value: 'not-a-valid-address', + accountId: validAccountId, + }, + }); + + const result = await handler.route(origin, invalidAddressRequest); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Invalid account and/or invalid address. Error: %s', + expect.any(String), + ); + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + }); + + it('rejects invalid UUID accountId', async () => { + const invalidRequest = mock({ + method: RpcMethod.OnAddressInput, + params: { + value: 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t', + accountId: 'not-a-uuid', + }, + }); + + await expect(handler.route(origin, invalidRequest)).rejects.toThrow( + 'Expected a string matching', + ); + }); + + it('rejects missing value parameter', async () => { + const missingValueRequest = mock({ + method: RpcMethod.OnAddressInput, + params: { + accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', + // Missing 'value' field + }, + }); + + await expect( + handler.route(origin, missingValueRequest), + ).rejects.toThrow('At path: value -- Expected a string'); + }); + + it('rejects missing accountId parameter', async () => { + const missingAccountRequest = mock({ + method: RpcMethod.OnAddressInput, + params: { + value: 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t', + // Missing 'accountId' field + }, + }); + + await expect( + handler.route(origin, missingAccountRequest), + ).rejects.toThrow('At path: accountId -- Expected a string'); + }); + }); + + describe('onAmountInput validation', () => { + it('rejects invalid UUID accountId', async () => { + const invalidRequest = mock({ + method: RpcMethod.OnAmountInput, + params: { + value: '1.5', + accountId: 'not-a-uuid', + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }); + + await expect(handler.route(origin, invalidRequest)).rejects.toThrow( + 'Expected a string matching', + ); + }); + + it('rejects negative amounts', async () => { + const negativeAmountRequest = mock({ + method: RpcMethod.OnAmountInput, + params: { + value: '-0.5', + accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }); + + const result = await handler.route(origin, negativeAmountRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + }); + + it('rejects zero amount', async () => { + const zeroAmountRequest = mock({ + method: RpcMethod.OnAmountInput, + params: { + value: '0', + accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }); + + const result = await handler.route(origin, zeroAmountRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + }); + + it('rejects invalid number formats', async () => { + const testCases = ['abc', '1.2.3', 'not-a-number', 'NaN', 'Infinity']; + + for (const invalidValue of testCases) { + const invalidAmountRequest = mock({ + method: RpcMethod.OnAmountInput, + params: { + value: invalidValue, + accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }); + + const result = await handler.route(origin, invalidAmountRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + } + }); + + it('rejects missing assetId parameter', async () => { + const missingAssetRequest = mock({ + method: RpcMethod.OnAmountInput, + params: { + value: '1.5', + accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', + // Missing 'assetId' field + }, + }); + + await expect( + handler.route(origin, missingAssetRequest), + ).rejects.toThrow( + 'At path: assetId -- Expected a value of type `CaipAssetType`', + ); + }); + + it('rejects invalid assetId format', async () => { + const invalidAssetRequest = mock({ + method: RpcMethod.OnAmountInput, + params: { + value: '1.5', + accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', + assetId: 'invalid-asset-id', + }, + }); + + await expect( + handler.route(origin, invalidAssetRequest), + ).rejects.toThrow( + 'At path: assetId -- Expected a value of type `CaipAssetType`', + ); + }); + }); + + describe('verifyMessage validation', () => { + it('rejects missing parameters', async () => { + const missingParamsRequest = mock({ + method: RpcMethod.VerifyMessage, + params: { + address: 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t', + // Missing 'message' and 'signature' + }, + }); + + await expect( + handler.route(origin, missingParamsRequest), + ).rejects.toThrow('At path: message -- Expected a string'); + }); + }); }); describe('route', () => { const mockRequest = mock({ method: RpcMethod.StartSendTransactionFlow, params: { - account: 'account-id', + account: validAccountId, }, }); @@ -64,7 +292,7 @@ describe('RpcHandler', () => { const mockRequest = mock({ method: RpcMethod.StartSendTransactionFlow, params: { - account: 'account-id', + account: validAccountId, }, }); @@ -83,9 +311,9 @@ describe('RpcHandler', () => { mockRequest.params, CreateSendFormRequest, ); - expect(mockSendFlowUseCases.display).toHaveBeenCalledWith('account-id'); + expect(mockSendFlowUseCases.display).toHaveBeenCalledWith(validAccountId); expect(mockAccountsUseCases.signPsbt).toHaveBeenCalledWith( - 'account-id', + validAccountId, mockPsbt, 'metamask', { broadcast: true, fill: false }, @@ -121,7 +349,7 @@ describe('RpcHandler', () => { const mockRequest = mock({ method: RpcMethod.SignAndSendTransaction, params: { - accountId: 'account-id', + accountId: validAccountId, transaction: psbt, }, }); @@ -138,7 +366,7 @@ describe('RpcHandler', () => { expect(assert).toHaveBeenCalledWith(mockRequest.params, SendPsbtRequest); expect(mockAccountsUseCases.signPsbt).toHaveBeenCalledWith( - 'account-id', + validAccountId, mockPsbt, 'metamask', { broadcast: true, fill: true }, @@ -161,7 +389,7 @@ describe('RpcHandler', () => { const mockRequest = mock({ method: RpcMethod.ComputeFee, params: { - accountId: 'account-id', + accountId: validAccountId, transaction: psbt, scope: BtcScope.Mainnet, }, @@ -181,7 +409,7 @@ describe('RpcHandler', () => { ); expect(Psbt.from_string).toHaveBeenCalledWith(psbt); expect(mockAccountsUseCases.computeFee).toHaveBeenCalledWith( - 'account-id', + validAccountId, mockPsbt, ); expect(result).toStrictEqual([ @@ -210,7 +438,7 @@ describe('RpcHandler', () => { const invalidRequest = mock({ method: RpcMethod.ComputeFee, params: { - accountId: 'account-id', + accountId: validAccountId, transaction: 'invalid-psbt-base64', scope: BtcScope.Mainnet, }, @@ -228,6 +456,120 @@ describe('RpcHandler', () => { }); }); + describe('onAddressInput', () => { + const mockBitcoinAccount = { + network: 'bitcoin', + }; + + const validAddressRequest = mock({ + method: RpcMethod.OnAddressInput, + params: { + value: 'bc1qtest123address', + accountId: validAccountId, + }, + }); + + beforeEach(() => { + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount as any); + }); + + it('validates a correct address', async () => { + const result = await handler.route(origin, validAddressRequest); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + expect(result).toStrictEqual({ + valid: true, + errors: [], + }); + }); + + it('handles account not found error', async () => { + const accountError = new Error('Account not found'); + mockAccountsUseCases.get.mockRejectedValue(accountError); + + const result = await handler.route(origin, validAddressRequest); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Invalid account and/or invalid address. Error: %s', + 'Account not found', + ); + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + }); + }); + + describe('onAmountInput', () => { + const validAmountRequest = mock({ + method: RpcMethod.OnAmountInput, + params: { + value: '0.5', + accountId: validAccountId, + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + const mockAmountAccount = { + network: 'bitcoin', + balance: { + trusted_spendable: { + to_btc: jest.fn().mockReturnValue(1.5), + }, + }, + }; + mockAccountsUseCases.get.mockResolvedValue(mockAmountAccount as any); + }); + + it('validates a correct amount within balance', async () => { + const result = await handler.route(origin, validAmountRequest); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + expect(result).toStrictEqual({ + valid: true, + errors: [], + }); + }); + + it('rejects amount exceeding balance', async () => { + const excessiveAmountRequest = mock({ + method: RpcMethod.OnAmountInput, + params: { + value: '2.0', // more than account's balance + accountId: validAccountId, + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }); + + const result = await handler.route(origin, excessiveAmountRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }); + }); + + it('handles account not found error', async () => { + const accountError = new Error('Account not found'); + mockAccountsUseCases.get.mockRejectedValue(accountError); + + const result = await handler.route(origin, validAmountRequest); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + expect(mockLogger.error).toHaveBeenCalledWith( + 'An error occurred: %s', + 'Account not found', + ); + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + }); + }); + describe('verifyMessage', () => { const mockRequest = mock({ method: RpcMethod.VerifyMessage, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index 7561442b..c540314c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -1,3 +1,4 @@ +import { Address } from '@metamask/bitcoindevkit'; import { BtcScope } from '@metamask/keyring-api'; import type { Json, JsonRpcRequest } from '@metamask/utils'; import { Verifier } from 'bip322-js'; @@ -5,8 +6,10 @@ import { assert, enums, object, optional, string } from 'superstruct'; import { AssertionError, + type CodifiedError, FormatError, InexistentMethodError, + type Logger, ValidationError, } from '../entities'; import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; @@ -14,12 +17,21 @@ import { scopeToNetwork } from './caip'; import type { TransactionFee } from './mappings'; import { mapToTransactionFees } from './mappings'; import { parsePsbt } from './parsers'; +import type { OnAddressInputRequest, OnAmountInputRequest } from './types'; +import type { ValidationResponse } from './validation'; +import { + SendErrorCodes, + OnAddressInputRequestStruct, + OnAmountInputRequestStruct, +} from './validation'; export enum RpcMethod { StartSendTransactionFlow = 'startSendTransactionFlow', SignAndSendTransaction = 'signAndSendTransaction', ComputeFee = 'computeFee', VerifyMessage = 'verifyMessage', + OnAddressInput = 'onAddressInput', + OnAmountInput = 'onAmountInput', } export const CreateSendFormRequest = object({ @@ -51,11 +63,18 @@ export const VerifyMessageRequest = object({ }); export class RpcHandler { + readonly #logger: Logger; + readonly #sendFlowUseCases: SendFlowUseCases; readonly #accountUseCases: AccountUseCases; - constructor(sendFlow: SendFlowUseCases, accounts: AccountUseCases) { + constructor( + sendFlow: SendFlowUseCases, + accounts: AccountUseCases, + logger: Logger, + ) { + this.#logger = logger; this.#sendFlowUseCases = sendFlow; this.#accountUseCases = accounts; } @@ -83,6 +102,14 @@ export class RpcHandler { params.scope, ); } + case RpcMethod.OnAddressInput: { + assert(params, OnAddressInputRequestStruct); + return this.#onAddressInput(params); + } + case RpcMethod.OnAmountInput: { + assert(params, OnAmountInputRequestStruct); + return this.#onAmountInput(params); + } case RpcMethod.VerifyMessage: { assert(params, VerifyMessageRequest); return this.#verifyMessage( @@ -152,6 +179,67 @@ export class RpcHandler { return [mapToTransactionFees(amount, scopeToNetwork[scope])]; } + async #onAddressInput( + request: OnAddressInputRequest, + ): Promise { + const { value, accountId } = request; + + try { + // get the scope of the account so we can validate the address against the + // appropriate network (e.g. mainnet, testnet etc) + const bitcoinAccount = await this.#accountUseCases.get(accountId); + + // try to parse the input address or throw if invalid. + Address.from_string(value, bitcoinAccount.network).toString(); + } catch (error) { + this.#logger.error( + `Invalid account and/or invalid address. Error: %s`, + (error as CodifiedError).message, + ); + + return { + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }; + } + + return { + valid: true, + errors: [], + }; + } + + async #onAmountInput( + request: OnAmountInputRequest, + ): Promise { + const { value, accountId } = request; + + const valueToNumber = Number(value); + if (!Number.isFinite(valueToNumber) || valueToNumber <= 0) { + return { valid: false, errors: [{ code: SendErrorCodes.Invalid }] }; + } + + try { + const bitcoinAccount = await this.#accountUseCases.get(accountId); + const balance = bitcoinAccount.balance.trusted_spendable.to_btc(); + + if (valueToNumber > balance) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }; + } + + return { valid: true, errors: [] }; + } catch (error) { + this.#logger.error( + 'An error occurred: %s', + (error as CodifiedError).message, + ); + return { valid: false, errors: [{ code: SendErrorCodes.Invalid }] }; + } + } + #verifyMessage( address: string, message: string, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/types.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/types.ts new file mode 100644 index 00000000..d4d72dc7 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/types.ts @@ -0,0 +1,9 @@ +import type { Infer } from 'superstruct'; + +import type { + OnAddressInputRequestStruct, + OnAmountInputRequestStruct, +} from './validation'; + +export type OnAddressInputRequest = Infer; +export type OnAmountInputRequest = Infer; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts new file mode 100644 index 00000000..b9bb59e4 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts @@ -0,0 +1,52 @@ +import { CaipAssetTypeStruct } from '@metamask/utils'; +import type { Infer } from 'superstruct'; +import { + pattern, + array, + boolean, + enums, + object, + string, + nonempty, + refine, +} from 'superstruct'; + +export enum SendErrorCodes { + // eslint-disable-next-line @typescript-eslint/no-shadow + Required = 'Required', + Invalid = 'Invalid', + InsufficientBalance = 'InsufficientBalance', +} + +export const NonEmptyStringStruct = refine( + nonempty(string()), + 'non-whitespace string', + (value) => value.trim().length > 0, +); + +export const UuidStruct = pattern( + NonEmptyStringStruct, + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u, +); + +export const OnAddressInputRequestStruct = object({ + value: NonEmptyStringStruct, + accountId: UuidStruct, +}); + +export const OnAmountInputRequestStruct = object({ + value: NonEmptyStringStruct, + accountId: UuidStruct, + assetId: CaipAssetTypeStruct, +}); + +export const ValidationResponseStruct = object({ + valid: boolean(), + errors: array( + object({ + code: enums(Object.values(SendErrorCodes)), + }), + ), +}); + +export type ValidationResponse = Infer; diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 5ed6195b..6a1ce886 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -89,7 +89,7 @@ const cronHandler = new CronHandler( sendFlowUseCases, snapClient, ); -const rpcHandler = new RpcHandler(sendFlowUseCases, accountsUseCases); +const rpcHandler = new RpcHandler(sendFlowUseCases, accountsUseCases, logger); const userInputHandler = new UserInputHandler( sendFlowUseCases, confirmationUseCases, From c4f381c67299c9dff9b2ad6c5ac69cfdeeb820b4 Mon Sep 17 00:00:00 2001 From: orestis Date: Mon, 22 Sep 2025 14:17:59 +0100 Subject: [PATCH 286/362] fix: add defensive code around snap_eventTrack (#531) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/use-cases/AccountUseCases.test.ts | 97 +++++++++++++++++++ .../src/use-cases/AccountUseCases.ts | 64 +++++++----- 3 files changed, 138 insertions(+), 25 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index b8c8ee86..63d0a4c0 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "ObDQQY4iLjatrZvVnrgYZiSDZtg5ha1xg+MLm/ccuAs=", + "shasum": "sP6GU6J9yQ5dDjo06/rmLqazBbD8+K7JRc5CknQ5SDI=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 508f14f7..127245f5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -654,6 +654,52 @@ describe('AccountUseCases', () => { expect(mockMetaProtocols.fetchInscriptions).not.toHaveBeenCalled(); }); + + it('continues synchronization when tracking events fail', async () => { + const mockTransaction = mock(); + const mockInscriptions = mock(); + const trackingError = new Error('Tracking service unavailable'); + + mockAccount.listTransactions + .mockReturnValueOnce([]) + .mockReturnValueOnce([mockTransaction]); + mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); + mockSnapClient.emitTrackingEvent.mockRejectedValue(trackingError); + + // should not throw despite tracking failure + expect(await useCases.synchronize(mockAccount, 'test')).toBeUndefined(); + + // core synchronization functionality should still work + expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); + expect(mockAccount.listTransactions).toHaveBeenCalledTimes(2); + expect(mockMetaProtocols.fetchInscriptions).toHaveBeenCalledWith( + mockAccount, + ); + expect(mockRepository.update).toHaveBeenCalledWith( + mockAccount, + mockInscriptions, + ); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount, [mockTransaction]); + + // tracking should have been attempted + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionReceived, + mockAccount, + mockTransaction, + 'test', + ); + + // error should be logged + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to track event', + trackingError, + ); + }); }); describe('fullScan', () => { @@ -997,6 +1043,57 @@ describe('AccountUseCases', () => { }), ).rejects.toBe(error); }); + + it('continues transaction processing when tracking events fail', async () => { + const trackingError = new Error('Tracking service unavailable'); + mockAccount.getTransaction.mockReturnValue(mockWalletTx); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + mockSnapClient.emitTrackingEvent.mockRejectedValue(trackingError); + + // Should complete successfully despite tracking failure + const { txid, psbt } = await useCases.signPsbt( + 'account-id', + mockPsbt, + 'metamask', + { + fill: false, + broadcast: true, + }, + ); + + // Core transaction functionality should work + expect(mockRepository.getWithSigner).toHaveBeenCalledWith('account-id'); + expect(mockAccount.sign).toHaveBeenCalledWith(mockPsbt); + expect(mockChain.broadcast).toHaveBeenCalledWith( + mockAccount.network, + mockTransaction, + ); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount, [mockWalletTx]); + + // Tracking should have been attempted + expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( + TrackingSnapEvent.TransactionSubmitted, + mockAccount, + mockWalletTx, + 'metamask', + ); + + // Error should be logged + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to track event', + trackingError, + ); + + // Transaction should still be successful + expect(txid).toBe(mockTxid); + expect(psbt).toBe('mockSignedPsbt'); + }); }); describe('fillPsbt', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index b90289a4..8829a958 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -231,12 +231,14 @@ export class AccountUseCases { if (!prevTx) { txsToNotify.push(tx); - await this.#snapClient.emitTrackingEvent( - TrackingSnapEvent.TransactionReceived, - account, - tx, - origin, - ); + await this.#trackEventSafely(async (): Promise => { + await this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionReceived, + account, + tx, + origin, + ); + }); continue; } @@ -251,23 +253,27 @@ export class AccountUseCases { if (tx.chain_position.is_confirmed) { txsToNotify.push(tx); - await this.#snapClient.emitTrackingEvent( - TrackingSnapEvent.TransactionFinalized, - account, - tx, - origin, - ); + await this.#trackEventSafely(async (): Promise => { + await this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionFinalized, + account, + tx, + origin, + ); + }); } else { // if the status was changed, and now it's NOT confirmed // it means the tx was reorged. txsToNotify.push(tx); - await this.#snapClient.emitTrackingEvent( - TrackingSnapEvent.TransactionReorged, - account, - tx, - origin, - ); + await this.#trackEventSafely(async (): Promise => { + await this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionReorged, + account, + tx, + origin, + ); + }); } } } @@ -592,12 +598,14 @@ export class AccountUseCases { walletTx, ]); - await this.#snapClient.emitTrackingEvent( - TrackingSnapEvent.TransactionSubmitted, - account, - walletTx, - origin, - ); + await this.#trackEventSafely(async (): Promise => { + await this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionSubmitted, + account, + walletTx, + origin, + ); + }); } return txid; @@ -615,4 +623,12 @@ export class AccountUseCases { }); } } + + async #trackEventSafely(fn: () => Promise): Promise { + try { + await fn(); + } catch (error) { + this.#logger.error('Failed to track event', error); + } + } } From 7317d2520f427af4864d8f8839acdb9815ac0eab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Sep 2025 16:37:59 +0100 Subject: [PATCH 287/362] 1.1.0 (#534) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 14 +++++++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 87879b06..5fe954e9 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] + +### Added + +- onAmountInput and onAddressInput RPC methods ([#532](https://github.com/MetaMask/snap-bitcoin-wallet/pull/532)) + +### Fixed + +- Defensive code around snap_eventTrack ([#531](https://github.com/MetaMask/snap-bitcoin-wallet/pull/531)) +- Error message on synchronization ([#529](https://github.com/MetaMask/snap-bitcoin-wallet/pull/529)) + ## [1.0.0] ### Added @@ -458,7 +469,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.3...v1.0.0 [0.19.3]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.2...v0.19.3 [0.19.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.1...v0.19.2 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index d373ed2f..758ace0c 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.0.0", + "version": "1.1.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 8550c3253ca8ebb32d1dd42d1cd0836682fa7dca Mon Sep 17 00:00:00 2001 From: orestis Date: Fri, 26 Sep 2025 12:04:38 +0100 Subject: [PATCH 288/362] feat: add confirmSend for unified send flow (#533) --- .../integration-test/blockchain-utils.ts | 38 ++ .../integration-test/client-request.test.ts | 172 ++++++ .../bitcoin-wallet-snap/openrpc.json | 300 +++++++++ .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../src/handlers/RpcHandler.test.ts | 584 +++++++++++++++--- .../src/handlers/RpcHandler.ts | 125 ++-- .../src/handlers/mappings.test.ts | 300 +++++++++ .../src/handlers/mappings.ts | 44 ++ .../bitcoin-wallet-snap/src/handlers/types.ts | 2 + .../src/handlers/validation.ts | 99 +++ .../src/use-cases/AccountUseCases.ts | 18 +- 11 files changed, 1538 insertions(+), 148 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/mappings.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/blockchain-utils.ts b/merged-packages/bitcoin-wallet-snap/integration-test/blockchain-utils.ts index d19d2d26..87f2e080 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/blockchain-utils.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/blockchain-utils.ts @@ -1,6 +1,7 @@ /* eslint-disable import-x/no-nodejs-modules */ import { execSync } from 'child_process'; import process from 'process'; + /* eslint-enable import-x/no-nodejs-modules */ /** @@ -136,4 +137,41 @@ export class BlockchainTestUtils { await this.#waitForEsploraHeight(targetHeight); } + + /** + * Get the balance of a Bitcoin address in satoshis + * + * @param address - The Bitcoin address to query + * @returns The balance in satoshis + */ + async getBalance(address: string): Promise { + try { + const response = await fetch( + `${this.#esploraBaseUrl}/address/${address}`, + ); + if (!response.ok) { + throw new Error(`Failed to get address info: ${response.statusText}`); + } + + const addressInfo = await response.json(); + const funded = BigInt(addressInfo.chain_stats.funded_txo_sum ?? 0); + const spent = BigInt(addressInfo.chain_stats.spent_txo_sum ?? 0); + return funded - spent; + } catch (error) { + throw new Error( + `Failed to get balance for address ${address}: ${String(error)}`, + ); + } + } + + /** + * Get the balance of a Bitcoin address in BTC + * + * @param address - The Bitcoin address to query + * @returns The balance in BTC as a number + */ + async getBalanceInBTC(address: string): Promise { + const balanceSats = await this.getBalance(address); + return Number(balanceSats) / 100_000_000; + } } diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts index 5e3360cb..51b308af 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts @@ -245,4 +245,176 @@ describe('OnClientRequestHandler', () => { errors: [{ code: 'Invalid' }], }); }); + + it('missing accountId for onAddressInput', async () => { + const response = await snap.onClientRequest({ + method: 'onAddressInput', + params: { + value: 'tb1qrn9d5qewjqq5syc4nrjprkfq8gge0cjdaznwcn', + }, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: + 'Invalid format: At path: accountId -- Expected a string, but received: undefined', + stack: expect.anything(), + }); + }); + + describe('confirmSend', () => { + it('creates a transaction without broadcasting', async () => { + const response = await snap.onClientRequest({ + method: 'confirmSend', + params: { + fromAccountId: account.id, + toAddress: TEST_ADDRESS_REGTEST, + assetId: Caip19Asset.Regtest, + amount: '0.001', // 0.001 BTC + }, + }); + + expect(response).toRespondWith({ + type: 'send', + id: expect.any(String), + account: account.id, + chain: BtcScope.Regtest, + status: 'unconfirmed', + timestamp: expect.any(Number), + events: [ + { + status: 'unconfirmed', + timestamp: expect.any(Number), + }, + ], + to: [ + { + address: TEST_ADDRESS_REGTEST, + asset: { + amount: '0.001', // BTC amount + fungible: true, + unit: CurrencyUnit.Regtest, + type: Caip19Asset.Regtest, + }, + }, + ], + from: [], + fees: [ + { + type: FeeType.Priority, + asset: { + amount: expect.any(String), + fungible: true, + unit: CurrencyUnit.Regtest, + type: Caip19Asset.Regtest, + }, + }, + ], + }); + }); + + it('fails with invalid account ID', async () => { + const response = await snap.onClientRequest({ + method: 'confirmSend', + params: { + fromAccountId: 'not-a-uuid', + toAddress: TEST_ADDRESS_REGTEST, + assetId: Caip19Asset.Regtest, + amount: '0.001', + }, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: expect.stringContaining('Expected a string matching'), + stack: expect.anything(), + }); + }); + + it('fails with invalid address', async () => { + const response = await snap.onClientRequest({ + method: 'confirmSend', + params: { + fromAccountId: account.id, + toAddress: 'invalid-address', + assetId: Caip19Asset.Regtest, + amount: '0.001', + }, + }); + + expect(response).toRespondWith({ + errors: [{ code: 'Invalid' }], + valid: false, + }); + }); + + it('fails with invalid amount', async () => { + const response = await snap.onClientRequest({ + method: 'confirmSend', + params: { + fromAccountId: account.id, + toAddress: TEST_ADDRESS_REGTEST, + assetId: Caip19Asset.Regtest, + amount: '-0.001', // negative amount + }, + }); + + expect(response).toRespondWith({ + errors: [{ code: 'Invalid' }], + valid: false, + }); + }); + + it('fails with insufficient funds', async () => { + const response = await snap.onClientRequest({ + method: 'confirmSend', + params: { + fromAccountId: account.id, + toAddress: TEST_ADDRESS_REGTEST, + assetId: Caip19Asset.Regtest, + amount: '1000', // 1000 BTC - more than available + }, + }); + + expect(response).toRespondWith({ + errors: [{ code: 'InsufficientBalance' }], + valid: false, + }); + }); + + it('fails with insufficient funds to pay fees', async () => { + const balanceBtc = await blockchain.getBalanceInBTC(account.address); + + const response = await snap.onClientRequest({ + method: 'confirmSend', + params: { + fromAccountId: account.id, + toAddress: TEST_ADDRESS_REGTEST, + assetId: Caip19Asset.Regtest, + amount: balanceBtc.toString(), + }, + }); + + expect(response).toRespondWith({ + errors: [{ code: 'InsufficientBalanceToCoverFee' }], + valid: false, + }); + }); + + it('fails with missing parameters', async () => { + const response = await snap.onClientRequest({ + method: 'confirmSend', + params: { + fromAccountId: account.id, + // missing toAddress, assetId, amount + } as any, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: expect.stringContaining('At path:'), + stack: expect.anything(), + }); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/openrpc.json b/merged-packages/bitcoin-wallet-snap/openrpc.json index 6e182e55..19ab92e0 100644 --- a/merged-packages/bitcoin-wallet-snap/openrpc.json +++ b/merged-packages/bitcoin-wallet-snap/openrpc.json @@ -197,6 +197,306 @@ } } } + }, + { + "name": "onAddressInput", + "description": "Validates a Bitcoin address for a send transaction.", + "paramStructure": "by-name", + "params": [ + { + "name": "value", + "description": "The Bitcoin address to validate.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "accountId", + "description": "Current account.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "validationResponse", + "description": "Validation result with any errors.", + "schema": { + "type": "object", + "required": ["valid", "errors"], + "properties": { + "valid": { + "type": "boolean", + "description": "The address is valid." + }, + "errors": { + "type": "array", + "description": "In case the address value is invalid.", + "items": { + "type": "object", + "required": ["code"], + "properties": { + "code": { + "type": "string", + "enum": ["Required", "Invalid"], + "description": "Error code." + } + } + } + } + } + } + } + }, + { + "name": "onAmountInput", + "description": "Validates a transaction amount for a send transaction. Checks for sufficient balance.", + "paramStructure": "by-name", + "params": [ + { + "name": "value", + "description": "The amount to send.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "accountId", + "description": "From where we will get native balance.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "assetId", + "description": "From where we will get balances.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "validationResponse", + "description": "Validation result with any errors.", + "schema": { + "type": "object", + "required": ["valid", "errors"], + "properties": { + "valid": { + "type": "boolean", + "description": "The amount is valid." + }, + "errors": { + "type": "array", + "description": "In case the amount value is invalid.", + "items": { + "type": "object", + "required": ["code"], + "properties": { + "code": { + "type": "string", + "enum": [ + "Required", + "InsufficientBalanceToCoverFee", + "InsufficientBalance" + ], + "description": "Error code." + } + } + } + } + } + } + } + }, + { + "name": "confirmSend", + "description": "Creates a Bitcoin transaction for confirmation. Returns a KeyringTransaction object.", + "paramStructure": "by-name", + "params": [ + { + "name": "fromAccountId", + "description": "The account ID from which to send.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "toAddress", + "description": "The recipient Bitcoin address.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "amount", + "description": "The amount to send in BTC.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "assetId", + "description": "The CAIP-19 asset identifier.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "keyringTransaction", + "description": "The KeyringTransaction object representing the transaction. Throws an error if the operation fails.", + "schema": { + "type": "object", + "description": "Transaction object", + "required": [ + "id", + "account", + "chain", + "type", + "status", + "timestamp", + "events", + "to", + "from", + "fees" + ], + "properties": { + "id": { + "type": "string", + "description": "The transaction ID." + }, + "account": { + "type": "string", + "description": "The account ID that created this transaction." + }, + "chain": { + "type": "string", + "description": "The chain scope (e.g., 'bip122:000000000019d6689c085ae165831e93')." + }, + "type": { + "type": "string", + "enum": ["send", "receive"], + "description": "The transaction type." + }, + "status": { + "type": "string", + "enum": ["unconfirmed", "confirmed", "failed"], + "description": "The transaction status." + }, + "timestamp": { + "type": ["number", "null"], + "description": "The timestamp when the transaction was created." + }, + "events": { + "type": "array", + "description": "Transaction status change events.", + "items": { + "type": "object", + "required": ["status", "timestamp"], + "properties": { + "status": { + "type": "string", + "enum": ["unconfirmed", "confirmed", "failed"] + }, + "timestamp": { + "type": ["number", "null"] + } + } + } + }, + "to": { + "type": "array", + "description": "Recipients of the transaction.", + "items": { + "type": "object", + "required": ["address", "asset"], + "properties": { + "address": { + "type": "string", + "description": "The recipient address." + }, + "asset": { + "type": "object", + "required": ["amount", "fungible", "unit", "type"], + "properties": { + "amount": { + "type": "string", + "description": "The amount in BTC." + }, + "fungible": { + "type": "boolean", + "const": true + }, + "unit": { + "type": "string", + "description": "The currency unit (e.g., 'BTC')." + }, + "type": { + "type": "string", + "description": "The CAIP-19 asset type." + } + } + } + } + } + }, + "from": { + "type": "array", + "description": "Senders of the transaction (typically empty for Bitcoin).", + "items": { + "type": "object" + } + }, + "fees": { + "type": "array", + "description": "Transaction fees.", + "items": { + "type": "object", + "required": ["type", "asset"], + "properties": { + "type": { + "type": "string", + "enum": ["priority"], + "description": "The fee type." + }, + "asset": { + "type": "object", + "required": ["amount", "fungible", "unit", "type"], + "properties": { + "amount": { + "type": "string", + "description": "The fee amount in BTC." + }, + "fungible": { + "type": "boolean", + "const": true + }, + "unit": { + "type": "string", + "description": "The currency unit (e.g., 'BTC')." + }, + "type": { + "type": "string", + "description": "The CAIP-19 asset type." + } + } + } + } + } + } + } + } + } } ] } diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 63d0a4c0..071b1fbc 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.0.0", + "version": "1.1.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "sP6GU6J9yQ5dDjo06/rmLqazBbD8+K7JRc5CknQ5SDI=", + "shasum": "IGDWqgoDjCmj1nL5bOgYrBIibzODt52PESb4IKfAWz8=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index aee1570c..8f17dd6b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -1,27 +1,16 @@ -import { Psbt, Address } from '@metamask/bitcoindevkit'; -import type { Amount, Txid } from '@metamask/bitcoindevkit'; +import { Psbt, Address, Amount } from '@metamask/bitcoindevkit'; +import type { Transaction, Txid } from '@metamask/bitcoindevkit'; +import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; import { BtcScope, FeeType } from '@metamask/keyring-api'; import type { JsonRpcRequest } from '@metamask/utils'; import { mock } from 'jest-mock-extended'; -import { assert } from 'superstruct'; -import type { Logger } from '../entities'; import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; import { Caip19Asset } from './caip'; -import { - ComputeFeeRequest, - CreateSendFormRequest, - RpcHandler, - RpcMethod, - SendPsbtRequest, - VerifyMessageRequest, -} from './RpcHandler'; -import { SendErrorCodes } from './validation'; - -jest.mock('superstruct', () => ({ - ...jest.requireActual('superstruct'), - assert: jest.fn(), -})); +import { RpcHandler } from './RpcHandler'; +import { RpcMethod, SendErrorCodes } from './validation'; +import type { Logger, BitcoinAccount, TransactionBuilder } from '../entities'; +import { mapPsbtToTransaction } from './mappings'; const mockPsbt = mock(); // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 @@ -31,6 +20,14 @@ jest.mock('@metamask/bitcoindevkit', () => ({ Address: { from_string: jest.fn(), }, + Amount: { + from_btc: jest.fn(), + }, +})); + +jest.mock('./mappings', () => ({ + ...jest.requireActual('./mappings'), + mapPsbtToTransaction: jest.fn(), })); describe('RpcHandler', () => { @@ -70,27 +67,30 @@ describe('RpcHandler', () => { }); describe('parameter validation', () => { - beforeEach(() => { - const { assert: realAssert } = jest.requireActual('superstruct'); - jest.mocked(assert).mockImplementation(realAssert); - }); - describe('onAddressInput validation', () => { + beforeEach(() => { + const mockAccount = mock({ network: 'bitcoin' }); + mockAccountsUseCases.get.mockResolvedValue(mockAccount); + }); + it('rejects invalid address format', async () => { - const invalidAddressRequest = mock({ + const invalidAddressRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.OnAddressInput, params: { value: 'not-a-valid-address', accountId: validAccountId, }, - }); + }; const result = await handler.route(origin, invalidAddressRequest); expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); expect(mockLogger.error).toHaveBeenCalledWith( - 'Invalid account and/or invalid address. Error: %s', - expect.any(String), + 'Invalid address for network %s. Error: %s', + 'bitcoin', + 'Invalid address: not-a-valid-address', ); expect(result).toStrictEqual({ valid: false, @@ -99,13 +99,15 @@ describe('RpcHandler', () => { }); it('rejects invalid UUID accountId', async () => { - const invalidRequest = mock({ + const invalidRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.OnAddressInput, params: { value: 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t', accountId: 'not-a-uuid', }, - }); + }; await expect(handler.route(origin, invalidRequest)).rejects.toThrow( 'Expected a string matching', @@ -113,13 +115,15 @@ describe('RpcHandler', () => { }); it('rejects missing value parameter', async () => { - const missingValueRequest = mock({ + const missingValueRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.OnAddressInput, params: { accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', // Missing 'value' field - }, - }); + } as any, + }; await expect( handler.route(origin, missingValueRequest), @@ -127,13 +131,15 @@ describe('RpcHandler', () => { }); it('rejects missing accountId parameter', async () => { - const missingAccountRequest = mock({ + const missingAccountRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.OnAddressInput, params: { value: 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t', // Missing 'accountId' field - }, - }); + } as any, + }; await expect( handler.route(origin, missingAccountRequest), @@ -143,14 +149,16 @@ describe('RpcHandler', () => { describe('onAmountInput validation', () => { it('rejects invalid UUID accountId', async () => { - const invalidRequest = mock({ + const invalidRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.OnAmountInput, params: { value: '1.5', accountId: 'not-a-uuid', assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', }, - }); + }; await expect(handler.route(origin, invalidRequest)).rejects.toThrow( 'Expected a string matching', @@ -158,14 +166,16 @@ describe('RpcHandler', () => { }); it('rejects negative amounts', async () => { - const negativeAmountRequest = mock({ + const negativeAmountRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.OnAmountInput, params: { value: '-0.5', accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', }, - }); + }; const result = await handler.route(origin, negativeAmountRequest); @@ -176,14 +186,16 @@ describe('RpcHandler', () => { }); it('rejects zero amount', async () => { - const zeroAmountRequest = mock({ + const zeroAmountRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.OnAmountInput, params: { value: '0', accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', }, - }); + }; const result = await handler.route(origin, zeroAmountRequest); @@ -197,14 +209,16 @@ describe('RpcHandler', () => { const testCases = ['abc', '1.2.3', 'not-a-number', 'NaN', 'Infinity']; for (const invalidValue of testCases) { - const invalidAmountRequest = mock({ + const invalidAmountRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.OnAmountInput, params: { value: invalidValue, accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', }, - }); + }; const result = await handler.route(origin, invalidAmountRequest); @@ -216,14 +230,16 @@ describe('RpcHandler', () => { }); it('rejects missing assetId parameter', async () => { - const missingAssetRequest = mock({ + const missingAssetRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.OnAmountInput, params: { value: '1.5', accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', // Missing 'assetId' field - }, - }); + } as any, + }; await expect( handler.route(origin, missingAssetRequest), @@ -233,14 +249,16 @@ describe('RpcHandler', () => { }); it('rejects invalid assetId format', async () => { - const invalidAssetRequest = mock({ + const invalidAssetRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.OnAmountInput, params: { value: '1.5', accountId: 'e36749ce-7c63-41df-b23c-6446c69b8e96', assetId: 'invalid-asset-id', }, - }); + }; await expect( handler.route(origin, invalidAssetRequest), @@ -252,13 +270,15 @@ describe('RpcHandler', () => { describe('verifyMessage validation', () => { it('rejects missing parameters', async () => { - const missingParamsRequest = mock({ + const missingParamsRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.VerifyMessage, params: { address: 'bcrt1qjtgffm20l9vu6a7gacxvpu2ej4kdcsgcgnly6t', // Missing 'message' and 'signature' - }, - }); + } as any, + }; await expect( handler.route(origin, missingParamsRequest), @@ -268,33 +288,37 @@ describe('RpcHandler', () => { }); describe('route', () => { - const mockRequest = mock({ + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.StartSendTransactionFlow, params: { account: validAccountId, }, - }); + }; it('throws error if missing params', async () => { await expect( - handler.route(origin, { ...mockRequest, params: undefined }), + handler.route(origin, { ...request, params: undefined }), ).rejects.toThrow('Missing params'); }); it('throws error if unrecognized method', async () => { await expect( - handler.route(origin, { ...mockRequest, method: 'randomMethod' }), + handler.route(origin, { ...request, method: 'randomMethod' }), ).rejects.toThrow('Method not found: randomMethod'); }); }); describe('executeSendFlow', () => { - const mockRequest = mock({ + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.StartSendTransactionFlow, params: { account: validAccountId, }, - }); + }; it('executes startSendTransactionFlow', async () => { mockSendFlowUseCases.display.mockResolvedValue(mockPsbt); @@ -305,12 +329,8 @@ describe('RpcHandler', () => { }), }); - const result = await handler.route(origin, mockRequest); + const result = await handler.route(origin, request); - expect(assert).toHaveBeenCalledWith( - mockRequest.params, - CreateSendFormRequest, - ); expect(mockSendFlowUseCases.display).toHaveBeenCalledWith(validAccountId); expect(mockAccountsUseCases.signPsbt).toHaveBeenCalledWith( validAccountId, @@ -325,7 +345,7 @@ describe('RpcHandler', () => { const error = new Error(); mockSendFlowUseCases.display.mockRejectedValue(error); - await expect(handler.route(origin, mockRequest)).rejects.toThrow(error); + await expect(handler.route(origin, request)).rejects.toThrow(error); expect(mockSendFlowUseCases.display).toHaveBeenCalled(); expect(mockAccountsUseCases.signPsbt).not.toHaveBeenCalled(); @@ -336,7 +356,7 @@ describe('RpcHandler', () => { mockSendFlowUseCases.display.mockResolvedValue(mockPsbt); mockAccountsUseCases.signPsbt.mockRejectedValue(error); - await expect(handler.route(origin, mockRequest)).rejects.toThrow(error); + await expect(handler.route(origin, request)).rejects.toThrow(error); expect(mockSendFlowUseCases.display).toHaveBeenCalled(); expect(mockAccountsUseCases.signPsbt).toHaveBeenCalled(); @@ -346,13 +366,15 @@ describe('RpcHandler', () => { describe('signAndSendTransaction', () => { const psbt = 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgu3FEiFNy9ZR/zSpTo9nHREjrSoAAAAAAAAAAAA='; - const mockRequest = mock({ + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.SignAndSendTransaction, params: { accountId: validAccountId, transaction: psbt, }, - }); + }; it('executes signAndSendTransaction', async () => { mockAccountsUseCases.signPsbt.mockResolvedValue({ @@ -362,9 +384,8 @@ describe('RpcHandler', () => { }), }); - const result = await handler.route(origin, mockRequest); + const result = await handler.route(origin, request); - expect(assert).toHaveBeenCalledWith(mockRequest.params, SendPsbtRequest); expect(mockAccountsUseCases.signPsbt).toHaveBeenCalledWith( validAccountId, mockPsbt, @@ -378,7 +399,7 @@ describe('RpcHandler', () => { const error = new Error(); mockAccountsUseCases.signPsbt.mockRejectedValue(error); - await expect(handler.route(origin, mockRequest)).rejects.toThrow(error); + await expect(handler.route(origin, request)).rejects.toThrow(error); expect(mockAccountsUseCases.signPsbt).toHaveBeenCalled(); }); @@ -386,14 +407,16 @@ describe('RpcHandler', () => { describe('computeFee', () => { const psbt = 'someEncodedPsbt'; - const mockRequest = mock({ + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.ComputeFee, params: { accountId: validAccountId, transaction: psbt, scope: BtcScope.Mainnet, }, - }); + }; it('executes computeFee', async () => { const mockAmount = mock({ @@ -401,12 +424,8 @@ describe('RpcHandler', () => { }); mockAccountsUseCases.computeFee.mockResolvedValue(mockAmount); - const result = await handler.route(origin, mockRequest); + const result = await handler.route(origin, request); - expect(assert).toHaveBeenCalledWith( - mockRequest.params, - ComputeFeeRequest, - ); expect(Psbt.from_string).toHaveBeenCalledWith(psbt); expect(mockAccountsUseCases.computeFee).toHaveBeenCalledWith( validAccountId, @@ -429,20 +448,22 @@ describe('RpcHandler', () => { const error = new Error('Insufficient funds'); mockAccountsUseCases.computeFee.mockRejectedValue(error); - await expect(handler.route(origin, mockRequest)).rejects.toThrow(error); + await expect(handler.route(origin, request)).rejects.toThrow(error); expect(mockAccountsUseCases.computeFee).toHaveBeenCalled(); }); it('throws FormatError for invalid PSBT', async () => { - const invalidRequest = mock({ + const invalidRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.ComputeFee, params: { accountId: validAccountId, transaction: 'invalid-psbt-base64', scope: BtcScope.Mainnet, }, - }); + }; jest.mocked(Psbt.from_string).mockImplementationOnce(() => { throw new Error('Invalid PSBT'); @@ -461,13 +482,15 @@ describe('RpcHandler', () => { network: 'bitcoin', }; - const validAddressRequest = mock({ + const validAddressRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.OnAddressInput, params: { value: 'bc1qtest123address', accountId: validAccountId, }, - }); + }; beforeEach(() => { mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount as any); @@ -491,7 +514,7 @@ describe('RpcHandler', () => { expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); expect(mockLogger.error).toHaveBeenCalledWith( - 'Invalid account and/or invalid address. Error: %s', + 'Invalid account. Error: %s', 'Account not found', ); expect(result).toStrictEqual({ @@ -502,26 +525,33 @@ describe('RpcHandler', () => { }); describe('onAmountInput', () => { - const validAmountRequest = mock({ + const validAmountRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.OnAmountInput, params: { value: '0.5', accountId: validAccountId, assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', }, - }); + }; beforeEach(() => { - jest.clearAllMocks(); + const mockTrustedSpendable = mock(); + mockTrustedSpendable.to_sat.mockReturnValue(BigInt(150_000_000)); + mockTrustedSpendable.to_btc.mockReturnValue(1.5); + const mockAmountAccount = { network: 'bitcoin', balance: { - trusted_spendable: { - to_btc: jest.fn().mockReturnValue(1.5), - }, + trusted_spendable: mockTrustedSpendable, }, }; mockAccountsUseCases.get.mockResolvedValue(mockAmountAccount as any); + + (Amount.from_btc as jest.Mock).mockImplementation((btc) => ({ + to_sat: () => BigInt(Math.round(btc * 100_000_000)), + })); }); it('validates a correct amount within balance', async () => { @@ -535,14 +565,16 @@ describe('RpcHandler', () => { }); it('rejects amount exceeding balance', async () => { - const excessiveAmountRequest = mock({ + const excessiveAmountRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.OnAmountInput, params: { value: '2.0', // more than account's balance accountId: validAccountId, assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', }, - }); + }; const result = await handler.route(origin, excessiveAmountRequest); @@ -571,7 +603,9 @@ describe('RpcHandler', () => { }); describe('verifyMessage', () => { - const mockRequest = mock({ + const request: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', method: RpcMethod.VerifyMessage, params: { address: 'bcrt1qs2fj7czz0amfm74j73yujx6dn6223md56gkkuy', @@ -579,24 +613,19 @@ describe('RpcHandler', () => { signature: 'AkcwRAIgZxodJQ60t9Rr/hABEHZ1zPUJ4m5hdM5QLpysH8fDSzgCIENOEuZtYf9/Nn/ZW15PcImkknol403dmZrgoOQ+6K+TASECwDKypXm/ElmVTxTLJ7nao6X5mB/iGbU2Q2qtot0QRL4=', }, - }); + }; it('executes verifyMessage successfully with valid signature', async () => { - const result = await handler.route(origin, mockRequest); - - expect(assert).toHaveBeenCalledWith( - mockRequest.params, - VerifyMessageRequest, - ); + const result = await handler.route(origin, request); expect(result).toStrictEqual({ valid: true }); }); it('executes verifyMessage successfully with invalid signature', async () => { const result = await handler.route(origin, { - ...mockRequest, + ...request, params: { - ...mockRequest.params, + ...request.params, address: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', // wrong address for given signature }, } as JsonRpcRequest); @@ -607,10 +636,361 @@ describe('RpcHandler', () => { it('throws ValidationError for invalid signature', async () => { await expect( handler.route(origin, { - ...mockRequest, - params: { ...mockRequest.params, signature: 'invalidaSignature' }, + ...request, + params: { ...request.params, signature: 'invalidaSignature' }, } as JsonRpcRequest), ).rejects.toThrow('Failed to verify signature'); }); }); + + describe('confirmSend', () => { + const mockAccount = mock(); + const mockTxBuilder = mock(); + const mockTemplatePsbt = mock(); + const mockSignedPsbt = mock(); + const mockTransaction = mock(); + + const validRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ConfirmSend, + params: { + fromAccountId: validAccountId, + toAddress: 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8', + amount: '0.0001', + assetId: Caip19Asset.Bitcoin, + }, + }; + + beforeEach(() => { + mockAccount.id = validAccountId; + mockAccount.network = 'bitcoin'; + + const mockBalanceAmount = mock(); + mockBalanceAmount.to_sat.mockReturnValue(BigInt(100_000_000)); // 1 BTC in satoshis + mockBalanceAmount.to_btc.mockReturnValue(1); + mockAccount.balance = { + trusted_spendable: mockBalanceAmount, + } as any; + + mockAccountsUseCases.get.mockResolvedValue(mockAccount); + mockAccountsUseCases.fillPsbt.mockResolvedValue(mockSignedPsbt); + + mockAccount.buildTx.mockReturnValue(mockTxBuilder); + mockTxBuilder.addRecipient.mockReturnThis(); + mockTxBuilder.finish.mockReturnValue(mockTemplatePsbt); + + const mockFeeAmount = mock(); + mockFeeAmount.to_sat.mockReturnValue(BigInt(500)); // 500 satoshis fee + mockSignedPsbt.fee.mockReturnValue(mockFeeAmount); + jest + .spyOn(mockSignedPsbt, 'toString') + .mockReturnValue('filled-psbt-string'); + jest.mocked(Psbt.from_string).mockReturnValue(mockSignedPsbt); + mockAccount.extractTransaction.mockReturnValue(mockTransaction); + + (Amount.from_btc as jest.Mock).mockImplementation((btc) => ({ + to_sat: () => BigInt(Math.round(btc * 100_000_000)), + })); + + // we mock the mapping function since we don't care about the result structure here + // it is tested in mappings.test.ts + jest + .mocked(mapPsbtToTransaction) + .mockReturnValue({} as KeyringTransaction); + }); + + it('creates and signs a transaction successfully', async () => { + const result = await handler.route(origin, validRequest); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + + expect(mockAccount.buildTx).toHaveBeenCalled(); + expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith( + '10000', // 0.0001 BTC in satoshis (addRecipient requires satoshis) + 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8', + ); + expect(mockTxBuilder.finish).toHaveBeenCalled(); + + expect(mockAccountsUseCases.fillPsbt).toHaveBeenCalledWith( + validAccountId, + mockTemplatePsbt, + ); + + expect(Psbt.from_string).toHaveBeenCalledWith('filled-psbt-string'); + expect(mockAccount.extractTransaction).toHaveBeenCalledWith( + mockSignedPsbt, + ); + expect(mapPsbtToTransaction).toHaveBeenCalledWith( + mockAccount, + mockTransaction, + ); + + expect(result).toBeDefined(); + }); + + it('handles different amounts and addresses', async () => { + const customRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ConfirmSend, + params: { + fromAccountId: validAccountId, + toAddress: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + amount: '0.0005', + assetId: Caip19Asset.Bitcoin, + }, + }; + + await handler.route(origin, customRequest); + + expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith( + '50000', // 0.0005 BTC in satoshis + '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + ); + }); + + it('throws error when account is not found', async () => { + mockAccountsUseCases.get.mockRejectedValue( + new Error('Account not found'), + ); + + await expect(handler.route(origin, validRequest)).rejects.toThrow( + 'Account not found', + ); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + expect(mockLogger.error).toHaveBeenCalledWith( + 'An error occurred: %s', + 'Account not found', + ); + }); + + it('throws error when buildTx fails', async () => { + const buildError = new Error('An error occurred when building PBST'); + mockTxBuilder.finish.mockImplementation(() => { + throw buildError; + }); + + await expect(handler.route(origin, validRequest)).rejects.toThrow( + buildError.message, + ); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'An error occurred: %s', + buildError.message, + ); + }); + + it('throws error when fillPsbt fails', async () => { + const fillError = new Error('Failed to fill PSBT'); + mockAccountsUseCases.fillPsbt.mockRejectedValue(fillError); + + await expect(handler.route(origin, validRequest)).rejects.toThrow( + 'Failed to fill PSBT', + ); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'An error occurred: %s', + 'Failed to fill PSBT', + ); + }); + + it('throws error when extractTransaction fails', async () => { + const extractError = new Error('Failed to extract transaction'); + mockAccount.extractTransaction.mockImplementation(() => { + throw extractError; + }); + + await expect(handler.route(origin, validRequest)).rejects.toThrow( + 'Failed to extract transaction', + ); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'An error occurred: %s', + 'Failed to extract transaction', + ); + }); + + it('validates request parameters', async () => { + const missingFieldRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ConfirmSend, + params: { + // missing fromAccountId + toAddress: 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8', + amount: '0.0001', + assetId: Caip19Asset.Bitcoin, + } as any, + }; + + await expect(handler.route(origin, missingFieldRequest)).rejects.toThrow( + 'At path:', + ); + + // invalid UUID format + const invalidUuidRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ConfirmSend, + params: { + fromAccountId: 'not-a-uuid', + toAddress: 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8', + amount: '0.0001', + assetId: Caip19Asset.Bitcoin, + }, + }; + + await expect(handler.route(origin, invalidUuidRequest)).rejects.toThrow( + 'Expected a string matching', + ); + }); + + it('returns validation error for invalid amount', async () => { + const invalidAmountRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ConfirmSend, + params: { + fromAccountId: validAccountId, + toAddress: 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8', + amount: '-0.0001', + assetId: Caip19Asset.Bitcoin, + }, + }; + + const result = await handler.route(origin, invalidAmountRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + }); + + it('returns validation error for invalid address', async () => { + const invalidAddressRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ConfirmSend, + params: { + fromAccountId: validAccountId, + toAddress: 'invalid-address', + amount: '0.0001', + assetId: Caip19Asset.Bitcoin, + }, + }; + + const result = await handler.route(origin, invalidAddressRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + }); + + it('throws error when PSBT construction fails due to insufficient funds for fees', async () => { + // small balance that won't cover amount + fees + const smallBalanceAmount = mock(); + smallBalanceAmount.to_sat.mockReturnValue(BigInt(5000)); // 0.00005 BTC in satoshis + smallBalanceAmount.to_btc.mockReturnValue(0.00005); + + const mockBalance = { + trusted_spendable: smallBalanceAmount, + free: mock(), + immature: mock(), + trusted_pending: mock(), + untrusted_pending: mock(), + coin_count: 1, + coin_value: mock(), + }; + + const smallBalanceAccount = mock(); + smallBalanceAccount.id = validAccountId; + smallBalanceAccount.network = 'bitcoin'; + smallBalanceAccount.balance = mockBalance as any; + + const mockTxBuilderWithError = mock(); + mockTxBuilderWithError.addRecipient.mockReturnThis(); + mockTxBuilderWithError.finish.mockImplementation(() => { + throw new Error( + 'Insufficient funds: 0.00005 BTC available of 0.00006 BTC needed', + ); + }); + + smallBalanceAccount.buildTx.mockReturnValue(mockTxBuilderWithError); + smallBalanceAccount.extractTransaction.mockReturnValue(mockTransaction); + + mockAccountsUseCases.get.mockResolvedValue(smallBalanceAccount); + + const insufficientBalanceRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ConfirmSend, + params: { + fromAccountId: validAccountId, + toAddress: 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8', + amount: '0.00005', // 0.00005 BTC (5000 sats) + 0.00001 BTC fee (1000 sats) > 0.00005 BTC balance (5000 sats) + assetId: Caip19Asset.Bitcoin, + }, + }; + + const result = await handler.route(origin, insufficientBalanceRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + }); + }); + + it('returns validation error for insufficient balance', async () => { + const smallBalanceAmount = mock(); + smallBalanceAmount.to_sat.mockReturnValue(BigInt(5000)); // 0.00005 BTC in satoshis + smallBalanceAmount.to_btc.mockReturnValue(0.00005); + + const mockBalance = { + trusted_spendable: smallBalanceAmount, + free: mock(), + immature: mock(), + trusted_pending: mock(), + untrusted_pending: mock(), + coin_count: 1, + coin_value: mock(), + }; + + const smallBalanceAccount = mock(); + smallBalanceAccount.id = validAccountId; + smallBalanceAccount.network = 'bitcoin'; + smallBalanceAccount.balance = mockBalance as any; + smallBalanceAccount.buildTx.mockReturnValue(mockTxBuilder); + smallBalanceAccount.extractTransaction.mockReturnValue(mockTransaction); + + mockAccountsUseCases.get.mockResolvedValue(smallBalanceAccount); + + const mockSignedPsbtWithFee = mock(); + const mockFeeAmount = mock(); + mockFeeAmount.to_sat.mockReturnValue(BigInt(1000)); // 0.00001 BTC fee in satoshis + mockSignedPsbtWithFee.fee.mockReturnValue(mockFeeAmount); + jest.mocked(Psbt.from_string).mockReturnValue(mockSignedPsbtWithFee); + + const insufficientBalanceRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.ConfirmSend, + params: { + fromAccountId: validAccountId, + toAddress: 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8', + amount: '0.00006', // 0.00006 BTC (6000 sats) + 0.00001 BTC fee (1000 sats) > 0.00005 BTC balance (5000 sats) + assetId: Caip19Asset.Bitcoin, + }, + }; + + const result = await handler.route(origin, insufficientBalanceRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index c540314c..39f87e9b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -1,4 +1,4 @@ -import { Address } from '@metamask/bitcoindevkit'; +import { Amount } from '@metamask/bitcoindevkit'; import { BtcScope } from '@metamask/keyring-api'; import type { Json, JsonRpcRequest } from '@metamask/utils'; import { Verifier } from 'bip322-js'; @@ -15,25 +15,27 @@ import { import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; import { scopeToNetwork } from './caip'; import type { TransactionFee } from './mappings'; -import { mapToTransactionFees } from './mappings'; +import { mapPsbtToTransaction, mapToTransactionFees } from './mappings'; import { parsePsbt } from './parsers'; -import type { OnAddressInputRequest, OnAmountInputRequest } from './types'; +import type { + ConfirmSendRequest, + OnAddressInputRequest, + OnAmountInputRequest, +} from './types'; import type { ValidationResponse } from './validation'; import { - SendErrorCodes, + NO_ERRORS_RESPONSE, + INVALID_RESPONSE, + ConfirmSendRequestStruct, OnAddressInputRequestStruct, OnAmountInputRequestStruct, + RpcMethod, + SendErrorCodes, + validateAmount, + validateAddress, + validateAccountBalance, } from './validation'; -export enum RpcMethod { - StartSendTransactionFlow = 'startSendTransactionFlow', - SignAndSendTransaction = 'signAndSendTransaction', - ComputeFee = 'computeFee', - VerifyMessage = 'verifyMessage', - OnAddressInput = 'onAddressInput', - OnAmountInput = 'onAmountInput', -} - export const CreateSendFormRequest = object({ account: string(), scope: optional(enums(Object.values(BtcScope))), // We don't use the scope but need to define it for validation @@ -110,6 +112,10 @@ export class RpcHandler { assert(params, OnAmountInputRequestStruct); return this.#onAmountInput(params); } + case RpcMethod.ConfirmSend: { + assert(params, ConfirmSendRequestStruct); + return await this.#confirmSend(params); + } case RpcMethod.VerifyMessage: { assert(params, VerifyMessageRequest); return this.#verifyMessage( @@ -189,24 +195,15 @@ export class RpcHandler { // appropriate network (e.g. mainnet, testnet etc) const bitcoinAccount = await this.#accountUseCases.get(accountId); - // try to parse the input address or throw if invalid. - Address.from_string(value, bitcoinAccount.network).toString(); + return validateAddress(value, bitcoinAccount.network, this.#logger); } catch (error) { this.#logger.error( - `Invalid account and/or invalid address. Error: %s`, + `Invalid account. Error: %s`, (error as CodifiedError).message, ); - return { - valid: false, - errors: [{ code: SendErrorCodes.Invalid }], - }; + return INVALID_RESPONSE; } - - return { - valid: true, - errors: [], - }; } async #onAmountInput( @@ -214,29 +211,22 @@ export class RpcHandler { ): Promise { const { value, accountId } = request; - const valueToNumber = Number(value); - if (!Number.isFinite(valueToNumber) || valueToNumber <= 0) { - return { valid: false, errors: [{ code: SendErrorCodes.Invalid }] }; + const amountValidation = validateAmount(value); + if (!amountValidation.valid) { + return amountValidation; } try { const bitcoinAccount = await this.#accountUseCases.get(accountId); - const balance = bitcoinAccount.balance.trusted_spendable.to_btc(); + const balanceValidation = validateAccountBalance(value, bitcoinAccount); - if (valueToNumber > balance) { - return { - valid: false, - errors: [{ code: SendErrorCodes.InsufficientBalance }], - }; - } - - return { valid: true, errors: [] }; + return balanceValidation.valid ? NO_ERRORS_RESPONSE : balanceValidation; } catch (error) { this.#logger.error( 'An error occurred: %s', (error as CodifiedError).message, ); - return { valid: false, errors: [{ code: SendErrorCodes.Invalid }] }; + return INVALID_RESPONSE; } } @@ -256,4 +246,63 @@ export class RpcHandler { ); } } + + async #confirmSend(request: ConfirmSendRequest): Promise { + try { + const account = await this.#accountUseCases.get(request.fromAccountId); + + const inputValidation = + validateAmount(request.amount).valid && + validateAddress(request.toAddress, account.network, this.#logger).valid; + + if (!inputValidation) { + return INVALID_RESPONSE; + } + + const balanceValidation = validateAccountBalance(request.amount, account); + + if (!balanceValidation.valid) { + return balanceValidation; + } + + const amountInSats = Amount.from_btc(Number(request.amount)) + .to_sat() + .toString(); + + try { + const templatePsbt = account + .buildTx() + .addRecipient(amountInSats, request.toAddress) + .finish(); + + const filledPbst = await this.#accountUseCases.fillPsbt( + account.id, + templatePsbt, + ); + + const signedPsbt = parsePsbt(filledPbst.toString()); + const tx = account.extractTransaction(signedPsbt); + return mapPsbtToTransaction(account, tx); + } catch (error) { + const { message } = error as CodifiedError; + + // we have tested for account balance earlier so if we get + // and insufficient funds message when trying to sign the PBST + // it will be because of insufficient fees + if (message.includes('Insufficient funds')) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + }; + } + + throw error; + } + } catch (error) { + const errorMessage = (error as CodifiedError).message; + this.#logger.error('An error occurred: %s', errorMessage); + + throw error; + } + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.test.ts new file mode 100644 index 00000000..c2dd3b8b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.test.ts @@ -0,0 +1,300 @@ +import type { + Transaction, + TxOut, + ScriptBuf, + Amount, + Txid, +} from '@metamask/bitcoindevkit'; +import { Address } from '@metamask/bitcoindevkit'; +import { TransactionStatus, FeeType } from '@metamask/keyring-api'; +import { mock } from 'jest-mock-extended'; + +import type { BitcoinAccount } from '../entities'; +import { Caip19Asset } from './caip'; +import { mapPsbtToTransaction } from './mappings'; + +// Mock the entire bitcoindevkit module +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => ({ + Address: { + from_script: jest.fn(), + }, +})); + +describe('mapPsbtToTransaction', () => { + const ACCOUNT_ID = '724ac464-6572-4d9c-a8e2-4075c8846d65'; + const TIMESTAMP = 1234567890; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Date, 'now').mockReturnValue(TIMESTAMP); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + /** + * Creates a mock transaction with the specified txid and outputs. + * + * @param txid - The transaction ID as a string. + * @param outputs - Array of transaction outputs. + * @returns A mocked Transaction object. + */ + function createMockTransaction(txid: string, outputs: TxOut[]) { + const mockTxid = mock(); + jest.spyOn(mockTxid, 'toString').mockReturnValue(txid); + + return mock({ + compute_txid: () => mockTxid, + output: outputs, + }); + } + + /** + * Creates a mock transaction output with the specified amount. + * + * @param satoshis - The amount in satoshis. + * @returns A mocked TxOut object. + */ + function createMockOutput(satoshis: number) { + const mockAmount = mock(); + jest.spyOn(mockAmount, 'to_btc').mockReturnValue(satoshis / 100_000_000); + + const mockScript = mock(); + jest.spyOn(mockScript, 'is_op_return').mockReturnValue(false); + + return mock({ + script_pubkey: mockScript, + value: mockAmount, + }); + } + + /** + * Creates a mock Bitcoin account. + * + * @param network - The network type ('bitcoin' or 'testnet'). + * @param feeSatoshis - The fee amount in satoshis. + * @returns A mocked BitcoinAccount object. + */ + function createMockAccount( + network: 'bitcoin' | 'testnet' = 'bitcoin', + feeSatoshis = 500, + ) { + const mockFeeAmount = mock(); + jest + .spyOn(mockFeeAmount, 'to_btc') + .mockReturnValue(feeSatoshis / 100_000_000); + + const account = mock(); + account.id = ACCOUNT_ID; + account.network = network; + jest.spyOn(account, 'calculateFee').mockReturnValue(mockFeeAmount); + jest.spyOn(account, 'isMine').mockReturnValue(false); + + return account; + } + + it('maps a bitcoin transaction with single recipient', () => { + const txId = 'abc123def456789'; + const account = createMockAccount(); + const output = createMockOutput(10000); + const transaction = createMockTransaction(txId, [output]); + + jest.mocked(Address.from_script).mockImplementationOnce( + () => + ({ + toString: () => 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + }) as any, + ); + + const result = mapPsbtToTransaction(account, transaction); + + expect(result).toStrictEqual({ + type: 'send', + id: txId, + account: ACCOUNT_ID, + chain: 'bip122:000000000019d6689c085ae165831e93', // Bitcoin mainnet CAIP-2 + status: TransactionStatus.Unconfirmed, + timestamp: TIMESTAMP, + events: [ + { + status: TransactionStatus.Unconfirmed, + timestamp: TIMESTAMP, + }, + ], + to: [ + { + address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + asset: { + amount: '0.0001', // BTC amount, not sats + fungible: true, + unit: 'BTC', + type: Caip19Asset.Bitcoin, + }, + }, + ], + from: [], + fees: [ + { + type: FeeType.Priority, + asset: { + amount: '0.000005', // BTC amount, not sats + fungible: true, + unit: 'BTC', + type: Caip19Asset.Bitcoin, + }, + }, + ], + }); + }); + + it('filters out change outputs owned by the account', () => { + const account = createMockAccount(); + const changeOutput = createMockOutput(5000); + const recipientOutput = createMockOutput(10000); + const transaction = createMockTransaction('def456abc123', [ + changeOutput, + recipientOutput, + ]); + + // First output is change (owned by account), second is recipient + jest + .spyOn(account, 'isMine') + .mockReturnValueOnce(true) // change output + .mockReturnValueOnce(false); // recipient output + + // Only mock for the recipient since change output won't call Address.from_script + jest.mocked(Address.from_script).mockImplementationOnce( + () => + ({ + toString: () => 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + }) as any, + ); + + const result = mapPsbtToTransaction(account, transaction); + + // Should only include the recipient output + expect(result.to).toHaveLength(1); + expect(result.to[0]?.address).toBe( + 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + ); + const asset = result.to[0]?.asset; + expect(asset?.fungible).toBe(true); + expect((asset as any).amount).toBe('0.0001'); + }); + + it('uses testnet chain ID and tBTC unit for testnet', () => { + const account = createMockAccount('testnet', 300); // 300 sats fee + const output = createMockOutput(10000); + const transaction = createMockTransaction('testnet123', [output]); + + // Mock Address.from_script for testnet address + jest.mocked(Address.from_script).mockImplementationOnce( + () => + ({ + toString: () => + 'tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7', + }) as any, + ); + + const result = mapPsbtToTransaction(account, transaction); + + expect(result.chain).toBe('bip122:000000000933ea01ad0ee984209779ba'); // Testnet CAIP-2 + + const recipientAsset = result.to[0]?.asset; + expect(recipientAsset?.fungible).toBe(true); + expect((recipientAsset as any).unit).toBe('tBTC'); + expect((recipientAsset as any).type).toBe(Caip19Asset.Testnet); + + const feeAsset = result.fees[0]?.asset; + expect(feeAsset?.fungible).toBe(true); + expect((feeAsset as any).unit).toBe('tBTC'); + expect((feeAsset as any).type).toBe(Caip19Asset.Testnet); + expect((feeAsset as any).amount).toBe('0.000003'); + }); + + it('includes multiple recipients', () => { + const account = createMockAccount(); + const output1 = createMockOutput(10000); + const output2 = createMockOutput(20000); + const output3 = createMockOutput(5000); + const transaction = createMockTransaction('multi123', [ + output1, + output2, + output3, + ]); + + jest + .mocked(Address.from_script) + .mockImplementationOnce( + () => ({ toString: () => '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa' }) as any, + ) + .mockImplementationOnce( + () => + ({ + toString: () => 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + }) as any, + ) + .mockImplementationOnce( + () => + ({ + toString: () => 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + }) as any, + ); + + const result = mapPsbtToTransaction(account, transaction); + + expect(result.to).toHaveLength(3); + expect(result.to[0]?.address).toBe('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'); + expect(result.to[1]?.address).toBe( + 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', + ); + expect(result.to[2]?.address).toBe( + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + ); + + // Check amounts for each output + const amounts = ['0.0001', '0.0002', '0.00005']; + result.to.forEach((recipient, index) => { + expect(recipient).toBeDefined(); + const asset = recipient?.asset; + expect(asset?.fungible).toBe(true); + expect((asset as any).amount).toBe(amounts[index]); + }); + }); + + it('handles transactions with no recipients (all change or empty)', () => { + const account = createMockAccount(); + + // Test empty outputs + const emptyTx = createMockTransaction('empty123', []); + const emptyResult = mapPsbtToTransaction(account, emptyTx); + expect(emptyResult.to).toStrictEqual([]); + + // Test all outputs being change + const changeOutput = createMockOutput(10000); + const changeOnlyTx = createMockTransaction('change123', [changeOutput]); + jest.spyOn(account, 'isMine').mockReturnValue(true); + + const changeOnlyResult = mapPsbtToTransaction(account, changeOnlyTx); + expect(changeOnlyResult.to).toStrictEqual([]); + }); + + it('calculates and includes transaction fees', () => { + const account = createMockAccount('bitcoin', 2500); // 2500 sats fee + const output = createMockOutput(50000); + const transaction = createMockTransaction('fee123', [output]); + + jest + .mocked(Address.from_script) + .mockImplementationOnce(() => ({ toString: () => 'bc1qtest' }) as any); + + const result = mapPsbtToTransaction(account, transaction); + + expect(account.calculateFee).toHaveBeenCalledWith(transaction); + const feeAsset = result.fees[0]?.asset; + expect(feeAsset?.fungible).toBe(true); + expect((feeAsset as any).amount).toBe('0.000025'); // 2500 sats = 0.000025 BTC + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index 01ec914e..8eabe7a3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -3,6 +3,7 @@ import type { ChainPosition, LocalOutput, Network, + Transaction, TxOut, WalletTx, } from '@metamask/bitcoindevkit'; @@ -214,6 +215,49 @@ export function mapToTransaction( return transaction; } +/** + * Maps a PSBT to a Keyring Transaction. + * + * @param account - The Bitcoin account. + * @param tx - The extracted transaction from the PSBT. + * @returns The Keyring transaction. + */ +export function mapPsbtToTransaction( + account: BitcoinAccount, + tx: Transaction, +): KeyringTransaction { + const txid = tx.compute_txid(); + const currentTime = Date.now(); + + const getRecipients = (txOut: TxOut[]): TransactionRecipient[] => { + return txOut.flatMap((output) => { + if (account.isMine(output.script_pubkey)) { + return []; + } + const recipient = mapToAssetMovement(output, account.network); + return recipient ? [recipient] : []; + }); + }; + + return { + type: 'send', + id: txid.toString(), + account: account.id, + chain: networkToScope[account.network], + status: TransactionStatus.Unconfirmed, + timestamp: currentTime, + events: [ + { + status: TransactionStatus.Unconfirmed, + timestamp: currentTime, + }, + ], + to: getRecipients(tx.output), + from: [], + fees: [mapToTransactionFees(account.calculateFee(tx), account.network)], + }; +} + /** * Maps a Bitcoin Account to a Discovered Account. * diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/types.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/types.ts index d4d72dc7..1a9dbebd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/types.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/types.ts @@ -1,9 +1,11 @@ import type { Infer } from 'superstruct'; import type { + ConfirmSendRequestStruct, OnAddressInputRequestStruct, OnAmountInputRequestStruct, } from './validation'; export type OnAddressInputRequest = Infer; export type OnAmountInputRequest = Infer; +export type ConfirmSendRequest = Infer; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts index b9bb59e4..648915a6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts @@ -1,3 +1,5 @@ +import type { Network } from '@metamask/bitcoindevkit'; +import { Address, Amount } from '@metamask/bitcoindevkit'; import { CaipAssetTypeStruct } from '@metamask/utils'; import type { Infer } from 'superstruct'; import { @@ -11,11 +13,24 @@ import { refine, } from 'superstruct'; +import type { BitcoinAccount, CodifiedError, Logger } from '../entities'; + +export enum RpcMethod { + StartSendTransactionFlow = 'startSendTransactionFlow', + SignAndSendTransaction = 'signAndSendTransaction', + ComputeFee = 'computeFee', + VerifyMessage = 'verifyMessage', + OnAddressInput = 'onAddressInput', + OnAmountInput = 'onAmountInput', + ConfirmSend = 'confirmSend', +} + export enum SendErrorCodes { // eslint-disable-next-line @typescript-eslint/no-shadow Required = 'Required', Invalid = 'Invalid', InsufficientBalance = 'InsufficientBalance', + InsufficientBalanceToCoverFee = 'InsufficientBalanceToCoverFee', } export const NonEmptyStringStruct = refine( @@ -40,6 +55,13 @@ export const OnAmountInputRequestStruct = object({ assetId: CaipAssetTypeStruct, }); +export const ConfirmSendRequestStruct = object({ + fromAccountId: UuidStruct, + toAddress: NonEmptyStringStruct, + assetId: CaipAssetTypeStruct, + amount: NonEmptyStringStruct, +}); + export const ValidationResponseStruct = object({ valid: boolean(), errors: array( @@ -50,3 +72,80 @@ export const ValidationResponseStruct = object({ }); export type ValidationResponse = Infer; + +// create constants for the two most common responses +export const INVALID_RESPONSE: ValidationResponse = { + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], +}; + +export const NO_ERRORS_RESPONSE: ValidationResponse = { + valid: true, + errors: [], +}; + +/** + * Validates that an amount is a positive number + * + * @param amount - The amount to validate + * @returns ValidationResponse indicating if the amount is valid + */ +export function validateAmount(amount: string): ValidationResponse { + const valueToNumber = Number(amount); + if (!Number.isFinite(valueToNumber) || valueToNumber <= 0) { + return INVALID_RESPONSE; + } + return NO_ERRORS_RESPONSE; +} + +/** + * Validates a Bitcoin address for a specific network + * + * @param address - The address to validate + * @param network - The Bitcoin network + * @param logger - Optional logger for error logging + * @returns ValidationResponse indicating if the address is valid + */ +export function validateAddress( + address: string, + network: Network, + logger?: Logger, +): ValidationResponse { + try { + Address.from_string(address, network).toString(); + return NO_ERRORS_RESPONSE; + } catch (error) { + if (logger) { + logger.error( + 'Invalid address for network %s. Error: %s', + network, + (error as CodifiedError).message, + ); + } + return INVALID_RESPONSE; + } +} + +/** + * Validates that an account has sufficient balance for a transaction + * + * @param amountInBtc - The amount in BTC + * @param account - The Bitcoin account + * @returns ValidationResponse indicating if the balance is sufficient + */ +export function validateAccountBalance( + amountInBtc: string, + account: BitcoinAccount, +): ValidationResponse { + const balance = account.balance.trusted_spendable; + const valueToNumber = Amount.from_btc(Number(amountInBtc)); + + if (valueToNumber.to_sat() > balance.to_sat()) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalance }], + }; + } + + return NO_ERRORS_RESPONSE; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 8829a958..3f5cae0f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -534,18 +534,24 @@ export class AccountUseCases { } } + async getFrozenUTXOs(accountId: string): Promise { + return this.#repository.getFrozenUTXOs(accountId); + } + + async getFallbackFeeRate(account: BitcoinAccount): Promise { + const feeEstimates = await this.#chain.getFeeEstimates(account.network); + return ( + feeEstimates.get(this.#targetBlocksConfirmation) ?? this.#fallbackFeeRate + ); + } + async #fillPsbt( account: BitcoinAccount, templatePsbt: Psbt, feeRate?: number, ): Promise { const frozenUTXOs = await this.#repository.getFrozenUTXOs(account.id); - const feeEstimates = await this.#chain.getFeeEstimates(account.network); - - const feeRateToUse = - feeRate ?? - feeEstimates.get(this.#targetBlocksConfirmation) ?? - this.#fallbackFeeRate; + const feeRateToUse = feeRate ?? (await this.getFallbackFeeRate(account)); try { let builder = account From cb9372c556aca7f44382447d79a1b7c25e82b612 Mon Sep 17 00:00:00 2001 From: orestis Date: Tue, 7 Oct 2025 12:24:26 +0100 Subject: [PATCH 289/362] feat: implement confirm send UI (#536) --- .../integration-test/client-request.test.ts | 4 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/entities/send-flow.ts | 17 ++ .../src/handlers/RpcHandler.test.ts | 108 +++------- .../src/handlers/RpcHandler.ts | 52 ++--- .../bitcoin-wallet-snap/src/index.ts | 1 + .../src/infra/jsx/format.ts | 13 ++ .../jsx/send-flow/ReviewTransactionView.tsx | 20 +- .../unified-send-flow/UnifiedSendFormView.tsx | 98 +++++++++ .../src/infra/jsx/unified-send-flow/index.ts | 1 + .../src/store/JSXSendFlowRepository.test.tsx | 4 + .../src/store/JSXSendFlowRepository.tsx | 13 ++ .../src/use-cases/SendFlowUseCases.test.ts | 186 +++++++++++++++++- .../src/use-cases/SendFlowUseCases.ts | 162 ++++++++++++--- 14 files changed, 529 insertions(+), 152 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/index.ts diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts index 51b308af..8e78ed60 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts @@ -25,6 +25,7 @@ describe('OnClientRequestHandler', () => { snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); snap.mockJsonRpc({ method: 'snap_trackError', result: {} }); + snap.mockJsonRpc({ method: 'snap_dialog', result: true }); const response = await snap.onKeyringRequest({ origin: ORIGIN, @@ -382,7 +383,8 @@ describe('OnClientRequestHandler', () => { }); }); - it('fails with insufficient funds to pay fees', async () => { + it.skip('fails with insufficient funds to pay fees', async () => { + // now with drainWallet in place this is not going to happen const balanceBtc = await blockchain.getBalanceInBTC(account.address); const response = await snap.onClientRequest({ diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 071b1fbc..112eaec3 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "IGDWqgoDjCmj1nL5bOgYrBIibzODt52PESb4IKfAWz8=", + "shasum": "bNMmNd7YVjOt2V8Yj8+IXnglcAGYKclk0r5fHgO2JMk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts index a012ac84..f8230c4b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts @@ -4,6 +4,21 @@ import type { CurrencyRate } from '@metamask/snaps-sdk'; import type { CurrencyUnit } from './currency'; import type { CodifiedError } from './error'; +// TODO: This context will be adjusted to the needs +// of unified send flow. +export type ConfirmSendFormContext = { + from: string; + explorerUrl: string; + network: Network; + currency: CurrencyUnit; + exchangeRate?: CurrencyRate; + recipient: string; + amount: string; + backgroundEventId?: string; + locale: string; + psbt: string; +}; + export type SendFormContext = { account: { id: string; @@ -99,4 +114,6 @@ export type SendFlowRepository = { * @param context - the review transaction context */ updateReview(id: string, context: ReviewTransactionContext): Promise; + + insertConfirmSendForm(context: ConfirmSendFormContext): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index 8f17dd6b..93951797 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -9,7 +9,7 @@ import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; import { Caip19Asset } from './caip'; import { RpcHandler } from './RpcHandler'; import { RpcMethod, SendErrorCodes } from './validation'; -import type { Logger, BitcoinAccount, TransactionBuilder } from '../entities'; +import type { Logger, BitcoinAccount } from '../entities'; import { mapPsbtToTransaction } from './mappings'; const mockPsbt = mock(); @@ -645,9 +645,6 @@ describe('RpcHandler', () => { describe('confirmSend', () => { const mockAccount = mock(); - const mockTxBuilder = mock(); - const mockTemplatePsbt = mock(); - const mockSignedPsbt = mock(); const mockTransaction = mock(); const validRequest: JsonRpcRequest = { @@ -674,21 +671,9 @@ describe('RpcHandler', () => { } as any; mockAccountsUseCases.get.mockResolvedValue(mockAccount); - mockAccountsUseCases.fillPsbt.mockResolvedValue(mockSignedPsbt); - - mockAccount.buildTx.mockReturnValue(mockTxBuilder); - mockTxBuilder.addRecipient.mockReturnThis(); - mockTxBuilder.finish.mockReturnValue(mockTemplatePsbt); - - const mockFeeAmount = mock(); - mockFeeAmount.to_sat.mockReturnValue(BigInt(500)); // 500 satoshis fee - mockSignedPsbt.fee.mockReturnValue(mockFeeAmount); - jest - .spyOn(mockSignedPsbt, 'toString') - .mockReturnValue('filled-psbt-string'); - jest.mocked(Psbt.from_string).mockReturnValue(mockSignedPsbt); - mockAccount.extractTransaction.mockReturnValue(mockTransaction); + mockSendFlowUseCases.confirmSendFlow.mockResolvedValue(mockTransaction); + // mock Amount.from_btc to return an object with to_sat method (Amount.from_btc as jest.Mock).mockImplementation((btc) => ({ to_sat: () => BigInt(Math.round(btc * 100_000_000)), })); @@ -704,23 +689,11 @@ describe('RpcHandler', () => { const result = await handler.route(origin, validRequest); expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); - - expect(mockAccount.buildTx).toHaveBeenCalled(); - expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith( - '10000', // 0.0001 BTC in satoshis (addRecipient requires satoshis) + expect(mockSendFlowUseCases.confirmSendFlow).toHaveBeenCalledWith( + mockAccount, + '0.0001', 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8', ); - expect(mockTxBuilder.finish).toHaveBeenCalled(); - - expect(mockAccountsUseCases.fillPsbt).toHaveBeenCalledWith( - validAccountId, - mockTemplatePsbt, - ); - - expect(Psbt.from_string).toHaveBeenCalledWith('filled-psbt-string'); - expect(mockAccount.extractTransaction).toHaveBeenCalledWith( - mockSignedPsbt, - ); expect(mapPsbtToTransaction).toHaveBeenCalledWith( mockAccount, mockTransaction, @@ -744,8 +717,9 @@ describe('RpcHandler', () => { await handler.route(origin, customRequest); - expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith( - '50000', // 0.0005 BTC in satoshis + expect(mockSendFlowUseCases.confirmSendFlow).toHaveBeenCalledWith( + mockAccount, + '0.0005', '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', ); }); @@ -766,49 +740,17 @@ describe('RpcHandler', () => { ); }); - it('throws error when buildTx fails', async () => { - const buildError = new Error('An error occurred when building PBST'); - mockTxBuilder.finish.mockImplementation(() => { - throw buildError; - }); - - await expect(handler.route(origin, validRequest)).rejects.toThrow( - buildError.message, - ); - - expect(mockLogger.error).toHaveBeenCalledWith( - 'An error occurred: %s', - buildError.message, - ); - }); - - it('throws error when fillPsbt fails', async () => { - const fillError = new Error('Failed to fill PSBT'); - mockAccountsUseCases.fillPsbt.mockRejectedValue(fillError); - - await expect(handler.route(origin, validRequest)).rejects.toThrow( - 'Failed to fill PSBT', - ); - - expect(mockLogger.error).toHaveBeenCalledWith( - 'An error occurred: %s', - 'Failed to fill PSBT', - ); - }); - - it('throws error when extractTransaction fails', async () => { - const extractError = new Error('Failed to extract transaction'); - mockAccount.extractTransaction.mockImplementation(() => { - throw extractError; - }); + it('throws error when confirmSendFlow fails', async () => { + const sendError = new Error('Failed to build transaction'); + mockSendFlowUseCases.confirmSendFlow.mockRejectedValue(sendError); await expect(handler.route(origin, validRequest)).rejects.toThrow( - 'Failed to extract transaction', + sendError.message, ); expect(mockLogger.error).toHaveBeenCalledWith( 'An error occurred: %s', - 'Failed to extract transaction', + sendError.message, ); }); @@ -889,7 +831,7 @@ describe('RpcHandler', () => { }); }); - it('throws error when PSBT construction fails due to insufficient funds for fees', async () => { + it('returns error when PSBT construction fails due to insufficient funds for fees', async () => { // small balance that won't cover amount + fees const smallBalanceAmount = mock(); smallBalanceAmount.to_sat.mockReturnValue(BigInt(5000)); // 0.00005 BTC in satoshis @@ -910,19 +852,15 @@ describe('RpcHandler', () => { smallBalanceAccount.network = 'bitcoin'; smallBalanceAccount.balance = mockBalance as any; - const mockTxBuilderWithError = mock(); - mockTxBuilderWithError.addRecipient.mockReturnThis(); - mockTxBuilderWithError.finish.mockImplementation(() => { - throw new Error( - 'Insufficient funds: 0.00005 BTC available of 0.00006 BTC needed', - ); - }); - - smallBalanceAccount.buildTx.mockReturnValue(mockTxBuilderWithError); - smallBalanceAccount.extractTransaction.mockReturnValue(mockTransaction); - mockAccountsUseCases.get.mockResolvedValue(smallBalanceAccount); + // mock confirmSendFlow to throw an insufficient funds error + mockSendFlowUseCases.confirmSendFlow.mockRejectedValue( + new Error( + 'Insufficient funds: 0.00005 BTC available of 0.00006 BTC needed', + ), + ); + const insufficientBalanceRequest: JsonRpcRequest = { id: 1, jsonrpc: '2.0', @@ -962,8 +900,6 @@ describe('RpcHandler', () => { smallBalanceAccount.id = validAccountId; smallBalanceAccount.network = 'bitcoin'; smallBalanceAccount.balance = mockBalance as any; - smallBalanceAccount.buildTx.mockReturnValue(mockTxBuilder); - smallBalanceAccount.extractTransaction.mockReturnValue(mockTransaction); mockAccountsUseCases.get.mockResolvedValue(smallBalanceAccount); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index 39f87e9b..379c9eea 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -1,4 +1,3 @@ -import { Amount } from '@metamask/bitcoindevkit'; import { BtcScope } from '@metamask/keyring-api'; import type { Json, JsonRpcRequest } from '@metamask/utils'; import { Verifier } from 'bip322-js'; @@ -265,40 +264,25 @@ export class RpcHandler { return balanceValidation; } - const amountInSats = Amount.from_btc(Number(request.amount)) - .to_sat() - .toString(); - - try { - const templatePsbt = account - .buildTx() - .addRecipient(amountInSats, request.toAddress) - .finish(); - - const filledPbst = await this.#accountUseCases.fillPsbt( - account.id, - templatePsbt, - ); - - const signedPsbt = parsePsbt(filledPbst.toString()); - const tx = account.extractTransaction(signedPsbt); - return mapPsbtToTransaction(account, tx); - } catch (error) { - const { message } = error as CodifiedError; - - // we have tested for account balance earlier so if we get - // and insufficient funds message when trying to sign the PBST - // it will be because of insufficient fees - if (message.includes('Insufficient funds')) { - return { - valid: false, - errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], - }; - } - - throw error; - } + const transaction = await this.#sendFlowUseCases.confirmSendFlow( + account, + request.amount, + request.toAddress, + ); + return mapPsbtToTransaction(account, transaction); } catch (error) { + const { message } = error as CodifiedError; + + // we have tested for account balance earlier so if we get + // and insufficient funds message when trying to sign the PBST + // it will be because of insufficient fees + if (message.includes('Insufficient funds')) { + return { + valid: false, + errors: [{ code: SendErrorCodes.InsufficientBalanceToCoverFee }], + }; + } + const errorMessage = (error as CodifiedError).message; this.#logger.error('An error occurred: %s', errorMessage); diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 6a1ce886..c04adb32 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -67,6 +67,7 @@ const sendFlowUseCases = new SendFlowUseCases( logger, snapClient, accountRepository, + accountsUseCases, sendFlowRepository, chainClient, assetRatesClient, diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts index c74ad114..fc647bb5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts @@ -45,6 +45,19 @@ export const translate = export const displayExplorerUrl = (url: string, address: string): string => `${url}/address/${address}`; +export const isValidSnapLinkProtocol = (url: string): boolean => { + try { + const { protocol } = new URL(url); + return ( + protocol === 'https:' || + protocol === 'mailto:' || + protocol === 'metamask:' + ); + } catch { + return false; + } +}; + export const errorCodeToLabel = (code: number): string => { const raw = BdkErrorCode[code] as string | undefined; if (!raw) { diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/ReviewTransactionView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/ReviewTransactionView.tsx index 4bffcc6b..793d9fcb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/ReviewTransactionView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/send-flow/ReviewTransactionView.tsx @@ -27,6 +27,7 @@ import { displayCaip10, displayExchangeAmount, displayExplorerUrl, + isValidSnapLinkProtocol, translate, } from '../format'; @@ -66,17 +67,28 @@ export const ReviewTransactionView: SnapComponent<
- + {isValidSnapLinkProtocol(explorerUrl) ? ( + +
+ + ) : (
- + )} - + {isValidSnapLinkProtocol(explorerUrl) ? ( + +
+ + ) : (
- + )}
diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx new file mode 100644 index 00000000..3c6dc2cc --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx @@ -0,0 +1,98 @@ +import { Psbt } from '@metamask/bitcoindevkit'; +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { + Address, + Heading, + Link, + Row, + Section, + Value, + Box, + Button, + Container, + Footer, + Text as SnapText, +} from '@metamask/snaps-sdk/jsx'; + +import type { Messages, ConfirmSendFormContext } from '../../../entities'; +import { networkToCurrencyUnit, ConfirmationEvent } from '../../../entities'; +import { + displayAmount, + displayCaip10, + displayExchangeAmount, + displayExplorerUrl, + isValidSnapLinkProtocol, + translate, +} from '../format'; + +export type UnifiedSendFormViewProps = { + context: ConfirmSendFormContext; + messages: Messages; +}; + +export const UnifiedSendFormView: SnapComponent = ({ + context, + messages, +}) => { + const t = translate(messages); + const { amount, exchangeRate, network, from, explorerUrl } = context; + + const psbt = Psbt.from_string(context.psbt); + const fee = psbt.fee().to_sat(); + const currency = networkToCurrencyUnit[network]; + + return ( + + + {t('Transaction request')} + +
+ + + + + + {displayAmount(BigInt(amount), currency)} + + {displayExchangeAmount(BigInt(amount), exchangeRate)} + + + +
+ +
+ + MetaMask + + + {isValidSnapLinkProtocol(explorerUrl) ? ( + +
+ + ) : ( +
+ )} + + + {network} + + + + +
+
+ +
+ + +
+
+ ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/index.ts new file mode 100644 index 00000000..679f4614 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/index.ts @@ -0,0 +1 @@ +export * from './UnifiedSendFormView'; diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx index c2e662d6..af3e14fb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx @@ -14,6 +14,10 @@ jest.mock('../infra/jsx', () => ({ ReviewTransactionView: jest.fn(), })); +jest.mock('../infra/jsx/unified-send-flow', () => ({ + UnifiedSendFormView: jest.fn(), +})); + describe('JSXSendFlowRepository', () => { const mockMessages = { foo: { message: 'bar' } }; diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx index d6dc2c21..2ba4984e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.tsx @@ -1,4 +1,5 @@ import { + type ConfirmSendFormContext, type SendFormContext, type SendFlowRepository, type SnapClient, @@ -7,6 +8,7 @@ import { AssertionError, } from '../entities'; import { ReviewTransactionView, SendFormView } from '../infra/jsx'; +import { UnifiedSendFormView } from '../infra/jsx/unified-send-flow'; export class JSXSendFlowRepository implements SendFlowRepository { readonly #snapClient: SnapClient; @@ -55,4 +57,15 @@ export class JSXSendFlowRepository implements SendFlowRepository { context, ); } + + async insertConfirmSendForm( + context: ConfirmSendFormContext, + ): Promise { + const messages = await this.#translator.load(context.locale); + + return await this.#snapClient.createInterface( + , + context, + ); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 3eb3e052..34b4e96f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -1,4 +1,8 @@ -import type { FeeEstimates, Network } from '@metamask/bitcoindevkit'; +import type { + FeeEstimates, + Network, + Transaction, +} from '@metamask/bitcoindevkit'; import { Psbt, Address, Amount } from '@metamask/bitcoindevkit'; import type { GetPreferencesResult } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; @@ -24,6 +28,7 @@ import { } from '../entities'; import { SendFlowUseCases } from './SendFlowUseCases'; import { CronMethod } from '../handlers'; +import type { AccountUseCases } from './AccountUseCases'; // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 /* eslint-disable @typescript-eslint/naming-convention */ @@ -44,6 +49,7 @@ describe('SendFlowUseCases', () => { const mockLogger = mock(); const mockSnapClient = mock(); const mockAccountRepository = mock(); + const mockAccountUseCases = mock(); const mockSendFlowRepository = mock(); const mockChain = mock(); const mockRatesClient = mock(); @@ -73,6 +79,7 @@ describe('SendFlowUseCases', () => { mockLogger, mockSnapClient, mockAccountRepository, + mockAccountUseCases, mockSendFlowRepository, mockChain, mockRatesClient, @@ -789,4 +796,181 @@ describe('SendFlowUseCases', () => { await expect(useCases.refresh('interface-id')).rejects.toThrow(error); }); }); + + describe('confirmSendFlow', () => { + const mockTxBuilder = mock(); + const mockTemplatePsbt = mock({ + toString: jest.fn().mockReturnValue('template-psbt-base64'), + }); + const mockSignedPsbt = mock({ + toString: jest.fn().mockReturnValue('signed-psbt-base64'), + }); + const mockTransaction = mock(); + const mockAmount = mock(); + const toAddress = 'bc1qux9xtsj6mr4un7yg9kgd7tv8kndvlhv2gv5yc8'; + const amount = '0.0001'; + const mockFeeEstimates = mock(); + + beforeEach(() => { + mockAccount.buildTx.mockReturnValue(mockTxBuilder); + mockAccount.extractTransaction.mockReturnValue(mockTransaction); + mockAmount.to_sat.mockReturnValue(BigInt(10000)); + (Amount.from_btc as jest.Mock).mockReturnValue(mockAmount); + mockTxBuilder.feeRate.mockReturnThis(); + mockTxBuilder.addRecipient.mockReturnThis(); + mockTxBuilder.drainWallet.mockReturnThis(); + mockTxBuilder.drainTo.mockReturnThis(); + mockTxBuilder.finish.mockReturnValue(mockTemplatePsbt); + mockSnapClient.getPreferences.mockResolvedValue(mockPreferences); + mockFeeEstimates.get.mockReturnValue(2.5); + mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); + mockChain.getExplorerUrl.mockReturnValue(explorerUrl); + mockSendFlowRepository.insertConfirmSendForm.mockResolvedValue( + 'interface-id', + ); + mockSnapClient.displayConfirmation.mockResolvedValue(true); + mockAccountUseCases.signPsbt.mockResolvedValue({ + psbt: mockSignedPsbt, + } as any); + (Psbt.from_string as jest.Mock).mockReturnValue(mockSignedPsbt); + mockRatesClient.spotPrices.mockResolvedValue({ + price: { value: 50000, currency: 'usd' }, + } as any); + }); + + it('builds and confirms a regular send transaction', async () => { + const result = await useCases.confirmSendFlow( + mockAccount, + amount, + toAddress, + ); + + expect(Amount.from_btc).toHaveBeenCalledWith(Number(amount)); + expect(mockAccount.buildTx).toHaveBeenCalled(); + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith(2.5); + expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith( + '10000', + toAddress, + ); + expect(mockTxBuilder.finish).toHaveBeenCalled(); + expect(mockSendFlowRepository.insertConfirmSendForm).toHaveBeenCalled(); + expect(mockSnapClient.displayConfirmation).toHaveBeenCalledWith( + 'interface-id', + ); + expect(mockAccountUseCases.signPsbt).toHaveBeenCalledWith( + mockAccount.id, + mockTemplatePsbt, + 'metamask', + { fill: false, broadcast: true }, + 2.5, + ); + expect(mockAccount.extractTransaction).toHaveBeenCalledWith( + mockSignedPsbt, + ); + expect(result).toBe(mockTransaction); + }); + + it('builds a drain transaction when amount equals balance', async () => { + const balanceAmount = mock(); + balanceAmount.to_sat.mockReturnValue(BigInt(10000)); + mockAccount.balance = { + trusted_spendable: balanceAmount, + } as any; + + await useCases.confirmSendFlow(mockAccount, amount, toAddress); + + expect(mockTxBuilder.drainWallet).toHaveBeenCalled(); + expect(mockTxBuilder.drainTo).toHaveBeenCalledWith(toAddress); + expect(mockTxBuilder.addRecipient).not.toHaveBeenCalled(); + }); + + it('uses fallback fee rate when fee estimates are not available', async () => { + const emptyFeeEstimates = mock(); + emptyFeeEstimates.get.mockReturnValue(undefined); + mockChain.getFeeEstimates.mockResolvedValue(emptyFeeEstimates); + + await useCases.confirmSendFlow(mockAccount, amount, toAddress); + + expect(mockTxBuilder.feeRate).toHaveBeenCalledWith(fallbackFeeRate); + }); + + it('throws error when user cancels confirmation', async () => { + mockSnapClient.displayConfirmation.mockResolvedValue(false); + + await expect( + useCases.confirmSendFlow(mockAccount, amount, toAddress), + ).rejects.toThrow('User canceled the confirmation'); + }); + + it('throws error when buildTx fails', async () => { + const buildError = new Error('Failed to build transaction'); + mockAccount.buildTx.mockImplementation(() => { + throw buildError; + }); + + await expect( + useCases.confirmSendFlow(mockAccount, amount, toAddress), + ).rejects.toThrow(buildError); + }); + + it('throws error when finish fails', async () => { + const finishError = new Error('Insufficient funds'); + mockTxBuilder.finish.mockImplementation(() => { + throw finishError; + }); + + await expect( + useCases.confirmSendFlow(mockAccount, amount, toAddress), + ).rejects.toThrow(finishError); + }); + + it('throws error when signPsbt fails', async () => { + const signError = new Error('Failed to sign PSBT'); + mockAccountUseCases.signPsbt.mockRejectedValue(signError); + + await expect( + useCases.confirmSendFlow(mockAccount, amount, toAddress), + ).rejects.toThrow(signError); + }); + + it('throws error when extractTransaction fails', async () => { + const extractError = new Error('Failed to extract transaction'); + mockAccount.extractTransaction.mockImplementation(() => { + throw extractError; + }); + + await expect( + useCases.confirmSendFlow(mockAccount, amount, toAddress), + ).rejects.toThrow(extractError); + }); + + it('includes exchange rate in context when available', async () => { + await useCases.confirmSendFlow(mockAccount, amount, toAddress); + + expect(mockRatesClient.spotPrices).toHaveBeenCalledWith('usd'); + expect(mockSendFlowRepository.insertConfirmSendForm).toHaveBeenCalledWith( + expect.objectContaining({ + exchangeRate: expect.objectContaining({ + conversionRate: { value: 50000, currency: 'usd' }, + currency: 'USD', + }), + currency: CurrencyUnit.Bitcoin, + }), + ); + }); + + it('handles missing exchange rate gracefully', async () => { + mockRatesClient.spotPrices.mockResolvedValue({ + price: undefined, + } as any); + + await useCases.confirmSendFlow(mockAccount, amount, toAddress); + + expect(mockSendFlowRepository.insertConfirmSendForm).toHaveBeenCalledWith( + expect.objectContaining({ + exchangeRate: undefined, + }), + ); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index 798f1121..27326772 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -1,28 +1,38 @@ -import { Psbt, Address, Amount } from '@metamask/bitcoindevkit'; +import { + type Network, + Address, + Amount, + Psbt, + type Transaction, +} from '@metamask/bitcoindevkit'; import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; -import type { InputChangeEvent } from '@metamask/snaps-sdk'; +import type { CurrencyRate, InputChangeEvent } from '@metamask/snaps-sdk'; import type { + AssetRatesClient, + BitcoinAccount, BitcoinAccountRepository, - SendFlowRepository, - SnapClient, BlockchainClient, - SendFormContext, - ReviewTransactionContext, - AssetRatesClient, - Logger, CodifiedError, + Logger, + ConfirmSendFormContext, + ReviewTransactionContext, + SendFlowRepository, + SendFormContext, + SnapClient, } from '../entities'; import { - SendFormEvent, - ReviewTransactionEvent, - networkToCurrencyUnit, + AssertionError, CurrencyUnit, - UserActionError, + networkToCurrencyUnit, NotFoundError, - AssertionError, + ReviewTransactionEvent, + SendFormEvent, + UserActionError, } from '../entities'; +import type { AccountUseCases } from './AccountUseCases'; import { CronMethod } from '../handlers'; +import { parsePsbt } from '../handlers/parsers'; type SetAccountEventValue = { accountId: string; @@ -35,6 +45,8 @@ export class SendFlowUseCases { readonly #accountRepository: BitcoinAccountRepository; + readonly #accountUseCases: AccountUseCases; + readonly #sendFlowRepository: SendFlowRepository; readonly #chainClient: BlockchainClient; @@ -51,6 +63,7 @@ export class SendFlowUseCases { logger: Logger, snapClient: SnapClient, accountRepository: BitcoinAccountRepository, + accounts: AccountUseCases, sendFlowRepository: SendFlowRepository, chainClient: BlockchainClient, ratesClient: AssetRatesClient, @@ -61,6 +74,7 @@ export class SendFlowUseCases { this.#logger = logger; this.#snapClient = snapClient; this.#accountRepository = accountRepository; + this.#accountUseCases = accounts; this.#sendFlowRepository = sendFlowRepository; this.#chainClient = chainClient; this.#ratesClient = ratesClient; @@ -69,6 +83,76 @@ export class SendFlowUseCases { this.#ratesRefreshInterval = ratesRefreshInterval; } + async confirmSendFlow( + account: BitcoinAccount, + amount: string, + toAddress: string, + ): Promise { + const amountInSats = Amount.from_btc(Number(amount)).to_sat(); + const templatePsbt = account.buildTx(); + const { locale, currency: fiatCurrency } = + await this.#snapClient.getPreferences(); + + const feeEstimates = await this.#chainClient.getFeeEstimates( + account.network, + ); + + const currentFeeRate = + feeEstimates.get(this.#targetBlocksConfirmation) ?? this.#fallbackFeeRate; + + templatePsbt.feeRate(currentFeeRate); + + if (amountInSats === account.balance.trusted_spendable.to_sat()) { + templatePsbt.drainWallet().drainTo(toAddress); + } else { + templatePsbt.addRecipient(amountInSats.toString(), toAddress); + } + + const psbt = templatePsbt.finish(); + const currency = networkToCurrencyUnit[account.network]; + + // TODO: add all the necessary properties we need here + const context: ConfirmSendFormContext = { + from: account.publicAddress.toString(), + explorerUrl: this.#chainClient.getExplorerUrl(account.network), + amount: amountInSats.toString(), + recipient: toAddress, + psbt: psbt.toString(), + currency, + exchangeRate: await this.#getExchangeRate(account.network, fiatCurrency), + network: account.network, + locale, + }; + + const interfaceId = + await this.#sendFlowRepository.insertConfirmSendForm(context); + + // Blocks and waits for user actions. + const confirmed = + await this.#snapClient.displayConfirmation(interfaceId); + + if (!confirmed) { + throw new UserActionError('User canceled the confirmation'); + } + + // sign and broadcast + const signedPsbt = ( + await this.#accountUseCases.signPsbt( + account.id, + psbt, + 'metamask', + { + fill: false, + broadcast: true, + }, + currentFeeRate, + ) + ).psbt; + + const parsedPsbt = parsePsbt(signedPsbt); + return account.extractTransaction(parsedPsbt); + } + async display(accountId: string): Promise { this.#logger.debug('Displaying Send form. Account: %s', accountId); @@ -231,6 +315,40 @@ export class SendFlowUseCases { return await this.#sendFlowRepository.updateForm(id, updatedContext); } + async #getExchangeRate( + network: Network, + currency: string, + ): Promise { + // Exchange rate is only relevant for Bitcoin + if (network === 'bitcoin') { + try { + const spotPrice = await this.#ratesClient.spotPrices(currency); + + if (spotPrice.price === undefined || spotPrice.price === null) { + this.#logger.warn( + `Exchange rate API returned invalid price for ${currency}`, + ); + return undefined; + } + + return { + conversionRate: spotPrice.price, + conversionDate: getCurrentUnixTimestamp(), + currency: currency.toUpperCase(), + }; + } catch (error) { + // exchange rates are optional display information - don't fail if unavailable + this.#logger.warn( + `Failed to fetch exchange rate for ${currency}. Error: %s`, + error, + ); + return undefined; + } + } + + return undefined; + } + async #handleSetRecipient( id: string, context: SendFormContext, @@ -411,21 +529,15 @@ export class SendFlowUseCases { try { const feeEstimates = await this.#chainClient.getFeeEstimates(network); - const feeRate = + + updatedContext.feeRate = feeEstimates.get(this.#targetBlocksConfirmation) ?? this.#fallbackFeeRate; - updatedContext.feeRate = feeRate; - - // Exchange rate is only relevant for Bitcoin - if (network === 'bitcoin') { - const spotPrice = await this.#ratesClient.spotPrices(currency); - updatedContext.exchangeRate = { - conversionRate: spotPrice.price, - conversionDate: getCurrentUnixTimestamp(), - currency: currency.toUpperCase(), - }; - } + updatedContext.exchangeRate = await this.#getExchangeRate( + network, + currency, + ); updatedContext = await this.#computeFee(updatedContext); } catch (error) { // We do not throw so we can reschedule. Previous fetched values or fallbacks will be used. From 4df4fb061dfbd03545a527276905a7eac5568597 Mon Sep 17 00:00:00 2001 From: orestis Date: Tue, 7 Oct 2025 12:42:40 +0100 Subject: [PATCH 290/362] feat: limit account creation & discovery to P2WPKH addresses (#537) --- .../integration-test/keyring.test.ts | 174 ++++++++++++++++++ .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/KeyringHandler.test.ts | 78 +++++++- .../src/handlers/KeyringHandler.ts | 30 ++- 4 files changed, 275 insertions(+), 9 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index a25fcc97..f27ee1fe 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -109,6 +109,42 @@ describe('Keyring', () => { index: 0, expectedAddress: TEST_ADDRESS_MAINNET, }, + ])( + 'creates a P2WPKH account: %s', + async ({ expectedAddress, ...requestOpts }) => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { options: { ...requestOpts, synchronize: false } }, + }); + + expect(response).toRespondWith({ + type: requestOpts.addressType, + id: expect.anything(), + address: expectedAddress, + options: { + entropySource: 'm', + entropy: { + type: 'mnemonic', + id: 'm', + groupIndex: requestOpts.index, + derivationPath: `m/${accountTypeToPurpose[requestOpts.addressType]}/${scopeToCoinType[requestOpts.scope]}/${requestOpts.index}'`, + }, + exportable: false, + }, + scopes: [requestOpts.scope], + methods: Object.values(AccountCapability), + }); + + // eslint-disable-next-line jest/no-conditional-in-test + if ('result' in response.response) { + accounts[expectedAddress] = response.response.result as KeyringAccount; + } + }, + ); + + // skip non-P2WPKH address types as we are not supporting them for v1 + it.skip.each([ { addressType: BtcAccountType.P2pkh, scope: BtcScope.Mainnet, @@ -211,6 +247,144 @@ describe('Keyring', () => { expect(response).toRespondWith(accounts[TEST_ADDRESS_REGTEST]); }); + it.each([ + { + addressType: BtcAccountType.P2pkh, + scope: BtcScope.Mainnet, + expectedError: 'Only native segwit (P2WPKH) addresses are supported', + }, + { + addressType: BtcAccountType.P2sh, + scope: BtcScope.Testnet, + expectedError: 'Only native segwit (P2WPKH) addresses are supported', + }, + { + addressType: BtcAccountType.P2tr, + scope: BtcScope.Mainnet, + expectedError: 'Only native segwit (P2WPKH) addresses are supported', + }, + ])( + 'rejects creation of non-P2WPKH account: $addressType', + async ({ addressType, scope, expectedError }) => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope, + addressType, + index: 0, + synchronize: false, + }, + }, + }); + + expect(response.response).toMatchObject({ + error: { + code: -32000, + message: `Invalid format: ${expectedError}`, + }, + }); + }, + ); + + it.each([ + { + derivationPath: "m/44'/0'/0'", // (P2PKH) + expectedError: + 'Only native segwit (BIP-84) derivation paths are supported', + }, + { + derivationPath: "m/49'/0'/0'", // (P2SH) + expectedError: + 'Only native segwit (BIP-84) derivation paths are supported', + }, + { + derivationPath: "m/86'/0'/0'", // (P2TR) + expectedError: + 'Only native segwit (BIP-84) derivation paths are supported', + }, + ])( + 'rejects creation with non-BIP84 derivation path: $derivationPath', + async ({ derivationPath, expectedError }) => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope: BtcScope.Regtest, + derivationPath, + synchronize: false, + }, + }, + }); + + expect(response.response).toMatchObject({ + error: { + code: -32000, + message: `Invalid format: ${expectedError}`, + }, + }); + }, + ); + + it('rejects creation when addressType and derivationPath mismatch', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope: BtcScope.Regtest, + addressType: BtcAccountType.P2wpkh, // Native segwit + derivationPath: "m/44'/0'/0'", // Legacy path (P2PKH) + synchronize: false, + }, + }, + }); + + expect(response.response).toMatchObject({ + error: { + code: -32000, + message: + 'Invalid format: Only native segwit (BIP-84) derivation paths are supported', + }, + }); + }); + + it('accepts creation when addressType and derivationPath both indicate P2WPKH', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_createAccount', + params: { + options: { + scope: BtcScope.Regtest, + addressType: BtcAccountType.P2wpkh, + derivationPath: "m/84'/1'/10'", // Native segwit path matching P2WPKH + synchronize: false, + }, + }, + }); + + expect(response.response).toHaveProperty('result'); + + const account: KeyringAccount = ( + response.response as { result: KeyringAccount } + ).result; + expect(account.address).toMatch(/^bcrt1/u); // Native segwit address + expect((account.options.entropy as { groupIndex: number }).groupIndex).toBe( + 10, + ); + + // remove to avoid interfering with other tests + await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_deleteAccount', + params: { + id: account.id, + }, + }); + }); + it('gets an account', async () => { const response = await snap.onKeyringRequest({ origin: ORIGIN, diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 112eaec3..fe5dacfe 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "bNMmNd7YVjOt2V8Yj8+IXnglcAGYKclk0r5fHgO2JMk=", + "shasum": "nX6/V6oSl1oKs1sy9BSZVV/Too21iVnVxGE0MNzGW28=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index f2ec9233..4fd9db37 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -20,7 +20,12 @@ import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; import type { BitcoinAccount } from '../entities'; -import { AccountCapability, CurrencyUnit, Purpose } from '../entities'; +import { + AccountCapability, + CurrencyUnit, + Purpose, + FormatError, +} from '../entities'; import { scopeToNetwork, caipToAddressType, Caip19Asset } from './caip'; import { KeyringHandler, CreateAccountRequest } from './KeyringHandler'; import type { KeyringRequestHandler } from './KeyringRequestHandler'; @@ -81,7 +86,8 @@ describe('KeyringHandler', () => { const index = 1; const correlationId = 'correlation-id'; - it('respects provided params', async () => { + // non-P2WPKH address types as we are not supporting them for v1 + it.skip('respects provided params', async () => { const options = { scope: BtcScope.Signet, entropySource, @@ -110,7 +116,8 @@ describe('KeyringHandler', () => { expect(mockAccounts.fullScan).not.toHaveBeenCalled(); }); - it('extracts index from derivationPath', async () => { + // only P2WPKH (BIP-84) derivation paths are now supported for v1 + it.skip('extracts index from derivationPath', async () => { const options = { scope: BtcScope.Signet, derivationPath: "m/44'/0'/5'/*/*", // change and address indexes can be anything @@ -138,7 +145,7 @@ describe('KeyringHandler', () => { }); it('auto increment index', async () => { - // We should get index index 1 + // We should get index 1 mockAccounts.list.mockResolvedValue([ mock({ entropySource: 'entropy1', @@ -185,10 +192,33 @@ describe('KeyringHandler', () => { expect(mockAccounts.create).toHaveBeenCalledWith(expectedCreateParams); }); - it.each([ + it.each([{ purpose: Purpose.NativeSegwit, addressType: 'p2wpkh' }] as { + purpose: Purpose; + addressType: AddressType; + }[])( + 'extracts P2WPKH address type from derivationPath: %s', + async ({ purpose, addressType }) => { + const options = { + scope: BtcScope.Signet, + derivationPath: `m/${purpose}'/0'/0'`, + }; + const expectedCreateParams: CreateAccountParams = { + network: 'signet', + index: 0, + addressType, + entropySource: 'm', + synchronize: true, + }; + + await handler.createAccount(options); + expect(mockAccounts.create).toHaveBeenCalledWith(expectedCreateParams); + }, + ); + + // skip non-P2WPKH address types as they are not supported on v1 + it.skip.each([ { purpose: Purpose.Legacy, addressType: 'p2pkh' }, { purpose: Purpose.Segwit, addressType: 'p2sh' }, - { purpose: Purpose.NativeSegwit, addressType: 'p2wpkh' }, { purpose: Purpose.Taproot, addressType: 'p2tr' }, { purpose: Purpose.Multisig, addressType: 'p2wsh' }, ] as { purpose: Purpose; addressType: AddressType }[])( @@ -234,6 +264,39 @@ describe('KeyringHandler', () => { ).rejects.toThrow("Invalid derivation path: m/44'"); }); + it('fails when addressType and derivationPath mismatch', async () => { + const options = { + scope: BtcScope.Signet, + addressType: BtcAccountType.P2wpkh, + derivationPath: "m/44'/0'/0'", // Legacy path (P2PKH) + }; + + // The error comes from #extractAddressType which validates the derivation path first + await expect(handler.createAccount(options)).rejects.toThrow( + new FormatError( + 'Only native segwit (BIP-84) derivation paths are supported', + ), + ); + }); + + it('succeeds when addressType and derivationPath both indicate P2WPKH', async () => { + const options = { + scope: BtcScope.Signet, + addressType: BtcAccountType.P2wpkh, + derivationPath: "m/84'/0'/5'", // Native segwit path + }; + const expectedCreateParams: CreateAccountParams = { + network: 'signet', + index: 5, + addressType: 'p2wpkh', + entropySource: 'm', + synchronize: true, + }; + + await handler.createAccount(options); + expect(mockAccounts.create).toHaveBeenCalledWith(expectedCreateParams); + }); + it('propagates errors from createAccount', async () => { const error = new Error('createAccount error'); mockAccounts.create.mockRejectedValue(error); @@ -251,7 +314,8 @@ describe('KeyringHandler', () => { const scopes = Object.values(BtcScope); it('creates, scans and returns accounts for every scope/addressType combination', async () => { - const addressTypes = Object.values(BtcAccountType); + // only P2WPKH is now supported for v1 + const addressTypes = [BtcAccountType.P2wpkh]; const totalCombinations = scopes.length * addressTypes.length; const expected: DiscoveredAccount[] = []; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 44ae8019..c6ffd923 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -184,11 +184,31 @@ export class KeyringHandler implements Keyring { let resolvedAddressType: AddressType; if (addressType) { + // only support P2WPKH addresses for v1 + if (addressType !== BtcAccountType.P2wpkh) { + throw new FormatError( + 'Only native segwit (P2WPKH) addresses are supported', + ); + } resolvedAddressType = caipToAddressType[addressType]; + + // if both addressType and derivationPath are provided, validate they match + if (derivationPath) { + const pathAddressType = this.#extractAddressType(derivationPath); + if (pathAddressType !== resolvedAddressType) { + throw new FormatError('Address type and derivation path mismatch'); + } + } } else if (derivationPath) { resolvedAddressType = this.#extractAddressType(derivationPath); } else { resolvedAddressType = this.#defaultAddressType; + // validate default address type is P2WPKH just to be sure + if (resolvedAddressType !== 'p2wpkh') { + throw new FormatError( + 'Only native segwit (P2WPKH) addresses are supported', + ); + } } // FIXME: This if should be removed ASAP as the index should always be defined or be 0 @@ -225,7 +245,8 @@ export class KeyringHandler implements Keyring { ): Promise { const accounts = await Promise.all( scopes.flatMap((scope) => - Object.values(BtcAccountType).map(async (addressType) => + // only discover P2WPKH addresses + [BtcAccountType.P2wpkh].map(async (addressType) => this.#accountsUseCases.discover({ network: scopeToNetwork[scope], entropySource, @@ -327,6 +348,13 @@ export class KeyringHandler implements Keyring { throw new FormatError(`Invalid BIP-purpose: ${purpose}`); } + // only support native segwit (BIP-84) derivation paths for now + if ((purpose as Purpose) !== Purpose.NativeSegwit) { + throw new FormatError( + `Only native segwit (BIP-84) derivation paths are supported`, + ); + } + const addressType = purposeToAddressType[purpose as Purpose]; if (!addressType) { throw new FormatError(`No address-type mapping for purpose: ${purpose}`); From 7bb31582ee1b18b899666e14290d50bb32798879 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:20:48 +0100 Subject: [PATCH 291/362] 1.2.0 (#538) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 11 ++++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 5fe954e9..21fcd043 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] + +### Added + +- Limit account creation & discovery to P2WPKH addresses ([#537](https://github.com/MetaMask/snap-bitcoin-wallet/pull/537)) +- Implement confirm send UI ([#536](https://github.com/MetaMask/snap-bitcoin-wallet/pull/536)) +- Add confirmSend for unified send flow ([#533](https://github.com/MetaMask/snap-bitcoin-wallet/pull/533)) + ## [1.1.0] ### Added @@ -469,7 +477,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.1.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.3...v1.0.0 [0.19.3]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.2...v0.19.3 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 758ace0c..8bdb644c 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.1.0", + "version": "1.2.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 8ea732922d3cf80f1cccb9f6b2f0e50dfd945af9 Mon Sep 17 00:00:00 2001 From: orestis Date: Thu, 9 Oct 2025 11:04:03 +0100 Subject: [PATCH 292/362] fix: update locales (#540) --- .../bitcoin-wallet-snap/locales/de.json | 15 ++++++++++++++ .../bitcoin-wallet-snap/locales/el.json | 15 ++++++++++++++ .../bitcoin-wallet-snap/locales/en.json | 15 ++++++++++++++ .../bitcoin-wallet-snap/locales/es.json | 15 ++++++++++++++ .../bitcoin-wallet-snap/locales/es_419.json | 15 ++++++++++++++ .../bitcoin-wallet-snap/locales/fr.json | 15 ++++++++++++++ .../bitcoin-wallet-snap/locales/hi.json | 15 ++++++++++++++ .../bitcoin-wallet-snap/locales/id.json | 15 ++++++++++++++ .../bitcoin-wallet-snap/locales/ja.json | 15 ++++++++++++++ .../bitcoin-wallet-snap/locales/ko.json | 15 ++++++++++++++ .../bitcoin-wallet-snap/locales/pt.json | 15 ++++++++++++++ .../bitcoin-wallet-snap/locales/ru.json | 15 ++++++++++++++ .../bitcoin-wallet-snap/locales/tl.json | 15 ++++++++++++++ .../bitcoin-wallet-snap/locales/tr.json | 15 ++++++++++++++ .../bitcoin-wallet-snap/locales/vi.json | 15 ++++++++++++++ .../bitcoin-wallet-snap/locales/zh_CN.json | 15 ++++++++++++++ .../bitcoin-wallet-snap/messages.json | 15 ++++++++++++++ .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- .../unified-send-flow/UnifiedSendFormView.tsx | 20 ++++++++++--------- 19 files changed, 268 insertions(+), 11 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/locales/de.json b/merged-packages/bitcoin-wallet-snap/locales/de.json index 9c339aa5..127d28ba 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/de.json +++ b/merged-packages/bitcoin-wallet-snap/locales/de.json @@ -195,6 +195,21 @@ }, "confirmation.signMessage.message": { "message": "Nachricht" + }, + "confirmation.estimatedChanges": { + "message": "Geschätzte Änderungen" + }, + "confirmation.estimatedChanges.send": { + "message": "Sie senden" + }, + "confirmation.confirmButton": { + "message": "Bestätigen" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Transaktionsanfrage" + }, + "confirmation.requestOrigin": { + "message": "Anfrage von" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/el.json b/merged-packages/bitcoin-wallet-snap/locales/el.json index 98172923..672318ab 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/el.json +++ b/merged-packages/bitcoin-wallet-snap/locales/el.json @@ -195,6 +195,21 @@ }, "confirmation.signMessage.message": { "message": "Μήνυμα" + }, + "confirmation.estimatedChanges": { + "message": "Εκτιμώμενες αλλαγές" + }, + "confirmation.estimatedChanges.send": { + "message": "Θα στείλετε" + }, + "confirmation.confirmButton": { + "message": "Επιβεβαίωση" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Αίτημα συναλλαγής" + }, + "confirmation.requestOrigin": { + "message": "Ζητήθηκε από" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/en.json b/merged-packages/bitcoin-wallet-snap/locales/en.json index 6d6d3bd4..a4e7e54a 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/en.json +++ b/merged-packages/bitcoin-wallet-snap/locales/en.json @@ -198,6 +198,21 @@ }, "confirmation.signMessage.message": { "message": "Message" + }, + "confirmation.estimatedChanges": { + "message": "Estimated changes" + }, + "confirmation.estimatedChanges.send": { + "message": "You send" + }, + "confirmation.confirmButton": { + "message": "Confirm" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Transaction request" + }, + "confirmation.requestOrigin": { + "message": "Request from" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/es.json b/merged-packages/bitcoin-wallet-snap/locales/es.json index bb82522b..0079d88d 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/es.json +++ b/merged-packages/bitcoin-wallet-snap/locales/es.json @@ -195,6 +195,21 @@ }, "confirmation.signMessage.message": { "message": "Mensaje" + }, + "confirmation.estimatedChanges": { + "message": "Cambios estimados" + }, + "confirmation.estimatedChanges.send": { + "message": "Usted envía" + }, + "confirmation.confirmButton": { + "message": "Confirmar" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Solicitud de transacción" + }, + "confirmation.requestOrigin": { + "message": "Solicitud de" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/es_419.json b/merged-packages/bitcoin-wallet-snap/locales/es_419.json index d9bfc653..9b75a07a 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/es_419.json +++ b/merged-packages/bitcoin-wallet-snap/locales/es_419.json @@ -195,6 +195,21 @@ }, "confirmation.signMessage.message": { "message": "Mensaje" + }, + "confirmation.estimatedChanges": { + "message": "Cambios estimados" + }, + "confirmation.estimatedChanges.send": { + "message": "Usted envía" + }, + "confirmation.confirmButton": { + "message": "Confirmar" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Solicitud de transacción" + }, + "confirmation.requestOrigin": { + "message": "Solicitud de" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/fr.json b/merged-packages/bitcoin-wallet-snap/locales/fr.json index b7e7589d..21c549a7 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/fr.json +++ b/merged-packages/bitcoin-wallet-snap/locales/fr.json @@ -195,6 +195,21 @@ }, "confirmation.signMessage.message": { "message": "Message" + }, + "confirmation.estimatedChanges": { + "message": "Changements estimés" + }, + "confirmation.estimatedChanges.send": { + "message": "Vous envoyez" + }, + "confirmation.confirmButton": { + "message": "Confirmer" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Demande de transaction" + }, + "confirmation.requestOrigin": { + "message": "Demande de la part de" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/hi.json b/merged-packages/bitcoin-wallet-snap/locales/hi.json index 47f9ea9d..dbc3ba3e 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/hi.json +++ b/merged-packages/bitcoin-wallet-snap/locales/hi.json @@ -195,6 +195,21 @@ }, "confirmation.signMessage.message": { "message": "संदेश" + }, + "confirmation.estimatedChanges": { + "message": "अनुमानित बदलाव" + }, + "confirmation.estimatedChanges.send": { + "message": "आप भेजते हैं" + }, + "confirmation.confirmButton": { + "message": "कन्फर्म करें" + }, + "confirmation.signAndSendTransaction.title": { + "message": "ट्रांसेक्शन रिक्वेस्ट" + }, + "confirmation.requestOrigin": { + "message": "इनसे मिला अनुरोध" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/id.json b/merged-packages/bitcoin-wallet-snap/locales/id.json index c853b941..67997e2b 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/id.json +++ b/merged-packages/bitcoin-wallet-snap/locales/id.json @@ -195,6 +195,21 @@ }, "confirmation.signMessage.message": { "message": "Pesan" + }, + "confirmation.estimatedChanges": { + "message": "Estimasi perubahan" + }, + "confirmation.estimatedChanges.send": { + "message": "Anda mengirim" + }, + "confirmation.confirmButton": { + "message": "Konfirmasikan" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Permintaan transaksi" + }, + "confirmation.requestOrigin": { + "message": "Permintaan dari" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/ja.json b/merged-packages/bitcoin-wallet-snap/locales/ja.json index 8bbd2f1b..1232b8e5 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/ja.json +++ b/merged-packages/bitcoin-wallet-snap/locales/ja.json @@ -195,6 +195,21 @@ }, "confirmation.signMessage.message": { "message": "メッセージ" + }, + "confirmation.estimatedChanges": { + "message": "予測される増減額" + }, + "confirmation.estimatedChanges.send": { + "message": "送金額" + }, + "confirmation.confirmButton": { + "message": "確定" + }, + "confirmation.signAndSendTransaction.title": { + "message": "トランザクションリクエスト" + }, + "confirmation.requestOrigin": { + "message": "要求元" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/ko.json b/merged-packages/bitcoin-wallet-snap/locales/ko.json index b0c18816..6c7fd74a 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/ko.json +++ b/merged-packages/bitcoin-wallet-snap/locales/ko.json @@ -165,6 +165,21 @@ }, "confirmation.signMessage.message": { "message": "메시지" + }, + "confirmation.estimatedChanges": { + "message": "예상 변동 사항" + }, + "confirmation.estimatedChanges.send": { + "message": "전송:" + }, + "confirmation.confirmButton": { + "message": "컨펌" + }, + "confirmation.signAndSendTransaction.title": { + "message": "트랜잭션 요청" + }, + "confirmation.requestOrigin": { + "message": "요청자:" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/pt.json b/merged-packages/bitcoin-wallet-snap/locales/pt.json index 56c15f61..6d3191a4 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/pt.json +++ b/merged-packages/bitcoin-wallet-snap/locales/pt.json @@ -165,6 +165,21 @@ }, "confirmation.signMessage.message": { "message": "Mensagem" + }, + "confirmation.estimatedChanges": { + "message": "Alterações estimadas" + }, + "confirmation.estimatedChanges.send": { + "message": "Você envia" + }, + "confirmation.confirmButton": { + "message": "Confirmar" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Solicitação de transação" + }, + "confirmation.requestOrigin": { + "message": "Solicitação de" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/ru.json b/merged-packages/bitcoin-wallet-snap/locales/ru.json index 00151dcd..e5604ceb 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/ru.json +++ b/merged-packages/bitcoin-wallet-snap/locales/ru.json @@ -165,6 +165,21 @@ }, "confirmation.signMessage.message": { "message": "Сообщение" + }, + "confirmation.estimatedChanges": { + "message": "Прогнозируемые изменения" + }, + "confirmation.estimatedChanges.send": { + "message": "Вы отправляете" + }, + "confirmation.confirmButton": { + "message": "Подтвердить" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Запрос транзакции" + }, + "confirmation.requestOrigin": { + "message": "Запрос от" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/tl.json b/merged-packages/bitcoin-wallet-snap/locales/tl.json index bb90b085..4b6b1829 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/tl.json +++ b/merged-packages/bitcoin-wallet-snap/locales/tl.json @@ -165,6 +165,21 @@ }, "confirmation.signMessage.message": { "message": "Mensahe" + }, + "confirmation.estimatedChanges": { + "message": "Tinatayang mga pagbabago" + }, + "confirmation.estimatedChanges.send": { + "message": "Nagpadala ka ng" + }, + "confirmation.confirmButton": { + "message": "Kumpirmahin" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Hiling na transaksyon" + }, + "confirmation.requestOrigin": { + "message": "Kahilingan mula sa/kay" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/tr.json b/merged-packages/bitcoin-wallet-snap/locales/tr.json index 2e843791..280a7a67 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/tr.json +++ b/merged-packages/bitcoin-wallet-snap/locales/tr.json @@ -165,6 +165,21 @@ }, "confirmation.signMessage.message": { "message": "Mesaj" + }, + "confirmation.estimatedChanges": { + "message": "Tahmini değişiklikler" + }, + "confirmation.estimatedChanges.send": { + "message": "Gönderdiğiniz" + }, + "confirmation.confirmButton": { + "message": "Onayla" + }, + "confirmation.signAndSendTransaction.title": { + "message": "İşlem talebi" + }, + "confirmation.requestOrigin": { + "message": "Talebi gönderen" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/vi.json b/merged-packages/bitcoin-wallet-snap/locales/vi.json index ae6659b1..ef2f934a 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/vi.json +++ b/merged-packages/bitcoin-wallet-snap/locales/vi.json @@ -165,6 +165,21 @@ }, "confirmation.signMessage.message": { "message": "Tin nhắn" + }, + "confirmation.estimatedChanges": { + "message": "Thay đổi ước tính" + }, + "confirmation.estimatedChanges.send": { + "message": "Bạn gửi" + }, + "confirmation.confirmButton": { + "message": "Xác nhận" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Yêu cầu giao dịch" + }, + "confirmation.requestOrigin": { + "message": "Yêu cầu từ" } } } diff --git a/merged-packages/bitcoin-wallet-snap/locales/zh_CN.json b/merged-packages/bitcoin-wallet-snap/locales/zh_CN.json index c8f4a09f..32d90701 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/zh_CN.json +++ b/merged-packages/bitcoin-wallet-snap/locales/zh_CN.json @@ -165,6 +165,21 @@ }, "confirmation.signMessage.message": { "message": "消息" + }, + "confirmation.estimatedChanges": { + "message": "预计变化" + }, + "confirmation.estimatedChanges.send": { + "message": "您发送" + }, + "confirmation.confirmButton": { + "message": "确认" + }, + "confirmation.signAndSendTransaction.title": { + "message": "交易请求" + }, + "confirmation.requestOrigin": { + "message": "请求来自" } } } diff --git a/merged-packages/bitcoin-wallet-snap/messages.json b/merged-packages/bitcoin-wallet-snap/messages.json index 546fcd28..d25ad7be 100644 --- a/merged-packages/bitcoin-wallet-snap/messages.json +++ b/merged-packages/bitcoin-wallet-snap/messages.json @@ -196,5 +196,20 @@ }, "confirmation.signMessage.message": { "message": "Message" + }, + "confirmation.estimatedChanges": { + "message": "Estimated changes" + }, + "confirmation.estimatedChanges.send": { + "message": "You send" + }, + "confirmation.confirmButton": { + "message": "Confirm" + }, + "confirmation.signAndSendTransaction.title": { + "message": "Transaction request" + }, + "confirmation.requestOrigin": { + "message": "Request from" } } diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index fe5dacfe..9bfbb2f1 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.1.0", + "version": "1.2.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "nX6/V6oSl1oKs1sy9BSZVV/Too21iVnVxGE0MNzGW28=", + "shasum": "MQOJv88tOZwmpdOwk0QlMDBRLrUqAp+ddeudT6+pF24=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx index 3c6dc2cc..5b0df328 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx @@ -44,13 +44,15 @@ export const UnifiedSendFormView: SnapComponent = ({ return ( - {t('Transaction request')} + + {t('confirmation.signAndSendTransaction.title')} +
- + - + {displayAmount(BigInt(amount), currency)} @@ -61,10 +63,10 @@ export const UnifiedSendFormView: SnapComponent = ({
- + MetaMask - + {isValidSnapLinkProtocol(explorerUrl) ? (
@@ -73,10 +75,10 @@ export const UnifiedSendFormView: SnapComponent = ({
)} - + {network} - + = ({
From aa48a3d9d7d4197fbd08fdf5488774c446f2dc5f Mon Sep 17 00:00:00 2001 From: orestis Date: Thu, 9 Oct 2025 15:19:07 +0100 Subject: [PATCH 293/362] fix: remove account address snap tracking (#539) --- .../integration-test/client-request.test.ts | 8 ++------ .../integration-test/cron-sync.test.ts | 4 +--- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts | 4 +--- 4 files changed, 5 insertions(+), 13 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts index 8e78ed60..cb93a856 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts @@ -69,10 +69,8 @@ describe('OnClientRequestHandler', () => { expect(response).toTrackEvent({ event: TrackingSnapEvent.TransactionSubmitted, properties: { - account_address: account.address, - account_id: account.id, account_type: BtcAccountType.P2wpkh, - chain_id: BtcScope.Regtest, + chain_id_caip: BtcScope.Regtest, message: 'Snap transaction submitted', origin: ORIGIN, tx_id: transactionId, @@ -95,9 +93,7 @@ describe('OnClientRequestHandler', () => { properties: { origin: 'cron', message: 'Snap transaction finalized', - chain_id: BtcScope.Regtest, - account_id: account.id, - account_address: account.address, + chain_id_caip: BtcScope.Regtest, account_type: BtcAccountType.P2wpkh, tx_id: transactionId, }, diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts index 5080afb0..d5254153 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts @@ -71,9 +71,7 @@ describe('CronHandler', () => { properties: { origin: 'cron', message: 'Snap transaction received', - chain_id: BtcScope.Regtest, - account_id: account.id, - account_address: account.address, + chain_id_caip: BtcScope.Regtest, account_type: BtcAccountType.P2wpkh, tx_id: txid, }, diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 9bfbb2f1..313ab8fc 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "MQOJv88tOZwmpdOwk0QlMDBRLrUqAp+ddeudT6+pF24=", + "shasum": "ok99LhCLjSmiSZr9VpQA2B+43LUZJjjTRma7gJ3clEU=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 6fc21433..f7a47e16 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -249,9 +249,7 @@ export class SnapClientAdapter implements SnapClient { properties: { origin, message: createMessage(), - chain_id: networkToScope[account.network], - account_id: account.id, - account_address: account.publicAddress.toString(), + chain_id_caip: networkToScope[account.network], account_type: addressTypeToCaip[account.addressType], tx_id: tx.txid.toString(), }, From 26d7c4a6bf43f4b6eed9acdc7026fb8bada55d98 Mon Sep 17 00:00:00 2001 From: orestis Date: Thu, 9 Oct 2025 17:18:23 +0100 Subject: [PATCH 294/362] fix: confirmSend UI modal (#541) --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/entities/snap.ts | 8 ++ .../src/infra/SnapClientAdapter.ts | 9 ++ .../infra/jsx/components/AssetIconInline.tsx | 30 ++++++ .../src/infra/jsx/components/index.ts | 1 + .../src/infra/jsx/images/bitcoin-inline.svg | 12 +++ .../unified-send-flow/UnifiedSendFormView.tsx | 92 +++++++++++++------ .../src/use-cases/SendFlowUseCases.test.ts | 6 +- .../src/use-cases/SendFlowUseCases.ts | 2 +- 9 files changed, 127 insertions(+), 35 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/AssetIconInline.tsx create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin-inline.svg diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 313ab8fc..2a54a101 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "ok99LhCLjSmiSZr9VpQA2B+43LUZJjjTRma7gJ3clEU=", + "shasum": "6Tzrjzyccj6Ane17A/FBIUEVNaB6pgoUEWRTQUbpB4o=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index 9c059669..8e259510 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -148,6 +148,14 @@ export type SnapClient = { */ displayConfirmation(id: string): Promise; + /** + * Display a User Prompt Dialog. + * + * @param id - The interface id. + * @returns the resolved value or null. + */ + displayUserPrompt(id: string): Promise; + /** * Resolve a User Interface. * diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index f7a47e16..7fb0faff 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -157,6 +157,15 @@ export class SnapClientAdapter implements SnapClient { })) as unknown as ResolveType; } + async displayUserPrompt( + id: string, + ): Promise { + return (await snap.request({ + method: 'snap_dialog', + params: { id }, + })) as unknown as ResolveType; + } + async getInterfaceState(id: string): Promise { return snap.request({ method: 'snap_getInterfaceState', diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/AssetIconInline.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/AssetIconInline.tsx new file mode 100644 index 00000000..5a542278 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/AssetIconInline.tsx @@ -0,0 +1,30 @@ +import type { Network } from '@metamask/bitcoindevkit'; +import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; +import { Image } from '@metamask/snaps-sdk/jsx'; + +import btcIconInline from '../images/bitcoin-inline.svg'; +import signetIcon from '../images/signet.svg'; +import testnetIcon from '../images/testnet.svg'; + +const networkToIcon: Record = { + bitcoin: btcIconInline, + testnet: testnetIcon, + testnet4: testnetIcon, + signet: signetIcon, + regtest: signetIcon, +}; + +export type AssetIconInlineProps = { + network: Network; + variant?: 'asset' | 'network'; +}; + +export const AssetIconInline: SnapComponent = ({ + network, + variant = 'asset', +}) => ( + +); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/index.ts index c0bdcc6a..9d729bef 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/components/index.ts @@ -1,3 +1,4 @@ export * from './SendForm'; export * from './HeadingWithReturn'; export * from './AssetIcon'; +export * from './AssetIconInline'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin-inline.svg b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin-inline.svg new file mode 100644 index 00000000..aad90223 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/images/bitcoin-inline.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx index 5b0df328..6edc75ba 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx @@ -4,9 +4,7 @@ import { Address, Heading, Link, - Row, Section, - Value, Box, Button, Container, @@ -15,7 +13,8 @@ import { } from '@metamask/snaps-sdk/jsx'; import type { Messages, ConfirmSendFormContext } from '../../../entities'; -import { networkToCurrencyUnit, ConfirmationEvent } from '../../../entities'; +import { ConfirmationEvent, networkToCurrencyUnit } from '../../../entities'; +import { AssetIconInline } from '../components'; import { displayAmount, displayCaip10, @@ -44,29 +43,51 @@ export const UnifiedSendFormView: SnapComponent = ({ return ( - - {t('confirmation.signAndSendTransaction.title')} - + + {null} + + {t('confirmation.signAndSendTransaction.title')} + + {null} +
- - - - - - {displayAmount(BigInt(amount), currency)} + + + {t('confirmation.estimatedChanges')} + + + + + {t('confirmation.estimatedChanges.send')} + + + + + -{displayAmount(BigInt(amount), currency).replace(' BTC', '')} + + + BTC + {displayExchangeAmount(BigInt(amount), exchangeRate)} - +
- + + + {t('confirmation.requestOrigin')} + MetaMask - - + + {null} + + + {t('confirmation.account')} + {isValidSnapLinkProtocol(explorerUrl) ? (
@@ -74,24 +95,35 @@ export const UnifiedSendFormView: SnapComponent = ({ ) : (
)} - - - {network} - - - - + + {null} + + + {t('network')} + + + + {network} + + + {null} + + + {t('networkFee')} + + + + {displayExchangeAmount(fee, exchangeRate)} + + {displayAmount(fee, currency)} + +
- - +
diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 34b4e96f..70c6d9b9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -828,7 +828,7 @@ describe('SendFlowUseCases', () => { mockSendFlowRepository.insertConfirmSendForm.mockResolvedValue( 'interface-id', ); - mockSnapClient.displayConfirmation.mockResolvedValue(true); + mockSnapClient.displayUserPrompt.mockResolvedValue(true); mockAccountUseCases.signPsbt.mockResolvedValue({ psbt: mockSignedPsbt, } as any); @@ -854,7 +854,7 @@ describe('SendFlowUseCases', () => { ); expect(mockTxBuilder.finish).toHaveBeenCalled(); expect(mockSendFlowRepository.insertConfirmSendForm).toHaveBeenCalled(); - expect(mockSnapClient.displayConfirmation).toHaveBeenCalledWith( + expect(mockSnapClient.displayUserPrompt).toHaveBeenCalledWith( 'interface-id', ); expect(mockAccountUseCases.signPsbt).toHaveBeenCalledWith( @@ -895,7 +895,7 @@ describe('SendFlowUseCases', () => { }); it('throws error when user cancels confirmation', async () => { - mockSnapClient.displayConfirmation.mockResolvedValue(false); + mockSnapClient.displayUserPrompt.mockResolvedValue(false); await expect( useCases.confirmSendFlow(mockAccount, amount, toAddress), diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index 27326772..05503624 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -129,7 +129,7 @@ export class SendFlowUseCases { // Blocks and waits for user actions. const confirmed = - await this.#snapClient.displayConfirmation(interfaceId); + await this.#snapClient.displayUserPrompt(interfaceId); if (!confirmed) { throw new UserActionError('User canceled the confirmation'); From 96d970aa63c7d679717ae8dcc9b086b0da90d57d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Oct 2025 19:26:07 +0200 Subject: [PATCH 295/362] 1.3.0 (#542) * 1.3.0 * doc: update CHANGELOG.md --------- Co-authored-by: github-actions Co-authored-by: orestis --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 13 +++++++++++-- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 21fcd043..2c861076 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] + +### Fixed + +- Fix confirmSend UI modal ([#541](https://github.com/MetaMask/snap-bitcoin-wallet/pull/541)) +- Remove account address snap tracking ([#539](https://github.com/MetaMask/snap-bitcoin-wallet/pull/539)) +- Update locales ([#540](https://github.com/MetaMask/snap-bitcoin-wallet/pull/540)) + ## [1.2.0] ### Added @@ -214,7 +222,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Icons as base64 ([#422](https://github.com/MetaMask/snap-bitcoin-wallet/pull/422)) - Synchronize Bitcoin accounts in a cron job ([#407](https://github.com/MetaMask/snap-bitcoin-wallet/pull/407)) - Integration tests ([#382](https://github.com/MetaMask/snap-bitcoin-wallet/pull/382)) -- Translations ([#403]https://github.com/MetaMask/snap-bitcoin-wallet/pull/403) +- Translations ([#403](https://github.com/MetaMask/snap-bitcoin-wallet/pull/403)) ### Changed @@ -477,7 +485,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.2.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.3.0...HEAD +[1.3.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v0.19.3...v1.0.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 8bdb644c..35490f1c 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.2.0", + "version": "1.3.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From d879d207a5b30f1f9cf49e40afdcda5939f71dc9 Mon Sep 17 00:00:00 2001 From: orestis Date: Fri, 17 Oct 2025 11:38:02 +0100 Subject: [PATCH 296/362] feat: cache spot prices for consecutive calls (#544) --- .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../bitcoin-wallet-snap/src/index.ts | 7 +- .../bitcoin-wallet-snap/src/store/ICache.ts | 124 ++++ .../src/store/InMemoryCache.test.ts | 528 ++++++++++++++++++ .../src/store/InMemoryCache.ts | 171 ++++++ .../src/use-cases/AssetsUseCases.test.ts | 79 ++- .../src/use-cases/AssetsUseCases.ts | 43 +- 7 files changed, 941 insertions(+), 15 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/store/ICache.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/store/InMemoryCache.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/store/InMemoryCache.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 2a54a101..2c4d4f56 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.2.0", + "version": "1.3.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "6Tzrjzyccj6Ane17A/FBIUEVNaB6pgoUEWRTQUbpB4o=", + "shasum": "wrFG8eeZC+ssoW/1r4fzoxoYQ8zCJrcoK51fHzNcUNo=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index c04adb32..6382232e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -29,6 +29,7 @@ import { LocalTranslatorAdapter, } from './infra'; import { BdkAccountRepository, JSXSendFlowRepository } from './store'; +import { InMemoryCache } from './store/InMemoryCache'; import { JSXConfirmationRepository } from './store/JSXConfirmationRepository'; import { AccountUseCases, @@ -75,7 +76,11 @@ const sendFlowUseCases = new SendFlowUseCases( Config.fallbackFeeRate, Config.ratesRefreshInterval, ); -const assetsUseCases = new AssetsUseCases(logger, assetRatesClient); +const assetsUseCases = new AssetsUseCases( + logger, + assetRatesClient, + new InMemoryCache(), +); const confirmationUseCases = new ConfirmationUseCases(logger, snapClient); // Application layer diff --git a/merged-packages/bitcoin-wallet-snap/src/store/ICache.ts b/merged-packages/bitcoin-wallet-snap/src/store/ICache.ts new file mode 100644 index 00000000..3fea3ef8 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/ICache.ts @@ -0,0 +1,124 @@ +import type { Json } from '@metamask/snaps-sdk'; + +/** + * A primitive value that can be serialized to JSON using the `serialize` function. + */ +export type Serializable = + | Json + | undefined + | null + | bigint + | BigNumber + | Uint8Array + | Serializable[] + | { + [prop: string]: Serializable; + }; + +export type TimestampMilliseconds = number; + +/** + * A single cache entry. + */ +export type CacheEntry = { + value: Serializable; + expiresAt: TimestampMilliseconds; +}; + +/** + * Interface for a generic cache implementation. + * + * @template TValue - The type of values stored in the cache + */ +export type ICache = { + /** + * Retrieves a value from the cache by key. + * + * @param key - The key to retrieve + * @returns The value if found, undefined if not found + */ + get(key: string): Promise; + + /** + * Stores a value in the cache with an optional TTL. + * - If a value is undefined, it will not be stored in the cache. + * - If a value is null, it will be stored in the cache. + * + * @param key - The key to store the value under + * @param value - The value to store + * @param ttlMilliseconds - Optional time-to-live in milliseconds. If not provided, the value will not expire. + * @throws Error if any entry's ttlMilliseconds is not a number, is negative, or is greater than 2^53 - 1 + */ + set(key: string, value: TValue, ttlMilliseconds?: number): Promise; + + /** + * Removes a value from the cache. + * + * @param key - The key to remove + * @returns true if the key was found and removed, false otherwise + */ + delete(key: string): Promise; + + /** + * Removes all values from the cache. + */ + clear(): Promise; + + /** + * Checks if a key exists in the cache. + * + * @param key - The key to check + * @returns true if the key exists, false otherwise + */ + has(key: string): Promise; + + /** + * Returns all keys currently in the cache. + * + * @returns Array of keys + */ + keys(): Promise; + + /** + * Returns the number of items in the cache. + * + * @returns The number of items + */ + size(): Promise; + + /** + * Retrieves a value from the cache without affecting its TTL or last accessed time. + * + * @param key - The key to peek at + * @returns The value if found, undefined if not found + */ + peek(key: string): Promise; + + /** + * Retrieves multiple values from the cache in a single operation. + * + * @param keys - Array of keys to retrieve + * @returns Object mapping keys to their values (or undefined if not found) + */ + mget(keys: string[]): Promise>; + + /** + * Stores multiple values in the cache in a single operation. + * - If a value is undefined, it will not be stored in the cache. + * - If a value is null, it will be stored in the cache. + * + * @param entries - Array of entries to store, each with key, value, and optional TTL (if not provided, the value will not expire) + * @throws Error if any entry's ttlMilliseconds is not a number, is negative, or is greater than 2^53 - 1 + */ + mset( + entries: { key: string; value: TValue; ttlMilliseconds?: number }[], + ): Promise; + + /** + * Removes multiple values from the cache. + * + * @param keys - Array of keys to remove + * @returns An object mapping each key to a boolean indicating whether it was found and removed + */ + mdelete(keys: string[]): Promise>; +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/store/InMemoryCache.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/InMemoryCache.test.ts new file mode 100644 index 00000000..a0e40d5f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/InMemoryCache.test.ts @@ -0,0 +1,528 @@ +import { InMemoryCache } from './InMemoryCache'; + +describe('InMemoryCache', () => { + let cache: InMemoryCache; + + beforeEach(() => { + cache = new InMemoryCache(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('set and get', () => { + it('stores and retrieves a value', async () => { + await cache.set('key1', 'value1'); + const result = await cache.get('key1'); + + expect(result).toBe('value1'); + }); + + it('stores and retrieves complex objects', async () => { + const complexObject = { + nested: { data: [1, 2, 3] }, + string: 'test', + number: 42, + }; + + await cache.set('complex', complexObject); + const result = await cache.get('complex'); + + expect(result).toStrictEqual(complexObject); + }); + + it('stores null values', async () => { + await cache.set('null-key', null); + const result = await cache.get('null-key'); + + expect(result).toBeNull(); + }); + + it('returns undefined for non-existent keys', async () => { + const result = await cache.get('non-existent'); + + expect(result).toBeUndefined(); + }); + + it('overwrites existing values', async () => { + await cache.set('key1', 'value1'); + await cache.set('key1', 'value2'); + const result = await cache.get('key1'); + + expect(result).toBe('value2'); + }); + }); + + describe('TTL (Time To Live)', () => { + it('stores value with custom TTL', async () => { + await cache.set('key1', 'value1', 1000); + const result = await cache.get('key1'); + + expect(result).toBe('value1'); + }); + + it('expires value after TTL', async () => { + jest.useFakeTimers(); + const ttl = 1000; + + await cache.set('key1', 'value1', ttl); + + // Before expiration + expect(await cache.get('key1')).toBe('value1'); + + // After expiration + jest.advanceTimersByTime(ttl + 1); + expect(await cache.get('key1')).toBeUndefined(); + }); + + it('uses default TTL when not specified', async () => { + await cache.set('key1', 'value1'); + const result = await cache.get('key1'); + + expect(result).toBe('value1'); + }); + + it('throws error for negative TTL', async () => { + await expect(cache.set('key1', 'value1', -100)).rejects.toThrow( + 'TTL must be positive', + ); + }); + + it('throws error for non-numeric TTL', async () => { + await expect( + cache.set('key1', 'value1', 'invalid' as any), + ).rejects.toThrow('TTL must be a number'); + }); + + it('throws error for TTL greater than MAX_SAFE_INTEGER', async () => { + await expect( + cache.set('key1', 'value1', Number.MAX_SAFE_INTEGER + 1), + ).rejects.toThrow('TTL must be less than 2^53 - 1'); + }); + + it('handles TTL of zero', async () => { + jest.useFakeTimers(); + + await cache.set('key1', 'value1', 0); + + // Should be immediately expired + jest.advanceTimersByTime(1); + expect(await cache.get('key1')).toBeUndefined(); + }); + + it('clamps TTL to MAX_SAFE_INTEGER to prevent overflow', async () => { + const largeButValidTTL = Number.MAX_SAFE_INTEGER; + await cache.set('key1', 'value1', largeButValidTTL); + const result = await cache.get('key1'); + + expect(result).toBe('value1'); + }); + }); + + describe('delete', () => { + it('deletes an existing key', async () => { + await cache.set('key1', 'value1'); + const deleted = await cache.delete('key1'); + + expect(deleted).toBe(true); + expect(await cache.get('key1')).toBeUndefined(); + }); + + it('returns false when deleting non-existent key', async () => { + const deleted = await cache.delete('non-existent'); + + expect(deleted).toBe(false); + }); + }); + + describe('has', () => { + it('returns true for existing keys', async () => { + await cache.set('key1', 'value1'); + + expect(await cache.has('key1')).toBe(true); + }); + + it('returns false for non-existent keys', async () => { + expect(await cache.has('non-existent')).toBe(false); + }); + + it('returns false and removes expired keys', async () => { + jest.useFakeTimers(); + await cache.set('key1', 'value1', 1000); + + expect(await cache.has('key1')).toBe(true); + + jest.advanceTimersByTime(1001); + + expect(await cache.has('key1')).toBe(false); + // Verify the key was actually removed + expect(await cache.get('key1')).toBeUndefined(); + }); + }); + + describe('clear', () => { + it('removes all entries', async () => { + await cache.set('key1', 'value1'); + await cache.set('key2', 'value2'); + await cache.set('key3', 'value3'); + + await cache.clear(); + + expect(await cache.get('key1')).toBeUndefined(); + expect(await cache.get('key2')).toBeUndefined(); + expect(await cache.get('key3')).toBeUndefined(); + expect(await cache.size()).toBe(0); + }); + + it('works on empty cache', async () => { + await cache.clear(); + expect(await cache.size()).toBe(0); + }); + }); + + describe('keys', () => { + it('returns all keys', async () => { + await cache.set('key1', 'value1'); + await cache.set('key2', 'value2'); + await cache.set('key3', 'value3'); + + const keys = await cache.keys(); + + expect(keys).toHaveLength(3); + expect(keys).toContain('key1'); + expect(keys).toContain('key2'); + expect(keys).toContain('key3'); + }); + + it('returns empty array for empty cache', async () => { + const keys = await cache.keys(); + + expect(keys).toStrictEqual([]); + }); + + it('excludes expired keys', async () => { + jest.useFakeTimers(); + await cache.set('key1', 'value1', 1000); + await cache.set('key2', 'value2', 2000); + await cache.set('key3', 'value3', 3000); + + jest.advanceTimersByTime(1500); + + const keys = await cache.keys(); + + expect(keys).toHaveLength(2); + expect(keys).not.toContain('key1'); + expect(keys).toContain('key2'); + expect(keys).toContain('key3'); + }); + }); + + describe('size', () => { + it('returns the number of entries', async () => { + expect(await cache.size()).toBe(0); + + await cache.set('key1', 'value1'); + expect(await cache.size()).toBe(1); + + await cache.set('key2', 'value2'); + expect(await cache.size()).toBe(2); + + await cache.delete('key1'); + expect(await cache.size()).toBe(1); + }); + + it('excludes expired entries', async () => { + jest.useFakeTimers(); + await cache.set('key1', 'value1', 1000); + await cache.set('key2', 'value2', 2000); + + expect(await cache.size()).toBe(2); + + jest.advanceTimersByTime(1500); + + expect(await cache.size()).toBe(1); + }); + }); + + describe('peek', () => { + it('retrieves value without affecting TTL', async () => { + await cache.set('key1', 'value1'); + const result = await cache.peek('key1'); + + expect(result).toBe('value1'); + }); + + it('returns undefined for non-existent keys', async () => { + const result = await cache.peek('non-existent'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined and removes expired keys', async () => { + jest.useFakeTimers(); + await cache.set('key1', 'value1', 1000); + + expect(await cache.peek('key1')).toBe('value1'); + + jest.advanceTimersByTime(1001); + + expect(await cache.peek('key1')).toBeUndefined(); + // Verify the key was actually removed + expect(await cache.get('key1')).toBeUndefined(); + }); + }); + + describe('mget', () => { + it('retrieves multiple values', async () => { + await cache.set('key1', 'value1'); + await cache.set('key2', 'value2'); + await cache.set('key3', 'value3'); + + const result = await cache.mget(['key1', 'key2', 'key3']); + + expect(result).toStrictEqual({ + key1: 'value1', + key2: 'value2', + key3: 'value3', + }); + }); + + it('returns undefined for non-existent keys', async () => { + await cache.set('key1', 'value1'); + + const result = await cache.mget(['key1', 'key2', 'key3']); + + expect(result).toStrictEqual({ + key1: 'value1', + key2: undefined, + key3: undefined, + }); + }); + + it('handles empty key array', async () => { + const result = await cache.mget([]); + + expect(result).toStrictEqual({}); + }); + + it('excludes expired entries', async () => { + jest.useFakeTimers(); + await cache.set('key1', 'value1', 1000); + await cache.set('key2', 'value2', 2000); + + jest.advanceTimersByTime(1500); + + const result = await cache.mget(['key1', 'key2']); + + expect(result).toStrictEqual({ + key1: undefined, + key2: 'value2', + }); + }); + }); + + describe('mset', () => { + it('stores multiple values', async () => { + await cache.mset([ + { key: 'key1', value: 'value1' }, + { key: 'key2', value: 'value2' }, + { key: 'key3', value: 'value3' }, + ]); + + expect(await cache.get('key1')).toBe('value1'); + expect(await cache.get('key2')).toBe('value2'); + expect(await cache.get('key3')).toBe('value3'); + }); + + it('stores multiple values with different TTLs', async () => { + jest.useFakeTimers(); + await cache.mset([ + { key: 'key1', value: 'value1', ttlMilliseconds: 1000 }, + { key: 'key2', value: 'value2', ttlMilliseconds: 2000 }, + ]); + + jest.advanceTimersByTime(1500); + + expect(await cache.get('key1')).toBeUndefined(); + expect(await cache.get('key2')).toBe('value2'); + }); + + it('skips undefined values', async () => { + await cache.mset([ + { key: 'key1', value: 'value1' }, + { key: 'key2', value: undefined }, + { key: 'key3', value: 'value3' }, + ]); + + expect(await cache.get('key1')).toBe('value1'); + expect(await cache.get('key2')).toBeUndefined(); + expect(await cache.get('key3')).toBe('value3'); + expect(await cache.size()).toBe(2); + }); + + it('stores null values', async () => { + await cache.mset([{ key: 'key1', value: null }]); + + expect(await cache.get('key1')).toBeNull(); + }); + + it('handles empty array', async () => { + await cache.mset([]); + expect(await cache.size()).toBe(0); + }); + + it('handles single entry (delegates to set)', async () => { + await cache.mset([ + { key: 'key1', value: 'value1', ttlMilliseconds: 1000 }, + ]); + + expect(await cache.get('key1')).toBe('value1'); + }); + + it('throws error if any TTL is invalid', async () => { + await expect( + cache.mset([ + { key: 'key1', value: 'value1', ttlMilliseconds: 1000 }, + { key: 'key2', value: 'value2', ttlMilliseconds: -100 }, + ]), + ).rejects.toThrow('TTL must be positive'); + + // Verify no values were set + expect(await cache.get('key1')).toBeUndefined(); + expect(await cache.get('key2')).toBeUndefined(); + }); + }); + + describe('mdelete', () => { + it('deletes multiple keys', async () => { + await cache.set('key1', 'value1'); + await cache.set('key2', 'value2'); + await cache.set('key3', 'value3'); + + const result = await cache.mdelete(['key1', 'key2']); + + expect(result).toStrictEqual({ + key1: true, + key2: true, + }); + expect(await cache.get('key1')).toBeUndefined(); + expect(await cache.get('key2')).toBeUndefined(); + expect(await cache.get('key3')).toBe('value3'); + }); + + it('returns false for non-existent keys', async () => { + await cache.set('key1', 'value1'); + + const result = await cache.mdelete(['key1', 'key2', 'key3']); + + expect(result).toStrictEqual({ + key1: true, + key2: false, + key3: false, + }); + }); + + it('handles empty array', async () => { + const result = await cache.mdelete([]); + + expect(result).toStrictEqual({}); + }); + }); + + describe('edge cases and integration', () => { + it('handles rapid successive operations', async () => { + await cache.set('key1', 'value1'); + await cache.set('key1', 'value2'); + await cache.set('key1', 'value3'); + + expect(await cache.get('key1')).toBe('value3'); + }); + + it('handles large number of entries', async () => { + const entries = Array.from({ length: 1000 }, (_, i) => ({ + key: `key${i}`, + value: `value${i}`, + })); + + await cache.mset(entries); + + expect(await cache.size()).toBe(1000); + expect(await cache.get('key500')).toBe('value500'); + }); + + it('maintains separate entries for similar keys', async () => { + await cache.set('key', 'value1'); + await cache.set('key1', 'value2'); + await cache.set('key10', 'value3'); + + expect(await cache.get('key')).toBe('value1'); + expect(await cache.get('key1')).toBe('value2'); + expect(await cache.get('key10')).toBe('value3'); + }); + + it('handles mixed operations with expiration', async () => { + jest.useFakeTimers(); + + await cache.set('key1', 'value1', 1000); + await cache.set('key2', 'value2', 2000); + await cache.set('key3', 'value3'); + + jest.advanceTimersByTime(1500); + + await cache.delete('key3'); + await cache.set('key4', 'value4'); + + expect(await cache.size()).toBe(2); // key2 and key4 + expect(await cache.keys()).toStrictEqual(['key2', 'key4']); + }); + + it('handles special characters in keys', async () => { + const specialKeys = [ + 'key:with:colons', + 'key.with.dots', + 'key-with-dashes', + 'key_with_underscores', + 'key with spaces', + 'key/with/slashes', + ]; + + for (const key of specialKeys) { + await cache.set(key, `value-${key}`); + } + + for (const key of specialKeys) { + expect(await cache.get(key)).toBe(`value-${key}`); + } + }); + + it('handles bigint values', async () => { + const bigIntValue = BigInt(9007199254740991); + await cache.set('bigint', bigIntValue); + + expect(await cache.get('bigint')).toBe(bigIntValue); + }); + + it('handles Uint8Array values', async () => { + const uint8Array = new Uint8Array([1, 2, 3, 4, 5]); + await cache.set('uint8array', uint8Array); + + const result = await cache.get('uint8array'); + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toStrictEqual(uint8Array); + }); + + it('handles nested arrays and objects', async () => { + const complexValue = { + array: [1, 2, { nested: 'value' }], + object: { a: 1, b: { c: 2 } }, + mixed: [{ x: 1 }, { y: 2 }], + }; + + await cache.set('complex', complexValue); + + expect(await cache.get('complex')).toStrictEqual(complexValue); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/InMemoryCache.ts b/merged-packages/bitcoin-wallet-snap/src/store/InMemoryCache.ts new file mode 100644 index 00000000..73657ce8 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/store/InMemoryCache.ts @@ -0,0 +1,171 @@ +import { assert } from '@metamask/utils'; + +import type { CacheEntry, ICache, Serializable } from './ICache'; + +/** + * A simple in-memory cache implementation supporting TTL (Time To Live) functionality. + * + * WARNINGS: + * - This cache is not persistent and will be lost when the process is restarted. + */ +export class InMemoryCache implements ICache { + readonly #cache: Map = new Map(); + + #validateTtlOrThrow(ttlMilliseconds?: number): void { + if (ttlMilliseconds === undefined) { + return; + } + + if (typeof ttlMilliseconds !== 'number') { + throw new Error('TTL must be a number'); + } + + if (ttlMilliseconds < 0) { + throw new Error('TTL must be positive'); + } + + if (ttlMilliseconds > Number.MAX_SAFE_INTEGER) { + throw new Error('TTL must be less than 2^53 - 1'); + } + } + + #isExpired(cacheEntry: CacheEntry): boolean { + return cacheEntry.expiresAt < Date.now(); + } + + async #cleanupExpiredEntries(): Promise { + const expiredKeys: string[] = []; + for (const [key, entry] of this.#cache.entries()) { + if (this.#isExpired(entry)) { + expiredKeys.push(key); + } + } + await this.mdelete(expiredKeys); + } + + async get(key: string): Promise { + const result = await this.mget([key]); + return result[key]; + } + + async set( + key: string, + value: Serializable, + ttlMilliseconds = Number.MAX_SAFE_INTEGER, + ): Promise { + this.#validateTtlOrThrow(ttlMilliseconds); + + this.#cache.set(key, { + value, + expiresAt: Math.min( + Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + ), + }); + } + + async delete(key: string): Promise { + const result = await this.mdelete([key]); + return result[key] ?? false; + } + + async clear(): Promise { + this.#cache.clear(); + } + + async has(key: string): Promise { + const cacheEntry = this.#cache.get(key); + if (!cacheEntry) { + return false; + } + + if (this.#isExpired(cacheEntry)) { + this.#cache.delete(key); + return false; + } + + return true; + } + + async keys(): Promise { + await this.#cleanupExpiredEntries(); + return Array.from(this.#cache.keys()); + } + + async size(): Promise { + await this.#cleanupExpiredEntries(); + return this.#cache.size; + } + + async peek(key: string): Promise { + const cacheEntry = this.#cache.get(key); + if (!cacheEntry) { + return undefined; + } + + if (this.#isExpired(cacheEntry)) { + this.#cache.delete(key); + return undefined; + } + + return cacheEntry.value; + } + + async mget( + keys: string[], + ): Promise> { + await this.#cleanupExpiredEntries(); + + const result: Record = {}; + + for (const key of keys) { + const cacheEntry = this.#cache.get(key); + if (!cacheEntry) { + result[key] = undefined; + continue; + } + + result[key] = cacheEntry.value; + } + + return result; + } + + async mset( + entries: { key: string; value: Serializable; ttlMilliseconds?: number }[], + ): Promise { + if (entries.length === 0) { + return; + } + + if (entries.length === 1) { + assert(entries[0]); // Enforce type narrowing as TS cannot infer that entries[0] is defined + const { key, value, ttlMilliseconds } = entries[0]; + await this.set(key, value, ttlMilliseconds); + return; + } + + entries.forEach(({ ttlMilliseconds }) => { + this.#validateTtlOrThrow(ttlMilliseconds); + }); + + entries.forEach(({ key, value, ttlMilliseconds }) => { + if (value === undefined) { + return; + } + this.#cache.set(key, { + value, + expiresAt: Math.min( + Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + ), + }); + }); + } + + async mdelete(keys: string[]): Promise> { + return Object.fromEntries( + keys.map((key) => [key, this.#cache.delete(key)]), + ); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts index 58662fb6..ca8819c6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts @@ -4,12 +4,14 @@ import { mock } from 'jest-mock-extended'; import type { AssetRatesClient, Logger, SpotPrice } from '../entities'; import { AssetsUseCases } from './AssetsUseCases'; import { Caip19Asset } from '../handlers/caip'; +import type { ICache, Serializable } from '../store/ICache'; describe('AssetsUseCases', () => { const mockLogger = mock(); const mockAssetRates = mock(); + const mockCache = mock>(); - const useCases = new AssetsUseCases(mockLogger, mockAssetRates); + const useCases = new AssetsUseCases(mockLogger, mockAssetRates, mockCache); describe('getBtcRates', () => { it('returns rate for the known assets and null for unknown', async () => { @@ -32,6 +34,7 @@ describe('AssetsUseCases', () => { }, }); + mockCache.get.mockResolvedValue(undefined); mockAssetRates.spotPrices.mockResolvedValueOnce(mockExchangeRatesETH); mockAssetRates.spotPrices.mockResolvedValueOnce(mockExchangeRatesBTC); mockAssetRates.spotPrices.mockResolvedValueOnce(mockExchangeRatesUSD); @@ -45,24 +48,96 @@ describe('AssetsUseCases', () => { expect(mockAssetRates.spotPrices).toHaveBeenCalled(); expect(result).toStrictEqual([ - ['swift:0/unknown:unknown', null], ['eip155:1/slip44:60', mockExchangeRatesETH], [ 'bip122:000000000019d6689c085ae165831e93/slip44:0', mockExchangeRatesBTC, ], ['swift:0/iso4217:USD', mockExchangeRatesUSD], + ['swift:0/unknown:unknown', null], ]); }); it('propagates an error if spotPrices fails', async () => { const error = new Error('getRates failed'); + mockCache.get.mockResolvedValue(undefined); mockAssetRates.spotPrices.mockRejectedValue(error); await expect(useCases.getRates([Caip19Asset.Testnet])).rejects.toBe( error, ); }); + + it('uses cached values when available', async () => { + const cachedSpotPrice = mock({ + price: 42000, + marketData: { + allTimeHigh: '110000', + }, + }); + + mockCache.get.mockResolvedValue(cachedSpotPrice); + + const result = await useCases.getRates(['swift:0/iso4217:USD']); + + expect(mockCache.get).toHaveBeenCalledWith('spotPrices:usd'); + expect(mockAssetRates.spotPrices).not.toHaveBeenCalled(); + expect(result).toStrictEqual([['swift:0/iso4217:USD', cachedSpotPrice]]); + }); + + it('caches fetched spot prices with 30 second TTL', async () => { + const mockSpotPrice = mock({ + price: 50000, + marketData: { + allTimeHigh: '110000', + }, + }); + + mockCache.get.mockResolvedValue(undefined); + mockAssetRates.spotPrices.mockResolvedValue(mockSpotPrice); + + await useCases.getRates(['swift:0/iso4217:USD']); + + expect(mockCache.set).toHaveBeenCalledWith( + 'spotPrices:usd', + mockSpotPrice, + 30000, + ); + }); + + it('deduplicates requests for assets with the same ticker', async () => { + const mockSpotPrice = mock({ + price: 50000, + marketData: { + allTimeHigh: '110000', + }, + }); + + // First call to cache.get returns undefined (cache miss) + // Subsequent calls return the cached value (cache hit) + mockCache.get + .mockResolvedValueOnce(undefined) + .mockResolvedValue(mockSpotPrice); + mockAssetRates.spotPrices.mockResolvedValue(mockSpotPrice); + + // Multiple assets that map to the same ticker (usd) + const result = await useCases.getRates([ + 'swift:0/iso4217:USD', + 'swift:1/iso4217:USD', + 'swift:2/iso4217:USD', + ]); + + // Should only call spotPrices once for the unique ticker + expect(mockAssetRates.spotPrices).toHaveBeenCalledTimes(1); + expect(mockAssetRates.spotPrices).toHaveBeenCalledWith('usd'); + + // All assets should get the same spot price + expect(result).toStrictEqual([ + ['swift:0/iso4217:USD', mockSpotPrice], + ['swift:1/iso4217:USD', mockSpotPrice], + ['swift:2/iso4217:USD', mockSpotPrice], + ]); + }); }); describe('getPriceIntervals', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts index ffa9d3e7..a27b8675 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts @@ -7,32 +7,55 @@ import type { AssetRatesClient, AssetRate, Logger, + SpotPrice, TimePeriod, } from '../entities'; +import type { ICache, Serializable } from '../store/ICache'; export class AssetsUseCases { readonly #logger: Logger; readonly #assetRates: AssetRatesClient; - constructor(logger: Logger, assetRates: AssetRatesClient) { + readonly #cache: ICache; + + constructor( + logger: Logger, + assetRates: AssetRatesClient, + cache: ICache, + ) { this.#logger = logger; this.#assetRates = assetRates; + this.#cache = cache; } async getRates(assets: CaipAssetType[]): Promise { this.#logger.debug('Fetching BTC rates for: %o', assets); const assetRates: AssetRate[] = []; - await Promise.all( - assets.map(async (asset) => { - const ticker = this.#assetToTicker(asset); - assetRates.push([ - asset, - ticker ? await this.#assetRates.spotPrices(ticker) : null, - ]); - }), - ); + + for (const asset of assets) { + const ticker = this.#assetToTicker(asset); + if (!ticker) { + assetRates.push([asset, null]); + continue; + } + + const cacheKey = `spotPrices:${ticker}`; + const cachedValue = await this.#cache.get(cacheKey); + + let spotPrices: SpotPrice; + if (cachedValue === undefined) { + spotPrices = await this.#assetRates.spotPrices(ticker); + // use 30secs as the ttl since we don't wanna risk stale prices + // just to avoid back to back calls for the same ticker + await this.#cache.set(cacheKey, spotPrices, 30000); + } else { + spotPrices = cachedValue as SpotPrice; + } + + assetRates.push([asset, spotPrices]); + } this.#logger.debug('BTC rates fetched successfully'); return assetRates; From f4f99128d0b6f7cdb0c32a50107cb151e63a446d Mon Sep 17 00:00:00 2001 From: orestis Date: Tue, 21 Oct 2025 14:00:23 +0100 Subject: [PATCH 297/362] feat: add support for `setSelectedAccounts` (#543) --- .../integration-test/client-request.test.ts | 26 ++++++++++++-- .../integration-test/cron-sync.test.ts | 23 +++++++++++-- .../integration-test/keyring-request.test.ts | 20 +++++++++-- .../integration-test/keyring.test.ts | 17 ++++++++-- .../bitcoin-wallet-snap/package.json | 6 ++-- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/CronHandler.test.ts | 25 ++++++++++++-- .../src/handlers/CronHandler.ts | 15 +++++++- .../src/handlers/KeyringHandler.test.ts | 8 ++--- .../src/handlers/KeyringHandler.ts | 34 ++++++++++++++++--- .../src/handlers/validation.ts | 23 +++++++++++++ .../bitcoin-wallet-snap/src/index.ts | 1 + .../src/use-cases/AccountUseCases.test.ts | 13 ------- .../src/use-cases/AccountUseCases.ts | 6 ---- 14 files changed, 175 insertions(+), 44 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts index cb93a856..b02831e6 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts @@ -14,6 +14,7 @@ describe('OnClientRequestHandler', () => { let account: KeyringAccount; let snap: Snap; let blockchain: BlockchainTestUtils; + let createdAccountId: string | undefined; beforeAll(async () => { blockchain = new BlockchainTestUtils(); @@ -23,9 +24,27 @@ describe('OnClientRequestHandler', () => { }, }); - snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); - snap.mockJsonRpc({ method: 'snap_trackError', result: {} }); - snap.mockJsonRpc({ method: 'snap_dialog', result: true }); + // mock snap_manageAccounts to handle different sub-methods + snap.mockJsonRpc((request) => { + if (request.method === 'snap_manageAccounts') { + const params = request.params as Record | undefined; + if (params && params.method === 'getSelectedAccounts') { + return createdAccountId ? [createdAccountId] : []; + } + return null; + } + + if (request.method === 'snap_trackError') { + return {}; + } + + if (request.method === 'snap_dialog') { + return true; + } + + // don't mock other methods + return undefined; + }); const response = await snap.onKeyringRequest({ origin: ORIGIN, @@ -41,6 +60,7 @@ describe('OnClientRequestHandler', () => { if ('result' in response.response) { account = response.response.result as KeyringAccount; + createdAccountId = account.id; } await blockchain.sendToAddress(account.address, 10); diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts index d5254153..0061ada7 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts @@ -11,6 +11,7 @@ const ACCOUNT_INDEX = 2; describe('CronHandler', () => { let snap: Snap; let blockchain: BlockchainTestUtils; + const accountsToSync: string[] = []; beforeAll(async () => { blockchain = new BlockchainTestUtils(); @@ -22,8 +23,24 @@ describe('CronHandler', () => { }); beforeEach(() => { - snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); - snap.mockJsonRpc({ method: 'snap_trackError', result: {} }); + // clear accounts list before each test + accountsToSync.length = 0; + + snap.mockJsonRpc((request) => { + if (request.method === 'snap_manageAccounts') { + const params = request.params as Record | undefined; + if (params && params.method === 'getSelectedAccounts') { + return [...accountsToSync]; + } + return null; + } + + if (request.method === 'snap_trackError') { + return {}; + } + + return undefined; + }); }); it('should synchronize the account', async () => { @@ -55,6 +72,8 @@ describe('CronHandler', () => { const account = (createResponse.response as { result: KeyringAccount }) .result; + accountsToSync.push(account.id); + // send a new transaction to the new account const txid = await blockchain.sendToAddress(account.address, 10); expect(txid).toBeDefined(); diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts index 1d3d6fa4..6b32144c 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts @@ -16,6 +16,7 @@ describe('KeyringRequestHandler', () => { let snap: Snap; let blockchain: BlockchainTestUtils; const origin = 'http://my-dapp.com'; + let createdAccountId: string | undefined; beforeAll(async () => { blockchain = new BlockchainTestUtils(); @@ -25,8 +26,22 @@ describe('KeyringRequestHandler', () => { }, }); - snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); - snap.mockJsonRpc({ method: 'snap_trackError', result: {} }); + snap.mockJsonRpc((request) => { + if (request.method === 'snap_manageAccounts') { + const params = request.params as Record | undefined; + if (params && params.method === 'getSelectedAccounts') { + return createdAccountId ? [createdAccountId] : []; + } + return null; + } + + if (request.method === 'snap_trackError') { + return {}; + } + + // no mocking for other methods + return undefined; + }); const response = await snap.onKeyringRequest({ origin: ORIGIN, @@ -42,6 +57,7 @@ describe('KeyringRequestHandler', () => { if ('result' in response.response) { account = response.response.result as KeyringAccount; + createdAccountId = account.id; } await blockchain.sendToAddress(account.address, 10); diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index f27ee1fe..b4b5df94 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -32,8 +32,21 @@ describe('Keyring', () => { }); beforeEach(() => { - snap.mockJsonRpc({ method: 'snap_manageAccounts', result: {} }); - snap.mockJsonRpc({ method: 'snap_trackError', result: {} }); + snap.mockJsonRpc((request) => { + if (request.method === 'snap_manageAccounts') { + const params = request.params as Record | undefined; + if (params && params.method === 'getSelectedAccounts') { + return []; + } + return null; + } + + if (request.method === 'snap_trackError') { + return {}; + } + + return undefined; + }); }); it('discover accounts successfully', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 35490f1c..564e1083 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -38,11 +38,11 @@ "@jest/globals": "^30.0.5", "@metamask/bitcoindevkit": "^0.1.13", "@metamask/key-tree": "^10.1.1", - "@metamask/keyring-api": "^20.1.1", - "@metamask/keyring-snap-sdk": "^6.0.0", + "@metamask/keyring-api": "^21.1.0", + "@metamask/keyring-snap-sdk": "^7.1.0", "@metamask/slip44": "^4.2.0", "@metamask/snaps-cli": "^8.3.0", - "@metamask/snaps-jest": "^9.4.0", + "@metamask/snaps-jest": "^9.4.1", "@metamask/snaps-sdk": "^9.3.0", "@metamask/utils": "^11.4.2", "bip322-js": "^3.0.0", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 2c4d4f56..26b9c734 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "wrFG8eeZC+ssoW/1r4fzoxoYQ8zCJrcoK51fHzNcUNo=", + "shasum": "sLNVKBvekQkt//cduuuEcYHRjw6oKDcZDq5ZkBBLg4w=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts index 28e44f44..3c24e60d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -1,3 +1,5 @@ +import { getSelectedAccounts } from '@metamask/keyring-snap-sdk'; +import type { SnapsProvider } from '@metamask/snaps-sdk'; import type { JsonRpcRequest } from '@metamask/utils'; import { mock } from 'jest-mock-extended'; @@ -5,18 +7,25 @@ import type { BitcoinAccount, SnapClient } from '../entities'; import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; import { CronHandler, CronMethod } from './CronHandler'; +jest.mock('@metamask/keyring-snap-sdk', () => ({ + getSelectedAccounts: jest.fn(), +})); + describe('CronHandler', () => { const mockSendFlowUseCases = mock(); const mockAccountUseCases = mock(); const mockSnapClient = mock(); + const mockSnap = mock(); const handler = new CronHandler( mockAccountUseCases, mockSendFlowUseCases, mockSnapClient, + mockSnap, ); beforeEach(() => { + jest.clearAllMocks(); mockSnapClient.getClientStatus.mockResolvedValue({ active: true, locked: false, @@ -24,10 +33,17 @@ describe('CronHandler', () => { }); describe('synchronizeAccounts', () => { - const mockAccounts = [mock(), mock()]; + const mockAccounts = [ + mock({ id: 'account-1' }), + mock({ id: 'account-2' }), + ]; const request = { method: 'synchronizeAccounts' } as JsonRpcRequest; - it('synchronizes all accounts', async () => { + it('synchronizes all selected accounts', async () => { + (getSelectedAccounts as jest.Mock).mockResolvedValue([ + 'account-1', + 'account-2', + ]); mockAccountUseCases.list.mockResolvedValue(mockAccounts); await handler.route(request); @@ -41,6 +57,7 @@ describe('CronHandler', () => { it('propagates errors from list', async () => { const error = new Error(); + (getSelectedAccounts as jest.Mock).mockResolvedValue(['account-1']); mockAccountUseCases.list.mockRejectedValue(error); await expect(handler.route(request)).rejects.toThrow(error); @@ -57,6 +74,10 @@ describe('CronHandler', () => { }); it('throws error if some account fails to synchronize', async () => { + (getSelectedAccounts as jest.Mock).mockResolvedValue([ + 'account-1', + 'account-2', + ]); mockAccountUseCases.list.mockResolvedValue(mockAccounts); mockAccountUseCases.synchronize.mockRejectedValue(new Error('error')); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index 1cf6118f..934f5f11 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -1,3 +1,5 @@ +import { getSelectedAccounts } from '@metamask/keyring-snap-sdk'; +import type { SnapsProvider } from '@metamask/snaps-sdk'; import type { JsonRpcRequest } from '@metamask/utils'; import { assert, object, string } from 'superstruct'; @@ -24,14 +26,18 @@ export class CronHandler { readonly #snapClient: SnapClient; + readonly #snap: SnapsProvider; + constructor( accounts: AccountUseCases, sendFlow: SendFlowUseCases, snapClient: SnapClient, + snap: SnapsProvider, ) { this.#accountsUseCases = accounts; this.#sendFlowUseCases = sendFlow; this.#snapClient = snapClient; + this.#snap = snap; } async route(request: JsonRpcRequest): Promise { @@ -56,7 +62,14 @@ export class CronHandler { } async synchronizeAccounts(): Promise { - const accounts = await this.#accountsUseCases.list(); + const selectedAccounts: Set = new Set( + await getSelectedAccounts(this.#snap), + ); + + const accounts = (await this.#accountsUseCases.list()).filter((account) => { + return selectedAccounts.has(account.id); + }); + const results = await Promise.allSettled( accounts.map(async (account) => { return this.#accountsUseCases.synchronize(account, 'cron'); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 4fd9db37..ac143b07 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -183,7 +183,7 @@ describe('KeyringHandler', () => { index: 1, addressType: 'p2wpkh', entropySource: 'entropy2', - synchronize: true, + synchronize: false, }; await handler.createAccount(options); @@ -207,7 +207,7 @@ describe('KeyringHandler', () => { index: 0, addressType, entropySource: 'm', - synchronize: true, + synchronize: false, }; await handler.createAccount(options); @@ -290,7 +290,7 @@ describe('KeyringHandler', () => { index: 5, addressType: 'p2wpkh', entropySource: 'm', - synchronize: true, + synchronize: false, }; await handler.createAccount(options); @@ -359,7 +359,7 @@ describe('KeyringHandler', () => { expect(discovered).toStrictEqual(expect.arrayContaining(expected)); }); - it('filters out accounts that have no transaction history', async () => { + it.skip('filters out accounts that have no transaction history', async () => { const addressTypes = Object.values(BtcAccountType); const totalCombinations = scopes.length * addressTypes.length; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index c6ffd923..1713ff97 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -13,6 +13,7 @@ import { ListAccountsRequestStruct, ListAccountTransactionsRequestStruct, MetaMaskOptionsStruct, + SetSelectedAccountsRequestStruct, SubmitRequestRequestStruct, } from '@metamask/keyring-api'; import type { @@ -60,6 +61,7 @@ import { mapToKeyringAccount, mapToTransaction, } from './mappings'; +import { validateSelectedAccounts } from './validation'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; export const CreateAccountRequest = object({ @@ -143,6 +145,11 @@ export class KeyringHandler implements Keyring { assert(request, SubmitRequestRequestStruct); return this.submitRequest(request.params); } + case `${KeyringRpcMethod.SetSelectedAccounts}`: { + assert(request, SetSelectedAccountsRequestStruct); + await this.setSelectedAccounts(request.params.accounts); + return null; + } default: { throw new InexistentMethodError('Keyring method not supported', { @@ -174,7 +181,7 @@ export class KeyringHandler implements Keyring { index, derivationPath, addressType, - synchronize = true, + synchronize = false, accountNameSuggestion, } = options; @@ -257,10 +264,7 @@ export class KeyringHandler implements Keyring { ), ); - // Return only accounts with history. - return accounts - .filter((account) => account.listTransactions().length > 0) - .map(mapToDiscoveredAccount); + return accounts.map(mapToDiscoveredAccount); } async getAccountBalances( @@ -330,6 +334,26 @@ export class KeyringHandler implements Keyring { return this.#keyringRequest.route(request); } + async setSelectedAccounts(accounts: string[]): Promise { + const accountIdSet = new Set(accounts); + const allAccounts = await this.#accountsUseCases.list(); + + validateSelectedAccounts( + accountIdSet, + allAccounts.map((acc) => acc.id), + ); + + const selectedAccounts = allAccounts.filter((account) => + accountIdSet.has(account.id), + ); + + const scanPromises = selectedAccounts.map(async (account) => + this.#accountsUseCases.fullScan(account), + ); + + await Promise.allSettled(scanPromises); + } + #extractAddressType(path: string): AddressType { const segments = path.split('/'); if (segments.length < 4) { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts index 648915a6..706d4e82 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts @@ -14,6 +14,7 @@ import { } from 'superstruct'; import type { BitcoinAccount, CodifiedError, Logger } from '../entities'; +import { ValidationError } from '../entities'; export enum RpcMethod { StartSendTransactionFlow = 'startSendTransactionFlow', @@ -149,3 +150,25 @@ export function validateAccountBalance( return NO_ERRORS_RESPONSE; } + +/** + * Validates that all account IDs are part of the existing accounts. + * + * @param accountIds - Set of account IDs to validate + * @param existingAccountIds - Array of existing account IDs + * @throws {ValidationError} If any account ID is not part of existing accounts + */ +export function validateSelectedAccounts( + accountIds: Set, + existingAccountIds: string[], +): void { + const isSubset = (first: Set, second: Set): boolean => { + return Array.from(first).every((element) => second.has(element)); + }; + + if (!isSubset(accountIds, new Set(existingAccountIds))) { + throw new ValidationError( + 'Account IDs were not part of existing accounts.', + ); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 6382232e..256140d8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -94,6 +94,7 @@ const cronHandler = new CronHandler( accountsUseCases, sendFlowUseCases, snapClient, + snap, ); const rpcHandler = new RpcHandler(sendFlowUseCases, accountsUseCases, logger); const userInputHandler = new UserInputHandler( diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 127245f5..1c307d87 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -326,7 +326,6 @@ describe('AccountUseCases', () => { discoverParams.network, tAddressType, ); - expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); }, ); @@ -359,7 +358,6 @@ describe('AccountUseCases', () => { tNetwork, discoverParams.addressType, ); - expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); }, ); @@ -393,17 +391,6 @@ describe('AccountUseCases', () => { expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.create).toHaveBeenCalled(); }); - - it('propagates an error if fullScan throws', async () => { - const error = new Error('fullScan failed'); - mockChain.fullScan.mockRejectedValue(error); - - await expect(useCases.discover(discoverParams)).rejects.toBe(error); - - expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); - expect(mockRepository.create).toHaveBeenCalled(); - expect(mockChain.fullScan).toHaveBeenCalled(); - }); }); describe('synchronize', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 3f5cae0f..9f56c353 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -128,12 +128,6 @@ export class AccountUseCases { addressType, ); - await this.#chain.fullScan(newAccount); - - this.#logger.info( - 'Bitcoin account discovered successfully. Request: %o', - req, - ); return newAccount; } From 742dbfd7bd75875df70747016ebaa8a843440496 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 16:49:16 +0100 Subject: [PATCH 298/362] 1.4.0 (#545) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 10 +++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 2c861076..20230afd 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.0] + +### Added + +- Add support for `setSelectedAccounts` ([#543](https://github.com/MetaMask/snap-bitcoin-wallet/pull/543)) +- Cache spot prices for consecutive calls ([#544](https://github.com/MetaMask/snap-bitcoin-wallet/pull/544)) + ## [1.3.0] ### Fixed @@ -485,7 +492,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.3.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.0...HEAD +[1.4.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.3.0...v1.4.0 [1.3.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.0.0...v1.1.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 564e1083..25d9481d 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.3.0", + "version": "1.4.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 7df94b35fa7c9b3cfaaa71c95ace9dd634ebc4e0 Mon Sep 17 00:00:00 2001 From: orestis Date: Thu, 23 Oct 2025 16:27:09 +0100 Subject: [PATCH 299/362] fix: add defensive code for fetching prices (#546) --- .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../src/handlers/AssetsHandler.test.ts | 58 +++++++--- .../src/handlers/AssetsHandler.ts | 38 +++++-- .../bitcoin-wallet-snap/src/index.ts | 1 + .../src/use-cases/AssetsUseCases.test.ts | 28 ++++- .../src/use-cases/AssetsUseCases.ts | 106 +++++++++++++----- 6 files changed, 176 insertions(+), 59 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 26b9c734..e803c731 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.3.0", + "version": "1.4.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "sLNVKBvekQkt//cduuuEcYHRjw6oKDcZDq5ZkBBLg4w=", + "shasum": "6EJ7jL04aFCb7QSjTBVr4yxq9Xjz9VNUvaMvuPhUoLU=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts index 1b6a7942..7a7c0fca 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts @@ -7,16 +7,21 @@ import { mock } from 'jest-mock-extended'; import type { AssetsUseCases } from '../use-cases'; import { AssetsHandler } from './AssetsHandler'; import { Caip19Asset } from './caip'; -import type { SpotPrice } from '../entities'; +import type { Logger, SpotPrice } from '../entities'; describe('AssetsHandler', () => { const mockAssetsUseCases = mock(); + const mockLogger = mock(); const expirationInterval = 60; let handler: AssetsHandler; beforeEach(() => { - handler = new AssetsHandler(mockAssetsUseCases, expirationInterval); + handler = new AssetsHandler( + mockAssetsUseCases, + expirationInterval, + mockLogger, + ); }); describe('lookup', () => { @@ -99,14 +104,21 @@ describe('AssetsHandler', () => { }); }); - it('propagates errors from getRates', async () => { + it('handles null rates gracefully when getRates returns null', async () => { const conversions = [ { from: Caip19Asset.Bitcoin, to: Caip19Asset.Testnet }, ]; - const error = new Error(); - mockAssetsUseCases.getRates.mockRejectedValue(error); + mockAssetsUseCases.getRates.mockResolvedValue([ + [Caip19Asset.Testnet, null], + ]); + + const result = await handler.conversion(conversions); - await expect(handler.conversion(conversions)).rejects.toThrow(error); + expect(result.conversionRates).toStrictEqual({ + [Caip19Asset.Bitcoin]: { + [Caip19Asset.Testnet]: null, + }, + }); }); }); @@ -134,13 +146,22 @@ describe('AssetsHandler', () => { expect(result?.historicalPrice.intervals).toStrictEqual(mockIntervals); }); - it('propagates errors from getPriceIntervals', async () => { - const error = new Error(); + it('returns null and logs warning when getPriceIntervals fails', async () => { + const error = new Error('getPriceIntervals failed'); mockAssetsUseCases.getPriceIntervals.mockRejectedValue(error); - await expect( - handler.historicalPrice(Caip19Asset.Bitcoin, Caip19Asset.Testnet), - ).rejects.toThrow(error); + const result = await handler.historicalPrice( + Caip19Asset.Bitcoin, + Caip19Asset.Testnet, + ); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to fetch historical prices from %s to %s. Error: %s', + Caip19Asset.Bitcoin, + Caip19Asset.Testnet, + error, + ); + expect(result).toBeNull(); }); }); @@ -193,14 +214,21 @@ describe('AssetsHandler', () => { }); }); - it('propagates errors from getRates', async () => { + it('handles null rates gracefully when getRates returns null', async () => { const assets = [ { asset: Caip19Asset.Bitcoin, unit: Caip19Asset.Testnet }, ]; - const error = new Error(); - mockAssetsUseCases.getRates.mockRejectedValue(error); + mockAssetsUseCases.getRates.mockResolvedValue([ + [Caip19Asset.Testnet, null], + ]); - await expect(handler.marketData(assets)).rejects.toThrow(error); + const result = await handler.marketData(assets); + + expect(result.marketData).toStrictEqual({ + [Caip19Asset.Bitcoin]: { + [Caip19Asset.Testnet]: null, + }, + }); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts index 16957d80..f95a4dc7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts @@ -13,6 +13,7 @@ import type { import { CaipAssetTypeStruct } from '@metamask/utils'; import { assert } from 'superstruct'; +import type { Logger } from '../entities'; import type { AssetsUseCases } from '../use-cases'; import { Caip19Asset } from './caip'; import { networkToIcon } from './icons'; @@ -22,9 +23,16 @@ export class AssetsHandler { readonly #expirationInterval: number; - constructor(assets: AssetsUseCases, expirationInterval: number) { + readonly #logger: Logger; + + constructor( + assets: AssetsUseCases, + expirationInterval: number, + logger: Logger, + ) { this.#assetsUseCases = assets; this.#expirationInterval = expirationInterval; + this.#logger = logger; } async lookup(): Promise { @@ -143,16 +151,26 @@ export class AssetsHandler { return null; } - const updateTime = getCurrentUnixTimestamp(); - const intervals = await this.#assetsUseCases.getPriceIntervals(to); + try { + const updateTime = getCurrentUnixTimestamp(); + const intervals = await this.#assetsUseCases.getPriceIntervals(to); - return { - historicalPrice: { - intervals, - updateTime, - expirationTime: updateTime + this.#expirationInterval, - }, - }; + return { + historicalPrice: { + intervals, + updateTime, + expirationTime: updateTime + this.#expirationInterval, + }, + }; + } catch (error) { + this.#logger.warn( + 'Failed to fetch historical prices from %s to %s. Error: %s', + from, + to, + error, + ); + return null; + } } async marketData( diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 256140d8..48baa8be 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -104,6 +104,7 @@ const userInputHandler = new UserInputHandler( const assetsHandler = new AssetsHandler( assetsUseCases, Config.conversionsExpirationInterval, + logger, ); export const onCronjob: OnCronjobHandler = async ({ request }) => diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts index ca8819c6..8a8bc6e2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts @@ -58,14 +58,18 @@ describe('AssetsUseCases', () => { ]); }); - it('propagates an error if spotPrices fails', async () => { + it('returns null for assets when spotPrices fails', async () => { const error = new Error('getRates failed'); mockCache.get.mockResolvedValue(undefined); mockAssetRates.spotPrices.mockRejectedValue(error); - await expect(useCases.getRates([Caip19Asset.Testnet])).rejects.toBe( + const result = await useCases.getRates([Caip19Asset.Testnet]); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to fetch spot price for ticker btc', error, ); + expect(result).toStrictEqual([[Caip19Asset.Testnet, null]]); }); it('uses cached values when available', async () => { @@ -158,13 +162,25 @@ describe('AssetsUseCases', () => { }); }); - it('propagates an error if historicalPrices fails', async () => { + it('returns empty arrays for periods when historicalPrices fails', async () => { const error = new Error('historicalPrices failed'); mockAssetRates.historicalPrices.mockRejectedValue(error); - await expect( - useCases.getPriceIntervals('swift:0/iso4217:USD'), - ).rejects.toBe(error); + const result = await useCases.getPriceIntervals('swift:0/iso4217:USD'); + + expect(mockLogger.warn).toHaveBeenCalledTimes(6); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to fetch historical prices for period P1D', + error, + ); + expect(result).toStrictEqual({ + P1D: [], + P7D: [], + P1M: [], + P3M: [], + P1Y: [], + P1000Y: [], + }); }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts index a27b8675..1d170587 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts @@ -4,8 +4,8 @@ import type { CaipAssetType } from '@metamask/utils'; import { parseCaipAssetType } from '@metamask/utils'; import type { - AssetRatesClient, AssetRate, + AssetRatesClient, Logger, SpotPrice, TimePeriod, @@ -32,33 +32,76 @@ export class AssetsUseCases { async getRates(assets: CaipAssetType[]): Promise { this.#logger.debug('Fetching BTC rates for: %o', assets); - const assetRates: AssetRate[] = []; + // group assets by ticker to deduplicate API calls. Multiple CAIP asset types + // can map to the same ticker (e.g., 'bip122:000000000019d6689c085ae165831e93/slip44:0' + // and other BTC representations both resolve to 'btc'), so we fetch each ticker only once. + const tickerToAssets = new Map(); + const assetsWithoutTicker: CaipAssetType[] = []; for (const asset of assets) { const ticker = this.#assetToTicker(asset); if (!ticker) { - assetRates.push([asset, null]); + assetsWithoutTicker.push(asset); continue; } - const cacheKey = `spotPrices:${ticker}`; - const cachedValue = await this.#cache.get(cacheKey); - - let spotPrices: SpotPrice; - if (cachedValue === undefined) { - spotPrices = await this.#assetRates.spotPrices(ticker); - // use 30secs as the ttl since we don't wanna risk stale prices - // just to avoid back to back calls for the same ticker - await this.#cache.set(cacheKey, spotPrices, 30000); + const existing = tickerToAssets.get(ticker); + if (existing) { + existing.push(asset); } else { - spotPrices = cachedValue as SpotPrice; + tickerToAssets.set(ticker, [asset]); } - - assetRates.push([asset, spotPrices]); } + // fetch all unique tickers in parallel. Each promise handles + // its own errors via .catch() to prevent one failure from breaking all fetches. + const promises = Array.from(tickerToAssets.entries()).map( + async ([ticker, tickerAssets]) => { + const cacheKey = `spotPrices:${ticker}`; + const cachedValue = await this.#cache.get(cacheKey); + + if (cachedValue !== undefined) { + return tickerAssets.map( + (asset) => [asset, cachedValue as SpotPrice] as AssetRate, + ); + } + + return this.#assetRates + .spotPrices(ticker) + .then(async (spotPrices) => { + await this.#cache.set(cacheKey, spotPrices, 30000); + return tickerAssets.map( + (asset) => [asset, spotPrices] as AssetRate, + ); + }) + .catch((error) => { + this.#logger.warn( + `Failed to fetch spot price for ticker ${ticker}`, + error, + ); + return tickerAssets.map((asset) => [asset, null] as AssetRate); + }); + }, + ); + + const results = await Promise.all(promises); + const ratesMap = new Map(); + + // flatten results from ticker-grouped arrays back to individual asset rates + results.flat().forEach(([asset, rate]) => { + ratesMap.set(asset, rate); + }); + + assetsWithoutTicker.forEach((asset) => { + ratesMap.set(asset, null); + }); + this.#logger.debug('BTC rates fetched successfully'); - return assetRates; + + return assets.map((asset) => { + const rate = ratesMap.get(asset); + return [asset, rate ?? null]; + }); } async getPriceIntervals( @@ -75,19 +118,30 @@ export class AssetsUseCases { 'P1000Y', ]; const vsCurrency = this.#assetToTicker(to); - const historicalPrices: HistoricalPriceIntervals = {}; - await Promise.all( - timePeriods.map(async (timePeriod) => { - const prices = await this.#assetRates.historicalPrices( - timePeriod, - vsCurrency, - ); - historicalPrices[timePeriod] = prices; - }), + + const promises = timePeriods.map(async (timePeriod) => + this.#assetRates + .historicalPrices(timePeriod, vsCurrency) + .then((prices) => ({ timePeriod, prices })) + .catch((error) => { + this.#logger.warn( + `Failed to fetch historical prices for period ${timePeriod}`, + error, + ); + return { timePeriod, prices: [] }; + }), ); + const results = await Promise.all(promises); + this.#logger.debug('BTC historical prices fetched successfully'); - return historicalPrices; + return results.reduce( + (acc, { timePeriod, prices }) => { + acc[timePeriod] = prices; + return acc; + }, + {}, + ); } #assetToTicker(asset: CaipAssetType): string | undefined { From a419858cd822f05ec079ae423044958ba2a2b21f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:46:53 +0100 Subject: [PATCH 300/362] 1.4.1 (#547) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 20230afd..1184c8a4 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.1] + +### Fixed + +- Add defensive code for fetching prices ([#546](https://github.com/MetaMask/snap-bitcoin-wallet/pull/546)) + ## [1.4.0] ### Added @@ -492,7 +498,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.1...HEAD +[1.4.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.0...v1.4.1 [1.4.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.3.0...v1.4.0 [1.3.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.1.0...v1.2.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 25d9481d..3e7f2fee 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.4.0", + "version": "1.4.1", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From e75f44c2bf882f7f96991d14086b951595cc71dc Mon Sep 17 00:00:00 2001 From: orestis Date: Wed, 29 Oct 2025 19:27:51 +0000 Subject: [PATCH 301/362] feat: full scan & sync accounts on the background (#548) * feat: update the schedule bg job to take params * feat: schedule a bg job for selectedAccounts * feat: schedule a bg job for fullScan * feat: dont sync accounts if wallet is locked * feat: sync account if `synchronize` flag is true for existing accounts * Revert "feat: sync account if `synchronize` flag is true for existing accounts" This reverts commit c7d2b3ac88511dc8fc81ce8a2c4bddacbaa46ef9. * feat: fullScan accounts on discovery and filter no history accounts * chore: add emit for existing discovered accounts --------- Co-authored-by: Alejandro Garcia --- .../integration-test/client-request.test.ts | 4 + .../integration-test/cron-sync.test.ts | 4 + .../integration-test/keyring-request.test.ts | 4 + .../integration-test/keyring.test.ts | 9 ++ .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../bitcoin-wallet-snap/src/entities/snap.ts | 29 +++-- .../src/handlers/CronHandler.test.ts | 111 ++++++++++++++++++ .../src/handlers/CronHandler.ts | 44 ++++++- .../src/handlers/KeyringHandler.test.ts | 47 ++++++-- .../src/handlers/KeyringHandler.ts | 27 +++-- .../bitcoin-wallet-snap/src/index.ts | 1 + .../src/infra/SnapClientAdapter.ts | 27 +++-- .../src/use-cases/AccountUseCases.test.ts | 35 +++++- .../src/use-cases/AccountUseCases.ts | 37 ++++-- .../src/use-cases/SendFlowUseCases.test.ts | 10 +- .../src/use-cases/SendFlowUseCases.ts | 10 +- 16 files changed, 328 insertions(+), 75 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts index b02831e6..89111e22 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts @@ -42,6 +42,10 @@ describe('OnClientRequestHandler', () => { return true; } + if (request.method === 'snap_scheduleBackgroundEvent') { + return 'mock-event-id'; + } + // don't mock other methods return undefined; }); diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts index 0061ada7..0aead019 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts @@ -39,6 +39,10 @@ describe('CronHandler', () => { return {}; } + if (request.method === 'snap_scheduleBackgroundEvent') { + return 'mock-event-id'; + } + return undefined; }); }); diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts index 6b32144c..fde6972e 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts @@ -39,6 +39,10 @@ describe('KeyringRequestHandler', () => { return {}; } + if (request.method === 'snap_scheduleBackgroundEvent') { + return 'mock-event-id'; + } + // no mocking for other methods return undefined; }); diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts index b4b5df94..b092ae3c 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring.test.ts @@ -45,6 +45,10 @@ describe('Keyring', () => { return {}; } + if (request.method === 'snap_scheduleBackgroundEvent') { + return 'mock-event-id'; + } + return undefined; }); }); @@ -105,6 +109,11 @@ describe('Keyring', () => { if ('result' in response.response) { accounts[TEST_ADDRESS_REGTEST] = response.response .result as KeyringAccount; + + await snap.onCronjob({ + method: 'fullScanAccount', + params: { accountId: accounts[TEST_ADDRESS_REGTEST].id }, + }); } }); diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index e803c731..217a7bac 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.4.0", + "version": "1.4.1", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "6EJ7jL04aFCb7QSjTBVr4yxq9Xjz9VNUvaMvuPhUoLU=", + "shasum": "As36JVjNmvTkWB7TLNFfveEqGUrV5WbbADr9cyGc2bQ=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index 8e259510..a1094285 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -181,18 +181,23 @@ export type SnapClient = { getInterfaceContext(id: string): Promise | null>; /** - * Schedule a one-off callback. - * - * @param interval - The interval in seconds before the event is executed. - * @param method - The method to call on reception of the event being triggered. - * @param interfaceId - The interface id. - * @returns the background event id. - */ - scheduleBackgroundEvent( - interval: string, - method: string, - interfaceId: string, - ): Promise; + * Schedules a background event. + * + * @param options - The options for the background event. + * @param options.method - The method to call. + * @param options.params - The params to pass to the method. + * @param options.duration - The duration to wait before the event is scheduled. + * @returns A promise that resolves to a string. + */ + scheduleBackgroundEvent({ + method, + params, + duration, + }: { + method: string; + params?: Record; + duration: string; + }): Promise; /** * Cancel an already scheduled background event. diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts index 3c24e60d..e4eacffa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -126,4 +126,115 @@ describe('CronHandler', () => { await expect(handler.route(request)).rejects.toThrow(error); }); }); + + describe('fullScanSelectedAccounts', () => { + const mockAccounts = [ + mock({ id: 'account-1' }), + mock({ id: 'account-2' }), + mock({ id: 'account-3' }), + ]; + const request = { + method: CronMethod.FullScanSelectedAccounts, + params: { accountIds: ['account-1', 'account-2'] }, + } as unknown as JsonRpcRequest; + + it('throws if invalid params', async () => { + await expect( + handler.route({ ...request, params: { invalid: true } }), + ).rejects.toThrow(''); + }); + + it('performs full scan on selected accounts', async () => { + mockAccountUseCases.list.mockResolvedValue(mockAccounts); + + await handler.route(request); + + expect(mockAccountUseCases.list).toHaveBeenCalled(); + expect(mockAccountUseCases.fullScan).toHaveBeenCalledTimes(2); + expect(mockAccountUseCases.fullScan).toHaveBeenCalledWith( + mockAccounts[0], + ); + expect(mockAccountUseCases.fullScan).toHaveBeenCalledWith( + mockAccounts[1], + ); + }); + + it('returns early if the client is not active', async () => { + mockSnapClient.getClientStatus.mockResolvedValue({ + active: false, + locked: true, + }); + await handler.route(request); + + expect(mockAccountUseCases.fullScan).not.toHaveBeenCalled(); + }); + + it('propagates errors from list', async () => { + const error = new Error(); + mockAccountUseCases.list.mockRejectedValue(error); + + await expect(handler.route(request)).rejects.toThrow(error); + }); + + it('completes successfully even if some accounts fail to scan', async () => { + mockAccountUseCases.list.mockResolvedValue(mockAccounts); + mockAccountUseCases.fullScan + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('scan failed')); + + const result = await handler.route(request); + + expect(result).toBeUndefined(); + expect(mockAccountUseCases.fullScan).toHaveBeenCalledTimes(2); + }); + }); + + describe('fullScanAccount', () => { + const mockAccount = mock({ id: 'account-1' }); + const request = { + method: CronMethod.FullScanAccount, + params: { accountId: 'account-1' }, + } as unknown as JsonRpcRequest; + + it('throws if invalid params', async () => { + await expect( + handler.route({ ...request, params: { invalid: true } }), + ).rejects.toThrow(''); + }); + + it('performs full scan on the specified account', async () => { + mockAccountUseCases.get.mockResolvedValue(mockAccount); + + await handler.route(request); + + expect(mockAccountUseCases.get).toHaveBeenCalledWith('account-1'); + expect(mockAccountUseCases.fullScan).toHaveBeenCalledWith(mockAccount); + }); + + it('returns early if the client is not active', async () => { + mockSnapClient.getClientStatus.mockResolvedValue({ + active: false, + locked: true, + }); + await handler.route(request); + + expect(mockAccountUseCases.get).not.toHaveBeenCalled(); + expect(mockAccountUseCases.fullScan).not.toHaveBeenCalled(); + }); + + it('propagates errors from get', async () => { + const error = new Error('get failed'); + mockAccountUseCases.get.mockRejectedValue(error); + + await expect(handler.route(request)).rejects.toThrow(error); + }); + + it('propagates errors from fullScan', async () => { + const error = new Error('fullScan failed'); + mockAccountUseCases.get.mockResolvedValue(mockAccount); + mockAccountUseCases.fullScan.mockRejectedValue(error); + + await expect(handler.route(request)).rejects.toThrow(error); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index 934f5f11..2f7e376c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -1,7 +1,7 @@ import { getSelectedAccounts } from '@metamask/keyring-snap-sdk'; import type { SnapsProvider } from '@metamask/snaps-sdk'; import type { JsonRpcRequest } from '@metamask/utils'; -import { assert, object, string } from 'superstruct'; +import { array, assert, object, string } from 'superstruct'; import { InexistentMethodError, @@ -13,12 +13,22 @@ import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; export enum CronMethod { SynchronizeAccounts = 'synchronizeAccounts', RefreshRates = 'refreshRates', + FullScanSelectedAccounts = 'fullScanSelectedAccounts', + FullScanAccount = 'fullScanAccount', } export const SendFormRefreshRatesRequest = object({ interfaceId: string(), }); +export const FullScanSelectedAccountsRequest = object({ + accountIds: array(string()), +}); + +export const FullScanAccountRequest = object({ + accountId: string(), +}); + export class CronHandler { readonly #accountsUseCases: AccountUseCases; @@ -43,8 +53,8 @@ export class CronHandler { async route(request: JsonRpcRequest): Promise { const { method, params } = request; - const { active } = await this.#snapClient.getClientStatus(); - if (!active) { + const { active, locked } = await this.#snapClient.getClientStatus(); + if (!active || locked) { return undefined; } @@ -56,6 +66,14 @@ export class CronHandler { assert(params, SendFormRefreshRatesRequest); return this.#sendFlowUseCases.refresh(params.interfaceId); } + case CronMethod.FullScanSelectedAccounts: { + assert(params, FullScanSelectedAccountsRequest); + return this.fullScanSelectedAccounts(params.accountIds); + } + case CronMethod.FullScanAccount: { + assert(params, FullScanAccountRequest); + return this.fullScanAccount(params.accountId); + } default: throw new InexistentMethodError(`Method not found: ${method}`); } @@ -93,4 +111,24 @@ export class CronHandler { ); } } + + async fullScanSelectedAccounts(accountIds: string[]): Promise { + const accountIdSet = new Set(accountIds); + const allAccounts = await this.#accountsUseCases.list(); + + const selectedAccounts = allAccounts.filter((account) => + accountIdSet.has(account.id), + ); + + const scanPromises = selectedAccounts.map(async (account) => + this.#accountsUseCases.fullScan(account), + ); + + await Promise.allSettled(scanPromises); + } + + async fullScanAccount(accountId: string): Promise { + const account = await this.#accountsUseCases.get(accountId); + await this.#accountsUseCases.fullScan(account); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index ac143b07..a459fe05 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -19,7 +19,7 @@ import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; -import type { BitcoinAccount } from '../entities'; +import type { BitcoinAccount, SnapClient } from '../entities'; import { AccountCapability, CurrencyUnit, @@ -53,6 +53,7 @@ jest.mock('@metamask/bitcoindevkit', () => { describe('KeyringHandler', () => { const mockKeyringRequest = mock(); const mockAccounts = mock(); + const mockSnapClient = mock(); const mockAddress = mock
({ toString: () => 'bc1qaddress...', }); @@ -75,6 +76,7 @@ describe('KeyringHandler', () => { mockKeyringRequest, mockAccounts, defaultAddressType, + mockSnapClient, ); beforeEach(() => { @@ -359,25 +361,46 @@ describe('KeyringHandler', () => { expect(discovered).toStrictEqual(expect.arrayContaining(expected)); }); - it.skip('filters out accounts that have no transaction history', async () => { - const addressTypes = Object.values(BtcAccountType); - const totalCombinations = scopes.length * addressTypes.length; + it('returns mix of accounts with and without history, filtering correctly', async () => { + // create mock accounts - some with history, some without + const accountWithHistory1 = mock({ + addressType: 'p2wpkh', + network: 'bitcoin', + listTransactions: jest.fn().mockReturnValue([{}, {}]), // has 2 transactions + derivationPath: ['m', "84'", "0'", "0'"], + }); - for (let i = 0; i < totalCombinations; i += 1) { - const acc = mock({ - listTransactions: jest.fn().mockReturnValue([]), // no history - }); + const accountWithoutHistory = mock({ + addressType: 'p2wpkh', + network: 'testnet', + listTransactions: jest.fn().mockReturnValue([]), // no history + derivationPath: ['m', "84'", "1'", "0'"], + }); - mockAccounts.discover.mockResolvedValueOnce(acc); - } + const accountWithHistory2 = mock({ + addressType: 'p2wpkh', + network: 'signet', + listTransactions: jest.fn().mockReturnValue([{}]), // has 1 transaction + derivationPath: ['m', "84'", "1'", "0'"], + }); + + mockAccounts.discover + .mockResolvedValueOnce(accountWithHistory1) + .mockResolvedValueOnce(accountWithoutHistory) + .mockResolvedValueOnce(accountWithHistory2); const discovered = await handler.discoverAccounts( - scopes, + [BtcScope.Mainnet, BtcScope.Testnet, BtcScope.Signet], entropySource, groupIndex, ); - expect(discovered).toHaveLength(0); + expect(mockAccounts.discover).toHaveBeenCalledTimes(3); + expect(discovered).toHaveLength(2); + expect(discovered).toStrictEqual([ + mapToDiscoveredAccount(accountWithHistory1), + mapToDiscoveredAccount(accountWithHistory2), + ]); }); it('propagates errors from discover', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 1713ff97..c3e43502 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -41,7 +41,7 @@ import { string, } from 'superstruct'; -import type { BitcoinAccount } from '../entities'; +import type { BitcoinAccount, SnapClient } from '../entities'; import { FormatError, InexistentMethodError, @@ -55,6 +55,7 @@ import { scopeToNetwork, networkToScope, } from './caip'; +import { CronMethod } from './CronHandler'; import type { KeyringRequestHandler } from './KeyringRequestHandler'; import { mapToDiscoveredAccount, @@ -82,14 +83,18 @@ export class KeyringHandler implements Keyring { readonly #defaultAddressType: AddressType; + readonly #snapClient: SnapClient; + constructor( keyringRequest: KeyringRequestHandler, accounts: AccountUseCases, defaultAddressType: AddressType, + snapClient: SnapClient, ) { this.#keyringRequest = keyringRequest; this.#accountsUseCases = accounts; this.#defaultAddressType = defaultAddressType; + this.#snapClient = snapClient; } async route(request: JsonRpcRequest): Promise { @@ -264,7 +269,10 @@ export class KeyringHandler implements Keyring { ), ); - return accounts.map(mapToDiscoveredAccount); + // Return only accounts with history. + return accounts + .filter((account) => account.listTransactions().length > 0) + .map(mapToDiscoveredAccount); } async getAccountBalances( @@ -343,15 +351,12 @@ export class KeyringHandler implements Keyring { allAccounts.map((acc) => acc.id), ); - const selectedAccounts = allAccounts.filter((account) => - accountIdSet.has(account.id), - ); - - const scanPromises = selectedAccounts.map(async (account) => - this.#accountsUseCases.fullScan(account), - ); - - await Promise.allSettled(scanPromises); + // Schedule immediate background job to perform full scan + await this.#snapClient.scheduleBackgroundEvent({ + duration: 'PT1S', + method: CronMethod.FullScanSelectedAccounts, + params: { accountIds: accounts }, + }); } #extractAddressType(path: string): AddressType { diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 48baa8be..c82e7e07 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -89,6 +89,7 @@ const keyringHandler = new KeyringHandler( keyringRequestHandler, accountsUseCases, Config.defaultAddressType, + snapClient, ); const cronHandler = new CronHandler( accountsUseCases, diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 7fb0faff..fede9fd8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -187,18 +187,31 @@ export class SnapClientAdapter implements SnapClient { }); } - async scheduleBackgroundEvent( - interval: string, - method: string, - interfaceId: string, - ): Promise { + /** + * Schedules a background event. + * + * @param options - The options for the background event. + * @param options.method - The method to call. + * @param options.params - The params to pass to the method. + * @param options.duration - The duration to wait before the event is scheduled. + * @returns A promise that resolves to a string. + */ + async scheduleBackgroundEvent({ + method, + params = {}, + duration, + }: { + method: string; + params?: Record; + duration: string; + }): Promise { return snap.request({ method: 'snap_scheduleBackgroundEvent', params: { - duration: interval, + duration, request: { method, - params: { interfaceId }, + params, }, }, }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 1c307d87..6611cd2f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -35,6 +35,7 @@ import type { DiscoverAccountParams, } from './AccountUseCases'; import { AccountUseCases } from './AccountUseCases'; +import { CronMethod } from '../handlers/CronHandler'; describe('AccountUseCases', () => { const mockLogger = mock(); @@ -168,7 +169,11 @@ describe('AccountUseCases', () => { createParams.correlationId, createParams.accountName, ); - expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: mockAccount.id }, + }); }, ); @@ -208,7 +213,11 @@ describe('AccountUseCases', () => { createParams.correlationId, createParams.accountName, ); - expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: mockAccount.id }, + }); }, ); @@ -266,9 +275,9 @@ describe('AccountUseCases', () => { expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalled(); }); - it('propagates an error if fullScan throws', async () => { - const error = new Error('fullScan failed'); - mockChain.fullScan.mockRejectedValue(error); + it('propagates an error if scheduleBackgroundEvent throws', async () => { + const error = new Error('scheduleBackgroundEvent failed'); + mockSnapClient.scheduleBackgroundEvent.mockRejectedValue(error); await expect( useCases.create({ ...createParams, synchronize: true }), @@ -278,7 +287,7 @@ describe('AccountUseCases', () => { expect(mockRepository.create).toHaveBeenCalled(); expect(mockRepository.insert).toHaveBeenCalled(); expect(mockSnapClient.emitAccountCreatedEvent).toHaveBeenCalled(); - expect(mockChain.fullScan).toHaveBeenCalled(); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalled(); }); }); @@ -326,6 +335,7 @@ describe('AccountUseCases', () => { discoverParams.network, tAddressType, ); + expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); }, ); @@ -358,6 +368,7 @@ describe('AccountUseCases', () => { tNetwork, discoverParams.addressType, ); + expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); }, ); @@ -368,6 +379,7 @@ describe('AccountUseCases', () => { expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.create).not.toHaveBeenCalled(); + expect(mockChain.fullScan).not.toHaveBeenCalled(); expect(result).toBe(mockAccount); }); @@ -391,6 +403,17 @@ describe('AccountUseCases', () => { expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); expect(mockRepository.create).toHaveBeenCalled(); }); + + it('propagates an error if fullScan throws', async () => { + const error = new Error('fullScan failed'); + mockChain.fullScan.mockRejectedValue(error); + + await expect(useCases.discover(discoverParams)).rejects.toBe(error); + + expect(mockRepository.getByDerivationPath).toHaveBeenCalled(); + expect(mockRepository.create).toHaveBeenCalled(); + expect(mockChain.fullScan).toHaveBeenCalled(); + }); }); describe('synchronize', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 9f56c353..deab8efa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -11,6 +11,15 @@ import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; import { Signer } from 'bip322-js'; import { encode } from 'wif'; +import type { + BitcoinAccount, + BitcoinAccountRepository, + BlockchainClient, + ConfirmationRepository, + Logger, + MetaProtocolsClient, + SnapClient, +} from '../entities'; import { AccountCapability, addressTypeToPurpose, @@ -22,15 +31,7 @@ import { ValidationError, WalletError, } from '../entities'; -import type { - BitcoinAccount, - BitcoinAccountRepository, - BlockchainClient, - Logger, - MetaProtocolsClient, - SnapClient, - ConfirmationRepository, -} from '../entities'; +import { CronMethod } from '../handlers/CronHandler'; export type DiscoverAccountParams = { network: Network; @@ -128,6 +129,15 @@ export class AccountUseCases { addressType, ); + // We need to do a full scan here to know if the account + // has any previous activity since later on we filter out + // accounts with no tx history + await this.#chain.fullScan(newAccount); + + this.#logger.info( + 'Bitcoin account discovered successfully. Request: %o', + req, + ); return newAccount; } @@ -160,7 +170,6 @@ export class AccountUseCases { correlationId, accountName, ); - return account; } @@ -174,7 +183,7 @@ export class AccountUseCases { await this.#repository.insert(newAccount); - // First notify the event has been created, then full scan. + // First notify the event has been created, then schedule full scan. await this.#snapClient.emitAccountCreatedEvent( newAccount, correlationId, @@ -182,7 +191,11 @@ export class AccountUseCases { ); if (synchronize) { - await this.fullScan(newAccount); + await this.#snapClient.scheduleBackgroundEvent({ + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: newAccount.id }, + }); } this.#logger.info( diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 70c6d9b9..51d261a6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -726,11 +726,11 @@ describe('SendFlowUseCases', () => { await useCases.refresh('interface-id'); - expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith( - ratesRefreshInterval, - CronMethod.RefreshRates, - 'interface-id', - ); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + duration: ratesRefreshInterval, + method: CronMethod.RefreshRates, + params: { interfaceId: 'interface-id' }, + }); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( 'interface-id', { diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index 05503624..843c3f30 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -549,11 +549,11 @@ export class SendFlowUseCases { } updatedContext.backgroundEventId = - await this.#snapClient.scheduleBackgroundEvent( - this.#ratesRefreshInterval, - CronMethod.RefreshRates, - id, - ); + await this.#snapClient.scheduleBackgroundEvent({ + duration: this.#ratesRefreshInterval, + method: CronMethod.RefreshRates, + params: { interfaceId: id }, + }); updatedContext.locale = locale; // Take advantage of the loop to update the locale as well await this.#sendFlowRepository.updateForm(id, updatedContext); From 4c7d29b415b5b01cb6aa14f8e8727d9cd8f6deab Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Anglada Date: Wed, 29 Oct 2025 21:24:50 +0100 Subject: [PATCH 302/362] chore: update shasum (#550) --- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 217a7bac..9d2f3468 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "As36JVjNmvTkWB7TLNFfveEqGUrV5WbbADr9cyGc2bQ=", + "shasum": "j+W3/Hn+uZ3M1Y4qbvC2Bc+pQWvdvAXZ0CNOnKazPD0=", "location": { "npm": { "filePath": "dist/bundle.js", From 1e809ca72bf9358dfa2f64e94268e4e4a61479fc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:33:23 +0000 Subject: [PATCH 303/362] 1.4.2 (#551) * 1.4.2 * Update CHANGELOG.md --------- Co-authored-by: github-actions Co-authored-by: Alejandro Garcia Anglada --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 1184c8a4..02475cc9 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.2] + +### Changed + +- Bringing back `fullScan` on discovery and making `setSelectedAccount` run `fullScan` in the background ([#548](https://github.com/MetaMask/snap-bitcoin-wallet/pull/548)) + ## [1.4.1] ### Fixed @@ -498,7 +504,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.1...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.2...HEAD +[1.4.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.1...v1.4.2 [1.4.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.0...v1.4.1 [1.4.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.3.0...v1.4.0 [1.3.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.2.0...v1.3.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 3e7f2fee..3cb0016b 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.4.1", + "version": "1.4.2", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 636434fc747a303c2eb67b99feb4439caa5c0ff3 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Thu, 30 Oct 2025 12:44:54 +0100 Subject: [PATCH 304/362] fix: fix fingerprint format (for BDK) (#552) * fix: fix fingerprint format (for BDK) * chore: yarn build * chore: lint * fix: also fix other fingerprints * chore: lint --- .../bitcoin-wallet-snap/snap.manifest.json | 4 +-- .../src/store/BdkAccountRepository.ts | 25 ++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 9d2f3468..84a7bf02 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.4.1", + "version": "1.4.2", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "j+W3/Hn+uZ3M1Y4qbvC2Bc+pQWvdvAXZ0CNOnKazPD0=", + "shasum": "QcEyQw877Qk+uanuNyo9sJ0NqTMPyy3gO5wlE+eFXNM=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts index 920c6d40..a9e48520 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts @@ -21,6 +21,19 @@ import { } from '../entities'; import { BdkAccountAdapter } from '../infra'; +/** + * Encode a fingerprint to a 4-bytes hex-string (required by the BDK). + * + * @param fingerprint - The fingerprint to encode. + * @returns The encoded fingerprint. + */ +function toBdkFingerprint(fingerprint: number): string { + // READ THIS CAREFULLY: + // The BDK expects 4-bytes fingerprint, so we have to pad with 0, in case + // our fingerprint contains leading null-bytes. + return fingerprint.toString(16).padStart(8, '0'); +} + export class BdkAccountRepository implements BitcoinAccountRepository { readonly #snapClient: SnapClient; @@ -84,9 +97,9 @@ export class BdkAccountRepository implements BitcoinAccountRepository { const { derivationPath, wallet } = accountState; const slip10 = await this.#snapClient.getPrivateEntropy(derivationPath); - const fingerprint = ( - slip10.masterFingerprint ?? slip10.parentFingerprint - ).toString(16); + const fingerprint = toBdkFingerprint( + slip10.masterFingerprint ?? slip10.parentFingerprint, + ); const account = BdkAccountAdapter.load( id, @@ -116,9 +129,9 @@ export class BdkAccountRepository implements BitcoinAccountRepository { ): Promise { const slip10 = await this.#snapClient.getPublicEntropy(derivationPath); const id = v4(); - const fingerprint = ( - slip10.masterFingerprint ?? slip10.parentFingerprint - ).toString(16); + const fingerprint = toBdkFingerprint( + slip10.masterFingerprint ?? slip10.parentFingerprint, + ); const xpub = slip10_to_extended(slip10, network); const descriptors = xpub_to_descriptor( From 9f51183e281094526a91ec37230b9f98c32add66 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 12:52:04 +0100 Subject: [PATCH 305/362] 1.4.3 (#553) * 1.4.3 * Update CHANGELOG.md --------- Co-authored-by: github-actions Co-authored-by: Alejandro Garcia Anglada --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 02475cc9..66d8426e 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.3] + +### Fixed + +- Fix fingerprint format (for BDK) ([#552](https://github.com/MetaMask/snap-bitcoin-wallet/pull/552)) + ## [1.4.2] ### Changed @@ -504,7 +510,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.2...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.3...HEAD +[1.4.3]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.2...v1.4.3 [1.4.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.1...v1.4.2 [1.4.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.0...v1.4.1 [1.4.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.3.0...v1.4.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 3cb0016b..39fa9883 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.4.2", + "version": "1.4.3", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From bcd40bb3696535ab73a3251ea75b1ef53294cf01 Mon Sep 17 00:00:00 2001 From: orestis Date: Tue, 4 Nov 2025 12:08:49 +0000 Subject: [PATCH 306/362] fix: sync on setSelectedAccounts & fix network name format (#554) --- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- .../src/handlers/CronHandler.test.ts | 18 ++++++++++-------- .../src/handlers/CronHandler.ts | 14 +++++++------- .../src/handlers/KeyringHandler.ts | 2 +- .../src/infra/jsx/format.ts | 4 ++++ .../unified-send-flow/UnifiedSendFormView.tsx | 7 ++++--- 6 files changed, 28 insertions(+), 21 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 84a7bf02..bb2b869d 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.4.2", + "version": "1.4.3", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "QcEyQw877Qk+uanuNyo9sJ0NqTMPyy3gO5wlE+eFXNM=", + "shasum": "On9X6VsyK9Nr8Zs0DmfA0SCKFsO8ayVV2oqNlGDpMFc=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts index e4eacffa..4921aff4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -127,14 +127,14 @@ describe('CronHandler', () => { }); }); - describe('fullScanSelectedAccounts', () => { + describe('syncSelectedAccounts', () => { const mockAccounts = [ mock({ id: 'account-1' }), mock({ id: 'account-2' }), mock({ id: 'account-3' }), ]; const request = { - method: CronMethod.FullScanSelectedAccounts, + method: CronMethod.SyncSelectedAccounts, params: { accountIds: ['account-1', 'account-2'] }, } as unknown as JsonRpcRequest; @@ -150,12 +150,14 @@ describe('CronHandler', () => { await handler.route(request); expect(mockAccountUseCases.list).toHaveBeenCalled(); - expect(mockAccountUseCases.fullScan).toHaveBeenCalledTimes(2); - expect(mockAccountUseCases.fullScan).toHaveBeenCalledWith( + expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes(2); + expect(mockAccountUseCases.synchronize).toHaveBeenCalledWith( mockAccounts[0], + 'metamask', ); - expect(mockAccountUseCases.fullScan).toHaveBeenCalledWith( + expect(mockAccountUseCases.synchronize).toHaveBeenCalledWith( mockAccounts[1], + 'metamask', ); }); @@ -166,7 +168,7 @@ describe('CronHandler', () => { }); await handler.route(request); - expect(mockAccountUseCases.fullScan).not.toHaveBeenCalled(); + expect(mockAccountUseCases.synchronize).not.toHaveBeenCalled(); }); it('propagates errors from list', async () => { @@ -178,14 +180,14 @@ describe('CronHandler', () => { it('completes successfully even if some accounts fail to scan', async () => { mockAccountUseCases.list.mockResolvedValue(mockAccounts); - mockAccountUseCases.fullScan + mockAccountUseCases.synchronize .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error('scan failed')); const result = await handler.route(request); expect(result).toBeUndefined(); - expect(mockAccountUseCases.fullScan).toHaveBeenCalledTimes(2); + expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes(2); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index 2f7e376c..e4b33668 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -13,7 +13,7 @@ import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; export enum CronMethod { SynchronizeAccounts = 'synchronizeAccounts', RefreshRates = 'refreshRates', - FullScanSelectedAccounts = 'fullScanSelectedAccounts', + SyncSelectedAccounts = 'syncSelectedAccounts', FullScanAccount = 'fullScanAccount', } @@ -21,7 +21,7 @@ export const SendFormRefreshRatesRequest = object({ interfaceId: string(), }); -export const FullScanSelectedAccountsRequest = object({ +export const SyncSelectedAccountsRequest = object({ accountIds: array(string()), }); @@ -66,9 +66,9 @@ export class CronHandler { assert(params, SendFormRefreshRatesRequest); return this.#sendFlowUseCases.refresh(params.interfaceId); } - case CronMethod.FullScanSelectedAccounts: { - assert(params, FullScanSelectedAccountsRequest); - return this.fullScanSelectedAccounts(params.accountIds); + case CronMethod.SyncSelectedAccounts: { + assert(params, SyncSelectedAccountsRequest); + return this.syncSelectedAccounts(params.accountIds); } case CronMethod.FullScanAccount: { assert(params, FullScanAccountRequest); @@ -112,7 +112,7 @@ export class CronHandler { } } - async fullScanSelectedAccounts(accountIds: string[]): Promise { + async syncSelectedAccounts(accountIds: string[]): Promise { const accountIdSet = new Set(accountIds); const allAccounts = await this.#accountsUseCases.list(); @@ -121,7 +121,7 @@ export class CronHandler { ); const scanPromises = selectedAccounts.map(async (account) => - this.#accountsUseCases.fullScan(account), + this.#accountsUseCases.synchronize(account, 'metamask'), ); await Promise.allSettled(scanPromises); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index c3e43502..ace7daa9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -354,7 +354,7 @@ export class KeyringHandler implements Keyring { // Schedule immediate background job to perform full scan await this.#snapClient.scheduleBackgroundEvent({ duration: 'PT1S', - method: CronMethod.FullScanSelectedAccounts, + method: CronMethod.SyncSelectedAccounts, params: { accountIds: accounts }, }); } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts index fc647bb5..12ca8101 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts @@ -78,3 +78,7 @@ export const displayCaip10 = ( ): CaipAccountId => { return `${networkToScope[network]}:${address}`; }; + +export const displayNetwork = (network: Network): string => { + return network.charAt(0).toUpperCase() + network.slice(1); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx index 6edc75ba..b73ffedb 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx @@ -20,6 +20,7 @@ import { displayCaip10, displayExchangeAmount, displayExplorerUrl, + displayNetwork, isValidSnapLinkProtocol, translate, } from '../format'; @@ -62,7 +63,7 @@ export const UnifiedSendFormView: SnapComponent = ({ {t('confirmation.estimatedChanges.send')} - + -{displayAmount(BigInt(amount), currency).replace(' BTC', '')} @@ -101,9 +102,9 @@ export const UnifiedSendFormView: SnapComponent = ({ {t('network')} - + - {network} + {displayNetwork(network)} {null} From 4007676e2844f3c72d1ce444955a6469189d35f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:05:31 +0000 Subject: [PATCH 307/362] 1.4.4 (#555) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 66d8426e..d51c11f7 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.4] + +### Fixed + +- Sync on setSelectedAccounts & fix network name format ([#554](https://github.com/MetaMask/snap-bitcoin-wallet/pull/554)) + ## [1.4.3] ### Fixed @@ -510,7 +516,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.3...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.4...HEAD +[1.4.4]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.3...v1.4.4 [1.4.3]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.2...v1.4.3 [1.4.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.1...v1.4.2 [1.4.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.0...v1.4.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 39fa9883..2520958e 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.4.3", + "version": "1.4.4", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From a893b76d24a278a4f75c9aec1e0f36098f886cca Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 10 Nov 2025 12:16:49 +0100 Subject: [PATCH 308/362] feat: add minimal amount validation (#558) * feat: update snap.manifest.json with last 1.4.4 release version * feat: add minimal dust amount validation * feat: update the API spec to matche the added validation * feat: return Invalid instead of a specific dust error --------- Co-authored-by: orestis --- .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../src/handlers/RpcHandler.test.ts | 38 ++++++++++++++++ .../src/handlers/RpcHandler.ts | 9 +++- .../src/handlers/validation.ts | 44 ++++++++++++++++++- 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index bb2b869d..9911d3a6 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.4.3", + "version": "1.4.4", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "On9X6VsyK9Nr8Zs0DmfA0SCKFsO8ayVV2oqNlGDpMFc=", + "shasum": "LqmgQNRugsbkbYBmFoO/CkS58naz0pAzWhEtVyRPbb0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index 93951797..f3716f73 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -543,6 +543,7 @@ describe('RpcHandler', () => { const mockAmountAccount = { network: 'bitcoin', + addressType: 'p2wpkh', balance: { trusted_spendable: mockTrustedSpendable, }, @@ -600,6 +601,43 @@ describe('RpcHandler', () => { errors: [{ code: SendErrorCodes.Invalid }], }); }); + + it('accepts amount equal to segwit dust limit for p2wpkh', async () => { + const segwitDustOkRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.OnAmountInput, + params: { + value: '0.00000294', + accountId: validAccountId, + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }; + + const result = await handler.route(origin, segwitDustOkRequest); + + expect(result).toStrictEqual({ valid: true, errors: [] }); + }); + + it('rejects amount below segwit dust limit for p2wpkh', async () => { + const segwitDustTooLowRequest: JsonRpcRequest = { + id: 1, + jsonrpc: '2.0', + method: RpcMethod.OnAmountInput, + params: { + value: '0.00000293', + accountId: validAccountId, + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + }, + }; + + const result = await handler.route(origin, segwitDustTooLowRequest); + + expect(result).toStrictEqual({ + valid: false, + errors: [{ code: SendErrorCodes.Invalid }], + }); + }); }); describe('verifyMessage', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index 379c9eea..f9df78c2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -33,6 +33,7 @@ import { validateAmount, validateAddress, validateAccountBalance, + validateDustLimit, } from './validation'; export const CreateSendFormRequest = object({ @@ -217,6 +218,10 @@ export class RpcHandler { try { const bitcoinAccount = await this.#accountUseCases.get(accountId); + const dustValidation = validateDustLimit(value, bitcoinAccount); + if (!dustValidation.valid) { + return dustValidation; + } const balanceValidation = validateAccountBalance(value, bitcoinAccount); return balanceValidation.valid ? NO_ERRORS_RESPONSE : balanceValidation; @@ -252,7 +257,9 @@ export class RpcHandler { const inputValidation = validateAmount(request.amount).valid && - validateAddress(request.toAddress, account.network, this.#logger).valid; + validateAddress(request.toAddress, account.network, this.#logger) + .valid && + validateDustLimit(request.amount, account).valid; if (!inputValidation) { return INVALID_RESPONSE; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts index 706d4e82..7d9080b0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts @@ -1,4 +1,4 @@ -import type { Network } from '@metamask/bitcoindevkit'; +import type { Network, AddressType } from '@metamask/bitcoindevkit'; import { Address, Amount } from '@metamask/bitcoindevkit'; import { CaipAssetTypeStruct } from '@metamask/utils'; import type { Infer } from 'superstruct'; @@ -172,3 +172,45 @@ export function validateSelectedAccounts( ); } } + +/** + * Returns the dust limit (in satoshis) for a given address type. + * + * @param addressType - The account address type (script type). + * @returns The minimum spendable amount in satoshis for this script type. + */ +function getDustLimitSats(addressType: AddressType): bigint { + switch (addressType) { + case 'p2wpkh': + return 294n; + case 'p2pkh': + return 546n; + case 'p2sh': + return 546n; + case 'p2wsh': + return 546n; + case 'p2tr': + return 546n; + default: + return 546n; + } +} + +/** + * Validates that the amount is above the dust limit for the account's script type. + * + * @param amountInBtc - The amount to send, in BTC units. + * @param account - The Bitcoin account providing address type and context. + * @returns ValidationResponse indicating whether the amount meets dust requirements. + */ +export function validateDustLimit( + amountInBtc: string, + account: BitcoinAccount, +): ValidationResponse { + const sats = Amount.from_btc(Number(amountInBtc)).to_sat(); + const min = getDustLimitSats(account.addressType); + if (sats < min) { + return { valid: false, errors: [{ code: SendErrorCodes.Invalid }] }; + } + return NO_ERRORS_RESPONSE; +} From 2c199825aa485ceceb3acecc1acc4d1510e12525 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:33:07 +0000 Subject: [PATCH 309/362] 1.4.5 (#559) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index d51c11f7..5648a06a 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.5] + +### Added + +- Add minimal amount validation ([#558](https://github.com/MetaMask/snap-bitcoin-wallet/pull/558)) + ## [1.4.4] ### Fixed @@ -516,7 +522,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.4...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.5...HEAD +[1.4.5]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.4...v1.4.5 [1.4.4]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.3...v1.4.4 [1.4.3]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.2...v1.4.3 [1.4.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.1...v1.4.2 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 2520958e..6e9d0b63 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.4.4", + "version": "1.4.5", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 1611c57b2c6753df243c10bf3268374ab5e8d612 Mon Sep 17 00:00:00 2001 From: orestis Date: Mon, 10 Nov 2025 11:57:59 +0000 Subject: [PATCH 310/362] feat: add perf tracing account creation (#557) --- .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../bitcoin-wallet-snap/src/entities/snap.ts | 16 ++ .../src/handlers/KeyringHandler.test.ts | 81 +++++++++- .../src/handlers/KeyringHandler.ts | 153 ++++++++++-------- .../bitcoin-wallet-snap/src/index.ts | 1 + .../src/infra/SnapClientAdapter.ts | 18 +++ .../src/use-cases/AccountUseCases.test.ts | 4 +- .../src/use-cases/AccountUseCases.ts | 85 +++++----- .../src/utils/snapHelpers.ts | 18 +++ 9 files changed, 272 insertions(+), 108 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/snapHelpers.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 9911d3a6..76f46613 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.4.4", + "version": "1.4.5", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "LqmgQNRugsbkbYBmFoO/CkS58naz0pAzWhEtVyRPbb0=", + "shasum": "SCdgZXGvpGJ62ZC1laEIQ6CSaWUCrA85T+tWIFirhqk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index a1094285..5c8ab999 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -241,4 +241,20 @@ export type SnapClient = { * @param error The error to track */ emitTrackingError(error: BaseError): Promise; + + /** + * Start a performance trace. + * + * @param name - The name of the trace. + * @returns A promise that resolves. + */ + startTrace(name: string): Promise; + + /** + * End a performance trace. + * + * @param name - The name of the trace. + * @returns A promise that resolves when the trace is ended. + */ + endTrace(name: string): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index a459fe05..b2f0047f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -19,7 +19,7 @@ import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; -import type { BitcoinAccount, SnapClient } from '../entities'; +import type { BitcoinAccount, Logger, SnapClient } from '../entities'; import { AccountCapability, CurrencyUnit, @@ -57,6 +57,8 @@ describe('KeyringHandler', () => { const mockAddress = mock
({ toString: () => 'bc1qaddress...', }); + const mockLogger = mock(); + // TODO: enable when this is merged: https://github.com/rustwasm/wasm-bindgen/issues/1818 /* eslint-disable @typescript-eslint/naming-convention */ const mockAccount = mock({ @@ -77,6 +79,7 @@ describe('KeyringHandler', () => { mockAccounts, defaultAddressType, mockSnapClient, + mockLogger, ); beforeEach(() => { @@ -308,6 +311,82 @@ describe('KeyringHandler', () => { ).rejects.toThrow(error); expect(mockAccounts.create).toHaveBeenCalled(); }); + + describe('tracing', () => { + const options = { + scope: BtcScope.Mainnet, + index: 0, + }; + + beforeEach(() => { + mockSnapClient.startTrace.mockResolvedValue(undefined); + mockSnapClient.endTrace.mockResolvedValue(undefined); + }); + + it('calls startTrace and endTrace with correct trace name', async () => { + await handler.createAccount(options); + + expect(mockSnapClient.startTrace).toHaveBeenCalledWith( + 'Create Bitcoin Account', + ); + expect(mockSnapClient.endTrace).toHaveBeenCalledWith( + 'Create Bitcoin Account', + ); + }); + + it('calls startTrace before creating account', async () => { + const callOrder: string[] = []; + mockSnapClient.startTrace.mockImplementation(async () => { + callOrder.push('startTrace'); + }); + mockAccounts.create.mockImplementation(async () => { + callOrder.push('createAccount'); + return mockAccount; + }); + + await handler.createAccount(options); + + expect(callOrder).toStrictEqual(['startTrace', 'createAccount']); + }); + + it('calls endTrace after creating account', async () => { + const callOrder: string[] = []; + mockAccounts.create.mockImplementation(async () => { + callOrder.push('createAccount'); + return mockAccount; + }); + mockSnapClient.endTrace.mockImplementation(async () => { + callOrder.push('endTrace'); + }); + + await handler.createAccount(options); + + expect(callOrder).toStrictEqual(['createAccount', 'endTrace']); + }); + + it('creates account even if startTrace fails', async () => { + mockSnapClient.startTrace.mockResolvedValue(undefined); + + const result = await handler.createAccount(options); + + expect(result).toBeDefined(); + expect(mockAccounts.create).toHaveBeenCalled(); + expect(mockSnapClient.startTrace).toHaveBeenCalled(); + }); + + it('calls both startTrace and endTrace even if startTrace returns null', async () => { + mockSnapClient.startTrace.mockResolvedValue(undefined); + + await handler.createAccount(options); + + expect(mockSnapClient.startTrace).toHaveBeenCalledWith( + 'Create Bitcoin Account', + ); + expect(mockSnapClient.endTrace).toHaveBeenCalledWith( + 'Create Bitcoin Account', + ); + }); + }); }); describe('discoverAccounts', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index ace7daa9..8f1fbfff 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -41,7 +41,7 @@ import { string, } from 'superstruct'; -import type { BitcoinAccount, SnapClient } from '../entities'; +import type { BitcoinAccount, Logger, SnapClient } from '../entities'; import { FormatError, InexistentMethodError, @@ -64,6 +64,7 @@ import { } from './mappings'; import { validateSelectedAccounts } from './validation'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; +import { runSnapActionSafely } from '../utils/snapHelpers'; export const CreateAccountRequest = object({ scope: enums(Object.values(BtcScope)), @@ -85,16 +86,20 @@ export class KeyringHandler implements Keyring { readonly #snapClient: SnapClient; + readonly #logger: Logger; + constructor( keyringRequest: KeyringRequestHandler, accounts: AccountUseCases, defaultAddressType: AddressType, snapClient: SnapClient, + logger: Logger, ) { this.#keyringRequest = keyringRequest; this.#accountsUseCases = accounts; this.#defaultAddressType = defaultAddressType; this.#snapClient = snapClient; + this.#logger = logger; } async route(request: JsonRpcRequest): Promise { @@ -179,75 +184,97 @@ export class KeyringHandler implements Keyring { ): Promise { assert(options, CreateAccountRequest); - const { - metamask, - scope, - entropySource = 'm', - index, - derivationPath, - addressType, - synchronize = false, - accountNameSuggestion, - } = options; - - let resolvedIndex = derivationPath - ? this.#extractAccountIndex(derivationPath) - : index; - - let resolvedAddressType: AddressType; - if (addressType) { - // only support P2WPKH addresses for v1 - if (addressType !== BtcAccountType.P2wpkh) { - throw new FormatError( - 'Only native segwit (P2WPKH) addresses are supported', - ); - } - resolvedAddressType = caipToAddressType[addressType]; + const traceName = 'Create Bitcoin Account'; + let traceStarted = false; + + try { + await runSnapActionSafely( + async () => { + await this.#snapClient.startTrace(traceName); + traceStarted = true; + }, + this.#logger, + 'startTrace', + ); - // if both addressType and derivationPath are provided, validate they match - if (derivationPath) { - const pathAddressType = this.#extractAddressType(derivationPath); - if (pathAddressType !== resolvedAddressType) { - throw new FormatError('Address type and derivation path mismatch'); + const { + metamask, + scope, + entropySource = 'm', + index, + derivationPath, + addressType, + synchronize = false, + accountNameSuggestion, + } = options; + + let resolvedIndex = derivationPath + ? this.#extractAccountIndex(derivationPath) + : index; + + let resolvedAddressType: AddressType; + if (addressType) { + // only support P2WPKH addresses for v1 + if (addressType !== BtcAccountType.P2wpkh) { + throw new FormatError( + 'Only native segwit (P2WPKH) addresses are supported', + ); + } + resolvedAddressType = caipToAddressType[addressType]; + + // if both addressType and derivationPath are provided, validate they match + if (derivationPath) { + const pathAddressType = this.#extractAddressType(derivationPath); + if (pathAddressType !== resolvedAddressType) { + throw new FormatError('Address type and derivation path mismatch'); + } + } + } else if (derivationPath) { + resolvedAddressType = this.#extractAddressType(derivationPath); + } else { + resolvedAddressType = this.#defaultAddressType; + // validate default address type is P2WPKH just to be sure + if (resolvedAddressType !== 'p2wpkh') { + throw new FormatError( + 'Only native segwit (P2WPKH) addresses are supported', + ); } } - } else if (derivationPath) { - resolvedAddressType = this.#extractAddressType(derivationPath); - } else { - resolvedAddressType = this.#defaultAddressType; - // validate default address type is P2WPKH just to be sure - if (resolvedAddressType !== 'p2wpkh') { - throw new FormatError( - 'Only native segwit (P2WPKH) addresses are supported', + + // FIXME: This if should be removed ASAP as the index should always be defined or be 0 + // The Snap automatically increasing the index per request creates significant issues + // such as: concurrency, lack of idempotency, dangling state (if MM crashes before saving the account), etc. + if (resolvedIndex === undefined || resolvedIndex === null) { + const accounts = (await this.#accountsUseCases.list()).filter( + (acc) => + acc.entropySource === entropySource && + acc.network === scopeToNetwork[scope] && + acc.addressType === resolvedAddressType, ); - } - } - // FIXME: This if should be removed ASAP as the index should always be defined or be 0 - // The Snap automatically increasing the index per request creates significant issues - // such as: concurrency, lack of idempotency, dangling state (if MM crashes before saving the account), etc. - if (resolvedIndex === undefined || resolvedIndex === null) { - const accounts = (await this.#accountsUseCases.list()).filter( - (acc) => - acc.entropySource === entropySource && - acc.network === scopeToNetwork[scope] && - acc.addressType === resolvedAddressType, - ); + resolvedIndex = this.#getLowestUnusedIndex(accounts); + } - resolvedIndex = this.#getLowestUnusedIndex(accounts); + const account = await this.#accountsUseCases.create({ + network: scopeToNetwork[scope], + entropySource, + index: resolvedIndex, + addressType: resolvedAddressType, + correlationId: metamask?.correlationId, + synchronize, + accountName: accountNameSuggestion, + }); + + return mapToKeyringAccount(account); + } finally { + if (traceStarted) { + await runSnapActionSafely( + async () => this.#snapClient.endTrace(traceName), + this.#logger, + 'endTrace', + ); + } } - - const account = await this.#accountsUseCases.create({ - network: scopeToNetwork[scope], - entropySource, - index: resolvedIndex, - addressType: resolvedAddressType, - correlationId: metamask?.correlationId, - synchronize, - accountName: accountNameSuggestion, - }); - - return mapToKeyringAccount(account); } async discoverAccounts( diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index c82e7e07..66a719ff 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -90,6 +90,7 @@ const keyringHandler = new KeyringHandler( accountsUseCases, Config.defaultAddressType, snapClient, + logger, ); const cronHandler = new CronHandler( accountsUseCases, diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index fede9fd8..1793eaf9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -302,4 +302,22 @@ export class SnapClientAdapter implements SnapClient { }, }); } + + async startTrace(name: string): Promise { + await snap.request({ + method: 'snap_startTrace', + params: { + name, + }, + }); + } + + async endTrace(name: string): Promise { + await snap.request({ + method: 'snap_endTrace', + params: { + name, + }, + }); + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 6611cd2f..0e805d80 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -706,7 +706,7 @@ describe('AccountUseCases', () => { // error should be logged expect(mockLogger.error).toHaveBeenCalledWith( - 'Failed to track event', + 'Failed to execute snap action: emitTrackingEvent:TransactionReceived', trackingError, ); }); @@ -1096,7 +1096,7 @@ describe('AccountUseCases', () => { // Error should be logged expect(mockLogger.error).toHaveBeenCalledWith( - 'Failed to track event', + 'Failed to execute snap action: emitTrackingEvent:TransactionSubmitted', trackingError, ); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index deab8efa..fabde513 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -32,6 +32,7 @@ import { WalletError, } from '../entities'; import { CronMethod } from '../handlers/CronHandler'; +import { runSnapActionSafely } from '../utils/snapHelpers'; export type DiscoverAccountParams = { network: Network; @@ -238,14 +239,17 @@ export class AccountUseCases { if (!prevTx) { txsToNotify.push(tx); - await this.#trackEventSafely(async (): Promise => { - await this.#snapClient.emitTrackingEvent( - TrackingSnapEvent.TransactionReceived, - account, - tx, - origin, - ); - }); + await runSnapActionSafely( + async () => + this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionReceived, + account, + tx, + origin, + ), + this.#logger, + 'emitTrackingEvent:TransactionReceived', + ); continue; } @@ -260,27 +264,33 @@ export class AccountUseCases { if (tx.chain_position.is_confirmed) { txsToNotify.push(tx); - await this.#trackEventSafely(async (): Promise => { - await this.#snapClient.emitTrackingEvent( - TrackingSnapEvent.TransactionFinalized, - account, - tx, - origin, - ); - }); + await runSnapActionSafely( + async () => + this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionFinalized, + account, + tx, + origin, + ), + this.#logger, + 'emitTrackingEvent:TransactionFinalized', + ); } else { // if the status was changed, and now it's NOT confirmed // it means the tx was reorged. txsToNotify.push(tx); - await this.#trackEventSafely(async (): Promise => { - await this.#snapClient.emitTrackingEvent( - TrackingSnapEvent.TransactionReorged, - account, - tx, - origin, - ); - }); + await runSnapActionSafely( + async () => + this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionReorged, + account, + tx, + origin, + ), + this.#logger, + 'emitTrackingEvent:TransactionReorged', + ); } } } @@ -611,14 +621,17 @@ export class AccountUseCases { walletTx, ]); - await this.#trackEventSafely(async (): Promise => { - await this.#snapClient.emitTrackingEvent( - TrackingSnapEvent.TransactionSubmitted, - account, - walletTx, - origin, - ); - }); + await runSnapActionSafely( + async () => + this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionSubmitted, + account, + walletTx, + origin, + ), + this.#logger, + 'emitTrackingEvent:TransactionSubmitted', + ); } return txid; @@ -636,12 +649,4 @@ export class AccountUseCases { }); } } - - async #trackEventSafely(fn: () => Promise): Promise { - try { - await fn(); - } catch (error) { - this.#logger.error('Failed to track event', error); - } - } } diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snapHelpers.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snapHelpers.ts new file mode 100644 index 00000000..ab27d94d --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/utils/snapHelpers.ts @@ -0,0 +1,18 @@ +import type { Logger } from '../entities'; + +/** + * @param fn - The async function to execute + * @param logger - Logger instance for error reporting + * @param actionName - Name of the action for logging purposes + */ +export const runSnapActionSafely = async ( + fn: () => Promise, + logger: Logger, + actionName: string, +): Promise => { + try { + await fn(); + } catch (error) { + logger.error(`Failed to execute snap action: ${actionName}`, error); + } +}; From 41e397b1440a9385dd6da4bfb4a5e9bd794be739 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Anglada Date: Wed, 12 Nov 2025 17:25:49 +0100 Subject: [PATCH 311/362] fix: accounts under encrypted state (#560) * fix: accounts under encrypted state * chore: fix test * chore: fix test --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/infra/SnapClientAdapter.ts | 12 +++- .../src/store/BdkAccountRepository.test.ts | 59 ++++++++++++------- .../src/store/BdkAccountRepository.ts | 30 ++++++---- 4 files changed, 67 insertions(+), 36 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 76f46613..8e8f4089 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "SCdgZXGvpGJ62ZC1laEIQ6CSaWUCrA85T+tWIFirhqk=", + "shasum": "nGyqPBVxGl7PFfJfi2eo+6zuj0ExrR55GPcK8mKy/lU=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 1793eaf9..f7d27310 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -33,12 +33,20 @@ export class SnapClientAdapter implements SnapClient { this.#encrypt = encrypt; } + decideToEncrypt(key?: string): boolean { + if (!key) { + return this.#encrypt; + } + + return key.includes('accounts') || this.#encrypt; + } + async getState(key?: string): Promise { return snap.request({ method: 'snap_getState', params: { key, - encrypted: this.#encrypt, + encrypted: this.decideToEncrypt(key), }, }); } @@ -49,7 +57,7 @@ export class SnapClientAdapter implements SnapClient { params: { key, value: newState, - encrypted: this.#encrypt, + encrypted: this.decideToEncrypt(key), }, }); } diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts index f61421d7..eb131770 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts @@ -15,7 +15,6 @@ import type { BitcoinAccount, Inscription, SnapClient, - SnapState, } from '../entities'; import { BdkAccountAdapter } from '../infra'; import { BdkAccountRepository } from './BdkAccountRepository'; @@ -138,6 +137,30 @@ describe('BdkAccountRepository', () => { expect(BdkAccountAdapter.load).toHaveBeenCalledTimes(2); expect(result).toStrictEqual([mockAccount1, mockAccount2]); }); + + it('filters out null accounts', async () => { + const id1 = 'some-id-1'; + const id2 = 'some-id-2'; + const id3 = 'deleted-id'; + const state = { + [id1]: { ...mockAccountState, id: id1 }, + [id2]: { ...mockAccountState, id: id2 }, + [id3]: null, // Deleted account + }; + const mockAccount1 = { ...mockAccount, id: id1 }; + const mockAccount2 = { ...mockAccount, id: id2 }; + + mockSnapClient.getState.mockResolvedValue(state); + (BdkAccountAdapter.load as jest.Mock) + .mockReturnValueOnce(mockAccount1) + .mockReturnValueOnce(mockAccount2); + + const result = await repo.getAll(); + + expect(mockSnapClient.getState).toHaveBeenCalledWith('accounts'); + expect(BdkAccountAdapter.load).toHaveBeenCalledTimes(2); + expect(result).toStrictEqual([mockAccount1, mockAccount2]); + }); }); describe('getByDerivationPath', () => { @@ -286,32 +309,26 @@ describe('BdkAccountRepository', () => { }); it('removes wallet data from store', async () => { - const mockState: SnapState = { - accounts: { - 'some-id-1': { ...mockAccountState, derivationPath: ["m/84'/0'/0'"] }, - 'some-id-2': { ...mockAccountState, derivationPath: ["m/86'/0'/0'"] }, - }, - derivationPaths: { - "m/84'/0'/0'": 'some-id-1', - "m/86'/0'/0'": 'some-id-2', - }, - }; - const expectedState = { - accounts: { - 'some-id-2': { ...mockAccountState, derivationPath: ["m/86'/0'/0'"] }, - }, - derivationPaths: { - "m/86'/0'/0'": 'some-id-2', - }, + const accountState: AccountState = { + ...mockAccountState, + derivationPath: ['m', "84'", "0'", "0'"], }; - mockSnapClient.getState.mockResolvedValue(mockState); + mockSnapClient.getState.mockResolvedValue(accountState); await repo.delete('some-id-1'); + expect(mockSnapClient.getState).toHaveBeenCalledWith( + 'accounts.some-id-1', + ); + expect(mockSnapClient.setState).toHaveBeenCalledTimes(2); + expect(mockSnapClient.setState).toHaveBeenCalledWith( + "derivationPaths.m/84'/0'/0'", + null, + ); expect(mockSnapClient.setState).toHaveBeenCalledWith( - undefined, - expectedState, + 'accounts.some-id-1', + null, ); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts index a9e48520..37b5d444 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts @@ -64,13 +64,15 @@ export class BdkAccountRepository implements BitcoinAccountRepository { return []; } - return Object.entries(accounts).map(([id, account]) => - BdkAccountAdapter.load( - id, - account.derivationPath, - ChangeSet.from_json(account.wallet), - ), - ); + return Object.entries(accounts) + .filter(([, account]) => account !== null) + .map(([id, account]) => + BdkAccountAdapter.load( + id, + account.derivationPath, + ChangeSet.from_json(account.wallet), + ), + ); } async getByDerivationPath( @@ -204,15 +206,19 @@ export class BdkAccountRepository implements BitcoinAccountRepository { } async delete(id: string): Promise { - const state = (await this.#snapClient.getState()) as SnapState | null; - if (!state?.accounts[id]) { + const account = (await this.#snapClient.getState( + `accounts.${id}`, + )) as AccountState | null; + if (!account) { return; } - delete state.derivationPaths[state.accounts[id].derivationPath.join('/')]; - delete state.accounts[id]; + const derivationPath = account.derivationPath.join('/'); - await this.#snapClient.setState(undefined, state); + await Promise.all([ + this.#snapClient.setState(`derivationPaths.${derivationPath}`, null), + this.#snapClient.setState(`accounts.${id}`, null), + ]); } async fetchInscriptions(id: string): Promise { From 12419a8949c6aa223f9fce7144931a3afa5cbfeb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:43:14 +0100 Subject: [PATCH 312/362] 1.5.0 (#561) * 1.5.0 * Update CHANGELOG.md --------- Co-authored-by: github-actions Co-authored-by: Alejandro Garcia Anglada --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 13 ++++++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 5648a06a..f3864978 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.0] + +### Added + +- Add perf tracing account creation ([#557](https://github.com/MetaMask/snap-bitcoin-wallet/pull/557)) + +### Changed + +- State `accounts` under encrypted state ([#560](https://github.com/MetaMask/snap-bitcoin-wallet/pull/560)) + ## [1.4.5] ### Added @@ -522,7 +532,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.5...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.5.0...HEAD +[1.5.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.5...v1.5.0 [1.4.5]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.4...v1.4.5 [1.4.4]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.3...v1.4.4 [1.4.3]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.2...v1.4.3 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 6e9d0b63..f0cb3608 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.4.5", + "version": "1.5.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 44d5e904273ed7b4c50edcbefda5a586a8e01df1 Mon Sep 17 00:00:00 2001 From: Alejandro Garcia Anglada Date: Mon, 17 Nov 2025 12:11:36 +0100 Subject: [PATCH 313/362] feat: add from/to to confirmation (#563) --- .../bitcoin-wallet-snap/snap.manifest.json | 4 +-- .../unified-send-flow/UnifiedSendFormView.tsx | 28 +++++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 8e8f4089..c0bbd8c5 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.4.5", + "version": "1.5.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "nGyqPBVxGl7PFfJfi2eo+6zuj0ExrR55GPcK8mKy/lU=", + "shasum": "j9W3ulkpAGrL0lkkChX7wavGEY3qq6nCf4lQ0KmmF78=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx index b73ffedb..cc36b6df 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx @@ -35,7 +35,8 @@ export const UnifiedSendFormView: SnapComponent = ({ messages, }) => { const t = translate(messages); - const { amount, exchangeRate, network, from, explorerUrl } = context; + const { amount, exchangeRate, network, from, recipient, explorerUrl } = + context; const psbt = Psbt.from_string(context.psbt); const fee = psbt.fee().to_sat(); @@ -87,7 +88,7 @@ export const UnifiedSendFormView: SnapComponent = ({ {null} - {t('confirmation.account')} + {t('from')} {isValidSnapLinkProtocol(explorerUrl) ? ( @@ -98,6 +99,29 @@ export const UnifiedSendFormView: SnapComponent = ({ )} {null} + + + {t('toAddress')} + + {isValidSnapLinkProtocol(explorerUrl) ? ( + +
+ + ) : ( +
+ )} + + {null} {t('network')} From f66ba7dd5978cd8b2a44411c9ce7e094bac552ae Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:38:55 +0100 Subject: [PATCH 314/362] 1.6.0 (#565) * 1.6.0 * chore: edit CHANGELOG.MD * Update packages/snap/CHANGELOG.md Co-authored-by: Alejandro Garcia Anglada --------- Co-authored-by: github-actions Co-authored-by: Frederic HENG Co-authored-by: Alejandro Garcia Anglada --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index f3864978..303514e0 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.6.0] + +### Changed + +- Add `from` and `to` to confirmation ([#563](https://github.com/MetaMask/snap-bitcoin-wallet/pull/563)) + ## [1.5.0] ### Added @@ -532,7 +538,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.5.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.6.0...HEAD +[1.6.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.5.0...v1.6.0 [1.5.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.5...v1.5.0 [1.4.5]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.4...v1.4.5 [1.4.4]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.3...v1.4.4 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index f0cb3608..38ab75b3 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.5.0", + "version": "1.6.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 958cf4291455f92f0b0c34088c8e9093fa4eee20 Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 26 Nov 2025 14:03:07 +0100 Subject: [PATCH 315/362] feat: integrate the 'signRewardsMessage' flow (#566) * feat: add the parse validation part of the RewardsMessage * feat: use atob for base64 decoding * test: add unit tests related to parseRewardsMessage validation * feat: add validation of address * feat: add signature * chore: remove unused variable * test: add unit test for signMessageDirect * fix: update snap manifest version * feat: refactor using accountUseCases.signMessage * chore: update signMessage skipConfirmation flag to have an option visibility * add metamask as origin for signMessage from signRewardsMessage --- .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../src/handlers/RpcHandler.test.ts | 96 ++++++++++ .../src/handlers/RpcHandler.ts | 61 ++++++ .../src/handlers/validation.test.ts | 174 ++++++++++++++++++ .../src/handlers/validation.ts | 67 +++++++ .../src/use-cases/AccountUseCases.test.ts | 10 + .../src/use-cases/AccountUseCases.ts | 14 +- 7 files changed, 419 insertions(+), 7 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index c0bbd8c5..7cc1bb1d 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.5.0", + "version": "1.6.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "j9W3ulkpAGrL0lkkChX7wavGEY3qq6nCf4lQ0KmmF78=", + "shasum": "TPFaRAwYCn6fMO0uaOcIMxprEPRPjX8aB0JViJv5gWA=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index f3716f73..70cf79e7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -967,4 +967,100 @@ describe('RpcHandler', () => { }); }); }); + + describe('signRewardsMessage', () => { + const mockBitcoinAccount = mock({ + id: validAccountId, + publicAddress: { + toString: () => 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k', + }, + network: 'bitcoin', + }); + + it('successfully signs a valid rewards message', async () => { + const address = 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k'; + const timestamp = 1736660000; + const message = btoa(`rewards,${address},${timestamp}`); + + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount); + jest + .mocked(Address.from_string) + .mockReturnValue(mockBitcoinAccount.publicAddress); + + const signMessageSpy = jest + .spyOn(mockAccountsUseCases, 'signMessage' as keyof AccountUseCases) + .mockResolvedValue('mock-signature-base64' as never); + + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id: '1', + method: RpcMethod.SignRewardsMessage, + params: { + accountId: validAccountId, + message, + }, + }; + + const result = await handler.route(origin, request); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + expect(Address.from_string).toHaveBeenCalledWith(address, 'bitcoin'); + expect(signMessageSpy).toHaveBeenCalledWith( + validAccountId, + `rewards,${address},${timestamp}`, + 'metamask', + { skipConfirmation: true }, + ); + expect(result).toStrictEqual({ signature: 'mock-signature-base64' }); + }); + + it('throws error when address in message does not match account', async () => { + const differentAddress = 'bc1qdifferentaddress123456789abcdefgh'; + const timestamp = 1736660000; + const message = btoa(`rewards,${differentAddress},${timestamp}`); + + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount); + jest + .mocked(Address.from_string) + .mockReturnValue(mockBitcoinAccount.publicAddress); + + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id: '1', + method: RpcMethod.SignRewardsMessage, + params: { + accountId: validAccountId, + message, + }, + }; + + await expect(handler.route(origin, request)).rejects.toThrow( + 'does not match signing account address', + ); + }); + + it('throws error when account is not found', async () => { + const address = 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k'; + const timestamp = 1736660000; + const message = btoa(`rewards,${address},${timestamp}`); + + mockAccountsUseCases.get.mockResolvedValue( + null as unknown as BitcoinAccount, + ); + + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id: '1', + method: RpcMethod.SignRewardsMessage, + params: { + accountId: 'non-existent-account', + message, + }, + }; + + await expect(handler.route(origin, request)).rejects.toThrow( + 'Account not found', + ); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index f9df78c2..b974ff80 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -34,6 +34,7 @@ import { validateAddress, validateAccountBalance, validateDustLimit, + parseRewardsMessage, } from './validation'; export const CreateSendFormRequest = object({ @@ -64,6 +65,11 @@ export const VerifyMessageRequest = object({ signature: string(), }); +export const SignRewardsMessageRequest = object({ + accountId: string(), + message: string(), +}); + export class RpcHandler { readonly #logger: Logger; @@ -124,6 +130,10 @@ export class RpcHandler { params.signature, ); } + case RpcMethod.SignRewardsMessage: { + assert(params, SignRewardsMessageRequest); + return this.#signRewardsMessage(params.accountId, params.message); + } default: throw new InexistentMethodError(`Method not found: ${method}`); @@ -296,4 +306,55 @@ export class RpcHandler { throw error; } } + + /** + * Handles the signing of a rewards message, of format 'rewards,{address},{timestamp}' base64 encoded. + * + * @param accountId - The ID of the account to sign with + * @param message - The base64-encoded rewards message + * @returns The signature + * @throws {ValidationError} If the account is not found or if the address in the message doesn't match the signing account + */ + async #signRewardsMessage( + accountId: string, + message: string, + ): Promise<{ signature: string }> { + const { address: messageAddress } = parseRewardsMessage(message); + + const account = await this.#accountUseCases.get(accountId); + if (!account) { + throw new ValidationError('Account not found', { accountId }); + } + + const addressValidation = validateAddress( + messageAddress, + account.network, + this.#logger, + ); + if (!addressValidation.valid) { + throw new ValidationError( + `Invalid Bitcoin address in rewards message for network ${account.network}`, + { messageAddress, network: account.network }, + ); + } + + const accountAddress = account.publicAddress.toString(); + if (messageAddress !== accountAddress) { + throw new ValidationError( + `Address in rewards message (${messageAddress}) does not match signing account address (${accountAddress})`, + { messageAddress, accountAddress }, + ); + } + + const decodedMessage = atob(message); + + const signature = await this.#accountUseCases.signMessage( + accountId, + decodedMessage, + 'metamask', + { skipConfirmation: true }, + ); + + return { signature }; + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts new file mode 100644 index 00000000..5c7a6b7b --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts @@ -0,0 +1,174 @@ +import { parseRewardsMessage } from './validation'; + +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => ({ + Address: { + from_string: jest.fn(), + }, + Amount: { + from_btc: jest.fn(), + }, +})); + +describe('validation', () => { + describe('parseRewardsMessage', () => { + const validBitcoinMainnetAddress = + 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'; + const currentTimestamp = Math.floor(Date.now() / 1000); + + const toBase64 = (utf8: string): string => btoa(utf8); + + describe('valid parsing', () => { + it('correctly extracts address and timestamp from valid message', () => { + const expectedAddress = validBitcoinMainnetAddress; + const expectedTimestamp = 1736660000; + const utf8Message = `rewards,${expectedAddress},${expectedTimestamp}`; + const base64Message = toBase64(utf8Message); + + const result = parseRewardsMessage(base64Message); + + expect(result.address).toBe(expectedAddress); + expect(result.timestamp).toBe(expectedTimestamp); + }); + }); + + describe('invalid messages', () => { + it('rejects string that decodes but not rewards format', () => { + const base64Message = 'hello world'; + expect(() => parseRewardsMessage(base64Message)).toThrow( + 'Message must start with "rewards,"', + ); + }); + + it('rejects empty string', () => { + const base64Message = ''; + expect(() => parseRewardsMessage(base64Message)).toThrow( + 'Message must start with "rewards,"', + ); + }); + + it('rejects invalid base64 with special characters', () => { + const base64Message = '!!!@@@###'; + expect(() => parseRewardsMessage(base64Message)).toThrow( + 'Invalid base64 encoding', + ); + }); + }); + + describe('invalid message prefix', () => { + it.each([ + { + message: `reward,${validBitcoinMainnetAddress},${currentTimestamp}`, + description: "missing 's'", + }, + { + message: `Rewards,${validBitcoinMainnetAddress},${currentTimestamp}`, + description: 'wrong case (capitalized)', + }, + { + message: `bonus,${validBitcoinMainnetAddress},${currentTimestamp}`, + description: 'wrong prefix', + }, + { + message: `${validBitcoinMainnetAddress},${currentTimestamp}`, + description: 'no prefix', + }, + { + message: `REWARDS,${validBitcoinMainnetAddress},${currentTimestamp}`, + description: 'all caps', + }, + ])( + 'rejects message that does not start with "rewards,": $description', + ({ message: utf8Message }) => { + const base64Message = toBase64(utf8Message); + expect(() => parseRewardsMessage(base64Message)).toThrow( + 'Message must start with "rewards,"', + ); + }, + ); + }); + + describe('invalid message structure', () => { + it('rejects message with only prefix', () => { + const utf8Message = 'rewards,'; + const base64Message = toBase64(utf8Message); + expect(() => parseRewardsMessage(base64Message)).toThrow( + 'Message must have exactly 3 parts', + ); + }); + + it('rejects message missing timestamp', () => { + const utf8Message = `rewards,${validBitcoinMainnetAddress}`; + const base64Message = toBase64(utf8Message); + expect(() => parseRewardsMessage(base64Message)).toThrow( + 'Message must have exactly 3 parts', + ); + }); + + it('rejects message with too many parts', () => { + const utf8Message = `rewards,${validBitcoinMainnetAddress},${currentTimestamp},extra`; + const base64Message = toBase64(utf8Message); + expect(() => parseRewardsMessage(base64Message)).toThrow( + 'Message must have exactly 3 parts', + ); + }); + + it('rejects message with empty parts', () => { + const utf8Message = 'rewards,,,'; + const base64Message = toBase64(utf8Message); + expect(() => parseRewardsMessage(base64Message)).toThrow(/timestamp/iu); + }); + + it('rejects message with only prefix without comma', () => { + const utf8Message = 'rewards'; + const base64Message = toBase64(utf8Message); + expect(() => parseRewardsMessage(base64Message)).toThrow( + 'Message must start with "rewards,"', + ); + }); + }); + + describe('invalid timestamps', () => { + it.each([ + { + timestamp: 'invalid', + description: 'non-numeric', + }, + { + timestamp: '', + description: 'empty timestamp', + }, + { + timestamp: '-1', + description: 'negative timestamp', + }, + { + timestamp: '0', + description: 'zero timestamp', + }, + { + timestamp: '123.456', + description: 'decimal timestamp', + }, + { + timestamp: '1.0', + description: 'decimal with .0', + }, + { + timestamp: 'abc123', + description: 'alphanumeric', + }, + ])( + 'rejects message with invalid timestamp: $description', + ({ timestamp }) => { + const utf8Message = `rewards,${validBitcoinMainnetAddress},${timestamp}`; + const base64Message = toBase64(utf8Message); + + expect(() => parseRewardsMessage(base64Message)).toThrow( + /timestamp/iu, + ); + }, + ); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts index 7d9080b0..32c57993 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts @@ -11,6 +11,7 @@ import { string, nonempty, refine, + is, } from 'superstruct'; import type { BitcoinAccount, CodifiedError, Logger } from '../entities'; @@ -24,6 +25,7 @@ export enum RpcMethod { OnAddressInput = 'onAddressInput', OnAmountInput = 'onAmountInput', ConfirmSend = 'confirmSend', + SignRewardsMessage = 'signRewardsMessage', } export enum SendErrorCodes { @@ -214,3 +216,68 @@ export function validateDustLimit( } return NO_ERRORS_RESPONSE; } + +export const PositiveNumberStringStruct = pattern( + string(), + /^(?!0\d)(\d+(\.\d+)?)$/u, +); + +/** + * Parses a base64-encoded rewards message in the format 'rewards,{address},{timestamp}' + * + * @param base64Message - The base64-encoded message to parse + * @returns Object containing the parsed address and timestamp + * @throws Error if the message format is invalid + */ +export function parseRewardsMessage(base64Message: string): { + address: string; + timestamp: number; +} { + // Decode the message from base64 to utf8 + let decodedMessage: string; + try { + decodedMessage = atob(base64Message); + } catch { + throw new Error('Invalid base64 encoding'); + } + + // Check if message starts with 'rewards,' + if (!decodedMessage.startsWith('rewards,')) { + throw new Error('Message must start with "rewards,"'); + } + + // Split the message into parts + const parts = decodedMessage.split(','); + if (parts.length !== 3) { + throw new Error( + 'Message must have exactly 3 parts: rewards,{address},{timestamp}', + ); + } + + const [prefix, addressPart, timestampPart] = parts; + + // Validate prefix (already checked above, but being explicit) + if (prefix !== 'rewards') { + throw new Error('Message must start with "rewards"'); + } + + // Validate timestamp + if (!is(timestampPart, PositiveNumberStringStruct)) { + throw new Error('Invalid timestamp format'); + } + + // Ensure timestamp is an integer (no decimals) + if (timestampPart.includes('.')) { + throw new Error('Invalid timestamp'); + } + + const timestamp = parseInt(timestampPart, 10); + if (timestamp <= 0) { + throw new Error('Invalid timestamp'); + } + + return { + address: addressPart as string, + timestamp, + }; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 0e805d80..e315cc9d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -1557,5 +1557,15 @@ describe('AccountUseCases', () => { useCases.signMessage('account-id', mockMessage, mockOrigin), ).rejects.toBe(error); }); + + it('skips confirmation when skipConfirmation option is true', async () => { + await useCases.signMessage('account-id', mockMessage, mockOrigin, { + skipConfirmation: true, + }); + + expect( + mockConfirmationRepository.insertSignMessage, + ).not.toHaveBeenCalled(); + }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index fabde513..09e46df3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -493,6 +493,7 @@ export class AccountUseCases { id: string, message: string, origin: string, + options: { skipConfirmation?: boolean } = {}, ): Promise { this.#logger.debug('Signing message: %s. Message: %s', id, message); @@ -502,11 +503,14 @@ export class AccountUseCases { } this.#checkCapability(account, AccountCapability.SignMessage); - await this.#confirmationRepository.insertSignMessage( - account, - message, - origin, - ); + const { skipConfirmation = false } = options; + if (!skipConfirmation) { + await this.#confirmationRepository.insertSignMessage( + account, + message, + origin, + ); + } const entropy = await this.#snapClient.getPrivateEntropy( account.derivationPath.concat(['0', '0']), // We sign with address index 0, which is the public address From ceacd6255e002dd1ab780e2aaf678404d208a292 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:29:01 +0100 Subject: [PATCH 316/362] 1.7.0 (#567) * 1.7.0 * chore: update version and CHANGELOG.MD * Update packages/snap/CHANGELOG.md Co-authored-by: Alejandro Garcia Anglada --------- Co-authored-by: github-actions Co-authored-by: Frederic HENG Co-authored-by: Alejandro Garcia Anglada --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 303514e0..305f73fb 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.7.0] + +### Added + +- Add `signRewardsMessage` method ([#566](https://github.com/MetaMask/snap-bitcoin-wallet/pull/566)) + ## [1.6.0] ### Changed @@ -538,7 +544,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.6.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.7.0...HEAD +[1.7.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.6.0...v1.7.0 [1.6.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.5.0...v1.6.0 [1.5.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.5...v1.5.0 [1.4.5]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.4...v1.4.5 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 38ab75b3..91f86918 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.6.0", + "version": "1.7.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 7cc1bb1d..0510dd84 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.6.0", + "version": "1.7.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "TPFaRAwYCn6fMO0uaOcIMxprEPRPjX8aB0JViJv5gWA=", + "shasum": "an5d3f7caF4hiwanFK329pfEXbsZVO5hvsnudh6cBq8=", "location": { "npm": { "filePath": "dist/bundle.js", From 70baa8786f35d382931201b08e3d910cac22cf2e Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 1 Dec 2025 15:11:06 +0100 Subject: [PATCH 317/362] feat: Remove onRPCRequest (#568) * feat: Remove onRPCRequest * chore: remove RPC permissions * chore: wait for release --- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 6 +----- merged-packages/bitcoin-wallet-snap/src/index.ts | 4 ---- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 0510dd84..968c037d 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "an5d3f7caF4hiwanFK329pfEXbsZVO5hvsnudh6cBq8=", + "shasum": "ZJWGWIUjaU7vre7bgrTSY68GMKyiB9RNVf6GHuksC4Y=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -31,10 +31,6 @@ }, "initialPermissions": { "endowment:webassembly": {}, - "endowment:rpc": { - "dapps": true, - "snaps": false - }, "endowment:keyring": {}, "snap_getBip32Entropy": [ { diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 66a719ff..5759e0c7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -2,7 +2,6 @@ import type { OnAssetsConversionHandler, OnAssetsLookupHandler, OnCronjobHandler, - OnRpcRequestHandler, OnKeyringRequestHandler, OnUserInputHandler, OnAssetHistoricalPriceHandler, @@ -112,9 +111,6 @@ const assetsHandler = new AssetsHandler( export const onCronjob: OnCronjobHandler = async ({ request }) => middleware.handle(async () => cronHandler.route(request)); -export const onRpcRequest: OnRpcRequestHandler = async ({ origin, request }) => - middleware.handle(async () => rpcHandler.route(origin, request)); - export const onClientRequest: OnClientRequestHandler = async ({ request }) => middleware.handle(async () => rpcHandler.route('metamask', request)); From 15c28d3292573d46d1d29be56cfdb9c92344bcf4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 14:35:37 +0000 Subject: [PATCH 318/362] 1.8.0 (#569) * 1.8.0 * chore: update version and CHANGELOG.MD --------- Co-authored-by: github-actions Co-authored-by: Frederic HENG --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 9 ++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 305f73fb..4df611fd 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.8.0] + +### Changed + +- feat: Remove `onRPCRequest` ([#568](https://github.com/MetaMask/snap-bitcoin-wallet/pull/568)) + ## [1.7.0] ### Added @@ -544,7 +550,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.7.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.8.0...HEAD +[1.8.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.7.0...v1.8.0 [1.7.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.6.0...v1.7.0 [1.6.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.5.0...v1.6.0 [1.5.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.4.5...v1.5.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 91f86918..7af469ac 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.7.0", + "version": "1.8.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 968c037d..6aabdd1f 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.7.0", + "version": "1.8.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "ZJWGWIUjaU7vre7bgrTSY68GMKyiB9RNVf6GHuksC4Y=", + "shasum": "hmikd6KULvwRb0sm9GHhXADSxdDLW2csc66F6nllr54=", "location": { "npm": { "filePath": "dist/bundle.js", From 5fa04eec84e3e98e0f334a19ad93829a3af2214a Mon Sep 17 00:00:00 2001 From: Fred Date: Tue, 2 Dec 2025 12:32:58 +0100 Subject: [PATCH 319/362] fix: update p2wsh, p2tr and p2sh dust minimum value (#570) * fix: update p2wsh and p2tr dust minimum value * fix: update shasum from snap manifest --- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 2 +- .../bitcoin-wallet-snap/src/handlers/validation.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 6aabdd1f..3eb205e9 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "hmikd6KULvwRb0sm9GHhXADSxdDLW2csc66F6nllr54=", + "shasum": "6kdMznE7tGXmwImqgqrIl4Kw7eCiiEOrHTE5TDhhWeg=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts index 32c57993..ccd73a25 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts @@ -188,11 +188,11 @@ function getDustLimitSats(addressType: AddressType): bigint { case 'p2pkh': return 546n; case 'p2sh': - return 546n; + return 540n; case 'p2wsh': - return 546n; + return 330n; case 'p2tr': - return 546n; + return 330n; default: return 546n; } From f0d3e08db12b65cea431a74e12e8e95271e82c25 Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 14 Jan 2026 15:37:11 +0100 Subject: [PATCH 320/362] feat: add change utxo dropped when full swap use case (#572) * feat: add change utxo dropped when full swap use case * test: script testing building template from quote * test: remove the non used testing script * chore: update shasum in manifest.json --- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/use-cases/AccountUseCases.test.ts | 197 +++++++++++++++++- .../src/use-cases/AccountUseCases.ts | 24 ++- 3 files changed, 217 insertions(+), 6 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 3eb205e9..7215aa79 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "6kdMznE7tGXmwImqgqrIl4Kw7eCiiEOrHTE5TDhhWeg=", + "shasum": "szJAoKtgR6QoysiE04qSBfy2Dz95MiYIChtIxmdOtjI=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index e315cc9d..f1cb916c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -882,7 +882,11 @@ describe('AccountUseCases', () => { const mockFeeEstimates = mock({ get: () => mockFeeRate, }); - const mockFilledPsbt = mock(); + const mockFilledPsbt = mock({ + unsigned_tx: { + output: [mockOutput], + }, + }); const mockTxBuilder = mock({ addRecipientByScript: jest.fn(), feeRate: jest.fn(), @@ -1129,7 +1133,11 @@ describe('AccountUseCases', () => { get: () => mockFeeRate, }); const mockFrozenUTXOs = ['utxo1', 'utxo2']; - const mockFilledPsbt = mock(); + const mockFilledPsbt = mock({ + unsigned_tx: { + output: [mockOutput], + }, + }); const mockTxBuilder = mock({ addRecipientByScript: jest.fn(), feeRate: jest.fn(), @@ -1225,6 +1233,179 @@ describe('AccountUseCases', () => { ), ); }); + + it('preserves all outputs from bridge PSBT template including change', async () => { + // Simulate a bridge transaction with 3 outputs: + // Output 0: Bridge deposit + // Output 1: OP_RETURN data + // Output 2: Change output + const bridgeDepositOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + const opReturnOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + const changeOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + + const bridgePsbt = mock({ + unsigned_tx: { + output: [bridgeDepositOutput, opReturnOutput, changeOutput], + }, + toString: () => 'bridgePsbtBase64', + }); + + // Mock the built PSBT to have matching output count (3 outputs) + // This simulates the happy path where drainToByScript works correctly + const builtBridgePsbt = mock({ + unsigned_tx: { + output: [bridgeDepositOutput, opReturnOutput, changeOutput], + }, + }); + mockTxBuilder.finish.mockReturnValue(builtBridgePsbt); + + // Account recognizes the change output as its own + const accountWithChange = mock({ + id: 'account-id', + network: 'bitcoin', + isMine: (script: ScriptBuf) => script === changeOutput.script_pubkey, + capabilities: [AccountCapability.FillPsbt], + }); + accountWithChange.buildTx.mockReturnValue(mockTxBuilder); + mockRepository.get.mockResolvedValueOnce(accountWithChange); + + await useCases.fillPsbt('account-id', bridgePsbt); + + // Verify exact call counts: + // - drainToByScript: called exactly 1 time (for change output) + // - addRecipientByScript: called exactly 2 times (for bridge deposit and OP_RETURN) + expect(mockTxBuilder.drainToByScript).toHaveBeenCalledTimes(1); + expect(mockTxBuilder.drainToByScript).toHaveBeenCalledWith( + changeOutput.script_pubkey, + ); + + expect(mockTxBuilder.addRecipientByScript).toHaveBeenCalledTimes(2); + expect(mockTxBuilder.addRecipientByScript).toHaveBeenNthCalledWith( + 1, + bridgeDepositOutput.value, + bridgeDepositOutput.script_pubkey, + ); + expect(mockTxBuilder.addRecipientByScript).toHaveBeenNthCalledWith( + 2, + opReturnOutput.value, + opReturnOutput.script_pubkey, + ); + + // finish() should only be called once since output count matches + expect(mockTxBuilder.finish).toHaveBeenCalledTimes(1); + }); + + it('rebuilds with fixed recipients when change output is dropped (full-balance swap)', async () => { + // This test specifically covers the case where change was dropped + // when user swapped their entire balance through a bridge + // Simulate a bridge transaction with 3 outputs: + // Output 0: Bridge deposit + // Output 1: OP_RETURN data + // Output 2: Change output + const bridgeDepositOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + const opReturnOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + const changeOutput = mock({ + script_pubkey: mock(), + value: mock(), + }); + + const fullBalanceSwapPsbt = mock({ + unsigned_tx: { + output: [bridgeDepositOutput, opReturnOutput, changeOutput], + }, + toString: () => 'fullBalanceSwapPsbt', + }); + + // First build returns PSBT with dropped change + const droppedChangePsbt = mock({ + unsigned_tx: { + output: [bridgeDepositOutput, opReturnOutput], // Only 2 outputs - change is dropped + }, + }); + // Second build (rebuild) returns PSBT with all outputs preserved + const rebuiltPsbt = mock({ + unsigned_tx: { + output: [bridgeDepositOutput, opReturnOutput, changeOutput], // All 3 outputs preserved + }, + }); + mockTxBuilder.finish + .mockReturnValueOnce(droppedChangePsbt) // First attempt: change dropped + .mockReturnValueOnce(rebuiltPsbt); // Second attempt: rebuilt with fixed recipients + + // Account recognizes the change output as its own + const accountFullBalance = mock({ + id: 'account-id', + network: 'bitcoin', + isMine: (script: ScriptBuf) => script === changeOutput.script_pubkey, + capabilities: [AccountCapability.FillPsbt], + }); + accountFullBalance.buildTx.mockReturnValue(mockTxBuilder); + mockRepository.get.mockResolvedValueOnce(accountFullBalance); + + const result = await useCases.fillPsbt('account-id', fullBalanceSwapPsbt); + + // finish() called twice: first attempt (change dropped) + rebuild + expect(mockTxBuilder.finish).toHaveBeenCalledTimes(2); + + // drainToByScript: called exactly 1 time (for change output in first attempt) + expect(mockTxBuilder.drainToByScript).toHaveBeenCalledTimes(1); + expect(mockTxBuilder.drainToByScript).toHaveBeenCalledWith( + changeOutput.script_pubkey, + ); + + // addRecipientByScript call count: + // First attempt: 2 calls (bridge deposit + OP_RETURN) + // Second attempt (rebuild): 3 calls (all outputs as fixed recipients) + // Total: 2 + 3 = 5 + expect(mockTxBuilder.addRecipientByScript).toHaveBeenCalledTimes(5); + + // First attempt: bridge deposit and OP_RETURN + expect(mockTxBuilder.addRecipientByScript).toHaveBeenNthCalledWith( + 1, + bridgeDepositOutput.value, + bridgeDepositOutput.script_pubkey, + ); + expect(mockTxBuilder.addRecipientByScript).toHaveBeenNthCalledWith( + 2, + opReturnOutput.value, + opReturnOutput.script_pubkey, + ); + + // Second attempt (rebuild): all 3 outputs as fixed recipients + expect(mockTxBuilder.addRecipientByScript).toHaveBeenNthCalledWith( + 3, + bridgeDepositOutput.value, + bridgeDepositOutput.script_pubkey, + ); + expect(mockTxBuilder.addRecipientByScript).toHaveBeenNthCalledWith( + 4, + opReturnOutput.value, + opReturnOutput.script_pubkey, + ); + expect(mockTxBuilder.addRecipientByScript).toHaveBeenNthCalledWith( + 5, + changeOutput.value, + changeOutput.script_pubkey, + ); + + // Result should be the rebuilt PSBT with all outputs preserved + expect(result).toBe(rebuiltPsbt); + }); }); describe('computeFee', () => { @@ -1250,7 +1431,11 @@ describe('AccountUseCases', () => { get: () => mockFeeRate, }); const mockFrozenUTXOs = ['utxo1', 'utxo2']; - const mockFilledPsbt = mock(); + const mockFilledPsbt = mock({ + unsigned_tx: { + output: [mockOutput], + }, + }); const mockTxBuilder = mock({ addRecipientByScript: jest.fn(), feeRate: jest.fn(), @@ -1366,7 +1551,11 @@ describe('AccountUseCases', () => { const mockFeeEstimates = mock({ get: () => mockFeeRate, }); - const mockFilledPsbt = mock(); + const mockFilledPsbt = mock({ + unsigned_tx: { + output: [mockOutput], + }, + }); const mockTxBuilder = mock({ addRecipient: jest.fn(), addRecipientByScript: jest.fn(), diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 09e46df3..2f3210b6 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -592,7 +592,29 @@ export class AccountUseCases { ); } } - return builder.finish(); + let builtPsbt = builder.finish(); + + if ( + builtPsbt.unsigned_tx.output.length < + templatePsbt.unsigned_tx.output.length + ) { + // Second attempt: use fixed recipients for all outputs + builder = account + .buildTx() + .feeRate(feeRateToUse) + .unspendable(frozenUTXOs) + .untouchedOrdering(); + + for (const txout of templatePsbt.unsigned_tx.output) { + builder = builder.addRecipientByScript( + txout.value, + txout.script_pubkey, + ); + } + builtPsbt = builder.finish(); + } + + return builtPsbt; } catch (error) { throw new ValidationError( 'Failed to build PSBT from template', From cce8bf3b019caedd152409dea47c02696f0e3457 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:06:16 +0100 Subject: [PATCH 321/362] 1.9.0 (#575) * 1.9.0 * chore: update version and CHANGELOG.MD --------- Co-authored-by: github-actions Co-authored-by: Frederic HENG --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 10 +++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 4df611fd..4b74620f 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.9.0] + +### Changed + +- Add change utxo dropped when full swap use case ([#572](https://github.com/MetaMask/snap-bitcoin-wallet/pull/572)) +- Update p2wsh, p2tr and p2sh dust minimum value ([#570](https://github.com/MetaMask/snap-bitcoin-wallet/pull/570)) + ## [1.8.0] ### Changed @@ -550,7 +557,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.8.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.9.0...HEAD +[1.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.8.0...v1.9.0 [1.8.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.7.0...v1.8.0 [1.7.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.6.0...v1.7.0 [1.6.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.5.0...v1.6.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 7af469ac..729c31c6 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.8.0", + "version": "1.9.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 7215aa79..76faf822 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.8.0", + "version": "1.9.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "szJAoKtgR6QoysiE04qSBfy2Dz95MiYIChtIxmdOtjI=", + "shasum": "Zm/sd/qBHb5IBLwlsjtAij6VGTVRJ6mwXrs2/SRviH0=", "location": { "npm": { "filePath": "dist/bundle.js", From 9c8d1ea21a6750a3dbf1c7272ec9ae5efaf27d60 Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 16 Jan 2026 11:49:06 +0000 Subject: [PATCH 322/362] chore: update Snaps technology dependencies (#574) --- merged-packages/bitcoin-wallet-snap/package.json | 10 +++++----- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/CronHandler.test.ts | 13 +++++++++++-- .../bitcoin-wallet-snap/src/handlers/CronHandler.ts | 3 +-- .../src/handlers/KeyringHandler.ts | 2 +- .../src/handlers/KeyringRequestHandler.ts | 2 +- .../src/handlers/RpcHandler.test.ts | 2 +- .../bitcoin-wallet-snap/src/handlers/RpcHandler.ts | 2 +- 8 files changed, 22 insertions(+), 14 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 729c31c6..45e83eed 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -38,13 +38,13 @@ "@jest/globals": "^30.0.5", "@metamask/bitcoindevkit": "^0.1.13", "@metamask/key-tree": "^10.1.1", - "@metamask/keyring-api": "^21.1.0", - "@metamask/keyring-snap-sdk": "^7.1.0", + "@metamask/keyring-api": "^21.3.0", + "@metamask/keyring-snap-sdk": "^7.1.1", "@metamask/slip44": "^4.2.0", "@metamask/snaps-cli": "^8.3.0", - "@metamask/snaps-jest": "^9.4.1", - "@metamask/snaps-sdk": "^9.3.0", - "@metamask/utils": "^11.4.2", + "@metamask/snaps-jest": "^9.8.0", + "@metamask/snaps-sdk": "^10.3.0", + "@metamask/utils": "^11.9.0", "bip322-js": "^3.0.0", "concurrently": "^9.2.1", "dotenv": "^17.2.1", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 76faf822..2a15093a 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -92,6 +92,6 @@ ] } }, - "platformVersion": "9.3.0", + "platformVersion": "10.3.0", "manifestVersion": "0.1" } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts index 4921aff4..5e0bdcd8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -1,6 +1,5 @@ import { getSelectedAccounts } from '@metamask/keyring-snap-sdk'; -import type { SnapsProvider } from '@metamask/snaps-sdk'; -import type { JsonRpcRequest } from '@metamask/utils'; +import type { SnapsProvider, JsonRpcRequest } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; import type { BitcoinAccount, SnapClient } from '../entities'; @@ -29,6 +28,8 @@ describe('CronHandler', () => { mockSnapClient.getClientStatus.mockResolvedValue({ active: true, locked: false, + clientVersion: '1.0.0', + platformVersion: '1.0.0', }); }); @@ -67,6 +68,8 @@ describe('CronHandler', () => { mockSnapClient.getClientStatus.mockResolvedValue({ active: false, locked: true, + clientVersion: '1.0.0', + platformVersion: '1.0.0', }); await handler.route(request); @@ -113,6 +116,8 @@ describe('CronHandler', () => { mockSnapClient.getClientStatus.mockResolvedValue({ active: false, locked: true, + clientVersion: '1.0.0', + platformVersion: '1.0.0', }); await handler.route(request); @@ -165,6 +170,8 @@ describe('CronHandler', () => { mockSnapClient.getClientStatus.mockResolvedValue({ active: false, locked: true, + clientVersion: '1.0.0', + platformVersion: '1.0.0', }); await handler.route(request); @@ -217,6 +224,8 @@ describe('CronHandler', () => { mockSnapClient.getClientStatus.mockResolvedValue({ active: false, locked: true, + clientVersion: '1.0.0', + platformVersion: '1.0.0', }); await handler.route(request); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index e4b33668..db87bf6c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -1,6 +1,5 @@ import { getSelectedAccounts } from '@metamask/keyring-snap-sdk'; -import type { SnapsProvider } from '@metamask/snaps-sdk'; -import type { JsonRpcRequest } from '@metamask/utils'; +import type { JsonRpcRequest, SnapsProvider } from '@metamask/snaps-sdk'; import { array, assert, object, string } from 'superstruct'; import { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 8f1fbfff..14e2b249 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -30,7 +30,7 @@ import type { DiscoveredAccount, KeyringRequest, } from '@metamask/keyring-api'; -import type { Json, JsonRpcRequest } from '@metamask/utils'; +import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; import { assert, boolean, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts index eab70f5f..fb84f7a8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts @@ -1,5 +1,5 @@ import type { KeyringRequest, KeyringResponse } from '@metamask/keyring-api'; -import type { Json } from '@metamask/utils'; +import type { Json } from '@metamask/snaps-sdk'; import { array, assert, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index 70cf79e7..0360bc5a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -2,7 +2,7 @@ import { Psbt, Address, Amount } from '@metamask/bitcoindevkit'; import type { Transaction, Txid } from '@metamask/bitcoindevkit'; import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; import { BtcScope, FeeType } from '@metamask/keyring-api'; -import type { JsonRpcRequest } from '@metamask/utils'; +import type { JsonRpcRequest } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; import type { AccountUseCases, SendFlowUseCases } from '../use-cases'; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index b974ff80..bbc4747a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -1,5 +1,5 @@ import { BtcScope } from '@metamask/keyring-api'; -import type { Json, JsonRpcRequest } from '@metamask/utils'; +import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; import { Verifier } from 'bip322-js'; import { assert, enums, object, optional, string } from 'superstruct'; From c23cc10b76637975e3a9dfeaf1d91043906961bf Mon Sep 17 00:00:00 2001 From: Fred Date: Tue, 3 Feb 2026 12:00:53 +0100 Subject: [PATCH 323/362] Feat/balance event (#577) * feat: balance event sent everytime for synchronize * feat: add SyncResult type and update emitAccountBalancesUpdatedEvent signature * refactor: update emitAccountBalancesUpdatedEvent to accept array * refactor: synchronize() returns SyncResult instead of emitting events and update broadcast to use array signature for balance event * feat: batch balance and transaction events in CronHandler sync methods * test: update unit tests AccountUseCases.test * chore: fix linter warnings * test update unit tests for CronHandler.test * test: add syncSelectedAccounts check in integration tests --- .../integration-test/cron-sync.test.ts | 32 ++++- .../bitcoin-wallet-snap/src/entities/snap.ts | 11 +- .../src/handlers/CronHandler.test.ts | 127 +++++++++++++++--- .../src/handlers/CronHandler.ts | 61 +++++++-- .../src/infra/SnapClientAdapter.ts | 19 ++- .../src/use-cases/AccountUseCases.test.ts | 89 ++++++------ .../src/use-cases/AccountUseCases.ts | 34 ++--- 7 files changed, 271 insertions(+), 102 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts index 0aead019..d90b6558 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/cron-sync.test.ts @@ -5,6 +5,7 @@ import { installSnap } from '@metamask/snaps-jest'; import { BlockchainTestUtils } from './blockchain-utils'; import { MNEMONIC, ORIGIN } from './constants'; +import { TrackingSnapEvent } from '../src/entities'; const ACCOUNT_INDEX = 2; @@ -55,7 +56,7 @@ describe('CronHandler', () => { expect(response).toBeDefined(); }); - it('tracks TransactionReceived for new unconfirmed transaction', async () => { + it('tracks TransactionReceived for new unconfirmed transaction with multiple syncs', async () => { // create account without initial sync const createResponse = await snap.onKeyringRequest({ origin: ORIGIN, @@ -79,11 +80,11 @@ describe('CronHandler', () => { accountsToSync.push(account.id); // send a new transaction to the new account - const txid = await blockchain.sendToAddress(account.address, 10); + let txid = await blockchain.sendToAddress(account.address, 10); expect(txid).toBeDefined(); // run cron sync to discover the unconfirmed transaction - const syncResponse = await snap.onCronjob({ + let syncResponse = await snap.onCronjob({ method: 'synchronizeAccounts', }); expect(syncResponse).toRespondWith(null); @@ -100,5 +101,30 @@ describe('CronHandler', () => { }, }); /* eslint-enable @typescript-eslint/naming-convention */ + + // send a transaction to the account + txid = await blockchain.sendToAddress(account.address, 5); + expect(txid).toBeDefined(); + + // sync using syncSelectedAccounts + syncResponse = await snap.onCronjob({ + method: 'syncSelectedAccounts', + params: { accountIds: [account.id] }, + }); + + expect(syncResponse).toRespondWith(null); + + /* eslint-disable @typescript-eslint/naming-convention */ + expect(syncResponse).toTrackEvent({ + event: TrackingSnapEvent.TransactionReceived, + properties: { + origin: 'metamask', + message: 'Snap transaction received', + chain_id_caip: BtcScope.Regtest, + account_type: BtcAccountType.P2wpkh, + tx_id: txid, + }, + }); + /* eslint-enable @typescript-eslint/naming-convention */ }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index 5c8ab999..9d8e9f42 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -27,6 +27,13 @@ export type AccountState = { inscriptions: Inscription[]; }; +export type SyncResult = { + // The synchronized account. + account: BitcoinAccount; + // Transactions that changed and should be notified. + transactionsToNotify: WalletTx[]; +}; + export enum TrackingSnapEvent { TransactionFinalized = 'Transaction Finalized', TransactionReceived = 'Transaction Received', @@ -92,9 +99,9 @@ export type SnapClient = { /** * Emit an event notifying the extension of updated balances * - * @param account - The Bitcoin account. + * @param accounts - The Bitcoin accounts to emit balances for. */ - emitAccountBalancesUpdatedEvent(account: BitcoinAccount): Promise; + emitAccountBalancesUpdatedEvent(accounts: BitcoinAccount[]): Promise; /** * Emit an event notifying the extension of updated transactions diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts index 5e0bdcd8..d553baa1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -1,8 +1,9 @@ +import type { WalletTx } from '@metamask/bitcoindevkit'; import { getSelectedAccounts } from '@metamask/keyring-snap-sdk'; import type { SnapsProvider, JsonRpcRequest } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; -import type { BitcoinAccount, SnapClient } from '../entities'; +import type { BitcoinAccount, SnapClient, SyncResult } from '../entities'; import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; import { CronHandler, CronMethod } from './CronHandler'; @@ -34,18 +35,28 @@ describe('CronHandler', () => { }); describe('synchronizeAccounts', () => { - const mockAccounts = [ - mock({ id: 'account-1' }), - mock({ id: 'account-2' }), - ]; + const mockAccount1 = mock({ id: 'account-1' }); + const mockAccount2 = mock({ id: 'account-2' }); + const mockAccounts = [mockAccount1, mockAccount2]; const request = { method: 'synchronizeAccounts' } as JsonRpcRequest; - it('synchronizes all selected accounts', async () => { + it('synchronizes all selected accounts and emits batched events', async () => { + const mockResult1: SyncResult = { + account: mockAccount1, + transactionsToNotify: [], + }; + const mockResult2: SyncResult = { + account: mockAccount2, + transactionsToNotify: [], + }; (getSelectedAccounts as jest.Mock).mockResolvedValue([ 'account-1', 'account-2', ]); mockAccountUseCases.list.mockResolvedValue(mockAccounts); + mockAccountUseCases.synchronize + .mockResolvedValueOnce(mockResult1) + .mockResolvedValueOnce(mockResult2); await handler.route(request); @@ -54,6 +65,41 @@ describe('CronHandler', () => { expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes( mockAccounts.length, ); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith(mockAccounts); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledTimes(1); + }); + + it('emits transaction events for accounts with new transactions', async () => { + const mockTx = mock(); + const mockResult1: SyncResult = { + account: mockAccount1, + transactionsToNotify: [mockTx], + }; + const mockResult2: SyncResult = { + account: mockAccount2, + transactionsToNotify: [], + }; + (getSelectedAccounts as jest.Mock).mockResolvedValue([ + 'account-1', + 'account-2', + ]); + mockAccountUseCases.list.mockResolvedValue(mockAccounts); + mockAccountUseCases.synchronize + .mockResolvedValueOnce(mockResult1) + .mockResolvedValueOnce(mockResult2); + + await handler.route(request); + + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount1, [mockTx]); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledTimes(1); }); it('propagates errors from list', async () => { @@ -76,13 +122,19 @@ describe('CronHandler', () => { expect(mockAccountUseCases.synchronize).not.toHaveBeenCalled(); }); - it('throws error if some account fails to synchronize', async () => { + it('throws error if some account fails but still emits for successful ones', async () => { + const mockResult: SyncResult = { + account: mockAccount1, + transactionsToNotify: [], + }; (getSelectedAccounts as jest.Mock).mockResolvedValue([ 'account-1', 'account-2', ]); mockAccountUseCases.list.mockResolvedValue(mockAccounts); - mockAccountUseCases.synchronize.mockRejectedValue(new Error('error')); + mockAccountUseCases.synchronize + .mockResolvedValueOnce(mockResult) + .mockRejectedValueOnce(new Error('error')); await expect(handler.route(request)).rejects.toThrow( 'Account synchronization failures', @@ -91,6 +143,10 @@ describe('CronHandler', () => { expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes( mockAccounts.length, ); + // Should still emit for successful account + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith([mockAccounts[0]]); }); }); @@ -133,11 +189,10 @@ describe('CronHandler', () => { }); describe('syncSelectedAccounts', () => { - const mockAccounts = [ - mock({ id: 'account-1' }), - mock({ id: 'account-2' }), - mock({ id: 'account-3' }), - ]; + const mockAccount1 = mock({ id: 'account-1' }); + const mockAccount2 = mock({ id: 'account-2' }); + const mockAccount3 = mock({ id: 'account-3' }); + const mockAccounts = [mockAccount1, mockAccount2, mockAccount3]; const request = { method: CronMethod.SyncSelectedAccounts, params: { accountIds: ['account-1', 'account-2'] }, @@ -149,8 +204,19 @@ describe('CronHandler', () => { ).rejects.toThrow(''); }); - it('performs full scan on selected accounts', async () => { + it('synchronizes selected accounts and emits batched events', async () => { + const mockResult1: SyncResult = { + account: mockAccount1, + transactionsToNotify: [], + }; + const mockResult2: SyncResult = { + account: mockAccount2, + transactionsToNotify: [], + }; mockAccountUseCases.list.mockResolvedValue(mockAccounts); + mockAccountUseCases.synchronize + .mockResolvedValueOnce(mockResult1) + .mockResolvedValueOnce(mockResult2); await handler.route(request); @@ -164,6 +230,13 @@ describe('CronHandler', () => { mockAccounts[1], 'metamask', ); + // Verify batched balance event + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith([mockAccounts[0], mockAccounts[1]]); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledTimes(1); }); it('returns early if the client is not active', async () => { @@ -185,16 +258,24 @@ describe('CronHandler', () => { await expect(handler.route(request)).rejects.toThrow(error); }); - it('completes successfully even if some accounts fail to scan', async () => { + it('emits events only for successful accounts when some fail', async () => { + const mockResult: SyncResult = { + account: mockAccount1, + transactionsToNotify: [], + }; mockAccountUseCases.list.mockResolvedValue(mockAccounts); mockAccountUseCases.synchronize - .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(mockResult) .mockRejectedValueOnce(new Error('scan failed')); const result = await handler.route(request); expect(result).toBeUndefined(); expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes(2); + // Should emit for successful account only + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith([mockAccounts[0]]); }); }); @@ -211,13 +292,25 @@ describe('CronHandler', () => { ).rejects.toThrow(''); }); - it('performs full scan on the specified account', async () => { + it('performs full scan and emits events', async () => { + const mockTxs = [mock()]; + const mockResult: SyncResult = { + account: mockAccount, + transactionsToNotify: mockTxs, + }; mockAccountUseCases.get.mockResolvedValue(mockAccount); + mockAccountUseCases.fullScan.mockResolvedValue(mockResult); await handler.route(request); expect(mockAccountUseCases.get).toHaveBeenCalledWith('account-1'); expect(mockAccountUseCases.fullScan).toHaveBeenCalledWith(mockAccount); + expect( + mockSnapClient.emitAccountBalancesUpdatedEvent, + ).toHaveBeenCalledWith([mockAccount]); + expect( + mockSnapClient.emitAccountTransactionsUpdatedEvent, + ).toHaveBeenCalledWith(mockAccount, mockTxs); }); it('returns early if the client is not active', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index db87bf6c..9199b8b1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -5,6 +5,7 @@ import { array, assert, object, string } from 'superstruct'; import { InexistentMethodError, type SnapClient, + type SyncResult, SynchronizationError, } from '../entities'; import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; @@ -88,14 +89,19 @@ export class CronHandler { }); const results = await Promise.allSettled( - accounts.map(async (account) => { - return this.#accountsUseCases.synchronize(account, 'cron'); - }), + accounts.map(async (account) => + this.#accountsUseCases.synchronize(account, 'cron'), + ), ); + const successfulResults: SyncResult[] = []; + const errors: Record = {}; + results.forEach((result, index) => { - if (result.status === 'rejected') { + if (result.status === 'fulfilled') { + successfulResults.push(result.value); + } else { const id = accounts[index]?.id; if (id) { errors[id] = result.reason; @@ -103,6 +109,8 @@ export class CronHandler { } }); + await this.#emitSyncEvents(successfulResults); + if (Object.keys(errors).length > 0) { throw new SynchronizationError( 'Account synchronization failures', @@ -119,15 +127,52 @@ export class CronHandler { accountIdSet.has(account.id), ); - const scanPromises = selectedAccounts.map(async (account) => - this.#accountsUseCases.synchronize(account, 'metamask'), + const results = await Promise.allSettled( + selectedAccounts.map(async (account) => + this.#accountsUseCases.synchronize(account, 'metamask'), + ), + ); + + const successfulResults = results + .filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ) + .map((result) => result.value); + + await this.#emitSyncEvents(successfulResults); + } + + /** + * Emit batched balance and transaction events for sync results. + * + * @param results - The successful sync results. + */ + async #emitSyncEvents(results: SyncResult[]): Promise { + if (results.length === 0) { + return; + } + + // Emit one batched balance event for all accounts + await this.#snapClient.emitAccountBalancesUpdatedEvent( + results.map((syncResult) => syncResult.account), ); - await Promise.allSettled(scanPromises); + // Emit transaction events per account + for (const { account, transactionsToNotify } of results) { + if (transactionsToNotify.length > 0) { + await this.#snapClient.emitAccountTransactionsUpdatedEvent( + account, + transactionsToNotify, + ); + } + } } async fullScanAccount(accountId: string): Promise { const account = await this.#accountsUseCases.get(accountId); - await this.#accountsUseCases.fullScan(account); + const result = await this.#accountsUseCases.fullScan(account); + + await this.#emitSyncEvents([result]); } } diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index f7d27310..3674432c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -102,18 +102,25 @@ export class SnapClientAdapter implements SnapClient { } async emitAccountBalancesUpdatedEvent( - account: BitcoinAccount, + accounts: BitcoinAccount[], ): Promise { - const balance = account.balance.trusted_spendable.to_btc().toString(); - return emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { - balances: { + const balances = accounts.reduce< + Record> + >( + (acc, account) => ({ + ...acc, [account.id]: { [networkToCaip19[account.network]]: { - amount: balance, + amount: account.balance.trusted_spendable.to_btc().toString(), unit: networkToCurrencyUnit[account.network], }, }, - }, + }), + {}, + ); + + return emitSnapKeyringEvent(snap, KeyringEvent.AccountBalancesUpdated, { + balances, }); } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index f1cb916c..f5c8ae9e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -429,11 +429,15 @@ describe('AccountUseCases', () => { it('synchronizes', async () => { mockAccount.listTransactions.mockReturnValue([]); - await useCases.synchronize(mockAccount, 'test'); + const result = await useCases.synchronize(mockAccount, 'test'); expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); expect(mockAccount.listTransactions).toHaveBeenCalledTimes(2); expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(result).toStrictEqual({ + account: mockAccount, + transactionsToNotify: [], + }); }); it('synchronizes with new transactions', async () => { @@ -444,7 +448,7 @@ describe('AccountUseCases', () => { .mockReturnValueOnce([mockTransaction]); mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); - await useCases.synchronize(mockAccount, 'test'); + const result = await useCases.synchronize(mockAccount, 'test'); expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); expect(mockAccount.listTransactions).toHaveBeenCalledTimes(2); @@ -455,12 +459,6 @@ describe('AccountUseCases', () => { mockAccount, mockInscriptions, ); - expect( - mockSnapClient.emitAccountBalancesUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount); - expect( - mockSnapClient.emitAccountTransactionsUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount, [mockTransaction]); expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( TrackingSnapEvent.TransactionReceived, mockAccount, @@ -468,6 +466,10 @@ describe('AccountUseCases', () => { 'test', ); expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual({ + account: mockAccount, + transactionsToNotify: [mockTransaction], + }); }); it('synchronizes with confirmed transactions', async () => { @@ -487,23 +489,21 @@ describe('AccountUseCases', () => { .mockReturnValueOnce([mockTxPending]) .mockReturnValueOnce([mockTxConfirmed]); - await useCases.synchronize(mockAccount, 'test'); + const result = await useCases.synchronize(mockAccount, 'test'); expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); expect(mockAccount.listTransactions).toHaveBeenCalledTimes(2); expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); - expect( - mockSnapClient.emitAccountBalancesUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount); - expect( - mockSnapClient.emitAccountTransactionsUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount, [mockTxConfirmed]); expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( TrackingSnapEvent.TransactionFinalized, mockAccount, mockTxConfirmed, 'test', ); + expect(result).toStrictEqual({ + account: mockAccount, + transactionsToNotify: [mockTxConfirmed], + }); }); it('synchronizes with both new and confirmed transactions', async () => { @@ -544,7 +544,7 @@ describe('AccountUseCases', () => { mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); const origin = 'test'; - await useCases.synchronize(mockAccount, origin); + const result = await useCases.synchronize(mockAccount, origin); expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); expect(mockAccount.listTransactions).toHaveBeenCalledTimes(2); @@ -555,16 +555,6 @@ describe('AccountUseCases', () => { mockAccount, mockInscriptions, ); - expect( - mockSnapClient.emitAccountBalancesUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount); - expect( - mockSnapClient.emitAccountTransactionsUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount, [ - mockTxConfirmed, - mockTxNew, - mockTxReorged, - ]); // Check for TransactionFinalized event for confirmed transaction expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( @@ -591,6 +581,10 @@ describe('AccountUseCases', () => { ); expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledTimes(3); + expect(result).toStrictEqual({ + account: mockAccount, + transactionsToNotify: [mockTxConfirmed, mockTxNew, mockTxReorged], + }); }); it('should emit TransactionReorged when a confirmed transaction becomes unconfirmed', async () => { @@ -606,7 +600,7 @@ describe('AccountUseCases', () => { .mockReturnValueOnce([mockTxConfirmed]) .mockReturnValueOnce([mockTxReorged]); - await useCases.synchronize(mockAccount, 'test'); + const result = await useCases.synchronize(mockAccount, 'test'); expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledTimes(1); expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( @@ -615,9 +609,10 @@ describe('AccountUseCases', () => { mockTxReorged, 'test', ); - expect( - mockSnapClient.emitAccountTransactionsUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount, [mockTxReorged]); + expect(result).toStrictEqual({ + account: mockAccount, + transactionsToNotify: [mockTxReorged], + }); }); it('propagates an error if the chain sync fails', async () => { @@ -676,8 +671,7 @@ describe('AccountUseCases', () => { mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); mockSnapClient.emitTrackingEvent.mockRejectedValue(trackingError); - // should not throw despite tracking failure - expect(await useCases.synchronize(mockAccount, 'test')).toBeUndefined(); + const result = await useCases.synchronize(mockAccount, 'test'); // core synchronization functionality should still work expect(mockChain.sync).toHaveBeenCalledWith(mockAccount); @@ -689,12 +683,6 @@ describe('AccountUseCases', () => { mockAccount, mockInscriptions, ); - expect( - mockSnapClient.emitAccountBalancesUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount); - expect( - mockSnapClient.emitAccountTransactionsUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount, [mockTransaction]); // tracking should have been attempted expect(mockSnapClient.emitTrackingEvent).toHaveBeenCalledWith( @@ -709,6 +697,11 @@ describe('AccountUseCases', () => { 'Failed to execute snap action: emitTrackingEvent:TransactionReceived', trackingError, ); + + expect(result).toStrictEqual({ + account: mockAccount, + transactionsToNotify: [mockTransaction], + }); }); }); @@ -723,7 +716,7 @@ describe('AccountUseCases', () => { mockAccount.listTransactions.mockReturnValue(mockTransactions); mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); - await useCases.fullScan(mockAccount); + const result = await useCases.fullScan(mockAccount); expect(mockChain.fullScan).toHaveBeenCalledWith(mockAccount); expect(mockMetaProtocols.fetchInscriptions).toHaveBeenCalledWith( @@ -733,12 +726,10 @@ describe('AccountUseCases', () => { mockAccount, mockInscriptions, ); - expect( - mockSnapClient.emitAccountBalancesUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount); - expect( - mockSnapClient.emitAccountTransactionsUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount, mockTransactions); + expect(result).toStrictEqual({ + account: mockAccount, + transactionsToNotify: mockTransactions, + }); }); it('propagates an error if the chain full scan fails', async () => { @@ -967,7 +958,7 @@ describe('AccountUseCases', () => { expect(mockTransaction.compute_txid).toHaveBeenCalled(); expect( mockSnapClient.emitAccountBalancesUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount); + ).toHaveBeenCalledWith([mockAccount]); expect( mockSnapClient.emitAccountTransactionsUpdatedEvent, ).toHaveBeenCalledWith(mockAccount, [mockWalletTx]); @@ -1008,7 +999,7 @@ describe('AccountUseCases', () => { expect(mockTransaction.compute_txid).toHaveBeenCalled(); expect( mockSnapClient.emitAccountBalancesUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount); + ).toHaveBeenCalledWith([mockAccount]); expect( mockSnapClient.emitAccountTransactionsUpdatedEvent, ).toHaveBeenCalledWith(mockAccount, [mockWalletTx]); @@ -1085,7 +1076,7 @@ describe('AccountUseCases', () => { expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); expect( mockSnapClient.emitAccountBalancesUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount); + ).toHaveBeenCalledWith([mockAccount]); expect( mockSnapClient.emitAccountTransactionsUpdatedEvent, ).toHaveBeenCalledWith(mockAccount, [mockWalletTx]); @@ -1623,7 +1614,7 @@ describe('AccountUseCases', () => { expect(mockTransaction.compute_txid).toHaveBeenCalled(); expect( mockSnapClient.emitAccountBalancesUpdatedEvent, - ).toHaveBeenCalledWith(mockAccount); + ).toHaveBeenCalledWith([mockAccount]); expect( mockSnapClient.emitAccountTransactionsUpdatedEvent, ).toHaveBeenCalledWith(mockAccount, [mockWalletTx]); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 2f3210b6..11d75b91 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -19,6 +19,7 @@ import type { Logger, MetaProtocolsClient, SnapClient, + SyncResult, } from '../entities'; import { AccountCapability, @@ -208,7 +209,10 @@ export class AccountUseCases { return newAccount; } - async synchronize(account: BitcoinAccount, origin: string): Promise { + async synchronize( + account: BitcoinAccount, + origin: string, + ): Promise { this.#logger.debug('Synchronizing account: %s', account.id); const txsBeforeSync = account.listTransactions(); @@ -295,18 +299,15 @@ export class AccountUseCases { } } - if (txsToNotify.length > 0) { - await this.#snapClient.emitAccountBalancesUpdatedEvent(account); - await this.#snapClient.emitAccountTransactionsUpdatedEvent( - account, - txsToNotify, - ); - } - this.#logger.debug('Account synchronized successfully: %s', account.id); + + return { + account, + transactionsToNotify: txsToNotify, + }; } - async fullScan(account: BitcoinAccount): Promise { + async fullScan(account: BitcoinAccount): Promise { this.#logger.debug('Performing initial full scan: %s', account.id); await this.#chain.fullScan(account); @@ -316,16 +317,15 @@ export class AccountUseCases { : []; await this.#repository.update(account, inscriptions); - await this.#snapClient.emitAccountBalancesUpdatedEvent(account); - await this.#snapClient.emitAccountTransactionsUpdatedEvent( - account, - account.listTransactions(), - ); - this.#logger.info( 'initial full scan performed successfully: %s', account.id, ); + + return { + account, + transactionsToNotify: account.listTransactions(), + }; } async delete(id: string): Promise { @@ -638,7 +638,7 @@ export class AccountUseCases { account.applyUnconfirmedTx(tx, getCurrentUnixTimestamp()); await this.#repository.update(account); - await this.#snapClient.emitAccountBalancesUpdatedEvent(account); + await this.#snapClient.emitAccountBalancesUpdatedEvent([account]); const walletTx = account.getTransaction(txid.toString()); if (walletTx) { From 4f89bfef008e9a5d81c0b0f1da4ac75462bbe988 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 18:19:23 +0100 Subject: [PATCH 324/362] 1.10.0 (#580) * 1.10.0 * feat: update CHANGELOG.MD and snap version in snap.manifest.json * feat: update packages/snap/snap.manifest.json hash and site CHANGELOG.MD * feat: update CHANGELOG.MD for snap * feat: restore packages/site/CHANGELOG.md to previous version --------- Co-authored-by: github-actions Co-authored-by: Frederic HENG --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 11 ++++++++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 4b74620f..7aae7584 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.10.0] + +### Changed + +- Modify balance/transaction events sending ([#577](https://github.com/MetaMask/snap-bitcoin-wallet/pull/577)) +- Bump `@metamask/keyring-api` from `^21.1.0` to `^21.3.0` ([#574](https://github.com/MetaMask/snap-bitcoin-wallet/pull/574)) +- Bump `@metamask/keyring-snap-sdk` from `^7.1.0` to `^7.1.1` ([#574](https://github.com/MetaMask/snap-bitcoin-wallet/pull/574)) + ## [1.9.0] ### Changed @@ -557,7 +565,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.9.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.0...HEAD +[1.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.9.0...v1.10.0 [1.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.8.0...v1.9.0 [1.8.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.7.0...v1.8.0 [1.7.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.6.0...v1.7.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 45e83eed..446f39b6 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.9.0", + "version": "1.10.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 2a15093a..33f1320e 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.9.0", + "version": "1.10.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "Zm/sd/qBHb5IBLwlsjtAij6VGTVRJ6mwXrs2/SRviH0=", + "shasum": "vRba/rR8D/OA3+XJO4+chU8zDYgLkdA18wSnehkisn8=", "location": { "npm": { "filePath": "dist/bundle.js", From f030a3473eb5927f88d9fa081f276435cafd62db Mon Sep 17 00:00:00 2001 From: Michele Esposito <34438276+mikesposito@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:31:05 +0100 Subject: [PATCH 325/362] ci: add changelog validation (#582) * ci: enable changelog validation * fix: update package name * fix: update regex and remove unused variable --- merged-packages/bitcoin-wallet-snap/.depcheckrc.json | 2 +- merged-packages/bitcoin-wallet-snap/package.json | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/.depcheckrc.json b/merged-packages/bitcoin-wallet-snap/.depcheckrc.json index 836108a8..c31d8118 100644 --- a/merged-packages/bitcoin-wallet-snap/.depcheckrc.json +++ b/merged-packages/bitcoin-wallet-snap/.depcheckrc.json @@ -1,3 +1,3 @@ { - "ignores": ["jest-transform-stub", "ts-jest"] + "ignores": ["jest-transform-stub", "ts-jest", "@metamask/auto-changelog"] } diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 446f39b6..2d20b009 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -21,6 +21,8 @@ "build:clean": "yarn clean && yarn build:locale && yarn build", "build:locale": "node ./scripts/populate-en-locale.js && prettier 'locales/**/*.json' -w", "build:locale:watch": "npx nodemon --watch packages/snap/messages.json --exec \"node ./scripts/populate-en-locale.js && prettier 'locales/**/*.json' -w\"", + "changelog:update": "../../scripts/update-changelog.sh @metamask/bitcoin-wallet-snap", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/bitcoin-wallet-snap", "clean": "rimraf dist", "lint": "yarn lint:eslint && yarn lint:misc && yarn lint:deps && yarn lint:types", "lint:deps": "depcheck", @@ -36,6 +38,7 @@ }, "devDependencies": { "@jest/globals": "^30.0.5", + "@metamask/auto-changelog": "^3.4.4", "@metamask/bitcoindevkit": "^0.1.13", "@metamask/key-tree": "^10.1.1", "@metamask/keyring-api": "^21.3.0", From 98fc961729510fbe58e00039fc009ccf617d15e4 Mon Sep 17 00:00:00 2001 From: Michele Esposito <34438276+mikesposito@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:38:17 +0100 Subject: [PATCH 326/362] ci: enforce package deduplication (#583) * ci: enforce package deduplication * run `yarn dedupe` --- merged-packages/bitcoin-wallet-snap/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 2d20b009..76b42576 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -25,7 +25,8 @@ "changelog:validate": "../../scripts/validate-changelog.sh @metamask/bitcoin-wallet-snap", "clean": "rimraf dist", "lint": "yarn lint:eslint && yarn lint:misc && yarn lint:deps && yarn lint:types", - "lint:deps": "depcheck", + "lint:deps": "depcheck && yarn dedupe --check", + "lint:deps:fix": "depcheck && yarn dedupe", "lint:eslint": "eslint . --cache --ext js,jsx,ts,tsx", "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", "lint:misc": "prettier '**/*.json' '**/*.md' --check", From 16a54708fbcbe1612c06c41c352970b36e920e10 Mon Sep 17 00:00:00 2001 From: Michele Esposito <34438276+mikesposito@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:01:10 +0100 Subject: [PATCH 327/362] chore: enable `@typescript-eslint/no-explicit-any` as `error` (#585) --- .../bitcoin-wallet-snap/src/entities/logger.ts | 10 ++++++++++ .../bitcoin-wallet-snap/src/handlers/CronHandler.ts | 2 ++ .../src/infra/ConsoleLoggerAdapter.ts | 10 ++++++++++ 3 files changed, 22 insertions(+) diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/logger.ts b/merged-packages/bitcoin-wallet-snap/src/entities/logger.ts index ae68dad7..4fcc85f2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/logger.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/logger.ts @@ -16,6 +16,8 @@ export type Logger = { * * @param data - The data to log. */ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any error(...data: any[]): void; /** @@ -23,6 +25,8 @@ export type Logger = { * * @param data - The data to log. */ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any warn(...data: any[]): void; /** @@ -30,6 +34,8 @@ export type Logger = { * * @param data - The data to log. */ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any info(...data: any[]): void; /** @@ -37,6 +43,8 @@ export type Logger = { * * @param data - The data to log. */ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any debug(...data: any[]): void; /** @@ -44,5 +52,7 @@ export type Logger = { * * @param data - The data to log. */ + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any trace(...data: any[]): void; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index 9199b8b1..308af2bf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -96,6 +96,8 @@ export class CronHandler { const successfulResults: SyncResult[] = []; + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any const errors: Record = {}; results.forEach((result, index) => { diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/ConsoleLoggerAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/ConsoleLoggerAdapter.ts index e73e46bf..de263406 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/ConsoleLoggerAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/ConsoleLoggerAdapter.ts @@ -21,30 +21,40 @@ export class ConsoleLoggerAdapter implements Logger { return logLevelPriority[level] <= logLevelPriority[this.#logLevel]; } + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any error(...data: any[]): void { if (this.#shouldLog(LogLevel.ERROR)) { console.error(...data); } } + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any warn(...data: any[]): void { if (this.#shouldLog(LogLevel.WARN)) { console.warn(...data); } } + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any info(...data: any[]): void { if (this.#shouldLog(LogLevel.INFO)) { console.info(...data); } } + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any debug(...data: any[]): void { if (this.#shouldLog(LogLevel.DEBUG)) { console.debug(...data); } } + // TODO: Replace `any` with type + // eslint-disable-next-line @typescript-eslint/no-explicit-any trace(...data: any[]): void { if (this.#shouldLog(LogLevel.TRACE)) { console.trace(...data); From bbf501d56364d6cf8f6023ca3c1caa107427d727 Mon Sep 17 00:00:00 2001 From: Michele Esposito <34438276+mikesposito@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:00:03 +0100 Subject: [PATCH 328/362] test: enable coverage collection and thresholds (#586) --- .../bitcoin-wallet-snap/jest.config.js | 28 +++++++++++++++++++ .../bitcoin-wallet-snap/package.json | 2 +- .../bitcoin-wallet-snap/snap.manifest.json | 2 +- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/jest.config.js b/merged-packages/bitcoin-wallet-snap/jest.config.js index e0442ef8..e8bd8785 100644 --- a/merged-packages/bitcoin-wallet-snap/jest.config.js +++ b/merged-packages/bitcoin-wallet-snap/jest.config.js @@ -3,6 +3,34 @@ * @type {import('ts-jest').JestConfigWithTsJest} */ const config = { + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + collectCoverageFrom: ['./src/**/*.ts', './src/**/*.tsx'], + + // The directory where Jest should output its coverage files + coverageDirectory: 'coverage', + + // An array of regexp pattern strings used to skip coverage collection + coveragePathIgnorePatterns: ['.*/index\\.ts'], + + // 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: ['text', 'html', 'json-summary', 'lcov'], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 65.5, + functions: 62.64, + lines: 75.29, + statements: 74.57, + }, + }, + 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 index 76b42576..88ac0093 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -34,7 +34,7 @@ "prepublishOnly": "mm-snap manifest", "serve": "mm-snap serve", "start": "concurrently \"mm-snap watch\" \"yarn build:locale:watch\"", - "test": "jest --passWithNoTests", + "test": "jest", "test:integration": "./integration-test/run-integration.sh" }, "devDependencies": { diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 33f1320e..5f2050c0 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "vRba/rR8D/OA3+XJO4+chU8zDYgLkdA18wSnehkisn8=", + "shasum": "o9ZJmt7WEmIOXnmPlqmqRbGWztcCkDTKkW2VaBza56E=", "location": { "npm": { "filePath": "dist/bundle.js", From 5e21d9dfaa7e44455b78eb7482987a4ad3fd9ec0 Mon Sep 17 00:00:00 2001 From: Michele Esposito <34438276+mikesposito@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:43:30 +0100 Subject: [PATCH 329/362] ci: enable preview builds (#587) * ci: enable preview builds * add execa dependency * fix dependency lint * update `.depcheckrc.json` and move `execa` dependency --- merged-packages/bitcoin-wallet-snap/.depcheckrc.json | 2 +- merged-packages/bitcoin-wallet-snap/package.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/.depcheckrc.json b/merged-packages/bitcoin-wallet-snap/.depcheckrc.json index c31d8118..4e7ac37c 100644 --- a/merged-packages/bitcoin-wallet-snap/.depcheckrc.json +++ b/merged-packages/bitcoin-wallet-snap/.depcheckrc.json @@ -1,3 +1,3 @@ { - "ignores": ["jest-transform-stub", "ts-jest", "@metamask/auto-changelog"] + "ignores": ["@metamask/auto-changelog", "jest-transform-stub", "ts-jest"] } diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 88ac0093..cad85f2a 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -31,6 +31,7 @@ "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", "lint:misc": "prettier '**/*.json' '**/*.md' --check", "lint:types": "tsc --noEmit", + "publish:preview": "yarn npm publish --tag preview", "prepublishOnly": "mm-snap manifest", "serve": "mm-snap serve", "start": "concurrently \"mm-snap watch\" \"yarn build:locale:watch\"", From f0f92b56f50d13e8458a961670e082316a15ae5e Mon Sep 17 00:00:00 2001 From: cloudonshore Date: Tue, 31 Mar 2026 08:38:17 -0400 Subject: [PATCH 330/362] fix: include BDK cause in PSBT build error message (#594) --- .../bitcoin-wallet-snap/CHANGELOG.md | 4 +++ .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/use-cases/AccountUseCases.test.ts | 26 ++++++++++++++++++- .../src/use-cases/AccountUseCases.ts | 3 ++- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 7aae7584..7e35502e 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Include BDK cause message in PSBT build error for better diagnostics ([#594](https://github.com/MetaMask/snap-bitcoin-wallet/pull/594)) + ## [1.10.0] ### Changed diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 5f2050c0..e0ad4da1 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "o9ZJmt7WEmIOXnmPlqmqRbGWztcCkDTKkW2VaBza56E=", + "shasum": "M7LJk6fzMpw9tjV4ytZEJpcNaMfY6t4RUQacQCqfGz8=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index f5c8ae9e..bea64eea 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -1214,7 +1214,7 @@ describe('AccountUseCases', () => { useCases.fillPsbt('account-id', mockTemplatePsbt), ).rejects.toThrow( new ValidationError( - 'Failed to build PSBT from template', + 'Failed to build PSBT from template: builder error', { id: 'account-id', templatePsbt: 'base64Psbt', @@ -1225,6 +1225,30 @@ describe('AccountUseCases', () => { ); }); + it('includes non-Error cause in ValidationError message', async () => { + mockTxBuilder.finish.mockImplementation(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw { message: 'InsufficientFunds', code: 11 }; + }); + + await expect( + useCases.fillPsbt('account-id', mockTemplatePsbt), + ).rejects.toThrow( + 'Failed to build PSBT from template: InsufficientFunds', + ); + }); + + it('uses unknown cause when error has no message', async () => { + mockTxBuilder.finish.mockImplementation(() => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 42; + }); + + await expect( + useCases.fillPsbt('account-id', mockTemplatePsbt), + ).rejects.toThrow('Failed to build PSBT from template: unknown cause'); + }); + it('preserves all outputs from bridge PSBT template including change', async () => { // Simulate a bridge transaction with 3 outputs: // Output 0: Bridge deposit diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 11d75b91..2cb2e131 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -616,8 +616,9 @@ export class AccountUseCases { return builtPsbt; } catch (error) { + const causeMessage = (error as Error)?.message ?? 'unknown cause'; throw new ValidationError( - 'Failed to build PSBT from template', + `Failed to build PSBT from template: ${causeMessage}`, { id: account.id, templatePsbt: templatePsbt.toString(), From 9953e3936b2edbb51acb298fcd0c177ab596df06 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:30:46 -0400 Subject: [PATCH 331/362] 1.10.1 (#595) * 1.10.1 * chore: update CHANGELOG.md for 1.10.1 release --------- Co-authored-by: github-actions Co-authored-by: Sam Walker --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 5 ++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 7e35502e..c255e402 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.10.1] + ### Fixed - Include BDK cause message in PSBT build error for better diagnostics ([#594](https://github.com/MetaMask/snap-bitcoin-wallet/pull/594)) @@ -569,7 +571,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.1...HEAD +[1.10.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.0...v1.10.1 [1.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.9.0...v1.10.0 [1.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.8.0...v1.9.0 [1.8.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.7.0...v1.8.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index cad85f2a..34c1132a 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.10.0", + "version": "1.10.1", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From f3bebf3d5b2693eb96aa8405d0bfef9603e0dda8 Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 4 May 2026 12:12:03 +0200 Subject: [PATCH 332/362] feat: add resolveAccountAddress method to KeyringHandler for dApp connectivity routing (#590) * refactor: extract and centralize request validation; add account: { address } to all request types * test: add the account: { address: account.address } param to every request for integration tests * fix: remove unused type export * feat: add resolveAccountAddress method to KeyringHandler for dApp connectivity routing * test: adjust unit tests to fit coverage * fix: remove switch since the assert against BtcWalletRequestStruct already guarantees the method is valid * docs: update CHANGELOG.md * fix: add space in CHANGELOG.md --- .../bitcoin-wallet-snap/CHANGELOG.md | 4 + .../integration-test/keyring-request.test.ts | 16 ++ .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/handlers/KeyringHandler.test.ts | 149 +++++++++++++++++- .../src/handlers/KeyringHandler.ts | 65 +++++++- .../handlers/KeyringRequestHandler.test.ts | 43 ++++- .../src/handlers/KeyringRequestHandler.ts | 60 ++----- .../bitcoin-wallet-snap/src/handlers/caip.ts | 3 + .../src/handlers/validation.test.ts | 57 ++++++- .../src/handlers/validation.ts | 112 +++++++++++++ 10 files changed, 449 insertions(+), 62 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index c255e402..f199692a 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Add `resolveAccountAddress` method to KeyringHandler for dApp connectivity ([#590](https://github.com/MetaMask/snap-bitcoin-wallet/pull/590)) + ## [1.10.1] ### Fixed diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts index fde6972e..4f28070c 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts @@ -167,6 +167,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.GetUtxo, params: { + account: { address: account.address }, outpoint: utxos[0]?.outpoint, }, }, @@ -221,6 +222,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SignPsbt, params: { + account: { address: account.address }, psbt: TEMPLATE_PSBT, feeRate: 3, options: { @@ -253,6 +255,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SignPsbt, params: { + account: { address: account.address }, psbt: TEMPLATE_PSBT, feeRate: 3, options: { @@ -285,6 +288,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SignPsbt, params: { + account: { address: account.address }, psbt: TEMPLATE_PSBT, feeRate: 3, options: { @@ -317,6 +321,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SignPsbt, params: { + account: { address: account.address }, psbt: 'notAPsbt', options: { fill: true, @@ -350,6 +355,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SignPsbt, params: { + account: { address: account.address }, psbt: TEMPLATE_PSBT, }, }, @@ -382,6 +388,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.FillPsbt, params: { + account: { address: account.address }, psbt: TEMPLATE_PSBT, feeRate: 3, }, @@ -409,6 +416,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.FillPsbt, params: { + account: { address: account.address }, psbt: 'notAPsbt', }, }, @@ -444,6 +452,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.ComputeFee, params: { + account: { address: account.address }, psbt: TEMPLATE_PSBT, feeRate: 3, }, @@ -471,6 +480,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.ComputeFee, params: { + account: { address: account.address }, psbt: 'notAPsbt', }, }, @@ -507,6 +517,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SignPsbt, params: { + account: { address: account.address }, psbt: TEMPLATE_PSBT, feeRate: 3, options: { @@ -533,6 +544,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.BroadcastPsbt, params: { + account: { address: account.address }, psbt: result.psbt, }, }, @@ -559,6 +571,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.BroadcastPsbt, params: { + account: { address: account.address }, psbt: 'notAPsbt', }, }, @@ -590,6 +603,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SendTransfer, params: { + account: { address: account.address }, recipients: [ { address: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', @@ -626,6 +640,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SendTransfer, params: { + account: { address: account.address }, recipients: [{ address: 'notAnAddress', amount: '1000' }], }, }, @@ -654,6 +669,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SignMessage, params: { + account: { address: account.address }, message: 'Hello, world!', }, }, diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index e0ad4da1..a23715e7 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "M7LJk6fzMpw9tjV4ytZEJpcNaMfY6t4RUQacQCqfGz8=", + "shasum": "qdexQ1itu3G1EGrC7+GT2HFuNr++VxwFe9zLfHVPiKU=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index b2f0047f..8691a519 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -14,8 +14,9 @@ import type { KeyringResponse, Transaction as KeyringTransaction, KeyringRequest, + KeyringAccount, } from '@metamask/keyring-api'; -import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; +import { BtcAccountType, BtcMethod, BtcScope } from '@metamask/keyring-api'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; @@ -844,4 +845,150 @@ describe('KeyringHandler', () => { expect(result).toStrictEqual(expectedResponse); }); }); + + describe('resolveAccountAddress', () => { + const mockKeyringAccount1 = mock({ + id: 'account-1', + address: 'test123', + scopes: [BtcScope.Regtest], + }); + const mockKeyringAccount2 = mock({ + id: 'account-2', + address: 'test456', + scopes: [BtcScope.Regtest], + }); + + beforeEach(() => { + mockAccounts.list.mockResolvedValue([mockAccount]); + }); + + it('resolves account address successfully', async () => { + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: BtcMethod.SignPsbt, + params: { + account: { address: 'test123' }, + psbt: 'psbt', + }, + }; + + jest + .spyOn(handler, 'listAccounts') + .mockResolvedValueOnce([mockKeyringAccount1, mockKeyringAccount2]); + + const result = await handler.resolveAccountAddress( + BtcScope.Regtest, + request, + ); + + expect(handler.listAccounts).toHaveBeenCalled(); + expect(result).toStrictEqual({ + address: `${BtcScope.Regtest}:test123`, + }); + }); + + it('returns null when account address not found', async () => { + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: BtcMethod.SignPsbt, + params: { + account: { address: 'notfound' }, + psbt: 'psbt', + }, + }; + + jest + .spyOn(handler, 'listAccounts') + .mockResolvedValue([mockKeyringAccount1, mockKeyringAccount2]); + + const result = await handler.resolveAccountAddress( + BtcScope.Regtest, + request, + ); + + expect(result).toBeNull(); + }); + + it('returns null when no accounts match the scope', async () => { + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: BtcMethod.SignPsbt, + params: { + account: { address: 'test123' }, + psbt: 'psbt', + }, + }; + + const accountWithDifferentScope = mock({ + id: 'account-3', + address: 'test123', + scopes: [BtcScope.Mainnet], + }); + + jest + .spyOn(handler, 'listAccounts') + .mockResolvedValue([accountWithDifferentScope]); + + const result = await handler.resolveAccountAddress( + BtcScope.Regtest, + request, + ); + + expect(result).toBeNull(); + }); + + it('returns null when scope validation fails', async () => { + const request = { + id: '1', + jsonrpc: '2.0' as const, + method: BtcMethod.SignPsbt, + params: { + account: { address: 'test123' }, + psbt: 'psbt', + }, + }; + + jest.mocked(assert).mockImplementationOnce(() => { + throw new Error('Invalid scope'); + }); + + const result = await handler.resolveAccountAddress( + 'invalid-scope' as never, + request, + ); + + expect(result).toBeNull(); + }); + + it('returns null when request validation fails', async () => { + const invalidRequest = { + id: '1', + jsonrpc: '2.0' as const, + method: 'invalid', + params: {}, + }; + + jest + .spyOn(handler, 'listAccounts') + .mockResolvedValue([mockKeyringAccount1, mockKeyringAccount2]); + + // First assert (scope) passes, second assert (request struct) throws + jest + .mocked(assert) + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { + throw new Error('Invalid request'); + }); + + const result = await handler.resolveAccountAddress( + BtcScope.Regtest, + invalidRequest, + ); + + expect(result).toBeNull(); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 14e2b249..7fc5a764 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -13,6 +13,7 @@ import { ListAccountsRequestStruct, ListAccountTransactionsRequestStruct, MetaMaskOptionsStruct, + ResolveAccountAddressRequestStruct, SetSelectedAccountsRequestStruct, SubmitRequestRequestStruct, } from '@metamask/keyring-api'; @@ -29,8 +30,9 @@ import type { MetaMaskOptions, DiscoveredAccount, KeyringRequest, + ResolvedAccountAddress, } from '@metamask/keyring-api'; -import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; +import type { CaipChainId, Json, JsonRpcRequest } from '@metamask/snaps-sdk'; import { assert, boolean, @@ -54,6 +56,7 @@ import { caipToAddressType, scopeToNetwork, networkToScope, + NetworkStruct, } from './caip'; import { CronMethod } from './CronHandler'; import type { KeyringRequestHandler } from './KeyringRequestHandler'; @@ -62,7 +65,7 @@ import { mapToKeyringAccount, mapToTransaction, } from './mappings'; -import { validateSelectedAccounts } from './validation'; +import { BtcWalletRequestStruct, validateSelectedAccounts } from './validation'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; import { runSnapActionSafely } from '../utils/snapHelpers'; @@ -160,6 +163,13 @@ export class KeyringHandler implements Keyring { await this.setSelectedAccounts(request.params.accounts); return null; } + case `${KeyringRpcMethod.ResolveAccountAddress}`: { + assert(request, ResolveAccountAddressRequestStruct); + return this.resolveAccountAddress( + request.params.scope, + request.params.request, + ); + } default: { throw new InexistentMethodError('Keyring method not supported', { @@ -386,6 +396,57 @@ export class KeyringHandler implements Keyring { }); } + /** + * Resolves the address of an account from a signing request. + * + * This is required by the routing system of MetaMask to dispatch + * incoming non-EVM dapp signing requests. + * + * @param scope - Request's scope (CAIP-2). + * @param request - Signing request object. + * @returns A Promise that resolves to the account address that must + * be used to process this signing request, or null if none candidates + * could be found. + */ + async resolveAccountAddress( + scope: CaipChainId, + request: JsonRpcRequest, + ): Promise { + try { + assert(scope, NetworkStruct); + const { method, params } = request; + + const requestWithoutCommonHeader = { method, params }; + assert(requestWithoutCommonHeader, BtcWalletRequestStruct); + + const allAccounts = await this.listAccounts(); + + const accountsWithThisScope = allAccounts.filter((account) => + account.scopes.includes(scope), + ); + + if (accountsWithThisScope.length === 0) { + throw new Error('No accounts with this scope'); + } + + const { address: addressToValidate } = + requestWithoutCommonHeader.params.account; + + const foundAccount = accountsWithThisScope.find( + (account) => account.address === addressToValidate, + ); + + if (!foundAccount) { + throw new Error('Account not found'); + } + + return { address: `${scope}:${addressToValidate}` }; + } catch (error: unknown) { + this.#logger.error({ error }, 'Error resolving account address'); + return null; + } + } + #extractAddressType(path: string): AddressType { const segments = path.split('/'); if (segments.length < 4) { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts index 5394e688..b457d96f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts @@ -4,21 +4,27 @@ import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; import type { AccountUseCases } from '../use-cases'; +import { KeyringRequestHandler } from './KeyringRequestHandler'; import { BroadcastPsbtRequest, ComputeFeeRequest, FillPsbtRequest, GetUtxoRequest, - KeyringRequestHandler, SendTransferRequest, SignPsbtRequest, -} from './KeyringRequestHandler'; +} from './validation'; import type { BitcoinAccount } from '../entities'; import { AccountCapability } from '../entities'; import type { Utxo } from './mappings'; import { mapToUtxo } from './mappings'; import { parsePsbt } from './parsers'; +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => ({ + Address: { from_string: jest.fn() }, + Amount: { from_btc: jest.fn() }, +})); + jest.mock('superstruct', () => ({ ...jest.requireActual('superstruct'), assert: jest.fn(), @@ -36,6 +42,11 @@ describe('KeyringRequestHandler', () => { const mockAccountsUseCases = mock(); const origin = 'metamask'; + const ACCOUNT_ADDRESS = 'test-account-address'; + const accountParam = { + account: { address: ACCOUNT_ADDRESS }, + }; + const handler = new KeyringRequestHandler(mockAccountsUseCases); beforeEach(() => { @@ -65,6 +76,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SignPsbt, params: { + ...accountParam, psbt: 'psbtBase64', feeRate: 3, options: mockOptions, @@ -109,7 +121,10 @@ describe('KeyringRequestHandler', () => { await expect( handler.route({ ...mockRequest, - request: { ...mockRequest.request, params: { psbt: 'invalidPsbt' } }, + request: { + ...mockRequest.request, + params: { ...accountParam, psbt: 'invalidPsbt' }, + }, }), ).rejects.toThrow(error); @@ -131,6 +146,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.ComputeFee, params: { + ...accountParam, psbt: 'psbtBase64', feeRate: 3, }, @@ -141,7 +157,6 @@ describe('KeyringRequestHandler', () => { it('executes computeFee', async () => { mockAccountsUseCases.computeFee.mockResolvedValue( mock({ - // eslint-disable-next-line @typescript-eslint/naming-convention to_sat: () => BigInt(1000), }), ); @@ -172,7 +187,10 @@ describe('KeyringRequestHandler', () => { await expect( handler.route({ ...mockRequest, - request: { ...mockRequest.request, params: { psbt: 'invalidPsbt' } }, + request: { + ...mockRequest.request, + params: { ...accountParam, psbt: 'invalidPsbt' }, + }, }), ).rejects.toThrow(error); @@ -194,6 +212,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.FillPsbt, params: { + ...accountParam, psbt: 'psbtBase64', feeRate: 3, }, @@ -233,7 +252,10 @@ describe('KeyringRequestHandler', () => { await expect( handler.route({ ...mockRequest, - request: { ...mockRequest.request, params: { psbt: 'invalidPsbt' } }, + request: { + ...mockRequest.request, + params: { ...accountParam, psbt: 'invalidPsbt' }, + }, }), ).rejects.toThrow(error); @@ -256,6 +278,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.BroadcastPsbt, params: { + ...accountParam, psbt: 'psbtBase64', feeRate: 3, }, @@ -295,7 +318,10 @@ describe('KeyringRequestHandler', () => { await expect( handler.route({ ...mockRequest, - request: { ...mockRequest.request, params: { psbt: 'invalidPsbt' } }, + request: { + ...mockRequest.request, + params: { ...accountParam, psbt: 'invalidPsbt' }, + }, }), ).rejects.toThrow(error); @@ -324,6 +350,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SendTransfer, params: { + ...accountParam, recipients, feeRate: 3, }, @@ -376,6 +403,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.GetUtxo, params: { + ...accountParam, outpoint: 'mytxid:0', }, }, @@ -470,6 +498,7 @@ describe('KeyringRequestHandler', () => { request: { method: AccountCapability.SignMessage, params: { + ...accountParam, message: 'message', }, }, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts index fb84f7a8..db0c7c74 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts @@ -1,14 +1,6 @@ import type { KeyringRequest, KeyringResponse } from '@metamask/keyring-api'; import type { Json } from '@metamask/snaps-sdk'; -import { - array, - assert, - boolean, - number, - object, - optional, - string, -} from 'superstruct'; +import { assert } from 'superstruct'; import { AccountCapability, @@ -17,67 +9,35 @@ import { } from '../entities'; import { mapToUtxo } from './mappings'; import { parsePsbt } from './parsers'; +import { + BroadcastPsbtRequest, + ComputeFeeRequest, + FillPsbtRequest, + GetUtxoRequest, + SendTransferRequest, + SignMessageRequest, + SignPsbtRequest, +} from './validation'; import type { AccountUseCases } from '../use-cases/AccountUseCases'; -export const SignPsbtRequest = object({ - psbt: string(), - feeRate: optional(number()), - options: object({ - fill: boolean(), - broadcast: boolean(), - }), -}); - export type SignPsbtResponse = { psbt: string; txid: string | null; }; -export const ComputeFeeRequest = object({ - psbt: string(), - feeRate: optional(number()), -}); - export type ComputeFeeResponse = { // Fee in satoshis fee: string; }; -export const BroadcastPsbtRequest = object({ - psbt: string(), -}); - export type BroadcastPsbtResponse = { txid: string; }; -export const FillPsbtRequest = object({ - psbt: string(), - feeRate: optional(number()), -}); - export type FillPsbtResponse = { psbt: string; }; -export const SendTransferRequest = object({ - recipients: array( - object({ - address: string(), - amount: string(), - }), - ), - feeRate: optional(number()), -}); - -export const GetUtxoRequest = object({ - outpoint: string(), -}); - -export const SignMessageRequest = object({ - message: string(), -}); - export type SignMessageResponse = { signature: string; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts index 182f0654..fc8ea620 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/caip.ts @@ -1,5 +1,6 @@ import type { AddressType, Network } from '@metamask/bitcoindevkit'; import { BtcAccountType, BtcScope } from '@metamask/keyring-api'; +import { enums } from 'superstruct'; const reverseMapping = < From extends string | number | symbol, @@ -37,6 +38,8 @@ export enum Caip19Asset { Regtest = 'bip122:regtest/slip44:0', } +export const NetworkStruct = enums(Object.values(BtcScope)); + export const networkToCaip19: Record = { bitcoin: Caip19Asset.Bitcoin, testnet: Caip19Asset.Testnet, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts index 5c7a6b7b..fc2d7238 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts @@ -1,4 +1,12 @@ -import { parseRewardsMessage } from './validation'; +import type { AddressType } from '@metamask/bitcoindevkit'; +import { mock } from 'jest-mock-extended'; + +import type { BitcoinAccount } from '../entities'; +import { + parseRewardsMessage, + validateDustLimit, + validateSelectedAccounts, +} from './validation'; /* eslint-disable @typescript-eslint/naming-convention */ jest.mock('@metamask/bitcoindevkit', () => ({ @@ -11,6 +19,53 @@ jest.mock('@metamask/bitcoindevkit', () => ({ })); describe('validation', () => { + describe('validateDustLimit', () => { + const makeAccount = (addressType: AddressType) => + mock({ addressType }); + + it.each<{ type: AddressType; dust: bigint }>([ + { type: 'p2pkh', dust: 546n }, + { type: 'p2sh', dust: 540n }, + { type: 'p2wsh', dust: 330n }, + { type: 'p2tr', dust: 330n }, + { type: 'p2wpkh', dust: 294n }, + ])( + 'returns valid for amount above dust limit for $type', + ({ type, dust }) => { + const amountBtc = (Number(dust) / 1e8 + 0.00000001).toFixed(8); + const mockFromBtc = { to_sat: () => dust + 1n }; + const { Amount } = jest.requireMock('@metamask/bitcoindevkit'); + Amount.from_btc.mockReturnValue(mockFromBtc); + + const result = validateDustLimit(amountBtc, makeAccount(type)); + expect(result.valid).toBe(true); + }, + ); + + it('returns invalid for amount below dust limit', () => { + const mockFromBtc = { to_sat: () => 100n }; + const { Amount } = jest.requireMock('@metamask/bitcoindevkit'); + Amount.from_btc.mockReturnValue(mockFromBtc); + + const result = validateDustLimit('0.000001', makeAccount('p2pkh')); + expect(result.valid).toBe(false); + }); + }); + + describe('validateSelectedAccounts', () => { + it('does not throw when all account IDs exist', () => { + expect(() => + validateSelectedAccounts(new Set(['a', 'b']), ['a', 'b', 'c']), + ).not.toThrow(); + }); + + it('throws when an account ID does not exist', () => { + expect(() => + validateSelectedAccounts(new Set(['a', 'x']), ['a', 'b']), + ).toThrow('Account IDs were not part of existing accounts.'); + }); + }); + describe('parseRewardsMessage', () => { const validBitcoinMainnetAddress = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts index ccd73a25..a2211864 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts @@ -1,5 +1,6 @@ import type { Network, AddressType } from '@metamask/bitcoindevkit'; import { Address, Amount } from '@metamask/bitcoindevkit'; +import { BtcMethod } from '@metamask/keyring-api'; import { CaipAssetTypeStruct } from '@metamask/utils'; import type { Infer } from 'superstruct'; import { @@ -7,10 +8,14 @@ import { array, boolean, enums, + literal, + number, object, + optional, string, nonempty, refine, + union, is, } from 'superstruct'; @@ -87,6 +92,113 @@ export const NO_ERRORS_RESPONSE: ValidationResponse = { errors: [], }; +/** + * Wallet account struct for Bitcoin requests. + */ +const WalletAccountStruct = object({ + address: string(), +}); + +export const SignPsbtRequest = object({ + account: WalletAccountStruct, + psbt: string(), + feeRate: optional(number()), + options: object({ + fill: boolean(), + broadcast: boolean(), + }), +}); + +export const ComputeFeeRequest = object({ + account: WalletAccountStruct, + psbt: string(), + feeRate: optional(number()), +}); + +export const BroadcastPsbtRequest = object({ + account: WalletAccountStruct, + psbt: string(), +}); + +export const FillPsbtRequest = object({ + account: WalletAccountStruct, + psbt: string(), + feeRate: optional(number()), +}); + +export const SendTransferRequest = object({ + account: WalletAccountStruct, + recipients: array( + object({ + address: string(), + amount: string(), + }), + ), + feeRate: optional(number()), +}); + +export const GetUtxoRequest = object({ + account: WalletAccountStruct, + outpoint: string(), +}); + +export const SignMessageRequest = object({ + account: WalletAccountStruct, + message: string(), +}); + +/** + * Validates that a JsonRpcRequest is a valid Bitcoin request. + * + * TODO: update btc-methods.md to include all the new methods + * + * @see https://github.com/MetaMask/accounts/blob/main/packages/keyring-api/docs/btc-methods.md + */ +export const SignPsbtKeyringRequestStruct = object({ + method: literal(BtcMethod.SignPsbt), + params: SignPsbtRequest, +}); + +export const FillPsbtKeyringRequestStruct = object({ + method: literal(BtcMethod.FillPsbt), + params: FillPsbtRequest, +}); + +export const ComputeFeeKeyringRequestStruct = object({ + method: literal(BtcMethod.ComputeFee), + params: ComputeFeeRequest, +}); + +export const BroadcastPsbtKeyringRequestStruct = object({ + method: literal(BtcMethod.BroadcastPsbt), + params: BroadcastPsbtRequest, +}); + +export const SendTransferKeyringRequestStruct = object({ + method: literal(BtcMethod.SendTransfer), + params: SendTransferRequest, +}); + +export const GetUtxoKeyringRequestStruct = object({ + method: literal(BtcMethod.GetUtxo), + params: GetUtxoRequest, +}); + +export const SignMessageKeyringRequestStruct = object({ + method: literal(BtcMethod.SignMessage), + params: SignMessageRequest, +}); + +export const BtcWalletRequestStruct = union([ + SignPsbtKeyringRequestStruct, + FillPsbtKeyringRequestStruct, + ComputeFeeKeyringRequestStruct, + BroadcastPsbtKeyringRequestStruct, + SendTransferKeyringRequestStruct, + GetUtxoKeyringRequestStruct, + SignMessageKeyringRequestStruct, +]); + /** * Validates that an amount is a positive number * From 6bbb959f46e37acf0055ccd374a186462c884434 Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 4 May 2026 18:39:49 +0200 Subject: [PATCH 333/362] feat: add confirmation dialog before sendTransfer and before signing a PSBT from the keyring handler (#591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: extract and centralize request validation; add account: { address } to all request types * test: add the account: { address: account.address } param to every request for integration tests * fix: remove unused type export * feat: add resolveAccountAddress method to KeyringHandler for dApp connectivity routing * test: adjust unit tests to fit coverage * fix: remove switch since the assert against BtcWalletRequestStruct already guarantees the method is valid * docs: update CHANGELOG.md * feat: show a confirmation dialog before signing a PSBT and a sendTransfer * docs: update CHANGELOG.md * chore: log warning when exchange rate fetch fails in confirmations Inject Logger into JSXConfirmationRepository and emit a warn-level log in the catch block of #getExchangeRate, replacing the previous silent swallow. Behavior is unchanged — exchange rates remain optional display information — but failures are now observable. --- .../bitcoin-wallet-snap/CHANGELOG.md | 1 + .../integration-test/keyring-request.test.ts | 87 +++- .../bitcoin-wallet-snap/locales/en.json | 42 ++ .../bitcoin-wallet-snap/messages.json | 42 ++ .../bitcoin-wallet-snap/snap.manifest.json | 4 +- .../src/entities/confirmation.ts | 64 ++- .../src/entities/send-flow.ts | 1 + .../handlers/KeyringRequestHandler.test.ts | 61 ++- .../src/handlers/KeyringRequestHandler.ts | 24 +- .../bitcoin-wallet-snap/src/index.ts | 8 +- .../SignPsbtConfirmationView.tsx | 188 ++++++++ .../src/infra/jsx/confirmations/index.ts | 1 + .../unified-send-flow/UnifiedSendFormView.tsx | 13 +- .../src/store/BdkAccountRepository.test.ts | 45 ++ .../store/JSXConfirmationRepository.test.tsx | 407 +++++++++++++++++- .../src/store/JSXConfirmationRepository.tsx | 174 +++++++- .../src/store/JSXSendFlowRepository.test.tsx | 19 + .../src/use-cases/AccountUseCases.test.ts | 106 ++++- .../src/use-cases/AccountUseCases.ts | 32 +- .../src/use-cases/SendFlowUseCases.test.ts | 116 +++++ 20 files changed, 1381 insertions(+), 54 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignPsbtConfirmationView.tsx diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index f199692a..e1333e4f 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Show a confirmation dialog before signing a PSBT from KeyringHandler and sending a transfer ([#591](https://github.com/MetaMask/snap-bitcoin-wallet/pull/591)) - Add `resolveAccountAddress` method to KeyringHandler for dApp connectivity ([#590](https://github.com/MetaMask/snap-bitcoin-wallet/pull/590)) ## [1.10.1] diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts index 4f28070c..07c0221b 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts @@ -211,7 +211,7 @@ describe('KeyringRequestHandler', () => { 'cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUgpMvYEJ/dp36svRJyRtNnpSo7bQAAAAAAAAAAA=='; it('signs a PSBT successfully: sign', async () => { - const response = await snap.onKeyringRequest({ + const response = snap.onKeyringRequest({ origin: ORIGIN, method: submitRequestMethod, params: { @@ -234,7 +234,13 @@ describe('KeyringRequestHandler', () => { } as KeyringRequest, }); - expect(response).toRespondWith({ + const ui = await response.getInterface(); + assertIsConfirmationDialog(ui); + await ui.ok(); + + const result = await response; + + expect(result).toRespondWith({ pending: false, result: { psbt: SIGNED_PSBT, @@ -244,7 +250,7 @@ describe('KeyringRequestHandler', () => { }); it('signs a PSBT successfully: fill and sign', async () => { - const response = await snap.onKeyringRequest({ + const response = snap.onKeyringRequest({ origin: ORIGIN, method: submitRequestMethod, params: { @@ -267,7 +273,13 @@ describe('KeyringRequestHandler', () => { } as KeyringRequest, }); - expect(response).toRespondWith({ + const ui = await response.getInterface(); + assertIsConfirmationDialog(ui); + await ui.ok(); + + const result = await response; + + expect(result).toRespondWith({ pending: false, result: { psbt: expect.any(String), // non deterministic @@ -277,7 +289,7 @@ describe('KeyringRequestHandler', () => { }); it('signs a PSBT successfully: fill, sign and broadcast', async () => { - const response = await snap.onKeyringRequest({ + const response = snap.onKeyringRequest({ origin: ORIGIN, method: submitRequestMethod, params: { @@ -300,7 +312,13 @@ describe('KeyringRequestHandler', () => { } as KeyringRequest, }); - expect(response).toRespondWith({ + const ui = await response.getInterface(); + assertIsConfirmationDialog(ui); + await ui.ok(); + + const result = await response; + + expect(result).toRespondWith({ pending: false, result: { psbt: expect.any(String), // non deterministic @@ -343,6 +361,37 @@ describe('KeyringRequestHandler', () => { }); }); + it('fails if missing account', async () => { + const response = await snap.onKeyringRequest({ + origin: ORIGIN, + method: submitRequestMethod, + params: { + id: account.id, + origin, + scope: BtcScope.Regtest, + account: account.id, + request: { + method: AccountCapability.SignPsbt, + params: { + psbt: TEMPLATE_PSBT, + feeRate: 3, + options: { + fill: true, + broadcast: true, + }, + }, + }, + } as KeyringRequest, + }); + + expect(response).toRespondWithError({ + code: -32000, + message: + 'Invalid format: At path: account -- Expected an object, but received: undefined', + stack: expect.anything(), + }); + }); + it('fails if missing options', async () => { const response = await snap.onKeyringRequest({ origin: ORIGIN, @@ -506,7 +555,7 @@ describe('KeyringRequestHandler', () => { it('broadcasts a PSBT successfully', async () => { // Prepare the PSBT to broadcast so we have a valid PSBT to broadcast - let response = await snap.onKeyringRequest({ + const signResponse = snap.onKeyringRequest({ origin: ORIGIN, method: submitRequestMethod, params: { @@ -529,11 +578,17 @@ describe('KeyringRequestHandler', () => { } as KeyringRequest, }); + const signUi = await signResponse.getInterface(); + assertIsConfirmationDialog(signUi); + await signUi.ok(); + + const signResult = await signResponse; + const { result } = ( - response.response as { result: { result: FillPsbtResponse } } + signResult.response as { result: { result: FillPsbtResponse } } ).result; - response = await snap.onKeyringRequest({ + const response = await snap.onKeyringRequest({ origin: ORIGIN, method: submitRequestMethod, params: { @@ -592,7 +647,7 @@ describe('KeyringRequestHandler', () => { describe('sendTransfer', () => { it('sends funds successfully', async () => { - const response = await snap.onKeyringRequest({ + const response = snap.onKeyringRequest({ origin: ORIGIN, method: submitRequestMethod, params: { @@ -609,10 +664,6 @@ describe('KeyringRequestHandler', () => { address: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', amount: '1000', }, - { - address: 'bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y', - amount: '1000', - }, ], feeRate: 3, }, @@ -620,7 +671,13 @@ describe('KeyringRequestHandler', () => { } as KeyringRequest, }); - expect(response).toRespondWith({ + const ui = await response.getInterface(); + assertIsConfirmationDialog(ui); + await ui.ok(); + + const result = await response; + + expect(result).toRespondWith({ pending: false, result: { txid: expect.any(String), diff --git a/merged-packages/bitcoin-wallet-snap/locales/en.json b/merged-packages/bitcoin-wallet-snap/locales/en.json index a4e7e54a..0542ef89 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/en.json +++ b/merged-packages/bitcoin-wallet-snap/locales/en.json @@ -213,6 +213,48 @@ }, "confirmation.requestOrigin": { "message": "Request from" + }, + "confirmation.signPsbt.title": { + "message": "Sign Transaction" + }, + "confirmation.signPsbt.confirmButton": { + "message": "Sign" + }, + "confirmation.signPsbt.psbt": { + "message": "Transaction (PSBT)" + }, + "confirmation.signPsbt.options": { + "message": "Options" + }, + "confirmation.signPsbt.options.fill": { + "message": "Auto-fill inputs" + }, + "confirmation.signPsbt.options.broadcast": { + "message": "Broadcast after signing" + }, + "confirmation.signPsbt.outputs": { + "message": "Transaction outputs" + }, + "confirmation.signPsbt.output.change": { + "message": "Change" + }, + "confirmation.signPsbt.output.opReturn": { + "message": "OP_RETURN (data)" + }, + "confirmation.signPsbt.output.unknown": { + "message": "Unknown script" + }, + "confirmation.signPsbt.inputs": { + "message": "Inputs" + }, + "confirmation.signPsbt.rawPsbt": { + "message": "Raw PSBT" + }, + "yes": { + "message": "Yes" + }, + "no": { + "message": "No" } } } diff --git a/merged-packages/bitcoin-wallet-snap/messages.json b/merged-packages/bitcoin-wallet-snap/messages.json index d25ad7be..1af59a2c 100644 --- a/merged-packages/bitcoin-wallet-snap/messages.json +++ b/merged-packages/bitcoin-wallet-snap/messages.json @@ -211,5 +211,47 @@ }, "confirmation.requestOrigin": { "message": "Request from" + }, + "confirmation.signPsbt.title": { + "message": "Sign Transaction" + }, + "confirmation.signPsbt.confirmButton": { + "message": "Sign" + }, + "confirmation.signPsbt.psbt": { + "message": "Transaction (PSBT)" + }, + "confirmation.signPsbt.options": { + "message": "Options" + }, + "confirmation.signPsbt.options.fill": { + "message": "Auto-fill inputs" + }, + "confirmation.signPsbt.options.broadcast": { + "message": "Broadcast after signing" + }, + "confirmation.signPsbt.outputs": { + "message": "Transaction outputs" + }, + "confirmation.signPsbt.output.change": { + "message": "Change" + }, + "confirmation.signPsbt.output.opReturn": { + "message": "OP_RETURN (data)" + }, + "confirmation.signPsbt.output.unknown": { + "message": "Unknown script" + }, + "confirmation.signPsbt.inputs": { + "message": "Inputs" + }, + "confirmation.signPsbt.rawPsbt": { + "message": "Raw PSBT" + }, + "yes": { + "message": "Yes" + }, + "no": { + "message": "No" } } diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index a23715e7..0d576bac 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.10.0", + "version": "1.10.1", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "qdexQ1itu3G1EGrC7+GT2HFuNr++VxwFe9zLfHVPiKU=", + "shasum": "1jbPT93y4v8JjV5Sj8zdfhhk9Eq1GJrTjSjciRYsl4c=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/confirmation.ts b/merged-packages/bitcoin-wallet-snap/src/entities/confirmation.ts index f7f5836b..ce04ae7c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/confirmation.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/confirmation.ts @@ -1,6 +1,8 @@ -import type { Network } from '@metamask/bitcoindevkit'; +import type { Network, Psbt } from '@metamask/bitcoindevkit'; +import type { CurrencyRate } from '@metamask/snaps-sdk'; import type { BitcoinAccount } from './account'; +import type { CurrencyUnit } from './currency'; export type SignMessageConfirmationContext = { message: string; @@ -12,6 +14,32 @@ export type SignMessageConfirmationContext = { origin: string; }; +export type SignPsbtOutput = { + address?: string; + amount: string; + isMine: boolean; + isOpReturn: boolean; +}; + +export type SignPsbtConfirmationContext = { + psbt: string; + account: { + id: string; + address: string; + }; + network: Network; + origin: string; + options: { + fill: boolean; + broadcast: boolean; + }; + currency: CurrencyUnit; + exchangeRate?: CurrencyRate; + fee?: string; + outputs: SignPsbtOutput[]; + inputCount: number; +}; + export enum ConfirmationEvent { Confirm = 'confirmation-confirm', Cancel = 'confirmation-cancel', @@ -33,4 +61,38 @@ export type ConfirmationRepository = { message: string, origin: string, ): Promise; + + /** + * Inserts a send transfer confirmation interface. + * + * @param account - The account sending the transfer. + * @param psbt - The PSBT of the transfer. + * @param recipient - The recipient of the transfer. + * @param recipient.address - The address of the recipient. + * @param recipient.amount - The amount to send to the recipient. + * @param origin - The origin of the request. + */ + insertSendTransfer( + account: BitcoinAccount, + psbt: Psbt, + recipient: { address: string; amount: string }, + origin: string, + ): Promise; + + /** + * Inserts a sign PSBT confirmation interface. + * + * @param account - The account to sign the PSBT. + * @param psbt - The PSBT to sign (as Psbt object). + * @param origin - The origin of the request. + * @param options - The sign options (fill, broadcast). + * @param options.fill - Whether to fill the PSBT. + * @param options.broadcast - Whether to broadcast the PSBT. + */ + insertSignPsbt( + account: BitcoinAccount, + psbt: Psbt, + origin: string, + options: { fill: boolean; broadcast: boolean }, + ): Promise; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts index f8230c4b..f47f28df 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts @@ -17,6 +17,7 @@ export type ConfirmSendFormContext = { backgroundEventId?: string; locale: string; psbt: string; + origin?: string; }; export type SendFormContext = { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts index b457d96f..2792d4ba 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts @@ -13,7 +13,7 @@ import { SendTransferRequest, SignPsbtRequest, } from './validation'; -import type { BitcoinAccount } from '../entities'; +import type { BitcoinAccount, ConfirmationRepository } from '../entities'; import { AccountCapability } from '../entities'; import type { Utxo } from './mappings'; import { mapToUtxo } from './mappings'; @@ -40,6 +40,7 @@ jest.mock('./mappings', () => ({ describe('KeyringRequestHandler', () => { const mockAccountsUseCases = mock(); + const mockConfirmationRepository = mock(); const origin = 'metamask'; const ACCOUNT_ADDRESS = 'test-account-address'; @@ -47,7 +48,10 @@ describe('KeyringRequestHandler', () => { account: { address: ACCOUNT_ADDRESS }, }; - const handler = new KeyringRequestHandler(mockAccountsUseCases); + const handler = new KeyringRequestHandler( + mockAccountsUseCases, + mockConfirmationRepository, + ); beforeEach(() => { jest.mocked(parsePsbt).mockReturnValue(mockPsbt); @@ -71,6 +75,7 @@ describe('KeyringRequestHandler', () => { describe('signPsbt', () => { const mockOptions = { fill: false, broadcast: true }; + const mockAccount = mock(); const mockRequest = mock({ origin, request: { @@ -85,7 +90,12 @@ describe('KeyringRequestHandler', () => { account: 'account-id', }); - it('executes signPsbt', async () => { + beforeEach(() => { + mockAccountsUseCases.get.mockResolvedValue(mockAccount); + mockConfirmationRepository.insertSignPsbt.mockResolvedValue(undefined); + }); + + it('executes signPsbt with confirmation', async () => { mockAccountsUseCases.signPsbt.mockResolvedValue({ psbt: 'psbtBase64', txid: mock({ @@ -99,6 +109,13 @@ describe('KeyringRequestHandler', () => { mockRequest.request.params, SignPsbtRequest, ); + expect(mockAccountsUseCases.get).toHaveBeenCalledWith('account-id'); + expect(mockConfirmationRepository.insertSignPsbt).toHaveBeenCalledWith( + mockAccount, + mockPsbt, + 'metamask', + mockOptions, + ); expect(mockAccountsUseCases.signPsbt).toHaveBeenCalledWith( 'account-id', mockPsbt, @@ -112,6 +129,32 @@ describe('KeyringRequestHandler', () => { }); }); + it('returns null txid when signPsbt result has no txid', async () => { + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + txid: undefined, + }); + + const result = await handler.route(mockRequest); + + expect(result).toStrictEqual({ + pending: false, + result: { psbt: 'psbtBase64', txid: null }, + }); + }); + + it('does not sign if user cancels confirmation', async () => { + mockConfirmationRepository.insertSignPsbt.mockRejectedValue( + new Error('User canceled the confirmation'), + ); + + await expect(handler.route(mockRequest)).rejects.toThrow( + 'User canceled the confirmation', + ); + + expect(mockAccountsUseCases.signPsbt).not.toHaveBeenCalled(); + }); + it('propagates errors from parsePsbt', async () => { const error = new Error('parsePsbt'); jest.mocked(parsePsbt).mockImplementationOnce(() => { @@ -432,6 +475,18 @@ describe('KeyringRequestHandler', () => { result: expectedUtxo, }); }); + + it('throws NotFoundError when UTXO does not exist', async () => { + const emptyAccount = mock({ + getUtxo: () => undefined, + network: 'bitcoin', + }); + mockAccountsUseCases.get.mockResolvedValue(emptyAccount); + + await expect(handler.route(mockRequest)).rejects.toThrow( + 'UTXO not found', + ); + }); }); describe('listUtxos', () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts index db0c7c74..bf1a3f4d 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts @@ -2,6 +2,7 @@ import type { KeyringRequest, KeyringResponse } from '@metamask/keyring-api'; import type { Json } from '@metamask/snaps-sdk'; import { assert } from 'superstruct'; +import type { ConfirmationRepository } from '../entities'; import { AccountCapability, InexistentMethodError, @@ -45,8 +46,14 @@ export type SignMessageResponse = { export class KeyringRequestHandler { readonly #accountsUseCases: AccountUseCases; - constructor(accounts: AccountUseCases) { + readonly #confirmationRepository: ConfirmationRepository; + + constructor( + accounts: AccountUseCases, + confirmationRepository: ConfirmationRepository, + ) { this.#accountsUseCases = accounts; + this.#confirmationRepository = confirmationRepository; } async route(request: KeyringRequest): Promise { @@ -113,7 +120,18 @@ export class KeyringRequestHandler { options: { fill: boolean; broadcast: boolean }, feeRate?: number, ): Promise { - const { psbt, txid } = await this.#accountsUseCases.signPsbt( + const psbt = parsePsbt(psbtBase64); + const account = await this.#accountsUseCases.get(id); + + await this.#confirmationRepository.insertSignPsbt( + account, + psbt, + origin, + options, + ); + + // Creates a fresh PSBT from the original base64 because the original PSBT is mutated by the confirmation repository + const { psbt: signedPsbt, txid } = await this.#accountsUseCases.signPsbt( id, parsePsbt(psbtBase64), origin, @@ -121,7 +139,7 @@ export class KeyringRequestHandler { feeRate, ); return this.#toKeyringResponse({ - psbt: psbt.toString(), + psbt: signedPsbt.toString(), txid: txid?.toString() ?? null, } as SignPsbtResponse); } diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 5759e0c7..2d8a6968 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -51,6 +51,9 @@ const sendFlowRepository = new JSXSendFlowRepository(snapClient, translator); const confirmationRepository = new JSXConfirmationRepository( snapClient, translator, + chainClient, + assetRatesClient, + logger, ); // Business layer @@ -83,7 +86,10 @@ const assetsUseCases = new AssetsUseCases( const confirmationUseCases = new ConfirmationUseCases(logger, snapClient); // Application layer -const keyringRequestHandler = new KeyringRequestHandler(accountsUseCases); +const keyringRequestHandler = new KeyringRequestHandler( + accountsUseCases, + confirmationRepository, +); const keyringHandler = new KeyringHandler( keyringRequestHandler, accountsUseCases, diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignPsbtConfirmationView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignPsbtConfirmationView.tsx new file mode 100644 index 00000000..56f0937c --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/SignPsbtConfirmationView.tsx @@ -0,0 +1,188 @@ +import { + Address, + Box, + Button, + Container, + Copyable, + Footer, + Section, + Text as SnapText, + type SnapComponent, +} from '@metamask/snaps-sdk/jsx'; + +import type { Messages, SignPsbtConfirmationContext } from '../../../entities'; +import { ConfirmationEvent } from '../../../entities'; +import { AssetIconInline, HeadingWithReturn } from '../components'; +import { + displayAmount, + displayCaip10, + displayExchangeAmount, + displayNetwork, + translate, +} from '../format'; + +type SignPsbtConfirmationViewProps = { + context: SignPsbtConfirmationContext; + messages: Messages; +}; + +export const SignPsbtConfirmationView: SnapComponent< + SignPsbtConfirmationViewProps +> = ({ context, messages }) => { + const t = translate(messages); + const { + account, + network, + origin, + psbt, + options, + fee, + currency, + exchangeRate, + outputs, + inputCount, + } = context; + + return ( + + + + + {outputs.length > 0 ? ( +
+ + + {t('confirmation.signPsbt.outputs')} + + + {outputs.map((output, index) => { + let label: string; + if (output.isOpReturn) { + label = t('confirmation.signPsbt.output.opReturn'); + } else if (output.isMine) { + label = t('confirmation.signPsbt.output.change'); + } else if (output.address) { + label = `${t('toAddress')} #${index + 1}`; + } else { + label = t('confirmation.signPsbt.output.unknown'); + } + + return ( + + + {label} + + + {displayExchangeAmount( + BigInt(output.amount), + exchangeRate, + )} + + + {displayAmount(BigInt(output.amount), currency)} + + + + {output.address ? ( + +
+ + ) : null} + + ); + })} +
+ ) : null} + +
+ + + {t('confirmation.signPsbt.options')} + + + + + {t('confirmation.signPsbt.options.fill')} + + {options.fill ? t('yes') : t('no')} + + + + {t('confirmation.signPsbt.options.broadcast')} + + {options.broadcast ? t('yes') : t('no')} + + {inputCount > 0 ? ( + + + {t('confirmation.signPsbt.inputs')} + + {inputCount.toString()} + + ) : null} + {fee === undefined ? null : ( + + {t('networkFee')} + + + {displayExchangeAmount(BigInt(fee), exchangeRate)} + + {displayAmount(BigInt(fee), currency)} + + + )} +
+ +
+ + + {t('confirmation.requestOrigin')} + + {origin ?? 'MetaMask'} + + {null} + + + {t('from')} + +
+ + {null} + + + {t('network')} + + + + {displayNetwork(network)} + + +
+ +
+ + + {t('confirmation.signPsbt.rawPsbt')} + + + +
+
+
+ + +
+
+ ); +}; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/index.ts index bcd5e740..9d8461da 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/confirmations/index.ts @@ -1 +1,2 @@ export * from './SignMessageConfirmationView'; +export * from './SignPsbtConfirmationView'; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx index cc36b6df..b283b771 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx @@ -35,8 +35,15 @@ export const UnifiedSendFormView: SnapComponent = ({ messages, }) => { const t = translate(messages); - const { amount, exchangeRate, network, from, recipient, explorerUrl } = - context; + const { + amount, + exchangeRate, + network, + from, + recipient, + explorerUrl, + origin, + } = context; const psbt = Psbt.from_string(context.psbt); const fee = psbt.fee().to_sat(); @@ -83,7 +90,7 @@ export const UnifiedSendFormView: SnapComponent = ({ {t('confirmation.requestOrigin')} - MetaMask + {origin ?? 'MetaMask'}
{null} diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts index eb131770..a89e1241 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts @@ -333,6 +333,51 @@ describe('BdkAccountRepository', () => { }); }); + describe('fetchInscriptions', () => { + it('returns null if inscriptions are not found', async () => { + mockSnapClient.getState.mockResolvedValue(null); + + const result = await repo.fetchInscriptions('non-existent-id'); + + expect(mockSnapClient.getState).toHaveBeenCalledWith( + 'accounts.non-existent-id.inscriptions', + ); + expect(result).toBeNull(); + }); + + it('returns inscriptions when found', async () => { + const mockInscriptions: Inscription[] = [ + { + id: 'ins1', + number: 1, + contentLength: 100, + contentType: 'image/png', + satNumber: 1000, + satRarity: 'common', + location: 'txid1:vout:offset', + }, + { + id: 'ins2', + number: 2, + contentLength: 200, + contentType: 'text/plain', + satNumber: 2000, + satRarity: 'uncommon', + location: 'txid2:vout:offset', + }, + ]; + + mockSnapClient.getState.mockResolvedValue(mockInscriptions); + + const result = await repo.fetchInscriptions('some-id'); + + expect(mockSnapClient.getState).toHaveBeenCalledWith( + 'accounts.some-id.inscriptions', + ); + expect(result).toStrictEqual(mockInscriptions); + }); + }); + describe('getFrozenUTXOs', () => { it('returns empty array if inscriptions is not found', async () => { mockSnapClient.getState.mockResolvedValue(null); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx index c1e084b1..37b61f4e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx @@ -1,21 +1,62 @@ -import type { Address } from '@metamask/bitcoindevkit'; +import type { + Address, + Amount, + Psbt, + ScriptBuf, + Transaction, + TxOut, +} from '@metamask/bitcoindevkit'; +import { Address as BdkAddress } from '@metamask/bitcoindevkit'; import type { GetPreferencesResult } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; -import type { SnapClient, Translator, BitcoinAccount } from '../entities'; +import type { + AssetRatesClient, + SnapClient, + Translator, + BitcoinAccount, + BlockchainClient, + Logger, + SpotPrice, +} from '../entities'; +import { networkToCurrencyUnit } from '../entities'; import { JSXConfirmationRepository } from './JSXConfirmationRepository'; import { SignMessageConfirmationView } from '../infra/jsx'; +import { UnifiedSendFormView } from '../infra/jsx/unified-send-flow'; + +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => ({ + Address: { + from_script: jest.fn(), + }, +})); + +const MockedBdkAddress = jest.mocked(BdkAddress); jest.mock('../infra/jsx', () => ({ SignMessageConfirmationView: jest.fn(), + SignPsbtConfirmationView: jest.fn(), +})); + +jest.mock('../infra/jsx/unified-send-flow', () => ({ + UnifiedSendFormView: jest.fn(), })); describe('JSXConfirmationRepository', () => { const mockMessages = { foo: { message: 'bar' } }; const mockSnapClient = mock(); const mockTranslator = mock(); + const mockChainClient = mock(); + const mockRatesClient = mock(); + const mockLogger = mock(); - const repo = new JSXConfirmationRepository(mockSnapClient, mockTranslator); + const repo = new JSXConfirmationRepository( + mockSnapClient, + mockTranslator, + mockChainClient, + mockRatesClient, + mockLogger, + ); describe('insertSignMessage', () => { const mockAccount = mock({ @@ -38,9 +79,9 @@ describe('JSXConfirmationRepository', () => { mockSnapClient.createInterface.mockResolvedValue('interface-id'); mockSnapClient.displayConfirmation.mockResolvedValue(true); mockTranslator.load.mockResolvedValue(mockMessages); - mockSnapClient.getPreferences.mockResolvedValue({ - locale: 'en', - } as GetPreferencesResult); + mockSnapClient.getPreferences.mockResolvedValue( + mock({ locale: 'en' }), + ); }); it('creates and displays a sign message interface', async () => { @@ -67,4 +108,358 @@ describe('JSXConfirmationRepository', () => { ).rejects.toThrow('User canceled the confirmation'); }); }); + + describe('insertSendTransfer', () => { + const mockAccount = mock({ + id: 'account-id', + network: 'bitcoin', + publicAddress: mock
({ toString: () => 'fromAddress' }), + }); + const mockPsbt = mock({ + toString: () => 'serialized-psbt', + }); + const recipient = { address: 'toAddress', amount: '50000' }; + const origin = 'dapp-origin'; + + beforeEach(() => { + mockSnapClient.createInterface.mockResolvedValue('send-interface-id'); + mockSnapClient.displayConfirmation.mockResolvedValue(true); + mockTranslator.load.mockResolvedValue(mockMessages); + mockSnapClient.getPreferences.mockResolvedValue( + mock({ locale: 'en', currency: 'usd' }), + ); + mockChainClient.getExplorerUrl.mockReturnValue('https://mempool.space'); + mockRatesClient.spotPrices.mockResolvedValue( + mock({ price: 50000 }), + ); + }); + + it('creates and displays a send transfer interface', async () => { + await repo.insertSendTransfer(mockAccount, mockPsbt, recipient, origin); + + const expectedContext = { + from: 'fromAddress', + explorerUrl: 'https://mempool.space', + network: mockAccount.network, + currency: networkToCurrencyUnit[mockAccount.network], + exchangeRate: expect.objectContaining({ + conversionRate: 50000, + currency: 'USD', + }), + recipient: recipient.address, + amount: recipient.amount, + locale: 'en', + psbt: 'serialized-psbt', + origin, + }; + + expect(mockSnapClient.getPreferences).toHaveBeenCalled(); + expect(mockChainClient.getExplorerUrl).toHaveBeenCalledWith( + mockAccount.network, + ); + expect(mockTranslator.load).toHaveBeenCalledWith('en'); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + , + expectedContext, + ); + expect(mockSnapClient.displayConfirmation).toHaveBeenCalledWith( + 'send-interface-id', + ); + }); + + it('throws UserActionError if the user cancels', async () => { + mockSnapClient.displayConfirmation.mockResolvedValue(false); + await expect( + repo.insertSendTransfer(mockAccount, mockPsbt, recipient, origin), + ).rejects.toThrow('User canceled the confirmation'); + }); + + it('sets exchangeRate to undefined for non-mainnet networks', async () => { + const testnetAccount = mock({ + id: 'account-id', + network: 'testnet', + publicAddress: mock
({ toString: () => 'fromAddress' }), + }); + + await repo.insertSendTransfer( + testnetAccount, + mockPsbt, + recipient, + origin, + ); + + expect(mockRatesClient.spotPrices).not.toHaveBeenCalled(); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ exchangeRate: undefined }), + ); + }); + + it('sets exchangeRate to undefined when spot price is null', async () => { + // @ts-expect-error - testing runtime guard against API returning null + mockRatesClient.spotPrices.mockResolvedValue({ price: null }); + + await repo.insertSendTransfer(mockAccount, mockPsbt, recipient, origin); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ exchangeRate: undefined }), + ); + }); + + it('sets exchangeRate to undefined when rates client throws', async () => { + mockRatesClient.spotPrices.mockRejectedValue(new Error('API error')); + + await repo.insertSendTransfer(mockAccount, mockPsbt, recipient, origin); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ exchangeRate: undefined }), + ); + }); + }); + + describe('insertSignPsbt', () => { + const mockScriptRecipient = mock({ + is_op_return: () => false, + }); + const mockTxOut = mock({ + value: mock({ + to_sat: () => BigInt(1000), + }), + script_pubkey: mockScriptRecipient, + }); + + const mockAccount = mock({ + id: 'account-id', + network: 'bitcoin', + publicAddress: mock
({ toString: () => 'myAddress' }), + isMine: () => false, + }); + + const mockSignPsbt = mock({ + toString: () => 'psbt-base64-string', + fee_amount: () => + mock({ + to_sat: () => BigInt(500), + }), + unsigned_tx: mock({ + output: [mockTxOut], + input: [{}], + }), + }); + + const options = { fill: true, broadcast: false }; + const origin = 'https://dapp.example.com'; + + beforeEach(() => { + mockSnapClient.createInterface.mockResolvedValue('psbt-interface-id'); + mockSnapClient.displayConfirmation.mockResolvedValue(true); + mockTranslator.load.mockResolvedValue(mockMessages); + mockSnapClient.getPreferences.mockResolvedValue( + mock({ locale: 'en', currency: 'usd' }), + ); + mockRatesClient.spotPrices.mockResolvedValue( + mock({ price: 50000 }), + ); + MockedBdkAddress.from_script.mockReturnValue( + mock
({ toString: () => 'resolved-address' }), + ); + }); + + it('creates and displays a sign PSBT confirmation interface with parsed outputs', async () => { + await repo.insertSignPsbt(mockAccount, mockSignPsbt, origin, options); + + expect(mockSnapClient.getPreferences).toHaveBeenCalled(); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + fee: '500', + currency: networkToCurrencyUnit[mockAccount.network], + exchangeRate: expect.objectContaining({ + conversionRate: 50000, + currency: 'USD', + }), + outputs: [ + { + address: 'resolved-address', + amount: '1000', + isMine: false, + isOpReturn: false, + }, + ], + inputCount: 1, + }), + ); + expect(mockSnapClient.displayConfirmation).toHaveBeenCalledWith( + 'psbt-interface-id', + ); + }); + + it('marks change outputs with isMine true', async () => { + const changeAccount = mock({ + id: 'account-id', + network: 'bitcoin', + publicAddress: mock
({ toString: () => 'myAddress' }), + isMine: () => true, + }); + + await repo.insertSignPsbt(changeAccount, mockSignPsbt, origin, options); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + outputs: [expect.objectContaining({ isMine: true })], + }), + ); + }); + + it('handles OP_RETURN outputs', async () => { + const opReturnScript = mock({ + is_op_return: () => true, + }); + const opReturnOut = mock({ + value: mock({ + to_sat: () => BigInt(0), + }), + script_pubkey: opReturnScript, + }); + const psbtWithOpReturn = mock({ + toString: () => 'psbt-op-return', + fee_amount: () => + mock({ + to_sat: () => BigInt(300), + }), + unsigned_tx: mock({ + output: [opReturnOut], + input: [], + }), + }); + + await repo.insertSignPsbt(mockAccount, psbtWithOpReturn, origin, options); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + outputs: [ + { + address: undefined, + amount: '0', + isMine: false, + isOpReturn: true, + }, + ], + inputCount: 0, + }), + ); + }); + + it('throws UserActionError if the user cancels', async () => { + mockSnapClient.displayConfirmation.mockResolvedValue(false); + await expect( + repo.insertSignPsbt(mockAccount, mockSignPsbt, origin, options), + ).rejects.toThrow('User canceled the confirmation'); + }); + + it('handles PSBT without fee information gracefully', async () => { + const psbtNoFee = mock({ + toString: () => 'psbt-no-fee', + fee_amount: () => undefined as unknown as Amount, + unsigned_tx: mock({ + output: [], + input: [], + }), + }); + await repo.insertSignPsbt(mockAccount, psbtNoFee, origin, options); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ fee: undefined }), + ); + }); + + it('handles PSBT fee_amount throwing an error gracefully', async () => { + const psbtFeeError = mock({ + toString: () => 'psbt-fee-error', + fee_amount: () => { + throw new Error('Missing TxOut data'); + }, + unsigned_tx: mock({ + output: [], + input: [], + }), + }); + await repo.insertSignPsbt(mockAccount, psbtFeeError, origin, options); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ fee: undefined }), + ); + }); + + it('sets exchangeRate to undefined for non-mainnet networks', async () => { + const testnetAccount = mock({ + id: 'account-id', + network: 'testnet', + publicAddress: mock
({ toString: () => 'myAddress' }), + isMine: () => false, + }); + + await repo.insertSignPsbt(testnetAccount, mockSignPsbt, origin, options); + + expect(mockRatesClient.spotPrices).not.toHaveBeenCalled(); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ exchangeRate: undefined }), + ); + }); + + it('sets exchangeRate to undefined when spot price is null', async () => { + // @ts-expect-error - testing runtime guard against API returning null + mockRatesClient.spotPrices.mockResolvedValue({ price: null }); + + await repo.insertSignPsbt(mockAccount, mockSignPsbt, origin, options); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ exchangeRate: undefined }), + ); + }); + + it('sets exchangeRate to undefined when rates client throws', async () => { + mockRatesClient.spotPrices.mockRejectedValue(new Error('API error')); + + await repo.insertSignPsbt(mockAccount, mockSignPsbt, origin, options); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ exchangeRate: undefined }), + ); + }); + + it('sets address to undefined when BdkAddress.from_script throws', async () => { + MockedBdkAddress.from_script.mockImplementation(() => { + throw new Error('Unrecognized script'); + }); + + await repo.insertSignPsbt(mockAccount, mockSignPsbt, origin, options); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + outputs: [ + { + address: undefined, + amount: '1000', + isMine: false, + isOpReturn: false, + }, + ], + }), + ); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx index 73ee9874..dbf86e9c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx @@ -1,21 +1,51 @@ +import type { Psbt } from '@metamask/bitcoindevkit'; +import { Address as BdkAddress } from '@metamask/bitcoindevkit'; +import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; +import type { CurrencyRate } from '@metamask/snaps-sdk'; + import type { + AssetRatesClient, BitcoinAccount, + BlockchainClient, ConfirmationRepository, + ConfirmSendFormContext, + Logger, SignMessageConfirmationContext, + SignPsbtConfirmationContext, + SignPsbtOutput, SnapClient, Translator, } from '../entities'; -import { UserActionError } from '../entities'; -import { SignMessageConfirmationView } from '../infra/jsx'; +import { networkToCurrencyUnit, UserActionError } from '../entities'; +import { + SignMessageConfirmationView, + SignPsbtConfirmationView, +} from '../infra/jsx'; +import { UnifiedSendFormView } from '../infra/jsx/unified-send-flow'; export class JSXConfirmationRepository implements ConfirmationRepository { readonly #snapClient: SnapClient; readonly #translator: Translator; - constructor(snapClient: SnapClient, translator: Translator) { + readonly #chainClient: BlockchainClient; + + readonly #ratesClient: AssetRatesClient; + + readonly #logger: Logger; + + constructor( + snapClient: SnapClient, + translator: Translator, + chainClient: BlockchainClient, + ratesClient: AssetRatesClient, + logger: Logger, + ) { this.#snapClient = snapClient; this.#translator = translator; + this.#chainClient = chainClient; + this.#ratesClient = ratesClient; + this.#logger = logger; } async insertSignMessage( @@ -48,4 +78,142 @@ export class JSXConfirmationRepository implements ConfirmationRepository { throw new UserActionError('User canceled the confirmation'); } } + + async insertSendTransfer( + account: BitcoinAccount, + psbt: Psbt, + recipient: { address: string; amount: string }, + origin: string, + ): Promise { + const { locale, currency: fiatCurrency } = + await this.#snapClient.getPreferences(); + + const context: ConfirmSendFormContext = { + from: account.publicAddress.toString(), + explorerUrl: this.#chainClient.getExplorerUrl(account.network), + network: account.network, + currency: networkToCurrencyUnit[account.network], + exchangeRate: await this.#getExchangeRate(account.network, fiatCurrency), + recipient: recipient.address, + amount: recipient.amount, + locale, + psbt: psbt.toString(), + origin, + }; + + const messages = await this.#translator.load(locale); + const interfaceId = await this.#snapClient.createInterface( + , + context, + ); + + const confirmed = + await this.#snapClient.displayConfirmation(interfaceId); + if (!confirmed) { + throw new UserActionError('User canceled the confirmation'); + } + } + + async insertSignPsbt( + account: BitcoinAccount, + psbt: Psbt, + origin: string, + options: { fill: boolean; broadcast: boolean }, + ): Promise { + const { locale, currency: fiatCurrency } = + await this.#snapClient.getPreferences(); + + let fee: string | undefined; + try { + const feeAmount = psbt.fee_amount(); + if (feeAmount) { + fee = feeAmount.to_sat().toString(); + } + } catch { + fee = undefined; + } + + const outputs: SignPsbtOutput[] = []; + for (const txout of psbt.unsigned_tx.output) { + const isOpReturn = txout.script_pubkey.is_op_return(); + const isMine = account.isMine(txout.script_pubkey); + + let address: string | undefined; + if (!isOpReturn) { + try { + address = BdkAddress.from_script( + txout.script_pubkey, + account.network, + ).toString(); + } catch { + address = undefined; + } + } + + outputs.push({ + address, + amount: txout.value.to_sat().toString(), + isMine, + isOpReturn, + }); + } + + const context: SignPsbtConfirmationContext = { + psbt: psbt.toString(), + origin, + account: { + id: account.id, + address: account.publicAddress.toString(), + }, + network: account.network, + options, + currency: networkToCurrencyUnit[account.network], + exchangeRate: await this.#getExchangeRate(account.network, fiatCurrency), + fee, + outputs, + inputCount: psbt.unsigned_tx.input.length, + }; + + const messages = await this.#translator.load(locale); + const interfaceId = await this.#snapClient.createInterface( + , + context, + ); + + const confirmed = + await this.#snapClient.displayConfirmation(interfaceId); + if (!confirmed) { + throw new UserActionError('User canceled the confirmation'); + } + } + + async #getExchangeRate( + network: string, + currency: string, + ): Promise { + if (network !== 'bitcoin') { + return undefined; + } + + try { + const spotPrice = await this.#ratesClient.spotPrices(currency); + + if (spotPrice.price === undefined || spotPrice.price === null) { + return undefined; + } + + return { + conversionRate: spotPrice.price, + conversionDate: getCurrentUnixTimestamp(), + currency: currency.toUpperCase(), + }; + } catch (error) { + // Exchange rates are optional display information - don't fail if unavailable + this.#logger.warn( + `Failed to fetch spot price for currency ${currency}`, + error, + ); + return undefined; + } + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx index af3e14fb..ad3ea611 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXSendFlowRepository.test.tsx @@ -3,11 +3,13 @@ import { mock } from 'jest-mock-extended'; import type { SnapClient, SendFormContext, + ConfirmSendFormContext, ReviewTransactionContext, Translator, } from '../entities'; import { JSXSendFlowRepository } from './JSXSendFlowRepository'; import { ReviewTransactionView, SendFormView } from '../infra/jsx'; +import { UnifiedSendFormView } from '../infra/jsx/unified-send-flow'; jest.mock('../infra/jsx', () => ({ SendFormView: jest.fn(), @@ -103,4 +105,21 @@ describe('JSXSendFlowRepository', () => { ); }); }); + + describe('insertConfirmSendForm', () => { + it('creates interface with confirm send form context', async () => { + const mockContext = mock({ locale: 'en' }); + mockSnapClient.createInterface.mockResolvedValue('confirm-interface-id'); + mockTranslator.load.mockResolvedValue(mockMessages); + + const result = await repo.insertConfirmSendForm(mockContext); + + expect(mockTranslator.load).toHaveBeenCalledWith('en'); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + , + mockContext, + ); + expect(result).toBe('confirm-interface-id'); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index bea64eea..210f9f9c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -1531,10 +1531,6 @@ describe('AccountUseCases', () => { address: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', amount: '1000', }, - { - address: 'bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y', - amount: '2000', - }, ]; const mockTxid = mock(); const mockOutput = mock({ @@ -1596,6 +1592,24 @@ describe('AccountUseCases', () => { mockTxBuilder.finish.mockReturnValue(mockFilledPsbt); mockTxBuilder.unspendable.mockReturnThis(); mockChain.getFeeEstimates.mockResolvedValue(mockFeeEstimates); + mockRepository.getFrozenUTXOs.mockResolvedValue([]); + }); + + it('throws error if there are multiple recipients', async () => { + const multipleRecipients = [ + { address: 'addr1', amount: '1000' }, + { address: 'addr2', amount: '2000' }, + ]; + + await expect( + useCases.sendTransfer('account-id', multipleRecipients, 'metamask'), + ).rejects.toThrow('There should be exactly one recipient'); + }); + + it('throws error if there are no recipients', async () => { + await expect( + useCases.sendTransfer('account-id', [], 'metamask'), + ).rejects.toThrow('There should be exactly one recipient'); }); it('throws error if account is not found', async () => { @@ -1609,7 +1623,6 @@ describe('AccountUseCases', () => { it('sends funds', async () => { mockAccount.getTransaction.mockReturnValue(mockWalletTx); mockTransaction.compute_txid.mockReturnValue(mockTxid); - mockTxBuilder.finish.mockReturnValueOnce(mockPsbt); const txid = await useCases.sendTransfer( 'account-id', @@ -1622,10 +1635,6 @@ describe('AccountUseCases', () => { '1000', 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', ); - expect(mockTxBuilder.addRecipient).toHaveBeenCalledWith( - '2000', - 'bcrt1q4gfcga7jfjmm02zpvrh4ttc5k7lmnq2re52z2y', - ); expect(mockChain.getFeeEstimates).toHaveBeenCalledWith( mockAccount.network, ); @@ -1681,6 +1690,75 @@ describe('AccountUseCases', () => { }); }); + describe('broadcastPsbt', () => { + const mockTxid = mock(); + const mockPsbt = mock(); + const mockTransaction = mock({ + compute_txid: jest.fn(), + clone: jest.fn(), + }); + const mockWalletTx = mock(); + const mockAccount = mock({ + network: 'bitcoin', + capabilities: [AccountCapability.BroadcastPsbt], + }); + + beforeEach(() => { + mockRepository.get.mockResolvedValue(mockAccount); + mockAccount.extractTransaction.mockReturnValue(mockTransaction); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + mockTransaction.clone.mockReturnThis(); + mockAccount.getTransaction.mockReturnValue(mockWalletTx); + }); + + it('throws error if account is not found', async () => { + mockRepository.get.mockResolvedValue(null); + + await expect( + useCases.broadcastPsbt('non-existent-id', mockPsbt, 'metamask'), + ).rejects.toThrow('Account not found'); + }); + + it('throws PermissionError if account lacks BroadcastPsbt capability', async () => { + const accountNoCap = mock({ + capabilities: [], + }); + mockRepository.get.mockResolvedValue(accountNoCap); + + await expect( + useCases.broadcastPsbt('account-id', mockPsbt, 'metamask'), + ).rejects.toThrow('Account missing given capability'); + }); + + it('broadcasts a PSBT and returns txid', async () => { + const txid = await useCases.broadcastPsbt( + 'account-id', + mockPsbt, + 'metamask', + ); + + expect(mockRepository.get).toHaveBeenCalledWith('account-id'); + expect(mockAccount.extractTransaction).toHaveBeenCalledWith(mockPsbt); + expect(mockChain.broadcast).toHaveBeenCalledWith( + mockAccount.network, + mockTransaction, + ); + expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); + expect(txid).toBe(mockTxid); + }); + }); + + describe('getFrozenUTXOs', () => { + it('delegates to repository', async () => { + mockRepository.getFrozenUTXOs.mockResolvedValue(['txid:0', 'txid:1']); + + const result = await useCases.getFrozenUTXOs('account-id'); + + expect(mockRepository.getFrozenUTXOs).toHaveBeenCalledWith('account-id'); + expect(result).toStrictEqual(['txid:0', 'txid:1']); + }); + }); + describe('signMessage', () => { const mockAccount = mock({ publicAddress: mock
({ @@ -1744,6 +1822,16 @@ describe('AccountUseCases', () => { ).rejects.toThrow('Failed to sign message'); }); + it('throws AssertionError if entropy has no privateKey', async () => { + mockSnapClient.getPrivateEntropy.mockResolvedValue( + mock({ privateKey: undefined }), + ); + + await expect( + useCases.signMessage('account-id', mockMessage, mockOrigin), + ).rejects.toThrow('Failed to get private entropy'); + }); + it('propagates an error if get fails', async () => { const error = new Error('get failed'); mockRepository.get.mockRejectedValueOnce(error); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 2cb2e131..345fe472 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -461,21 +461,37 @@ export class AccountUseCases { recipients, ); + if (!recipients[0] || recipients.length > 1) { + throw new ValidationError('There should be exactly one recipient', { + recipients, + }); + } + const recipient = recipients[0]; + const account = await this.#repository.getWithSigner(id); if (!account) { throw new NotFoundError('Account not found', { id }); } this.#checkCapability(account, AccountCapability.SendTransfer); - // Create a template PSBT with the recipients as outputs - let builder = account.buildTx(); - for (const { address, amount } of recipients) { - builder = builder.addRecipient(amount, address); - } - const templatePsbt = builder.finish(); + const frozenUTXOs = await this.#repository.getFrozenUTXOs(account.id); + const feeRateToUse = feeRate ?? (await this.getFallbackFeeRate(account)); + + // Build PSBT with the recipient as output + const psbt = account + .buildTx() + .feeRate(feeRateToUse) + .unspendable(frozenUTXOs) + .addRecipient(recipient.amount, recipient.address) + .finish(); + + await this.#confirmationRepository.insertSendTransfer( + account, + psbt, + recipient, + origin, + ); - // Complete the PSBT with the necessary inputs, fee rate, etc. - const psbt = await this.#fillPsbt(account, templatePsbt, feeRate); const signedPsbt = account.sign(psbt); const tx = account.extractTransaction(signedPsbt); const txid = await this.#broadcast(account, tx, origin); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 51d261a6..bedb81c7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -205,6 +205,30 @@ describe('SendFlowUseCases', () => { ); }); + it('clears state on ClearAmount', async () => { + const expectedContext = { + ...mockContext, + amount: undefined, + drain: undefined, + fee: undefined, + errors: { + ...mockContext.errors, + amount: undefined, + tx: undefined, + }, + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.ClearAmount, + mockContext, + ); + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expectedContext, + ); + }); + it('clears state on ClearRecipient', async () => { const expectedContext = { ...mockContext, @@ -321,6 +345,30 @@ describe('SendFlowUseCases', () => { ); }); + it('falls back to updateForm with tx error when builder.finish throws on Confirm', async () => { + const buildError = new Error('Insufficient funds'); + mockAccountRepository.get.mockResolvedValue(mockAccount); + mockAccountRepository.getFrozenUTXOs.mockResolvedValue([mockOutPoint]); + mockTxBuilder.finish.mockImplementation(() => { + throw buildError; + }); + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.Confirm, + mockContext, + ); + + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expect.objectContaining({ + errors: expect.objectContaining({ + tx: buildError, + }), + }), + ); + }); + it('clears state and set drain to true on SetMax', async () => { // avoid computing the fee in this test const testContext = { @@ -571,6 +619,64 @@ describe('SendFlowUseCases', () => { ); }); + it('returns undefined on SwitchCurrency when no exchangeRate', async () => { + const contextNoRate = { ...mockContext, exchangeRate: undefined }; + + const result = await useCases.onChangeForm( + 'interface-id', + SendFormEvent.SwitchCurrency, + contextNoRate, + ); + + expect(result).toBeUndefined(); + expect(mockSendFlowRepository.updateForm).not.toHaveBeenCalled(); + }); + + it('switches currency from Bitcoin to Fiat on SwitchCurrency', async () => { + const contextWithRate = { + ...mockContext, + exchangeRate: { + currency: 'USD', + conversionRate: 50000, + conversionDate: 2025, + }, + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.SwitchCurrency, + contextWithRate, + ); + + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expect.objectContaining({ currency: CurrencyUnit.Fiat }), + ); + }); + + it('switches currency from Fiat to Bitcoin on SwitchCurrency', async () => { + const contextFiat = { + ...mockContext, + currency: CurrencyUnit.Fiat, + exchangeRate: { + currency: 'USD', + conversionRate: 50000, + conversionDate: 2025, + }, + }; + + await useCases.onChangeForm( + 'interface-id', + SendFormEvent.SwitchCurrency, + contextFiat, + ); + + expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( + 'interface-id', + expect.objectContaining({ currency: CurrencyUnit.Bitcoin }), + ); + }); + it('sets account from state on Account', async () => { const accountId = 'myAccount2'; mockAccount.publicAddress.toString = () => 'myAddress2'; @@ -795,6 +901,16 @@ describe('SendFlowUseCases', () => { await expect(useCases.refresh('interface-id')).rejects.toThrow(error); }); + + it('returns undefined when context is not found', async () => { + mockSendFlowRepository.getContext.mockRejectedValue( + new Error('Missing context'), + ); + + const result = await useCases.refresh('interface-id'); + + expect(result).toBeUndefined(); + }); }); describe('confirmSendFlow', () => { From 7aec011c986b6a5f16a7e63d2ad0c9ba03b58d7c Mon Sep 17 00:00:00 2001 From: Hassan Malik <41640681+hmalik88@users.noreply.github.com> Date: Tue, 5 May 2026 10:15:44 -0400 Subject: [PATCH 334/362] fix: update error reporting (#600) * fix: wrap unknown errors in SnapError * chore: update manifest * test: add tests * fix: lint fix * chore: add changelog entry * Revert "chore: update manifest" This reverts commit 1e93d98262357914efff8758c8d170911fcc5505. * fix: formatting --- .../bitcoin-wallet-snap/CHANGELOG.md | 4 +++ .../src/handlers/HandlerMiddleware.test.ts | 27 +++++++++++++++---- .../src/handlers/HandlerMiddleware.ts | 10 ++++--- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index e1333e4f..bf02a33a 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Show a confirmation dialog before signing a PSBT from KeyringHandler and sending a transfer ([#591](https://github.com/MetaMask/snap-bitcoin-wallet/pull/591)) - Add `resolveAccountAddress` method to KeyringHandler for dApp connectivity ([#590](https://github.com/MetaMask/snap-bitcoin-wallet/pull/590)) +### Fixed + +- Preserve original error message and class when wrapping unknown errors at the handler boundary ([#600](https://github.com/MetaMask/snap-bitcoin-wallet/pull/600)) + ## [1.10.1] ### Fixed diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts index 8459d518..196f1257 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts @@ -40,14 +40,31 @@ describe('HandlerMiddleware', () => { expect(result).toBe('success'); }); - it('throws internal error if error is unexpected', async () => { - const mockFn = jest.fn().mockRejectedValue(new Error()); + it('wraps an unexpected Error and preserves its message', async () => { + const error = new Error('boom'); + const mockFn = jest.fn().mockRejectedValue(error); - await expect(middleware.handle(mockFn)).rejects.toThrow( - 'Unexpected error', - ); + await expect(middleware.handle(mockFn)).rejects.toThrow('boom'); expect(mockSnapClient.getPreferences).toHaveBeenCalled(); expect(mockTranslator.load).toHaveBeenCalledWith('en'); + expect(mockLogger.error).toHaveBeenCalledWith(error); + }); + + it('wraps a non-Error thrown value by stringifying it', async () => { + const mockFn = jest.fn().mockRejectedValue('string failure'); + + await expect(middleware.handle(mockFn)).rejects.toThrow('string failure'); + expect(mockLogger.error).toHaveBeenCalledWith('string failure'); + }); + + it('wraps a thrown plain object by stringifying it', async () => { + const thrown = { foo: 'bar' }; + const mockFn = jest.fn().mockRejectedValue(thrown); + + await expect(middleware.handle(mockFn)).rejects.toThrow( + '[object Object]', + ); + expect(mockLogger.error).toHaveBeenCalledWith(thrown); }); it('handles error successfully if instance of BaseError', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts index a1d0d990..ca6c5d9c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts @@ -7,6 +7,7 @@ import { ResourceNotFoundError, UnauthorizedError, UserRejectedRequestError, + SnapError, } from '@metamask/snaps-sdk'; import { StructError } from 'superstruct'; @@ -113,10 +114,13 @@ export class HandlerMiddleware { error.data, ); } - // this should never happen unless a BaseError is not thrown - const errMsg = messages.unexpected?.message ?? 'Unexpected error'; + // Unknown error type — wrap in SnapError to preserve the original + // error's message and class info for observability (previously this + // branch replaced everything with a generic "Unexpected error" + // string, making cross-boundary errors like KeyringControllerError + // opaque in Sentry). this.#logger.error(error); - throw new InternalError(errMsg); + throw new SnapError(error instanceof Error ? error : String(error)); } } } From 88fa1c010ce429fd86c6e60e1a12a9115fbe04b2 Mon Sep 17 00:00:00 2001 From: jeremytsng Date: Mon, 18 May 2026 14:15:22 +0700 Subject: [PATCH 335/362] fix: include unconfirmed external-keychain change in displayed balance (#605) * fix: include unconfirmed external-keychain change in displayed balance When a partner-supplied PSBT (bridge / swap) drains change to the user's external (public) address, BDK classifies that unconfirmed UTXO as `untrusted_pending` rather than `trusted_pending`, so the snap's previous display path (`Balance.trusted_spendable`) reported zero until the tx confirmed. Introduce `computeDisplayBalanceSats` which adds back any unconfirmed external-keychain UTXO whose parent transaction was authored by this wallet (`sentAndReceived(tx).sent > 0`). Genuinely untrusted incoming unconfirmed funds remain excluded. Wired into the two display sites: - `KeyringHandler.getAccountBalances` - `SnapClientAdapter.emitAccountBalancesUpdatedEvent` Spend-validation paths (`SendFlowUseCases`, `validation.ts`) intentionally keep using `trusted_spendable`, since BDK's coin selection only spends confirmed + internal-keychain pending UTXOs anyway. Adds unit tests for the new helper and an integration regression in `keyring-request.test.ts` that funds a single UTXO, broadcasts a partial-spend PSBT whose change lands on the external keychain, and asserts the post-broadcast balance is between the change and the original amount. Closes #597. * chore: update changelog PR link --------- Co-authored-by: Jeremy <261848901+jeremy-consensys@users.noreply.github.com> --- .../bitcoin-wallet-snap/CHANGELOG.md | 1 + .../integration-test/keyring-request.test.ts | 29 +++ .../src/entities/balance.test.ts | 213 ++++++++++++++++++ .../src/entities/balance.ts | 46 ++++ .../bitcoin-wallet-snap/src/entities/index.ts | 1 + .../src/handlers/KeyringHandler.test.ts | 11 +- .../src/handlers/KeyringHandler.ts | 6 +- .../src/infra/SnapClientAdapter.ts | 6 +- 8 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/balance.test.ts create mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/balance.ts diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index bf02a33a..81625d4f 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Preserve original error message and class when wrapping unknown errors at the handler boundary ([#600](https://github.com/MetaMask/snap-bitcoin-wallet/pull/600)) +- Display unconfirmed change to the external keychain (e.g. bridge/swap refunds) in the account balance so it no longer drops to zero while a partial-spend tx is pending ([#605](https://github.com/MetaMask/snap-bitcoin-wallet/pull/605)) ## [1.10.1] diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts index 07c0221b..c76a3ccf 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts @@ -6,6 +6,7 @@ import { assertIsConfirmationDialog, installSnap } from '@metamask/snaps-jest'; import { BlockchainTestUtils } from './blockchain-utils'; import { MNEMONIC, ORIGIN } from './constants'; import { AccountCapability } from '../src/entities'; +import { Caip19Asset } from '../src/handlers/caip'; import type { FillPsbtResponse } from '../src/handlers/KeyringRequestHandler'; const ACCOUNT_INDEX = 3; @@ -325,6 +326,34 @@ describe('KeyringRequestHandler', () => { txid: expect.any(String), }, }); + + // Regression for issue #597: after broadcasting a partial-spend tx + // whose change lands on the external (public) keychain — as bridges + // and swaps do — the displayed balance must include that unconfirmed + // change. The wallet was funded with a single 10 BTC UTXO in + // `beforeAll`; the broadcast spends a small amount plus fee, so the + // change should be ~9.99 BTC, never 0. + const balanceResp = await snap.onKeyringRequest({ + origin: ORIGIN, + method: 'keyring_getAccountBalances', + params: { + id: account.id, + assets: [Caip19Asset.Regtest], + }, + }); + + const balanceResult = ( + balanceResp.response as { + result: Record; + } + ).result; + const amount = balanceResult[Caip19Asset.Regtest]?.amount; + expect(amount).toBeDefined(); + // Bound from above as well: a stale fix where applyUnconfirmedTx + // no-ops would still report the original 10 BTC and pass a `> 9` + // check on its own, hiding the regression. + expect(parseFloat(amount as string)).toBeGreaterThan(9); + expect(parseFloat(amount as string)).toBeLessThan(10); }); it('fails if invalid PSBT', async () => { diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/balance.test.ts b/merged-packages/bitcoin-wallet-snap/src/entities/balance.test.ts new file mode 100644 index 00000000..b6361de8 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/balance.test.ts @@ -0,0 +1,213 @@ +import type { + Amount, + LocalOutput, + WalletTx, + Transaction, +} from '@metamask/bitcoindevkit'; +import { mock } from 'jest-mock-extended'; + +import type { BitcoinAccount } from './account'; +import { computeDisplayBalanceSats } from './balance'; + +/* eslint-disable @typescript-eslint/naming-convention */ + +const sat = (value: bigint): Amount => + ({ + to_sat: () => value, + }) as unknown as Amount; + +const mockUtxo = (opts: { + keychain: 'external' | 'internal'; + txidString: string; + valueSats: bigint; +}): LocalOutput => + ({ + keychain: opts.keychain, + outpoint: { + txid: { toString: () => opts.txidString }, + }, + txout: { value: sat(opts.valueSats) }, + }) as unknown as LocalOutput; + +const mockWalletTx = (isConfirmed: boolean): WalletTx => + ({ + tx: {} as Transaction, + chain_position: { is_confirmed: isConfirmed }, + }) as unknown as WalletTx; + +describe('computeDisplayBalanceSats', () => { + const buildAccount = (overrides: { + trustedSpendableSats: bigint; + utxos: LocalOutput[]; + txByTxid: Record; + sentByTxid: Record; + }): BitcoinAccount => { + const account = mock(); + Object.defineProperty(account, 'balance', { + get: () => + ({ + trusted_spendable: sat(overrides.trustedSpendableSats), + }) as never, + }); + account.listUnspent.mockReturnValue(overrides.utxos); + account.getTransaction.mockImplementation((txid) => { + return overrides.txByTxid[txid]; + }); + account.sentAndReceived.mockImplementation((tx) => { + // Match by reference: find the txid whose mocked WalletTx.tx === tx. + for (const [txid, walletTx] of Object.entries(overrides.txByTxid)) { + if (walletTx && walletTx.tx === tx) { + return [sat(overrides.sentByTxid[txid] ?? 0n), sat(0n)]; + } + } + return [sat(0n), sat(0n)]; + }); + return account; + }; + + it('returns trusted_spendable when there are no relevant unspents', () => { + const account = buildAccount({ + trustedSpendableSats: 100n, + utxos: [], + txByTxid: {}, + sentByTxid: {}, + }); + + expect(computeDisplayBalanceSats(account)).toBe(100n); + }); + + it('skips internal-keychain unspents (already in trusted_pending)', () => { + const utxo = mockUtxo({ + keychain: 'internal', + txidString: 'tx_internal', + valueSats: 50n, + }); + const account = buildAccount({ + trustedSpendableSats: 100n, + utxos: [utxo], + txByTxid: { tx_internal: mockWalletTx(false) }, + sentByTxid: { tx_internal: 200n }, + }); + + expect(computeDisplayBalanceSats(account)).toBe(100n); + }); + + it('skips external-keychain unspents whose tx is already confirmed', () => { + const utxo = mockUtxo({ + keychain: 'external', + txidString: 'tx_confirmed', + valueSats: 50n, + }); + const account = buildAccount({ + trustedSpendableSats: 100n, + utxos: [utxo], + txByTxid: { tx_confirmed: mockWalletTx(true) }, + sentByTxid: { tx_confirmed: 200n }, + }); + + expect(computeDisplayBalanceSats(account)).toBe(100n); + }); + + it('skips external-keychain unspents from foreign transactions (sent === 0)', () => { + const utxo = mockUtxo({ + keychain: 'external', + txidString: 'tx_incoming', + valueSats: 50n, + }); + const account = buildAccount({ + trustedSpendableSats: 100n, + utxos: [utxo], + txByTxid: { tx_incoming: mockWalletTx(false) }, + sentByTxid: { tx_incoming: 0n }, + }); + + expect(computeDisplayBalanceSats(account)).toBe(100n); + }); + + it('includes external-keychain change from our own broadcasts (issue #597)', () => { + const utxo = mockUtxo({ + keychain: 'external', + txidString: 'tx_self', + valueSats: 999_900_243n, + }); + const account = buildAccount({ + trustedSpendableSats: 0n, + utxos: [utxo], + txByTxid: { tx_self: mockWalletTx(false) }, + sentByTxid: { tx_self: 1_000_000_000n }, + }); + + expect(computeDisplayBalanceSats(account)).toBe(999_900_243n); + }); + + it('skips unspents whose parent tx is unknown (defensive)', () => { + const utxo = mockUtxo({ + keychain: 'external', + txidString: 'tx_missing', + valueSats: 50n, + }); + const account = buildAccount({ + trustedSpendableSats: 100n, + utxos: [utxo], + txByTxid: {}, + sentByTxid: {}, + }); + + expect(computeDisplayBalanceSats(account)).toBe(100n); + }); + + it('sums trusted_spendable and multiple qualifying external unspents', () => { + const owned = mockUtxo({ + keychain: 'external', + txidString: 'tx_self', + valueSats: 50n, + }); + const skippedInternal = mockUtxo({ + keychain: 'internal', + txidString: 'tx_int', + valueSats: 70n, + }); + const skippedConfirmed = mockUtxo({ + keychain: 'external', + txidString: 'tx_conf', + valueSats: 30n, + }); + const skippedIncoming = mockUtxo({ + keychain: 'external', + txidString: 'tx_in', + valueSats: 20n, + }); + const ownedSecond = mockUtxo({ + keychain: 'external', + txidString: 'tx_self2', + valueSats: 25n, + }); + + const account = buildAccount({ + trustedSpendableSats: 100n, + utxos: [ + owned, + skippedInternal, + skippedConfirmed, + skippedIncoming, + ownedSecond, + ], + txByTxid: { + tx_self: mockWalletTx(false), + tx_int: mockWalletTx(false), + tx_conf: mockWalletTx(true), + tx_in: mockWalletTx(false), + tx_self2: mockWalletTx(false), + }, + sentByTxid: { + tx_self: 100n, + tx_int: 100n, + tx_conf: 100n, + tx_in: 0n, + tx_self2: 100n, + }, + }); + + expect(computeDisplayBalanceSats(account)).toBe(100n + 50n + 25n); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/balance.ts b/merged-packages/bitcoin-wallet-snap/src/entities/balance.ts new file mode 100644 index 00000000..043d368e --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/balance.ts @@ -0,0 +1,46 @@ +import type { BitcoinAccount } from './account'; + +/** + * Compute the spendable balance to display to the user, in satoshis. + * + * BDK's `Balance.trusted_spendable` only counts unconfirmed change UTXOs + * landing on the *internal* keychain. When the snap fills a partner-supplied + * PSBT (bridge/swap), the change output's script is dictated by the template + * and typically drains to the user's *external* (public) address — so BDK + * conservatively classifies that change as `untrusted_pending` and the user + * sees their balance flash to zero until the tx confirms. + * + * We extend BDK's "trusted spendable" by also counting any unconfirmed UTXO + * whose parent transaction was created by this wallet itself (i.e. spends + * from one of our own inputs). Genuinely untrusted incoming unconfirmed + * funds from third parties remain excluded. + * + * @param account - The Bitcoin account to inspect. + * @returns The available balance in satoshis. + */ +export function computeDisplayBalanceSats(account: BitcoinAccount): bigint { + let sats = account.balance.trusted_spendable.to_sat(); + + for (const utxo of account.listUnspent()) { + // Already counted by BDK as `trusted_pending` (change to internal keychain) + // or as `confirmed` once anchored. + if (utxo.keychain === 'internal') { + continue; + } + + const walletTx = account.getTransaction(utxo.outpoint.txid.toString()); + if (!walletTx || walletTx.chain_position.is_confirmed) { + continue; + } + + // `sent > 0` means the wallet contributed inputs to this transaction, + // so the output landing back on our own external script is trusted — + // a third party cannot have produced it without one of our keys. + const [sent] = account.sentAndReceived(walletTx.tx); + if (sent.to_sat() > 0n) { + sats += utxo.txout.value.to_sat(); + } + } + + return sats; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts index 75ba7457..3584fa42 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/index.ts @@ -1,4 +1,5 @@ export * from './account'; +export * from './balance'; export type * from './config'; export * from './chain'; export * from './currency'; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 8691a519..cfa50356 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -48,6 +48,12 @@ jest.mock('@metamask/bitcoindevkit', () => { Address: { from_script: jest.fn(), }, + Amount: { + from_sat: (sats: bigint) => ({ + to_btc: () => Number(sats) / 100_000_000, + to_sat: () => sats, + }), + }, }; }); @@ -65,13 +71,16 @@ describe('KeyringHandler', () => { const mockAccount = mock({ id: 'some-id', addressType: 'p2wpkh', - balance: { trusted_spendable: { to_btc: () => 1 } }, + balance: { + trusted_spendable: { to_btc: () => 1, to_sat: () => 100_000_000n }, + }, network: 'bitcoin', derivationPath: ['myEntropy', "84'", "0'", "0'"], entropySource: 'myEntropy', accountIndex: 0, publicAddress: mockAddress, capabilities: [AccountCapability.SignPsbt, AccountCapability.ComputeFee], + listUnspent: () => [], }); const defaultAddressType: AddressType = 'p2wpkh'; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 7fc5a764..3e6d1023 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -1,4 +1,5 @@ import type { AddressType } from '@metamask/bitcoindevkit'; +import { Amount } from '@metamask/bitcoindevkit'; import { BtcAccountType, BtcScope, @@ -45,6 +46,7 @@ import { import type { BitcoinAccount, Logger, SnapClient } from '../entities'; import { + computeDisplayBalanceSats, FormatError, InexistentMethodError, networkToCurrencyUnit, @@ -316,7 +318,9 @@ export class KeyringHandler implements Keyring { id: string, ): Promise> { const account = await this.#accountsUseCases.get(id); - const balance = account.balance.trusted_spendable.to_btc().toString(); + const balance = Amount.from_sat(computeDisplayBalanceSats(account)) + .to_btc() + .toString(); return { [networkToCaip19[account.network]]: { diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 3674432c..7a434d7f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -1,4 +1,5 @@ import type { WalletTx } from '@metamask/bitcoindevkit'; +import { Amount } from '@metamask/bitcoindevkit'; import type { JsonSLIP10Node } from '@metamask/key-tree'; import { SLIP10Node } from '@metamask/key-tree'; import { KeyringEvent } from '@metamask/keyring-api'; @@ -15,6 +16,7 @@ import { DialogType } from '@metamask/snaps-sdk'; import type { BaseError, BitcoinAccount, SnapClient } from '../entities'; import { + computeDisplayBalanceSats, TrackingSnapEvent, networkToCurrencyUnit, AssertionError, @@ -111,7 +113,9 @@ export class SnapClientAdapter implements SnapClient { ...acc, [account.id]: { [networkToCaip19[account.network]]: { - amount: account.balance.trusted_spendable.to_btc().toString(), + amount: Amount.from_sat(computeDisplayBalanceSats(account)) + .to_btc() + .toString(), unit: networkToCurrencyUnit[account.network], }, }, From 799c16d7541925c784b4a3e8c00f9a28cf1e7d25 Mon Sep 17 00:00:00 2001 From: jeremytsng Date: Wed, 20 May 2026 14:40:16 +0700 Subject: [PATCH 336/362] feat: add canBeMalleable flag to broadcast/send responses (#599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add canBeMalleable flag to broadcast/send responses Surface a `canBeMalleable: boolean` on every snap response that returns a post-broadcast txid, so consumers can detect when a transaction's id may be rewritten by a third party before confirmation. Computed from the source account's address type: only legacy P2PKH (BIP44) keeps signatures in scriptSig and is therefore malleable. The codebase's `p2sh` is BIP49 wrapped SegWit (sh(wpkh(...))) — signatures live in the witness, so it is not malleable. Today every account is P2WPKH, so the flag is always false; the implementation is in place for when legacy support is added. Wired through every consumer-facing txid path: - AccountUseCases#broadcast (now the chokepoint, returns BroadcastResult) - signPsbt / broadcastPsbt / sendTransfer (use-case layer) - KeyringRequestHandler signPsbt / broadcastPsbt / sendTransfer - RpcHandler executeSendFlow / signAndSend / confirmSend - mapPsbtToTransaction (KeyringTransaction shape used by confirmSend) KeyringRequestHandler asserts that signPsbt cannot return a txid without the flag, mirroring the existing RpcHandler assertion. The helper's exhaustive switch throws AssertionError on unknown AddressType so a future variant cannot leak a non-boolean to consumers. OpenRPC schemas updated for both sendTransactionResponse shapes and confirmSend's KeyringTransaction return. Limitation: the flag is computed from the snap account's address type, which is correct for transactions the snap builds itself. For externally-provided PSBTs broadcast via broadcastPsbt or signPsbt({broadcast:true,fill:false}), foreign legacy inputs from collaborative transactions could still produce a malleable txid; per- input analysis is a follow-up. * fix: split RpcHandler assertion messages by failure mode Cursor review noted the combined `!txid || canBeMalleable === undefined` guard threw 'Missing transaction ID' regardless of which condition fired, masking missing-flag protocol violations. Split the guard so each branch reports its own cause, matching the message used in KeyringRequestHandler. * docs: add changelog entry for canBeMalleable flag * refactor: use Record for malleability table Replaces the switch with a typed lookup object guarded by `satisfies Record` for compile-time exhaustiveness. A new AddressType variant added upstream now surfaces as a TypeScript error on the literal, rather than a runtime AssertionError no one will see until production. Drops the now-unused AssertionError import. --------- Co-authored-by: Jeremy <261848901+jeremy-consensys@users.noreply.github.com> --- .../bitcoin-wallet-snap/CHANGELOG.md | 4 + .../integration-test/client-request.test.ts | 2 + .../integration-test/keyring-request.test.ts | 3 + .../bitcoin-wallet-snap/openrpc.json | 19 ++- .../src/entities/account.test.ts | 15 +++ .../src/entities/account.ts | 41 ++++++ .../handlers/KeyringRequestHandler.test.ts | 121 ++++++++++++++++-- .../src/handlers/KeyringRequestHandler.ts | 36 +++++- .../src/handlers/RpcHandler.test.ts | 64 ++++++++- .../src/handlers/RpcHandler.ts | 25 +++- .../src/handlers/mappings.test.ts | 39 ++++++ .../src/handlers/mappings.ts | 22 +++- .../src/use-cases/AccountUseCases.test.ts | 76 ++++++++++- .../src/use-cases/AccountUseCases.ts | 61 +++++++-- 14 files changed, 479 insertions(+), 49 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/entities/account.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 81625d4f..772e1d62 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `canBeMalleable` flag to broadcast/send response shapes so consumers can detect when a transaction id may be rewritten by a third party before confirmation (relevant only for legacy P2PKH accounts, currently always `false`) ([#599](https://github.com/MetaMask/snap-bitcoin-wallet/pull/599)) + ### Changed - Show a confirmation dialog before signing a PSBT from KeyringHandler and sending a transfer ([#591](https://github.com/MetaMask/snap-bitcoin-wallet/pull/591)) diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts index 89111e22..2cb56a67 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/client-request.test.ts @@ -84,6 +84,7 @@ describe('OnClientRequestHandler', () => { expect(response).toRespondWith({ transactionId: expect.any(String), + canBeMalleable: false, }); const { transactionId } = ( response.response as { result: { transactionId: string } } @@ -331,6 +332,7 @@ describe('OnClientRequestHandler', () => { }, }, ], + canBeMalleable: false, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts index c76a3ccf..9f84bb52 100644 --- a/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts +++ b/merged-packages/bitcoin-wallet-snap/integration-test/keyring-request.test.ts @@ -324,6 +324,7 @@ describe('KeyringRequestHandler', () => { result: { psbt: expect.any(String), // non deterministic txid: expect.any(String), + canBeMalleable: false, }, }); @@ -639,6 +640,7 @@ describe('KeyringRequestHandler', () => { pending: false, result: { txid: expect.any(String), + canBeMalleable: false, }, }); }); @@ -710,6 +712,7 @@ describe('KeyringRequestHandler', () => { pending: false, result: { txid: expect.any(String), + canBeMalleable: false, }, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/openrpc.json b/merged-packages/bitcoin-wallet-snap/openrpc.json index 19ab92e0..6dc00918 100644 --- a/merged-packages/bitcoin-wallet-snap/openrpc.json +++ b/merged-packages/bitcoin-wallet-snap/openrpc.json @@ -51,11 +51,15 @@ { "type": "null" }, { "type": "object", - "required": ["transactionId"], + "required": ["transactionId", "canBeMalleable"], "properties": { "transactionId": { "type": "string", "description": "The transaction ID." + }, + "canBeMalleable": { + "type": "boolean", + "description": "Whether the transaction ID can be changed by a third party (transaction malleability) before confirmation. True only for legacy P2PKH accounts; false for all currently supported address types." } } } @@ -108,11 +112,15 @@ { "type": "null" }, { "type": "object", - "required": ["transactionId"], + "required": ["transactionId", "canBeMalleable"], "properties": { "transactionId": { "type": "string", "description": "The transaction ID." + }, + "canBeMalleable": { + "type": "boolean", + "description": "Whether the transaction ID can be changed by a third party (transaction malleability) before confirmation. True only for legacy P2PKH accounts; false for all currently supported address types." } } } @@ -368,13 +376,18 @@ "events", "to", "from", - "fees" + "fees", + "canBeMalleable" ], "properties": { "id": { "type": "string", "description": "The transaction ID." }, + "canBeMalleable": { + "type": "boolean", + "description": "Whether the transaction ID can be changed by a third party (transaction malleability) before confirmation. True only for legacy P2PKH accounts; false for all currently supported address types." + }, "account": { "type": "string", "description": "The account ID that created this transaction." diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.test.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.test.ts new file mode 100644 index 00000000..7dc62017 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.test.ts @@ -0,0 +1,15 @@ +import type { AddressType } from '@metamask/bitcoindevkit'; + +import { canAccountTxidBeMalleated } from './account'; + +describe('canAccountTxidBeMalleated', () => { + it.each<[AddressType, boolean]>([ + ['p2pkh', true], + ['p2sh', false], + ['p2wpkh', false], + ['p2wsh', false], + ['p2tr', false], + ])('returns %s -> %s', (addressType, expected) => { + expect(canAccountTxidBeMalleated(addressType)).toBe(expected); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index f7f30af6..bcf2383e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -344,3 +344,44 @@ export const networkToCoinType: Record = { signet: Slip44.Testnet, regtest: Slip44.Testnet, }; + +/** + * Whether transactions broadcast from an account of each AddressType can + * have their txid changed by a third party (transaction malleability) + * before confirmation. + * + * Only legacy P2PKH (BIP44) carries signatures in scriptSig and is therefore + * malleable. Every other supported AddressType puts signatures in witness + * data, which is excluded from the txid hash. Note that `p2sh` here means + * BIP49 wrapped SegWit (sh(wpkh(...))), not generic legacy P2SH; its + * scriptSig is a fixed canonical push of the witness program and signatures + * live in the witness. If support for arbitrary legacy P2SH descriptors + * (bare multisig, custom redeem scripts with signatures in scriptSig) is + * added later, this table must be revisited — do not blindly keep the + * `p2sh` entry as `false`. + * + * Compile-time exhaustiveness: `satisfies Record` + * forces every AddressType variant to appear as a key. If a new variant is + * ever added upstream, this object literal becomes a TypeScript error + * until a deliberate malleability decision is recorded here. + */ +const ADDRESS_TYPE_TXID_MALLEABILITY = { + p2pkh: true, + p2sh: false, + p2wpkh: false, + p2wsh: false, + p2tr: false, +} as const satisfies Record; + +/** + * Whether transactions broadcast from an account of the given address type + * can have their txid changed by a third party (transaction malleability) + * before confirmation. + * + * @param addressType - The account's address type. + * @returns `true` if a third party can rewrite the txid of a transaction + * broadcast from this account before confirmation; `false` otherwise. + */ +export function canAccountTxidBeMalleated(addressType: AddressType): boolean { + return ADDRESS_TYPE_TXID_MALLEABILITY[addressType]; +} diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts index 2792d4ba..9d6a8af0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts @@ -95,12 +95,13 @@ describe('KeyringRequestHandler', () => { mockConfirmationRepository.insertSignPsbt.mockResolvedValue(undefined); }); - it('executes signPsbt with confirmation', async () => { + it('executes signPsbt with confirmation and propagates canBeMalleable when broadcasting', async () => { mockAccountsUseCases.signPsbt.mockResolvedValue({ psbt: 'psbtBase64', txid: mock({ toString: jest.fn().mockReturnValue('txid'), }), + canBeMalleable: false, }); const result = await handler.route(mockRequest); @@ -125,7 +126,71 @@ describe('KeyringRequestHandler', () => { ); expect(result).toStrictEqual({ pending: false, - result: { psbt: 'psbtBase64', txid: 'txid' }, + result: { + psbt: 'psbtBase64', + txid: 'txid', + canBeMalleable: false, + }, + }); + }); + + it('omits canBeMalleable when broadcast=false (no txid returned)', async () => { + const noBroadcastRequest = mock({ + origin, + request: { + method: AccountCapability.SignPsbt, + params: { + psbt: 'psbtBase64', + feeRate: 3, + options: { fill: false, broadcast: false }, + }, + }, + account: 'account-id', + }); + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + }); + + const result = await handler.route(noBroadcastRequest); + + expect(result).toStrictEqual({ + pending: false, + result: { psbt: 'psbtBase64', txid: null }, + }); + }); + + it('throws AssertionError if usecase returns txid without canBeMalleable', async () => { + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + txid: mock({ + toString: jest.fn().mockReturnValue('txid'), + }), + // canBeMalleable intentionally omitted — protocol violation + }); + + await expect(handler.route(mockRequest)).rejects.toThrow( + 'signPsbt returned txid without canBeMalleable flag', + ); + }); + + it('passes canBeMalleable=true through for legacy P2PKH accounts', async () => { + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + txid: mock({ + toString: jest.fn().mockReturnValue('txid'), + }), + canBeMalleable: true, + }); + + const result = await handler.route(mockRequest); + + expect(result).toStrictEqual({ + pending: false, + result: { + psbt: 'psbtBase64', + txid: 'txid', + canBeMalleable: true, + }, }); }); @@ -329,11 +394,14 @@ describe('KeyringRequestHandler', () => { account: 'account-id', }); - it('executes broadcastPsbt', async () => { + it('executes broadcastPsbt and propagates canBeMalleable', async () => { const mockTxid = mock({ toString: jest.fn().mockReturnValue('txid'), }); - mockAccountsUseCases.broadcastPsbt.mockResolvedValue(mockTxid); + mockAccountsUseCases.broadcastPsbt.mockResolvedValue({ + txid: mockTxid, + canBeMalleable: false, + }); const result = await handler.route(mockRequest); @@ -348,7 +416,24 @@ describe('KeyringRequestHandler', () => { ); expect(result).toStrictEqual({ pending: false, - result: { txid: 'txid' }, + result: { txid: 'txid', canBeMalleable: false }, + }); + }); + + it('passes canBeMalleable=true through for legacy P2PKH accounts', async () => { + const mockTxid = mock({ + toString: jest.fn().mockReturnValue('txid'), + }); + mockAccountsUseCases.broadcastPsbt.mockResolvedValue({ + txid: mockTxid, + canBeMalleable: true, + }); + + const result = await handler.route(mockRequest); + + expect(result).toStrictEqual({ + pending: false, + result: { txid: 'txid', canBeMalleable: true }, }); }); @@ -401,11 +486,14 @@ describe('KeyringRequestHandler', () => { account: 'account-id', }); - it('executes sendTransferq', async () => { + it('executes sendTransfer and propagates canBeMalleable', async () => { const mockTxid = mock({ toString: jest.fn().mockReturnValue('txid'), }); - mockAccountsUseCases.sendTransfer.mockResolvedValue(mockTxid); + mockAccountsUseCases.sendTransfer.mockResolvedValue({ + txid: mockTxid, + canBeMalleable: false, + }); const result = await handler.route(mockRequest); @@ -421,7 +509,24 @@ describe('KeyringRequestHandler', () => { ); expect(result).toStrictEqual({ pending: false, - result: { txid: 'txid' }, + result: { txid: 'txid', canBeMalleable: false }, + }); + }); + + it('passes canBeMalleable=true through for legacy P2PKH accounts', async () => { + const mockTxid = mock({ + toString: jest.fn().mockReturnValue('txid'), + }); + mockAccountsUseCases.sendTransfer.mockResolvedValue({ + txid: mockTxid, + canBeMalleable: true, + }); + + const result = await handler.route(mockRequest); + + expect(result).toStrictEqual({ + pending: false, + result: { txid: 'txid', canBeMalleable: true }, }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts index bf1a3f4d..13fc720f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts @@ -5,6 +5,7 @@ import { assert } from 'superstruct'; import type { ConfirmationRepository } from '../entities'; import { AccountCapability, + AssertionError, InexistentMethodError, NotFoundError, } from '../entities'; @@ -24,6 +25,10 @@ import type { AccountUseCases } from '../use-cases/AccountUseCases'; export type SignPsbtResponse = { psbt: string; txid: string | null; + // Present only when broadcast happened. True if the source account's + // address type allows third-party txid malleation before confirmation + // (currently only legacy P2PKH). + canBeMalleable?: boolean; }; export type ComputeFeeResponse = { @@ -33,6 +38,9 @@ export type ComputeFeeResponse = { export type BroadcastPsbtResponse = { txid: string; + // True if the source account's address type allows third-party txid + // malleation before confirmation (currently only legacy P2PKH). + canBeMalleable: boolean; }; export type FillPsbtResponse = { @@ -131,17 +139,33 @@ export class KeyringRequestHandler { ); // Creates a fresh PSBT from the original base64 because the original PSBT is mutated by the confirmation repository - const { psbt: signedPsbt, txid } = await this.#accountsUseCases.signPsbt( + const { + psbt: signedPsbt, + txid, + canBeMalleable, + } = await this.#accountsUseCases.signPsbt( id, parsePsbt(psbtBase64), origin, options, feeRate, ); - return this.#toKeyringResponse({ + // Invariant: signPsbt sets txid and canBeMalleable together (when broadcast + // happened) or neither (when it didn't). A txid without the flag would + // leak a possibly-malleable txid to the consumer. + if (txid !== undefined && canBeMalleable === undefined) { + throw new AssertionError( + 'signPsbt returned txid without canBeMalleable flag', + ); + } + const response: SignPsbtResponse = { psbt: signedPsbt.toString(), txid: txid?.toString() ?? null, - } as SignPsbtResponse); + }; + if (canBeMalleable !== undefined) { + response.canBeMalleable = canBeMalleable; + } + return this.#toKeyringResponse(response); } async #fillPsbt( @@ -179,13 +203,14 @@ export class KeyringRequestHandler { psbtBase64: string, origin: string, ): Promise { - const txid = await this.#accountsUseCases.broadcastPsbt( + const { txid, canBeMalleable } = await this.#accountsUseCases.broadcastPsbt( id, parsePsbt(psbtBase64), origin, ); return this.#toKeyringResponse({ txid: txid.toString(), + canBeMalleable, } as BroadcastPsbtResponse); } @@ -195,7 +220,7 @@ export class KeyringRequestHandler { origin: string, feeRate?: number, ): Promise { - const txid = await this.#accountsUseCases.sendTransfer( + const { txid, canBeMalleable } = await this.#accountsUseCases.sendTransfer( id, recipients, origin, @@ -203,6 +228,7 @@ export class KeyringRequestHandler { ); return this.#toKeyringResponse({ txid: txid.toString(), + canBeMalleable, } as BroadcastPsbtResponse); } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index 0360bc5a..27cad4a5 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -327,6 +327,7 @@ describe('RpcHandler', () => { txid: mock({ toString: jest.fn().mockReturnValue('txId'), }), + canBeMalleable: false, }); const result = await handler.route(origin, request); @@ -338,7 +339,43 @@ describe('RpcHandler', () => { 'metamask', { broadcast: true, fill: false }, ); - expect(result).toStrictEqual({ transactionId: 'txId' }); + expect(result).toStrictEqual({ + transactionId: 'txId', + canBeMalleable: false, + }); + }); + + it('propagates canBeMalleable=true from legacy P2PKH accounts', async () => { + mockSendFlowUseCases.display.mockResolvedValue(mockPsbt); + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + txid: mock({ + toString: jest.fn().mockReturnValue('txId'), + }), + canBeMalleable: true, + }); + + const result = await handler.route(origin, request); + + expect(result).toStrictEqual({ + transactionId: 'txId', + canBeMalleable: true, + }); + }); + + it('throws when canBeMalleable is missing (signPsbt did not broadcast)', async () => { + mockSendFlowUseCases.display.mockResolvedValue(mockPsbt); + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'psbtBase64', + txid: mock({ + toString: jest.fn().mockReturnValue('txId'), + }), + // canBeMalleable intentionally omitted + }); + + await expect(handler.route(origin, request)).rejects.toThrow( + 'signPsbt returned txid without canBeMalleable flag', + ); }); it('propagates errors from display', async () => { @@ -382,6 +419,7 @@ describe('RpcHandler', () => { txid: mock({ toString: jest.fn().mockReturnValue('txId'), }), + canBeMalleable: false, }); const result = await handler.route(origin, request); @@ -392,7 +430,10 @@ describe('RpcHandler', () => { 'metamask', { broadcast: true, fill: true }, ); - expect(result).toStrictEqual({ transactionId: 'txId' }); + expect(result).toStrictEqual({ + transactionId: 'txId', + canBeMalleable: false, + }); }); it('propagates errors from signAndSendTransaction', async () => { @@ -718,9 +759,11 @@ describe('RpcHandler', () => { // we mock the mapping function since we don't care about the result structure here // it is tested in mappings.test.ts - jest - .mocked(mapPsbtToTransaction) - .mockReturnValue({} as KeyringTransaction); + jest.mocked(mapPsbtToTransaction).mockReturnValue({ + canBeMalleable: false, + } as KeyringTransaction & { + canBeMalleable: boolean; + }); }); it('creates and signs a transaction successfully', async () => { @@ -740,6 +783,17 @@ describe('RpcHandler', () => { expect(result).toBeDefined(); }); + it('passes the canBeMalleable flag through from mapPsbtToTransaction', async () => { + jest.mocked(mapPsbtToTransaction).mockReturnValueOnce({ + id: 'tx-id', + canBeMalleable: true, + } as unknown as KeyringTransaction & { canBeMalleable: boolean }); + + const result = await handler.route(origin, validRequest); + + expect((result as any).canBeMalleable).toBe(true); + }); + it('handles different amounts and addresses', async () => { const customRequest: JsonRpcRequest = { id: 1, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index bbc4747a..3443c6a2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -57,6 +57,9 @@ export const ComputeFeeRequest = object({ export type SendTransactionResponse = { transactionId: string; + // True if the source account's address type allows third-party txid + // malleation before confirmation (currently only legacy P2PKH). + canBeMalleable: boolean; }; export const VerifyMessageRequest = object({ @@ -148,17 +151,22 @@ export class RpcHandler { if (!psbt) { return null; } - const { txid } = await this.#accountUseCases.signPsbt( + const { txid, canBeMalleable } = await this.#accountUseCases.signPsbt( account, psbt, origin, { fill: false, broadcast: true }, ); if (!txid) { - throw new AssertionError('Missing transaction ID '); + throw new AssertionError('Missing transaction ID'); + } + if (canBeMalleable === undefined) { + throw new AssertionError( + 'signPsbt returned txid without canBeMalleable flag', + ); } - return { transactionId: txid.toString() }; + return { transactionId: txid.toString(), canBeMalleable }; } async #signAndSend( @@ -168,7 +176,7 @@ export class RpcHandler { ): Promise { const psbt = parsePsbt(transaction); - const { txid } = await this.#accountUseCases.signPsbt( + const { txid, canBeMalleable } = await this.#accountUseCases.signPsbt( accountId, psbt, origin, @@ -178,10 +186,15 @@ export class RpcHandler { }, ); if (!txid) { - throw new AssertionError('Missing transaction ID '); + throw new AssertionError('Missing transaction ID'); + } + if (canBeMalleable === undefined) { + throw new AssertionError( + 'signPsbt returned txid without canBeMalleable flag', + ); } - return { transactionId: txid.toString() }; + return { transactionId: txid.toString(), canBeMalleable }; } async #computeFee( diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.test.ts index c2dd3b8b..4394759e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.test.ts @@ -75,11 +75,13 @@ describe('mapPsbtToTransaction', () => { * * @param network - The network type ('bitcoin' or 'testnet'). * @param feeSatoshis - The fee amount in satoshis. + * @param addressType - The account's address type (script type). * @returns A mocked BitcoinAccount object. */ function createMockAccount( network: 'bitcoin' | 'testnet' = 'bitcoin', feeSatoshis = 500, + addressType: 'p2pkh' | 'p2sh' | 'p2wpkh' | 'p2wsh' | 'p2tr' = 'p2wpkh', ) { const mockFeeAmount = mock(); jest @@ -89,6 +91,7 @@ describe('mapPsbtToTransaction', () => { const account = mock(); account.id = ACCOUNT_ID; account.network = network; + account.addressType = addressType; jest.spyOn(account, 'calculateFee').mockReturnValue(mockFeeAmount); jest.spyOn(account, 'isMine').mockReturnValue(false); @@ -146,9 +149,45 @@ describe('mapPsbtToTransaction', () => { }, }, ], + canBeMalleable: false, }); }); + it('sets canBeMalleable=true for legacy P2PKH accounts', () => { + const account = createMockAccount('bitcoin', 500, 'p2pkh'); + const output = createMockOutput(10000); + const transaction = createMockTransaction('p2pkhtx', [output]); + + jest.mocked(Address.from_script).mockImplementationOnce( + () => + ({ + toString: () => '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + }) as any, + ); + + const result = mapPsbtToTransaction(account, transaction); + + expect(result.canBeMalleable).toBe(true); + }); + + it('sets canBeMalleable=false for BIP49 wrapped-segwit (p2sh) accounts', () => { + // BIP49 sh(wpkh(...)) keeps signatures in witness, not scriptSig. + const account = createMockAccount('bitcoin', 500, 'p2sh'); + const output = createMockOutput(10000); + const transaction = createMockTransaction('p2shtx', [output]); + + jest.mocked(Address.from_script).mockImplementationOnce( + () => + ({ + toString: () => '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy', + }) as any, + ); + + const result = mapPsbtToTransaction(account, transaction); + + expect(result.canBeMalleable).toBe(false); + }); + it('filters out change outputs owned by the account', () => { const account = createMockAccount(); const changeOutput = createMockOutput(5000); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index 8eabe7a3..fc260c67 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -19,7 +19,11 @@ import { TransactionStatus, } from '@metamask/keyring-api'; -import { type BitcoinAccount, networkToCurrencyUnit } from '../entities'; +import { + type BitcoinAccount, + canAccountTxidBeMalleated, + networkToCurrencyUnit, +} from '../entities'; import type { Caip19Asset } from './caip'; import { addressTypeToCaip, networkToCaip19, networkToScope } from './caip'; @@ -216,7 +220,18 @@ export function mapToTransaction( } /** - * Maps a PSBT to a Keyring Transaction. + * KeyringTransaction augmented with a malleability flag. The base + * `KeyringTransaction` shape from `@metamask/keyring-api` does not include + * `canBeMalleable`; consumers (extension, dApp) that ignore unknown fields + * will see the standard shape, while consumers that opt in can read the flag + * to decide whether to trust the txid before block confirmation. + */ +export type KeyringTransactionWithMalleability = KeyringTransaction & { + canBeMalleable: boolean; +}; + +/** + * Maps a PSBT to a Keyring Transaction with a malleability flag. * * @param account - The Bitcoin account. * @param tx - The extracted transaction from the PSBT. @@ -225,7 +240,7 @@ export function mapToTransaction( export function mapPsbtToTransaction( account: BitcoinAccount, tx: Transaction, -): KeyringTransaction { +): KeyringTransactionWithMalleability { const txid = tx.compute_txid(); const currentTime = Date.now(); @@ -255,6 +270,7 @@ export function mapPsbtToTransaction( to: getRecipients(tx.output), from: [], fees: [mapToTransactionFees(account.calculateFee(tx), account.network)], + canBeMalleable: canAccountTxidBeMalleated(account.addressType), }; } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 210f9f9c..08b1d151 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -865,6 +865,7 @@ describe('AccountUseCases', () => { }); const mockAccount = mock({ network: 'bitcoin', + addressType: 'p2wpkh', sign: jest.fn(), capabilities: [AccountCapability.SignPsbt], }); @@ -938,7 +939,7 @@ describe('AccountUseCases', () => { mockAccount.getTransaction.mockReturnValue(mockWalletTx); mockTransaction.compute_txid.mockReturnValue(mockTxid); - const { txid, psbt } = await useCases.signPsbt( + const { txid, psbt, canBeMalleable } = await useCases.signPsbt( 'account-id', mockPsbt, 'metamask', @@ -970,6 +971,34 @@ describe('AccountUseCases', () => { ); expect(txid).toBe(mockTxid); expect(psbt).toBe('mockSignedPsbt'); + expect(canBeMalleable).toBe(false); + }); + + it('omits canBeMalleable when broadcast is false', async () => { + const { canBeMalleable } = await useCases.signPsbt( + 'account-id', + mockPsbt, + 'metamask', + { fill: false, broadcast: false }, + ); + + expect(canBeMalleable).toBeUndefined(); + }); + + it('sets canBeMalleable=true when broadcasting from a legacy P2PKH account', async () => { + mockAccount.getTransaction.mockReturnValue(mockWalletTx); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + const legacyAccount = { ...mockAccount, addressType: 'p2pkh' as const }; + mockRepository.getWithSigner.mockResolvedValueOnce(legacyAccount); + + const { canBeMalleable } = await useCases.signPsbt( + 'account-id', + mockPsbt, + 'metamask', + { fill: false, broadcast: true }, + ); + + expect(canBeMalleable).toBe(true); }); it('fills, signs and broadcasts a PSBT', async () => { @@ -1554,6 +1583,7 @@ describe('AccountUseCases', () => { }); const mockAccount = mock({ network: 'bitcoin', + addressType: 'p2wpkh', sign: jest.fn(), capabilities: [AccountCapability.SendTransfer], }); @@ -1624,7 +1654,7 @@ describe('AccountUseCases', () => { mockAccount.getTransaction.mockReturnValue(mockWalletTx); mockTransaction.compute_txid.mockReturnValue(mockTxid); - const txid = await useCases.sendTransfer( + const result = await useCases.sendTransfer( 'account-id', recipients, 'metamask', @@ -1657,7 +1687,25 @@ describe('AccountUseCases', () => { mockWalletTx, 'metamask', ); - expect(txid).toBe(mockTxid); + expect(result.txid).toBe(mockTxid); + expect(result.canBeMalleable).toBe(false); + }); + + it('sets canBeMalleable=true on legacy P2PKH accounts', async () => { + mockAccount.getTransaction.mockReturnValue(mockWalletTx); + mockTransaction.compute_txid.mockReturnValue(mockTxid); + mockTxBuilder.finish.mockReturnValueOnce(mockPsbt); + + const legacyAccount = { ...mockAccount, addressType: 'p2pkh' as const }; + mockRepository.getWithSigner.mockResolvedValueOnce(legacyAccount); + + const result = await useCases.sendTransfer( + 'account-id', + recipients, + 'metamask', + ); + + expect(result.canBeMalleable).toBe(true); }); it('propagates an error if getWithSigner fails', async () => { @@ -1700,6 +1748,7 @@ describe('AccountUseCases', () => { const mockWalletTx = mock(); const mockAccount = mock({ network: 'bitcoin', + addressType: 'p2wpkh', capabilities: [AccountCapability.BroadcastPsbt], }); @@ -1730,8 +1779,8 @@ describe('AccountUseCases', () => { ).rejects.toThrow('Account missing given capability'); }); - it('broadcasts a PSBT and returns txid', async () => { - const txid = await useCases.broadcastPsbt( + it('broadcasts a PSBT and returns txid with canBeMalleable=false for P2WPKH', async () => { + const result = await useCases.broadcastPsbt( 'account-id', mockPsbt, 'metamask', @@ -1744,7 +1793,22 @@ describe('AccountUseCases', () => { mockTransaction, ); expect(mockRepository.update).toHaveBeenCalledWith(mockAccount); - expect(txid).toBe(mockTxid); + expect(result.txid).toBe(mockTxid); + expect(result.canBeMalleable).toBe(false); + }); + + it('returns canBeMalleable=true when broadcasting from a legacy P2PKH account', async () => { + const legacyAccount = { ...mockAccount, addressType: 'p2pkh' as const }; + mockRepository.get.mockResolvedValueOnce(legacyAccount); + + const result = await useCases.broadcastPsbt( + 'account-id', + mockPsbt, + 'metamask', + ); + + expect(result.txid).toBe(mockTxid); + expect(result.canBeMalleable).toBe(true); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index 345fe472..b87d32ec 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -25,6 +25,7 @@ import { AccountCapability, addressTypeToPurpose, AssertionError, + canAccountTxidBeMalleated, networkToCoinType, NotFoundError, PermissionError, @@ -48,6 +49,29 @@ export type CreateAccountParams = DiscoverAccountParams & { accountName?: string; }; +/** + * Result of broadcasting a Bitcoin transaction. + * + * `canBeMalleable` is `true` when the source account's address type allows a + * third party to rewrite the txid before confirmation (currently only legacy + * P2PKH). Consumers that persist or surface the txid to the dApp should treat + * a `true` flag as a signal that the txid may change, and avoid trusting it + * for finality decisions before block confirmation. + * + * Limitation: the flag is computed from the snap account's address type, which + * is correct for transactions the snap builds itself (`sendTransfer`, + * `executeSendFlow`, `confirmSend`). For externally-provided PSBTs broadcast + * via `broadcastPsbt` or `signPsbt({ broadcast: true, fill: false })`, the + * transaction may include foreign inputs of any address type; if any foreign + * input is legacy non-witness, the txid is malleable even when the snap's + * account is P2WPKH. Callers building collaborative transactions must compute + * malleability per-input. + */ +export type BroadcastResult = { + txid: Txid; + canBeMalleable: boolean; +}; + export class AccountUseCases { readonly #logger: Logger; @@ -372,7 +396,7 @@ export class AccountUseCases { origin: string, options: { fill: boolean; broadcast: boolean }, feeRate?: number, - ): Promise<{ psbt: string; txid?: Txid }> { + ): Promise<{ psbt: string; txid?: Txid; canBeMalleable?: boolean }> { this.#logger.debug('Signing PSBT: %s', id, options); const account = await this.#repository.getWithSigner(id); @@ -389,7 +413,11 @@ export class AccountUseCases { if (options.broadcast) { const psbtString = signedPsbt.toString(); const tx = account.extractTransaction(signedPsbt); - const txid = await this.#broadcast(account, tx, origin); + const { txid, canBeMalleable } = await this.#broadcast( + account, + tx, + origin, + ); this.#logger.info( 'Transaction sent successfully: %s. Account: %s, Network: %s, Options: %o', @@ -398,7 +426,7 @@ export class AccountUseCases { account.network, options, ); - return { psbt: psbtString, txid }; + return { psbt: psbtString, txid, canBeMalleable }; } this.#logger.info( @@ -427,7 +455,11 @@ export class AccountUseCases { return psbt.fee(); } - async broadcastPsbt(id: string, psbt: Psbt, origin: string): Promise { + async broadcastPsbt( + id: string, + psbt: Psbt, + origin: string, + ): Promise { this.#logger.debug('Sending transaction: %s', id); const account = await this.#repository.get(id); @@ -437,16 +469,16 @@ export class AccountUseCases { this.#checkCapability(account, AccountCapability.BroadcastPsbt); const tx = account.extractTransaction(psbt); - const txid = await this.#broadcast(account, tx, origin); + const result = await this.#broadcast(account, tx, origin); this.#logger.info( 'Transaction sent successfully: %s. Account: %s, Network: %s', - txid, + result.txid, account.id, account.network, ); - return txid; + return result; } async sendTransfer( @@ -454,7 +486,7 @@ export class AccountUseCases { recipients: { address: string; amount: string }[], origin: string, feeRate?: number, - ): Promise { + ): Promise { this.#logger.debug( 'Transferring funds: %s. Recipients: %o', id, @@ -494,15 +526,15 @@ export class AccountUseCases { const signedPsbt = account.sign(psbt); const tx = account.extractTransaction(signedPsbt); - const txid = await this.#broadcast(account, tx, origin); + const result = await this.#broadcast(account, tx, origin); this.#logger.info( 'Funds transferred successfully: %s. Account: %s, Network: %s', - txid.toString(), + result.txid.toString(), account.id, account.network, ); - return txid; + return result; } async signMessage( @@ -649,7 +681,7 @@ export class AccountUseCases { account: BitcoinAccount, tx: Transaction, origin: string, - ): Promise { + ): Promise { const txid = tx.compute_txid(); await this.#chain.broadcast(account.network, tx.clone()); account.applyUnconfirmedTx(tx, getCurrentUnixTimestamp()); @@ -677,7 +709,10 @@ export class AccountUseCases { ); } - return txid; + return { + txid, + canBeMalleable: canAccountTxidBeMalleated(account.addressType), + }; } #checkCapability( From 124f80d2cc3a2b21827fa4d97c7bc5d28213c806 Mon Sep 17 00:00:00 2001 From: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> Date: Sat, 23 May 2026 18:29:23 -0400 Subject: [PATCH 337/362] chore: bump keyring API dependencies (#608) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 3 +++ merged-packages/bitcoin-wallet-snap/package.json | 6 +++--- .../bitcoin-wallet-snap/src/entities/confirmation.ts | 3 +-- .../bitcoin-wallet-snap/src/entities/currency.ts | 6 ++++++ .../bitcoin-wallet-snap/src/entities/send-flow.ts | 3 +-- merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts | 4 ++-- .../src/store/JSXConfirmationRepository.tsx | 2 +- .../bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts | 3 ++- 8 files changed, 19 insertions(+), 11 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 772e1d62..3f3f3d39 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Show a confirmation dialog before signing a PSBT from KeyringHandler and sending a transfer ([#591](https://github.com/MetaMask/snap-bitcoin-wallet/pull/591)) - Add `resolveAccountAddress` method to KeyringHandler for dApp connectivity ([#590](https://github.com/MetaMask/snap-bitcoin-wallet/pull/590)) +- Bump `@metamask/keyring-api` from `^21.3.0` to `^22.0.0` ([#608](https://github.com/MetaMask/snap-bitcoin-wallet/pull/608)) +- Bump `@metamask/keyring-snap-sdk` from `^7.1.1` to `^8.0.0` ([#608](https://github.com/MetaMask/snap-bitcoin-wallet/pull/608)) +- Bump `@metamask/snaps-sdk` from `^10.3.0` to `^11.0.0` ([#608](https://github.com/MetaMask/snap-bitcoin-wallet/pull/608)) ### Fixed diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 34c1132a..e69c089f 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -43,12 +43,12 @@ "@metamask/auto-changelog": "^3.4.4", "@metamask/bitcoindevkit": "^0.1.13", "@metamask/key-tree": "^10.1.1", - "@metamask/keyring-api": "^21.3.0", - "@metamask/keyring-snap-sdk": "^7.1.1", + "@metamask/keyring-api": "^22.0.0", + "@metamask/keyring-snap-sdk": "^8.0.0", "@metamask/slip44": "^4.2.0", "@metamask/snaps-cli": "^8.3.0", "@metamask/snaps-jest": "^9.8.0", - "@metamask/snaps-sdk": "^10.3.0", + "@metamask/snaps-sdk": "^11.0.0", "@metamask/utils": "^11.9.0", "bip322-js": "^3.0.0", "concurrently": "^9.2.1", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/confirmation.ts b/merged-packages/bitcoin-wallet-snap/src/entities/confirmation.ts index ce04ae7c..3a079b56 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/confirmation.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/confirmation.ts @@ -1,8 +1,7 @@ import type { Network, Psbt } from '@metamask/bitcoindevkit'; -import type { CurrencyRate } from '@metamask/snaps-sdk'; import type { BitcoinAccount } from './account'; -import type { CurrencyUnit } from './currency'; +import type { CurrencyRate, CurrencyUnit } from './currency'; export type SignMessageConfirmationContext = { message: string; diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts b/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts index 9bf091a6..1b71c644 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/currency.ts @@ -8,6 +8,12 @@ export enum CurrencyUnit { Fiat = 'fiat', // Can also be cryptos like ETH, but will be fiat for 99% of users } +export type CurrencyRate = { + conversionRate: number; + conversionDate: number; + currency: string; +}; + export const networkToCurrencyUnit: Record = { bitcoin: CurrencyUnit.Bitcoin, testnet: CurrencyUnit.Testnet, diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts index f47f28df..659a62da 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts @@ -1,7 +1,6 @@ import type { Network } from '@metamask/bitcoindevkit'; -import type { CurrencyRate } from '@metamask/snaps-sdk'; -import type { CurrencyUnit } from './currency'; +import type { CurrencyRate, CurrencyUnit } from './currency'; import type { CodifiedError } from './error'; // TODO: This context will be adjusted to the needs diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts index 12ca8101..e91e6dfa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts @@ -1,8 +1,8 @@ import type { Network } from '@metamask/bitcoindevkit'; import { Amount, BdkErrorCode } from '@metamask/bitcoindevkit'; -import type { CaipAccountId, CurrencyRate } from '@metamask/snaps-sdk'; +import type { CaipAccountId } from '@metamask/snaps-sdk'; -import type { CurrencyUnit, Messages } from '../../entities'; +import type { CurrencyRate, CurrencyUnit, Messages } from '../../entities'; import { networkToScope } from '../../handlers'; export const displayAmount = ( diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx index dbf86e9c..c3f88a06 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx @@ -1,7 +1,6 @@ import type { Psbt } from '@metamask/bitcoindevkit'; import { Address as BdkAddress } from '@metamask/bitcoindevkit'; import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; -import type { CurrencyRate } from '@metamask/snaps-sdk'; import type { AssetRatesClient, @@ -9,6 +8,7 @@ import type { BlockchainClient, ConfirmationRepository, ConfirmSendFormContext, + CurrencyRate, Logger, SignMessageConfirmationContext, SignPsbtConfirmationContext, diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index 843c3f30..30c81b35 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -6,7 +6,7 @@ import { type Transaction, } from '@metamask/bitcoindevkit'; import { getCurrentUnixTimestamp } from '@metamask/keyring-snap-sdk'; -import type { CurrencyRate, InputChangeEvent } from '@metamask/snaps-sdk'; +import type { InputChangeEvent } from '@metamask/snaps-sdk'; import type { AssetRatesClient, @@ -14,6 +14,7 @@ import type { BitcoinAccountRepository, BlockchainClient, CodifiedError, + CurrencyRate, Logger, ConfirmSendFormContext, ReviewTransactionContext, From ace4853b4794769ef3817cb0b825ea8e0103f6fd Mon Sep 17 00:00:00 2001 From: Battambang Date: Wed, 27 May 2026 14:48:22 +0200 Subject: [PATCH 338/362] fix: include external service details in connection errors (#618) --- .../src/handlers/HandlerMiddleware.test.ts | 17 +++++++++++++++++ .../src/handlers/HandlerMiddleware.ts | 5 ++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts index 196f1257..82e7dac4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts @@ -6,6 +6,7 @@ import { type SnapClient, type Translator, BaseError, + ExternalServiceError, } from '../entities'; import { HandlerMiddleware } from './HandlerMiddleware'; @@ -25,6 +26,7 @@ describe('HandlerMiddleware', () => { ); beforeEach(() => { + jest.clearAllMocks(); mockSnapClient.getPreferences.mockResolvedValue({ locale: 'en', } as GetPreferencesResult); @@ -80,5 +82,20 @@ describe('HandlerMiddleware', () => { expect(mockLogger.error).toHaveBeenCalledWith(error, error.data); expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); }); + + it('includes the concrete external service failure in the returned error message', async () => { + const error = new ExternalServiceError('Failed to synchronize account', { + account: 'account-1', + }); + const mockFn = jest.fn().mockRejectedValue(error); + mockTranslator.load.mockResolvedValue({ + 'error.3000': { message: 'Connection error' }, + }); + + await expect(middleware.handle(mockFn)).rejects.toThrow( + 'Connection error: Failed to synchronize account', + ); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); + }); }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts index ca6c5d9c..97c986fd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts @@ -96,7 +96,10 @@ export class HandlerMiddleware { // Internal errors that we should not expose to the user: Equivalent to 5xx errors } else if (error instanceof ExternalServiceError) { - throw new DisconnectedError(errMsg, error.data); + throw new DisconnectedError( + `${errMsg}: ${error.message}`, + error.data, + ); } else if ( error instanceof WalletError || error instanceof StorageError || From e4d6ccdcecc2751beb5ca466e11c1882f8692994 Mon Sep 17 00:00:00 2001 From: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> Date: Wed, 27 May 2026 10:37:00 -0400 Subject: [PATCH 339/362] feat: add `insertMany` to repository API (Batch accounts 2/5) (#609) --- .../bitcoin-wallet-snap/CHANGELOG.md | 1 + .../src/entities/account.ts | 24 +++ .../bitcoin-wallet-snap/src/entities/snap.ts | 2 +- .../src/infra/BdkAccountAdapter.ts | 24 ++- .../src/store/BdkAccountRepository.test.ts | 188 ++++++++++++++++++ .../src/store/BdkAccountRepository.ts | 178 +++++++++++++++-- 6 files changed, 393 insertions(+), 24 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 3f3f3d39..6d966589 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `canBeMalleable` flag to broadcast/send response shapes so consumers can detect when a transaction id may be rewritten by a third party before confirmation (relevant only for legacy P2PKH accounts, currently always `false`) ([#599](https://github.com/MetaMask/snap-bitcoin-wallet/pull/599)) +- Add `insertMany` to repository API ([#609](https://github.com/MetaMask/snap-bitcoin-wallet/pull/609)) ### Changed diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts index bcf2383e..f8d27eee 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/account.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/account.ts @@ -123,6 +123,13 @@ export type BitcoinAccount = { */ takeStaged(): ChangeSet | undefined; + /** + * Check whether a change set exists without making it unavailable for extraction. + * + * @returns true if there is a change set + */ + hasStaged(): boolean; + /** * Returns a Transaction Builder. * @@ -262,6 +269,16 @@ export type BitcoinAccountRepository = { */ getByDerivationPath(derivationPath: string[]): Promise; + /** + * Get accounts by derivation path. + * + * @param derivationPaths - derivation paths. + * @returns the accounts or null if they do not exist, in input order + */ + getByDerivationPaths( + derivationPaths: string[][], + ): Promise<(BitcoinAccount | null)[]>; + /** * Create a new account, without persisting it. * @@ -283,6 +300,13 @@ export type BitcoinAccountRepository = { */ insert(account: BitcoinAccount): Promise; + /** + * Insert accounts. + * + * @param accounts - Bitcoin accounts. + */ + insertMany(accounts: BitcoinAccount[]): Promise; + /** * Update an account. * diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index 9d8e9f42..8d150ce3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -13,7 +13,7 @@ import type { Inscription } from './meta-protocols'; export type SnapState = { // accountId -> account state. This is the main state of the snap. - accounts: Record; + accounts: Record; // derivationPath -> accountId. Only needed for fast lookup. derivationPaths: Record; }; diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index a8c8a86d..425bd440 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -41,6 +41,8 @@ export class BdkAccountAdapter implements BitcoinAccount { readonly #wallet: Wallet; + #staged?: ChangeSet; + readonly #capabilities: AccountCapability[]; constructor(id: string, derivationPath: string[], wallet: Wallet) { @@ -157,7 +159,13 @@ export class BdkAccountAdapter implements BitcoinAccount { } takeStaged(): ChangeSet | undefined { - return this.#wallet.take_staged(); + const staged = this.#collectStaged(); + this.#staged = undefined; + return staged; + } + + hasStaged(): boolean { + return Boolean(this.#collectStaged()); } buildTx(): TransactionBuilder { @@ -243,4 +251,18 @@ export class BdkAccountAdapter implements BitcoinAccount { new UnconfirmedTx(tx, BigInt(lastSeen)), ]); } + + #collectStaged(): ChangeSet | undefined { + const staged = this.#wallet.take_staged(); + if (!staged) { + return this.#staged; + } + + if (this.#staged) { + staged.merge(this.#staged); + } + + this.#staged = staged; + return this.#staged; + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts index a89e1241..4f4f6fd9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts @@ -77,6 +77,7 @@ describe('BdkAccountRepository', () => { (mockAccount.takeStaged as jest.Mock) = jest .fn() .mockReturnValue(mockChangeSet); + (mockAccount.hasStaged as jest.Mock) = jest.fn().mockReturnValue(true); (mockChangeSet.to_json as jest.Mock) = jest .fn() .mockReturnValue(mockWalletData); @@ -185,6 +186,94 @@ describe('BdkAccountRepository', () => { }); }); + describe('getByDerivationPaths', () => { + const derivationPath1 = ['m', "84'", "0'", "1'"]; + const derivationPath2 = ['m', "84'", "0'", "2'"]; + const accountState1 = { + ...mockAccountState, + derivationPath: derivationPath1, + }; + const accountState2 = { + ...mockAccountState, + derivationPath: derivationPath2, + }; + const mockAccount1 = mock({ + ...mockAccount, + id: 'some-id-1', + derivationPath: derivationPath1, + }); + const mockAccount2 = mock({ + ...mockAccount, + id: 'some-id-2', + derivationPath: derivationPath2, + }); + + it('returns accounts in derivation path order with one state read per namespace', async () => { + mockSnapClient.getState + .mockResolvedValueOnce({ + "m/84'/0'/1'": 'some-id-1', + "m/84'/0'/2'": 'some-id-2', + }) + .mockResolvedValueOnce({ + 'some-id-1': accountState1, + 'some-id-2': accountState2, + }); + (BdkAccountAdapter.load as jest.Mock) + .mockReturnValueOnce(mockAccount2) + .mockReturnValueOnce(mockAccount1); + + const result = await repo.getByDerivationPaths([ + derivationPath2, + derivationPath1, + ]); + + expect(mockSnapClient.getState).toHaveBeenCalledWith('derivationPaths'); + expect(mockSnapClient.getState).toHaveBeenCalledWith('accounts'); + expect(mockSnapClient.getState).toHaveBeenCalledTimes(2); + expect(result).toStrictEqual([mockAccount2, mockAccount1]); + expect(mockSnapClient.setState).not.toHaveBeenCalled(); + }); + + it('repairs missing derivation path indexes from account state', async () => { + mockSnapClient.getState + .mockResolvedValueOnce({ + "m/84'/0'/1'": 'some-id-1', + }) + .mockResolvedValueOnce({ + 'some-id-1': accountState1, + 'some-id-2': accountState2, + }); + (BdkAccountAdapter.load as jest.Mock) + .mockReturnValueOnce(mockAccount1) + .mockReturnValueOnce(mockAccount2); + + const result = await repo.getByDerivationPaths([ + derivationPath1, + derivationPath2, + ]); + + expect(result).toStrictEqual([mockAccount1, mockAccount2]); + expect(mockSnapClient.setState).toHaveBeenCalledWith('derivationPaths', { + "m/84'/0'/1'": 'some-id-1', + "m/84'/0'/2'": 'some-id-2', + }); + }); + + it('repairs a missing derivation path index for a single lookup', async () => { + mockSnapClient.getState.mockResolvedValueOnce({}).mockResolvedValueOnce({ + 'some-id-1': accountState1, + }); + (BdkAccountAdapter.load as jest.Mock).mockReturnValueOnce(mockAccount1); + + const result = await repo.getByDerivationPaths([derivationPath1]); + + expect(result).toStrictEqual([mockAccount1]); + expect(mockSnapClient.setState).toHaveBeenCalledWith('derivationPaths', { + "m/84'/0'/1'": 'some-id-1', + }); + }); + }); + describe('getWithSigner', () => { it('returns null if account not found', async () => { mockSnapClient.getState.mockResolvedValue(null); @@ -258,6 +347,105 @@ describe('BdkAccountRepository', () => { }); }); + describe('insertMany', () => { + it('returns an empty array when there are no accounts to insert', async () => { + const result = await repo.insertMany([]); + + expect(result).toStrictEqual([]); + expect(mockSnapClient.getState).not.toHaveBeenCalled(); + expect(mockSnapClient.setState).not.toHaveBeenCalled(); + }); + + it('throws an error without consuming staged data if any account has no wallet data', async () => { + const accountWithWalletData = mock({ + id: 'some-id-1', + derivationPath: ['m', "84'", "0'", "1'"], + }); + (accountWithWalletData.hasStaged as jest.Mock) = jest + .fn() + .mockReturnValue(true); + (accountWithWalletData.takeStaged as jest.Mock) = jest + .fn() + .mockReturnValue(mockChangeSet); + const missingWalletAccount = mock({ + id: 'missing-wallet', + derivationPath: ['m', "84'", "0'", "2'"], + }); + (missingWalletAccount.hasStaged as jest.Mock) = jest + .fn() + .mockReturnValue(false); + (missingWalletAccount.takeStaged as jest.Mock) = jest.fn(); + + await expect( + repo.insertMany([accountWithWalletData, missingWalletAccount]), + ).rejects.toThrow( + 'Missing changeset data for account "missing-wallet" for insertion.', + ); + + expect(accountWithWalletData.hasStaged).toHaveBeenCalled(); + expect(missingWalletAccount.hasStaged).toHaveBeenCalled(); + expect(accountWithWalletData.takeStaged).not.toHaveBeenCalled(); + expect(missingWalletAccount.takeStaged).not.toHaveBeenCalled(); + expect(mockSnapClient.getState).not.toHaveBeenCalled(); + expect(mockSnapClient.setState).not.toHaveBeenCalled(); + }); + + it('inserts multiple accounts with one accounts write and one derivation path write', async () => { + const existingAccountState: AccountState = { + wallet: mockWalletData, + inscriptions: [], + derivationPath: mockDerivationPath, + }; + const account1 = mock(); + account1.id = 'some-id-1'; + account1.derivationPath = ['m', "84'", "0'", "1'"]; + const account2 = mock(); + account2.id = 'some-id-2'; + account2.derivationPath = ['m', "84'", "0'", "2'"]; + (account1.takeStaged as jest.Mock) = jest + .fn() + .mockReturnValue(mockChangeSet); + (account1.hasStaged as jest.Mock) = jest.fn().mockReturnValue(true); + (account2.takeStaged as jest.Mock) = jest + .fn() + .mockReturnValue(mockChangeSet); + (account2.hasStaged as jest.Mock) = jest.fn().mockReturnValue(true); + mockSnapClient.getState + .mockResolvedValueOnce({ + 'existing-id': existingAccountState, + }) + .mockResolvedValueOnce({ + "m/84'/0'/0'": 'existing-id', + }); + + const result = await repo.insertMany([account1, account2]); + + expect(result).toStrictEqual([account1, account2]); + expect(mockSnapClient.setState).toHaveBeenNthCalledWith(1, 'accounts', { + 'existing-id': existingAccountState, + 'some-id-1': { + wallet: mockWalletData, + inscriptions: [], + derivationPath: ['m', "84'", "0'", "1'"], + }, + 'some-id-2': { + wallet: mockWalletData, + inscriptions: [], + derivationPath: ['m', "84'", "0'", "2'"], + }, + }); + expect(mockSnapClient.setState).toHaveBeenNthCalledWith( + 2, + 'derivationPaths', + { + "m/84'/0'/0'": 'existing-id', + "m/84'/0'/1'": 'some-id-1', + "m/84'/0'/2'": 'some-id-2', + }, + ); + }); + }); + describe('update', () => { it('does nothing if no wallet data', async () => { await repo.update({ diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts index 37b5d444..c1ec5c26 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts @@ -34,6 +34,30 @@ function toBdkFingerprint(fingerprint: number): string { return fingerprint.toString(16).padStart(8, '0'); } +/** + * @param derivationPath - Split derivation path. + * @returns Storage key for a derivation path. + */ +function getDerivationPathKey(derivationPath: string[]): string { + return derivationPath.join('/'); +} + +/** + * @param account - Account to persist. + * @param walletData - Serialized wallet data. + * @returns Account state. + */ +function getAccountState( + account: BitcoinAccount, + walletData: ChangeSet, +): AccountState { + return { + wallet: walletData.to_json(), + inscriptions: [], + derivationPath: account.derivationPath, + }; +} + export class BdkAccountRepository implements BitcoinAccountRepository { readonly #snapClient: SnapClient; @@ -49,11 +73,7 @@ export class BdkAccountRepository implements BitcoinAccountRepository { return null; } - return BdkAccountAdapter.load( - id, - account.derivationPath, - ChangeSet.from_json(account.wallet), - ); + return this.#loadAccount(id, account); } async getAll(): Promise { @@ -64,22 +84,16 @@ export class BdkAccountRepository implements BitcoinAccountRepository { return []; } - return Object.entries(accounts) - .filter(([, account]) => account !== null) - .map(([id, account]) => - BdkAccountAdapter.load( - id, - account.derivationPath, - ChangeSet.from_json(account.wallet), - ), - ); + return Object.entries(accounts).flatMap(([id, account]) => + account ? [this.#loadAccount(id, account)] : [], + ); } async getByDerivationPath( derivationPath: string[], ): Promise { const id = await this.#snapClient.getState( - `derivationPaths.${derivationPath.join('/')}`, + `derivationPaths.${getDerivationPathKey(derivationPath)}`, ); if (!id) { return null; @@ -88,6 +102,65 @@ export class BdkAccountRepository implements BitcoinAccountRepository { return this.get(id as string); } + async getByDerivationPaths( + derivationPaths: string[][], + ): Promise<(BitcoinAccount | null)[]> { + if (derivationPaths.length === 0) { + return []; + } + + const [derivationPathIndex, accounts] = await Promise.all([ + this.#snapClient.getState('derivationPaths') as Promise< + SnapState['derivationPaths'] | null + >, + this.#snapClient.getState('accounts') as Promise< + SnapState['accounts'] | null + >, + ]); + + const accountsById = accounts ?? {}; + const existingDerivationPathIndex = derivationPathIndex ?? {}; + const accountsByDerivationPath = new Map(); + + for (const [id, account] of Object.entries(accountsById)) { + if (account) { + accountsByDerivationPath.set( + getDerivationPathKey(account.derivationPath), + [id, account], + ); + } + } + + const repairs: Record = {}; + const results = derivationPaths.map((derivationPath) => { + const pathKey = getDerivationPathKey(derivationPath); + const indexedId = existingDerivationPathIndex[pathKey]; + const indexedAccount = indexedId ? accountsById[indexedId] : null; + + if (indexedId && indexedAccount) { + return this.#loadAccount(indexedId, indexedAccount); + } + + const fallback = accountsByDerivationPath.get(pathKey); + if (!fallback) { + return null; + } + + const [id, account] = fallback; + repairs[pathKey] = id; + return this.#loadAccount(id, account); + }); + + if (Object.keys(repairs).length > 0) { + await this.#snapClient.setState('derivationPaths', { + ...existingDerivationPathIndex, + ...repairs, + }); + } + + return results; + } + async getWithSigner(id: string): Promise { const accountState = (await this.#snapClient.getState( `accounts.${id}`, @@ -148,7 +221,6 @@ export class BdkAccountRepository implements BitcoinAccountRepository { async insert(account: BitcoinAccount): Promise { const { id, derivationPath } = account; - const walletData = account.takeStaged(); if (!walletData) { throw new StorageError( @@ -158,19 +230,73 @@ export class BdkAccountRepository implements BitcoinAccountRepository { await Promise.all([ this.#snapClient.setState( - `derivationPaths.${derivationPath.join('/')}`, + `derivationPaths.${getDerivationPathKey(derivationPath)}`, id, ), - this.#snapClient.setState(`accounts.${id}`, { - wallet: walletData.to_json(), - inscriptions: [], - derivationPath, - }), + this.#snapClient.setState( + `accounts.${id}`, + getAccountState(account, walletData), + ), ]); return account; } + async insertMany(accounts: BitcoinAccount[]): Promise { + if (accounts.length === 0) { + return []; + } + + if (accounts.length === 1) { + return [await this.insert(accounts[0] as BitcoinAccount)]; + } + + const accountStateEntries: [string, AccountState][] = []; + const derivationPathEntries: [string, string][] = []; + + for (const account of accounts) { + if (!account.hasStaged()) { + throw new StorageError( + `Missing changeset data for account "${account.id}" for insertion.`, + ); + } + } + + for (const account of accounts) { + const { id, derivationPath } = account; + const walletData = account.takeStaged(); + + if (!walletData) { + throw new StorageError( + `Missing changeset data for account "${id}" for insertion.`, + ); + } + accountStateEntries.push([id, getAccountState(account, walletData)]); + derivationPathEntries.push([getDerivationPathKey(derivationPath), id]); + } + + const [existingAccounts, existingDerivationPaths] = await Promise.all([ + this.#snapClient.getState('accounts') as Promise< + SnapState['accounts'] | null + >, + this.#snapClient.getState('derivationPaths') as Promise< + SnapState['derivationPaths'] | null + >, + ]); + + await this.#snapClient.setState('accounts', { + ...(existingAccounts ?? {}), + ...Object.fromEntries(accountStateEntries), + }); + + await this.#snapClient.setState('derivationPaths', { + ...(existingDerivationPaths ?? {}), + ...Object.fromEntries(derivationPathEntries), + }); + + return accounts; + } + async update( account: BitcoinAccount, inscriptions?: Inscription[], @@ -246,4 +372,12 @@ export class BdkAccountRepository implements BitcoinAccountRepository { return `${txid}:${vout}`; }); } + + #loadAccount(id: string, account: AccountState): BitcoinAccount { + return BdkAccountAdapter.load( + id, + account.derivationPath, + ChangeSet.from_json(account.wallet), + ); + } } From 2d830b1ced883a3d113ee1cdd25eb95295d56bac Mon Sep 17 00:00:00 2001 From: Battambang Date: Wed, 27 May 2026 17:13:00 +0200 Subject: [PATCH 340/362] fix: validate feeRate as a finite number >= 1 (#616) * fix: validate feeRate as a finite number >= 1 * fix lint error in test file with an error message to toThrow() --- .../bitcoin-wallet-snap/keyring.openrpc.json | 8 +- .../src/handlers/validation.test.ts | 91 +++++++++++++++++++ .../src/handlers/validation.ts | 14 ++- 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/keyring.openrpc.json b/merged-packages/bitcoin-wallet-snap/keyring.openrpc.json index fa2f2867..1e1bea97 100644 --- a/merged-packages/bitcoin-wallet-snap/keyring.openrpc.json +++ b/merged-packages/bitcoin-wallet-snap/keyring.openrpc.json @@ -21,7 +21,7 @@ "name": "feeRate", "description": "Optional fee rate in sat/vB.", "required": false, - "schema": { "type": "number" } + "schema": { "type": "number", "minimum": 1 } }, { "name": "options", @@ -65,7 +65,7 @@ "name": "feeRate", "description": "Optional fee rate in sat/vB.", "required": false, - "schema": { "type": "number" } + "schema": { "type": "number", "minimum": 1 } } ], "result": { @@ -95,7 +95,7 @@ "name": "feeRate", "description": "Optional fee rate in sat/vB.", "required": false, - "schema": { "type": "number" } + "schema": { "type": "number", "minimum": 1 } } ], "result": { @@ -159,7 +159,7 @@ "name": "feeRate", "description": "Optional fee rate in sat/vB.", "required": false, - "schema": { "type": "number" } + "schema": { "type": "number", "minimum": 1 } } ], "result": { diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts index fc2d7238..684f4bf7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts @@ -1,9 +1,14 @@ import type { AddressType } from '@metamask/bitcoindevkit'; import { mock } from 'jest-mock-extended'; +import { assert } from 'superstruct'; import type { BitcoinAccount } from '../entities'; import { + ComputeFeeRequest, + FillPsbtRequest, parseRewardsMessage, + SendTransferRequest, + SignPsbtRequest, validateDustLimit, validateSelectedAccounts, } from './validation'; @@ -19,6 +24,92 @@ jest.mock('@metamask/bitcoindevkit', () => ({ })); describe('validation', () => { + describe('feeRate request validation', () => { + const account = { account: { address: 'test-account-address' } }; + const optionalFeeRate = (feeRate?: number) => + feeRate === undefined ? {} : { feeRate }; + const requestCases: { + name: string; + assertParams: (feeRate?: number) => void; + }[] = [ + { + name: 'SignPsbtRequest', + assertParams: (feeRate?: number) => + assert( + { + ...account, + psbt: 'psbtBase64', + ...optionalFeeRate(feeRate), + options: { fill: false, broadcast: true }, + }, + SignPsbtRequest, + ), + }, + { + name: 'FillPsbtRequest', + assertParams: (feeRate?: number) => + assert( + { + ...account, + psbt: 'psbtBase64', + ...optionalFeeRate(feeRate), + }, + FillPsbtRequest, + ), + }, + { + name: 'ComputeFeeRequest', + assertParams: (feeRate?: number) => + assert( + { + ...account, + psbt: 'psbtBase64', + ...optionalFeeRate(feeRate), + }, + ComputeFeeRequest, + ), + }, + { + name: 'SendTransferRequest', + assertParams: (feeRate?: number) => + assert( + { + ...account, + recipients: [ + { + address: 'bcrt1qstku2y3pfh9av50lxj55arm8r5gj8tf2yv5nxz', + amount: '1000', + }, + ], + ...optionalFeeRate(feeRate), + }, + SendTransferRequest, + ), + }, + ]; + + describe.each(requestCases)('$name', ({ assertParams }) => { + it('accepts an omitted feeRate', () => { + expect(() => assertParams()).not.toThrow(); + }); + + it.each([1, 2.4, 3])('accepts feeRate %p', (feeRate) => { + expect(() => assertParams(feeRate)).not.toThrow(); + }); + + it.each([ + ['zero', 0], + ['below minimum', 0.5], + ['negative', -5], + ['NaN', NaN], + ['Infinity', Infinity], + ['-Infinity', -Infinity], + ])('rejects %s feeRate', (_description, feeRate) => { + expect(() => assertParams(feeRate)).toThrow('At path: feeRate'); + }); + }); + }); + describe('validateDustLimit', () => { const makeAccount = (addressType: AddressType) => mock({ addressType }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts index a2211864..c3db6667 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts @@ -99,10 +99,16 @@ const WalletAccountStruct = object({ address: string(), }); +export const FeeRateStruct = refine( + number(), + 'fee rate greater than or equal to 1', + (value) => Number.isFinite(value) && value >= 1, +); + export const SignPsbtRequest = object({ account: WalletAccountStruct, psbt: string(), - feeRate: optional(number()), + feeRate: optional(FeeRateStruct), options: object({ fill: boolean(), broadcast: boolean(), @@ -112,7 +118,7 @@ export const SignPsbtRequest = object({ export const ComputeFeeRequest = object({ account: WalletAccountStruct, psbt: string(), - feeRate: optional(number()), + feeRate: optional(FeeRateStruct), }); export const BroadcastPsbtRequest = object({ @@ -123,7 +129,7 @@ export const BroadcastPsbtRequest = object({ export const FillPsbtRequest = object({ account: WalletAccountStruct, psbt: string(), - feeRate: optional(number()), + feeRate: optional(FeeRateStruct), }); export const SendTransferRequest = object({ @@ -134,7 +140,7 @@ export const SendTransferRequest = object({ amount: string(), }), ), - feeRate: optional(number()), + feeRate: optional(FeeRateStruct), }); export const GetUtxoRequest = object({ From 76366e767a9543f66b871cf40809c2750dded57c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 20:00:54 +0200 Subject: [PATCH 341/362] 1.11.0 (#614) * 1.11.0 * docs: update CHANGELOG.md --------- Co-authored-by: github-actions Co-authored-by: Battambang --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 5 ++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 6d966589..08d2ada9 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.11.0] + ### Added - Add `canBeMalleable` flag to broadcast/send response shapes so consumers can detect when a transaction id may be rewritten by a third party before confirmation (relevant only for legacy P2PKH accounts, currently always `false`) ([#599](https://github.com/MetaMask/snap-bitcoin-wallet/pull/599)) @@ -589,7 +591,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.1...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.11.0...HEAD +[1.11.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.1...v1.11.0 [1.10.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.0...v1.10.1 [1.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.9.0...v1.10.0 [1.9.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.8.0...v1.9.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index e69c089f..d2ecb57a 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.10.1", + "version": "1.11.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 4ab63c2693ef1d3d4d55f0464cc97bb0ccd2e3eb Mon Sep 17 00:00:00 2001 From: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> Date: Thu, 28 May 2026 05:38:35 -0400 Subject: [PATCH 342/362] chore: add `createMany` use case (Batch accounts 3/5) (#610) --- .../bitcoin-wallet-snap/CHANGELOG.md | 5 + .../src/store/BdkAccountRepository.test.ts | 16 + .../src/use-cases/AccountUseCases.test.ts | 140 ++++++++ .../src/use-cases/AccountUseCases.ts | 315 ++++++++++++++---- 4 files changed, 414 insertions(+), 62 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 08d2ada9..2a9eb2f7 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,12 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `createMany` use case ([#610](https://github.com/MetaMask/snap-bitcoin-wallet/pull/610)) + ## [1.11.0] ### Added - Add `canBeMalleable` flag to broadcast/send response shapes so consumers can detect when a transaction id may be rewritten by a third party before confirmation (relevant only for legacy P2PKH accounts, currently always `false`) ([#599](https://github.com/MetaMask/snap-bitcoin-wallet/pull/599)) - Add `insertMany` to repository API ([#609](https://github.com/MetaMask/snap-bitcoin-wallet/pull/609)) +- Add `createMany` use case ([#610](https://github.com/MetaMask/snap-bitcoin-wallet/pull/610)) ### Changed diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts index 4f4f6fd9..32a8f7b4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts @@ -356,6 +356,22 @@ describe('BdkAccountRepository', () => { expect(mockSnapClient.setState).not.toHaveBeenCalled(); }); + it('throws an error if any account has no wallet data', async () => { + await expect( + repo.insertMany([ + { + ...mockAccount, + id: 'missing-wallet', + takeStaged: jest.fn().mockReturnValue(undefined), + }, + mockAccount, + ]), + ).rejects.toThrow( + 'Missing changeset data for account "missing-wallet" for insertion.', + ); + expect(mockSnapClient.setState).not.toHaveBeenCalled(); + }); + it('throws an error without consuming staged data if any account has no wallet data', async () => { const accountWithWalletData = mock({ id: 'some-id-1', diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 08b1d151..6ac4a570 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -291,6 +291,146 @@ describe('AccountUseCases', () => { }); }); + describe('createMany', () => { + const createParams: CreateAccountParams = { + network: 'bitcoin', + entropySource: 'some-source', + index: 1, + addressType: 'p2wpkh', + synchronize: false, + correlationId: 'correlation-id', + accountName: 'My account', + }; + const firstDerivationPath = ['some-source', "84'", "0'", "1'"]; + const secondDerivationPath = ['some-source', "84'", "0'", "2'"]; + const existingAccount = mock({ + id: 'existing-id', + network: createParams.network, + }); + const newAccount = mock({ + id: 'new-id', + network: createParams.network, + }); + + it('reuses existing accounts and bulk-inserts newly-created accounts', async () => { + mockRepository.getByDerivationPaths.mockResolvedValue([ + existingAccount, + null, + ]); + mockRepository.create.mockResolvedValue(newAccount); + + const result = await useCases.createMany([ + createParams, + { ...createParams, index: 2, synchronize: true }, + ]); + + expect(mockRepository.getByDerivationPaths).toHaveBeenCalledWith([ + firstDerivationPath, + secondDerivationPath, + ]); + expect(mockRepository.create).toHaveBeenCalledWith( + secondDerivationPath, + createParams.network, + createParams.addressType, + ); + expect(newAccount.revealNextAddress).toHaveBeenCalled(); + expect(mockRepository.insertMany).toHaveBeenCalledWith([newAccount]); + expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); + expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalledWith({ + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: newAccount.id }, + }); + expect(result).toStrictEqual([existingAccount, newAccount]); + }); + + it('creates only one account for duplicate derivation paths in the same batch', async () => { + mockRepository.getByDerivationPaths.mockResolvedValue([null]); + mockRepository.create.mockResolvedValue(newAccount); + + const result = await useCases.createMany([createParams, createParams]); + + expect(mockRepository.getByDerivationPaths).toHaveBeenCalledWith([ + firstDerivationPath, + ]); + expect(mockRepository.create).toHaveBeenCalledTimes(1); + expect(mockRepository.insertMany).toHaveBeenCalledWith([newAccount]); + expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); + expect(result).toStrictEqual([newAccount, newAccount]); + }); + + it('does not create or insert accounts when all accounts already exist', async () => { + mockRepository.getByDerivationPaths.mockResolvedValue([existingAccount]); + + const result = await useCases.createMany([createParams]); + + expect(mockRepository.create).not.toHaveBeenCalled(); + expect(mockRepository.insertMany).not.toHaveBeenCalled(); + expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); + expect(result).toStrictEqual([existingAccount]); + }); + + it('propagates insertMany errors without emitting account-created events', async () => { + const error = new Error('insertMany failed'); + mockRepository.getByDerivationPaths.mockResolvedValue([null]); + mockRepository.create.mockResolvedValue(newAccount); + mockRepository.insertMany.mockRejectedValue(error); + + await expect(useCases.createMany([createParams])).rejects.toBe(error); + + expect(mockRepository.insertMany).toHaveBeenCalledWith([newAccount]); + expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); + }); + + it('waits for in-flight creates before rejecting when one create fails', async () => { + const error = new Error('create failed'); + const slowAccount = mock({ + id: 'slow-id', + network: createParams.network, + }); + let resolveSlowCreate: (account: BitcoinAccount) => void = () => + undefined; + const slowCreate = new Promise((resolve) => { + resolveSlowCreate = resolve; + }); + const callOrder: string[] = []; + + mockRepository.getByDerivationPaths.mockResolvedValue([null, null]); + mockRepository.create + .mockImplementationOnce(async () => { + callOrder.push('create-1'); + throw error; + }) + .mockImplementationOnce(async () => { + callOrder.push('create-2'); + const account = await slowCreate; + callOrder.push('resolve-2'); + return account; + }); + + const createManyPromise = useCases.createMany([ + createParams, + { ...createParams, index: 2 }, + ]); + const onSettled = jest.fn(); + const settlementObserver = createManyPromise.then(onSettled, onSettled); + + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(onSettled).not.toHaveBeenCalled(); + + resolveSlowCreate(slowAccount); + + await expect(createManyPromise).rejects.toBe(error); + await settlementObserver; + expect(callOrder).toStrictEqual(['create-1', 'create-2', 'resolve-2']); + expect(mockRepository.insertMany).not.toHaveBeenCalled(); + expect(mockSnapClient.emitAccountCreatedEvent).not.toHaveBeenCalled(); + }); + }); + describe('discover', () => { const discoverParams: DiscoverAccountParams = { network: 'bitcoin', diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index b87d32ec..a491f1c2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -49,6 +49,83 @@ export type CreateAccountParams = DiscoverAccountParams & { accountName?: string; }; +// Snap entropy derivation can become very spiky under wider parallelism. +const CREATE_ACCOUNTS_CONCURRENCY = 2; + +/** + * @param req - Account creation or discovery request. + * @returns The BIP-44 account derivation path. + */ +function getAccountDerivationPath(req: DiscoverAccountParams): string[] { + return [ + req.entropySource, + `${addressTypeToPurpose[req.addressType]}'`, + `${networkToCoinType[req.network]}'`, + `${req.index}'`, + ]; +} + +/** + * @param derivationPath - Split derivation path. + * @returns Storage key for a derivation path. + */ +function getDerivationPathKey(derivationPath: string[]): string { + return derivationPath.join('/'); +} + +/** + * Map items to results with at most `concurrency` in-flight async operations. + * Output order matches `items` order. + * + * @param items - Values to map in pool order. + * @param concurrency - Maximum number of concurrent mapper executions. + * @param mapper - Async function applied to each item. + * @returns Results in the same order as `items`. + */ +async function runWithConcurrencyLimit( + items: readonly Item[], + concurrency: number, + mapper: (item: Item, index: number) => Promise, +): Promise { + if (items.length === 0) { + return []; + } + + const results: Result[] = new Array(items.length); + let next = 0; + let firstError: unknown; + let hasError = false; + + const worker = async (): Promise => { + while (!hasError) { + const idx = next; + next += 1; + if (idx >= items.length) { + return; + } + + try { + results[idx] = await mapper(items[idx] as Item, idx); + } catch (error) { + if (!hasError) { + firstError = error; + hasError = true; + } + return; + } + } + }; + + const poolSize = Math.min(Math.max(1, concurrency), items.length); + await Promise.all(Array.from({ length: poolSize }, async () => worker())); + + if (hasError) { + throw firstError; + } + + return results; +} + /** * Result of broadcasting a Bitcoin transaction. * @@ -89,6 +166,8 @@ export class AccountUseCases { readonly #targetBlocksConfirmation: number; + #accountMutationQueue: Promise = Promise.resolve(); + constructor( logger: Logger, snapClient: SnapClient, @@ -133,14 +212,8 @@ export class AccountUseCases { async discover(req: DiscoverAccountParams): Promise { this.#logger.debug('Discovering Bitcoin account. Request: %o', req); - const { addressType, index, network, entropySource } = req; - - const derivationPath = [ - entropySource, - `${addressTypeToPurpose[addressType]}'`, - `${networkToCoinType[network]}'`, - `${index}'`, - ]; + const { addressType, network } = req; + const derivationPath = getAccountDerivationPath(req); // Idempotent account creation + ensures only one account per derivation path const account = await this.#repository.getByDerivationPath(derivationPath); @@ -170,67 +243,165 @@ export class AccountUseCases { async create(req: CreateAccountParams): Promise { this.#logger.debug('Creating new Bitcoin account. Request: %o', req); - const { - addressType, - index, - network, - entropySource, - correlationId, - accountName, - synchronize, - } = req; - - const derivationPath = [ - entropySource, - `${addressTypeToPurpose[addressType]}'`, - `${networkToCoinType[network]}'`, - `${index}'`, - ]; + return this.#runAccountMutation(async () => { + const { addressType, network, correlationId, accountName, synchronize } = + req; + const derivationPath = getAccountDerivationPath(req); + + // Idempotent account creation + ensures only one account per derivation path + const account = + await this.#repository.getByDerivationPath(derivationPath); + if (account && account.network === network) { + this.#logger.debug('Account already exists: %s,', account.id); + await this.#snapClient.emitAccountCreatedEvent( + account, + correlationId, + accountName, + ); + return account; + } - // Idempotent account creation + ensures only one account per derivation path - const account = await this.#repository.getByDerivationPath(derivationPath); - if (account && account.network === network) { - this.#logger.debug('Account already exists: %s,', account.id); + const newAccount = await this.#repository.create( + derivationPath, + network, + addressType, + ); + + newAccount.revealNextAddress(); + + await this.#repository.insert(newAccount); + + // First notify the event has been created, then schedule full scan. await this.#snapClient.emitAccountCreatedEvent( - account, + newAccount, correlationId, accountName, ); - return account; + + if (synchronize) { + await this.#snapClient.scheduleBackgroundEvent({ + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: newAccount.id }, + }); + } + + this.#logger.info( + 'Bitcoin account created successfully: %s. Public address: %s, Request: %o', + newAccount.id, + newAccount.publicAddress, + req, + ); + return newAccount; + }); + } + + async createMany(reqs: CreateAccountParams[]): Promise { + if (reqs.length === 0) { + return []; } - const newAccount = await this.#repository.create( - derivationPath, - network, - addressType, - ); + const { accounts, createdAccountKeys } = await this.#runAccountMutation( + async () => { + const entries = reqs.map((req, index) => { + const derivationPath = getAccountDerivationPath(req); + return { + req, + index, + derivationPath, + pathKey: getDerivationPathKey(derivationPath), + }; + }); + + const uniqueEntriesByPath = new Map(); + for (const entry of entries) { + if (!uniqueEntriesByPath.has(entry.pathKey)) { + uniqueEntriesByPath.set(entry.pathKey, entry); + } + } + const uniqueEntries = [...uniqueEntriesByPath.values()]; + + const existingAccounts = await this.#repository.getByDerivationPaths( + uniqueEntries.map(({ derivationPath }) => derivationPath), + ); + const existingAccountsByPath = new Map(); + + uniqueEntries.forEach((entry, index) => { + const account = existingAccounts[index]; + if (account && account.network === entry.req.network) { + existingAccountsByPath.set(entry.pathKey, account); + } + }); - newAccount.revealNextAddress(); + const entriesToCreate = uniqueEntries.filter( + ({ pathKey }) => !existingAccountsByPath.has(pathKey), + ); + const newAccounts = await runWithConcurrencyLimit( + entriesToCreate, + CREATE_ACCOUNTS_CONCURRENCY, + async ({ derivationPath, req }) => { + const newAccount = await this.#repository.create( + derivationPath, + req.network, + req.addressType, + ); + newAccount.revealNextAddress(); + return newAccount; + }, + ); - await this.#repository.insert(newAccount); + if (newAccounts.length > 0) { + await this.#repository.insertMany(newAccounts); + } + + const newAccountsByPath = new Map( + entriesToCreate.map((entry, index) => [ + entry.pathKey, + newAccounts[index] as BitcoinAccount, + ]), + ); - // First notify the event has been created, then schedule full scan. - await this.#snapClient.emitAccountCreatedEvent( - newAccount, - correlationId, - accountName, + const accountsInOrder = entries.map((entry) => { + const account = + existingAccountsByPath.get(entry.pathKey) ?? + newAccountsByPath.get(entry.pathKey); + + if (!account) { + throw new AssertionError('Failed to create account', { + index: entry.index, + derivationPath: entry.derivationPath, + }); + } + + return account; + }); + + return { + accounts: accountsInOrder, + createdAccountKeys: new Set(newAccountsByPath.keys()), + }; + }, ); - if (synchronize) { - await this.#snapClient.scheduleBackgroundEvent({ - duration: 'PT1S', - method: CronMethod.FullScanAccount, - params: { accountId: newAccount.id }, - }); + const scheduledAccountIds = new Set(); + for (const [index, account] of accounts.entries()) { + const req = reqs[index] as CreateAccountParams; + const pathKey = getDerivationPathKey(getAccountDerivationPath(req)); + if ( + req.synchronize && + createdAccountKeys.has(pathKey) && + !scheduledAccountIds.has(account.id) + ) { + scheduledAccountIds.add(account.id); + await this.#snapClient.scheduleBackgroundEvent({ + duration: 'PT1S', + method: CronMethod.FullScanAccount, + params: { accountId: account.id }, + }); + } } - this.#logger.info( - 'Bitcoin account created successfully: %s. Public address: %s, Request: %o', - newAccount.id, - newAccount.publicAddress, - req, - ); - return newAccount; + return accounts; } async synchronize( @@ -355,15 +526,17 @@ export class AccountUseCases { async delete(id: string): Promise { this.#logger.debug('Deleting account: %s', id); - const account = await this.#repository.get(id); - if (!account) { - throw new NotFoundError('Account not found', { id }); - } + await this.#runAccountMutation(async () => { + const account = await this.#repository.get(id); + if (!account) { + throw new NotFoundError('Account not found', { id }); + } - await this.#snapClient.emitAccountDeletedEvent(id); - await this.#repository.delete(id); + await this.#snapClient.emitAccountDeletedEvent(id); + await this.#repository.delete(id); - this.#logger.info('Account deleted successfully: %s', account.id); + this.#logger.info('Account deleted successfully: %s', account.id); + }); } async fillPsbt( @@ -715,6 +888,24 @@ export class AccountUseCases { }; } + async #runAccountMutation( + fn: () => Promise, + ): Promise { + const previousMutation = this.#accountMutationQueue; + let releaseMutation: () => void = () => undefined; + this.#accountMutationQueue = new Promise((resolve) => { + releaseMutation = resolve; + }); + + await previousMutation; + + try { + return await fn(); + } finally { + releaseMutation(); + } + } + #checkCapability( account: BitcoinAccount, capability: AccountCapability, From 80a51c3c2e04ef95d0f03c3b775273e9c24be686 Mon Sep 17 00:00:00 2001 From: jeremytsng Date: Fri, 29 May 2026 10:13:32 +0700 Subject: [PATCH 343/362] feat: warn on self-sends in sendTransfer confirmation (#607) * fix: warn on self-sends in sendTransfer confirmation The dApp-initiated sendTransfer flow built ConfirmSendFormContext without checking whether the recipient address belonged to the sending account, so UnifiedSendFormView rendered self-sends as ordinary outgoing transfers. The signPsbt flow already runs account.isMine on each output. Bring sendTransfer to parity: derive isMine from the recipient's script_pubkey in JSXConfirmationRepository and surface a warning banner in the confirmation modal when it's true. * fix: extend self-send warning to in-app send flow and all locales Address codex audit findings on the previous commit: - isMine was optional and only populated by the dApp keyring path; the in-app SendFlowUseCases.confirmSendFlow renders the same UnifiedSendFormView and was missing the warning. Make isMine required on ConfirmSendFormContext and derive it from the recipient script in both producers. - The new self-send copy was only added to en.json, so non-English locales would render the raw {key} placeholder. Add the keys to every locale file with the English text as a placeholder until translations land. - Adjust the warning copy to acknowledge that self-sends still pay network fees. - Regenerate the snap manifest shasum after the source changes. * docs: add changelog entry for self-send confirmation warning Also regenerates snap.manifest.json shasum to match the current bundle output. --------- Co-authored-by: Jeremy <261848901+jeremy-consensys@users.noreply.github.com> --- .../bitcoin-wallet-snap/CHANGELOG.md | 1 + .../bitcoin-wallet-snap/locales/de.json | 6 +++ .../bitcoin-wallet-snap/locales/el.json | 6 +++ .../bitcoin-wallet-snap/locales/en.json | 6 +++ .../bitcoin-wallet-snap/locales/es.json | 6 +++ .../bitcoin-wallet-snap/locales/es_419.json | 6 +++ .../bitcoin-wallet-snap/locales/fr.json | 6 +++ .../bitcoin-wallet-snap/locales/hi.json | 6 +++ .../bitcoin-wallet-snap/locales/id.json | 6 +++ .../bitcoin-wallet-snap/locales/ja.json | 6 +++ .../bitcoin-wallet-snap/locales/ko.json | 6 +++ .../bitcoin-wallet-snap/locales/pt.json | 6 +++ .../bitcoin-wallet-snap/locales/ru.json | 6 +++ .../bitcoin-wallet-snap/locales/tl.json | 6 +++ .../bitcoin-wallet-snap/locales/tr.json | 6 +++ .../bitcoin-wallet-snap/locales/vi.json | 6 +++ .../bitcoin-wallet-snap/locales/zh_CN.json | 6 +++ .../bitcoin-wallet-snap/messages.json | 6 +++ .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/entities/send-flow.ts | 1 + .../unified-send-flow/UnifiedSendFormView.tsx | 13 ++++++ .../store/JSXConfirmationRepository.test.tsx | 46 +++++++++++++++++++ .../src/store/JSXConfirmationRepository.tsx | 12 +++++ .../src/use-cases/SendFlowUseCases.test.ts | 31 +++++++++++++ .../src/use-cases/SendFlowUseCases.ts | 12 +++++ 25 files changed, 219 insertions(+), 1 deletion(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 2a9eb2f7..42141f83 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Show a self-send warning in the send confirmation when the recipient address belongs to the sending account ([#607](https://github.com/MetaMask/snap-bitcoin-wallet/pull/607)) - Show a confirmation dialog before signing a PSBT from KeyringHandler and sending a transfer ([#591](https://github.com/MetaMask/snap-bitcoin-wallet/pull/591)) - Add `resolveAccountAddress` method to KeyringHandler for dApp connectivity ([#590](https://github.com/MetaMask/snap-bitcoin-wallet/pull/590)) - Bump `@metamask/keyring-api` from `^21.3.0` to `^22.0.0` ([#608](https://github.com/MetaMask/snap-bitcoin-wallet/pull/608)) diff --git a/merged-packages/bitcoin-wallet-snap/locales/de.json b/merged-packages/bitcoin-wallet-snap/locales/de.json index 127d28ba..dddeb658 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/de.json +++ b/merged-packages/bitcoin-wallet-snap/locales/de.json @@ -208,6 +208,12 @@ "confirmation.signAndSendTransaction.title": { "message": "Transaktionsanfrage" }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, "confirmation.requestOrigin": { "message": "Anfrage von" } diff --git a/merged-packages/bitcoin-wallet-snap/locales/el.json b/merged-packages/bitcoin-wallet-snap/locales/el.json index 672318ab..0eff56d1 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/el.json +++ b/merged-packages/bitcoin-wallet-snap/locales/el.json @@ -208,6 +208,12 @@ "confirmation.signAndSendTransaction.title": { "message": "Αίτημα συναλλαγής" }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, "confirmation.requestOrigin": { "message": "Ζητήθηκε από" } diff --git a/merged-packages/bitcoin-wallet-snap/locales/en.json b/merged-packages/bitcoin-wallet-snap/locales/en.json index 0542ef89..5c8c69f1 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/en.json +++ b/merged-packages/bitcoin-wallet-snap/locales/en.json @@ -211,6 +211,12 @@ "confirmation.signAndSendTransaction.title": { "message": "Transaction request" }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, "confirmation.requestOrigin": { "message": "Request from" }, diff --git a/merged-packages/bitcoin-wallet-snap/locales/es.json b/merged-packages/bitcoin-wallet-snap/locales/es.json index 0079d88d..371cda0d 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/es.json +++ b/merged-packages/bitcoin-wallet-snap/locales/es.json @@ -208,6 +208,12 @@ "confirmation.signAndSendTransaction.title": { "message": "Solicitud de transacción" }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, "confirmation.requestOrigin": { "message": "Solicitud de" } diff --git a/merged-packages/bitcoin-wallet-snap/locales/es_419.json b/merged-packages/bitcoin-wallet-snap/locales/es_419.json index 9b75a07a..e3c29cbb 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/es_419.json +++ b/merged-packages/bitcoin-wallet-snap/locales/es_419.json @@ -208,6 +208,12 @@ "confirmation.signAndSendTransaction.title": { "message": "Solicitud de transacción" }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, "confirmation.requestOrigin": { "message": "Solicitud de" } diff --git a/merged-packages/bitcoin-wallet-snap/locales/fr.json b/merged-packages/bitcoin-wallet-snap/locales/fr.json index 21c549a7..23950195 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/fr.json +++ b/merged-packages/bitcoin-wallet-snap/locales/fr.json @@ -208,6 +208,12 @@ "confirmation.signAndSendTransaction.title": { "message": "Demande de transaction" }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, "confirmation.requestOrigin": { "message": "Demande de la part de" } diff --git a/merged-packages/bitcoin-wallet-snap/locales/hi.json b/merged-packages/bitcoin-wallet-snap/locales/hi.json index dbc3ba3e..f5ef7c5a 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/hi.json +++ b/merged-packages/bitcoin-wallet-snap/locales/hi.json @@ -208,6 +208,12 @@ "confirmation.signAndSendTransaction.title": { "message": "ट्रांसेक्शन रिक्वेस्ट" }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, "confirmation.requestOrigin": { "message": "इनसे मिला अनुरोध" } diff --git a/merged-packages/bitcoin-wallet-snap/locales/id.json b/merged-packages/bitcoin-wallet-snap/locales/id.json index 67997e2b..4c4b9354 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/id.json +++ b/merged-packages/bitcoin-wallet-snap/locales/id.json @@ -208,6 +208,12 @@ "confirmation.signAndSendTransaction.title": { "message": "Permintaan transaksi" }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, "confirmation.requestOrigin": { "message": "Permintaan dari" } diff --git a/merged-packages/bitcoin-wallet-snap/locales/ja.json b/merged-packages/bitcoin-wallet-snap/locales/ja.json index 1232b8e5..476db885 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/ja.json +++ b/merged-packages/bitcoin-wallet-snap/locales/ja.json @@ -208,6 +208,12 @@ "confirmation.signAndSendTransaction.title": { "message": "トランザクションリクエスト" }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, "confirmation.requestOrigin": { "message": "要求元" } diff --git a/merged-packages/bitcoin-wallet-snap/locales/ko.json b/merged-packages/bitcoin-wallet-snap/locales/ko.json index 6c7fd74a..3d3305e1 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/ko.json +++ b/merged-packages/bitcoin-wallet-snap/locales/ko.json @@ -178,6 +178,12 @@ "confirmation.signAndSendTransaction.title": { "message": "트랜잭션 요청" }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, "confirmation.requestOrigin": { "message": "요청자:" } diff --git a/merged-packages/bitcoin-wallet-snap/locales/pt.json b/merged-packages/bitcoin-wallet-snap/locales/pt.json index 6d3191a4..38609f32 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/pt.json +++ b/merged-packages/bitcoin-wallet-snap/locales/pt.json @@ -178,6 +178,12 @@ "confirmation.signAndSendTransaction.title": { "message": "Solicitação de transação" }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, "confirmation.requestOrigin": { "message": "Solicitação de" } diff --git a/merged-packages/bitcoin-wallet-snap/locales/ru.json b/merged-packages/bitcoin-wallet-snap/locales/ru.json index e5604ceb..e0be0afa 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/ru.json +++ b/merged-packages/bitcoin-wallet-snap/locales/ru.json @@ -178,6 +178,12 @@ "confirmation.signAndSendTransaction.title": { "message": "Запрос транзакции" }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, "confirmation.requestOrigin": { "message": "Запрос от" } diff --git a/merged-packages/bitcoin-wallet-snap/locales/tl.json b/merged-packages/bitcoin-wallet-snap/locales/tl.json index 4b6b1829..2d671764 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/tl.json +++ b/merged-packages/bitcoin-wallet-snap/locales/tl.json @@ -178,6 +178,12 @@ "confirmation.signAndSendTransaction.title": { "message": "Hiling na transaksyon" }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, "confirmation.requestOrigin": { "message": "Kahilingan mula sa/kay" } diff --git a/merged-packages/bitcoin-wallet-snap/locales/tr.json b/merged-packages/bitcoin-wallet-snap/locales/tr.json index 280a7a67..4ec3aab3 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/tr.json +++ b/merged-packages/bitcoin-wallet-snap/locales/tr.json @@ -178,6 +178,12 @@ "confirmation.signAndSendTransaction.title": { "message": "İşlem talebi" }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, "confirmation.requestOrigin": { "message": "Talebi gönderen" } diff --git a/merged-packages/bitcoin-wallet-snap/locales/vi.json b/merged-packages/bitcoin-wallet-snap/locales/vi.json index ef2f934a..a2e9a273 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/vi.json +++ b/merged-packages/bitcoin-wallet-snap/locales/vi.json @@ -178,6 +178,12 @@ "confirmation.signAndSendTransaction.title": { "message": "Yêu cầu giao dịch" }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, "confirmation.requestOrigin": { "message": "Yêu cầu từ" } diff --git a/merged-packages/bitcoin-wallet-snap/locales/zh_CN.json b/merged-packages/bitcoin-wallet-snap/locales/zh_CN.json index 32d90701..253957bd 100644 --- a/merged-packages/bitcoin-wallet-snap/locales/zh_CN.json +++ b/merged-packages/bitcoin-wallet-snap/locales/zh_CN.json @@ -178,6 +178,12 @@ "confirmation.signAndSendTransaction.title": { "message": "交易请求" }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, "confirmation.requestOrigin": { "message": "请求来自" } diff --git a/merged-packages/bitcoin-wallet-snap/messages.json b/merged-packages/bitcoin-wallet-snap/messages.json index 1af59a2c..5ae58ba5 100644 --- a/merged-packages/bitcoin-wallet-snap/messages.json +++ b/merged-packages/bitcoin-wallet-snap/messages.json @@ -209,6 +209,12 @@ "confirmation.signAndSendTransaction.title": { "message": "Transaction request" }, + "confirmation.signAndSendTransaction.selfSend.title": { + "message": "Sending to your own address" + }, + "confirmation.signAndSendTransaction.selfSend.description": { + "message": "The recipient address belongs to this wallet. The amount will stay in your account, but network fees still apply." + }, "confirmation.requestOrigin": { "message": "Request from" }, diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 0d576bac..36096269 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "1jbPT93y4v8JjV5Sj8zdfhhk9Eq1GJrTjSjciRYsl4c=", + "shasum": "ULNv7Bt/BuxJkLiIo7x79kO0vVLtnZj8CxGoa2bEQ+M=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts index 659a62da..bee8546b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/send-flow.ts @@ -17,6 +17,7 @@ export type ConfirmSendFormContext = { locale: string; psbt: string; origin?: string; + isMine: boolean; }; export type SendFormContext = { diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx index b283b771..774cd145 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/unified-send-flow/UnifiedSendFormView.tsx @@ -2,6 +2,7 @@ import { Psbt } from '@metamask/bitcoindevkit'; import type { SnapComponent } from '@metamask/snaps-sdk/jsx'; import { Address, + Banner, Heading, Link, Section, @@ -43,6 +44,7 @@ export const UnifiedSendFormView: SnapComponent = ({ recipient, explorerUrl, origin, + isMine, } = context; const psbt = Psbt.from_string(context.psbt); @@ -60,6 +62,17 @@ export const UnifiedSendFormView: SnapComponent = ({ {null} + {isMine ? ( + + + {t('confirmation.signAndSendTransaction.selfSend.description')} + + + ) : null} +
diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx index 37b61f4e..0498115c 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx @@ -28,6 +28,7 @@ import { UnifiedSendFormView } from '../infra/jsx/unified-send-flow'; jest.mock('@metamask/bitcoindevkit', () => ({ Address: { from_script: jest.fn(), + from_string: jest.fn(), }, })); @@ -110,10 +111,12 @@ describe('JSXConfirmationRepository', () => { }); describe('insertSendTransfer', () => { + const mockRecipientScript = mock(); const mockAccount = mock({ id: 'account-id', network: 'bitcoin', publicAddress: mock
({ toString: () => 'fromAddress' }), + isMine: () => false, }); const mockPsbt = mock({ toString: () => 'serialized-psbt', @@ -132,6 +135,9 @@ describe('JSXConfirmationRepository', () => { mockRatesClient.spotPrices.mockResolvedValue( mock({ price: 50000 }), ); + MockedBdkAddress.from_string.mockReturnValue( + mock
({ script_pubkey: mockRecipientScript }), + ); }); it('creates and displays a send transfer interface', async () => { @@ -151,6 +157,7 @@ describe('JSXConfirmationRepository', () => { locale: 'en', psbt: 'serialized-psbt', origin, + isMine: false, }; expect(mockSnapClient.getPreferences).toHaveBeenCalled(); @@ -170,6 +177,44 @@ describe('JSXConfirmationRepository', () => { ); }); + it('marks the recipient as isMine when it belongs to the account', async () => { + const selfSendAccount = mock({ + id: 'account-id', + network: 'bitcoin', + publicAddress: mock
({ toString: () => 'fromAddress' }), + isMine: () => true, + }); + + await repo.insertSendTransfer( + selfSendAccount, + mockPsbt, + recipient, + origin, + ); + + expect(MockedBdkAddress.from_string).toHaveBeenCalledWith( + recipient.address, + selfSendAccount.network, + ); + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ isMine: true }), + ); + }); + + it('defaults isMine to false when the recipient address fails to parse', async () => { + MockedBdkAddress.from_string.mockImplementation(() => { + throw new Error('Invalid address'); + }); + + await repo.insertSendTransfer(mockAccount, mockPsbt, recipient, origin); + + expect(mockSnapClient.createInterface).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ isMine: false }), + ); + }); + it('throws UserActionError if the user cancels', async () => { mockSnapClient.displayConfirmation.mockResolvedValue(false); await expect( @@ -182,6 +227,7 @@ describe('JSXConfirmationRepository', () => { id: 'account-id', network: 'testnet', publicAddress: mock
({ toString: () => 'fromAddress' }), + isMine: () => false, }); await repo.insertSendTransfer( diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx index c3f88a06..291440c3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx @@ -88,6 +88,17 @@ export class JSXConfirmationRepository implements ConfirmationRepository { const { locale, currency: fiatCurrency } = await this.#snapClient.getPreferences(); + let isMine = false; + try { + const recipientScript = BdkAddress.from_string( + recipient.address, + account.network, + ).script_pubkey; + isMine = account.isMine(recipientScript); + } catch { + isMine = false; + } + const context: ConfirmSendFormContext = { from: account.publicAddress.toString(), explorerUrl: this.#chainClient.getExplorerUrl(account.network), @@ -99,6 +110,7 @@ export class JSXConfirmationRepository implements ConfirmationRepository { locale, psbt: psbt.toString(), origin, + isMine, }; const messages = await this.#translator.load(locale); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index bedb81c7..0b2bbf19 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -986,6 +986,37 @@ describe('SendFlowUseCases', () => { expect(result).toBe(mockTransaction); }); + it('marks the recipient as isMine when the address belongs to the account', async () => { + const recipientScript = {} as any; + (Address.from_string as jest.Mock).mockReturnValue({ + script_pubkey: recipientScript, + }); + mockAccount.isMine.mockReturnValue(true); + + await useCases.confirmSendFlow(mockAccount, amount, toAddress); + + expect(Address.from_string).toHaveBeenCalledWith( + toAddress, + mockAccount.network, + ); + expect(mockAccount.isMine).toHaveBeenCalledWith(recipientScript); + expect(mockSendFlowRepository.insertConfirmSendForm).toHaveBeenCalledWith( + expect.objectContaining({ isMine: true }), + ); + }); + + it('defaults isMine to false when the recipient address fails to parse', async () => { + (Address.from_string as jest.Mock).mockImplementation(() => { + throw new Error('Invalid address'); + }); + + await useCases.confirmSendFlow(mockAccount, amount, toAddress); + + expect(mockSendFlowRepository.insertConfirmSendForm).toHaveBeenCalledWith( + expect.objectContaining({ isMine: false }), + ); + }); + it('builds a drain transaction when amount equals balance', async () => { const balanceAmount = mock(); balanceAmount.to_sat.mockReturnValue(BigInt(10000)); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index 30c81b35..f837eac3 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -112,6 +112,17 @@ export class SendFlowUseCases { const psbt = templatePsbt.finish(); const currency = networkToCurrencyUnit[account.network]; + let isMine = false; + try { + const recipientScript = Address.from_string( + toAddress, + account.network, + ).script_pubkey; + isMine = account.isMine(recipientScript); + } catch { + isMine = false; + } + // TODO: add all the necessary properties we need here const context: ConfirmSendFormContext = { from: account.publicAddress.toString(), @@ -123,6 +134,7 @@ export class SendFlowUseCases { exchangeRate: await this.#getExchangeRate(account.network, fiatCurrency), network: account.network, locale, + isMine, }; const interfaceId = From 6f1deb4d68bddf9551ad84a8f8ed395b95333bf0 Mon Sep 17 00:00:00 2001 From: jeremytsng Date: Fri, 29 May 2026 15:03:42 +0700 Subject: [PATCH 344/362] fix: show filled PSBT in signPsbt confirmation (#606) * fix: show filled PSBT in signPsbt confirmation The signPsbt confirmation dialog was previously rendered from the unfilled template PSBT supplied by the dApp, while the PSBT that actually got signed was filled with the dApp-supplied feeRate. A malicious dApp could therefore display a benign template to the user and sign a divergent transaction. Fill the PSBT before opening the confirmation, then sign with fill: false so the displayed and signed transactions match. * chore: add changelog entry for signPsbt confirmation fix --------- Co-authored-by: Jeremy <261848901+jeremy-consensys@users.noreply.github.com> --- .../bitcoin-wallet-snap/CHANGELOG.md | 4 + .../handlers/KeyringRequestHandler.test.ts | 104 +++++++++++++++++- .../src/handlers/KeyringRequestHandler.ts | 18 ++- 3 files changed, 119 insertions(+), 7 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 42141f83..aff8520f 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `createMany` use case ([#610](https://github.com/MetaMask/snap-bitcoin-wallet/pull/610)) +### Fixed + +- Show the filled PSBT in the `signPsbt` confirmation dialog so the displayed transaction matches the one being signed ([#606](https://github.com/MetaMask/snap-bitcoin-wallet/pull/606)) + ## [1.11.0] ### Added diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts index 9d6a8af0..affd08e4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.test.ts @@ -111,6 +111,7 @@ describe('KeyringRequestHandler', () => { SignPsbtRequest, ); expect(mockAccountsUseCases.get).toHaveBeenCalledWith('account-id'); + expect(mockAccountsUseCases.fillPsbt).not.toHaveBeenCalled(); expect(mockConfirmationRepository.insertSignPsbt).toHaveBeenCalledWith( mockAccount, mockPsbt, @@ -121,7 +122,7 @@ describe('KeyringRequestHandler', () => { 'account-id', mockPsbt, 'metamask', - mockOptions, + { fill: false, broadcast: true }, 3, ); expect(result).toStrictEqual({ @@ -194,6 +195,77 @@ describe('KeyringRequestHandler', () => { }); }); + it('fills the PSBT before showing the confirmation when options.fill is true', async () => { + const fillOptions = { fill: true, broadcast: true }; + const fillRequest = mock({ + origin, + request: { + method: AccountCapability.SignPsbt, + params: { + ...accountParam, + psbt: 'psbtBase64', + feeRate: 3, + options: fillOptions, + }, + }, + account: 'account-id', + }); + + const filledPsbt = mock({ + toString: jest.fn().mockReturnValue('filledPsbtBase64'), + }); + const psbtForConfirmation = mock(); + const psbtForSigning = mock(); + mockAccountsUseCases.fillPsbt.mockResolvedValue(filledPsbt); + mockAccountsUseCases.signPsbt.mockResolvedValue({ + psbt: 'signedPsbtBase64', + txid: mock({ + toString: jest.fn().mockReturnValue('txid'), + }), + canBeMalleable: false, + }); + jest + .mocked(parsePsbt) + .mockReturnValueOnce(mockPsbt) + .mockReturnValueOnce(psbtForConfirmation) + .mockReturnValueOnce(psbtForSigning); + + await handler.route(fillRequest); + + const fillOrder = + mockAccountsUseCases.fillPsbt.mock.invocationCallOrder[0]; + const insertOrder = + mockConfirmationRepository.insertSignPsbt.mock.invocationCallOrder[0]; + const signOrder = + mockAccountsUseCases.signPsbt.mock.invocationCallOrder[0]; + + expect(fillOrder).toBeLessThan(insertOrder as number); + expect(insertOrder).toBeLessThan(signOrder as number); + + expect(mockAccountsUseCases.fillPsbt).toHaveBeenCalledWith( + 'account-id', + mockPsbt, + 3, + ); + expect(parsePsbt).toHaveBeenNthCalledWith(1, 'psbtBase64'); + expect(parsePsbt).toHaveBeenNthCalledWith(2, 'filledPsbtBase64'); + expect(parsePsbt).toHaveBeenNthCalledWith(3, 'filledPsbtBase64'); + expect(mockConfirmationRepository.insertSignPsbt).toHaveBeenCalledWith( + mockAccount, + psbtForConfirmation, + 'metamask', + fillOptions, + ); + expect(mockAccountsUseCases.signPsbt).toHaveBeenCalledWith( + 'account-id', + psbtForSigning, + 'metamask', + { fill: false, broadcast: true }, + 3, + ); + expect(psbtForSigning).not.toBe(psbtForConfirmation); + }); + it('returns null txid when signPsbt result has no txid', async () => { mockAccountsUseCases.signPsbt.mockResolvedValue({ psbt: 'psbtBase64', @@ -220,6 +292,30 @@ describe('KeyringRequestHandler', () => { expect(mockAccountsUseCases.signPsbt).not.toHaveBeenCalled(); }); + it('does not show confirmation or sign if fillPsbt fails', async () => { + const fillOptions = { fill: true, broadcast: true }; + const fillRequest = mock({ + origin, + request: { + method: AccountCapability.SignPsbt, + params: { + ...accountParam, + psbt: 'psbtBase64', + feeRate: 3, + options: fillOptions, + }, + }, + account: 'account-id', + }); + const error = new Error('fee rate too high'); + mockAccountsUseCases.fillPsbt.mockRejectedValue(error); + + await expect(handler.route(fillRequest)).rejects.toThrow(error); + + expect(mockConfirmationRepository.insertSignPsbt).not.toHaveBeenCalled(); + expect(mockAccountsUseCases.signPsbt).not.toHaveBeenCalled(); + }); + it('propagates errors from parsePsbt', async () => { const error = new Error('parsePsbt'); jest.mocked(parsePsbt).mockImplementationOnce(() => { @@ -231,7 +327,11 @@ describe('KeyringRequestHandler', () => { ...mockRequest, request: { ...mockRequest.request, - params: { ...accountParam, psbt: 'invalidPsbt' }, + params: { + ...accountParam, + psbt: 'invalidPsbt', + options: mockOptions, + }, }, }), ).rejects.toThrow(error); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts index 13fc720f..170ec887 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringRequestHandler.ts @@ -128,26 +128,34 @@ export class KeyringRequestHandler { options: { fill: boolean; broadcast: boolean }, feeRate?: number, ): Promise { - const psbt = parsePsbt(psbtBase64); const account = await this.#accountsUseCases.get(id); + const psbtBase64ToSign = options.fill + ? ( + await this.#accountsUseCases.fillPsbt( + id, + parsePsbt(psbtBase64), + feeRate, + ) + ).toString() + : psbtBase64; + await this.#confirmationRepository.insertSignPsbt( account, - psbt, + parsePsbt(psbtBase64ToSign), origin, options, ); - // Creates a fresh PSBT from the original base64 because the original PSBT is mutated by the confirmation repository const { psbt: signedPsbt, txid, canBeMalleable, } = await this.#accountsUseCases.signPsbt( id, - parsePsbt(psbtBase64), + parsePsbt(psbtBase64ToSign), origin, - options, + { ...options, fill: false }, feeRate, ); // Invariant: signPsbt sets txid and canBeMalleable together (when broadcast From 79438395f4fdeb20ccb36c017552bba2d3e4ec35 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:21:37 +0800 Subject: [PATCH 345/362] 1.12.0 (#619) * 1.12.0 * docs: update CHANGELOG.md --------- Co-authored-by: github-actions Co-authored-by: Jeremy <261848901+jeremy-consensys@users.noreply.github.com> --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 5 ++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index aff8520f..c9ab94db 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.12.0] + ### Added - Add `createMany` use case ([#610](https://github.com/MetaMask/snap-bitcoin-wallet/pull/610)) @@ -601,7 +603,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.11.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.12.0...HEAD +[1.12.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.11.0...v1.12.0 [1.11.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.1...v1.11.0 [1.10.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.0...v1.10.1 [1.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.9.0...v1.10.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index d2ecb57a..941dde04 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.11.0", + "version": "1.12.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From a7a7b71d419162f3c8bdf83166b973fdee372213 Mon Sep 17 00:00:00 2001 From: jeremytsng Date: Tue, 2 Jun 2026 19:54:22 +0700 Subject: [PATCH 346/362] Revert "1.12.0 (#619)" (#621) This reverts commit 79438395f4fdeb20ccb36c017552bba2d3e4ec35. Co-authored-by: Jeremy <261848901+jeremy-consensys@users.noreply.github.com> --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 5 +---- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index c9ab94db..aff8520f 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,8 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.12.0] - ### Added - Add `createMany` use case ([#610](https://github.com/MetaMask/snap-bitcoin-wallet/pull/610)) @@ -603,8 +601,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.12.0...HEAD -[1.12.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.11.0...v1.12.0 +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.11.0...HEAD [1.11.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.1...v1.11.0 [1.10.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.0...v1.10.1 [1.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.9.0...v1.10.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 941dde04..d2ecb57a 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.12.0", + "version": "1.11.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 6b44131747e5e9530bb2b26ba60a3743b3003853 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:34:30 +0800 Subject: [PATCH 347/362] 1.12.0 (#622) * 1.12.0 * docs: clean up auto-generated changelog entries --------- Co-authored-by: github-actions Co-authored-by: Jeremy <261848901+jeremy-consensys@users.noreply.github.com> --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 5 ++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index aff8520f..c9ab94db 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.12.0] + ### Added - Add `createMany` use case ([#610](https://github.com/MetaMask/snap-bitcoin-wallet/pull/610)) @@ -601,7 +603,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.11.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.12.0...HEAD +[1.12.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.11.0...v1.12.0 [1.11.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.1...v1.11.0 [1.10.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.0...v1.10.1 [1.10.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.9.0...v1.10.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index d2ecb57a..941dde04 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.11.0", + "version": "1.12.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 0b246f07ed8c4f54fe7c29d51b3352bf9f8b2135 Mon Sep 17 00:00:00 2001 From: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:19:54 -0400 Subject: [PATCH 348/362] feat: wire `keyring_createAccounts` (Batch accounts 4/5) (#611) --- .../bitcoin-wallet-snap/CHANGELOG.md | 4 + .../src/handlers/KeyringHandler.test.ts | 302 +++++++++++++++++- .../src/handlers/KeyringHandler.ts | 119 ++++++- 3 files changed, 421 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index c9ab94db..8f1391f5 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `createMany` use case ([#610](https://github.com/MetaMask/snap-bitcoin-wallet/pull/610)) +### Changed + +- Enable `keyring_createAccounts` ([#611](https://github.com/MetaMask/snap-bitcoin-wallet/pull/611)) + ### Fixed - Show the filled PSBT in the `signPsbt` confirmation dialog so the displayed transaction matches the one being signed ([#606](https://github.com/MetaMask/snap-bitcoin-wallet/pull/606)) diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index cfa50356..1efa056f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -11,12 +11,17 @@ import type { import { Address } from '@metamask/bitcoindevkit'; import type { DiscoveredAccount, + KeyringAccount, KeyringResponse, Transaction as KeyringTransaction, KeyringRequest, - KeyringAccount, } from '@metamask/keyring-api'; -import { BtcAccountType, BtcMethod, BtcScope } from '@metamask/keyring-api'; +import { + AccountCreationType, + BtcAccountType, + BtcMethod, + BtcScope, +} from '@metamask/keyring-api'; import { mock } from 'jest-mock-extended'; import { assert } from 'superstruct'; @@ -57,6 +62,17 @@ jest.mock('@metamask/bitcoindevkit', () => { }; }); +/** + * Narrows `T | undefined` after `expect(value).toBeDefined()` for use in tests. + * + * @param value - Possibly undefined value. + * @returns The same value narrowed to `T`. + */ +function expectDefined(value: T | undefined): T { + expect(value).toBeDefined(); + return value as T; +} + describe('KeyringHandler', () => { const mockKeyringRequest = mock(); const mockAccounts = mock(); @@ -102,6 +118,7 @@ describe('KeyringHandler', () => { const correlationId = 'correlation-id'; // non-P2WPKH address types as we are not supporting them for v1 + // eslint-disable-next-line jest/no-disabled-tests it.skip('respects provided params', async () => { const options = { scope: BtcScope.Signet, @@ -132,6 +149,7 @@ describe('KeyringHandler', () => { }); // only P2WPKH (BIP-84) derivation paths are now supported for v1 + // eslint-disable-next-line jest/no-disabled-tests it.skip('extracts index from derivationPath', async () => { const options = { scope: BtcScope.Signet, @@ -231,6 +249,7 @@ describe('KeyringHandler', () => { ); // skip non-P2WPKH address types as they are not supported on v1 + // eslint-disable-next-line jest/no-disabled-tests it.skip.each([ { purpose: Purpose.Legacy, addressType: 'p2pkh' }, { purpose: Purpose.Segwit, addressType: 'p2sh' }, @@ -399,6 +418,285 @@ describe('KeyringHandler', () => { }); }); + describe('createAccounts', () => { + const entropySource = 'some-source'; + + const mnemonicGroupIndex = (account: KeyringAccount): number => { + const { entropy } = account.options; + if (entropy?.type !== 'mnemonic') { + throw new Error('expected mnemonic entropy'); + } + return entropy.groupIndex; + }; + + const buildMockAccount = (index: number): BitcoinAccount => + mock({ + id: `id-${index}`, + addressType: 'p2wpkh', + balance: { trusted_spendable: { to_btc: () => 1 } }, + network: 'bitcoin', + derivationPath: ['myEntropy', "84'", "0'", `${index}'`], + entropySource: 'myEntropy', + accountIndex: index, + publicAddress: mockAddress, + capabilities: [ + AccountCapability.SignPsbt, + AccountCapability.ComputeFee, + ], + }); + + it('creates a single account for Bip44DeriveIndex', async () => { + const bitcoinAccount = buildMockAccount(2); + mockAccounts.createMany.mockResolvedValue([bitcoinAccount]); + + const result = await handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + groupIndex: 2, + entropySource, + }); + + expect(mockAccounts.createMany).toHaveBeenCalledTimes(1); + expect(mockAccounts.createMany).toHaveBeenCalledWith([ + { + network: 'bitcoin', + entropySource, + index: 2, + addressType: 'p2wpkh', + synchronize: false, + }, + ]); + expect(result).toHaveLength(1); + const keyringAccount = expectDefined(result[0]); + expect(keyringAccount.id).toBe('id-2'); + expect(mnemonicGroupIndex(keyringAccount)).toBe(2); + }); + + it('creates an inclusive range of accounts for Bip44DeriveIndexRange', async () => { + mockAccounts.createMany.mockResolvedValue( + [0, 1, 2].map(buildMockAccount), + ); + + const result = await handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + range: { from: 0, to: 2 }, + entropySource, + }); + + expect(mockAccounts.createMany).toHaveBeenCalledTimes(1); + expect(mockAccounts.createMany).toHaveBeenCalledWith( + [0, 1, 2].map((index) => ({ + network: 'bitcoin', + entropySource, + index, + addressType: 'p2wpkh', + synchronize: false, + })), + ); + expect(result).toHaveLength(3); + expect(result.map((a) => mnemonicGroupIndex(a))).toStrictEqual([0, 1, 2]); + }); + + it('creates one account when range from and to are equal', async () => { + mockAccounts.createMany.mockResolvedValue([buildMockAccount(9)]); + + const result = await handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + range: { from: 9, to: 9 }, + entropySource, + }); + + expect(mockAccounts.createMany).toHaveBeenCalledTimes(1); + expect(mockAccounts.createMany).toHaveBeenCalledWith([ + expect.objectContaining({ index: 9 }), + ]); + expect(result).toHaveLength(1); + expect(mnemonicGroupIndex(expectDefined(result[0]))).toBe(9); + }); + + it('creates accounts for a non-zero index range', async () => { + mockAccounts.createMany.mockResolvedValue( + [10, 11, 12].map(buildMockAccount), + ); + + await handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + range: { from: 10, to: 12 }, + entropySource, + }); + + expect(mockAccounts.createMany).toHaveBeenCalledWith( + [10, 11, 12].map((index) => expect.objectContaining({ index })), + ); + }); + + it('allows the maximum batch size of 100 accounts in one RPC', async () => { + const indices = Array.from({ length: 100 }, (_, index) => index); + mockAccounts.createMany.mockResolvedValue(indices.map(buildMockAccount)); + + const result = await handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + range: { from: 0, to: 99 }, + entropySource, + }); + + expect(mockAccounts.createMany).toHaveBeenCalledTimes(1); + expect(mockAccounts.createMany).toHaveBeenCalledWith( + indices.map((index) => expect.objectContaining({ index })), + ); + expect(result).toHaveLength(100); + expect(mnemonicGroupIndex(expectDefined(result[0]))).toBe(0); + expect(mnemonicGroupIndex(expectDefined(result[99]))).toBe(99); + }); + + it('returns accounts in the order returned by createMany', async () => { + mockAccounts.createMany.mockResolvedValue( + [0, 1, 2, 3, 4].map(buildMockAccount), + ); + + const result = await handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + range: { from: 0, to: 4 }, + entropySource, + }); + + expect(result.map((a) => mnemonicGroupIndex(a))).toStrictEqual([ + 0, 1, 2, 3, 4, + ]); + }); + + it('rejects when the handler default address type is not P2WPKH', async () => { + const handlerNonSegwit = new KeyringHandler( + mockKeyringRequest, + mockAccounts, + 'p2tr' as AddressType, + mockSnapClient, + mockLogger, + ); + + await expect( + handlerNonSegwit.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + groupIndex: 0, + entropySource, + }), + ).rejects.toThrow( + /Only native segwit \(P2WPKH\) addresses are supported/iu, + ); + expect(mockAccounts.createMany).not.toHaveBeenCalled(); + }); + + it('rejects an invalid index range when from is greater than to', async () => { + await expect( + handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + range: { from: 2, to: 0 }, + entropySource, + }), + ).rejects.toThrow(/invalid.*range|from must be/iu); + expect(mockAccounts.createMany).not.toHaveBeenCalled(); + }); + + it.each([ + { from: -1, to: 0 }, + { from: 0.5, to: 1 }, + { from: 0, to: Number.MAX_SAFE_INTEGER + 1 }, + ])('rejects invalid account index bounds %#', async (range) => { + await expect( + handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + range, + entropySource, + }), + ).rejects.toThrow(/non-negative integers/iu); + expect(mockAccounts.createMany).not.toHaveBeenCalled(); + }); + + it('splits requests larger than 100 accounts into internal batches', async () => { + mockAccounts.createMany.mockImplementation(async (requests) => + requests.map(({ index }) => buildMockAccount(index)), + ); + + const result = await handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndexRange, + range: { from: 0, to: 100 }, + entropySource, + }); + + expect(mockAccounts.createMany).toHaveBeenCalledTimes(2); + expect(mockAccounts.createMany).toHaveBeenNthCalledWith( + 1, + Array.from({ length: 100 }, (_, index) => + expect.objectContaining({ index }), + ), + ); + expect(mockAccounts.createMany).toHaveBeenNthCalledWith(2, [ + expect.objectContaining({ index: 100 }), + ]); + expect(result).toHaveLength(101); + expect( + result.map((account) => mnemonicGroupIndex(account)), + ).toStrictEqual(Array.from({ length: 101 }, (_, index) => index)); + }); + + it('rejects unsupported creation types', async () => { + await expect( + handler.createAccounts({ + type: AccountCreationType.Bip44DerivePath, + derivationPath: "m/84'/0'/0'", + entropySource, + }), + ).rejects.toThrow(/not supported|unsupported/iu); + expect(mockAccounts.createMany).not.toHaveBeenCalled(); + }); + + it('propagates errors from createMany', async () => { + const error = new Error('create error'); + mockAccounts.createMany.mockRejectedValue(error); + + await expect( + handler.createAccounts({ + type: AccountCreationType.Bip44DeriveIndex, + groupIndex: 0, + entropySource, + }), + ).rejects.toThrow(error); + }); + + describe('tracing', () => { + const options = { + type: AccountCreationType.Bip44DeriveIndex as const, + groupIndex: 0, + entropySource, + }; + + beforeEach(() => { + mockSnapClient.startTrace.mockResolvedValue(undefined); + mockSnapClient.endTrace.mockResolvedValue(undefined); + mockAccounts.createMany.mockResolvedValue([buildMockAccount(0)]); + }); + + it('calls startTrace and endTrace with correct trace name', async () => { + await handler.createAccounts(options); + + expect(mockSnapClient.startTrace).toHaveBeenCalledWith( + 'Create Bitcoin Accounts Batch', + ); + expect(mockSnapClient.endTrace).toHaveBeenCalledWith( + 'Create Bitcoin Accounts Batch', + ); + }); + + it('calls endTrace even if create fails', async () => { + mockAccounts.createMany.mockRejectedValue(new Error('boom')); + + await expect(handler.createAccounts(options)).rejects.toThrow('boom'); + expect(mockSnapClient.endTrace).toHaveBeenCalledWith( + 'Create Bitcoin Accounts Batch', + ); + }); + }); + }); + describe('discoverAccounts', () => { const entropySource = 'some-source'; const groupIndex = 0; diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 3e6d1023..52731e96 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -1,9 +1,12 @@ import type { AddressType } from '@metamask/bitcoindevkit'; import { Amount } from '@metamask/bitcoindevkit'; import { + AccountCreationType, + assertCreateAccountOptionIsSupported, BtcAccountType, BtcScope, CreateAccountRequestStruct, + CreateAccountsRequestStruct, DeleteAccountRequestStruct, DiscoverAccountsRequestStruct, FilterAccountChainsStruct, @@ -19,6 +22,7 @@ import { SubmitRequestRequestStruct, } from '@metamask/keyring-api'; import type { + CreateAccountOptions, Keyring, KeyringAccount, KeyringResponse, @@ -44,14 +48,16 @@ import { string, } from 'superstruct'; -import type { BitcoinAccount, Logger, SnapClient } from '../entities'; import { + type BitcoinAccount, computeDisplayBalanceSats, FormatError, InexistentMethodError, + type Logger, networkToCurrencyUnit, Purpose, purposeToAddressType, + type SnapClient, } from '../entities'; import { networkToCaip19, @@ -68,7 +74,10 @@ import { mapToTransaction, } from './mappings'; import { BtcWalletRequestStruct, validateSelectedAccounts } from './validation'; -import type { AccountUseCases } from '../use-cases/AccountUseCases'; +import type { + AccountUseCases, + CreateAccountParams, +} from '../use-cases/AccountUseCases'; import { runSnapActionSafely } from '../utils/snapHelpers'; export const CreateAccountRequest = object({ @@ -82,6 +91,9 @@ export const CreateAccountRequest = object({ ...MetaMaskOptionsStruct.schema, }); +/** Maximum number of accounts to create in one internal createMany call. */ +const MAX_CREATE_ACCOUNTS_PER_BATCH = 100; + export class KeyringHandler implements Keyring { readonly #accountsUseCases: AccountUseCases; @@ -121,6 +133,10 @@ export class KeyringHandler implements Keyring { assert(request, CreateAccountRequestStruct); return this.createAccount(request.params.options); } + case `${KeyringRpcMethod.CreateAccounts}`: { + assert(request, CreateAccountsRequestStruct); + return this.createAccounts(request.params.options); + } case `${KeyringRpcMethod.DiscoverAccounts}`: { assert(request, DiscoverAccountsRequestStruct); return this.discoverAccounts( @@ -289,6 +305,105 @@ export class KeyringHandler implements Keyring { } } + async createAccounts( + options: CreateAccountOptions, + ): Promise { + assertCreateAccountOptionIsSupported(options, [ + `${AccountCreationType.Bip44DeriveIndex}`, + `${AccountCreationType.Bip44DeriveIndexRange}`, + ]); + + const { entropySource } = options; + + const range = + options.type === AccountCreationType.Bip44DeriveIndex + ? { from: options.groupIndex, to: options.groupIndex } + : options.range; + + if ( + !Number.isSafeInteger(range.from) || + !Number.isSafeInteger(range.to) || + range.from < 0 || + range.to < 0 + ) { + throw new FormatError( + 'Account index range is invalid: from and to must be non-negative integers', + ); + } + + if (range.from > range.to) { + throw new FormatError( + 'Account index range is invalid: from must be less than or equal to to', + ); + } + + // Only P2WPKH (BIP-84) on bitcoin mainnet is supported for v1, mirroring + // the defaults used by `createAccount` when no scope is provided. + const network = scopeToNetwork[BtcScope.Mainnet]; + const addressType = this.#defaultAddressType; + if (addressType !== 'p2wpkh') { + throw new FormatError( + 'Only native segwit (P2WPKH) addresses are supported', + ); + } + + const traceName = 'Create Bitcoin Accounts Batch'; + let traceStarted = false; + + try { + await runSnapActionSafely( + async () => { + await this.#snapClient.startTrace(traceName); + traceStarted = true; + }, + this.#logger, + 'startTrace', + ); + + // `AccountUseCases.createMany` is idempotent: if an account already exists + // for the resolved derivation path, it will be returned as-is. + const accounts: BitcoinAccount[] = []; + let chunkFrom = range.from; + + while (chunkFrom <= range.to) { + const chunkTo = Math.min( + chunkFrom + MAX_CREATE_ACCOUNTS_PER_BATCH - 1, + range.to, + ); + const chunkRequests: CreateAccountParams[] = []; + + for (let index = chunkFrom; index <= chunkTo; index += 1) { + chunkRequests.push({ + network, + entropySource, + index, + addressType, + synchronize: false, + }); + } + + accounts.push( + ...(await this.#accountsUseCases.createMany(chunkRequests)), + ); + + if (chunkTo === range.to) { + break; + } + chunkFrom = chunkTo + 1; + } + + return accounts.map(mapToKeyringAccount); + } finally { + if (traceStarted) { + await runSnapActionSafely( + async () => this.#snapClient.endTrace(traceName), + this.#logger, + 'endTrace', + ); + } + } + } + async discoverAccounts( scopes: BtcScope[], entropySource: string, From f0240ca3d671002c971fe2d35875ffab0949eb72 Mon Sep 17 00:00:00 2001 From: Gustavo Antunes <17601467+gantunesr@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:16:06 -0400 Subject: [PATCH 349/362] refactor: cache account response metadata (Batch accounts 5/5) (#612) --- .../bitcoin-wallet-snap/CHANGELOG.md | 4 + .../bitcoin-wallet-snap/src/entities/snap.ts | 15 +- .../src/infra/StoredAccountAdapter.ts | 185 ++++++++++++++++++ .../bitcoin-wallet-snap/src/infra/index.ts | 1 + .../src/store/BdkAccountRepository.test.ts | 70 +++++++ .../src/store/BdkAccountRepository.ts | 31 ++- 6 files changed, 301 insertions(+), 5 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 8f1391f5..0be98b36 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Cache account response metadata to avoid loading full BDK wallets for faster checks ([#612](https://github.com/MetaMask/snap-bitcoin-wallet/pull/612/changes)) + ## [1.12.0] ### Added diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index 8d150ce3..f84bcfa0 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -1,4 +1,4 @@ -import type { WalletTx } from '@metamask/bitcoindevkit'; +import type { AddressType, Network, WalletTx } from '@metamask/bitcoindevkit'; import type { JsonSLIP10Node, SLIP10Node } from '@metamask/key-tree'; import type { ComponentOrElement, @@ -25,6 +25,19 @@ export type AccountState = { wallet: string; // Wallet inscriptions for meta protocols (ordinals, etc.) inscriptions: Inscription[]; + // Metadata used by keyring account responses without loading the BDK wallet. + metadata?: AccountMetadata; +}; + +export type AccountMetadata = { + // Public receive address at account address index 0. + address: string; + // Account address type. + addressType: AddressType; + // Bitcoin network. + network: Network; + // Public descriptor for read-only descriptor requests. + publicDescriptor: string; }; export type SyncResult = { diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts new file mode 100644 index 00000000..ae7c9d57 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/StoredAccountAdapter.ts @@ -0,0 +1,185 @@ +import type { + AddressInfo, + Amount, + Balance, + FullScanRequest, + LocalOutput, + Psbt, + ScriptBuf, + SyncRequest, + Transaction, + Update, + WalletTx, + ChangeSet, + AddressType, + Network, +} from '@metamask/bitcoindevkit'; +import { Address } from '@metamask/bitcoindevkit'; + +import { + AccountCapability, + WalletError, + type AccountMetadata, + type AccountState, + type BitcoinAccount, + type TransactionBuilder, +} from '../entities'; + +type AccountStateWithMetadata = AccountState & { + metadata: AccountMetadata; +}; + +export class StoredAccountAdapter implements BitcoinAccount { + readonly #id: string; + + readonly #derivationPath: string[]; + + readonly #metadata: AccountMetadata; + + readonly #capabilities: AccountCapability[]; + + constructor(id: string, account: AccountStateWithMetadata) { + this.#id = id; + this.#derivationPath = account.derivationPath; + this.#metadata = account.metadata; + this.#capabilities = Object.values(AccountCapability); + } + + static canLoad(account: AccountState): account is AccountStateWithMetadata { + return Boolean(account.metadata); + } + + static load( + id: string, + account: AccountStateWithMetadata, + ): StoredAccountAdapter { + return new StoredAccountAdapter(id, account); + } + + get id(): string { + return this.#id; + } + + get derivationPath(): string[] { + return this.#derivationPath; + } + + get entropySource(): string { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.#derivationPath[0]!; + } + + get accountIndex(): number { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const segment = this.#derivationPath[3]!; + const numericPart = segment.endsWith("'") ? segment.slice(0, -1) : segment; + return Number(numericPart); + } + + get balance(): Balance { + return this.#unsupported(); + } + + get addressType(): AddressType { + return this.#metadata.addressType; + } + + get network(): Network { + return this.#metadata.network; + } + + get publicAddress(): Address { + return Address.from_string(this.#metadata.address, this.#metadata.network); + } + + get publicDescriptor(): string { + return this.#metadata.publicDescriptor; + } + + get capabilities(): AccountCapability[] { + return this.#capabilities; + } + + peekAddress(_index: number): AddressInfo { + return this.#unsupported(); + } + + nextUnusedAddress(): AddressInfo { + return this.#unsupported(); + } + + revealNextAddress(): AddressInfo { + return this.#unsupported(); + } + + startFullScan(): FullScanRequest { + return this.#unsupported(); + } + + startSync(): SyncRequest { + return this.#unsupported(); + } + + applyUpdate(_update: Update): void { + this.#unsupported(); + } + + takeStaged(): ChangeSet | undefined { + return this.#unsupported(); + } + + hasStaged(): boolean { + return false; + } + + buildTx(): TransactionBuilder { + return this.#unsupported(); + } + + sign(_psbt: Psbt): Psbt { + return this.#unsupported(); + } + + extractTransaction(_psbt: Psbt, _maxFeeRate?: number): Transaction { + return this.#unsupported(); + } + + getUtxo(_outpoint: string): LocalOutput | undefined { + return this.#unsupported(); + } + + listUnspent(): LocalOutput[] { + return this.#unsupported(); + } + + listTransactions(): WalletTx[] { + return this.#unsupported(); + } + + getTransaction(_txid: string): WalletTx | undefined { + return this.#unsupported(); + } + + calculateFee(_tx: Transaction): Amount { + return this.#unsupported(); + } + + isMine(_script: ScriptBuf): boolean { + return this.#unsupported(); + } + + sentAndReceived(_tx: Transaction): [Amount, Amount] { + return this.#unsupported(); + } + + applyUnconfirmedTx(_tx: Transaction, _lastSeen: number): void { + this.#unsupported(); + } + + #unsupported(): never { + throw new WalletError( + 'Stored account metadata cannot be used for wallet operations; load the full account first.', + { id: this.#id }, + ); + } +} diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/index.ts b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts index dbc4a2f3..fa4ce4fd 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/index.ts @@ -1,4 +1,5 @@ export * from './BdkAccountAdapter'; +export * from './StoredAccountAdapter'; export * from './SnapClientAdapter'; export * from './EsploraClientAdapter'; export * from './PriceApiClientAdapter'; diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts index 32a8f7b4..c4a30e2b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.test.ts @@ -3,6 +3,7 @@ import type { DescriptorPair } from '@metamask/bitcoindevkit'; import { + Address, ChangeSet, xpriv_to_descriptor, xpub_to_descriptor, @@ -26,6 +27,9 @@ jest.mock('@metamask/bitcoindevkit', () => { ChangeSet: { from_json: jest.fn(), }, + Address: { + from_string: jest.fn(), + }, slip10_to_extended: jest.fn().mockReturnValue('mock-extended'), xpub_to_descriptor: jest.fn(), xpriv_to_descriptor: jest.fn(), @@ -57,11 +61,16 @@ describe('BdkAccountRepository', () => { derivationPath: mockDerivationPath, }); const mockChangeSet = mock(); + const mockAddress = mock
({ + toString: () => 'bc1qaddress...', + }); const mockAccount = mock({ id: 'some-id', derivationPath: mockDerivationPath, network: 'bitcoin', addressType: 'p2wpkh', + publicAddress: mockAddress, + publicDescriptor: 'mock-public-descriptor', }); const repo = new BdkAccountRepository(mockSnapClient); @@ -74,6 +83,7 @@ describe('BdkAccountRepository', () => { mockSnapClient.getPublicEntropy.mockResolvedValue(mockSlip10Node); (xpriv_to_descriptor as jest.Mock).mockReturnValue(mockDescriptors); (xpub_to_descriptor as jest.Mock).mockReturnValue(mockDescriptors); + jest.mocked(Address.from_string).mockReturnValue(mockAddress); (mockAccount.takeStaged as jest.Mock) = jest .fn() .mockReturnValue(mockChangeSet); @@ -234,6 +244,40 @@ describe('BdkAccountRepository', () => { expect(mockSnapClient.setState).not.toHaveBeenCalled(); }); + it('uses cached account metadata without loading BDK wallets', async () => { + const accountStateWithMetadata: AccountState = { + ...accountState1, + metadata: { + address: 'bc1qcached...', + addressType: 'p2wpkh', + network: 'bitcoin', + publicDescriptor: 'cached-public-descriptor', + }, + }; + mockSnapClient.getState + .mockResolvedValueOnce({ + "m/84'/0'/1'": 'some-id-1', + }) + .mockResolvedValueOnce({ + 'some-id-1': accountStateWithMetadata, + }); + (BdkAccountAdapter.load as jest.Mock).mockClear(); + (ChangeSet.from_json as jest.Mock).mockClear(); + + const result = await repo.getByDerivationPaths([derivationPath1]); + const account = result[0]; + + expect(account?.id).toBe('some-id-1'); + expect(account?.publicAddress.toString()).toBe('bc1qaddress...'); + expect(account?.publicDescriptor).toBe('cached-public-descriptor'); + expect(jest.mocked(Address.from_string)).toHaveBeenCalledWith( + 'bc1qcached...', + 'bitcoin', + ); + expect(ChangeSet.from_json).not.toHaveBeenCalled(); + expect(BdkAccountAdapter.load).not.toHaveBeenCalled(); + }); + it('repairs missing derivation path indexes from account state', async () => { mockSnapClient.getState .mockResolvedValueOnce({ @@ -342,6 +386,12 @@ describe('BdkAccountRepository', () => { wallet: mockWalletData, inscriptions: [], derivationPath: mockDerivationPath, + metadata: { + address: 'bc1qaddress...', + addressType: 'p2wpkh', + network: 'bitcoin', + publicDescriptor: 'mock-public-descriptor', + }, }, ); }); @@ -415,9 +465,17 @@ describe('BdkAccountRepository', () => { const account1 = mock(); account1.id = 'some-id-1'; account1.derivationPath = ['m', "84'", "0'", "1'"]; + account1.network = 'bitcoin'; + account1.addressType = 'p2wpkh'; + account1.publicAddress = mockAddress; + account1.publicDescriptor = 'mock-public-descriptor-1'; const account2 = mock(); account2.id = 'some-id-2'; account2.derivationPath = ['m', "84'", "0'", "2'"]; + account2.network = 'bitcoin'; + account2.addressType = 'p2wpkh'; + account2.publicAddress = mockAddress; + account2.publicDescriptor = 'mock-public-descriptor-2'; (account1.takeStaged as jest.Mock) = jest .fn() .mockReturnValue(mockChangeSet); @@ -443,11 +501,23 @@ describe('BdkAccountRepository', () => { wallet: mockWalletData, inscriptions: [], derivationPath: ['m', "84'", "0'", "1'"], + metadata: { + address: 'bc1qaddress...', + addressType: 'p2wpkh', + network: 'bitcoin', + publicDescriptor: 'mock-public-descriptor-1', + }, }, 'some-id-2': { wallet: mockWalletData, inscriptions: [], derivationPath: ['m', "84'", "0'", "2'"], + metadata: { + address: 'bc1qaddress...', + addressType: 'p2wpkh', + network: 'bitcoin', + publicDescriptor: 'mock-public-descriptor-2', + }, }, }); expect(mockSnapClient.setState).toHaveBeenNthCalledWith( diff --git a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts index c1ec5c26..252970ed 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts +++ b/merged-packages/bitcoin-wallet-snap/src/store/BdkAccountRepository.ts @@ -16,10 +16,11 @@ import { type SnapClient, type Inscription, type AccountState, + type AccountMetadata, type SnapState, StorageError, } from '../entities'; -import { BdkAccountAdapter } from '../infra'; +import { BdkAccountAdapter, StoredAccountAdapter } from '../infra'; /** * Encode a fingerprint to a 4-bytes hex-string (required by the BDK). @@ -42,10 +43,23 @@ function getDerivationPathKey(derivationPath: string[]): string { return derivationPath.join('/'); } +/** + * @param account - Account to persist. + * @returns Metadata needed for keyring account responses. + */ +function getAccountMetadata(account: BitcoinAccount): AccountMetadata { + return { + address: account.publicAddress.toString(), + addressType: account.addressType, + network: account.network, + publicDescriptor: account.publicDescriptor, + }; +} + /** * @param account - Account to persist. * @param walletData - Serialized wallet data. - * @returns Account state. + * @returns Account state with cached response metadata. */ function getAccountState( account: BitcoinAccount, @@ -55,6 +69,7 @@ function getAccountState( wallet: walletData.to_json(), inscriptions: [], derivationPath: account.derivationPath, + metadata: getAccountMetadata(account), }; } @@ -138,7 +153,7 @@ export class BdkAccountRepository implements BitcoinAccountRepository { const indexedAccount = indexedId ? accountsById[indexedId] : null; if (indexedId && indexedAccount) { - return this.#loadAccount(indexedId, indexedAccount); + return this.#loadPersistedAccount(indexedId, indexedAccount); } const fallback = accountsByDerivationPath.get(pathKey); @@ -148,7 +163,7 @@ export class BdkAccountRepository implements BitcoinAccountRepository { const [id, account] = fallback; repairs[pathKey] = id; - return this.#loadAccount(id, account); + return this.#loadPersistedAccount(id, account); }); if (Object.keys(repairs).length > 0) { @@ -380,4 +395,12 @@ export class BdkAccountRepository implements BitcoinAccountRepository { ChangeSet.from_json(account.wallet), ); } + + #loadPersistedAccount(id: string, account: AccountState): BitcoinAccount { + if (StoredAccountAdapter.canLoad(account)) { + return StoredAccountAdapter.load(id, account); + } + + return this.#loadAccount(id, account); + } } From 150118e2d0a873ae7c4dd7a9842c5cb486f072f7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:40:58 -0400 Subject: [PATCH 350/362] 1.13.0 (#625) --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 5 ++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 0be98b36..d52bd524 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.13.0] + ### Changed - Cache account response metadata to avoid loading full BDK wallets for faster checks ([#612](https://github.com/MetaMask/snap-bitcoin-wallet/pull/612/changes)) @@ -611,7 +613,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.12.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.13.0...HEAD +[1.13.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.12.0...v1.13.0 [1.12.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.11.0...v1.12.0 [1.11.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.1...v1.11.0 [1.10.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.0...v1.10.1 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 941dde04..73066a4e 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.12.0", + "version": "1.13.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 8dd63035877049e14102d1d45d120fe7841dbc06 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:54:34 +0200 Subject: [PATCH 351/362] chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#620) * chore(deps): bump the npm_and_yarn group across 2 directories with 1 update Bumps the npm_and_yarn group with 1 update in the /packages/site directory: [uuid](https://github.com/uuidjs/uuid). Bumps the npm_and_yarn group with 1 update in the /packages/snap directory: [uuid](https://github.com/uuidjs/uuid). Updates `uuid` from 11.1.1 to 14.0.0 - [Release notes](https://github.com/uuidjs/uuid/releases) - [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md) - [Commits](https://github.com/uuidjs/uuid/compare/v11.1.1...v14.0.0) Updates `uuid` from 11.1.1 to 14.0.0 - [Release notes](https://github.com/uuidjs/uuid/releases) - [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md) - [Commits](https://github.com/uuidjs/uuid/compare/v11.1.1...v14.0.0) --- updated-dependencies: - dependency-name: uuid dependency-version: 14.0.0 dependency-type: direct:production - dependency-name: uuid dependency-version: 14.0.0 dependency-type: direct:development ... Signed-off-by: dependabot[bot] * fix: update yarn.lock with uuid package Signed-off-by: Frederic HENG --------- Signed-off-by: dependabot[bot] Signed-off-by: Frederic HENG Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Frederic HENG --- 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 73066a4e..97fed967 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -58,7 +58,7 @@ "jest-transform-stub": "2.0.0", "superstruct": "^2.0.2", "ts-jest": "^29.4.1", - "uuid": "^11.1.0", + "uuid": "^14.0.0", "wif": "^5.0.0" }, "publishConfig": { From f8759e875889c79ba170cf55fb48c7afe5d34962 Mon Sep 17 00:00:00 2001 From: Mathieu Artu Date: Fri, 12 Jun 2026 10:50:17 +0200 Subject: [PATCH 352/362] feat: add `signProofOfOwnership` method for silent proof-of-ownership signing (#626) Introduces a no-prompt signing RPC gated by strict message format and address binding, which is security-sensitive though aligned with existing signRewardsMessage patterns. Overview Adds a new signProofOfOwnership RPC that returns a silent BIP-322 signature (no user confirmation) for plaintext messages metamask:proof-of-ownership:{nonce}:{address}. The handler parses and validates the message, canonicalizes bech32 addresses (lowercase) before network validation and equality checks against the signing account, then calls signMessage with skipConfirmation: true. New helpers parseProofOfOwnershipMessage and canonicalizeBitcoinAddress back this flow, with unit and handler tests. Changelog documents the method; snap.manifest.json updates bundle shasum and platformVersion to 11.1.1 (package version stays 1.13.0). --- .../bitcoin-wallet-snap/CHANGELOG.md | 5 + .../bitcoin-wallet-snap/snap.manifest.json | 6 +- .../src/handlers/RpcHandler.test.ts | 136 ++++++++++++++++++ .../src/handlers/RpcHandler.ts | 72 ++++++++++ .../src/handlers/validation.test.ts | 89 ++++++++++++ .../src/handlers/validation.ts | 69 +++++++++ 6 files changed, 374 insertions(+), 3 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index d52bd524..98f62361 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `signProofOfOwnership` client request method ([#626](https://github.com/MetaMask/snap-bitcoin-wallet/pull/626)) + - This method silently signs `metamask:proof-of-ownership::
` messages with the account's BIP-322 signer + ## [1.13.0] ### Changed diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 36096269..66e34c7f 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.10.1", + "version": "1.13.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "ULNv7Bt/BuxJkLiIo7x79kO0vVLtnZj8CxGoa2bEQ+M=", + "shasum": "7GSBhdWnQOP8h+FoCeEtq9XioCJMVPWT+r0cIylafrg=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -92,6 +92,6 @@ ] } }, - "platformVersion": "10.3.0", + "platformVersion": "11.1.1", "manifestVersion": "0.1" } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts index 27cad4a5..597d5faf 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.test.ts @@ -1117,4 +1117,140 @@ describe('RpcHandler', () => { ); }); }); + + describe('signProofOfOwnership', () => { + const accountAddress = 'bc1qwl8399fz829uqvqly9tcatgrgtwp3udnhxfq4k'; + const nonce = 'a1b2c3d4e5f6789012345678'; + + const mockBitcoinAccount = mock({ + id: validAccountId, + publicAddress: { + toString: () => accountAddress, + }, + network: 'bitcoin', + }); + + const buildRequest = ( + message: string, + accountId: string = validAccountId, + ): JsonRpcRequest => ({ + jsonrpc: '2.0', + id: '1', + method: RpcMethod.SignProofOfOwnership, + params: { accountId, message }, + }); + + beforeEach(() => { + jest + .mocked(Address.from_string) + .mockReturnValue(mockBitcoinAccount.publicAddress); + }); + + it('signs the proof message and returns the BIP-322 signature', async () => { + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount); + const signMessageSpy = jest + .spyOn(mockAccountsUseCases, 'signMessage' as keyof AccountUseCases) + .mockResolvedValue('mock-bip322-signature' as never); + + const message = `metamask:proof-of-ownership:${nonce}:${accountAddress}`; + const result = await handler.route(origin, buildRequest(message)); + + expect(mockAccountsUseCases.get).toHaveBeenCalledWith(validAccountId); + expect(Address.from_string).toHaveBeenCalledWith( + accountAddress, + 'bitcoin', + ); + expect(signMessageSpy).toHaveBeenCalledWith( + validAccountId, + message, + 'metamask', + { skipConfirmation: true }, + ); + expect(result).toStrictEqual({ signature: 'mock-bip322-signature' }); + }); + + it('canonicalizes mixed-case bech32 addresses before validating and comparing', async () => { + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount); + jest + .spyOn(mockAccountsUseCases, 'signMessage' as keyof AccountUseCases) + .mockResolvedValue('mock-bip322-signature' as never); + + const uppercased = accountAddress.toUpperCase(); + const message = `metamask:proof-of-ownership:${nonce}:${uppercased}`; + + const result = await handler.route(origin, buildRequest(message)); + + // BDK's `Address.from_string` rejects mixed-case bech32 per BIP-173. + // Asserting it was called with the lowercase form (and never the + // uppercase input) is what guards against the canonicalization step + // being moved back after `validateAddress`. + expect(Address.from_string).toHaveBeenCalledWith( + accountAddress, + 'bitcoin', + ); + expect(Address.from_string).not.toHaveBeenCalledWith( + uppercased, + 'bitcoin', + ); + expect(result).toStrictEqual({ signature: 'mock-bip322-signature' }); + }); + + it('throws when the message does not start with the proof prefix', async () => { + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount); + + await expect( + handler.route( + origin, + buildRequest(`rewards,${accountAddress},1736660000`), + ), + ).rejects.toThrow('Message must start with'); + }); + + it('throws when the account is not found', async () => { + mockAccountsUseCases.get.mockResolvedValue( + null as unknown as BitcoinAccount, + ); + + const message = `metamask:proof-of-ownership:${nonce}:${accountAddress}`; + await expect( + handler.route(origin, buildRequest(message, 'missing')), + ).rejects.toThrow('Account not found'); + }); + + it('throws when the embedded address is invalid for the account network', async () => { + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount); + jest.mocked(Address.from_string).mockImplementationOnce(() => { + throw new Error('invalid address'); + }); + + const message = `metamask:proof-of-ownership:${nonce}:${accountAddress}`; + await expect( + handler.route(origin, buildRequest(message)), + ).rejects.toThrow( + 'Invalid Bitcoin address in proof-of-ownership message', + ); + }); + + it('throws when the embedded address does not match the signing account', async () => { + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount); + const otherAddress = 'bc1qdifferentaddress123456789abcdefgh'; + const message = `metamask:proof-of-ownership:${nonce}:${otherAddress}`; + + await expect( + handler.route(origin, buildRequest(message)), + ).rejects.toThrow('does not match signing account address'); + }); + + it('propagates errors from the signer', async () => { + mockAccountsUseCases.get.mockResolvedValue(mockBitcoinAccount); + jest + .spyOn(mockAccountsUseCases, 'signMessage' as keyof AccountUseCases) + .mockRejectedValue(new Error('signer unavailable') as never); + + const message = `metamask:proof-of-ownership:${nonce}:${accountAddress}`; + await expect( + handler.route(origin, buildRequest(message)), + ).rejects.toThrow('signer unavailable'); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index 3443c6a2..6f3c8393 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -35,6 +35,8 @@ import { validateAccountBalance, validateDustLimit, parseRewardsMessage, + parseProofOfOwnershipMessage, + canonicalizeBitcoinAddress, } from './validation'; export const CreateSendFormRequest = object({ @@ -73,6 +75,11 @@ export const SignRewardsMessageRequest = object({ message: string(), }); +export const SignProofOfOwnershipRequest = object({ + accountId: string(), + message: string(), +}); + export class RpcHandler { readonly #logger: Logger; @@ -137,6 +144,10 @@ export class RpcHandler { assert(params, SignRewardsMessageRequest); return this.#signRewardsMessage(params.accountId, params.message); } + case RpcMethod.SignProofOfOwnership: { + assert(params, SignProofOfOwnershipRequest); + return this.#signProofOfOwnership(params.accountId, params.message); + } default: throw new InexistentMethodError(`Method not found: ${method}`); @@ -370,4 +381,65 @@ export class RpcHandler { return { signature }; } + + /** + * Handles the signing of a proof-of-ownership message, of format 'metamask:proof-of-ownership:{nonce}:{address}'. + * + * @param accountId - The ID of the account to sign with + * @param message - The plaintext proof-of-ownership message + * @returns The signature + * @throws {ValidationError} If the account is not found or if the address in the message doesn't match the signing account + */ + async #signProofOfOwnership( + accountId: string, + message: string, + ): Promise<{ signature: string }> { + const { address: messageAddress } = parseProofOfOwnershipMessage(message); + + const account = await this.#accountUseCases.get(accountId); + if (!account) { + throw new ValidationError('Account not found', { accountId }); + } + + // Canonicalize before validating: BDK's `Address.from_string` rejects + // mixed-case bech32 per BIP-173, but bech32 addresses are + // case-insensitive on the wire and the canonical form is lowercase. + // Validating the raw message address would reject perfectly valid + // mixed-case input before we ever got a chance to normalize it. + const canonicalMessageAddress = canonicalizeBitcoinAddress(messageAddress); + const canonicalAccountAddress = canonicalizeBitcoinAddress( + account.publicAddress.toString(), + ); + + const addressValidation = validateAddress( + canonicalMessageAddress, + account.network, + this.#logger, + ); + if (!addressValidation.valid) { + throw new ValidationError( + `Invalid Bitcoin address in proof-of-ownership message for network ${account.network}`, + { messageAddress, network: account.network }, + ); + } + + if (canonicalMessageAddress !== canonicalAccountAddress) { + throw new ValidationError( + `Address in proof-of-ownership message (${messageAddress}) does not match signing account address (${canonicalAccountAddress})`, + { + messageAddress, + accountAddress: canonicalAccountAddress, + }, + ); + } + + const signature = await this.#accountUseCases.signMessage( + accountId, + message, + 'metamask', + { skipConfirmation: true }, + ); + + return { signature }; + } } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts index 684f4bf7..f90d1703 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.test.ts @@ -4,8 +4,10 @@ import { assert } from 'superstruct'; import type { BitcoinAccount } from '../entities'; import { + canonicalizeBitcoinAddress, ComputeFeeRequest, FillPsbtRequest, + parseProofOfOwnershipMessage, parseRewardsMessage, SendTransferRequest, SignPsbtRequest, @@ -317,4 +319,91 @@ describe('validation', () => { ); }); }); + + describe('parseProofOfOwnershipMessage', () => { + const address = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'; + const nonce = 'a1b2c3d4e5f6789012345678'; + + it('extracts the nonce and address from a valid message', () => { + const result = parseProofOfOwnershipMessage( + `metamask:proof-of-ownership:${nonce}:${address}`, + ); + expect(result).toStrictEqual({ nonce, address }); + }); + + it('preserves embedded colons in the nonce (split on last ":")', () => { + const colonNonce = 'ns:abc:123'; + const result = parseProofOfOwnershipMessage( + `metamask:proof-of-ownership:${colonNonce}:${address}`, + ); + expect(result).toStrictEqual({ nonce: colonNonce, address }); + }); + + it.each([ + `rewards,${address},123`, + `metamask:proof:${nonce}:${address}`, + `Metamask:proof-of-ownership:${nonce}:${address}`, + `${nonce}:${address}`, + '', + ])('rejects messages without the expected prefix: "%s"', (message) => { + expect(() => parseProofOfOwnershipMessage(message)).toThrow( + 'Message must start with "metamask:proof-of-ownership:"', + ); + }); + + it('rejects messages missing the address separator', () => { + expect(() => + parseProofOfOwnershipMessage(`metamask:proof-of-ownership:${nonce}`), + ).toThrow('Message must follow the format'); + }); + + it('rejects messages with an empty nonce', () => { + expect(() => + parseProofOfOwnershipMessage(`metamask:proof-of-ownership::${address}`), + ).toThrow('non-empty nonce'); + }); + + it('rejects messages with an empty address', () => { + expect(() => + parseProofOfOwnershipMessage(`metamask:proof-of-ownership:${nonce}:`), + ).toThrow('non-empty address'); + }); + }); + + describe('canonicalizeBitcoinAddress', () => { + it('lowercases bech32 P2WPKH addresses', () => { + expect( + canonicalizeBitcoinAddress( + 'BC1QAR0SRRR7XFKVY5L643LYDNW9RE59GTZZWF5MDQ', + ), + ).toBe('bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'); + }); + + it('lowercases bech32m P2TR addresses', () => { + expect( + canonicalizeBitcoinAddress( + 'BC1PMFR3P9J00PFXJH0ZMGP99Y8ZFTMD3S5PMEDQHYPTWY6LYLEX2GYS2WAMVS', + ), + ).toBe('bc1pmfr3p9j00pfxjh0zmgp99y8zftmd3s5pmedqhyptwy6lylex2gys2wamvs'); + }); + + it.each(['tb1q...', 'TB1Q...', 'bcrt1q...', 'BCRT1Q...'])( + 'lowercases bech32 testnet and regtest addresses: "%s"', + (input) => { + expect(canonicalizeBitcoinAddress(input)).toBe(input.toLowerCase()); + }, + ); + + it('leaves legacy base58check P2PKH addresses unchanged', () => { + expect( + canonicalizeBitcoinAddress('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'), + ).toBe('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'); + }); + + it('leaves legacy base58check P2SH addresses unchanged', () => { + expect( + canonicalizeBitcoinAddress('3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy'), + ).toBe('3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy'); + }); + }); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts index c3db6667..8dbfda3b 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/validation.ts @@ -31,6 +31,7 @@ export enum RpcMethod { OnAmountInput = 'onAmountInput', ConfirmSend = 'confirmSend', SignRewardsMessage = 'signRewardsMessage', + SignProofOfOwnership = 'signProofOfOwnership', } export enum SendErrorCodes { @@ -399,3 +400,71 @@ export function parseRewardsMessage(base64Message: string): { timestamp, }; } + +export const PROOF_OF_OWNERSHIP_MESSAGE_PREFIX = 'metamask:proof-of-ownership:'; + +// bech32/bech32m HRPs for Bitcoin mainnet, testnet, and regtest. Addresses +// starting with one of these are case-insensitive but only canonical in +// lowercase. +const BECH32_BITCOIN_ADDRESS_PREFIXES = ['bc1', 'tb1', 'bcrt1']; + +/** + * Canonicalizes a Bitcoin address for equality comparison: bech32/bech32m + * (P2WPKH, P2WSH, P2TR) are lowercased, legacy base58check (P2PKH, P2SH) + * are passed through unchanged. + * + * @param address - The Bitcoin address to canonicalize + * @returns The canonical form of the address + */ +export function canonicalizeBitcoinAddress(address: string): string { + const lowercased = address.toLowerCase(); + if ( + BECH32_BITCOIN_ADDRESS_PREFIXES.some((hrp) => lowercased.startsWith(hrp)) + ) { + return lowercased; + } + return address; +} + +/** + * Parses a plaintext proof-of-ownership message in the format 'metamask:proof-of-ownership:{nonce}:{address}' + * + * @param message - The plaintext message to parse + * @returns Object containing the parsed nonce and address + * @throws Error if the message format is invalid + */ +export function parseProofOfOwnershipMessage(message: string): { + nonce: string; + address: string; +} { + if (!message.startsWith(PROOF_OF_OWNERSHIP_MESSAGE_PREFIX)) { + throw new Error( + `Message must start with "${PROOF_OF_OWNERSHIP_MESSAGE_PREFIX}"`, + ); + } + + const remainder = message.slice(PROOF_OF_OWNERSHIP_MESSAGE_PREFIX.length); + const separatorIdx = remainder.lastIndexOf(':'); + if (separatorIdx === -1) { + throw new Error( + 'Message must follow the format "metamask:proof-of-ownership:{nonce}:{address}"', + ); + } + + const nonce = remainder.slice(0, separatorIdx); + const address = remainder.slice(separatorIdx + 1); + + if (nonce === '') { + throw new Error( + 'Proof-of-ownership message must contain a non-empty nonce', + ); + } + + if (address === '') { + throw new Error( + 'Proof-of-ownership message must contain a non-empty address', + ); + } + + return { nonce, address }; +} From 0a64cf026905c8d81a63bf45775e75c90bbd63c3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:47:20 +0200 Subject: [PATCH 353/362] 1.14.0 (#629) * 1.14.0 * fix: update CHANGELOG * fix: revert site release * fix: remove dev entry from CHANGELOG * fix: update manifest --------- Co-authored-by: github-actions Co-authored-by: Mathieu Artu --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 5 ++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- merged-packages/bitcoin-wallet-snap/snap.manifest.json | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 98f62361..53074688 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.14.0] + ### Added - Add `signProofOfOwnership` client request method ([#626](https://github.com/MetaMask/snap-bitcoin-wallet/pull/626)) @@ -618,7 +620,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.13.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.14.0...HEAD +[1.14.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.13.0...v1.14.0 [1.13.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.12.0...v1.13.0 [1.12.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.11.0...v1.12.0 [1.11.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.10.1...v1.11.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 97fed967..13b03299 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.13.0", + "version": "1.14.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 66e34c7f..813c0449 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.13.0", + "version": "1.14.0", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "7GSBhdWnQOP8h+FoCeEtq9XioCJMVPWT+r0cIylafrg=", + "shasum": "Mqa9EOV9G6g7Ox8UGUYXxuXD5+kypzP+xjQ2v8zWW4c=", "location": { "npm": { "filePath": "dist/bundle.js", From 621ed073da7ad6e6c5cbc155a9de06e0c4e855a6 Mon Sep 17 00:00:00 2001 From: Battambang Date: Wed, 17 Jun 2026 18:26:18 +0200 Subject: [PATCH 354/362] Fix: guard against webassembly issue (#628) * fix: guard against unavailable WebAssembly in BdkAccountAdapter Adds an explicit WebAssembly availability check in BdkAccountAdapter.create() and BdkAccountAdapter.load() to handle environments where WebAssembly is disabled (e.g. iOS Lockdown Mode), converting an obscure crash into a clear WalletError with an actionable message. Fixes METAMASK-MOBILE-524Y * fix: remove TODO * docs: update changelog for WebAssembly guard fix * docs: fix PR number in changelog * Update packages/snap/CHANGELOG.md Co-authored-by: Michele Esposito <34438276+mikesposito@users.noreply.github.com> * fix: refactor assertWebAssemblyAvailable() outside of BdkAccountAdapter class * fix: update CHANGELOD.md --------- Co-authored-by: Michele Esposito <34438276+mikesposito@users.noreply.github.com> --- .../bitcoin-wallet-snap/CHANGELOG.md | 5 + .../bitcoin-wallet-snap/snap.manifest.json | 2 +- .../src/infra/BdkAccountAdapter.test.ts | 192 ++++++++++++++++++ .../src/infra/BdkAccountAdapter.ts | 15 ++ 4 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 53074688..bc40cfb7 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Throw a `WalletError` when `WebAssembly` is unavailable (e.g. iOS Lockdown Mode) ([#628](https://github.com/MetaMask/snap-bitcoin-wallet/pull/628)) + - The snap was previously crashing with a confusing `"undefined is not an object (evaluating 'WebAssembly.instantiate')"` error. + ## [1.14.0] ### Added diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index 813c0449..cf305a1a 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "Mqa9EOV9G6g7Ox8UGUYXxuXD5+kypzP+xjQ2v8zWW4c=", + "shasum": "MTzKFh6HogVOYFf+j53R/wr/HNjocn0FpRA4E1mX0Yc=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.test.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.test.ts new file mode 100644 index 00000000..42015012 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.test.ts @@ -0,0 +1,192 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { + ChangeSet, + DescriptorPair, + Network, +} from '@metamask/bitcoindevkit'; +import { Wallet } from '@metamask/bitcoindevkit'; +import { mock } from 'jest-mock-extended'; + +import { WalletError } from '../entities'; +import { BdkAccountAdapter } from './BdkAccountAdapter'; + +jest.mock('@metamask/bitcoindevkit', () => ({ + FeeRate: jest.fn(), + OutPoint: { from_string: jest.fn() }, + SignOptions: jest.fn(), + Txid: { from_string: jest.fn() }, + UnconfirmedTx: jest.fn(), + Wallet: { + create: jest.fn(), + load: jest.fn(), + }, +})); + +describe('BdkAccountAdapter', () => { + const mockWallet = mock(); + const mockId = 'test-id'; + const mockDerivationPath = ['m', "84'", "0'", "0'"]; + const mockDescriptors = mock({ + external: 'ext-desc', + internal: 'int-desc', + }); + const mockChangeSet = mock(); + const mockNetwork: Network = 'bitcoin'; + + beforeEach(() => { + jest.mocked(Wallet.create).mockReturnValue(mockWallet); + jest.mocked(Wallet.load).mockReturnValue(mockWallet); + }); + + describe('WebAssembly availability guard', () => { + // eslint-disable-next-line no-restricted-globals -- needed to save/restore the WebAssembly global in tests + let originalWebAssembly: typeof WebAssembly; + + beforeEach(() => { + originalWebAssembly = globalThis.WebAssembly; + }); + + afterEach(() => { + Object.defineProperty(globalThis, 'WebAssembly', { + configurable: true, + value: originalWebAssembly, + writable: true, + }); + }); + + /** Simulates an environment where WebAssembly is unavailable (e.g., iOS Lockdown Mode). */ + function disableWebAssembly(): void { + Object.defineProperty(globalThis, 'WebAssembly', { + configurable: true, + value: undefined, + writable: true, + }); + } + + describe('create', () => { + it('throws WalletError when WebAssembly is unavailable', () => { + disableWebAssembly(); + + expect(() => + BdkAccountAdapter.create( + mockId, + mockDerivationPath, + mockDescriptors, + mockNetwork, + ), + ).toThrow(WalletError); + }); + + it('error message mentions iOS Lockdown Mode', () => { + disableWebAssembly(); + + expect(() => + BdkAccountAdapter.create( + mockId, + mockDerivationPath, + mockDescriptors, + mockNetwork, + ), + ).toThrow(/Lockdown Mode/u); + }); + + it('does not call Wallet.create when WebAssembly is unavailable', () => { + disableWebAssembly(); + + expect(() => + BdkAccountAdapter.create( + mockId, + mockDerivationPath, + mockDescriptors, + mockNetwork, + ), + ).toThrow(WalletError); + expect(Wallet.create).not.toHaveBeenCalled(); + }); + + it('calls Wallet.create when WebAssembly is available', () => { + BdkAccountAdapter.create( + mockId, + mockDerivationPath, + mockDescriptors, + mockNetwork, + ); + + expect(Wallet.create).toHaveBeenCalledWith( + mockNetwork, + mockDescriptors.external, + mockDescriptors.internal, + ); + }); + + it('returns a BdkAccountAdapter instance when WebAssembly is available', () => { + const result = BdkAccountAdapter.create( + mockId, + mockDerivationPath, + mockDescriptors, + mockNetwork, + ); + + expect(result).toBeInstanceOf(BdkAccountAdapter); + }); + }); + + describe('load', () => { + it('throws WalletError when WebAssembly is unavailable', () => { + disableWebAssembly(); + + expect(() => + BdkAccountAdapter.load(mockId, mockDerivationPath, mockChangeSet), + ).toThrow(WalletError); + }); + + it('error message mentions iOS Lockdown Mode', () => { + disableWebAssembly(); + + expect(() => + BdkAccountAdapter.load(mockId, mockDerivationPath, mockChangeSet), + ).toThrow(/Lockdown Mode/u); + }); + + it('does not call Wallet.load when WebAssembly is unavailable', () => { + disableWebAssembly(); + + expect(() => + BdkAccountAdapter.load(mockId, mockDerivationPath, mockChangeSet), + ).toThrow(WalletError); + expect(Wallet.load).not.toHaveBeenCalled(); + }); + + it('calls Wallet.load without descriptors', () => { + BdkAccountAdapter.load(mockId, mockDerivationPath, mockChangeSet); + + expect(Wallet.load).toHaveBeenCalledWith(mockChangeSet); + }); + + it('calls Wallet.load with descriptors', () => { + BdkAccountAdapter.load( + mockId, + mockDerivationPath, + mockChangeSet, + mockDescriptors, + ); + + expect(Wallet.load).toHaveBeenCalledWith( + mockChangeSet, + mockDescriptors.external, + mockDescriptors.internal, + ); + }); + + it('returns a BdkAccountAdapter instance when WebAssembly is available', () => { + const result = BdkAccountAdapter.load( + mockId, + mockDerivationPath, + mockChangeSet, + ); + + expect(result).toBeInstanceOf(BdkAccountAdapter); + }); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts index 425bd440..760f6be7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/BdkAccountAdapter.ts @@ -34,6 +34,19 @@ import { } from '../entities'; import { BdkTxBuilderAdapter } from './BdkTxBuilderAdapter'; +/** + * Throws a {@link WalletError} if WebAssembly is not available in the current environment. + */ +function assertWebAssemblyAvailable(): void { + // eslint-disable-next-line no-restricted-globals -- WebAssembly is a valid endowment in the snap execution environment (endowment:webassembly) + if (typeof WebAssembly === 'undefined') { + throw new WalletError( + 'WebAssembly is not available in this environment. ' + + 'If iOS Lockdown Mode is enabled, Bitcoin wallet functionality is not supported.', + ); + } +} + export class BdkAccountAdapter implements BitcoinAccount { readonly #id: string; @@ -58,6 +71,7 @@ export class BdkAccountAdapter implements BitcoinAccount { descriptors: DescriptorPair, network: Network, ): BdkAccountAdapter { + assertWebAssemblyAvailable(); return new BdkAccountAdapter( id, derivationPath, @@ -71,6 +85,7 @@ export class BdkAccountAdapter implements BitcoinAccount { walletData: ChangeSet, descriptors?: DescriptorPair, ): BdkAccountAdapter { + assertWebAssemblyAvailable(); // Load with signer if (descriptors) { return new BdkAccountAdapter( From dcc447e81174478e0257fa643335608707e69370 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:49:17 +0200 Subject: [PATCH 355/362] 1.14.1 (#631) * 1.14.1 * fix: update CHANGELOG.md * fix: remove snap site update * fix: update CHANGELOG.md * fix: revert site package.json version --------- Co-authored-by: github-actions Co-authored-by: Frederic HENG --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 5 ++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index bc40cfb7..fe14e0b2 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.14.1] + ### Fixed - Throw a `WalletError` when `WebAssembly` is unavailable (e.g. iOS Lockdown Mode) ([#628](https://github.com/MetaMask/snap-bitcoin-wallet/pull/628)) @@ -625,7 +627,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.14.0...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.14.1...HEAD +[1.14.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.14.0...v1.14.1 [1.14.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.13.0...v1.14.0 [1.13.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.12.0...v1.13.0 [1.12.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.11.0...v1.12.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 13b03299..1e8b83d9 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.14.0", + "version": "1.14.1", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 792ae2a0879c687235a66b93b46e1cf832ea4653 Mon Sep 17 00:00:00 2001 From: 0xEdouard <15703023+0xEdouardEth@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:39:11 +0200 Subject: [PATCH 356/362] fix: handle non-url origins (#634) * fix: handle non-url origins * fix: review --- .../bitcoin-wallet-snap/CHANGELOG.md | 4 ++ .../src/infra/jsx/format.test.ts | 50 +++++++++++++++++++ .../src/infra/jsx/format.ts | 24 ++++++++- 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index fe14e0b2..cb31608a 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Display known non-URL origins in confirmations without throwing on invalid origin values ([#634](https://github.com/MetaMask/snap-bitcoin-wallet/pull/634)) + ## [1.14.1] ### Fixed diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.test.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.test.ts new file mode 100644 index 00000000..a175612f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.test.ts @@ -0,0 +1,50 @@ +import { displayOrigin } from './format'; + +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => ({ + Amount: { + from_sat: jest.fn(), + }, + BdkErrorCode: {}, +})); +/* eslint-enable @typescript-eslint/naming-convention */ + +describe('displayOrigin', () => { + it('returns the known label for the internal "metamask" origin', () => { + expect(displayOrigin('metamask')).toBe('MetaMask'); + }); + + it('returns the known label for the "wallet-connect" origin', () => { + expect(displayOrigin('wallet-connect')).toBe('WalletConnect'); + }); + + it('matches known origins case-insensitively', () => { + expect(displayOrigin('MetaMask')).toBe('MetaMask'); + expect(displayOrigin('Wallet-Connect')).toBe('WalletConnect'); + }); + + it('returns the hostname for a valid https URL', () => { + expect(displayOrigin('https://app.uniswap.org')).toBe('app.uniswap.org'); + }); + + it('returns the hostname for a valid http URL', () => { + expect(displayOrigin('http://localhost:8080')).toBe('localhost'); + }); + + it('returns an empty string for a non-http(s) URL', () => { + expect(displayOrigin('ftp://example.com')).toBe(''); + }); + + it('returns an empty string for a WalletConnect channelId', () => { + expect( + displayOrigin( + 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2', + ), + ).toBe(''); + }); + + it('returns an empty string for an invalid origin', () => { + expect(displayOrigin('not a url')).toBe(''); + expect(displayOrigin('')).toBe(''); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts index e91e6dfa..8e54922f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts @@ -68,8 +68,30 @@ export const errorCodeToLabel = (code: number): string => { return raw.charAt(0).toLowerCase() + raw.slice(1); }; +/** + * Known origins mapped to their human-readable labels. Keys are lowercased so + * lookups can be performed case-insensitively against the raw origin. + */ +const KNOWN_ORIGIN_LABELS: Record = { + metamask: 'MetaMask', + 'wallet-connect': 'WalletConnect', +}; + export const displayOrigin = (origin: string): string => { - return new URL(origin).hostname; + const knownLabel = KNOWN_ORIGIN_LABELS[origin.toLowerCase()]; + if (knownLabel) { + return knownLabel; + } + + try { + const url = new URL(origin); + return url.protocol === 'http:' || url.protocol === 'https:' + ? url.hostname + : ''; + } catch { + console.log('[format] - displayOrigin - failed to parse origin', origin); + return ''; + } }; export const displayCaip10 = ( From a84377b38485f4fddc540b2ea13b46ff3b0a49dc Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 1 Jul 2026 15:19:08 +0200 Subject: [PATCH 357/362] chore: track all errors in handle middleware WPN-1451 (#632) * chore: emitTrackingError any error in HandlerMiddleware * chore: update trace and tracking handling tests * chore: skip tracking canceled confirmations * fix: lint errors * chore: track JSX confirmation repository errors (#633) * chore: track error batch (#635) * chore: track errors in AssetsUseCases * chore: track errors in SendFlowUseCases * chore: track errors in Assets, Cron, Keyring, and Rpc handlers * chore: comment suppressed errors intent in mappings --- .../bitcoin-wallet-snap/src/entities/snap.ts | 7 +- .../src/handlers/AssetsHandler.test.ts | 7 +- .../src/handlers/AssetsHandler.ts | 8 +- .../src/handlers/CronHandler.test.ts | 17 +- .../src/handlers/CronHandler.ts | 14 ++ .../src/handlers/HandlerMiddleware.test.ts | 42 ++++- .../src/handlers/HandlerMiddleware.ts | 29 +++- .../src/handlers/KeyringHandler.test.ts | 39 ++++- .../src/handlers/KeyringHandler.ts | 37 +--- .../src/handlers/RpcHandler.ts | 4 + .../src/handlers/mappings.ts | 2 + .../bitcoin-wallet-snap/src/index.ts | 4 +- .../src/infra/SnapClientAdapter.test.ts | 158 ++++++++++++++++++ .../src/infra/SnapClientAdapter.ts | 144 ++++++++-------- .../store/JSXConfirmationRepository.test.tsx | 24 ++- .../src/store/JSXConfirmationRepository.tsx | 11 +- .../src/use-cases/AccountUseCases.test.ts | 18 +- .../src/use-cases/AccountUseCases.ts | 61 +++---- .../src/use-cases/AssetsUseCases.test.ts | 19 ++- .../src/use-cases/AssetsUseCases.ts | 13 +- .../src/use-cases/SendFlowUseCases.test.ts | 18 +- .../src/use-cases/SendFlowUseCases.ts | 17 +- .../src/utils/snapHelpers.ts | 18 -- 23 files changed, 503 insertions(+), 208 deletions(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.test.ts delete mode 100644 merged-packages/bitcoin-wallet-snap/src/utils/snapHelpers.ts diff --git a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts index f84bcfa0..e3a545ad 100644 --- a/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts +++ b/merged-packages/bitcoin-wallet-snap/src/entities/snap.ts @@ -8,7 +8,6 @@ import type { import type { Json } from '@metamask/utils'; import type { BitcoinAccount } from './account'; -import type { BaseError } from './error'; import type { Inscription } from './meta-protocols'; export type SnapState = { @@ -260,15 +259,15 @@ export type SnapClient = { * * @param error The error to track */ - emitTrackingError(error: BaseError): Promise; + emitTrackingError(error: Error): Promise; /** * Start a performance trace. * * @param name - The name of the trace. - * @returns A promise that resolves. + * @returns boolean whether the trace was started successfully. */ - startTrace(name: string): Promise; + startTrace(name: string): Promise; /** * End a performance trace. diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts index 7a7c0fca..bee2613a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.test.ts @@ -7,12 +7,13 @@ import { mock } from 'jest-mock-extended'; import type { AssetsUseCases } from '../use-cases'; import { AssetsHandler } from './AssetsHandler'; import { Caip19Asset } from './caip'; -import type { Logger, SpotPrice } from '../entities'; +import type { Logger, SnapClient, SpotPrice } from '../entities'; describe('AssetsHandler', () => { const mockAssetsUseCases = mock(); const mockLogger = mock(); const expirationInterval = 60; + const mockSnapClient = mock(); let handler: AssetsHandler; @@ -21,6 +22,7 @@ describe('AssetsHandler', () => { mockAssetsUseCases, expirationInterval, mockLogger, + mockSnapClient, ); }); @@ -146,7 +148,7 @@ describe('AssetsHandler', () => { expect(result?.historicalPrice.intervals).toStrictEqual(mockIntervals); }); - it('returns null and logs warning when getPriceIntervals fails', async () => { + it('returns null, tracks the error, and logs warning when getPriceIntervals fails', async () => { const error = new Error('getPriceIntervals failed'); mockAssetsUseCases.getPriceIntervals.mockRejectedValue(error); @@ -155,6 +157,7 @@ describe('AssetsHandler', () => { Caip19Asset.Testnet, ); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); expect(mockLogger.warn).toHaveBeenCalledWith( 'Failed to fetch historical prices from %s to %s. Error: %s', Caip19Asset.Bitcoin, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts index f95a4dc7..50f3ff74 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/AssetsHandler.ts @@ -13,7 +13,7 @@ import type { import { CaipAssetTypeStruct } from '@metamask/utils'; import { assert } from 'superstruct'; -import type { Logger } from '../entities'; +import type { Logger, SnapClient } from '../entities'; import type { AssetsUseCases } from '../use-cases'; import { Caip19Asset } from './caip'; import { networkToIcon } from './icons'; @@ -25,14 +25,18 @@ export class AssetsHandler { readonly #logger: Logger; + readonly #snapClient: SnapClient; + constructor( assets: AssetsUseCases, expirationInterval: number, logger: Logger, + snapClient: SnapClient, ) { this.#assetsUseCases = assets; this.#expirationInterval = expirationInterval; this.#logger = logger; + this.#snapClient = snapClient; } async lookup(): Promise { @@ -163,6 +167,8 @@ export class AssetsHandler { }, }; } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); + this.#logger.warn( 'Failed to fetch historical prices from %s to %s. Error: %s', from, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts index d553baa1..4a406285 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.test.ts @@ -3,7 +3,11 @@ import { getSelectedAccounts } from '@metamask/keyring-snap-sdk'; import type { SnapsProvider, JsonRpcRequest } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; -import type { BitcoinAccount, SnapClient, SyncResult } from '../entities'; +import { + type BitcoinAccount, + type SnapClient, + type SyncResult, +} from '../entities'; import type { SendFlowUseCases, AccountUseCases } from '../use-cases'; import { CronHandler, CronMethod } from './CronHandler'; @@ -263,15 +267,24 @@ describe('CronHandler', () => { account: mockAccount1, transactionsToNotify: [], }; + const syncError = new Error('scan failed'); mockAccountUseCases.list.mockResolvedValue(mockAccounts); mockAccountUseCases.synchronize .mockResolvedValueOnce(mockResult) - .mockRejectedValueOnce(new Error('scan failed')); + .mockRejectedValueOnce(syncError); const result = await handler.route(request); expect(result).toBeUndefined(); expect(mockAccountUseCases.synchronize).toHaveBeenCalledTimes(2); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'SynchronizationError', + message: 'Failed to synchronize 1 selected accounts', + cause: syncError, + }), + ); + // Should emit for successful account only expect( mockSnapClient.emitAccountBalancesUpdatedEvent, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts index 308af2bf..658f6a4a 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/CronHandler.ts @@ -142,6 +142,20 @@ export class CronHandler { ) .map((result) => result.value); + const rejectedResults = results.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + + if (rejectedResults.length > 0) { + await this.#snapClient.emitTrackingError( + new SynchronizationError( + `Failed to synchronize ${rejectedResults.length} selected accounts`, + undefined, + rejectedResults[0]?.reason, + ), + ); + } + await this.#emitSyncEvents(successfulResults); } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts index 82e7dac4..a7dc68ae 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.test.ts @@ -7,8 +7,9 @@ import { type Translator, BaseError, ExternalServiceError, + UserActionError, } from '../entities'; -import { HandlerMiddleware } from './HandlerMiddleware'; +import { HandlerMiddleware, shouldTrackError } from './HandlerMiddleware'; describe('HandlerMiddleware', () => { const mockLogger = mock(); @@ -33,6 +34,27 @@ describe('HandlerMiddleware', () => { mockTranslator.load.mockResolvedValue({}); }); + describe('shouldTrackError', () => { + it('returns false for canceled confirmation errors', () => { + expect( + shouldTrackError( + new UserActionError('User canceled the confirmation'), + mockLogger, + ), + ).toBe(false); + }); + + it('returns true for other errors', () => { + expect(shouldTrackError(new Error('boom'), mockLogger)).toBe(true); + expect( + shouldTrackError( + new UserActionError('Another user action'), + mockLogger, + ), + ).toBe(true); + }); + }); + describe('handle', () => { it('executes the function successfully', async () => { const mockFn = jest.fn().mockResolvedValue('success'); @@ -52,6 +74,24 @@ describe('HandlerMiddleware', () => { expect(mockLogger.error).toHaveBeenCalledWith(error); }); + it('tracks an unexpected Error before rethrowing it as a SnapError', async () => { + const error = new Error('tracked boom'); + const mockFn = jest.fn().mockRejectedValue(error); + + await expect(middleware.handle(mockFn)).rejects.toThrow('tracked boom'); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); + }); + + it('continues to throw a SnapError when emitTrackingError fails', async () => { + const error = new Error('boom after tracking failure'); + const mockFn = jest.fn().mockRejectedValue(error); + + await expect(middleware.handle(mockFn)).rejects.toThrow(error); + + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); + expect(mockSnapClient.getPreferences).toHaveBeenCalled(); + }); + it('wraps a non-Error thrown value by stringifying it', async () => { const mockFn = jest.fn().mockRejectedValue('string failure'); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts index 97c986fd..6d83d684 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/HandlerMiddleware.ts @@ -26,6 +26,24 @@ import { AssertionError, } from '../entities'; +/** + * Determines whether an error should be reported through `snap_trackError`. + * + * @param error - The error to evaluate. + * @param logger - logger for error + * @returns `true` when the error should be tracked. + */ +export function shouldTrackError(error: unknown, logger: Logger): boolean { + try { + return !( + (error as UserActionError)?.message === 'User canceled the confirmation' + ); + } catch { + logger.error(error, 'Failed to determine if error should be tracked'); + return false; + } +} + export class HandlerMiddleware { readonly #logger: Logger; @@ -43,19 +61,16 @@ export class HandlerMiddleware { try { return await fn(); } catch (error) { + if (shouldTrackError(error, this.#logger)) { + await this.#snapClient.emitTrackingError(error as Error); + } + const { locale } = await this.#snapClient.getPreferences(); const messages = await this.#translator.load(locale); if (error instanceof BaseError) { this.#logger.error(error, error.data); - try { - await this.#snapClient.emitTrackingError(error); - } catch (trackingError) { - // The tracking pipeline is non‑critical; log and proceed so we don’t mask the original failure. - this.#logger.error('Failed to track error', trackingError); - } - const errMsg = messages[`error.${error.code}`]?.message ?? messages['error.internal']?.message ?? diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts index 1efa056f..2ca7f1db 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.test.ts @@ -348,7 +348,7 @@ describe('KeyringHandler', () => { }; beforeEach(() => { - mockSnapClient.startTrace.mockResolvedValue(undefined); + mockSnapClient.startTrace.mockResolvedValue(true); mockSnapClient.endTrace.mockResolvedValue(undefined); }); @@ -367,6 +367,7 @@ describe('KeyringHandler', () => { const callOrder: string[] = []; mockSnapClient.startTrace.mockImplementation(async () => { callOrder.push('startTrace'); + return true; }); mockAccounts.create.mockImplementation(async () => { callOrder.push('createAccount'); @@ -394,7 +395,7 @@ describe('KeyringHandler', () => { }); it('creates account even if startTrace fails', async () => { - mockSnapClient.startTrace.mockResolvedValue(undefined); + mockSnapClient.startTrace.mockResolvedValue(false); const result = await handler.createAccount(options); @@ -403,8 +404,8 @@ describe('KeyringHandler', () => { expect(mockSnapClient.startTrace).toHaveBeenCalled(); }); - it('calls both startTrace and endTrace even if startTrace returns null', async () => { - mockSnapClient.startTrace.mockResolvedValue(undefined); + it('calls endTrace when startTrace returns true', async () => { + mockSnapClient.startTrace.mockResolvedValue(true); await handler.createAccount(options); @@ -415,6 +416,17 @@ describe('KeyringHandler', () => { 'Create Bitcoin Account', ); }); + + it('does not call endTrace when startTrace returns false', async () => { + mockSnapClient.startTrace.mockResolvedValue(false); + + await handler.createAccount(options); + + expect(mockSnapClient.startTrace).toHaveBeenCalledWith( + 'Create Bitcoin Account', + ); + expect(mockSnapClient.endTrace).not.toHaveBeenCalled(); + }); }); }); @@ -670,7 +682,7 @@ describe('KeyringHandler', () => { }; beforeEach(() => { - mockSnapClient.startTrace.mockResolvedValue(undefined); + mockSnapClient.startTrace.mockResolvedValue(true); mockSnapClient.endTrace.mockResolvedValue(undefined); mockAccounts.createMany.mockResolvedValue([buildMockAccount(0)]); }); @@ -694,6 +706,17 @@ describe('KeyringHandler', () => { 'Create Bitcoin Accounts Batch', ); }); + + it('does not call endTrace when startTrace returns false', async () => { + mockSnapClient.startTrace.mockResolvedValue(false); + + await handler.createAccounts(options); + + expect(mockSnapClient.startTrace).toHaveBeenCalledWith( + 'Create Bitcoin Accounts Batch', + ); + expect(mockSnapClient.endTrace).not.toHaveBeenCalled(); + }); }); }); @@ -1247,7 +1270,7 @@ describe('KeyringHandler', () => { expect(result).toBeNull(); }); - it('returns null when scope validation fails', async () => { + it('returns null and tracks the error when scope validation fails', async () => { const request = { id: '1', jsonrpc: '2.0' as const, @@ -1257,9 +1280,10 @@ describe('KeyringHandler', () => { psbt: 'psbt', }, }; + const error = new Error('Invalid scope'); jest.mocked(assert).mockImplementationOnce(() => { - throw new Error('Invalid scope'); + throw error; }); const result = await handler.resolveAccountAddress( @@ -1267,6 +1291,7 @@ describe('KeyringHandler', () => { request, ); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); expect(result).toBeNull(); }); diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts index 52731e96..7c4823b4 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/KeyringHandler.ts @@ -78,7 +78,6 @@ import type { AccountUseCases, CreateAccountParams, } from '../use-cases/AccountUseCases'; -import { runSnapActionSafely } from '../utils/snapHelpers'; export const CreateAccountRequest = object({ scope: enums(Object.values(BtcScope)), @@ -213,18 +212,9 @@ export class KeyringHandler implements Keyring { assert(options, CreateAccountRequest); const traceName = 'Create Bitcoin Account'; - let traceStarted = false; + const traceStarted = await this.#snapClient.startTrace(traceName); try { - await runSnapActionSafely( - async () => { - await this.#snapClient.startTrace(traceName); - traceStarted = true; - }, - this.#logger, - 'startTrace', - ); - const { metamask, scope, @@ -296,11 +286,7 @@ export class KeyringHandler implements Keyring { return mapToKeyringAccount(account); } finally { if (traceStarted) { - await runSnapActionSafely( - async () => this.#snapClient.endTrace(traceName), - this.#logger, - 'endTrace', - ); + await this.#snapClient.endTrace(traceName); } } } @@ -348,18 +334,9 @@ export class KeyringHandler implements Keyring { } const traceName = 'Create Bitcoin Accounts Batch'; - let traceStarted = false; + const traceStarted = await this.#snapClient.startTrace(traceName); try { - await runSnapActionSafely( - async () => { - await this.#snapClient.startTrace(traceName); - traceStarted = true; - }, - this.#logger, - 'startTrace', - ); - // `AccountUseCases.createMany` is idempotent: if an account already exists // for the resolved derivation path, it will be returned as-is. const accounts: BitcoinAccount[] = []; @@ -395,11 +372,7 @@ export class KeyringHandler implements Keyring { return accounts.map(mapToKeyringAccount); } finally { if (traceStarted) { - await runSnapActionSafely( - async () => this.#snapClient.endTrace(traceName), - this.#logger, - 'endTrace', - ); + await this.#snapClient.endTrace(traceName); } } } @@ -561,6 +534,8 @@ export class KeyringHandler implements Keyring { return { address: `${scope}:${addressToValidate}` }; } catch (error: unknown) { + await this.#snapClient.emitTrackingError(error as Error); + this.#logger.error({ error }, 'Error resolving account address'); return null; } diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index 6f3c8393..d0717056 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -231,6 +231,8 @@ export class RpcHandler { return validateAddress(value, bitcoinAccount.network, this.#logger); } catch (error) { + // We do not track this error as it is a user input. + this.#logger.error( `Invalid account. Error: %s`, (error as CodifiedError).message, @@ -260,6 +262,8 @@ export class RpcHandler { return balanceValidation.valid ? NO_ERRORS_RESPONSE : balanceValidation; } catch (error) { + // We do not track this error as it is a user input. + this.#logger.error( 'An error occurred: %s', (error as CodifiedError).message, diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts index fc260c67..6f90bea7 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/mappings.ts @@ -111,6 +111,7 @@ const mapToAssetMovement = ( asset: mapToAmount(output.value, network), }; } catch { + // We do not track this error as non-address scripts are expected here return null; } }; @@ -302,6 +303,7 @@ export function mapToUtxo(utxo: LocalOutput, network: Network): Utxo { try { address = Address.from_script(utxo.txout.script_pubkey, network); } catch { + // We do not track this error as the address field is explicitly optional in Utxo address = undefined; } diff --git a/merged-packages/bitcoin-wallet-snap/src/index.ts b/merged-packages/bitcoin-wallet-snap/src/index.ts index 2d8a6968..7751ca0f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/index.ts +++ b/merged-packages/bitcoin-wallet-snap/src/index.ts @@ -39,7 +39,7 @@ import { // Infra layer const logger = new ConsoleLoggerAdapter(Config.logLevel); -const snapClient = new SnapClientAdapter(Config.encrypt); +const snapClient = new SnapClientAdapter(logger, Config.encrypt); const chainClient = new EsploraClientAdapter(Config.chain); const assetRatesClient = new PriceApiClientAdapter(Config.priceApi); const translator = new LocalTranslatorAdapter(); @@ -82,6 +82,7 @@ const assetsUseCases = new AssetsUseCases( logger, assetRatesClient, new InMemoryCache(), + snapClient, ); const confirmationUseCases = new ConfirmationUseCases(logger, snapClient); @@ -112,6 +113,7 @@ const assetsHandler = new AssetsHandler( assetsUseCases, Config.conversionsExpirationInterval, logger, + snapClient, ); export const onCronjob: OnCronjobHandler = async ({ request }) => diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.test.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.test.ts new file mode 100644 index 00000000..64902bf9 --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.test.ts @@ -0,0 +1,158 @@ +import type { WalletTx } from '@metamask/bitcoindevkit'; +import { getJsonError } from '@metamask/snaps-sdk'; +import { mock } from 'jest-mock-extended'; + +import type { BitcoinAccount, Logger } from '../entities'; +import { TrackingSnapEvent } from '../entities'; +import { SnapClientAdapter } from './SnapClientAdapter'; + +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => ({ + Amount: { + from_sat: jest.fn(() => ({ + to_btc: jest.fn(() => ({ + toString: jest.fn(() => '0'), + })), + })), + }, +})); +/* eslint-enable @typescript-eslint/naming-convention */ + +const setupTest = () => { + const mockLogger = mock(); + const mockRequest = jest.fn(); + const snapClient = new SnapClientAdapter(mockLogger); + + Object.defineProperty(globalThis, 'snap', { + configurable: true, + value: { request: mockRequest }, + writable: true, + }); + + return { snapClient, mockLogger, mockRequest }; +}; + +describe('SnapClientAdapter', () => { + describe('emitTrackingEvent', () => { + it("doesn't throw and logs when event tracking fails", async () => { + const { snapClient, mockLogger, mockRequest } = setupTest(); + + const trackingError = new Error('event tracking failed'); + const account = mock({ + network: 'bitcoin', + addressType: 'p2wpkh', + }); + const tx = mock({ + txid: { toString: () => 'txid-123' }, + }); + mockRequest.mockRejectedValue(trackingError); + + expect( + await snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionReceived, + account, + tx, + 'metamask', + ), + ).toBeUndefined(); + + /* eslint-disable @typescript-eslint/naming-convention */ + expect(mockRequest).toHaveBeenCalledWith({ + method: 'snap_trackEvent', + params: { + event: { + event: TrackingSnapEvent.TransactionReceived, + properties: { + origin: 'metamask', + message: 'Snap transaction received', + chain_id_caip: 'bip122:000000000019d6689c085ae165831e93', + account_type: 'bip122:p2wpkh', + tx_id: 'txid-123', + }, + }, + }, + }); + /* eslint-enable @typescript-eslint/naming-convention */ + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to track event: Transaction Received', + trackingError, + ); + }); + }); + + describe('emitTrackingError', () => { + it('sends the tracking error payload to the snap client', async () => { + const { snapClient, mockRequest } = setupTest(); + + const error = new Error('boom'); + mockRequest.mockResolvedValue(undefined); + + expect(await snapClient.emitTrackingError(error)).toBeUndefined(); + + expect(mockRequest).toHaveBeenCalledWith({ + method: 'snap_trackError', + params: { error: getJsonError(error) }, + }); + }); + + it("doesn't break execution when error tracking fails", async () => { + const { snapClient, mockLogger, mockRequest } = setupTest(); + + const error = new Error('boom'); + const trackingError = new Error('track failed'); + mockRequest.mockRejectedValue(trackingError); + + expect(await snapClient.emitTrackingError(error)).toBeUndefined(); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to track error', + trackingError, + ); + }); + }); + + describe('startTrace', () => { + it('returns false and logs when starting a trace fails', async () => { + const { snapClient, mockLogger, mockRequest } = setupTest(); + + const traceError = new Error('trace failed'); + mockRequest.mockRejectedValue(traceError); + + expect(await snapClient.startTrace('Create Bitcoin Account')).toBe(false); + + expect(mockRequest).toHaveBeenCalledWith({ + method: 'snap_startTrace', + params: { + name: 'Create Bitcoin Account', + }, + }); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to start trace', + traceError, + ); + }); + }); + + describe('endTrace', () => { + it('does not throw and logs when ending a trace fails', async () => { + const { snapClient, mockLogger, mockRequest } = setupTest(); + + const traceError = new Error('trace end failed'); + mockRequest.mockRejectedValue(traceError); + + expect( + await snapClient.endTrace('Create Bitcoin Account'), + ).toBeUndefined(); + + expect(mockRequest).toHaveBeenCalledWith({ + method: 'snap_endTrace', + params: { + name: 'Create Bitcoin Account', + }, + }); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to end trace', + traceError, + ); + }); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts index 7a434d7f..8d8a6d39 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/SnapClientAdapter.ts @@ -12,9 +12,9 @@ import type { GetPreferencesResult, Json, } from '@metamask/snaps-sdk'; -import { DialogType } from '@metamask/snaps-sdk'; +import { DialogType, getJsonError } from '@metamask/snaps-sdk'; -import type { BaseError, BitcoinAccount, SnapClient } from '../entities'; +import type { BitcoinAccount, Logger, SnapClient } from '../entities'; import { computeDisplayBalanceSats, TrackingSnapEvent, @@ -31,7 +31,10 @@ import { mapToKeyringAccount, mapToTransaction } from '../handlers/mappings'; export class SnapClientAdapter implements SnapClient { readonly #encrypt: boolean; - constructor(encrypt = false) { + readonly #logger: Logger; + + constructor(logger: Logger, encrypt = false) { + this.#logger = logger; this.#encrypt = encrypt; } @@ -263,80 +266,83 @@ export class SnapClientAdapter implements SnapClient { tx: WalletTx, origin: string, ): Promise { - const createMessage = (): string => { - switch (eventType) { - case TrackingSnapEvent.TransactionFinalized: - return 'Snap transaction finalized'; - case TrackingSnapEvent.TransactionSubmitted: - return 'Snap transaction submitted'; - case TrackingSnapEvent.TransactionReorged: - return 'Snap transaction reorged'; - case TrackingSnapEvent.TransactionReceived: - return 'Snap transaction received'; - default: - throw new AssertionError(`Unhandled tracking event type`, { - eventType, - origin, - }); - } - }; - - /* eslint-disable @typescript-eslint/naming-convention */ - await snap.request({ - method: 'snap_trackEvent', - params: { - event: { - event: eventType, - properties: { - origin, - message: createMessage(), - chain_id_caip: networkToScope[account.network], - account_type: addressTypeToCaip[account.addressType], - tx_id: tx.txid.toString(), + try { + const createMessage = (): string => { + switch (eventType) { + case TrackingSnapEvent.TransactionFinalized: + return 'Snap transaction finalized'; + case TrackingSnapEvent.TransactionSubmitted: + return 'Snap transaction submitted'; + case TrackingSnapEvent.TransactionReorged: + return 'Snap transaction reorged'; + case TrackingSnapEvent.TransactionReceived: + return 'Snap transaction received'; + default: + throw new AssertionError(`Unhandled tracking event type`, { + eventType, + origin, + }); + } + }; + + /* eslint-disable @typescript-eslint/naming-convention */ + await snap.request({ + method: 'snap_trackEvent', + params: { + event: { + event: eventType, + properties: { + origin, + message: createMessage(), + chain_id_caip: networkToScope[account.network], + account_type: addressTypeToCaip[account.addressType], + tx_id: tx.txid.toString(), + }, }, }, - }, - }); - /* eslint-enable @typescript-eslint/naming-convention */ + }); + /* eslint-enable @typescript-eslint/naming-convention */ + } catch (error) { + this.#logger.error(`Failed to track event: ${eventType}`, error); + } } - async emitTrackingError(error: BaseError): Promise { - await snap.request({ - method: 'snap_trackError', - params: { - error: { - name: error.name, - message: error.message, - stack: error.stack ?? null, - cause: - error.cause && error.cause instanceof Error - ? { - cause: null, - message: error.cause.message, - name: error.cause.name, - stack: error.cause.stack ?? null, - } - : null, - }, - }, - }); + async emitTrackingError(error: Error): Promise { + try { + await snap.request({ + method: 'snap_trackError', + params: { error: getJsonError(error) }, + }); + } catch (trackingError) { + this.#logger.error('Failed to track error', trackingError); + } } - async startTrace(name: string): Promise { - await snap.request({ - method: 'snap_startTrace', - params: { - name, - }, - }); + async startTrace(name: string): Promise { + try { + await snap.request({ + method: 'snap_startTrace', + params: { + name, + }, + }); + return true; + } catch (error) { + this.#logger.error(`Failed to start trace`, error); + return false; + } } async endTrace(name: string): Promise { - await snap.request({ - method: 'snap_endTrace', - params: { - name, - }, - }); + try { + await snap.request({ + method: 'snap_endTrace', + params: { + name, + }, + }); + } catch (error) { + this.#logger.error(`Failed to end trace`, error); + } } } diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx index 0498115c..8d7d7db1 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.test.tsx @@ -203,12 +203,16 @@ describe('JSXConfirmationRepository', () => { }); it('defaults isMine to false when the recipient address fails to parse', async () => { + const addressError = new Error('Invalid address'); MockedBdkAddress.from_string.mockImplementation(() => { - throw new Error('Invalid address'); + throw addressError; }); await repo.insertSendTransfer(mockAccount, mockPsbt, recipient, origin); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith( + addressError, + ); expect(mockSnapClient.createInterface).toHaveBeenCalledWith( undefined, expect.objectContaining({ isMine: false }), @@ -257,10 +261,12 @@ describe('JSXConfirmationRepository', () => { }); it('sets exchangeRate to undefined when rates client throws', async () => { - mockRatesClient.spotPrices.mockRejectedValue(new Error('API error')); + const ratesError = new Error('API error'); + mockRatesClient.spotPrices.mockRejectedValue(ratesError); await repo.insertSendTransfer(mockAccount, mockPsbt, recipient, origin); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(ratesError); expect(mockSnapClient.createInterface).toHaveBeenCalledWith( undefined, expect.objectContaining({ exchangeRate: undefined }), @@ -428,10 +434,11 @@ describe('JSXConfirmationRepository', () => { }); it('handles PSBT fee_amount throwing an error gracefully', async () => { + const feeError = new Error('Missing TxOut data'); const psbtFeeError = mock({ toString: () => 'psbt-fee-error', fee_amount: () => { - throw new Error('Missing TxOut data'); + throw feeError; }, unsigned_tx: mock({ output: [], @@ -440,6 +447,7 @@ describe('JSXConfirmationRepository', () => { }); await repo.insertSignPsbt(mockAccount, psbtFeeError, origin, options); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(feeError); expect(mockSnapClient.createInterface).toHaveBeenCalledWith( undefined, expect.objectContaining({ fee: undefined }), @@ -476,10 +484,12 @@ describe('JSXConfirmationRepository', () => { }); it('sets exchangeRate to undefined when rates client throws', async () => { - mockRatesClient.spotPrices.mockRejectedValue(new Error('API error')); + const ratesError = new Error('API error'); + mockRatesClient.spotPrices.mockRejectedValue(ratesError); await repo.insertSignPsbt(mockAccount, mockSignPsbt, origin, options); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(ratesError); expect(mockSnapClient.createInterface).toHaveBeenCalledWith( undefined, expect.objectContaining({ exchangeRate: undefined }), @@ -487,12 +497,16 @@ describe('JSXConfirmationRepository', () => { }); it('sets address to undefined when BdkAddress.from_script throws', async () => { + const scriptError = new Error('Unrecognized script'); MockedBdkAddress.from_script.mockImplementation(() => { - throw new Error('Unrecognized script'); + throw scriptError; }); await repo.insertSignPsbt(mockAccount, mockSignPsbt, origin, options); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith( + scriptError, + ); expect(mockSnapClient.createInterface).toHaveBeenCalledWith( undefined, expect.objectContaining({ diff --git a/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx index 291440c3..35cda5c2 100644 --- a/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx +++ b/merged-packages/bitcoin-wallet-snap/src/store/JSXConfirmationRepository.tsx @@ -95,7 +95,8 @@ export class JSXConfirmationRepository implements ConfirmationRepository { account.network, ).script_pubkey; isMine = account.isMine(recipientScript); - } catch { + } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); isMine = false; } @@ -141,7 +142,8 @@ export class JSXConfirmationRepository implements ConfirmationRepository { if (feeAmount) { fee = feeAmount.to_sat().toString(); } - } catch { + } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); fee = undefined; } @@ -157,7 +159,8 @@ export class JSXConfirmationRepository implements ConfirmationRepository { txout.script_pubkey, account.network, ).toString(); - } catch { + } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); address = undefined; } } @@ -220,6 +223,8 @@ export class JSXConfirmationRepository implements ConfirmationRepository { currency: currency.toUpperCase(), }; } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); + // Exchange rates are optional display information - don't fail if unavailable this.#logger.warn( `Failed to fetch spot price for currency ${currency}`, diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts index 6ac4a570..293c74af 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.test.ts @@ -809,7 +809,12 @@ describe('AccountUseCases', () => { .mockReturnValueOnce([]) .mockReturnValueOnce([mockTransaction]); mockMetaProtocols.fetchInscriptions.mockResolvedValue(mockInscriptions); - mockSnapClient.emitTrackingEvent.mockRejectedValue(trackingError); + mockSnapClient.emitTrackingEvent.mockImplementation(async () => { + mockLogger.error( + `Failed to track event: ${TrackingSnapEvent.TransactionReceived}`, + trackingError, + ); + }); const result = await useCases.synchronize(mockAccount, 'test'); @@ -834,7 +839,7 @@ describe('AccountUseCases', () => { // error should be logged expect(mockLogger.error).toHaveBeenCalledWith( - 'Failed to execute snap action: emitTrackingEvent:TransactionReceived', + 'Failed to track event: Transaction Received', trackingError, ); @@ -1222,7 +1227,12 @@ describe('AccountUseCases', () => { const trackingError = new Error('Tracking service unavailable'); mockAccount.getTransaction.mockReturnValue(mockWalletTx); mockTransaction.compute_txid.mockReturnValue(mockTxid); - mockSnapClient.emitTrackingEvent.mockRejectedValue(trackingError); + mockSnapClient.emitTrackingEvent.mockImplementation(async () => { + mockLogger.error( + `Failed to track event: ${TrackingSnapEvent.TransactionSubmitted}`, + trackingError, + ); + }); // Should complete successfully despite tracking failure const { txid, psbt } = await useCases.signPsbt( @@ -1260,7 +1270,7 @@ describe('AccountUseCases', () => { // Error should be logged expect(mockLogger.error).toHaveBeenCalledWith( - 'Failed to execute snap action: emitTrackingEvent:TransactionSubmitted', + 'Failed to track event: Transaction Submitted', trackingError, ); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts index a491f1c2..ae31ecd8 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AccountUseCases.ts @@ -34,7 +34,6 @@ import { WalletError, } from '../entities'; import { CronMethod } from '../handlers/CronHandler'; -import { runSnapActionSafely } from '../utils/snapHelpers'; export type DiscoverAccountParams = { network: Network; @@ -438,16 +437,11 @@ export class AccountUseCases { if (!prevTx) { txsToNotify.push(tx); - await runSnapActionSafely( - async () => - this.#snapClient.emitTrackingEvent( - TrackingSnapEvent.TransactionReceived, - account, - tx, - origin, - ), - this.#logger, - 'emitTrackingEvent:TransactionReceived', + await this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionReceived, + account, + tx, + origin, ); continue; @@ -463,32 +457,22 @@ export class AccountUseCases { if (tx.chain_position.is_confirmed) { txsToNotify.push(tx); - await runSnapActionSafely( - async () => - this.#snapClient.emitTrackingEvent( - TrackingSnapEvent.TransactionFinalized, - account, - tx, - origin, - ), - this.#logger, - 'emitTrackingEvent:TransactionFinalized', + await this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionFinalized, + account, + tx, + origin, ); } else { // if the status was changed, and now it's NOT confirmed // it means the tx was reorged. txsToNotify.push(tx); - await runSnapActionSafely( - async () => - this.#snapClient.emitTrackingEvent( - TrackingSnapEvent.TransactionReorged, - account, - tx, - origin, - ), - this.#logger, - 'emitTrackingEvent:TransactionReorged', + await this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionReorged, + account, + tx, + origin, ); } } @@ -869,16 +853,11 @@ export class AccountUseCases { walletTx, ]); - await runSnapActionSafely( - async () => - this.#snapClient.emitTrackingEvent( - TrackingSnapEvent.TransactionSubmitted, - account, - walletTx, - origin, - ), - this.#logger, - 'emitTrackingEvent:TransactionSubmitted', + await this.#snapClient.emitTrackingEvent( + TrackingSnapEvent.TransactionSubmitted, + account, + walletTx, + origin, ); } diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts index 8a8bc6e2..7b5a4e85 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.test.ts @@ -1,7 +1,12 @@ import type { HistoricalPriceValue } from '@metamask/snaps-sdk'; import { mock } from 'jest-mock-extended'; -import type { AssetRatesClient, Logger, SpotPrice } from '../entities'; +import type { + AssetRatesClient, + Logger, + SnapClient, + SpotPrice, +} from '../entities'; import { AssetsUseCases } from './AssetsUseCases'; import { Caip19Asset } from '../handlers/caip'; import type { ICache, Serializable } from '../store/ICache'; @@ -10,8 +15,14 @@ describe('AssetsUseCases', () => { const mockLogger = mock(); const mockAssetRates = mock(); const mockCache = mock>(); + const mockSnapClient = mock(); - const useCases = new AssetsUseCases(mockLogger, mockAssetRates, mockCache); + const useCases = new AssetsUseCases( + mockLogger, + mockAssetRates, + mockCache, + mockSnapClient, + ); describe('getBtcRates', () => { it('returns rate for the known assets and null for unknown', async () => { @@ -65,6 +76,8 @@ describe('AssetsUseCases', () => { const result = await useCases.getRates([Caip19Asset.Testnet]); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledTimes(1); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); expect(mockLogger.warn).toHaveBeenCalledWith( 'Failed to fetch spot price for ticker btc', error, @@ -168,6 +181,8 @@ describe('AssetsUseCases', () => { const result = await useCases.getPriceIntervals('swift:0/iso4217:USD'); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledTimes(6); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); expect(mockLogger.warn).toHaveBeenCalledTimes(6); expect(mockLogger.warn).toHaveBeenCalledWith( 'Failed to fetch historical prices for period P1D', diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts index 1d170587..0c5e12f9 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/AssetsUseCases.ts @@ -7,6 +7,7 @@ import type { AssetRate, AssetRatesClient, Logger, + SnapClient, SpotPrice, TimePeriod, } from '../entities'; @@ -19,14 +20,18 @@ export class AssetsUseCases { readonly #cache: ICache; + readonly #snapClient: SnapClient; + constructor( logger: Logger, assetRates: AssetRatesClient, cache: ICache, + snapClient: SnapClient, ) { this.#logger = logger; this.#assetRates = assetRates; this.#cache = cache; + this.#snapClient = snapClient; } async getRates(assets: CaipAssetType[]): Promise { @@ -74,7 +79,9 @@ export class AssetsUseCases { (asset) => [asset, spotPrices] as AssetRate, ); }) - .catch((error) => { + .catch(async (error) => { + await this.#snapClient.emitTrackingError(error as Error); + this.#logger.warn( `Failed to fetch spot price for ticker ${ticker}`, error, @@ -123,7 +130,9 @@ export class AssetsUseCases { this.#assetRates .historicalPrices(timePeriod, vsCurrency) .then((prices) => ({ timePeriod, prices })) - .catch((error) => { + .catch(async (error) => { + await this.#snapClient.emitTrackingError(error as Error); + this.#logger.warn( `Failed to fetch historical prices for period ${timePeriod}`, error, diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts index 0b2bbf19..b99de0bc 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.test.ts @@ -359,6 +359,7 @@ describe('SendFlowUseCases', () => { mockContext, ); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(buildError); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( 'interface-id', expect.objectContaining({ @@ -810,12 +811,12 @@ describe('SendFlowUseCases', () => { }); it('schedules next event if fetching rates fail', async () => { - mockChain.getFeeEstimates.mockRejectedValueOnce( - new Error('getFeeEstimates'), - ); + const error = new Error('getFeeEstimates'); + mockChain.getFeeEstimates.mockRejectedValueOnce(error); await useCases.refresh('interface-id'); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalled(); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( 'interface-id', @@ -884,6 +885,7 @@ describe('SendFlowUseCases', () => { await useCases.refresh('interface-id'); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); expect(mockSnapClient.scheduleBackgroundEvent).toHaveBeenCalled(); expect(mockSendFlowRepository.updateForm).toHaveBeenCalledWith( 'interface-id', @@ -903,12 +905,12 @@ describe('SendFlowUseCases', () => { }); it('returns undefined when context is not found', async () => { - mockSendFlowRepository.getContext.mockRejectedValue( - new Error('Missing context'), - ); + const error = new Error('Missing context'); + mockSendFlowRepository.getContext.mockRejectedValue(error); const result = await useCases.refresh('interface-id'); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); expect(result).toBeUndefined(); }); }); @@ -1006,12 +1008,14 @@ describe('SendFlowUseCases', () => { }); it('defaults isMine to false when the recipient address fails to parse', async () => { + const error = new Error('Invalid address'); (Address.from_string as jest.Mock).mockImplementation(() => { - throw new Error('Invalid address'); + throw error; }); await useCases.confirmSendFlow(mockAccount, amount, toAddress); + expect(mockSnapClient.emitTrackingError).toHaveBeenCalledWith(error); expect(mockSendFlowRepository.insertConfirmSendForm).toHaveBeenCalledWith( expect.objectContaining({ isMine: false }), ); diff --git a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts index f837eac3..b04a1b17 100644 --- a/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts +++ b/merged-packages/bitcoin-wallet-snap/src/use-cases/SendFlowUseCases.ts @@ -119,7 +119,8 @@ export class SendFlowUseCases { account.network, ).script_pubkey; isMine = account.isMine(recipientScript); - } catch { + } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); isMine = false; } @@ -350,6 +351,8 @@ export class SendFlowUseCases { currency: currency.toUpperCase(), }; } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); + // exchange rates are optional display information - don't fail if unavailable this.#logger.warn( `Failed to fetch exchange rate for ${currency}. Error: %s`, @@ -378,6 +381,8 @@ export class SendFlowUseCases { ).toString(); updatedContext = await this.#computeFee(updatedContext); } catch (error) { + // We do not track this error as it is a user input. + this.#logger.error( `Invalid recipient. Error: %s`, (error as CodifiedError).message, @@ -419,6 +424,8 @@ export class SendFlowUseCases { updatedContext.amount = amount.to_sat().toString(); updatedContext = await this.#computeFee(updatedContext); } catch (error) { + // We do not track this error as it is a user input. + this.#logger.error( `Invalid amount. Error: %s`, (error as CodifiedError).message, @@ -475,6 +482,8 @@ export class SendFlowUseCases { return this.#sendFlowRepository.updateReview(id, reviewContext); } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); + this.#logger.error( `Failed to build PSBT on Confirm. Error: %s`, (error as CodifiedError).message, @@ -528,6 +537,8 @@ export class SendFlowUseCases { const context = await this.#sendFlowRepository.getContext(id); return this.#refreshRates(id, context); } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); + // We do not throw as this is probably due to a scheduled event executing after the interface has been removed. this.#logger.debug('Context not found in send flow:', id, error); return undefined; @@ -553,6 +564,8 @@ export class SendFlowUseCases { ); updatedContext = await this.#computeFee(updatedContext); } catch (error) { + await this.#snapClient.emitTrackingError(error as Error); + // We do not throw so we can reschedule. Previous fetched values or fallbacks will be used. this.#logger.warn( `Failed to fetch rates in send form: %s. Error: %s`, @@ -603,6 +616,8 @@ export class SendFlowUseCases { const psbt = builder.addRecipient(amount, recipient).finish(); return { ...context, fee: psbt.fee().to_sat().toString(), balance }; } catch (error) { + // We do not track this error as it is a form validation feedback. + this.#logger.error( `Failed to build PSBT. Error: %s`, (error as CodifiedError).message, diff --git a/merged-packages/bitcoin-wallet-snap/src/utils/snapHelpers.ts b/merged-packages/bitcoin-wallet-snap/src/utils/snapHelpers.ts deleted file mode 100644 index ab27d94d..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/utils/snapHelpers.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Logger } from '../entities'; - -/** - * @param fn - The async function to execute - * @param logger - Logger instance for error reporting - * @param actionName - Name of the action for logging purposes - */ -export const runSnapActionSafely = async ( - fn: () => Promise, - logger: Logger, - actionName: string, -): Promise => { - try { - await fn(); - } catch (error) { - logger.error(`Failed to execute snap action: ${actionName}`, error); - } -}; From d9160a923c70b2e061a83ad912fa1bbaec1604f8 Mon Sep 17 00:00:00 2001 From: Julink Date: Wed, 1 Jul 2026 16:48:07 +0200 Subject: [PATCH 358/362] fix: signAndSendTransaction new expected params struct (#636) * fix: signAndSendTransaction new expected params struct * chore: update changelog --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 1 + .../bitcoin-wallet-snap/snap.manifest.json | 4 ++-- .../bitcoin-wallet-snap/src/handlers/RpcHandler.ts | 11 ++++++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index cb31608a..39b8f268 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Display known non-URL origins in confirmations without throwing on invalid origin values ([#634](https://github.com/MetaMask/snap-bitcoin-wallet/pull/634)) +- Accept the optional `options` object in `signAndSendTransaction` request params to fix broken BTC bridging ([#636](https://github.com/MetaMask/snap-bitcoin-wallet/pull/636)) ## [1.14.1] diff --git a/merged-packages/bitcoin-wallet-snap/snap.manifest.json b/merged-packages/bitcoin-wallet-snap/snap.manifest.json index cf305a1a..8a06e8f0 100644 --- a/merged-packages/bitcoin-wallet-snap/snap.manifest.json +++ b/merged-packages/bitcoin-wallet-snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "1.14.0", + "version": "1.14.1", "description": "Manage Bitcoin using MetaMask", "proposedName": "Bitcoin", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-bitcoin-wallet.git" }, "source": { - "shasum": "MTzKFh6HogVOYFf+j53R/wr/HNjocn0FpRA4E1mX0Yc=", + "shasum": "AJMM/qNI5aAedy2QgWak4Ms+B1e5r0VQBbRoJthPXnw=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts index d0717056..1722459e 100644 --- a/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts +++ b/merged-packages/bitcoin-wallet-snap/src/handlers/RpcHandler.ts @@ -1,7 +1,15 @@ import { BtcScope } from '@metamask/keyring-api'; import type { Json, JsonRpcRequest } from '@metamask/snaps-sdk'; import { Verifier } from 'bip322-js'; -import { assert, enums, object, optional, string } from 'superstruct'; +import { + assert, + enums, + object, + optional, + record, + string, + unknown, +} from 'superstruct'; import { AssertionError, @@ -49,6 +57,7 @@ export const SendPsbtRequest = object({ accountId: string(), transaction: string(), scope: optional(enums(Object.values(BtcScope))), // We don't use the scope but need to define it for validation + options: optional(record(string(), unknown())), // We don't use the options but need to define it for validation }); export const ComputeFeeRequest = object({ From ce41d406f9c1d94ed728fb95fdc9bd9fcadd1b85 Mon Sep 17 00:00:00 2001 From: Julink Date: Wed, 1 Jul 2026 18:20:47 +0200 Subject: [PATCH 359/362] Revert "fix: handle non-url origins (#634)" (#637) This reverts commit 792ae2a0879c687235a66b93b46e1cf832ea4653. --- .../bitcoin-wallet-snap/CHANGELOG.md | 1 - .../src/infra/jsx/format.test.ts | 50 ------------------- .../src/infra/jsx/format.ts | 24 +-------- 3 files changed, 1 insertion(+), 74 deletions(-) delete mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 39b8f268..e1013d3e 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Display known non-URL origins in confirmations without throwing on invalid origin values ([#634](https://github.com/MetaMask/snap-bitcoin-wallet/pull/634)) - Accept the optional `options` object in `signAndSendTransaction` request params to fix broken BTC bridging ([#636](https://github.com/MetaMask/snap-bitcoin-wallet/pull/636)) ## [1.14.1] diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.test.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.test.ts deleted file mode 100644 index a175612f..00000000 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { displayOrigin } from './format'; - -/* eslint-disable @typescript-eslint/naming-convention */ -jest.mock('@metamask/bitcoindevkit', () => ({ - Amount: { - from_sat: jest.fn(), - }, - BdkErrorCode: {}, -})); -/* eslint-enable @typescript-eslint/naming-convention */ - -describe('displayOrigin', () => { - it('returns the known label for the internal "metamask" origin', () => { - expect(displayOrigin('metamask')).toBe('MetaMask'); - }); - - it('returns the known label for the "wallet-connect" origin', () => { - expect(displayOrigin('wallet-connect')).toBe('WalletConnect'); - }); - - it('matches known origins case-insensitively', () => { - expect(displayOrigin('MetaMask')).toBe('MetaMask'); - expect(displayOrigin('Wallet-Connect')).toBe('WalletConnect'); - }); - - it('returns the hostname for a valid https URL', () => { - expect(displayOrigin('https://app.uniswap.org')).toBe('app.uniswap.org'); - }); - - it('returns the hostname for a valid http URL', () => { - expect(displayOrigin('http://localhost:8080')).toBe('localhost'); - }); - - it('returns an empty string for a non-http(s) URL', () => { - expect(displayOrigin('ftp://example.com')).toBe(''); - }); - - it('returns an empty string for a WalletConnect channelId', () => { - expect( - displayOrigin( - 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2', - ), - ).toBe(''); - }); - - it('returns an empty string for an invalid origin', () => { - expect(displayOrigin('not a url')).toBe(''); - expect(displayOrigin('')).toBe(''); - }); -}); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts index 8e54922f..e91e6dfa 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts @@ -68,30 +68,8 @@ export const errorCodeToLabel = (code: number): string => { return raw.charAt(0).toLowerCase() + raw.slice(1); }; -/** - * Known origins mapped to their human-readable labels. Keys are lowercased so - * lookups can be performed case-insensitively against the raw origin. - */ -const KNOWN_ORIGIN_LABELS: Record = { - metamask: 'MetaMask', - 'wallet-connect': 'WalletConnect', -}; - export const displayOrigin = (origin: string): string => { - const knownLabel = KNOWN_ORIGIN_LABELS[origin.toLowerCase()]; - if (knownLabel) { - return knownLabel; - } - - try { - const url = new URL(origin); - return url.protocol === 'http:' || url.protocol === 'https:' - ? url.hostname - : ''; - } catch { - console.log('[format] - displayOrigin - failed to parse origin', origin); - return ''; - } + return new URL(origin).hostname; }; export const displayCaip10 = ( From eefd600e31b417c944f91703411690945150db88 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:48:12 +0000 Subject: [PATCH 360/362] 1.14.2 (#639) * 1.14.2 * chore: remove site release * fix: changelog * Update packages/snap/CHANGELOG.md Co-authored-by: Michele Esposito <34438276+mikesposito@users.noreply.github.com> --------- Co-authored-by: github-actions Co-authored-by: Julien Fontanel Co-authored-by: Michele Esposito <34438276+mikesposito@users.noreply.github.com> --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 5 ++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index e1013d3e..0bd5a4d8 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.14.2] + ### Fixed - Accept the optional `options` object in `signAndSendTransaction` request params to fix broken BTC bridging ([#636](https://github.com/MetaMask/snap-bitcoin-wallet/pull/636)) @@ -631,7 +633,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.14.1...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.14.2...HEAD +[1.14.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.14.1...v1.14.2 [1.14.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.14.0...v1.14.1 [1.14.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.13.0...v1.14.0 [1.13.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.12.0...v1.13.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index 1e8b83d9..f5aaeeac 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.14.1", + "version": "1.14.2", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git", From 7e3789577903204baada9e7aca45c6f42ac29012 Mon Sep 17 00:00:00 2001 From: Julink Date: Wed, 1 Jul 2026 20:07:27 +0200 Subject: [PATCH 361/362] Reapply "fix: handle non-url origins (#634)" (#637) (#640) * Reapply "fix: handle non-url origins (#634)" (#637) This reverts commit ce41d406f9c1d94ed728fb95fdc9bd9fcadd1b85. * fix: changelog * fix: changelog * fix: changelog --- .../bitcoin-wallet-snap/CHANGELOG.md | 4 ++ .../src/infra/jsx/format.test.ts | 50 +++++++++++++++++++ .../src/infra/jsx/format.ts | 24 ++++++++- 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.test.ts diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 0bd5a4d8..42df7625 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Display known non-URL origins in confirmations without throwing on invalid origin values ([#640](https://github.com/MetaMask/snap-bitcoin-wallet/pull/640)) + ## [1.14.2] ### Fixed diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.test.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.test.ts new file mode 100644 index 00000000..a175612f --- /dev/null +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.test.ts @@ -0,0 +1,50 @@ +import { displayOrigin } from './format'; + +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('@metamask/bitcoindevkit', () => ({ + Amount: { + from_sat: jest.fn(), + }, + BdkErrorCode: {}, +})); +/* eslint-enable @typescript-eslint/naming-convention */ + +describe('displayOrigin', () => { + it('returns the known label for the internal "metamask" origin', () => { + expect(displayOrigin('metamask')).toBe('MetaMask'); + }); + + it('returns the known label for the "wallet-connect" origin', () => { + expect(displayOrigin('wallet-connect')).toBe('WalletConnect'); + }); + + it('matches known origins case-insensitively', () => { + expect(displayOrigin('MetaMask')).toBe('MetaMask'); + expect(displayOrigin('Wallet-Connect')).toBe('WalletConnect'); + }); + + it('returns the hostname for a valid https URL', () => { + expect(displayOrigin('https://app.uniswap.org')).toBe('app.uniswap.org'); + }); + + it('returns the hostname for a valid http URL', () => { + expect(displayOrigin('http://localhost:8080')).toBe('localhost'); + }); + + it('returns an empty string for a non-http(s) URL', () => { + expect(displayOrigin('ftp://example.com')).toBe(''); + }); + + it('returns an empty string for a WalletConnect channelId', () => { + expect( + displayOrigin( + 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2', + ), + ).toBe(''); + }); + + it('returns an empty string for an invalid origin', () => { + expect(displayOrigin('not a url')).toBe(''); + expect(displayOrigin('')).toBe(''); + }); +}); diff --git a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts index e91e6dfa..8e54922f 100644 --- a/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts +++ b/merged-packages/bitcoin-wallet-snap/src/infra/jsx/format.ts @@ -68,8 +68,30 @@ export const errorCodeToLabel = (code: number): string => { return raw.charAt(0).toLowerCase() + raw.slice(1); }; +/** + * Known origins mapped to their human-readable labels. Keys are lowercased so + * lookups can be performed case-insensitively against the raw origin. + */ +const KNOWN_ORIGIN_LABELS: Record = { + metamask: 'MetaMask', + 'wallet-connect': 'WalletConnect', +}; + export const displayOrigin = (origin: string): string => { - return new URL(origin).hostname; + const knownLabel = KNOWN_ORIGIN_LABELS[origin.toLowerCase()]; + if (knownLabel) { + return knownLabel; + } + + try { + const url = new URL(origin); + return url.protocol === 'http:' || url.protocol === 'https:' + ? url.hostname + : ''; + } catch { + console.log('[format] - displayOrigin - failed to parse origin', origin); + return ''; + } }; export const displayCaip10 = ( From 983e07947c6c04eb25c9e140956880b072dd5525 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:57:28 +0200 Subject: [PATCH 362/362] 1.15.0 (#644) * 1.15.0 * chore: remove site changelog --------- Co-authored-by: github-actions Co-authored-by: Andrew Taran --- merged-packages/bitcoin-wallet-snap/CHANGELOG.md | 6 +++++- merged-packages/bitcoin-wallet-snap/package.json | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md index 42df7625..75c4ec47 100644 --- a/merged-packages/bitcoin-wallet-snap/CHANGELOG.md +++ b/merged-packages/bitcoin-wallet-snap/CHANGELOG.md @@ -7,8 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.15.0] + ### Fixed +- Handle non-url origins ([#634](https://github.com/MetaMask/snap-bitcoin-wallet/pull/634)) - Display known non-URL origins in confirmations without throwing on invalid origin values ([#640](https://github.com/MetaMask/snap-bitcoin-wallet/pull/640)) ## [1.14.2] @@ -637,7 +640,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add CI for lint and test ([#2](https://github.com/MetaMask/bitcoin/pull/2)) - feat: init commit -[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.14.2...HEAD +[Unreleased]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.15.0...HEAD +[1.15.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.14.2...v1.15.0 [1.14.2]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.14.1...v1.14.2 [1.14.1]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.14.0...v1.14.1 [1.14.0]: https://github.com/MetaMask/snap-bitcoin-wallet/compare/v1.13.0...v1.14.0 diff --git a/merged-packages/bitcoin-wallet-snap/package.json b/merged-packages/bitcoin-wallet-snap/package.json index f5aaeeac..377c3b1b 100644 --- a/merged-packages/bitcoin-wallet-snap/package.json +++ b/merged-packages/bitcoin-wallet-snap/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bitcoin-wallet-snap", - "version": "1.14.2", + "version": "1.15.0", "description": "A Bitcoin wallet Snap.", "repository": { "type": "git",